diff --git a/.github/workflows/rehearse-release-signing.yml b/.github/workflows/rehearse-release-signing.yml new file mode 100644 index 000000000..0354d14f3 --- /dev/null +++ b/.github/workflows/rehearse-release-signing.yml @@ -0,0 +1,230 @@ +name: Rehearse release signing + +on: + pull_request: + paths: + - .github/workflows/rehearse-release-signing.yml + - scripts/rehearse-release-signing.py* + - scripts/smoke-test-signed-python.py + - scripts/cpython-entitlements.plist + workflow_dispatch: + inputs: + tag: + description: Published PBS release to rehearse + type: string + default: "20260901" + python-version: + description: CPython version in that release + type: string + default: "3.14.7" + sign: + description: Use the protected release signing identity + type: boolean + default: false + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +defaults: + run: + shell: bash + +env: + RELEASE_TAG: ${{ inputs.tag || '20260901' }} + PYTHON_VERSION: ${{ inputs.python-version || '3.14.7' }} + +jobs: + prepare: + name: Reassemble unsigned / ${{ matrix.target }} + runs-on: ${{ matrix.runner }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: &targets + include: + - target: aarch64-apple-darwin + system: macos + runner: macos-15 + signer: ubuntu-24.04 + python: bin/python3 + uv-checksum: 46740540b63fdee9a6cb2e19baf3f1f475b850c440a33e63455087a6871263f1 + - target: x86_64-apple-darwin + system: macos + runner: macos-15-intel + signer: ubuntu-24.04 + python: bin/python3 + uv-checksum: 0dc8cd6c961582b0d140b5398f96b23502885277fb3464241456a2435e460dfa + - target: x86_64-pc-windows-msvc + system: windows + runner: windows-2025 + signer: windows-2025 + python: python.exe + uv-checksum: 3d54912924c36e862c14f427d04f2ed70a99e8001d1c30caa101f6d5711626d5 + env: + TARGET: ${{ matrix.target }} + SYSTEM: ${{ matrix.system }} + PYTHON_BINARY: ${{ matrix.python }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + version: "0.12.12" + checksum: ${{ matrix.uv-checksum }} + enable-cache: false + - name: Download published archive + env: + GH_TOKEN: ${{ github.token }} + run: uv run --locked scripts/rehearse-release-signing.py download "$RELEASE_TAG" "$PYTHON_VERSION" "$TARGET" original + - name: Reassemble without signing + run: | + archive=(original/*.tar.gz) + uv run --locked scripts/rehearse-release-signing.py prepare "$SYSTEM" "${archive[0]}" "unsigned/$TARGET" + uv run --locked scripts/rehearse-release-signing.py assemble "$SYSTEM" "${archive[0]}" "unsigned/$TARGET" "assembled/$(basename "${archive[0]}")" + uv run --locked scripts/rehearse-release-signing.py check "$SYSTEM" "${archive[0]}" "unsigned/$TARGET" "assembled/$(basename "${archive[0]}")" + - name: Exercise assembled Python + run: | + mkdir installed + tar -xzf assembled/*.tar.gz -C installed + "installed/python/$PYTHON_BINARY" -I scripts/smoke-test-signed-python.py + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: signing-source-${{ matrix.target }} + path: original/*.tar.gz + if-no-files-found: error + overwrite: true + + sign: + name: Sign and reassemble / ${{ matrix.target }} + needs: prepare + if: >- + github.event_name == 'workflow_dispatch' && inputs.sign && + github.repository == 'astral-sh/python-build-standalone' && + github.ref == 'refs/heads/main' + runs-on: ${{ matrix.signer }} + environment: release + permissions: + contents: read + id-token: write + strategy: + fail-fast: false + matrix: *targets + env: + TARGET: ${{ matrix.target }} + SYSTEM: ${{ matrix.system }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + version: "0.12.12" + checksum: ${{ runner.os == 'Linux' && 'ab9b309d4586403f024e100abaceb396616e178a553e2500c36087d180f09509' || matrix.uv-checksum }} + enable-cache: false + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: signing-source-${{ matrix.target }} + path: original + - name: Prepare native code + id: prepare + run: | + archive=(original/*.tar.gz) + uv run --locked scripts/rehearse-release-signing.py prepare "$SYSTEM" "${archive[0]}" "unsigned/$TARGET" + - name: Sign and notarize macOS binaries + if: matrix.system == 'macos' + uses: astral-sh/github-actions/sign-macos@8d868a52b4aaf77e89d86505026689474cbb8747 + with: + unsigned-directory: unsigned + signed-directory: signed + entitlements-file: scripts/cpython-entitlements.plist + entitlements-binaries: ${{ steps.prepare.outputs.executables }} + azure-client-id: ${{ secrets.CODESIGN_AZURE_CLIENT_ID_MACOS }} + azure-tenant-id: ${{ secrets.CODESIGN_AZURE_TENANT_ID_MACOS }} + azure-subscription-id: ${{ secrets.CODESIGN_AZURE_SUBSCRIPTION_ID_MACOS }} + storage-account: ${{ secrets.CODESIGN_STORAGE_ACCOUNT }} + storage-container: ${{ secrets.CODESIGN_STORAGE_CONTAINER }} + component-rcodesign-blob: ${{ secrets.CODESIGN_COMPONENT_RCODESIGN_BLOB }} + component-rcodesign-sha256: ${{ secrets.CODESIGN_COMPONENT_RCODESIGN_SHA256 }} + component-pkcs11-blob: ${{ secrets.CODESIGN_COMPONENT_PKCS11_BLOB }} + component-pkcs11-sha256: ${{ secrets.CODESIGN_COMPONENT_PKCS11_SHA256 }} + azure-keyvault-name: ${{ secrets.CODESIGN_AZURE_KEYVAULT_NAME }} + azure-keyvault-key-version: ${{ secrets.CODESIGN_AZURE_KEYVAULT_KEY_VERSION }} + key-name: ${{ secrets.CODESIGN_KEY_NAME }} + certificate-sha256: ${{ secrets.CODESIGN_CERTIFICATE_SHA256 }} + apple-notarization-akv-key-name: ${{ secrets.CODESIGN_APPLE_NOTARIZATION_AKV_KEY_NAME }} + - name: Sign Windows binaries + if: matrix.system == 'windows' + uses: astral-sh/github-actions/sign-windows@8d868a52b4aaf77e89d86505026689474cbb8747 + with: + unsigned-directory: unsigned + signed-directory: signed + azure-client-id: ${{ secrets.CODESIGN_AZURE_CLIENT_ID_WINDOWS }} + azure-tenant-id: ${{ secrets.CODESIGN_AZURE_TENANT_ID_WINDOWS }} + azure-subscription-id: ${{ secrets.CODESIGN_AZURE_SUBSCRIPTION_ID_WINDOWS }} + endpoint: ${{ secrets.CODESIGN_ENDPOINT }} + signing-account-name: ${{ secrets.CODESIGN_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ secrets.CODESIGN_CERTIFICATE_PROFILE_NAME }} + certificate-subject: ${{ secrets.CODESIGN_CERTIFICATE_SUBJECT }} + - name: Reassemble signed archive + run: | + archive=(original/*.tar.gz) + uv run --locked scripts/rehearse-release-signing.py assemble "$SYSTEM" "${archive[0]}" "signed/$TARGET" "assembled/$(basename "${archive[0]}")" + uv run --locked scripts/rehearse-release-signing.py check "$SYSTEM" "${archive[0]}" "signed/$TARGET" "assembled/$(basename "${archive[0]}")" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: signed-rehearsal-${{ matrix.target }} + path: | + assembled/ + signed/ + if-no-files-found: error + overwrite: true + + verify: + name: Verify signed archive / ${{ matrix.target }} + needs: sign + runs-on: ${{ matrix.runner }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: *targets + env: + TARGET: ${{ matrix.target }} + SYSTEM: ${{ matrix.system }} + PYTHON_BINARY: ${{ matrix.python }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + version: "0.12.12" + checksum: ${{ matrix.uv-checksum }} + enable-cache: false + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: signing-source-${{ matrix.target }} + path: original + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: signed-rehearsal-${{ matrix.target }} + - name: Check complete archive contents + run: | + archive=(original/*.tar.gz) + uv run --locked scripts/rehearse-release-signing.py check "$SYSTEM" "${archive[0]}" "signed/$TARGET" "assembled/$(basename "${archive[0]}")" + uv run --locked scripts/rehearse-release-signing.py prepare "$SYSTEM" "assembled/$(basename "${archive[0]}")" packaged-binaries + - name: Verify packaged signatures + uses: astral-sh/github-actions/verify-binaries@8d868a52b4aaf77e89d86505026689474cbb8747 + with: + signed-directory: signed/${{ matrix.target }} + binaries-directory: packaged-binaries + - name: Exercise signed Python + run: | + mkdir installed + tar -xzf assembled/*.tar.gz -C installed + "installed/python/$PYTHON_BINARY" -I scripts/smoke-test-signed-python.py diff --git a/scripts/cpython-entitlements.plist b/scripts/cpython-entitlements.plist new file mode 100644 index 000000000..58598e227 --- /dev/null +++ b/scripts/cpython-entitlements.plist @@ -0,0 +1,14 @@ + + + + + + com.apple.security.cs.disable-library-validation + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + + diff --git a/scripts/rehearse-release-signing.py b/scripts/rehearse-release-signing.py new file mode 100644 index 000000000..04e31a4e9 --- /dev/null +++ b/scripts/rehearse-release-signing.py @@ -0,0 +1,265 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = [] +# +# [tool.uv] +# no-build = true +# /// +"""Prepare and reassemble PBS install-only archives for the signing rehearsal. + +Only native code for the selected platform is replaced. All other files, links, +and archive metadata are retained, and the replacement inventory must match. +The existing release publisher does not consume these experimental outputs. +""" + +import argparse +import copy +import hashlib +import io +import json +import os +import struct +import tarfile +import urllib.parse +import urllib.request +from dataclasses import dataclass +from pathlib import Path + + +@dataclass +class Member: + """One TAR member and its data, if it is a regular file.""" + + info: tarfile.TarInfo + data: bytes | None + + +def sha256(data: bytes) -> str: + """Return the digest used by GitHub release assets and PBS checksums.""" + return hashlib.sha256(data).hexdigest() + + +def download(tag: str, version: str, target: str, output: Path) -> None: + """Download one published stripped archive and verify GitHub's asset digest.""" + name = f"cpython-{version}+{tag}-{target}-install_only_stripped.tar.gz" + url = ( + "https://api.github.com/repos/astral-sh/python-build-standalone/" + f"releases/tags/{urllib.parse.quote(tag, safe='')}" + ) + headers = {"Accept": "application/vnd.github+json"} + if token := os.environ.get("GH_TOKEN"): + headers["Authorization"] = f"Bearer {token}" + with urllib.request.urlopen(urllib.request.Request(url, headers=headers)) as source: + release = json.load(source) + if release["draft"] or release["tag_name"] != tag: + raise ValueError("Expected a published release") + [asset] = [asset for asset in release["assets"] if asset["name"] == name] + with urllib.request.urlopen(asset["browser_download_url"]) as source: + data = source.read() + if asset["digest"] != f"sha256:{sha256(data)}" or asset["size"] != len(data): + raise ValueError(f"Release asset digest or size differs: {name}") + output.mkdir(parents=True) + (output / name).write_bytes(data) + print(f"Downloaded {name} ({sha256(data)})") + + +def read_archive(path: Path) -> list[Member]: + """Read a trusted install-only archive, preserving member order and metadata.""" + members = [] + names = set() + with tarfile.open(path, "r:gz") as archive: + for info in archive: + if info.name in names: + raise ValueError(f"Duplicate archive member: {info.name}") + names.add(info.name) + source = archive.extractfile(info) if info.isfile() else None + data = source.read() if source is not None else None + if source is not None: + source.close() + members.append(Member(info, data)) + return members + + +def macho_filetypes(data: bytes) -> set[int]: + """Read the Mach-O file types from a thin or universal binary's headers.""" + thin = { + b"\xce\xfa\xed\xfe": "<", + b"\xcf\xfa\xed\xfe": "<", + b"\xfe\xed\xfa\xce": ">", + b"\xfe\xed\xfa\xcf": ">", + } + if data[:4] in thin and len(data) >= 16: + return {struct.unpack_from(thin[data[:4]] + "I", data, 12)[0]} + fat = { + b"\xca\xfe\xba\xbe": (">", "I", 20), + b"\xbe\xba\xfe\xca": ("<", "I", 20), + b"\xca\xfe\xba\xbf": (">", "Q", 32), + b"\xbf\xba\xfe\xca": ("<", "Q", 32), + } + if data[:4] not in fat or len(data) < 8: + return set() + endian, width, record_size = fat[data[:4]] + count = struct.unpack_from(endian + "I", data, 4)[0] + if len(data) < 8 + count * record_size: + return set() + result = set() + for index in range(count): + offset, size = struct.unpack_from( + endian + width * 2, data, 16 + index * record_size + ) + result.update(macho_filetypes(data[offset : offset + size])) + return result + + +def is_native(data: bytes, system: str) -> bool: + """Recognize loadable Mach-O images or PE images, independent of filenames.""" + if system == "macos": + # MH_EXECUTE, MH_DYLIB, and MH_BUNDLE. Object files are not signed. + return bool(macho_filetypes(data) & {2, 6, 8}) + if data[:2] != b"MZ" or len(data) < 64: + return False + offset = struct.unpack_from(" dict[str, bytes]: + """Return every regular native-code member that this platform must sign.""" + binaries = { + member.info.name: member.data + for member in members + if member.data is not None and is_native(member.data, system) + } + if not binaries: + raise ValueError("No native binaries found") + if system == "windows" and any( + Path(name).suffix.lower() not in {".exe", ".dll", ".pyd"} for name in binaries + ): + raise ValueError("Found a PE image with an unsupported signing extension") + return binaries + + +def prepare(archive: Path, system: str, output: Path) -> None: + """Extract only native code, retaining paths used by the shared signing actions.""" + binaries = native_members(read_archive(archive), system) + output.mkdir(parents=True) + for name, data in binaries.items(): + path = output / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + path.chmod(0o755) + executables = sorted( + name for name, data in binaries.items() if 2 in macho_filetypes(data) + ) + if github_output := os.environ.get("GITHUB_OUTPUT"): + with Path(github_output).open("a", encoding="utf-8") as file: + file.write(f"executables={json.dumps(executables)}\n") + print(f"Prepared {len(binaries)} {system} binaries from {archive.name}") + + +def replacements( + directory: Path, expected: dict[str, bytes], system: str +) -> dict[str, bytes]: + """Require exactly the prepared native-code paths in the signing output.""" + found = { + path.relative_to(directory).as_posix(): path.read_bytes() + for path in directory.rglob("*") + if path.is_file() + and not (system == "macos" and path == directory / "certificate.pem") + } + if found.keys() != expected.keys(): + raise ValueError( + f"Signing inventory differs: missing={sorted(expected.keys() - found.keys())}, " + f"unexpected={sorted(found.keys() - expected.keys())}" + ) + if any(not is_native(data, system) for data in found.values()): + raise ValueError("Signing output contains a non-native file") + return found + + +def assemble(archive: Path, system: str, signed: Path, output: Path) -> None: + """Replace all native code and write a new archive with its checksum sidecar.""" + members = read_archive(archive) + binaries = replacements(signed, native_members(members, system), system) + output.parent.mkdir(parents=True, exist_ok=True) + if output.exists(): + raise FileExistsError(output) + with tarfile.open(output, "w:gz") as destination: + for member in members: + info = copy.copy(member.info) + data = binaries.get(info.name, member.data) + if data is not None: + info.size = len(data) + if "size" in info.pax_headers: + info.pax_headers = {**info.pax_headers, "size": str(len(data))} + destination.addfile(info, io.BytesIO(data) if data is not None else None) + output.with_name(f"{output.name}.sha256").write_text( + f"{sha256(output.read_bytes())} {output.name}\n", encoding="utf-8" + ) + + +def check(archive: Path, system: str, signed: Path, assembled: Path) -> None: + """Check every member's bytes and metadata against its expected replacement.""" + before = read_archive(archive) + after = read_archive(assembled) + binaries = replacements(signed, native_members(before, system), system) + if len(before) != len(after): + raise ValueError("Archive member count changed") + for original, actual in zip(before, after, strict=True): + expected_data = binaries.get(original.info.name, original.data) + expected_info = original.info.get_info() + actual_info = actual.info.get_info() + # TAR header checksums depend on the header encoding, not its metadata. + del expected_info["chksum"], actual_info["chksum"] + if expected_data is not None: + expected_info["size"] = len(expected_data) + if actual.data != expected_data or actual_info != expected_info: + raise ValueError( + f"Archive member changed unexpectedly: {original.info.name}" + ) + # These PAX fields are already reflected in the metadata checked above. + fields = {"path", "linkpath", "size", "mtime", "uid", "gid", "uname", "gname"} + expected_pax = { + k: v for k, v in original.info.pax_headers.items() if k not in fields + } + actual_pax = { + k: v for k, v in actual.info.pax_headers.items() if k not in fields + } + if actual_pax != expected_pax: + raise ValueError(f"Archive extended metadata changed: {original.info.name}") + if assembled.with_name(f"{assembled.name}.sha256").read_text().split() != [ + sha256(assembled.read_bytes()), + assembled.name, + ]: + raise ValueError("Assembled archive checksum differs") + print(f"Checked all {len(after)} members of {assembled.name}") + + +def main() -> None: + """Run one phase of the release-signing rehearsal.""" + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + fetch = commands.add_parser("download") + fetch.add_argument("tag") + fetch.add_argument("version") + fetch.add_argument("target") + fetch.add_argument("output", type=Path) + for name in ("prepare", "assemble", "check"): + command = commands.add_parser(name) + command.add_argument("system", choices=("macos", "windows")) + command.add_argument("archive", type=Path) + if name != "prepare": + command.add_argument("signed", type=Path) + command.add_argument("output", type=Path) + args = parser.parse_args() + if args.command == "download": + download(args.tag, args.version, args.target, args.output) + elif args.command == "prepare": + prepare(args.archive, args.system, args.output) + elif args.command == "assemble": + assemble(args.archive, args.system, args.signed, args.output) + else: + check(args.archive, args.system, args.signed, args.output) + + +if __name__ == "__main__": + main() diff --git a/scripts/rehearse-release-signing.py.lock b/scripts/rehearse-release-signing.py.lock new file mode 100644 index 000000000..7518fc90b --- /dev/null +++ b/scripts/rehearse-release-signing.py.lock @@ -0,0 +1,3 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" diff --git a/scripts/smoke-test-signed-python.py b/scripts/smoke-test-signed-python.py new file mode 100644 index 000000000..d586e0932 --- /dev/null +++ b/scripts/smoke-test-signed-python.py @@ -0,0 +1,56 @@ +"""Exercise an assembled Python's native modules, callbacks, venv, and pip.""" + +import ctypes +import sqlite3 +import ssl +import subprocess +import sys +import tempfile +import tkinter +import venv +from pathlib import Path + + +def main() -> None: + """Check native loading, executable memory, and a third-party extension wheel.""" + print(sys.version) + print(ssl.OPENSSL_VERSION) + assert sqlite3.connect(":memory:").execute("select 42").fetchone() == (42,) + print(tkinter.Tcl().eval("info patchlevel")) + callback = ctypes.CFUNCTYPE(ctypes.c_int)(lambda: 42) + assert callback() == 42 + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + venv.EnvBuilder(with_pip=True).create(directory) + python = directory / ( + "Scripts/python.exe" if sys.platform == "win32" else "bin/python" + ) + subprocess.run( + [ + python, + "-m", + "pip", + "--isolated", + "--disable-pip-version-check", + "install", + "--index-url", + "https://pypi.org/simple", + "--no-cache-dir", + "--only-binary=:all:", + "cffi", + ], + check=True, + ) + subprocess.run( + [ + python, + "-I", + "-c", + "import _cffi_backend; from cffi import FFI; f = FFI().callback('int(void)', lambda: 42); assert f() == 42", + ], + check=True, + ) + + +if __name__ == "__main__": + main()