diff --git a/.github/scripts/macos-signing/provisioned_macos_cli_package.py b/.github/scripts/macos-signing/provisioned_macos_cli_package.py new file mode 100644 index 0000000000..449dd15499 --- /dev/null +++ b/.github/scripts/macos-signing/provisioned_macos_cli_package.py @@ -0,0 +1,229 @@ +"""Prepare and verify provisioned CLI bundles using an explicitly approved profile.""" + +import argparse +import hashlib +import json +import os +import plistlib +import re +import shutil +import subprocess +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path + +BUNDLE_ID = "com.openai.codex.cli" +APP = Path("CodexCLI.app") +EXECUTABLE = APP / "Contents/MacOS/codex" +SIGNING = Path(__file__).resolve().parent +HELPERS = ("bin/codex-code-mode-host", "codex-path/rg", "codex-resources/zsh/bin/zsh") +LAUNCHER = """#!/bin/sh +set -eu +entry="$0" +while [ -L "$entry" ]; do + parent=$(CDPATH= cd -P -- "$(dirname -- "$entry")" && pwd) + entry=$(readlink "$entry") + case "$entry" in /*) ;; *) entry="$parent/$entry" ;; esac +done +bin_dir=$(CDPATH= cd -P -- "$(dirname -- "$entry")" && pwd) +exec "$bin_dir/../CodexCLI.app/Contents/MacOS/codex" "$@" +""" + + +@dataclass(frozen=True) +class ProfileConfiguration: + """Independent release expectations; never infer these from the signed package.""" + + profile: Path + profile_sha256: str + certificate_sha256: str + team_id: str + + def __post_init__(self): + for name in ("profile_sha256", "certificate_sha256"): + if not re.fullmatch(r"[0-9a-f]{64}", getattr(self, name)): + raise ValueError(f"{name} must be a lowercase SHA-256 digest") + if not re.fullmatch(r"[A-Z0-9]{10}", self.team_id): + raise ValueError("team_id must be a ten-character Apple Team ID") + + def validate_signing_certificate(self, certificate: Path): + certificate_der = subprocess.check_output( + ["openssl", "x509", "-in", str(certificate), "-outform", "DER"] + ) + if hashlib.sha256(certificate_der).hexdigest() != self.certificate_sha256: + raise ValueError("AKV signing certificate is not authorized by the profile") + + +def load_profile(configuration: ProfileConfiguration): + # Pin the portal-approved CMS as well as checking its signature. -noverify + # skips OpenSSL's non-Apple trust store, not CMS signature verification. + if ( + hashlib.sha256(configuration.profile.read_bytes()).hexdigest() + != configuration.profile_sha256 + ): + raise ValueError("Provisioning profile changed; review and update its pin") + profile = plistlib.loads( + subprocess.check_output( + [ + "openssl", + "cms", + "-verify", + "-inform", + "DER", + "-noverify", + "-in", + str(configuration.profile), + ] + ) + ) + validate_profile(profile, configuration) + return profile + + +def validate_profile(profile, configuration: ProfileConfiguration): + allowed = profile["Entitlements"] + if ( + profile["TeamIdentifier"] != [configuration.team_id] + or profile["ApplicationIdentifierPrefix"] != [configuration.team_id] + or allowed["com.apple.application-identifier"] + != f"{configuration.team_id}.{BUNDLE_ID}" + or allowed["com.apple.developer.team-identifier"] != configuration.team_id + or allowed["keychain-access-groups"] != [f"{configuration.team_id}.*"] + or allowed.get("get-task-allow", False) + or not profile.get("ProvisionsAllDevices") + or "ProvisionedDevices" in profile + ): + raise ValueError("Profile does not authorize the CLI Developer ID identity") + now = datetime.now(timezone.utc).replace(tzinfo=None) + if not profile["CreationDate"] <= now < profile["ExpirationDate"]: + raise ValueError("Provisioning profile is not currently valid") + if [ + hashlib.sha256(cert).hexdigest() for cert in profile["DeveloperCertificates"] + ] != [configuration.certificate_sha256]: + raise ValueError("Profile does not authorize the approved signing certificate") + + +def entitlements(configuration: ProfileConfiguration): + base = plistlib.loads((SIGNING / "codex.entitlements.plist").read_bytes()) + return { + **base, + "com.apple.application-identifier": f"{configuration.team_id}.{BUNDLE_ID}", + "com.apple.developer.team-identifier": configuration.team_id, + "keychain-access-groups": [f"{configuration.team_id}.{BUNDLE_ID}"], + } + + +def prepare(package, reports, configuration: ProfileConfiguration): + load_profile(configuration) + metadata = json.loads((package / "codex-package.json").read_text()) + if ( + metadata["variant"] != "codex" + or metadata["layoutVersion"] != 1 + or metadata["target"] not in ("aarch64-apple-darwin", "x86_64-apple-darwin") + or metadata["entrypoint"] != "bin/codex" + ): + raise ValueError("Expected a canonical macOS CLI package") + for relative in ("bin/codex", *HELPERS): + path = package / relative + if path.is_symlink() or not path.is_file() or not os.access(path, os.X_OK): + raise ValueError(f"Expected a regular executable: {relative}") + # Refuse to overwrite a previously prepared or signed bundle. + (package / EXECUTABLE).parent.mkdir(parents=True, exist_ok=False) + (package / "bin/codex").rename(package / EXECUTABLE) + (package / "bin/codex").write_text(LAUNCHER) + (package / "bin/codex").chmod(0o755) + contents = package / APP / "Contents" + shutil.copyfile(configuration.profile, contents / "embedded.provisionprofile") + (contents / "Info.plist").write_bytes( + plistlib.dumps( + { + "CFBundleIdentifier": BUNDLE_ID, + "CFBundleExecutable": "codex", + "CFBundleName": "Codex CLI", + "CFBundlePackageType": "APPL", + "CFBundleVersion": "1", + } + ) + ) + reports.mkdir(parents=True, exist_ok=True) + # Keep expected entitlements separate from the shared verifier's extracted + # codex-entitlements.plist, otherwise it would overwrite its own expectation. + (reports / "codex-provisioned-entitlements.plist").write_bytes( + plistlib.dumps(entitlements(configuration)) + ) + + +def verify(package, reports, expected_target, configuration: ProfileConfiguration): + """Check profile and certificate pins; the signing driver verifies code.""" + load_profile(configuration) + if ( + package / APP / "Contents/embedded.provisionprofile" + ).read_bytes() != configuration.profile.read_bytes(): + raise ValueError("Embedded profile differs from the reviewed profile") + if (package / "bin/codex").read_text() != LAUNCHER: + raise ValueError("Unexpected CLI launcher") + metadata = json.loads((package / "codex-package.json").read_text()) + if metadata["target"] != expected_target: + raise ValueError( + "Package target differs from the requested verification target" + ) + reports.mkdir(parents=True, exist_ok=True) + (reports / "codex-provisioned-entitlements.plist").write_bytes( + plistlib.dumps(entitlements(configuration)) + ) + # sign_macos_cli_package.py owns shared architecture, signature and entitlement + # checks. Here, require the exact certificate authorized by the profile. + for relative in (EXECUTABLE, *(Path(helper) for helper in HELPERS)): + binary = package / relative + target = package / APP if relative == EXECUTABLE else binary + prefix = reports / f"{binary.name}-cert-" + subprocess.run( + ["codesign", "-d", f"--extract-certificates={prefix}", str(target)], + check=True, + ) + if ( + hashlib.sha256(Path(f"{prefix}0").read_bytes()).hexdigest() + != configuration.certificate_sha256 + ): + raise ValueError(f"Unexpected signing certificate: {relative}") + + +def parse_profile_args(parser, configuration: ProfileConfiguration | None): + """Require independent profile expectations unless the caller supplies them.""" + if configuration is None: + parser.add_argument("--profile", type=Path, required=True) + parser.add_argument("--profile-sha256", required=True) + parser.add_argument("--certificate-sha256", required=True) + parser.add_argument("--team-id", required=True) + args = parser.parse_args() + if configuration is None: + configuration = ProfileConfiguration( + args.profile, args.profile_sha256, args.certificate_sha256, args.team_id + ) + return args, configuration + + +def main(configuration: ProfileConfiguration | None = None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("operation", choices=("prepare", "verify", "validate-profile")) + parser.add_argument("--package", type=Path, default=Path("package")) + parser.add_argument("--reports", type=Path, default=Path("provisioned-reports")) + parser.add_argument("--certificate", type=Path) + parser.add_argument( + "--target", choices=("aarch64-apple-darwin", "x86_64-apple-darwin") + ) + args, configuration = parse_profile_args(parser, configuration) + if args.certificate: + configuration.validate_signing_certificate(args.certificate) + if args.operation == "validate-profile": + load_profile(configuration) + elif args.operation == "prepare": + prepare(args.package, args.reports, configuration) + else: + if not args.target: + parser.error("--target is required for verification") + verify(args.package, args.reports, args.target, configuration) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/macos-signing/sign_macos_cli_package.py b/.github/scripts/macos-signing/sign_macos_cli_package.py new file mode 100644 index 0000000000..6d555945a7 --- /dev/null +++ b/.github/scripts/macos-signing/sign_macos_cli_package.py @@ -0,0 +1,177 @@ +"""Sign and verify macOS CLI packages using the shared AKV and native tools.""" + +import argparse +import os +import subprocess +import sys +from pathlib import Path + +import provisioned_macos_cli_package as bundle + + +def main(configuration: bundle.ProfileConfiguration | None = None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("operation", choices=("sign", "verify")) + parser.add_argument("package", type=Path) + args, configuration = bundle.parse_profile_args(parser, configuration) + package = args.package.resolve(strict=True) + if not package.is_dir(): + parser.error("package must be an extracted CLI package directory") + repo_root = os.environ.get("CODEX_REPO_ROOT") + if not repo_root: + parser.error("CODEX_REPO_ROOT must be set") + reports = Path(repo_root) / "signing-verification" + reports.mkdir(parents=True, exist_ok=True) + provisioned = os.environ.get("PROVISIONED_MACOS", "false") == "true" + if provisioned: + if args.operation == "sign": + configuration.validate_signing_certificate( + Path(os.environ["OAI_AKV_SIGNING_CERTIFICATE_PEM"]) + ) + bundle.prepare(package, reports, configuration) + else: + bundle.verify(package, reports, os.environ["TARGET"], configuration) + + for relative in ("bin/codex", *bundle.HELPERS): + binary = package / relative + name = binary.name + target = binary + identifier = name + entitlements = bundle.SIGNING / f"{name}.entitlements.plist" + if provisioned and relative == "bin/codex": + binary = package / bundle.EXECUTABLE + target = package / bundle.APP + identifier = bundle.BUNDLE_ID + entitlements = reports / "codex-provisioned-entitlements.plist" + if args.operation == "sign": + command = [ + "bash", + str(bundle.SIGNING / "sign_macos_code.sh"), + "--target", + str(target), + "--identity", + "unused", + "--deep", + "false", + "--options", + "runtime", + "--timestamp", + "true", + ] + if relative.startswith("bin/"): + command.extend( + ["--identifier", identifier, "--entitlements", str(entitlements)] + ) + else: + command.extend(["--identifier", f"com.openai.codex.{name}"]) + subprocess.run(command, check=True) + with (reports / f"{name}-signature.yaml").open("wb") as output: + subprocess.run( + ["rcodesign", "print-signature-info", str(binary)], + stdout=output, + check=True, + ) + if not provisioned: + subprocess.run( + [ + "bash", + str(bundle.SIGNING / "notarize_macos_binary_with_akv.sh"), + "--binary", + str(binary), + "--report-dir", + str(reports / name), + ], + check=True, + ) + else: + target_triple = os.environ["TARGET"] + architectures = { + "aarch64-apple-darwin": "arm64", + "x86_64-apple-darwin": "x86_64", + } + if target_triple not in architectures: + raise ValueError(f"Unexpected macOS target: {target_triple}") + subprocess.run( + ["lipo", str(binary), "-verify_arch", architectures[target_triple]], + check=True, + ) + subprocess.run( + [ + "codesign", + "--verify", + "--strict", + "--verbose=2", + "--test-requirement", + # Require Apple's Developer ID Application certificate chain. + ( + "=anchor apple generic" + " and certificate 1[field.1.2.840.113635.100.6.2.6] exists" + " and certificate leaf[field.1.2.840.113635.100.6.1.13] exists" + ), + str(target), + ], + check=True, + ) + signature = reports / f"{name}-signature.txt" + with signature.open("wb") as output: + subprocess.run( + ["codesign", "-d", "--verbose=4", str(target)], + stderr=output, + check=True, + ) + actual = reports / f"{name}-entitlements.plist" + with actual.open("wb") as output: + subprocess.run( + ["codesign", "-d", "--entitlements", ":-", str(target)], + stdout=output, + check=True, + ) + if relative.startswith("bin/"): + expected = reports / f"{name}-expected.plist" + subprocess.run( + [ + "plutil", + "-convert", + "xml1", + "-o", + str(expected), + str(entitlements), + ], + check=True, + ) + subprocess.run(["plutil", "-convert", "xml1", str(actual)], check=True) + subprocess.run(["diff", "-u", str(expected), str(actual)], check=True) + elif actual.stat().st_size: + raise ValueError( + f"Bundled helper {name} must not have signing entitlements" + ) + + if provisioned and args.operation == "sign": + archive = Path(os.environ["RUNNER_TEMP"]) / "provisioned-cli.zip" + subprocess.run(["zip", "-q", "-r", str(archive), "."], cwd=package, check=True) + command = [ + sys.executable, + str(bundle.SIGNING / "notarize_with_akv.py"), + "--file", + str(archive), + "--report-log", + str(reports / "notarization-developer-log.json"), + "--max-wait-seconds", + "1200", + ] + with ( + (reports / "notarization.log").open("w") as output, + subprocess.Popen( + command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True + ) as process, + ): + assert process.stdout is not None + for line in process.stdout: + print(line, end="", flush=True) + output.write(line) + if returncode := process.wait(): + raise subprocess.CalledProcessError(returncode, command) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/macos-signing/test_provisioned_macos_cli_package.py b/.github/scripts/macos-signing/test_provisioned_macos_cli_package.py new file mode 100644 index 0000000000..1c6c266b53 --- /dev/null +++ b/.github/scripts/macos-signing/test_provisioned_macos_cli_package.py @@ -0,0 +1,217 @@ +"""Exercise profile validation, relocation and signing without release credentials.""" + +import copy +import hashlib +import json +import plistlib +import shutil +import ssl +import subprocess +import tarfile +import tempfile +import unittest +from dataclasses import replace +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import provisioned_macos_cli_package as bundle + + +class ProvisioningTestCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + directory = tempfile.TemporaryDirectory() + cls.addClassCleanup(directory.cleanup) + root = Path(directory.name) + key = root / "test-key.pem" + certificate = root / "test-certificate.pem" + subprocess.run( + [ + "openssl", + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-keyout", + str(key), + "-out", + str(certificate), + "-days", + "1", + "-subj", + "/CN=CLI provisioning test/OU=TESTTEAM01", + ], + check=True, + capture_output=True, + ) + cert_der = ssl.PEM_cert_to_DER_cert(certificate.read_text()) + now = datetime.now(timezone.utc).replace(tzinfo=None, microsecond=0) + cls.profile = { + "TeamIdentifier": ["TESTTEAM01"], + "ApplicationIdentifierPrefix": ["TESTTEAM01"], + "Entitlements": { + "com.apple.application-identifier": "TESTTEAM01.com.openai.codex.cli", + "com.apple.developer.team-identifier": "TESTTEAM01", + "keychain-access-groups": ["TESTTEAM01.*"], + }, + "CreationDate": now - timedelta(days=1), + "ExpirationDate": now + timedelta(days=1), + "ProvisionsAllDevices": True, + "DeveloperCertificates": [cert_der], + } + payload = root / "profile.plist" + payload.write_bytes(plistlib.dumps(cls.profile)) + profile_path = root / "test.provisionprofile" + subprocess.run( + [ + "openssl", + "cms", + "-sign", + "-binary", + "-nodetach", + "-outform", + "DER", + "-in", + str(payload), + "-signer", + str(certificate), + "-inkey", + str(key), + "-out", + str(profile_path), + ], + check=True, + capture_output=True, + ) + cls.configuration = bundle.ProfileConfiguration( + profile=profile_path, + profile_sha256=hashlib.sha256(profile_path.read_bytes()).hexdigest(), + certificate_sha256=hashlib.sha256(cert_der).hexdigest(), + team_id="TESTTEAM01", + ) + + +class ProvisionedCliTests(ProvisioningTestCase): + def test_profile_configuration_fails_closed(self): + self.assertEqual(bundle.load_profile(self.configuration), self.profile) + for changes in ( + {"profile_sha256": "0" * 64}, + {"certificate_sha256": "0" * 64}, + {"team_id": "OTHERTEAM1"}, + {"profile_sha256": ""}, + {"certificate_sha256": ""}, + {"team_id": ""}, + ): + with self.subTest(changes=changes), self.assertRaises(ValueError): + bundle.load_profile(replace(self.configuration, **changes)) + + def test_rejects_profile_with_wrong_identity_certificate_or_validity(self): + for field, value in ( + ("TeamIdentifier", ["OTHERTEAM"]), + ("DeveloperCertificates", [b"another certificate"]), + ( + "ExpirationDate", + datetime(2000, 1, 1, tzinfo=timezone.utc).replace(tzinfo=None), + ), + ( + "CreationDate", + datetime(2099, 1, 1, tzinfo=timezone.utc).replace(tzinfo=None), + ), + ("ProvisionsAllDevices", False), + ("ProvisionedDevices", ["device"]), + ): + with self.subTest(field=field): + profile = copy.deepcopy(self.profile) + profile[field] = value + with self.assertRaises(ValueError): + bundle.validate_profile(profile, self.configuration) + for field, value in ( + ( + "com.apple.application-identifier", + f"{self.configuration.team_id}.another.app", + ), + ("keychain-access-groups", [f"{self.configuration.team_id}.another.app"]), + ("get-task-allow", True), + ): + with self.subTest(field=field): + profile = copy.deepcopy(self.profile) + profile["Entitlements"][field] = value + with self.assertRaises(ValueError): + bundle.validate_profile(profile, self.configuration) + + def test_archive_relocation_copy_and_symlink_preserve_launcher(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + package = root / "original package" + metadata = { + "variant": "codex", + "layoutVersion": 1, + "target": "aarch64-apple-darwin", + "entrypoint": "bin/codex", + } + for relative in ("bin/codex", *bundle.HELPERS): + path = package / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('#!/bin/sh\nprintf "%s\\n" "$@"\nexit 23\n') + path.chmod(0o755) + (package / "codex-package.json").write_text(json.dumps(metadata)) + original_binary = (package / "bin/codex").read_bytes() + bundle.prepare(package, root / "reports", self.configuration) + with self.assertRaises(FileExistsError): + bundle.prepare(package, root / "reports", self.configuration) + with self.assertRaisesRegex(ValueError, "Package target differs"): + bundle.verify( + package, root / "reports", "x86_64-apple-darwin", self.configuration + ) + self.assertEqual( + (package / bundle.EXECUTABLE).read_bytes(), original_binary + ) + self.assertEqual( + json.loads((package / "codex-package.json").read_text()), metadata + ) + + archive_path = root / "package.tar.gz" + with tarfile.open(archive_path, "w:gz") as archive: + archive.add(package, arcname=".") + relocated = root / "relocated package" + with tarfile.open(archive_path) as archive: + archive.extractall(relocated, filter="data") + shutil.rmtree(package) + # npm currently dereferences symlinks when copying packages. The + # launcher remains a script and the provisioned executable stays put. + copied = root / "copied package" + shutil.copytree(relocated, copied) + link = root / "installed codex" + link.symlink_to(Path("copied package/bin/codex")) + absolute_link = root / "absolute codex" + absolute_link.symlink_to(link) + for entry in ( + relocated / "bin/codex", + copied / "bin/codex", + link, + absolute_link, + ): + with self.subTest(entry=entry): + result = subprocess.run( + [str(entry), "space argument", "*", "--flag"], + cwd="/", + capture_output=True, + text=True, + check=False, + ) + self.assertEqual( + (result.returncode, result.stdout, result.stderr), + (23, "space argument\n*\n--flag\n", ""), + ) + (copied / bundle.APP / "Contents/embedded.provisionprofile").write_bytes( + b"tampered" + ) + with self.assertRaisesRegex(ValueError, "Embedded profile differs"): + bundle.verify( + copied, root / "reports", "aarch64-apple-darwin", self.configuration + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/macos-signing/test_sign_macos_cli_package.py b/.github/scripts/macos-signing/test_sign_macos_cli_package.py new file mode 100644 index 0000000000..38efc12098 --- /dev/null +++ b/.github/scripts/macos-signing/test_sign_macos_cli_package.py @@ -0,0 +1,293 @@ +"""Exercise package signing and native verification with generated credentials.""" + +import itertools +import json +import os +import shutil +import ssl +import subprocess +import sys +import tempfile +import unittest +import zipfile +from pathlib import Path + +import provisioned_macos_cli_package as bundle +from test_provisioned_macos_cli_package import ProvisioningTestCase + +NATIVE_TOOL = """#!/usr/bin/env python3 +import json, os, plistlib, sys +from pathlib import Path +name = Path(sys.argv[0]).name +args = sys.argv[1:] +with open(os.environ["CALL_LOG"], "a") as log: + log.write(json.dumps([name, *args]) + "\\n") +if name == "codesign": + for arg in args: + if arg.startswith("--extract-certificates="): + Path(arg.split("=", 1)[1] + "0").write_bytes(Path(os.environ["MOCK_CERTIFICATE"]).read_bytes()) + if "--test-requirement" in args and os.environ.get("MOCK_WRONG_SIGNING_IDENTITY"): + print("code failed to satisfy specified code requirement(s)", file=sys.stderr) + sys.exit(1) + if "--entitlements" in args: + target = Path(args[-1]).name + if target == "CodexCLI.app": + source = Path(os.environ["CODEX_REPO_ROOT"]) / "signing-verification/codex-provisioned-entitlements.plist" + elif target in ("codex", "codex-code-mode-host"): + source = Path(os.environ["MOCK_SIGNING_SCRIPTS"]) / (target + ".entitlements.plist") + else: + sys.exit(0) + if os.environ.get("MOCK_WRONG_ENTITLEMENTS"): + sys.stdout.buffer.write(plistlib.dumps({"unexpected": True})) + else: + sys.stdout.buffer.write(source.read_bytes()) +elif name == "plutil": + source = Path(args[-1]) + target = Path(args[args.index("-o") + 1]) if "-o" in args else source + target.write_bytes(plistlib.dumps(plistlib.loads(source.read_bytes()))) +""" + + +class MacosSigningTests(ProvisioningTestCase): + def fixture(self, root, provisioned, target="aarch64-apple-darwin"): + source = Path(bundle.__file__).parent + common = Path(os.path.commonpath((source, bundle.SIGNING))) + helpers = root / source.relative_to(common) + scripts = root / bundle.SIGNING.relative_to(common) + helpers.mkdir(parents=True, exist_ok=True) + scripts.mkdir(parents=True, exist_ok=True) + for name in ("provisioned_macos_cli_package.py", "sign_macos_cli_package.py"): + shutil.copyfile(source / name, helpers / name) + for path in bundle.SIGNING.glob("*.entitlements.plist"): + shutil.copyfile(path, scripts / path.name) + (root / "record.py").write_text( + "import json, os, sys\n" + "with open(os.environ['CALL_LOG'], 'a') as log:\n" + " log.write(json.dumps(sys.argv[1:]) + '\\n')\n" + "if os.environ.get('FAIL_TOOL') == sys.argv[1]:\n" + " print('fixture stdout', flush=True)\n" + " print('fixture stderr', file=sys.stderr, flush=True)\n" + " sys.exit(17)\n" + ) + for name in ("sign_macos_code.sh", "notarize_macos_binary_with_akv.sh"): + (scripts / name).write_text( + 'python3 "$CODEX_REPO_ROOT/record.py" "$(basename "$0")" "$@"\n' + ) + (scripts / "notarize_with_akv.py").write_text( + "import os, runpy, sys\n" + "sys.argv.insert(1, 'notarize_with_akv.py')\n" + "runpy.run_path(os.environ['CODEX_REPO_ROOT'] + '/record.py')\n" + ) + for name in ("rcodesign", "codesign", "lipo", "plutil"): + path = root / name + path.write_text(NATIVE_TOOL) + path.chmod(0o755) + certificate_der = self.profile["DeveloperCertificates"][0] + certificate = root / "certificate.pem" + certificate.write_text(ssl.DER_cert_to_PEM_cert(certificate_der)) + (root / "certificate.der").write_bytes(certificate_der) + package = root / "package with spaces" + for relative in ("bin/codex", *bundle.HELPERS): + path = package / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("#!/bin/sh\nexit 0\n") + path.chmod(0o755) + (package / "codex-package.json").write_text( + json.dumps( + { + "variant": "codex", + "layoutVersion": 1, + "target": target, + "entrypoint": "bin/codex", + } + ) + ) + env = { + key: value + for key, value in os.environ.items() + if key not in ("GITHUB_WORKSPACE", "CODEX_PROVISIONING_HELPER") + } + env.update( + CODEX_REPO_ROOT=str(root), + RUNNER_TEMP=str(root), + TARGET=target, + PATH=f"{root}{os.pathsep}{os.environ['PATH']}", + CALL_LOG=str(root / "calls.jsonl"), + PROVISIONED_MACOS=str(provisioned).lower(), + OAI_AKV_SIGNING_CERTIFICATE_PEM=str(certificate), + MOCK_CERTIFICATE=str(root / "certificate.der"), + MOCK_SIGNING_SCRIPTS=str(scripts), + ) + return package, helpers / "sign_macos_cli_package.py", env + + def run_driver(self, driver, operation, package, env): + return subprocess.run( + [ + sys.executable, + str(driver), + operation, + str(package), + "--profile", + str(self.configuration.profile), + "--profile-sha256", + self.configuration.profile_sha256, + "--certificate-sha256", + self.configuration.certificate_sha256, + "--team-id", + self.configuration.team_id, + ], + env=env, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + def test_standard_and_provisioned_signing_and_verification(self): + for provisioned, target in itertools.product( + (False, True), ("aarch64-apple-darwin", "x86_64-apple-darwin") + ): + with ( + self.subTest(provisioned=provisioned, target=target), + tempfile.TemporaryDirectory() as directory, + ): + root = Path(directory).resolve() + package, driver, env = self.fixture(root, provisioned, target) + result = self.run_driver(driver, "sign", package, env) + self.assertEqual(result.returncode, 0, result.stderr) + result = self.run_driver(driver, "verify", package, env) + self.assertEqual(result.returncode, 0, result.stderr) + calls = [ + json.loads(line) + for line in (root / "calls.jsonl").read_text().splitlines() + ] + signing = [call for call in calls if call[0] == "sign_macos_code.sh"] + self.assertEqual( + [call[call.index("--target") + 1] for call in signing], + [ + str(package / relative) + for relative in ( + bundle.APP if provisioned else "bin/codex", + *bundle.HELPERS, + ) + ], + ) + self.assertEqual( + signing[0][signing[0].index("--entitlements") + 1], + str( + root + / "signing-verification/codex-provisioned-entitlements.plist" + ) + if provisioned + else str( + Path(env["MOCK_SIGNING_SCRIPTS"]) / "codex.entitlements.plist" + ), + ) + self.assertEqual( + ["--entitlements" in call for call in signing], + [True, True, False, False], + ) + self.assertEqual( + [call[0] for call in calls if call[0].startswith("notarize")], + ["notarize_with_akv.py"] + if provisioned + else ["notarize_macos_binary_with_akv.sh"] * 4, + ) + self.assertEqual( + [call[1:] for call in calls if call[0] == "lipo"], + [ + [ + str(package / relative), + "-verify_arch", + "arm64" if target.startswith("aarch64") else "x86_64", + ] + for relative in ( + bundle.EXECUTABLE if provisioned else "bin/codex", + *bundle.HELPERS, + ) + ], + ) + if provisioned: + with zipfile.ZipFile(root / "provisioned-cli.zip") as archive: + self.assertEqual( + archive.read( + "CodexCLI.app/Contents/embedded.provisionprofile" + ), + self.configuration.profile.read_bytes(), + ) + self.assertEqual( + archive.read("bin/codex").decode(), bundle.LAUNCHER + ) + self.assertTrue( + all( + helper in archive.namelist() + for helper in bundle.HELPERS + ) + ) + + def test_signing_and_notarization_failures_stop_the_driver(self): + for tool in ("sign_macos_code.sh", "notarize_with_akv.py"): + with self.subTest(tool=tool), tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + package, driver, env = self.fixture(root, True) + result = self.run_driver( + driver, "sign", package, {**env, "FAIL_TOOL": tool} + ) + self.assertNotEqual(result.returncode, 0) + calls = [ + json.loads(line) + for line in (root / "calls.jsonl").read_text().splitlines() + ] + self.assertEqual(calls[-1][0], tool) + if tool == "notarize_with_akv.py": + self.assertEqual( + (root / "signing-verification/notarization.log").read_text(), + "fixture stdout\nfixture stderr\n", + ) + self.assertIn("fixture stdout\nfixture stderr\n", result.stdout) + else: + self.assertFalse((root / "provisioned-cli.zip").exists()) + + def test_verification_rejects_wrong_signing_identity_and_entitlements(self): + for provisioned, failure in itertools.product( + (False, True), ("MOCK_WRONG_SIGNING_IDENTITY", "MOCK_WRONG_ENTITLEMENTS") + ): + with ( + self.subTest(provisioned=provisioned, failure=failure), + tempfile.TemporaryDirectory() as directory, + ): + root = Path(directory).resolve() + package, driver, env = self.fixture(root, provisioned) + signed = self.run_driver(driver, "sign", package, env) + self.assertEqual(signed.returncode, 0, signed.stderr) + result = self.run_driver( + driver, "verify", package, {**env, failure: "true"} + ) + self.assertNotEqual(result.returncode, 0) + if failure == "MOCK_WRONG_SIGNING_IDENTITY": + self.assertIn( + "code failed to satisfy specified code requirement(s)", + result.stderr, + ) + else: + self.assertIn("unexpected", result.stdout) + + def test_cli_requires_independent_profile_configuration(self): + with tempfile.TemporaryDirectory() as directory: + result = subprocess.run( + [ + sys.executable, + str(Path(bundle.__file__).with_name("sign_macos_cli_package.py")), + "verify", + directory, + ], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 2) + self.assertIn("the following arguments are required", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/repo-checks.yml b/.github/workflows/repo-checks.yml index efcc043de8..d43bcdccc0 100644 --- a/.github/workflows/repo-checks.yml +++ b/.github/workflows/repo-checks.yml @@ -43,8 +43,8 @@ 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: Test macOS signing and notarization + run: python3 -m unittest discover -s .github/scripts/macos-signing -p 'test_*.py' - name: Setup pnpm uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 diff --git a/.github/workflows/rust-release-provisioned-macos.yml b/.github/workflows/rust-release-provisioned-macos.yml new file mode 100644 index 0000000000..d131835618 --- /dev/null +++ b/.github/workflows/rust-release-provisioned-macos.yml @@ -0,0 +1,213 @@ +# Optional side artifacts only; no release, npm, installer, or DMG publication. +# Required secrets in the codesigning environment: +# - CODEX_CLI_PROVISIONING_PROFILE_BASE64: profile approved for public distribution. +# - CODEX_CLI_PROVISIONING_PROFILE_SHA256: reviewed lowercase SHA-256 of that profile. +# - CODEX_CLI_PROVISIONING_TEAM_ID: expected ten-character Apple Team ID. +# - AKV_CODESIGN_CERTIFICATE_SHA256: required signing certificate pin. +# Set repository variable CODEX_PROVISIONED_MACOS_CANDIDATE to true to enable +# candidates on normal tag releases; unset it or set it to false to disable them. +name: Provisioned macOS CLI candidate + +on: + workflow_call: + +permissions: + contents: read + +env: + CODEX_REPO_ROOT: ${{ github.workspace }} + +jobs: + sign: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/rust-v') && vars.CODEX_PROVISIONED_MACOS_CANDIDATE == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 45 + environment: + name: codesigning + deployment: false + permissions: + contents: read + id-token: write + strategy: + fail-fast: false + matrix: + target: [aarch64-apple-darwin, x86_64-apple-darwin] + env: + TARGET: ${{ matrix.target }} + PROVISIONED_MACOS: "true" + PROFILE_SHA256: ${{ secrets.CODEX_CLI_PROVISIONING_PROFILE_SHA256 }} + CERTIFICATE_SHA256: ${{ secrets.AKV_CODESIGN_CERTIFICATE_SHA256 }} + TEAM_ID: ${{ secrets.CODEX_CLI_PROVISIONING_TEAM_ID }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + - name: Validate the independently approved public-distribution profile + env: + PROFILE_BASE64: ${{ secrets.CODEX_CLI_PROVISIONING_PROFILE_BASE64 }} + run: | + python3 - <<'PY' + import base64 + import os + from pathlib import Path + profile = Path(os.environ["RUNNER_TEMP"]) / "codex-cli.provisionprofile" + profile.write_bytes(base64.b64decode("".join(os.environ["PROFILE_BASE64"].split()), validate=True)) + PY + python3 .github/scripts/macos-signing/provisioned_macos_cli_package.py validate-profile \ + --profile "$RUNNER_TEMP/codex-cli.provisionprofile" --profile-sha256 "$PROFILE_SHA256" \ + --certificate-sha256 "$CERTIFICATE_SHA256" --team-id "$TEAM_ID" + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ matrix.target }} + path: standard + - name: Extract the verified package from this tag-release run + run: | + python3 - <<'PY' + import json + import os + import tarfile + from pathlib import Path + with tarfile.open(f"standard/codex-package-{os.environ['TARGET']}.tar.gz") as archive: + archive.extractall("package", filter="data") + metadata = json.loads(Path("package/codex-package.json").read_text()) + if metadata["target"] != os.environ["TARGET"]: + raise ValueError("Package target does not match this release job") + PY + - name: Set up the existing AKV signer + uses: ./.github/actions/setup-akv-pkcs11-codesigning + with: + rcodesign-blob-uri: ${{ secrets.AKV_CODESIGN_RCODESIGN_BLOB_URI }} + rcodesign-sha256: ${{ secrets.AKV_CODESIGN_RCODESIGN_SHA256 }} + akv-pkcs11-library-blob-uri: ${{ secrets.AKV_CODESIGN_PKCS11_LIBRARY_BLOB_URI }} + akv-pkcs11-library-sha256: ${{ secrets.AKV_CODESIGN_PKCS11_LIBRARY_SHA256 }} + azure-client-id: ${{ secrets.AKV_CODESIGN_AZURE_CLIENT_ID }} + azure-tenant-id: ${{ secrets.AKV_CODESIGN_TENANT }} + azure-subscription-id: ${{ secrets.AKV_CODESIGN_SUBSCRIPTION }} + key-vault-name: ${{ secrets.AKV_CODESIGN_KEY_VAULT_NAME }} + key-name: ${{ secrets.AKV_CODESIGN_KEY_NAME }} + key-version: ${{ secrets.AKV_CODESIGN_KEY_VERSION || '' }} + certificate-sha256: ${{ secrets.AKV_CODESIGN_CERTIFICATE_SHA256 }} + - name: Sign and notarize the provisioned bundle + env: + APPLE_NOTARIZATION_AKV_KEY_NAME: ${{ secrets.AKV_NOTARIZATION_KEY_NAME }} + APPLE_NOTARIZATION_AKV_KEY_VERSION: ${{ secrets.AKV_NOTARIZATION_KEY_VERSION }} + run: | + python3 .github/scripts/macos-signing/sign_macos_cli_package.py sign package \ + --profile "$RUNNER_TEMP/codex-cli.provisionprofile" --profile-sha256 "$PROFILE_SHA256" \ + --certificate-sha256 "$CERTIFICATE_SHA256" --team-id "$TEAM_ID" + mkdir signed + tar -czf "signed/codex-provisioned-package-$TARGET.tar.gz" -C package \ + bin codex-resources codex-path codex-package.json CodexCLI.app + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: provisioned-macos-signed-candidate-${{ matrix.target }} + path: signed/*.tar.gz + if-no-files-found: error + - name: Retain signing reports + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: provisioned-macos-signing-reports-${{ matrix.target }} + path: signing-verification/ + if-no-files-found: warn + + verify: + needs: sign + runs-on: macos-15-xlarge + timeout-minutes: 30 + environment: + name: codesigning + deployment: false + # Read independent profile/certificate expectations without a signing token. + permissions: + contents: read + id-token: none + strategy: + fail-fast: false + matrix: + target: [aarch64-apple-darwin, x86_64-apple-darwin] + env: + TARGET: ${{ matrix.target }} + PROVISIONED_MACOS: "true" + PROFILE_SHA256: ${{ secrets.CODEX_CLI_PROVISIONING_PROFILE_SHA256 }} + CERTIFICATE_SHA256: ${{ secrets.AKV_CODESIGN_CERTIFICATE_SHA256 }} + TEAM_ID: ${{ secrets.CODEX_CLI_PROVISIONING_TEAM_ID }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: provisioned-macos-signed-candidate-${{ matrix.target }} + path: signed + - name: Extract candidate and load independent verification profile + env: + PROFILE_BASE64: ${{ secrets.CODEX_CLI_PROVISIONING_PROFILE_BASE64 }} + run: | + python3 - <<'PY' + import base64 + import os + import tarfile + from pathlib import Path + profile = Path(os.environ["RUNNER_TEMP"]) / "codex-cli.provisionprofile" + profile.write_bytes(base64.b64decode("".join(os.environ["PROFILE_BASE64"].split()), validate=True)) + with tarfile.open(f"signed/codex-provisioned-package-{os.environ['TARGET']}.tar.gz") as archive: + archive.extractall("package", filter="data") + PY + - name: Verify signatures, profile, architecture, stapling and Gatekeeper + run: | + profile_args=(--profile "$RUNNER_TEMP/codex-cli.provisionprofile" + --profile-sha256 "$PROFILE_SHA256" --certificate-sha256 "$CERTIFICATE_SHA256" + --team-id "$TEAM_ID") + python3 .github/scripts/macos-signing/sign_macos_cli_package.py verify package "${profile_args[@]}" + xcrun stapler staple package/CodexCLI.app + xcrun stapler validate package/CodexCLI.app + spctl --assess --type execute --verbose=4 package/CodexCLI.app + python3 .github/scripts/macos-signing/sign_macos_cli_package.py verify package "${profile_args[@]}" + mkdir verified + tar -czf "verified/codex-provisioned-package-$TARGET.tar.gz" -C package \ + bin codex-resources codex-path codex-package.json CodexCLI.app + (cd verified && shasum -a 256 "codex-provisioned-package-$TARGET.tar.gz" > SHA256SUMS) + - uses: ./.github/actions/setup-ci + - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + version: "0.11.3" + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ matrix.target }}-app-server + path: app-server + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ matrix.target }}-symbols + path: symbols + - name: Test the exact verified package, including sandboxed code mode + working-directory: scripts/codex_package/smoke_tests + env: + PYTHONPATH: ${{ github.workspace }}/sdk/python/src:${{ github.workspace }}/sdk/python/tests + run: | + # This repackaging does not change debug code. The normal release's + # primary symbols omit app-server symbols, so skip symbol-only tests. + uv run --frozen pytest -v --compression gzip --package-target "$TARGET" \ + --cli-archive "$CODEX_REPO_ROOT/verified/codex-provisioned-package-$TARGET.tar.gz" \ + --app-server-archive "$CODEX_REPO_ROOT/app-server/codex-app-server-package-$TARGET.tar.gz" \ + --symbols-archive "$CODEX_REPO_ROOT/symbols/codex-symbols-$TARGET.tar.gz" \ + test_codex_package.py -k 'not debug_symbols' + - name: Retain verified candidate only after package tests pass + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: provisioned-macos-verified-candidate-${{ matrix.target }} + path: verified/* + if-no-files-found: error + - name: Retain verification reports + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: provisioned-macos-verification-reports-${{ matrix.target }} + path: signing-verification/ + if-no-files-found: warn diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 67876e7a52..d351371dfa 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -1394,6 +1394,16 @@ jobs: path: codex-rs/dist/${{ matrix.target }}/* if-no-files-found: error + # Separate opt-in candidates never enter the normal release download patterns. + provisioned-macos-candidate: + needs: finalize-macos + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/rust-v') && vars.CODEX_PROVISIONED_MACOS_CANDIDATE == 'true' + permissions: + contents: read + id-token: write + uses: ./.github/workflows/rust-release-provisioned-macos.yml + secrets: inherit + build-windows: needs: tag-check uses: ./.github/workflows/rust-release-windows.yml diff --git a/scripts/codex_package/smoke_tests/test_codex_package.py b/scripts/codex_package/smoke_tests/test_codex_package.py index df808ecd5e..8ce57ea4c0 100644 --- a/scripts/codex_package/smoke_tests/test_codex_package.py +++ b/scripts/codex_package/smoke_tests/test_codex_package.py @@ -121,9 +121,11 @@ def test_app_server_runs_code_mode_through_python_sdk( sandbox=Sandbox.workspace_write, ).run("run package smoke") assert turn.final_response == "Done", turn + # The mock also records analytics POSTs, which have no Responses input list. output = next( item for request in responses_server.requests() + if request.path == "/v1/responses" for item in request.input() if item.get("type") == "custom_tool_call_output" and item.get("call_id") == "package-smoke"