From ff8f6327d7bdcbaf2bf16ef43bdcec61d78969c7 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Wed, 29 Jul 2026 11:26:20 -0700 Subject: [PATCH 1/2] Provider-sign every published Apple XCFramework Xcode records the state of each .xcframework an app links against AS ITS PUBLISHER SHIPPED IT and writes that into the IPA as Signatures/.xcframework-.signature. Everything we publish was unsigned, so those receipts read `signed = false` / `isSecureTimestamp = false` -- which is what Apple's App Store scan reports as ITMS-91065: Missing signature (flet-dev/flet#6724). The app's own signature does not fill this in: Xcode re-signs the embedded copy with the submitting team's identity, and the SDK-origin receipt is a separate record. Stable provider-owned identifiers (darwin/xcframework_identifiers.sh) A framework's CFBundleIdentifier becomes its code-signing identifier and is baked in here, long before anyone knows which app will embed it. It must therefore not depend on the consuming app -- serious_python used to rewrite it downstream, which edits an Info.plist inside a signed bundle and destroys the very signature the receipt reports on. The Python runtime is now dev.flet.python.runtime and each stdlib extension dev.flet.python., replacing the shared org.python.*. Leading hyphens are kept (`_ssl` -> `dev.flet.python.-ssl`), matching CPython's own convention, so `_ssl` cannot collide with a hypothetical `ssl`. Validation asserts every identifier is syntactically valid, unique across the set, identical across device and simulator slices, and provider-prefixed. Signing (darwin/xcframework_signing.sh, darwin/sign_darwin_archives.sh) Signing must be the last thing that happens to a bundle and it needs a release certificate. Doing it on the finished archives satisfies both: by the time an archive exists, every mutation -- install names, plists, privacy manifests, header overlays, pruning, stripping, bytecode compilation -- is done, and the certificate never has to be present in the build matrix that runs on every push and PR. Per archive: extract, validate identifiers, sign every outer xcframework and verify each immediately, re-pack, then extract the re-packed archive into a fresh directory and verify again. The re-pack preserves the original member-path shape ("./install/..." vs "install/...") because consumers extract with --wildcards patterns that match one and not the other. The outer seal is stamped with an explicit `-i`, read off the xcframework's own inner framework: an .xcframework root Info.plist is an XFWK manifest with no CFBundleIdentifier, so codesign would otherwise fall back to the bundle's file name. Verification asserts an outer _CodeSignature/CodeResources, `codesign --verify --strict`, a non-ad-hoc signature, a real Timestamp= (not the self-asserted Signed Time=), the expected authority and TeamIdentifier, and that the seal's identifier matches the inner framework's. No --deep, no --timestamp=none. package-ios-for-dart.sh additionally asserts the _ssl and _hashlib privacy manifests survived into both slices -- a silent miss there surfaces as an App Store rejection, not a build error. CI sign-darwin-artifacts is a release-only job in the protected `release-signing` environment, gated on a workflow_dispatch with a release_date from main. It imports the p12 into a temporary keychain without -A (only codesign and security may reach the key), derives exactly one identity fingerprint and fails on any other count, and deletes the keychain in an always() step. The unsigned build artifact is renamed darwin-unsigned-* rather than python-darwin-*, specifically so no `python-*` download pattern in publish-release can reach it. publish-release now downloads Android/Linux/Windows by explicit pattern plus the python-darwin-signed bundle, is gated on main like the signing job, and fails if the expected Darwin tarballs are absent. The mobile-forge Python.xcframework gets its identifier immediately after build_ios.py finishes -- that script strips and rewrites inside the bundle, so it is the first moment the artifact is final. Local builds without credentials still work and produce unsigned artifacts. REQUIRE_XCFRAMEWORK_SIGNATURE=1, set on the release path, turns every missing credential, missing timestamp, wrong team, or empty archive into a hard failure. --- .github/workflows/build-python-version.yml | 17 +- .github/workflows/build-python.yml | 167 +++++++++++- README.md | 35 +++ darwin/package-ios-for-dart.sh | 24 ++ darwin/package-macos-for-dart.sh | 11 + darwin/sign_darwin_archives.sh | 100 +++++++ darwin/xcframework_identifiers.sh | 201 ++++++++++++++ darwin/xcframework_signing.sh | 298 +++++++++++++++++++++ darwin/xcframework_utils.sh | 19 +- 9 files changed, 858 insertions(+), 14 deletions(-) create mode 100644 darwin/sign_darwin_archives.sh create mode 100644 darwin/xcframework_identifiers.sh create mode 100644 darwin/xcframework_signing.sh diff --git a/.github/workflows/build-python-version.yml b/.github/workflows/build-python-version.yml index 5af4562..16ead53 100644 --- a/.github/workflows/build-python-version.yml +++ b/.github/workflows/build-python-version.yml @@ -58,6 +58,14 @@ jobs: # iOS/README.rst, 3.12 uses beeware. Emits normalized ./install + ./support. python build_ios.py "$PYTHON_VERSION" + # build_ios.py mutates the CPython-created Python.xcframework after assembly + # (sysconfigdata rewrites, `strip -x` of every slice binary), so this is the + # first moment the bundle is final. Give it its stable provider identifier + # here — the release signing job seals it, and a plist edit afterwards would + # invalidate that seal. + bash ./xcframework_identifiers.sh set \ + "support/$PYTHON_VERSION_SHORT/iOS/Python.xcframework" dev.flet.python.runtime + # mobile-forge artifact: iOS-only install+support tree (same structure as before). # Captured before the macOS build also writes into ./support / ./install. tar -czf dist/python-ios-mobile-forge-$PYTHON_VERSION.tar.gz install support @@ -73,10 +81,15 @@ jobs: bash ./package-ios-for-dart.sh . "$PYTHON_VERSION" bash ./package-macos-for-dart.sh . "$PYTHON_VERSION" - - name: Upload Darwin build artifacts + # Deliberately NOT named `python-darwin-*`: the XCFrameworks in these + # tarballs are unsigned, and the publish job selects release assets with + # `python-*` patterns. Keeping the unsigned Darwin build outside that + # namespace makes it structurally impossible for it to be published by + # accident — sign-darwin-artifacts re-emits them as `python-darwin-signed`. + - name: Upload Darwin build artifacts (unsigned) uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: python-darwin-${{ env.PYTHON_VERSION }} + name: darwin-unsigned-${{ env.PYTHON_VERSION }} path: darwin/dist/python-*.tar.gz if-no-files-found: error diff --git a/.github/workflows/build-python.yml b/.github/workflows/build-python.yml index b05d800..930d479 100644 --- a/.github/workflows/build-python.yml +++ b/.github/workflows/build-python.yml @@ -52,17 +52,134 @@ jobs: with: python_version: ${{ matrix.python_version }} + # Release-only, isolated signing job. The provider certificate is never + # available to build-matrix (which runs on every push and PR); it lives in the + # protected `release-signing` environment and is only reachable from an + # explicit release dispatch on main. + # + # Signing operates on the finished archives rather than inside the build, which + # guarantees the required ordering: every mutation (install names, plists, + # privacy manifests, headers, pruning, stripping) is already done by the time + # an archive exists. The script re-packs and re-verifies after a round trip. + sign-darwin-artifacts: + name: Provider-sign Darwin XCFrameworks + runs-on: macos-26 + if: >- + github.event_name == 'workflow_dispatch' + && inputs.release_date != '' + && github.ref == 'refs/heads/main' + needs: + - build-matrix + environment: release-signing + env: + XCFRAMEWORK_EXPECTED_TEAM_ID: ${{ vars.XCFRAMEWORK_EXPECTED_TEAM_ID }} + # Missing credentials, a missing secure timestamp, a wrong team, or an + # archive containing zero XCFrameworks all fail the release here. + REQUIRE_XCFRAMEWORK_SIGNATURE: '1' + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + + - name: Download unsigned Darwin archives + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + pattern: darwin-unsigned-* + path: unsigned + merge-multiple: true + + - name: Import Apple Distribution certificate into a temporary keychain + env: + CERT_P12_BASE64: ${{ secrets.APPLE_DISTRIBUTION_CERT_P12_BASE64 }} + CERT_P12_PASSWORD: ${{ secrets.APPLE_DISTRIBUTION_CERT_P12_PASSWORD }} + run: | + set -euo pipefail + : "${CERT_P12_BASE64:?APPLE_DISTRIBUTION_CERT_P12_BASE64 is not set}" + : "${CERT_P12_PASSWORD:?APPLE_DISTRIBUTION_CERT_P12_PASSWORD is not set}" + + KEYCHAIN_PATH="$RUNNER_TEMP/xcframework-signing.keychain-db" + CERT_PATH="$RUNNER_TEMP/xcframework-signing.p12" + # Ephemeral: the keychain lives for this job only and is deleted in the + # always-run cleanup step, so the password never leaves this step. + KEYCHAIN_PASSWORD=$(openssl rand -base64 24) + + printf '%s' "$CERT_P12_BASE64" | base64 --decode > "$CERT_PATH" + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + # No -A: the private key is reachable only by the two Apple tools named + # below, not by any process that happens to run in this job. + security import "$CERT_PATH" -k "$KEYCHAIN_PATH" -P "$CERT_P12_PASSWORD" \ + -f pkcs12 -T /usr/bin/codesign -T /usr/bin/security + security set-key-partition-list -S apple-tool:,apple: -s \ + -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" >/dev/null + + # codesign resolves an identity through the search list even when + # --keychain is passed, so prepend ours to the user list. + security list-keychains -d user -s "$KEYCHAIN_PATH" \ + $(security list-keychains -d user | tr -d '"') + + # Derive EXACTLY ONE fingerprint. Selecting by display name is + # ambiguous when a keychain holds more than one matching certificate, + # and codesign then picks arbitrarily; a hard count check turns a + # multi-certificate .p12 into a build failure instead of a coin flip. + IDENTITIES=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH") + echo "$IDENTITIES" + FPRS=$(printf '%s\n' "$IDENTITIES" \ + | sed -n 's/^ *[0-9]*) \([0-9A-F]\{40\}\) .*/\1/p' | sort -u) + COUNT=$(printf '%s' "$FPRS" | grep -c . || true) + if [ "$COUNT" -ne 1 ]; then + echo "::error::expected exactly 1 codesigning identity in the imported keychain, found $COUNT" + exit 1 + fi + + # Fingerprint and keychain path are not secrets. + echo "XCFRAMEWORK_CODESIGN_IDENTITY=$FPRS" >> "$GITHUB_ENV" + echo "XCFRAMEWORK_SIGNING_KEYCHAIN=$KEYCHAIN_PATH" >> "$GITHUB_ENV" + + - name: Sign and re-pack Darwin archives + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + archives=(unsigned/python-*.tar.gz) + if [ "${#archives[@]}" -eq 0 ]; then + echo "::error::no Darwin archives to sign" + exit 1 + fi + bash darwin/sign_darwin_archives.sh signed "${archives[@]}" + + - name: Upload signed Darwin archives + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: python-darwin-signed + path: signed/python-*.tar.gz + if-no-files-found: error + + - name: Remove temporary keychain and certificate + if: always() + run: | + security delete-keychain "$RUNNER_TEMP/xcframework-signing.keychain-db" 2>/dev/null || true + rm -f "$RUNNER_TEMP/xcframework-signing.p12" + publish-release: name: Publish Release Assets runs-on: ubuntu-latest # Date-keyed releases (PBS-style): only publish when an operator explicitly - # triggers via workflow_dispatch with a `release_date` input. Pushes still - # exercise the matrix but leave per-job artifacts for inspection and do - # not touch GitHub releases. - if: github.event_name == 'workflow_dispatch' && inputs.release_date != '' + # triggers via workflow_dispatch with a `release_date` input, and only from + # the protected main branch — the same condition that gates real signing, so + # a release can never be assembled from artifacts that were never signed. + if: >- + github.event_name == 'workflow_dispatch' + && inputs.release_date != '' + && github.ref == 'refs/heads/main' needs: - setup - build-matrix + - sign-darwin-artifacts permissions: contents: write steps: @@ -71,13 +188,51 @@ jobs: with: persist-credentials: false - - name: Download all build artifacts + # Downloaded by explicit pattern, never `python-*` wholesale: the unsigned + # Darwin build artifacts are named `darwin-unsigned-*` precisely so no + # pattern here can reach them. The only Darwin tarballs that enter the + # release directory are the ones sign-darwin-artifacts produced. + - name: Download Android build artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + pattern: python-android-* + path: release-artifacts + merge-multiple: true + + - name: Download Linux build artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - pattern: python-* + pattern: python-linux-* path: release-artifacts merge-multiple: true + - name: Download Windows build artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + pattern: python-windows-* + path: release-artifacts + merge-multiple: true + + - name: Download signed Darwin artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: python-darwin-signed + path: release-artifacts + + - name: Assert the release payload carries the signed Darwin tarballs + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + ls -lh release-artifacts + for kind in python-ios-dart python-macos-dart python-ios-mobile-forge; do + found=(release-artifacts/$kind-*.tar.gz) + if [ "${#found[@]}" -eq 0 ]; then + echo "::error::no $kind-*.tar.gz in the release payload" + exit 1 + fi + done + - name: Add runtime manifest (with release date) to the release # Publish the same manifest.json that drove this build, with the release # date injected, so consumers can fetch a consistent version set by date. diff --git a/README.md b/README.md index d15bd79..785a58b 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,41 @@ Releases are date-keyed (`YYYYMMDD`) and cut manually: A push without a `release_date` still exercises the full matrix but publishes no release (per-job artifacts only). +### Apple XCFramework signing + +Every `.xcframework` in the Darwin tarballs is provider-signed with the Flet +publishing team's Apple Distribution identity and a secure timestamp. + +This matters because Xcode records the state of each `.xcframework` an app links +against **as its publisher shipped it**, and writes the result into the IPA as +`Signatures/.xcframework-ios.signature`. Unsigned artifacts make those +receipts read `signed = false` / `isSecureTimestamp = false`, which Apple's App +Store scan reports as `ITMS-91065: Missing signature`. The app's own signature is +a separate thing and does not fill it in. + +Signing runs in `sign-darwin-artifacts`, a release-only job gated on a +`workflow_dispatch` with a `release_date` **from `main`**, using the protected +`release-signing` environment. It downloads the finished Darwin archives, signs +every xcframework inside them, re-packs, and re-verifies after extraction — so the +certificate is never present in the build matrix that runs on pushes and PRs, +while the "sign only after every mutation" ordering is still guaranteed. + +The unsigned build artifacts are named `darwin-unsigned-*` rather than +`python-darwin-*` specifically so that no `python-*` download pattern in the +publish job can reach them. `publish-release` downloads Android/Linux/Windows +artifacts by explicit pattern plus the `python-darwin-signed` bundle, and fails if +the expected Darwin tarballs are absent. + +Framework bundle identifiers are assigned here, before signing, and are +publisher-owned and stable: `dev.flet.python.runtime` for the runtime and +`dev.flet.python.` for each stdlib extension. They must not depend on the +consuming app — see `darwin/xcframework_identifiers.sh`, which validates that they +are unique, syntactically valid, and consistent across device and simulator slices. + +Local builds without signing credentials still work and produce unsigned artifacts; +`REQUIRE_XCFRAMEWORK_SIGNATURE=1` (set on the release path) turns every missing +credential, missing timestamp, wrong team, or empty archive into a hard failure. + ## Consumers - **serious_python** pins a release date, fetches that release's `manifest.json`, diff --git a/darwin/package-ios-for-dart.sh b/darwin/package-ios-for-dart.sh index 10d0212..375984d 100755 --- a/darwin/package-ios-for-dart.sh +++ b/darwin/package-ios-for-dart.sh @@ -44,6 +44,13 @@ rm -rf $frameworks_dir/Python.xcframework/lib cp -r $script_dir/Modules $frameworks_dir/Python.xcframework/ios-arm64/Python.framework cp -r $script_dir/Modules $frameworks_dir/Python.xcframework/ios-arm64_x86_64-simulator/Python.framework +# Stable, provider-owned identifier for the Python runtime, replacing CPython's +# shared `org.python.python`. Assigned here — while the xcframework is still +# unsigned — because the downstream signing job seals the bundle and any later +# plist edit invalidates that seal. serious_python used to rewrite this to the +# consuming app's bundle id; it no longer does, and must not. +xcf_set_framework_identifier "$frameworks_dir/Python.xcframework" "$XCF_PYTHON_RUNTIME_IDENTIFIER" + # copy stdlibs for arch in "${archs[@]}"; do rsync -av --exclude-from=$script_dir/python-darwin-stdlib.exclude $python_apple_support_root/install/iOS/$arch/python-*/lib/python$python_version_short/* $stdlib_dir/$arch @@ -66,6 +73,23 @@ find "$stdlib_dir/${archs[0]}/lib-dynload" -name "*.$dylib_ext" | while read ful #break # run for one lib only - for tests done +# The privacy manifests are copied into the frameworks by +# create_xcframework_from_dylibs above. Apple's scan reads them out of the +# embedded framework, so a silent miss here surfaces much later as an App Store +# rejection — assert instead. +for _pm in _ssl _hashlib; do + for _slice in ios-arm64 ios-arm64_x86_64-simulator; do + _pm_path="$python_frameworks_dir/$_pm.xcframework/$_slice/$_pm.framework/PrivacyInfo.xcprivacy" + [ -f "$_pm_path" ] || { echo "missing privacy manifest: $_pm_path"; exit 1; } + done +done +echo "Privacy manifests present for _ssl and _hashlib" + +# Last plist-touching step before the archive: every identifier must be valid, +# unique, consistent across slices, and provider-owned. Anything that fails here +# would otherwise be signed as-is and only diagnosed from an IPA. +xcf_validate_identifiers "$frameworks_dir" "$python_frameworks_dir" + mv $stdlib_dir/${archs[0]}/* $stdlib_dir # cleanup diff --git a/darwin/package-macos-for-dart.sh b/darwin/package-macos-for-dart.sh index fa04588..cc608ff 100755 --- a/darwin/package-macos-for-dart.sh +++ b/darwin/package-macos-for-dart.sh @@ -6,6 +6,9 @@ python_version=${2:?} script_dir=$(dirname $(realpath $0)) +# shellcheck source=darwin/xcframework_identifiers.sh +. "$script_dir/xcframework_identifiers.sh" + # build short Python version read python_version_major python_version_minor < <(echo $python_version | sed -E 's/^([0-9]+)\.([0-9]+).*/\1 \2/') python_version_short=$python_version_major.$python_version_minor @@ -38,6 +41,14 @@ cp -r $python_apple_support_root/support/$python_version_short/macOS/Python.xcfr # overlaid darwin/Modules/module.modulemap above; -f tolerates builds without one. rm -f $frameworks_dir/Python.xcframework/macos-arm64_x86_64/Python.framework/Headers/module.modulemap +# Last mutation of the bundle: stable, provider-owned identifier for the Python +# runtime, replacing CPython's shared `org.python.python`. The macOS framework is +# versioned, so this writes Versions//Resources/Info.plist and leaves the +# nested Python.app's own identifier alone. Must precede signing — see +# xcframework_identifiers.sh. +xcf_set_framework_identifier "$frameworks_dir/Python.xcframework" "$XCF_PYTHON_RUNTIME_IDENTIFIER" +xcf_validate_identifiers "$frameworks_dir" + # copy stdlibs rsync -av --exclude-from=$script_dir/python-darwin-stdlib.exclude $python_apple_support_root/install/macOS/macosx/python-*/Python.framework/Versions/Current/lib/python$python_version_short/* $stdlib_dir diff --git a/darwin/sign_darwin_archives.sh b/darwin/sign_darwin_archives.sh new file mode 100644 index 0000000..1f7068f --- /dev/null +++ b/darwin/sign_darwin_archives.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +set -euo pipefail +# +# sign_darwin_archives.sh ... +# +# Provider-sign every outer XCFramework inside already-built Darwin archives and +# emit re-packed, verified copies into . +# +# WHY OPERATE ON THE ARCHIVES AND NOT INSIDE THE BUILD +# Signing must be the LAST thing that happens to a bundle, and it needs a +# release certificate. Doing it here means the certificate never has to be +# present in the general build matrix (which runs on every push and PR), while +# still guaranteeing the ordering: by the time an archive exists, every +# mutation — install names, Info.plists, privacy manifests, header overlays, +# pruning, stripping, bytecode compilation — is finished. +# +# WHAT IT DOES per archive +# 1. extract into a scratch directory +# 2. validate bundle identifiers (valid, unique, per-slice consistent, and +# provider-owned — never derived from a consuming app) +# 3. sign every outer *.xcframework, verifying each immediately +# 4. re-pack, preserving the original member-path shape +# 5. extract the re-packed archive into a FRESH directory and verify again +# +# Step 5 is the one that matters: consumers get the tarball, not the directory +# we signed, so an archiving step that mangles symlinks or permissions must fail +# here rather than in someone's app. +# +# Environment: see xcframework_signing.sh. Release runs set +# REQUIRE_XCFRAMEWORK_SIGNATURE=1, which turns every missing credential, missing +# timestamp, wrong team, or empty archive into a hard failure. + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) +# shellcheck source=darwin/xcframework_signing.sh +. "$script_dir/xcframework_signing.sh" +# shellcheck source=darwin/xcframework_identifiers.sh +. "$script_dir/xcframework_identifiers.sh" + +out_dir=${1:?usage: sign_darwin_archives.sh ...} +shift +[ "$#" -gt 0 ] || { echo "sign_darwin_archives.sh: no archives given" >&2; exit 1; } + +mkdir -p "$out_dir" +out_dir=$(cd "$out_dir" && pwd -P) + +preflight_rc=0 +xcf_signing_preflight || preflight_rc=$? +[ "$preflight_rc" -le 1 ] || exit 1 + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +for archive in "$@"; do + [ -f "$archive" ] || { echo "sign_darwin_archives.sh: not a file: $archive" >&2; exit 1; } + base=$(basename "$archive") + echo "=== $base ===" + + src="$work/src" + rm -rf "$src"; mkdir -p "$src" + tar -xzf "$archive" -C "$src" + + # Re-packing must reproduce the original member paths. `tar -czf ... -C dir .` + # writes "./install/..." while `tar -czf ... install support` writes + # "install/...", and consumers that extract with --wildcards patterns (e.g. + # dart-bridge's CI pulling "install/android/...") match one shape and not the + # other. Read which shape this archive uses instead of guessing. + listing="$work/listing.txt" + tar -tzf "$archive" > "$listing" + if head -1 "$listing" | grep -q '^\./'; then + dot_prefixed=1 + else + dot_prefixed=0 + fi + + xcf_validate_identifiers "$src" + xcf_sign_tree "$src" + + if [ "$dot_prefixed" -eq 1 ]; then + ( cd "$src" && COPYFILE_DISABLE=1 tar -czf "$out_dir/$base" . ) + else + # Explicit top-level members, so the archive keeps its original entry + # names. Dotfiles at the root would be missed by a bare `*`, so enumerate. + tops=() + while IFS= read -r top; do + [ -n "$top" ] && tops+=("$top") + done < <(cd "$src" && ls -A) + [ "${#tops[@]}" -gt 0 ] || { echo "sign_darwin_archives.sh: $base extracted to nothing" >&2; exit 1; } + ( cd "$src" && COPYFILE_DISABLE=1 tar -czf "$out_dir/$base" -- "${tops[@]}" ) + fi + + rt="$work/roundtrip" + rm -rf "$rt"; mkdir -p "$rt" + tar -xzf "$out_dir/$base" -C "$rt" + xcf_verify_tree "$rt" + + echo "=== $base: done -> $out_dir/$base ===" +done + +echo "Signed archives in $out_dir:" +ls -lh "$out_dir" diff --git a/darwin/xcframework_identifiers.sh b/darwin/xcframework_identifiers.sh new file mode 100644 index 0000000..e86ef2a --- /dev/null +++ b/darwin/xcframework_identifiers.sh @@ -0,0 +1,201 @@ +#!/usr/bin/env bash +# +# Stable, provider-owned bundle identifiers for the XCFrameworks this repo +# publishes — and the validation that keeps them that way. +# +# WHY +# A framework's CFBundleIdentifier becomes its code-signing identifier, and it +# is baked in here, long before anyone knows which app will embed it. Two +# properties matter: +# +# * It must NOT depend on the consuming application's bundle id. Rewriting +# it downstream (as serious_python used to) edits an Info.plist inside an +# already-signed XCFramework and destroys the provider signature — the +# exact thing the IPA's Signatures/ receipts report on. +# * It must be unique per framework. `_ssl` and `_hashlib` shipping the same +# identifier is the kind of collision that only shows up as a confusing +# App Store validation error. +# +# `dev.flet.python.*` is owned by the Flet publishing team, so one identifier +# is correct in every app forever. +# +# NAMING +# Module name with every character outside [A-Za-z0-9-] mapped to '-'. Leading +# hyphens are KEPT: `_ssl` -> `dev.flet.python.-ssl`. That is what CPython's own +# iOS support emits (Python.xcframework/build/utils.sh does `tr "_" "-"` and +# nothing else) and what serious_python has been shipping; stripping the hyphen +# would silently merge `_ssl` with a hypothetical `ssl`. +# +# CLI: +# xcframework_identifiers.sh set +# xcframework_identifiers.sh validate ... + +# Prefix every identifier this repo mints must start with. Asserted by +# xcf_validate_identifiers, which is what catches an app-scoped identifier +# sneaking back in. +XCF_IDENTIFIER_PREFIX=${XCF_IDENTIFIER_PREFIX:-dev.flet.} + +# The Python runtime's stable identifier, shared by every slice of every +# Python.xcframework we publish (iOS and macOS). +XCF_PYTHON_RUNTIME_IDENTIFIER=${XCF_PYTHON_RUNTIME_IDENTIFIER:-dev.flet.python.runtime} + +xcfid_err() { echo "xcframework-identifiers: ERROR: $*" >&2; } + +# CFBundleIdentifier grammar: dot-separated, non-empty components of +# [A-Za-z0-9-]. +xcf_identifier_is_valid() { + printf '%s' "$1" | grep -Eq '^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*$' +} + +# Turn an arbitrary module name into one identifier component. +xcf_identifier_component() { + printf '%s' "$1" | tr '_' '-' | sed 's/[^A-Za-z0-9-]/-/g' +} + +# Emit the path of every slice's OWN framework Info.plist inside an xcframework. +# +# Deliberately not a blanket `find -name Info.plist`: the macOS Python.framework +# carries a nested Python.app whose Info.plist must not be touched, and the +# xcframework's own root Info.plist is a manifest, not a bundle descriptor. +# Versions/Current is a symlink to the real version directory — skipped so each +# plist is visited once. +xcf_framework_plists() { + local xcf=$1 + local name + name=$(basename "$xcf" .xcframework) + + local slice p + for slice in "$xcf"/*/; do + [ -d "$slice$name.framework" ] || continue + # Flat (iOS) layout. + [ -f "$slice$name.framework/Info.plist" ] && printf '%s\n' "$slice$name.framework/Info.plist" + # Versioned (macOS) layout. + for p in "$slice$name.framework"/Versions/*/Resources/Info.plist; do + [ -f "$p" ] || continue + case "$p" in */Versions/Current/*) continue ;; esac + printf '%s\n' "$p" + done + done +} + +# Assign one identifier to every slice of an xcframework. +# +# MUST run before the xcframework is signed — a plist edit afterwards +# invalidates the seal. +xcf_set_framework_identifier() { + local xcf=$1 identifier=$2 + + [ -d "$xcf" ] || { xcfid_err "not a directory: $xcf"; return 1; } + if ! xcf_identifier_is_valid "$identifier"; then + xcfid_err "'$identifier' is not a valid CFBundleIdentifier" + return 1 + fi + + local plists count=0 plist err + plists=$(xcf_framework_plists "$xcf") + while IFS= read -r plist; do + [ -n "$plist" ] || continue + if ! err=$(plutil -replace CFBundleIdentifier -string "$identifier" "$plist" 2>&1); then + xcfid_err "plutil failed for $plist: $err" + return 1 + fi + count=$((count + 1)) + done < $identifier ($count slice plist(s))" +} + +# Validate every xcframework below the given roots: +# * each slice's identifier is syntactically valid +# * all slices of one xcframework agree (device and simulator must match, or +# the two halves sign as different bundles) +# * identifiers are unique across the whole set +# * identifiers carry the provider prefix, i.e. do not depend on any app +xcf_validate_identifiers() { + local list seen status=0 count=0 xcf name plists plist ident first + list=$(mktemp) + seen=$(mktemp) + + local root + for root in "$@"; do + [ -e "$root" ] || continue + find "$root" -name '*.xcframework' -prune -print0 + done > "$list" + + while IFS= read -r -d '' xcf; do + name=$(basename "$xcf" .xcframework) + plists=$(xcf_framework_plists "$xcf") + first="" + local slice_count=0 + while IFS= read -r plist; do + [ -n "$plist" ] || continue + ident=$(plutil -extract CFBundleIdentifier raw -o - "$plist" 2>/dev/null || true) + if [ -z "$ident" ]; then + xcfid_err "$plist: no CFBundleIdentifier" + status=1; continue + fi + if ! xcf_identifier_is_valid "$ident"; then + xcfid_err "$plist: '$ident' is not a valid CFBundleIdentifier" + status=1 + fi + case "$ident" in + "$XCF_IDENTIFIER_PREFIX"*) ;; + *) xcfid_err "$plist: '$ident' does not start with '$XCF_IDENTIFIER_PREFIX';" \ + "provider identifiers must not depend on the consuming app" + status=1 ;; + esac + if [ -z "$first" ]; then + first=$ident + elif [ "$ident" != "$first" ]; then + xcfid_err "$xcf: slices disagree on CFBundleIdentifier ('$first' vs '$ident')" + status=1 + fi + slice_count=$((slice_count + 1)) + done <> "$seen" + fi + fi + count=$((count + 1)) + done < "$list" + + rm -f "$list" "$seen" + + if [ "$count" -eq 0 ]; then + xcfid_err "no *.xcframework found under: $*" + return 1 + fi + [ "$status" -eq 0 ] || return 1 + echo "xcframework-identifiers: validated $count xcframework(s) under: $*" +} + +# CLI entry point when executed rather than sourced. +if [ "${BASH_SOURCE[0]:-}" = "$0" ]; then + set -euo pipefail + cmd=${1:?usage: xcframework_identifiers.sh set | validate ...} + shift + case "$cmd" in + set) xcf_set_framework_identifier "$@" ;; + validate) xcf_validate_identifiers "$@" ;; + *) echo "xcframework_identifiers.sh: unknown command '$cmd'" >&2; exit 2 ;; + esac +fi diff --git a/darwin/xcframework_signing.sh b/darwin/xcframework_signing.sh new file mode 100644 index 0000000..5aa88c8 --- /dev/null +++ b/darwin/xcframework_signing.sh @@ -0,0 +1,298 @@ +#!/usr/bin/env bash +# +# Provider-signing helpers for Apple XCFrameworks. Source this file; it defines +# functions only and never signs anything on its own. +# +# WHY THIS EXISTS +# Xcode records the state of every XCFramework an app links against at the +# moment it is consumed, and writes the result into the IPA's top-level +# `Signatures/.xcframework-.signature` receipts. If the +# XCFramework we publish is unsigned, that receipt says `signed = false` / +# `isSecureTimestamp = false` no matter how the app itself is signed — +# app-signing the embedded inner framework does NOT retroactively supply an +# SDK-origin signature. Apple's App Store scan reports the gap as +# `ITMS-91065: Missing signature`. +# See https://developer.apple.com/documentation/Xcode/verifying-the-origin-of-your-xcframeworks +# +# ORDER MATTERS +# Signing seals the whole bundle by content hash. Every mutation — install +# names, Info.plists, privacy manifests, headers, pruning, stripping — must be +# finished BEFORE the outer `.xcframework` is signed, and nothing inside it may +# change afterwards. Sign last, verify, archive, then verify again after an +# archive round trip. +# +# INPUTS (environment) +# XCFRAMEWORK_CODESIGN_IDENTITY Certificate SHA-1 fingerprint of the Apple +# Distribution identity to sign with. A +# fingerprint, not a display name: display +# names are ambiguous when a keychain holds +# more than one matching certificate, and +# codesign then picks arbitrarily. +# XCFRAMEWORK_SIGNING_KEYCHAIN Path to the keychain holding that identity. +# Optional; the default search list is used +# when unset. +# XCFRAMEWORK_EXPECTED_TEAM_ID 10-character Team ID asserted on the +# resulting signature. Required when signing. +# XCFRAMEWORK_EXPECTED_AUTHORITY Substring every signature's authority chain +# must contain. Default "Apple Distribution". +# REQUIRE_XCFRAMEWORK_SIGNATURE Set to 1 for release builds: missing +# credentials, a missing timestamp, a wrong +# team, or an empty set of XCFrameworks all +# become hard failures instead of a skip. +# +# Local and PR builds supply none of these and produce unsigned artifacts, which +# is fine for testing and never publishable. + +xcf_log() { echo "xcframework-signing: $*"; } +xcf_warn() { echo "xcframework-signing: $*" >&2; } +xcf_err() { echo "xcframework-signing: ERROR: $*" >&2; } + +# True when a signing identity has been supplied. +xcf_signing_configured() { [ -n "${XCFRAMEWORK_CODESIGN_IDENTITY:-}" ]; } + +# True when this build must not publish an unsigned artifact. +xcf_signing_required() { [ "${REQUIRE_XCFRAMEWORK_SIGNATURE:-0}" = "1" ]; } + +# Validate the signing configuration once, up front, so a release build fails +# before spending minutes compiling rather than at the very last step. +# +# Returns 0 when signing is configured, 1 when it is not. Callers that must not +# proceed unsigned check xcf_signing_required themselves (or call +# xcf_sign_tree, which enforces it). +xcf_signing_preflight() { + if ! xcf_signing_configured; then + if xcf_signing_required; then + xcf_err "REQUIRE_XCFRAMEWORK_SIGNATURE=1 but XCFRAMEWORK_CODESIGN_IDENTITY is empty" + return 2 + fi + xcf_warn "no XCFRAMEWORK_CODESIGN_IDENTITY; artifacts will be UNSIGNED (not publishable)" + return 1 + fi + + if [ -z "${XCFRAMEWORK_EXPECTED_TEAM_ID:-}" ]; then + xcf_err "XCFRAMEWORK_CODESIGN_IDENTITY is set but XCFRAMEWORK_EXPECTED_TEAM_ID is empty;" \ + "refusing to sign without a team to verify against" + return 2 + fi + + if [ -n "${XCFRAMEWORK_SIGNING_KEYCHAIN:-}" ] && [ ! -f "$XCFRAMEWORK_SIGNING_KEYCHAIN" ]; then + xcf_err "keychain not found: $XCFRAMEWORK_SIGNING_KEYCHAIN" + return 2 + fi + + # Confirm the fingerprint actually resolves to a codesigning identity before + # the first `codesign` call, so a mis-imported certificate is reported as + # such instead of as "no identity found" from deep inside a build. + local identities + identities=$(security find-identity -v -p codesigning ${XCFRAMEWORK_SIGNING_KEYCHAIN:+"$XCFRAMEWORK_SIGNING_KEYCHAIN"} 2>&1) || { + xcf_err "security find-identity failed: $identities" + return 2 + } + if ! printf '%s\n' "$identities" | grep -qF "$XCFRAMEWORK_CODESIGN_IDENTITY"; then + xcf_err "identity $XCFRAMEWORK_CODESIGN_IDENTITY not present in" \ + "${XCFRAMEWORK_SIGNING_KEYCHAIN:-the default keychain search list}" + printf '%s\n' "$identities" >&2 + return 2 + fi + + xcf_log "signing with $XCFRAMEWORK_CODESIGN_IDENTITY (team ${XCFRAMEWORK_EXPECTED_TEAM_ID})" + return 0 +} + +# Enumerate every *.xcframework at or below the given roots, NUL-separated. +# +# -prune stops the walk at each match: only the OUTER bundle is a signing +# target, and re-signing something nested inside one would break the outer +# seal. NUL separation because module names come from arbitrary wheels. +xcf_find() { + local root + for root in "$@"; do + [ -e "$root" ] || continue + find "$root" -name '*.xcframework' -prune -print0 + done +} + +# The identifier to seal the OUTER bundle under. +# +# An .xcframework's root Info.plist is an XFWK manifest: it carries +# AvailableLibraries, CFBundlePackageType and XCFrameworkFormatVersion, and no +# CFBundleIdentifier at all. Left alone, codesign falls back to the bundle's file +# name, so the seal reports a bare `Identifier=dart_bridge` instead of a +# reverse-DNS one. Read the identifier off the xcframework's own inner framework +# instead: it is already stable and provider-owned in every artifact we publish, +# so the outer seal and the framework it wraps agree by construction and neither +# depends on the consuming application. +xcf_signing_identifier() { + local xcf=$1 + local name plist ident + name=$(basename "$xcf" .xcframework) + + local slice + for slice in "$xcf"/*/; do + [ -d "$slice$name.framework" ] || continue + # Flat (iOS) layout, then versioned (macOS). Versions/Current is a + # symlink to the real version directory, so skip it. + for plist in "$slice$name.framework/Info.plist" \ + "$slice$name.framework"/Versions/*/Resources/Info.plist; do + [ -f "$plist" ] || continue + case "$plist" in */Versions/Current/*) continue ;; esac + ident=$(plutil -extract CFBundleIdentifier raw -o - "$plist" 2>/dev/null) || continue + if [ -n "$ident" ]; then + printf '%s' "$ident" + return 0 + fi + done + done + return 1 +} + +# Sign one completed outer XCFramework. +xcf_sign_one() { + local xcf=$1 + [ -d "$xcf" ] || { xcf_err "not a directory: $xcf"; return 1; } + + local ident + if ! ident=$(xcf_signing_identifier "$xcf"); then + xcf_err "$xcf: no inner framework Info.plist with a CFBundleIdentifier;" \ + "cannot derive a signing identifier" + return 1 + fi + + local args=(--force --timestamp -i "$ident" --sign "$XCFRAMEWORK_CODESIGN_IDENTITY") + [ -n "${XCFRAMEWORK_SIGNING_KEYCHAIN:-}" ] && args+=(--keychain "$XCFRAMEWORK_SIGNING_KEYCHAIN") + + xcf_log "signing $xcf as $ident" + # Deliberately no --deep: it re-signs nested code with the outer options and + # is documented by Apple as inappropriate for producing a distributable + # signature. Deliberately no --timestamp=none: the receipt's + # isSecureTimestamp is exactly what we are here to make true. + codesign "${args[@]}" "$xcf" || { xcf_err "codesign failed for $xcf"; return 1; } +} + +# Verify one outer XCFramework carries a real provider signature. +xcf_verify_one() { + local xcf=$1 + local expect_team=${XCFRAMEWORK_EXPECTED_TEAM_ID:-} + local expect_authority=${XCFRAMEWORK_EXPECTED_AUTHORITY:-Apple Distribution} + + if [ ! -f "$xcf/_CodeSignature/CodeResources" ]; then + xcf_err "$xcf: no outer _CodeSignature/CodeResources — the XCFramework is unsigned" + return 1 + fi + + if ! codesign --verify --strict --verbose=4 "$xcf" 2>&1; then + xcf_err "$xcf: codesign --verify --strict failed" + return 1 + fi + + local info + if ! info=$(codesign -dvvv "$xcf" 2>&1); then + xcf_err "$xcf: codesign -dvvv failed: $info" + return 1 + fi + printf '%s\n' "$info" + + # An ad-hoc signature satisfies --verify but carries no identity at all, so + # it would sail past the checks below if they were the only ones. + if printf '%s\n' "$info" | grep -q '^Signature=adhoc'; then + xcf_err "$xcf: ad-hoc signature; a release artifact needs a real identity" + return 1 + fi + + # `Timestamp=` is the secure (Apple TSA) timestamp. A signature made without + # --timestamp reports `Signed Time=` instead, which is self-asserted and is + # what makes an IPA receipt report isSecureTimestamp = false. + if ! printf '%s\n' "$info" | grep -q '^Timestamp='; then + xcf_err "$xcf: no secure timestamp (signed without --timestamp?)" + return 1 + fi + + if ! printf '%s\n' "$info" | grep '^Authority=' | grep -qF "$expect_authority"; then + xcf_err "$xcf: no '$expect_authority' authority in the signature chain" + return 1 + fi + + if [ -n "$expect_team" ]; then + local actual_team + actual_team=$(printf '%s\n' "$info" | sed -n 's/^TeamIdentifier=//p' | head -1) + if [ "$actual_team" != "$expect_team" ]; then + xcf_err "$xcf: TeamIdentifier '$actual_team' != expected '$expect_team'" + return 1 + fi + fi + + # The outer seal must name the same provider-owned identifier as the inner + # framework. A mismatch means the bundle was re-signed by something that did + # not pass -i, and fell back to the file name. + local expect_ident actual_ident + if expect_ident=$(xcf_signing_identifier "$xcf"); then + actual_ident=$(printf '%s\n' "$info" | sed -n 's/^Identifier=//p' | head -1) + if [ "$actual_ident" != "$expect_ident" ]; then + xcf_err "$xcf: signing identifier '$actual_ident' != inner framework's '$expect_ident'" + return 1 + fi + fi + + xcf_log "verified $xcf" +} + +# Sign every XCFramework below the given roots. +# +# Skips (with a warning) when no identity is configured, unless +# REQUIRE_XCFRAMEWORK_SIGNATURE=1 — which is what keeps local and PR builds +# working while making a release that lost its credentials fail loudly. +xcf_sign_tree() { + if ! xcf_signing_configured; then + if xcf_signing_required; then + xcf_err "REQUIRE_XCFRAMEWORK_SIGNATURE=1 but no signing identity configured" + return 1 + fi + xcf_warn "skipping signing of: $* (no identity configured)" + return 0 + fi + + local list status=0 count=0 xcf + list=$(mktemp) + xcf_find "$@" > "$list" + while IFS= read -r -d '' xcf; do + if ! xcf_sign_one "$xcf"; then status=1; break; fi + if ! xcf_verify_one "$xcf"; then status=1; break; fi + count=$((count + 1)) + done < "$list" + rm -f "$list" + + [ "$status" -eq 0 ] || return 1 + if [ "$count" -eq 0 ]; then + xcf_err "no *.xcframework found under: $*" + return 1 + fi + xcf_log "signed and verified $count xcframework(s) under: $*" +} + +# Verify every XCFramework below the given roots. Fails when the tree contains +# none — an empty archive must never read as "everything passed". +# +# A no-op when signing is not configured, so unsigned local builds still run +# the same code path. +xcf_verify_tree() { + if ! xcf_signing_configured && ! xcf_signing_required; then + xcf_warn "skipping verification of: $* (no identity configured)" + return 0 + fi + + local list status=0 count=0 xcf + list=$(mktemp) + xcf_find "$@" > "$list" + while IFS= read -r -d '' xcf; do + if ! xcf_verify_one "$xcf"; then status=1; break; fi + count=$((count + 1)) + done < "$list" + rm -f "$list" + + [ "$status" -eq 0 ] || return 1 + if [ "$count" -eq 0 ]; then + xcf_err "no *.xcframework found under: $*" + return 1 + fi + xcf_log "verified $count xcframework(s) under: $*" +} diff --git a/darwin/xcframework_utils.sh b/darwin/xcframework_utils.sh index ce8630c..15545d0 100644 --- a/darwin/xcframework_utils.sh +++ b/darwin/xcframework_utils.sh @@ -2,6 +2,10 @@ archs=("iphoneos.arm64" "iphonesimulator.arm64" "iphonesimulator.x86_64") dylib_ext=so +xcf_utils_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) +# Stable provider-owned bundle identifiers (dev.flet.python.*) + their validation. +. "$xcf_utils_dir/xcframework_identifiers.sh" + # Build-provenance values that Xcode stamps into every framework Info.plist it # produces (DT* / BuildMachineOSBuild). The frameworks here are assembled by hand # from lib-dynload .so's, so without this they carry none of them and the bundle @@ -129,11 +133,14 @@ create_xcframework_from_dylibs() { echo "Creating framework for $dylib_relative_path" dylib_without_ext=$(echo $dylib_relative_path | cut -d "." -f 1) framework=$(echo $dylib_without_ext | tr "/" ".") - framework_identifier=${framework//_/-} - while [[ $framework_identifier == -* ]]; do - framework_identifier=${framework_identifier#-} - done + # Provider-owned and stable: `dev.flet.python.-ssl`, never `.-ssl`. It is + # assigned here, before the xcframework is created and signed, because any + # later plist edit would invalidate the provider signature — see + # xcframework_identifiers.sh. Leading hyphens are kept (CPython's own + # convention) so `_ssl` stays distinct from `ssl`. + framework_identifier=$(xcf_identifier_component "$framework") framework_identifier=${framework_identifier:-framework} + framework_identifier="dev.flet.python.$framework_identifier" # creating "iphoneos" framework fd=iphoneos/$framework.framework @@ -141,7 +148,7 @@ create_xcframework_from_dylibs() { mv "$iphone_dir/$dylib_relative_path" $fd/$framework echo "Frameworks/$framework.framework/$framework" > "$iphone_dir/$dylib_without_ext.fwork" install_name_tool -id @rpath/$framework.framework/$framework $fd/$framework - create_plist $framework "org.python.$framework_identifier" $fd/Info.plist iphoneos + create_plist $framework "$framework_identifier" $fd/Info.plist iphoneos echo "$origin_prefix/$dylib_without_ext.fwork" > $fd/$framework.origin # copy privacy manifest if any @@ -159,7 +166,7 @@ create_xcframework_from_dylibs() { rm "$simulator_arm64_dir/$dylib_without_ext".*.$dylib_ext rm "$simulator_x86_64_dir/$dylib_without_ext".*.$dylib_ext install_name_tool -id @rpath/$framework.framework/$framework $fd/$framework - create_plist $framework "org.python.$framework_identifier" $fd/Info.plist iphonesimulator + create_plist $framework "$framework_identifier" $fd/Info.plist iphonesimulator echo "$origin_prefix/$dylib_without_ext.fwork" > $fd/$framework.origin # copy privacy manifest if any From e9fe4ba545b9a828a61d50ddb96310f49e974e8f Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Wed, 29 Jul 2026 11:26:27 -0700 Subject: [PATCH 2/2] Bump dart bridge version to 1.7.0 1.7.0 is the first dart_bridge release whose dart_bridge.xcframework carries a provider signature with a secure timestamp, sealed under dev.flet.dartbridge -- the counterpart of what this branch does for the Python XCFrameworks. Both halves have to move together, or the IPA still reports one of them as unsigned. Version-keyed consumer caches hold the unsigned 1.6.1 zip, so this must be a version bump rather than a re-release. Compiled binaries are unchanged from 1.6.1. --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index 0cae70b..38fca9a 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "default_python_version": "3.14", - "dart_bridge_version": "1.6.1", + "dart_bridge_version": "1.7.0", "pythons": { "3.12": { "full_version": "3.12.13",