Skip to content

Commit 3c168a5

Browse files
Sign inner .framework bundles too; dart_bridge 1.7.1 (#38)
* 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. * 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. * Find slice frameworks by glob, not by the xcframework's name xcf_slice_frameworks located each slice's bundle as <slice>/<xcframework basename>.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-<platform>.xcframework, and an unsigned inner framework in a renamed copy would be reported as "no slice frameworks found" instead of as unsigned. Globbing <slice>/*.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).
1 parent 7d064ec commit 3c168a5

3 files changed

Lines changed: 132 additions & 37 deletions

File tree

‎README.md‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,17 @@ release (per-job artifacts only).
6565
### Apple XCFramework signing
6666

6767
Every `.xcframework` in the Darwin tarballs is provider-signed with the Flet
68-
publishing team's Apple Distribution identity and a secure timestamp.
68+
publishing team's Apple Distribution identity and a secure timestamp — **both the
69+
inner `.framework` bundles in each slice and the outer xcframework**, in that
70+
order.
71+
72+
Signing only the outer bundle is not enough: an IPA built against such an
73+
artifact reports `signed = true` but `isSecureTimestamp = false`. Every slice of
74+
an XCFramework Apple's App Store scan demonstrably accepts
75+
([krzyzanowskim/OpenSSL](https://github.com/krzyzanowskim/OpenSSL)) carries its
76+
own signature, with the outer bundle signed last. The order matters — the outer
77+
seal hashes the bundle contents, so signing an inner framework afterwards
78+
invalidates it.
6979

7080
This matters because Xcode records the state of each `.xcframework` an app links
7181
against **as its publisher shipped it**, and writes the result into the IPA as

‎darwin/xcframework_signing.sh‎

Lines changed: 120 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -124,16 +124,13 @@ xcf_find() {
124124
# depends on the consuming application.
125125
xcf_signing_identifier() {
126126
local xcf=$1
127-
local name plist ident
128-
name=$(basename "$xcf" .xcframework)
127+
local fw plist ident
129128

130-
local slice
131-
for slice in "$xcf"/*/; do
132-
[ -d "$slice$name.framework" ] || continue
129+
while IFS= read -r fw; do
130+
[ -n "$fw" ] || continue
133131
# Flat (iOS) layout, then versioned (macOS). Versions/Current is a
134132
# symlink to the real version directory, so skip it.
135-
for plist in "$slice$name.framework/Info.plist" \
136-
"$slice$name.framework"/Versions/*/Resources/Info.plist; do
133+
for plist in "$fw/Info.plist" "$fw"/Versions/*/Resources/Info.plist; do
137134
[ -f "$plist" ] || continue
138135
case "$plist" in */Versions/Current/*) continue ;; esac
139136
ident=$(plutil -extract CFBundleIdentifier raw -o - "$plist" 2>/dev/null) || continue
@@ -142,11 +139,51 @@ xcf_signing_identifier() {
142139
return 0
143140
fi
144141
done
145-
done
142+
done <<EOF
143+
$(xcf_slice_frameworks "$xcf")
144+
EOF
146145
return 1
147146
}
148147

149-
# Sign one completed outer XCFramework.
148+
# Emit the per-slice .framework bundles inside an xcframework.
149+
#
150+
# Found by globbing each slice directory rather than by assuming the framework is
151+
# named after the xcframework. The two normally match, but a consumer may stage a
152+
# copy under a different name -- serious_python renames Python.xcframework to
153+
# Python-<platform>.xcframework -- and a name-keyed lookup silently finds nothing
154+
# there, which would let an unsigned slice pass as "no slices to check".
155+
xcf_slice_frameworks() {
156+
local xcf=$1
157+
local slice fw
158+
for slice in "$xcf"/*/; do
159+
[ -d "$slice" ] || continue
160+
for fw in "$slice"*.framework; do
161+
[ -d "$fw" ] && printf '%s\n' "$fw"
162+
done
163+
done
164+
return 0
165+
}
166+
167+
# Sign one completed outer XCFramework — inner frameworks first, outer last.
168+
#
169+
# WHY BOTH LAYERS
170+
# Signing only the outer bundle is not enough. Compare against a third-party
171+
# XCFramework Apple's App Store scan demonstrably accepts
172+
# (github.com/krzyzanowskim/OpenSSL): every one of its ten slices carries its
173+
# own Apple Distribution signature with a secure timestamp, and the outer
174+
# bundle is signed afterwards. Our artifacts previously had unsigned inner
175+
# frameworks, and their IPA receipts came back `signed = true` but
176+
# `isSecureTimestamp = false` — the one structural difference between the two.
177+
#
178+
# Order matters and is not optional: the outer seal hashes the bundle contents,
179+
# so the inner signatures have to exist before it is computed. Signing an inner
180+
# framework afterwards would invalidate the outer seal, which is the same
181+
# mistake this whole effort exists to stop making.
182+
#
183+
# `xcodebuild -create-xcframework` preserves inner `_CodeSignature`
184+
# directories, so a producer may equally sign the .framework bundles before
185+
# assembling the xcframework; doing it here keeps one code path for both the
186+
# build-time and the sign-the-finished-archive callers.
150187
xcf_sign_one() {
151188
local xcf=$1
152189
[ -d "$xcf" ] || { xcf_err "not a directory: $xcf"; return 1; }
@@ -158,69 +195,117 @@ xcf_sign_one() {
158195
return 1
159196
fi
160197

161-
local args=(--force --timestamp -i "$ident" --sign "$XCFRAMEWORK_CODESIGN_IDENTITY")
198+
local args=(--force --timestamp --sign "$XCFRAMEWORK_CODESIGN_IDENTITY")
162199
[ -n "${XCFRAMEWORK_SIGNING_KEYCHAIN:-}" ] && args+=(--keychain "$XCFRAMEWORK_SIGNING_KEYCHAIN")
163200

164-
xcf_log "signing $xcf as $ident"
165-
# Deliberately no --deep: it re-signs nested code with the outer options and
166-
# is documented by Apple as inappropriate for producing a distributable
167-
# signature. Deliberately no --timestamp=none: the receipt's
168-
# isSecureTimestamp is exactly what we are here to make true.
169-
codesign "${args[@]}" "$xcf" || { xcf_err "codesign failed for $xcf"; return 1; }
201+
# Deliberately no --deep anywhere below: it re-signs nested code with the
202+
# outer bundle's options and is documented by Apple as inappropriate for
203+
# producing a distributable signature. Signing each slice explicitly is the
204+
# supported way to get the same coverage. Deliberately no --timestamp=none:
205+
# the receipt's isSecureTimestamp is exactly what we are here to make true.
206+
local fw count=0
207+
while IFS= read -r fw; do
208+
[ -n "$fw" ] || continue
209+
# No -i for the inner bundles: a .framework has a real CFBundleIdentifier
210+
# in its own Info.plist, which codesign picks up.
211+
codesign "${args[@]}" "$fw" || { xcf_err "codesign failed for $fw"; return 1; }
212+
count=$((count + 1))
213+
done <<EOF
214+
$(xcf_slice_frameworks "$xcf")
215+
EOF
216+
217+
if [ "$count" -eq 0 ]; then
218+
xcf_err "$xcf: no slice frameworks found to sign"
219+
return 1
220+
fi
221+
222+
xcf_log "signing $xcf as $ident ($count slice framework(s) + outer)"
223+
codesign "${args[@]}" -i "$ident" "$xcf" || { xcf_err "codesign failed for $xcf"; return 1; }
170224
}
171225

172-
# Verify one outer XCFramework carries a real provider signature.
173-
xcf_verify_one() {
174-
local xcf=$1
226+
# Assert one signed bundle (inner framework or outer xcframework) carries a real
227+
# provider signature: verifiable, not ad-hoc, securely timestamped, right
228+
# authority, right team.
229+
xcf_assert_signature() {
230+
local target=$1
175231
local expect_team=${XCFRAMEWORK_EXPECTED_TEAM_ID:-}
176232
local expect_authority=${XCFRAMEWORK_EXPECTED_AUTHORITY:-Apple Distribution}
177233

178-
if [ ! -f "$xcf/_CodeSignature/CodeResources" ]; then
179-
xcf_err "$xcf: no outer _CodeSignature/CodeResources — the XCFramework is unsigned"
180-
return 1
181-
fi
182-
183-
if ! codesign --verify --strict --verbose=4 "$xcf" 2>&1; then
184-
xcf_err "$xcf: codesign --verify --strict failed"
234+
if ! codesign --verify --strict --verbose=4 "$target" 2>&1; then
235+
xcf_err "$target: codesign --verify --strict failed"
185236
return 1
186237
fi
187238

188239
local info
189-
if ! info=$(codesign -dvvv "$xcf" 2>&1); then
190-
xcf_err "$xcf: codesign -dvvv failed: $info"
240+
if ! info=$(codesign -dvvv "$target" 2>&1); then
241+
xcf_err "$target: codesign -dvvv failed: $info"
191242
return 1
192243
fi
193-
printf '%s\n' "$info"
194244

195-
# An ad-hoc signature satisfies --verify but carries no identity at all, so
196-
# it would sail past the checks below if they were the only ones.
197245
if printf '%s\n' "$info" | grep -q '^Signature=adhoc'; then
198-
xcf_err "$xcf: ad-hoc signature; a release artifact needs a real identity"
246+
xcf_err "$target: ad-hoc signature; a release artifact needs a real identity"
199247
return 1
200248
fi
201249

202250
# `Timestamp=` is the secure (Apple TSA) timestamp. A signature made without
203251
# --timestamp reports `Signed Time=` instead, which is self-asserted and is
204252
# what makes an IPA receipt report isSecureTimestamp = false.
205253
if ! printf '%s\n' "$info" | grep -q '^Timestamp='; then
206-
xcf_err "$xcf: no secure timestamp (signed without --timestamp?)"
254+
xcf_err "$target: no secure timestamp (signed without --timestamp?)"
207255
return 1
208256
fi
209257

210258
if ! printf '%s\n' "$info" | grep '^Authority=' | grep -qF "$expect_authority"; then
211-
xcf_err "$xcf: no '$expect_authority' authority in the signature chain"
259+
xcf_err "$target: no '$expect_authority' authority in the signature chain"
212260
return 1
213261
fi
214262

215263
if [ -n "$expect_team" ]; then
216264
local actual_team
217265
actual_team=$(printf '%s\n' "$info" | sed -n 's/^TeamIdentifier=//p' | head -1)
218266
if [ "$actual_team" != "$expect_team" ]; then
219-
xcf_err "$xcf: TeamIdentifier '$actual_team' != expected '$expect_team'"
267+
xcf_err "$target: TeamIdentifier '$actual_team' != expected '$expect_team'"
268+
return 1
269+
fi
270+
fi
271+
}
272+
273+
# Verify one XCFramework carries real provider signatures on BOTH layers.
274+
xcf_verify_one() {
275+
local xcf=$1
276+
277+
if [ ! -f "$xcf/_CodeSignature/CodeResources" ]; then
278+
xcf_err "$xcf: no outer _CodeSignature/CodeResources — the XCFramework is unsigned"
279+
return 1
280+
fi
281+
282+
# Every slice's framework must be signed in its own right. An unsigned inner
283+
# bundle is what produced `isSecureTimestamp = false` receipts even though the
284+
# outer seal was valid — see xcf_sign_one.
285+
local fw count=0
286+
while IFS= read -r fw; do
287+
[ -n "$fw" ] || continue
288+
if [ ! -d "$fw/_CodeSignature" ] && [ ! -d "$fw/Versions/A/_CodeSignature" ]; then
289+
xcf_err "$fw: inner framework is unsigned"
220290
return 1
221291
fi
292+
xcf_assert_signature "$fw" >/dev/null || return 1
293+
count=$((count + 1))
294+
done <<EOF
295+
$(xcf_slice_frameworks "$xcf")
296+
EOF
297+
298+
if [ "$count" -eq 0 ]; then
299+
xcf_err "$xcf: no slice frameworks found to verify"
300+
return 1
222301
fi
223302

303+
xcf_assert_signature "$xcf" || return 1
304+
305+
local info
306+
info=$(codesign -dvvv "$xcf" 2>&1) || info=""
307+
printf '%s\n' "$info"
308+
224309
# The outer seal must name the same provider-owned identifier as the inner
225310
# framework. A mismatch means the bundle was re-signed by something that did
226311
# not pass -i, and fell back to the file name.

‎manifest.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"default_python_version": "3.14",
3-
"dart_bridge_version": "1.7.0",
3+
"dart_bridge_version": "1.7.1",
44
"pythons": {
55
"3.12": {
66
"full_version": "3.12.13",

0 commit comments

Comments
 (0)