From ac54a05173d090b9fdfa181ea49b92f45aff2681 Mon Sep 17 00:00:00 2001 From: tarunoai Date: Wed, 11 Nov 2026 00:22:21 +0000 Subject: [PATCH] Add a Signer Service backend for macOS notarization (#60695) ## What changed Allow `APPLE_NOTARIZATION_BACKEND=signer-service` to sign Apple notarization JWTs through `grpcurl`, using GitHub OIDC authentication. Validate the returned signing key and algorithm, and redact transport and signing failure details. Keep Azure Key Vault as the default backend and skip its configuration requirements when Signer Service is selected. ## Testing Add coverage for JWT refresh with fresh OIDC tokens and for the binary wrapper running without Azure configuration. All six notarization tests pass. GitOrigin-RevId: e38ea90e94639d87c11ab075c999d85b61b37c17 --- .../notarize_macos_binary_with_akv.sh | 26 ++-- .../macos-signing/notarize_with_akv.py | 120 ++++++++++++++++-- .../macos-signing/test_notarize_with_akv.py | 83 +++++++++++- 3 files changed, 206 insertions(+), 23 deletions(-) diff --git a/.github/scripts/macos-signing/notarize_macos_binary_with_akv.sh b/.github/scripts/macos-signing/notarize_macos_binary_with_akv.sh index 90268aa8bf..3f739142f2 100755 --- a/.github/scripts/macos-signing/notarize_macos_binary_with_akv.sh +++ b/.github/scripts/macos-signing/notarize_macos_binary_with_akv.sh @@ -68,19 +68,21 @@ for command_name in python3 zip; do fi done -missing_environment=0 -for variable_name in \ - APPLE_NOTARIZATION_AKV_KEY_NAME \ - AZURE_KEYVAULT_NAME -do - if [[ -z "${!variable_name:-}" ]]; then - echo "$variable_name must be configured before notarizing a binary." >&2 - missing_environment=1 - fi -done +if [[ "${APPLE_NOTARIZATION_BACKEND:-akv}" != signer-service ]]; then + missing_environment=0 + for variable_name in \ + APPLE_NOTARIZATION_AKV_KEY_NAME \ + AZURE_KEYVAULT_NAME + do + if [[ -z "${!variable_name:-}" ]]; then + echo "$variable_name must be configured before notarizing a binary." >&2 + missing_environment=1 + fi + done -if [[ "$missing_environment" -ne 0 ]]; then - exit 2 + if [[ "$missing_environment" -ne 0 ]]; then + exit 2 + fi fi mkdir -p "$report_dir" diff --git a/.github/scripts/macos-signing/notarize_with_akv.py b/.github/scripts/macos-signing/notarize_with_akv.py index 1929482cd4..c6393b4b41 100644 --- a/.github/scripts/macos-signing/notarize_with_akv.py +++ b/.github/scripts/macos-signing/notarize_with_akv.py @@ -15,6 +15,7 @@ import time import urllib.error import urllib.parse import urllib.request +import uuid from dataclasses import dataclass from pathlib import Path from typing import Any @@ -165,14 +166,22 @@ def base64url_decode(value: str) -> bytes: def create_apple_jwt( - configuration: NotarizationConfiguration, *, issued_at: int + configuration: NotarizationConfiguration | None, *, issued_at: int ) -> str: - """Create an authentication token for notarization requests.""" + """Create Apple's JWT; a None configuration selects Signer Service.""" - header = {"alg": "ES256", "kid": configuration.apple_key_id, "typ": "JWT"} + header = { + "alg": "ES256", + "kid": configuration.apple_key_id + if configuration + else os.environ["APPLE_NOTARIZATION_KEY_ID"], + "typ": "JWT", + } # Restrict the token to notarization requests. claims = { - "iss": configuration.issuer_id, + "iss": configuration.issuer_id + if configuration + else os.environ["APPLE_NOTARIZATION_ISSUER_ID"], "iat": issued_at, "exp": issued_at + JWT_LIFETIME_SECONDS, "aud": "appstoreconnect-v1", @@ -186,6 +195,9 @@ def create_apple_jwt( ) signing_input = f"{encoded_header}.{encoded_claims}" digest = hashlib.sha256(signing_input.encode("ascii")).digest() + if configuration is None: + signature = sign_digest_with_signer_service(digest) + return f"{signing_input}.{base64url_encode(signature)}" key_url = configuration.versioned_key_id request_body = json.dumps({"alg": "ES256", "value": base64url_encode(digest)}) @@ -240,6 +252,87 @@ def create_apple_jwt( return f"{signing_input}.{base64url_encode(signature)}" +def sign_digest_with_signer_service(digest: bytes) -> bytes: + """Sign the Apple JWT digest with the configured Signer Service key.""" + + endpoint = os.environ["APPLE_NOTARIZATION_SIGNER_ENDPOINT"] + url = urllib.parse.urlsplit(os.environ["ACTIONS_ID_TOKEN_REQUEST_URL"]) + query = dict(urllib.parse.parse_qsl(url.query), audience=endpoint) + request = urllib.request.Request( + urllib.parse.urlunsplit(url._replace(query=urllib.parse.urlencode(query))), + headers={ + "Authorization": "Bearer " + os.environ["ACTIONS_ID_TOKEN_REQUEST_TOKEN"] + }, + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + token = json.load(response)["value"] + except urllib.error.HTTPError as error: + raise NotarizationError( + f"GitHub OIDC request returned HTTP {error.code}" + ) from None + except OSError: + raise NotarizationError( + "GitHub OIDC request failed; transport details redacted" + ) from None + except (ValueError, KeyError, TypeError): + raise NotarizationError( + "GitHub OIDC returned an invalid token response" + ) from None + proto = Path(os.environ["APPLE_NOTARIZATION_SIGNER_PROTO"]) + try: + result = subprocess.run( + [ + "grpcurl", + "-import-path", + str(proto.parent), + "-proto", + proto.name, + "-max-time", + "30", + "-expand-headers", + "-rpc-header", + "authorization: Bearer ${NOTARIZATION_OIDC_TOKEN}", + "-rpc-header", + f"x-openai-route-key: {uuid.uuid4()}", + "-d", + "@", + urllib.parse.urlsplit(endpoint).netloc + ":443", + "oaiproto.security.signer.v1.SignerService/Sign", + ], + input=json.dumps( + { + "purpose": os.environ["APPLE_NOTARIZATION_SIGNER_PURPOSE"], + "digest": base64.b64encode(digest).decode(), + } + ), + env={**os.environ, "NOTARIZATION_OIDC_TOKEN": token}, + check=True, + capture_output=True, + text=True, + ) + except (OSError, subprocess.CalledProcessError): + # grpcurl errors may contain authorization headers or response bodies. + raise NotarizationError( + "Signer Service signing request failed; details redacted" + ) from None + try: + result = json.loads(result.stdout) + key_id, algorithm = result["keyId"], result["algorithm"] + signature = base64.b64decode(result["signature"], validate=True) + except (ValueError, KeyError, TypeError): + raise NotarizationError( + "Signer Service returned an invalid signing response" + ) from None + if key_id != os.environ["APPLE_NOTARIZATION_SIGNER_KEY_ID"]: + raise NotarizationError("Signer Service returned an unexpected signing key") + if algorithm != "SIGNING_ALGORITHM_ECDSA_P256_SHA256": + raise NotarizationError( + "Signer Service returned an unexpected signing algorithm" + ) + return signature + + def json_request( url: str, token: str, *, body: dict[str, Any] | None = None ) -> dict[str, Any]: @@ -416,12 +509,12 @@ def write_developer_log( def notarize( path: Path, - configuration: NotarizationConfiguration, + configuration: NotarizationConfiguration | None, *, max_wait_seconds: int, report_log: Path | None, ) -> str: - """Submit an artifact, upload it to Apple, and wait for a terminal result.""" + """Submit an artifact and await Apple's result; None selects Signer Service.""" issued_at = int(time.time()) token = create_apple_jwt(configuration, issued_at=issued_at) @@ -503,13 +596,24 @@ def main() -> int: 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) + backend = os.environ.get("APPLE_NOTARIZATION_BACKEND", "akv") + if backend not in {"akv", "signer-service"}: + raise NotarizationError( + "APPLE_NOTARIZATION_BACKEND must be akv or signer-service" + ) + configuration = ( + NotarizationConfiguration.from_environment() if backend == "akv" else None + ) notarize( arguments.file, - NotarizationConfiguration.from_environment(), + configuration, max_wait_seconds=arguments.max_wait_seconds, report_log=arguments.report_log, ) - except NotarizationError as error: + except KeyError as error: + print(f"Missing notarization configuration: {error.args[0]}", file=sys.stderr) + return 1 + except (NotarizationError, OSError) as error: print(f"Notarization failed: {error}", file=sys.stderr) return 1 return 0 diff --git a/.github/scripts/macos-signing/test_notarize_with_akv.py b/.github/scripts/macos-signing/test_notarize_with_akv.py index 7c3aa50534..1b56205b42 100644 --- a/.github/scripts/macos-signing/test_notarize_with_akv.py +++ b/.github/scripts/macos-signing/test_notarize_with_akv.py @@ -1,3 +1,4 @@ +import base64 import contextlib import hashlib import io @@ -148,6 +149,73 @@ class NotarizationTest(unittest.TestCase): ): notary.create_apple_jwt(CONFIGURATION, issued_at=1) + def test_signer_service_refreshes_tokens_without_azure(self): + environment = { + "APPLE_NOTARIZATION_BACKEND": "signer-service", + "APPLE_NOTARIZATION_ISSUER_ID": CONFIGURATION.issuer_id, + "APPLE_NOTARIZATION_KEY_ID": CONFIGURATION.apple_key_id, + "APPLE_NOTARIZATION_SIGNER_ENDPOINT": "https://signer.example.com", + "APPLE_NOTARIZATION_SIGNER_PURPOSE": "test-notarization", + "APPLE_NOTARIZATION_SIGNER_KEY_ID": "expected-key", + "APPLE_NOTARIZATION_SIGNER_PROTO": "/tmp/notarization_signer.proto", + "ACTIONS_ID_TOKEN_REQUEST_URL": "https://pipelines.actions.githubusercontent.com/token", + "ACTIONS_ID_TOKEN_REQUEST_TOKEN": "request-secret", + } + with ( + tempfile.NamedTemporaryFile() as artifact, + patch.dict(os.environ, environment, clear=True), + patch.object( + sys, "argv", ["notarize_with_akv.py", "--file", artifact.name] + ), + patch.object(notary.time, "time", side_effect=[1, 900, 900]), + patch.object( + notary, + "json_request", + side_effect=[ + {"data": {"id": "submission-1", "attributes": CREDENTIALS}}, + {"data": {"attributes": {"status": "Accepted"}}}, + ], + ) as request, + patch.object(notary, "upload_to_apple_s3"), + patch.object( + notary.urllib.request, + "urlopen", + side_effect=[ + response({"value": "first-oidc"}), + response({"value": "fresh-oidc"}), + ], + ), + patch.object( + notary.subprocess, + "run", + return_value=azure_response( + { + "keyId": "expected-key", + "algorithm": "SIGNING_ALGORITHM_ECDSA_P256_SHA256", + "signature": base64.b64encode(bytes(64)).decode(), + } + ), + ) as sign, + ): + self.assertEqual(notary.main(), 0) + self.assertEqual( + [ + json.loads(notary.base64url_decode(entry.args[1].split(".")[1]))["iat"] + for entry in request.call_args_list + ], + [1, 900], + ) + self.assertEqual( + [entry.args[0][0] for entry in sign.call_args_list], ["grpcurl", "grpcurl"] + ) + self.assertEqual( + [ + entry.kwargs["env"]["NOTARIZATION_OIDC_TOKEN"] + for entry in sign.call_args_list + ], + ["first-oidc", "fresh-oidc"], + ) + def test_uploads_polls_and_retains_sanitized_apple_diagnostics(self) -> None: signature = azure_response( { @@ -224,8 +292,17 @@ class NotarizationWrapperTest(unittest.TestCase): f"#!/usr/bin/env bash\nset -euo pipefail\n{body}\n" ) executable.chmod(0o755) - for kind in ("binary", "dmg"): - with self.subTest(kind=kind): + for kind, backend in ( + ("binary", "akv"), + ("dmg", "akv"), + ("binary", "signer-service"), + ): + selected_environment = dict(environment) + if backend == "signer-service": + selected_environment["APPLE_NOTARIZATION_BACKEND"] = backend + for name in ENVIRONMENT: + selected_environment.pop(name, None) + with self.subTest(kind=kind, backend=backend): artifact = root / ("codex.dmg" if kind == "dmg" else "codex") artifact.write_bytes(b"signed release artifact") subprocess.run( @@ -238,7 +315,7 @@ class NotarizationWrapperTest(unittest.TestCase): "--report-dir", str(root / "report"), ], - env=environment, + env=selected_environment, check=True, capture_output=True, text=True,