diff --git a/.github/scripts/macos-signing/notarize_macos_binary_with_rcodesign.sh b/.github/scripts/macos-signing/notarize_macos_binary_with_akv.sh similarity index 58% rename from .github/scripts/macos-signing/notarize_macos_binary_with_rcodesign.sh rename to .github/scripts/macos-signing/notarize_macos_binary_with_akv.sh index 8ebe490d41e0..9e053a97b3ae 100755 --- a/.github/scripts/macos-signing/notarize_macos_binary_with_rcodesign.sh +++ b/.github/scripts/macos-signing/notarize_macos_binary_with_akv.sh @@ -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" diff --git a/.github/scripts/macos-signing/notarize_macos_dmg_with_rcodesign.sh b/.github/scripts/macos-signing/notarize_macos_dmg_with_akv.sh similarity index 52% rename from .github/scripts/macos-signing/notarize_macos_dmg_with_rcodesign.sh rename to .github/scripts/macos-signing/notarize_macos_dmg_with_akv.sh index a1125d436aaa..3433bacc4662 100755 --- a/.github/scripts/macos-signing/notarize_macos_dmg_with_rcodesign.sh +++ b/.github/scripts/macos-signing/notarize_macos_dmg_with_akv.sh @@ -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" diff --git a/.github/scripts/macos-signing/notarize_with_akv.py b/.github/scripts/macos-signing/notarize_with_akv.py new file mode 100644 index 000000000000..ece64dca3f41 --- /dev/null +++ b/.github/scripts/macos-signing/notarize_with_akv.py @@ -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()) diff --git a/.github/scripts/macos-signing/test_notarize_with_akv.py b/.github/scripts/macos-signing/test_notarize_with_akv.py new file mode 100644 index 000000000000..2a5f0b54576f --- /dev/null +++ b/.github/scripts/macos-signing/test_notarize_with_akv.py @@ -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() diff --git a/.github/workflows/repo-checks.yml b/.github/workflows/repo-checks.yml index 97932c4fd4bc..6c82ebcd0c0e 100644 --- a/.github/workflows/repo-checks.yml +++ b/.github/workflows/repo-checks.yml @@ -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: diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 1b423273af47..72b6e238952a 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -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"