From 9fc8549c81ff500cc74eac6e135278175768d66d Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Thu, 30 Jul 2026 13:33:37 -0700 Subject: [PATCH 1/3] Sign inner .framework bundles too, not just the outer xcframework 20260729 signed each outer .xcframework but left the .framework bundles inside every slice unsigned. An App Store IPA built against it reported `signed = true` but `isSecureTimestamp = false` for Python-ios, _ssl, _hashlib and dart_bridge, even though the outer seals carry genuine Apple TSA timestamps -- the receipt cdhashes match the published artifacts byte for byte, so Xcode was reading our signatures. The same result appeared in a development archive and an App Store export, so the export method was not the variable. The control that settles it: krzyzanowskim/OpenSSL 3.6.3000, an XCFramework Apple's App Store scan demonstrably accepts, signs all ten of its slices with an Apple Distribution identity and a secure timestamp, then signs the outer bundle last. An unsigned inner framework was the only structural difference left. xcf_sign_one now signs each slice's .framework first and the outer bundle last. The order is not optional: the outer seal hashes the bundle contents, so signing an inner framework afterwards would invalidate it. No -i for the inner bundles -- a .framework has a real CFBundleIdentifier in its own Info.plist. Verification was refactored into xcf_assert_signature (verifiable, not ad-hoc, secure timestamp, expected authority and team) and now covers every slice framework as well as the outer bundle, so an unsigned inner framework fails the release rather than shipping. No script outside the shared helper changes: sign_darwin_archives.sh already signs the finished archives through xcf_sign_one, and inner _CodeSignature directories survive both `xcodebuild -create-xcframework` and the tar round trip. This does add roughly two extra codesign invocations per xcframework -- about 500 TSA round trips across the three Python versions -- so expect sign-darwin-artifacts to take noticeably longer than on 20260729. --- README.md | 12 ++- darwin/xcframework_signing.sh | 134 +++++++++++++++++++++++++++------- 2 files changed, 118 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 785a58b..24361fa 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,17 @@ 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. +publishing team's Apple Distribution identity and a secure timestamp — **both the +inner `.framework` bundles in each slice and the outer xcframework**, in that +order. + +Signing only the outer bundle is not enough: an IPA built against such an +artifact reports `signed = true` but `isSecureTimestamp = false`. Every slice of +an XCFramework Apple's App Store scan demonstrably accepts +([krzyzanowskim/OpenSSL](https://github.com/krzyzanowskim/OpenSSL)) carries its +own signature, with the outer bundle signed last. The order matters — the outer +seal hashes the bundle contents, so signing an inner framework afterwards +invalidates it. 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 diff --git a/darwin/xcframework_signing.sh b/darwin/xcframework_signing.sh index 5aa88c8..267099d 100644 --- a/darwin/xcframework_signing.sh +++ b/darwin/xcframework_signing.sh @@ -146,7 +146,39 @@ xcf_signing_identifier() { return 1 } -# Sign one completed outer XCFramework. +# Emit the per-slice .framework bundles inside an xcframework. +xcf_slice_frameworks() { + local xcf=$1 + local name + name=$(basename "$xcf" .xcframework) + + local slice + for slice in "$xcf"/*/; do + [ -d "$slice$name.framework" ] && printf '%s\n' "$slice$name.framework" + done + return 0 +} + +# Sign one completed outer XCFramework — inner frameworks first, outer last. +# +# WHY BOTH LAYERS +# Signing only the outer bundle is not enough. Compare against a third-party +# XCFramework Apple's App Store scan demonstrably accepts +# (github.com/krzyzanowskim/OpenSSL): every one of its ten slices carries its +# own Apple Distribution signature with a secure timestamp, and the outer +# bundle is signed afterwards. Our artifacts previously had unsigned inner +# frameworks, and their IPA receipts came back `signed = true` but +# `isSecureTimestamp = false` — the one structural difference between the two. +# +# Order matters and is not optional: the outer seal hashes the bundle contents, +# so the inner signatures have to exist before it is computed. Signing an inner +# framework afterwards would invalidate the outer seal, which is the same +# mistake this whole effort exists to stop making. +# +# `xcodebuild -create-xcframework` preserves inner `_CodeSignature` +# directories, so a producer may equally sign the .framework bundles before +# assembling the xcframework; doing it here keeps one code path for both the +# build-time and the sign-the-finished-archive callers. xcf_sign_one() { local xcf=$1 [ -d "$xcf" ] || { xcf_err "not a directory: $xcf"; return 1; } @@ -158,44 +190,55 @@ xcf_sign_one() { return 1 fi - local args=(--force --timestamp -i "$ident" --sign "$XCFRAMEWORK_CODESIGN_IDENTITY") + local args=(--force --timestamp --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; } + # Deliberately no --deep anywhere below: it re-signs nested code with the + # outer bundle's options and is documented by Apple as inappropriate for + # producing a distributable signature. Signing each slice explicitly is the + # supported way to get the same coverage. Deliberately no --timestamp=none: + # the receipt's isSecureTimestamp is exactly what we are here to make true. + local fw count=0 + while IFS= read -r fw; do + [ -n "$fw" ] || continue + # No -i for the inner bundles: a .framework has a real CFBundleIdentifier + # in its own Info.plist, which codesign picks up. + codesign "${args[@]}" "$fw" || { xcf_err "codesign failed for $fw"; return 1; } + count=$((count + 1)) + done <&1; then - xcf_err "$xcf: codesign --verify --strict failed" + if ! codesign --verify --strict --verbose=4 "$target" 2>&1; then + xcf_err "$target: codesign --verify --strict failed" return 1 fi local info - if ! info=$(codesign -dvvv "$xcf" 2>&1); then - xcf_err "$xcf: codesign -dvvv failed: $info" + if ! info=$(codesign -dvvv "$target" 2>&1); then + xcf_err "$target: 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" + xcf_err "$target: ad-hoc signature; a release artifact needs a real identity" return 1 fi @@ -203,12 +246,12 @@ xcf_verify_one() { # --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?)" + xcf_err "$target: 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" + xcf_err "$target: no '$expect_authority' authority in the signature chain" return 1 fi @@ -216,10 +259,47 @@ xcf_verify_one() { 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'" + xcf_err "$target: TeamIdentifier '$actual_team' != expected '$expect_team'" return 1 fi fi +} + +# Verify one XCFramework carries real provider signatures on BOTH layers. +xcf_verify_one() { + local xcf=$1 + + if [ ! -f "$xcf/_CodeSignature/CodeResources" ]; then + xcf_err "$xcf: no outer _CodeSignature/CodeResources — the XCFramework is unsigned" + return 1 + fi + + # Every slice's framework must be signed in its own right. An unsigned inner + # bundle is what produced `isSecureTimestamp = false` receipts even though the + # outer seal was valid — see xcf_sign_one. + local fw count=0 + while IFS= read -r fw; do + [ -n "$fw" ] || continue + if [ ! -d "$fw/_CodeSignature" ] && [ ! -d "$fw/Versions/A/_CodeSignature" ]; then + xcf_err "$fw: inner framework is unsigned" + return 1 + fi + xcf_assert_signature "$fw" >/dev/null || return 1 + count=$((count + 1)) + done <&1) || info="" + printf '%s\n' "$info" # 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 From be07a83bfb960bb5e2a343ed50106ed1907c376a Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Thu, 30 Jul 2026 13:36:55 -0700 Subject: [PATCH 2/3] Bump dart bridge version to 1.7.1 1.7.1 is the first dart_bridge release whose dart_bridge.framework slices are signed, not just the outer xcframework -- the counterpart of the change on this branch for the Python XCFrameworks (flet-dev/dart-bridge#14). Both halves have to move together. An IPA that picks up a 1.7.0 dart_bridge alongside inner-signed Python XCFrameworks would still carry one receipt reporting isSecureTimestamp = false, which leaves the ITMS-91065 question unanswered rather than answered. Version-keyed consumer caches hold the outer-only-signed 1.7.0 zip, so this must be a version bump rather than a re-release. Compiled binaries are unchanged from 1.7.0 on every platform. --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index 38fca9a..ea37d11 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "default_python_version": "3.14", - "dart_bridge_version": "1.7.0", + "dart_bridge_version": "1.7.1", "pythons": { "3.12": { "full_version": "3.12.13", From 768b5e3dcf2797083e65b32d280bb70598485f4a Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Thu, 30 Jul 2026 13:41:51 -0700 Subject: [PATCH 3/3] Find slice frameworks by glob, not by the xcframework's name xcf_slice_frameworks located each slice's bundle as /.framework. Everything this repo publishes has matching names, but the assumption fails silently rather than loudly when it does not hold: serious_python stages Python.xcframework as Python-.xcframework, and an unsigned inner framework in a renamed copy would be reported as "no slice frameworks found" instead of as unsigned. Globbing /*.framework removes the assumption. xcf_signing_identifier now walks the same enumerator instead of repeating the name-keyed lookup. Verification only -- signed output is unchanged. Keeps this helper byte-identical to dart-bridge's copy (flet-dev/dart-bridge#15). --- darwin/xcframework_signing.sh | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/darwin/xcframework_signing.sh b/darwin/xcframework_signing.sh index 267099d..414f2e4 100644 --- a/darwin/xcframework_signing.sh +++ b/darwin/xcframework_signing.sh @@ -124,16 +124,13 @@ xcf_find() { # depends on the consuming application. xcf_signing_identifier() { local xcf=$1 - local name plist ident - name=$(basename "$xcf" .xcframework) + local fw plist ident - local slice - for slice in "$xcf"/*/; do - [ -d "$slice$name.framework" ] || continue + while IFS= read -r fw; do + [ -n "$fw" ] || 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 + for plist in "$fw/Info.plist" "$fw"/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 @@ -142,19 +139,27 @@ xcf_signing_identifier() { return 0 fi done - done + done <.framework bundles inside an xcframework. +# Emit the per-slice .framework bundles inside an xcframework. +# +# Found by globbing each slice directory rather than by assuming the framework is +# named after the xcframework. The two normally match, but a consumer may stage a +# copy under a different name -- serious_python renames Python.xcframework to +# Python-.xcframework -- and a name-keyed lookup silently finds nothing +# there, which would let an unsigned slice pass as "no slices to check". xcf_slice_frameworks() { local xcf=$1 - local name - name=$(basename "$xcf" .xcframework) - - local slice + local slice fw for slice in "$xcf"/*/; do - [ -d "$slice$name.framework" ] && printf '%s\n' "$slice$name.framework" + [ -d "$slice" ] || continue + for fw in "$slice"*.framework; do + [ -d "$fw" ] && printf '%s\n' "$fw" + done done return 0 }