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 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",