Use Azure Key Vault for macOS notarization (#37154)

## Why

Keep the App Store Connect private key in Azure Key Vault instead of exporting it to release runners as a base64-encoded `.p8` secret.

## What changed

- Add a notarization client that creates notary-scoped Apple JWTs with a pinned Azure Key Vault key, submits and streams artifacts to Apple's notarization service, polls for completion, and saves developer diagnostics.
- Update the binary and DMG release wrappers to use the new client while continuing to staple accepted DMGs with `rcodesign`.
- Configure release jobs with the notarization vault key name and version instead of private-key material.

## Testing

- Add unit coverage for key validation, JWT signing, uploads, polling, diagnostic logs, and the binary and DMG wrapper contracts.

GitOrigin-RevId: 8f526c5691d774964fd33f847f8420cac0840b05
This commit is contained in:
Eric Burke
2026-08-05 20:31:46 +00:00
committed by copyberry
parent b6c3b51533
commit 0c07c7ee47
6 changed files with 697 additions and 75 deletions
@@ -1,19 +1,17 @@
#!/usr/bin/env bash
# Submits a signed standalone macOS binary to Apple notarization through
# rcodesign. Standalone binaries cannot carry a stapled ticket, so the binary
# is submitted in a ZIP and the successful notarization log is retained.
# Notarize a standalone binary and retain its diagnostic report.
set -euo pipefail
usage() {
cat >&2 <<'EOF'
Usage: notarize_macos_binary_with_rcodesign.sh --binary PATH [--report-dir PATH] [--max-wait-seconds SECONDS]
Usage: notarize_macos_binary_with_akv.sh --binary PATH [--report-dir PATH] [--max-wait-seconds SECONDS]
Options:
--binary PATH Signed standalone macOS binary to notarize.
--report-dir PATH Directory for notarization logs.
--max-wait-seconds SECONDS Maximum rcodesign notarization wait time.
--max-wait-seconds SECONDS Maximum Apple notarization wait time.
EOF
}
@@ -63,7 +61,7 @@ if [[ ! "$max_wait_seconds" =~ ^[0-9]+$ ]]; then
exit 2
fi
for command_name in rcodesign zip; do
for command_name in python3 zip; do
if ! command -v "$command_name" >/dev/null 2>&1; then
echo "$command_name was not found on PATH." >&2
exit 1
@@ -73,11 +71,11 @@ done
missing_environment=0
for variable_name in \
APPLE_NOTARIZATION_ISSUER_ID \
APPLE_NOTARIZATION_KEY_ID \
APPLE_NOTARIZATION_KEY_P8
APPLE_NOTARIZATION_AKV_KEY_NAME \
AZURE_KEYVAULT_NAME
do
if [[ -z "${!variable_name:-}" ]]; then
echo "$variable_name must be set from CI secrets before notarizing a binary." >&2
echo "$variable_name must be configured before notarizing a binary." >&2
missing_environment=1
fi
done
@@ -91,23 +89,6 @@ mkdir -p "$report_dir"
notarization_temp_dir="$(mktemp -d)"
trap 'rm -rf "$notarization_temp_dir" >/dev/null' EXIT
private_key_path="$notarization_temp_dir/AuthKey_${APPLE_NOTARIZATION_KEY_ID}.p8"
if ! printf '%s' "$APPLE_NOTARIZATION_KEY_P8" | base64 --decode >"$private_key_path" 2>/dev/null; then
if ! printf '%s' "$APPLE_NOTARIZATION_KEY_P8" | base64 -D >"$private_key_path" 2>/dev/null; then
echo "APPLE_NOTARIZATION_KEY_P8 must be a base64-encoded .p8 private key." >&2
exit 2
fi
fi
chmod 600 "$private_key_path"
api_key_path="$notarization_temp_dir/app-store-connect-api-key.json"
rcodesign encode-app-store-connect-api-key \
--output-path "$api_key_path" \
"$APPLE_NOTARIZATION_ISSUER_ID" \
"$APPLE_NOTARIZATION_KEY_ID" \
"$private_key_path" \
>"$report_dir/encode-app-store-connect-api-key.log" 2>&1
binary_name="$(basename "$binary_path")"
archive_path="$notarization_temp_dir/${binary_name}.zip"
(
@@ -116,16 +97,15 @@ archive_path="$notarization_temp_dir/${binary_name}.zip"
)
notarization_log="$report_dir/${binary_name}-notarization.log"
rcodesign notarize \
--api-key-file "$api_key_path" \
python3 "$(dirname "$0")/notarize_with_akv.py" \
--file "$archive_path" \
--report-log "$report_dir/${binary_name}-notarization-developer-log.json" \
--max-wait-seconds "$max_wait_seconds" \
--wait \
"$archive_path" \
2>&1 | tee "$notarization_log"
{
echo "binary_name=$binary_name"
echo "max_wait_seconds=$max_wait_seconds"
echo "binary_sha256=$(shasum -a 256 "$binary_path" | awk '{ print $1 }')"
echo "rcodesign_notarize=completed"
echo "notarization=completed"
} >"$report_dir/${binary_name}-notarization-summary.txt"
@@ -1,21 +1,17 @@
#!/usr/bin/env bash
# Notarizes and staples a signed macOS DMG through rcodesign.
#
# This is the Linux-compatible notarization path for the AKV/PKCS#11 signing
# flow. It records notarization inputs and logs so workflow artifacts can be
# audited without exposing the App Store Connect private key.
# Notarize a signed disk image and staple its ticket.
set -euo pipefail
usage() {
cat >&2 <<'EOF'
Usage: notarize_macos_dmg_with_rcodesign.sh --dmg PATH [--report-dir PATH] [--max-wait-seconds SECONDS]
Usage: notarize_macos_dmg_with_akv.sh --dmg PATH [--report-dir PATH] [--max-wait-seconds SECONDS]
Options:
--dmg PATH Signed DMG to submit to Apple notarization.
--report-dir PATH Directory for notarization logs.
--max-wait-seconds SECONDS Maximum rcodesign notarization wait time.
--max-wait-seconds SECONDS Maximum Apple notarization wait time.
EOF
}
@@ -73,11 +69,11 @@ fi
missing_environment=0
for variable_name in \
APPLE_NOTARIZATION_ISSUER_ID \
APPLE_NOTARIZATION_KEY_ID \
APPLE_NOTARIZATION_KEY_P8
APPLE_NOTARIZATION_AKV_KEY_NAME \
AZURE_KEYVAULT_NAME
do
if [[ -z "${!variable_name:-}" ]]; then
echo "$variable_name must be set from CI secrets before notarizing a DMG." >&2
echo "$variable_name must be configured before notarizing a DMG." >&2
missing_environment=1
fi
done
@@ -88,37 +84,18 @@ fi
mkdir -p "$report_dir"
notarization_temp_dir="$(mktemp -d)"
trap 'rm -rf "$notarization_temp_dir" > /dev/null' EXIT
private_key_path="$notarization_temp_dir/AuthKey_${APPLE_NOTARIZATION_KEY_ID}.p8"
if ! printf '%s' "$APPLE_NOTARIZATION_KEY_P8" | base64 --decode > "$private_key_path" 2> /dev/null; then
if ! printf '%s' "$APPLE_NOTARIZATION_KEY_P8" | base64 -D > "$private_key_path" 2> /dev/null; then
echo "APPLE_NOTARIZATION_KEY_P8 must be a base64-encoded .p8 private key." >&2
exit 2
fi
fi
chmod 600 "$private_key_path"
api_key_path="$notarization_temp_dir/app-store-connect-api-key.json"
rcodesign encode-app-store-connect-api-key \
--output-path "$api_key_path" \
"$APPLE_NOTARIZATION_ISSUER_ID" \
"$APPLE_NOTARIZATION_KEY_ID" \
"$private_key_path" \
> "$report_dir/encode-app-store-connect-api-key.log" 2>&1
notarization_log="$report_dir/dmg-notarization.log"
rcodesign notarize \
--api-key-file "$api_key_path" \
python3 "$(dirname "$0")/notarize_with_akv.py" \
--file "$dmg_path" \
--report-log "$report_dir/dmg-notarization-developer-log.json" \
--max-wait-seconds "$max_wait_seconds" \
--staple \
"$dmg_path" \
2>&1 | tee "$notarization_log"
rcodesign staple "$dmg_path" 2>&1 | tee -a "$notarization_log"
{
echo "dmg_path=$dmg_path"
echo "max_wait_seconds=$max_wait_seconds"
echo "dmg_sha256=$(shasum -a 256 "$dmg_path" | awk '{ print $1 }')"
echo "rcodesign_notarize_staple=completed"
echo "notarization_staple=completed"
} > "$report_dir/dmg-notarization-summary.txt"
@@ -0,0 +1,460 @@
#!/usr/bin/env python3
"""Submit release artifacts for notarization and wait for the result."""
import argparse
import base64
import datetime
import hashlib
import hmac
import json
import os
import re
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Any
APPLE_NOTARY_URL = "https://appstoreconnect.apple.com/notary/v2/submissions"
AZURE_KEYVAULT_API_VERSION = "7.4"
AWS_REGION = "us-west-2"
JWT_LIFETIME_SECONDS = 15 * 60
POLL_INTERVAL_SECONDS = 10
MAX_SINGLE_UPLOAD_BYTES = 5 * 1024 * 1024 * 1024
class NotarizationError(RuntimeError):
"""Describe a notarization failure."""
@dataclass(frozen=True)
class NotarizationConfiguration:
"""Hold the account and signing-key configuration."""
issuer_id: str
apple_key_id: str
vault_name: str
vault_key_name: str
vault_key_version: str
@classmethod
def from_environment(cls) -> "NotarizationConfiguration":
"""Load and validate the notarization configuration."""
required = {
"issuer_id": "APPLE_NOTARIZATION_ISSUER_ID",
"vault_name": "AZURE_KEYVAULT_NAME",
"vault_key_name": "APPLE_NOTARIZATION_AKV_KEY_NAME",
}
values = {field: os.environ.get(name, "").strip() for field, name in required.items()}
missing = [name for field, name in required.items() if not values[field]]
if missing:
raise NotarizationError("Missing notarization configuration: " + ", ".join(missing))
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9-]{2,23}", values["vault_name"]):
raise NotarizationError("Invalid signing vault name")
if not re.fullmatch(r"[A-Za-z0-9-]+", values["vault_key_name"]):
raise NotarizationError("Invalid notarization key name")
key_version = os.environ.get("APPLE_NOTARIZATION_AKV_KEY_VERSION", "").strip()
if key_version and not re.fullmatch(r"[0-9a-fA-F]{32}", key_version):
raise NotarizationError(
"Notarization key must resolve to a 32-character pinned version"
)
command = [
"az",
"keyvault",
"key",
"show",
"--vault-name",
values["vault_name"],
"--name",
values["vault_key_name"],
]
if key_version:
command.extend(["--version", key_version])
command.extend(
[
"--query",
'{id:key.kid,apple_key_id:tags."apple-key-id"}',
"--output",
"json",
"--only-show-errors",
]
)
# Resolve the key identifier and version from the same lookup.
try:
result = subprocess.run(command, check=True, capture_output=True, text=True)
except (OSError, subprocess.CalledProcessError) as error:
raise NotarizationError("Notarization signing key could not be read") from error
try:
metadata = json.loads(result.stdout)
returned_key_id = metadata["id"]
apple_key_id = metadata["apple_key_id"]
except (json.JSONDecodeError, KeyError, TypeError) as error:
raise NotarizationError("Notarization key metadata is invalid") from error
key_id_prefix = (
f"https://{values['vault_name']}.vault.azure.net/keys/{values['vault_key_name']}/"
)
if not isinstance(returned_key_id, str) or not returned_key_id.startswith(key_id_prefix):
raise NotarizationError("Unexpected notarization signing key")
returned_key_version = returned_key_id[len(key_id_prefix) :]
if not re.fullmatch(r"[0-9a-fA-F]{32}", returned_key_version):
raise NotarizationError(
"Notarization key must resolve to a 32-character pinned version"
)
if key_version and returned_key_version.lower() != key_version.lower():
raise NotarizationError("Unexpected notarization key version")
if not isinstance(apple_key_id, str) or not apple_key_id.strip():
raise NotarizationError("Notarization key must have an apple-key-id tag")
values["apple_key_id"] = apple_key_id.strip()
values["vault_key_version"] = returned_key_version
return cls(**values)
@property
def versioned_key_id(self) -> str:
"""Return the versioned signing-key identifier."""
return (
f"https://{self.vault_name}.vault.azure.net/keys/"
f"{self.vault_key_name}/{self.vault_key_version}"
)
def base64url_encode(value: bytes) -> str:
"""Encode bytes using the unpadded base64url format required by JWTs."""
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
def base64url_decode(value: str) -> bytes:
"""Decode a base64url value and reject malformed input."""
try:
return base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True)
except (ValueError, UnicodeEncodeError) as error:
raise NotarizationError("Signing service returned an invalid signature") from error
def create_apple_jwt(configuration: NotarizationConfiguration, *, issued_at: int) -> str:
"""Create an authentication token for notarization requests."""
header = {"alg": "ES256", "kid": configuration.apple_key_id, "typ": "JWT"}
# Restrict the token to notarization requests.
claims = {
"iss": configuration.issuer_id,
"iat": issued_at,
"exp": issued_at + JWT_LIFETIME_SECONDS,
"aud": "appstoreconnect-v1",
"scope": ["/notary/v2"],
}
encoded_header = base64url_encode(json.dumps(header, separators=(",", ":")).encode("utf-8"))
encoded_claims = base64url_encode(json.dumps(claims, separators=(",", ":")).encode("utf-8"))
signing_input = f"{encoded_header}.{encoded_claims}"
digest = hashlib.sha256(signing_input.encode("ascii")).digest()
key_url = configuration.versioned_key_id
request_body = json.dumps({"alg": "ES256", "value": base64url_encode(digest)})
try:
result = subprocess.run(
[
"az",
"rest",
"--method",
"post",
"--url",
f"{key_url}/sign?api-version={AZURE_KEYVAULT_API_VERSION}",
"--resource",
"https://vault.azure.net",
"--headers",
"Content-Type=application/json",
"--body",
request_body,
"--output",
"json",
"--only-show-errors",
],
check=True,
capture_output=True,
text=True,
)
except (OSError, subprocess.CalledProcessError) as error:
detail = getattr(error, "stderr", "").strip()
message = "Signing service could not sign the authentication token"
if detail:
message = f"{message}: {detail}"
raise NotarizationError(message) from error
try:
response = json.loads(result.stdout)
returned_key_id = response["kid"]
encoded_signature = response["value"]
except (json.JSONDecodeError, KeyError, TypeError) as error:
raise NotarizationError("Signing service returned an invalid response") from error
# Reject signatures generated with a different key version.
if returned_key_id != key_url:
raise NotarizationError("Signing service returned an unexpected key version")
if not isinstance(encoded_signature, str):
raise NotarizationError("Signing service returned an invalid signature")
signature = base64url_decode(encoded_signature)
# ES256 JWT signatures are 64 raw R || S bytes, not ASN.1/DER-encoded ECDSA signatures.
if len(signature) != 64:
raise NotarizationError("ES256 signature must contain 64 JOSE R || S bytes")
return f"{signing_input}.{base64url_encode(signature)}"
def json_request(url: str, token: str, *, body: dict[str, Any] | None = None) -> dict[str, Any]:
"""Send an authenticated JSON request to Apple's notarization service."""
data = None if body is None else json.dumps(body).encode("utf-8")
request = urllib.request.Request(
url,
data=data,
method="GET" if body is None else "POST",
headers={
"Accept": "application/json",
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
)
try:
with urllib.request.urlopen(request, timeout=60) as response:
result = json.load(response)
except urllib.error.HTTPError as error:
detail = error.read(1024).decode("utf-8", errors="replace")
raise NotarizationError(f"Apple Notary API returned HTTP {error.code}: {detail}") from error
except (OSError, json.JSONDecodeError) as error:
raise NotarizationError("Apple Notary API request failed") from error
if not isinstance(result, dict):
raise NotarizationError("Apple Notary API returned an invalid response")
return result
def sha256_file(path: Path) -> str:
"""Hash an artifact incrementally so large release files stay off the heap."""
digest = hashlib.sha256()
with path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def hmac_sha256(key: bytes, value: str) -> bytes:
"""Derive one AWS Signature Version 4 signing-key component."""
return hmac.new(key, value.encode("utf-8"), hashlib.sha256).digest()
def upload_to_apple_s3(
path: Path,
file_digest: str,
credentials: dict[str, Any],
*,
timestamp: datetime.datetime | None = None,
) -> None:
"""Upload an artifact using the provided temporary credentials."""
required = (
"awsAccessKeyId",
"awsSecretAccessKey",
"awsSessionToken",
"bucket",
"object",
)
if any(
not isinstance(credentials.get(name), str) or not credentials[name] for name in required
):
raise NotarizationError("Apple returned incomplete temporary upload credentials")
size = path.stat().st_size
if not 0 < size <= MAX_SINGLE_UPLOAD_BYTES:
raise NotarizationError("Notarization payload must be between 1 byte and 5 GiB")
now = timestamp or datetime.datetime.now(datetime.timezone.utc)
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
date_stamp = now.strftime("%Y%m%d")
host = f"{credentials['bucket']}.s3.{AWS_REGION}.amazonaws.com"
object_path = "/" + urllib.parse.quote(credentials["object"].lstrip("/"), safe="/-_.~")
headers = {
"content-type": "application/octet-stream",
"host": host,
"x-amz-content-sha256": file_digest,
"x-amz-date": amz_date,
"x-amz-security-token": credentials["awsSessionToken"],
}
# Build the signature required by the upload endpoint.
signed_headers = ";".join(sorted(headers))
canonical_headers = "".join(f"{name}:{headers[name]}\n" for name in sorted(headers))
canonical_request = "\n".join(
["PUT", object_path, "", canonical_headers, signed_headers, file_digest]
)
credential_scope = f"{date_stamp}/{AWS_REGION}/s3/aws4_request"
string_to_sign = "\n".join(
[
"AWS4-HMAC-SHA256",
amz_date,
credential_scope,
hashlib.sha256(canonical_request.encode("utf-8")).hexdigest(),
]
)
signing_key = ("AWS4" + credentials["awsSecretAccessKey"]).encode("utf-8")
for component in (date_stamp, AWS_REGION, "s3", "aws4_request"):
signing_key = hmac_sha256(signing_key, component)
signature = hmac.new(signing_key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
authorization = (
"AWS4-HMAC-SHA256 "
f"Credential={credentials['awsAccessKeyId']}/{credential_scope}, "
f"SignedHeaders={signed_headers}, Signature={signature}"
)
# Stream the artifact to avoid buffering its full contents.
with path.open("rb") as source:
request = urllib.request.Request(
f"https://{host}{object_path}",
data=source,
method="PUT",
headers={
**headers,
"Authorization": authorization,
"Content-Length": str(size),
},
)
try:
with urllib.request.urlopen(request, timeout=300):
pass
except urllib.error.HTTPError as error:
raise NotarizationError(
f"Apple notarization payload upload failed with HTTP {error.code}"
) from error
except OSError as error:
raise NotarizationError("Apple notarization payload upload failed") from error
def write_developer_log(submission_id: str, token: str, destination: Path | None) -> None:
"""Write the submission's optional diagnostic report."""
if destination is None:
return
response = json_request(f"{APPLE_NOTARY_URL}/{submission_id}/logs", token)
try:
log_url = response["data"]["attributes"]["developerLogUrl"]
except (KeyError, TypeError) as error:
raise NotarizationError("Apple did not provide a notarization developer log") from error
if not isinstance(log_url, str) or not log_url.startswith("https://"):
raise NotarizationError("Apple returned an invalid notarization developer log URL")
try:
# Do not forward unrelated authorization headers to the download URL.
with urllib.request.urlopen(log_url, timeout=60) as response:
destination.write_bytes(response.read())
except urllib.error.HTTPError as error:
raise NotarizationError(
f"Apple notarization developer log download failed with HTTP {error.code}"
) from error
except OSError as error:
raise NotarizationError("Apple notarization developer log download failed") from error
def notarize(
path: Path,
configuration: NotarizationConfiguration,
*,
max_wait_seconds: int,
report_log: Path | None,
) -> str:
"""Submit an artifact, upload it to Apple, and wait for a terminal result."""
issued_at = int(time.time())
token = create_apple_jwt(configuration, issued_at=issued_at)
file_digest = sha256_file(path)
# Create the submission before uploading the artifact.
response = json_request(
APPLE_NOTARY_URL,
token,
body={"sha256": file_digest, "submissionName": path.name},
)
try:
submission_id = response["data"]["id"]
upload_credentials = response["data"]["attributes"]
except (KeyError, TypeError) as error:
raise NotarizationError("Apple returned an invalid notarization submission") from error
if not isinstance(submission_id, str) or not submission_id:
raise NotarizationError("Apple did not return a notarization submission ID")
if not isinstance(upload_credentials, dict):
raise NotarizationError("Apple returned invalid notarization upload credentials")
print(f"Uploading notarization submission {submission_id} for {path.name}")
upload_to_apple_s3(path, file_digest, upload_credentials)
# A monotonic deadline keeps the wait limit stable if the system clock changes.
deadline = time.monotonic() + max_wait_seconds
# Wait for completion, equivalent to `notarytool submit --wait`.
while True:
# Refresh the token before it expires.
if int(time.time()) >= issued_at + JWT_LIFETIME_SECONDS - 60:
issued_at = int(time.time())
token = create_apple_jwt(configuration, issued_at=issued_at)
result = json_request(f"{APPLE_NOTARY_URL}/{submission_id}", token)
try:
status = result["data"]["attributes"]["status"]
except (KeyError, TypeError) as error:
raise NotarizationError("Apple returned an invalid notarization status") from error
if status in {"Accepted", "Invalid", "Rejected"}:
# Save diagnostics for both accepted and rejected submissions.
write_developer_log(submission_id, token, report_log)
message = f"Notarization submission {submission_id} completed with status {status}"
if status != "Accepted":
raise NotarizationError(message)
print(message)
return submission_id
if status != "In Progress":
raise NotarizationError(f"Unexpected notarization status: {status!r}")
remaining = deadline - time.monotonic()
if remaining <= 0:
raise NotarizationError(
f"Notarization submission {submission_id} exceeded {max_wait_seconds} seconds"
)
print(f"Notarization submission {submission_id} is still in progress")
time.sleep(min(POLL_INTERVAL_SECONDS, remaining))
def main() -> int:
"""Run notarization using command-line arguments."""
parser = argparse.ArgumentParser()
parser.add_argument("--file", type=Path, required=True)
parser.add_argument("--report-log", type=Path)
parser.add_argument("--max-wait-seconds", type=int, default=600)
arguments = parser.parse_args()
try:
if not arguments.file.is_file():
raise NotarizationError(f"Notarization payload does not exist: {arguments.file}")
if arguments.max_wait_seconds < 0:
raise NotarizationError("--max-wait-seconds must be non-negative")
if arguments.report_log is not None:
arguments.report_log.parent.mkdir(parents=True, exist_ok=True)
notarize(
arguments.file,
NotarizationConfiguration.from_environment(),
max_wait_seconds=arguments.max_wait_seconds,
report_log=arguments.report_log,
)
except NotarizationError as error:
print(f"Notarization failed: {error}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,202 @@
import contextlib
import hashlib
import io
import json
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
SIGNING_DIRECTORY = Path(__file__).resolve().parent
sys.path.insert(0, str(SIGNING_DIRECTORY))
import notarize_with_akv as notary # noqa: E402
CONFIGURATION = notary.NotarizationConfiguration(
"issuer-id",
"APPLEKEY01",
"notary-vault",
"example-signing-key",
"0123456789abcdef" * 2,
)
ENVIRONMENT = {
"APPLE_NOTARIZATION_ISSUER_ID": CONFIGURATION.issuer_id,
"AZURE_KEYVAULT_NAME": CONFIGURATION.vault_name,
"APPLE_NOTARIZATION_AKV_KEY_NAME": CONFIGURATION.vault_key_name,
}
CREDENTIALS = {
"awsAccessKeyId": "temporary-access-key",
"awsSecretAccessKey": "temporary-secret-key",
"awsSessionToken": "temporary-session-token",
"bucket": "apple-notary-bucket",
"object": "uploads/release artifact.dmg",
}
def response(payload):
return io.BytesIO(json.dumps(payload).encode() if isinstance(payload, dict) else payload)
def azure_response(payload):
return subprocess.CompletedProcess([], 0, stdout=json.dumps(payload), stderr="")
class NotarizationTest(unittest.TestCase):
def test_validates_the_apple_key_tag_and_pins_its_version(self) -> None:
metadata = {"id": CONFIGURATION.versioned_key_id, "apple_key_id": "APPLEKEY01"}
with (
patch.dict(os.environ, ENVIRONMENT, clear=True),
patch.object(
notary.subprocess,
"run",
return_value=azure_response(metadata),
) as show,
):
self.assertEqual(
notary.NotarizationConfiguration.from_environment(),
CONFIGURATION,
)
show.return_value = azure_response({**metadata, "apple_key_id": None})
with self.assertRaisesRegex(notary.NotarizationError, "apple-key-id tag"):
notary.NotarizationConfiguration.from_environment()
self.assertIn('{id:key.kid,apple_key_id:tags."apple-key-id"}', show.call_args.args[0])
invalid_environment = {**ENVIRONMENT, "APPLE_NOTARIZATION_AKV_KEY_NAME": "../bad"}
with (
patch.dict(os.environ, invalid_environment, clear=True),
self.assertRaisesRegex(notary.NotarizationError, "key name"),
):
notary.NotarizationConfiguration.from_environment()
def test_signs_the_notary_scoped_digest_with_the_pinned_key(self) -> None:
signature = bytes(range(64))
payload = {
"kid": CONFIGURATION.versioned_key_id,
"value": notary.base64url_encode(signature),
}
with patch.object(notary.subprocess, "run", return_value=azure_response(payload)) as sign:
token = notary.create_apple_jwt(CONFIGURATION, issued_at=1_780_000_000)
header, claims, encoded_signature = token.split(".")
self.assertEqual(json.loads(notary.base64url_decode(header))["kid"], "APPLEKEY01")
self.assertEqual(
json.loads(notary.base64url_decode(claims))["scope"],
["/notary/v2"],
)
self.assertEqual(notary.base64url_decode(encoded_signature), signature)
command = sign.call_args.args[0]
body = json.loads(command[command.index("--body") + 1])
self.assertEqual(body["alg"], "ES256")
self.assertEqual(
notary.base64url_decode(body["value"]),
hashlib.sha256(f"{header}.{claims}".encode()).digest(),
)
def test_rejects_wrong_key_versions_and_invalid_signatures(self) -> None:
cases = (
(CONFIGURATION.versioned_key_id + "wrong", bytes(64), "unexpected key version"),
(CONFIGURATION.versioned_key_id, b"short", "64 JOSE"),
)
for key_id, signature, message in cases:
payload = {"kid": key_id, "value": notary.base64url_encode(signature)}
with (
self.subTest(message=message),
patch.object(
notary.subprocess,
"run",
return_value=azure_response(payload),
),
self.assertRaisesRegex(notary.NotarizationError, message),
):
notary.create_apple_jwt(CONFIGURATION, issued_at=1)
def test_uploads_polls_and_retains_sanitized_apple_diagnostics(self) -> None:
signature = azure_response(
{
"kid": CONFIGURATION.versioned_key_id,
"value": notary.base64url_encode(bytes(64)),
}
)
responses = [
response({"data": {"id": "submission-1", "attributes": CREDENTIALS}}),
response(b""),
response({"data": {"attributes": {"status": "In Progress"}}}),
response({"data": {"attributes": {"status": "Accepted"}}}),
response({"data": {"attributes": {"developerLogUrl": "https://logs.example.com/log"}}}),
response(b'{"status":"Accepted"}'),
]
with tempfile.TemporaryDirectory() as directory:
artifact, log = (
Path(directory) / "codex.zip",
Path(directory) / "notary-log.json",
)
artifact.write_bytes(b"signed release binary")
output = io.StringIO()
with (
patch.object(notary.subprocess, "run", return_value=signature),
patch.object(notary.urllib.request, "urlopen", side_effect=responses) as requests,
patch.object(notary.time, "sleep") as sleep,
contextlib.redirect_stdout(output),
):
submission = notary.notarize(
artifact, CONFIGURATION, max_wait_seconds=600, report_log=log
)
self.assertEqual(submission, "submission-1")
self.assertEqual(json.loads(log.read_text()), {"status": "Accepted"})
sleep.assert_called_once_with(10)
upload = requests.call_args_list[1].args[0]
self.assertEqual(upload.get_method(), "PUT")
self.assertNotIn("temporary-secret-key", upload.get_header("Authorization"))
self.assertNotIn("temporary-secret-key", output.getvalue())
class NotarizationWrapperTest(unittest.TestCase):
def test_binary_and_dmg_wrappers_preserve_notarization_contracts(
self,
) -> None:
with tempfile.TemporaryDirectory() as directory:
root, tools = Path(directory), Path(directory) / "tools"
tools.mkdir()
call_log = root / "calls.txt"
environment = {
**os.environ,
**ENVIRONMENT,
"PATH": f"{tools}:{os.environ['PATH']}",
"RUNNER_TEMP": str(root),
"CALL_LOG": str(call_log),
}
for name in ("az", "python3", "rcodesign"):
body = "exit 0" if name == "az" else f'printf "{name} %s\\n" "$*" >> "$CALL_LOG"'
executable = tools / name
executable.write_text(f"#!/usr/bin/env bash\nset -euo pipefail\n{body}\n")
executable.chmod(0o755)
for kind in ("binary", "dmg"):
with self.subTest(kind=kind):
artifact = root / ("codex.dmg" if kind == "dmg" else "codex")
artifact.write_bytes(b"signed release artifact")
subprocess.run(
[
str(SIGNING_DIRECTORY / f"notarize_macos_{kind}_with_akv.sh"),
f"--{kind}",
str(artifact),
"--report-dir",
str(root / "report"),
],
env=environment,
check=True,
capture_output=True,
text=True,
)
calls = call_log.read_text().splitlines()
self.assertIn("notarize_with_akv.py --file", calls[0])
if kind == "dmg":
self.assertEqual(calls[1], f"rcodesign staple {artifact}")
else:
self.assertEqual(len(calls), 1)
self.assertEqual(list(root.rglob("*.p8")), [])
call_log.unlink()
if __name__ == "__main__":
unittest.main()
+3
View File
@@ -33,6 +33,9 @@ jobs:
- name: Test standalone installer
run: python3 -m unittest discover -s scripts/install -p 'test_*.py'
- name: Test macOS notarization
run: python3 -m unittest discover -s .github/scripts/macos-signing -p 'test_notarize_with_akv.py'
- name: Setup pnpm
uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5
with:
+9 -9
View File
@@ -538,9 +538,9 @@ jobs:
env:
TARGET: ${{ matrix.target }}
BINARIES: ${{ matrix.binaries }}
APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }}
APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }}
APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}
APPLE_NOTARIZATION_AKV_KEY_NAME: ${{ secrets.AKV_NOTARIZATION_KEY_NAME }}
APPLE_NOTARIZATION_AKV_KEY_VERSION: ${{ secrets.AKV_NOTARIZATION_KEY_VERSION }}
run: |
set -euo pipefail
@@ -579,7 +579,7 @@ jobs:
rcodesign print-signature-info "$signed_path" \
>"${report_dir}/${binary}/signature-info.yaml"
.github/scripts/macos-signing/notarize_macos_binary_with_rcodesign.sh \
.github/scripts/macos-signing/notarize_macos_binary_with_akv.sh \
--binary "$signed_path" \
--report-dir "${report_dir}/${binary}"
done
@@ -589,9 +589,9 @@ jobs:
shell: bash
env:
TARGET: ${{ matrix.target }}
APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }}
APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }}
APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}
APPLE_NOTARIZATION_AKV_KEY_NAME: ${{ secrets.AKV_NOTARIZATION_KEY_NAME }}
APPLE_NOTARIZATION_AKV_KEY_VERSION: ${{ secrets.AKV_NOTARIZATION_KEY_VERSION }}
run: |
set -euo pipefail
@@ -636,7 +636,7 @@ jobs:
rcodesign print-signature-info "$binary" \
>"${report_dir}/signature-info.yaml"
.github/scripts/macos-signing/notarize_macos_binary_with_rcodesign.sh \
.github/scripts/macos-signing/notarize_macos_binary_with_akv.sh \
--binary "$binary" \
--report-dir "$report_dir"
done
@@ -923,9 +923,9 @@ jobs:
shell: bash
env:
TARGET: ${{ matrix.target }}
APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }}
APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }}
APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}
APPLE_NOTARIZATION_AKV_KEY_NAME: ${{ secrets.AKV_NOTARIZATION_KEY_NAME }}
APPLE_NOTARIZATION_AKV_KEY_VERSION: ${{ secrets.AKV_NOTARIZATION_KEY_VERSION }}
run: |
set -euo pipefail
@@ -946,7 +946,7 @@ jobs:
rcodesign print-signature-info "$dmg_path" \
>"${report_dir}/signature-info-before-notarization.yaml"
.github/scripts/macos-signing/notarize_macos_dmg_with_rcodesign.sh \
.github/scripts/macos-signing/notarize_macos_dmg_with_akv.sh \
--dmg "$dmg_path" \
--report-dir "$report_dir"