mirror of
https://github.com/openai/codex.git
synced 2026-11-12 21:20:34 +00:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user