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
This commit is contained in:
tarunoai
2026-11-11 00:58:44 +00:00
committed by copyberry
parent 87b7bdbf2e
commit ac54a05173
3 changed files with 206 additions and 23 deletions
@@ -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"
@@ -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
@@ -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,