mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
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:
+11
-31
@@ -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"
|
||||
+12
-35
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user