Sign the engine with the developer's own Developer ID; embed it explicitly; Flutter 3.47.2 - #73
Conversation
The engine used to reach the bundle through Xcode's implicit embed of the FlutterFramework SwiftPM binary target — undocumented behaviour that never signed what it embedded, which a separate "Sign Flutter.framework" phase had to compensate for. That left the engine one build phase away from shipping unsigned, and an unsigned engine passes upload and internal TestFlight before being rejected at external Beta App Review (ITMS-91065). Replace it with the three explicit steps upstream Flutter has used since flutter/flutter#181739: * the CLI stages the engine at tvos/Flutter/Flutter.framework (unchanged); * a "Prepare Flutter framework" scheme pre-action copies it into BUILT_PRODUCTS_DIR, where SwiftPM target compiles look for frameworks, with the CLI doing the same copy for its own builds since a pre-action runs with Xcode's environment rather than the CLI's SYMROOT; * an "Embed Flutter.framework" build phase copies it into the app minus the compile-time-only Headers/Modules and signs it with the app's own identity. FlutterFramework is now an empty package: it exists only so the ../FlutterFramework dependency every federated plugin manifest declares keeps resolving. Plugin manifests are unchanged — they already match upstream's shape. Projects generated before these phases stay on the old binary-target package and its signing phase, with a build warning pointing at the migration, so existing apps keep building untouched. Device builds now fail if the built app has no engine, or an engine whose signature does not verify while the app's own does. With the artifacts no longer guaranteed to be signed, the app-side signature is the only one. Also upgrades Flutter 3.47.1 -> 3.47.2. The tvOS patch set applied with zero content churn and none of the upstream build-graph classes the CLI mirrors moved. Verified against a 3.47.2 engine built unsigned: 415 unit tests; simulator debug; device debug, profile and release on an Apple TV 4K, with hot reload and hot restart effective, os=tvos / isTvOS=true in all three modes including AOT, and ten FFI symbols exported from each binary.
Apple's ITMS-91065 check requires the Flutter engine to carry a Developer ID Application signature before the build embeds it. Signing the copy inside the app does not satisfy it, and neither does the Distribution signature xcodebuild -exportArchive applies -- that one is always present anyway, since export re-signs nested frameworks unconditionally. A stock Flutter iOS app ships its engine signed by the app's own certificate with no trace of FLUTTER.IO LLC, and passes, so the authority on the embedded copy is not what Apple reads. Established by A/B on one afternoon, same app and pipeline: four builds with an unsigned engine (3.41.4, 3.44.5, 3.47.2) were each rejected within ~2 minutes of external submission, while the same 3.47.2 engine signed with a Developer ID cleared the automated check. The bundle identifier made no difference. Upload, altool --validate-app, processing to VALID and internal TestFlight all pass either way -- only external Beta App Review exercises this. Published artifacts stay unsigned, so the public project carries no maintainer identity and a release does not depend on one person's certificate. TvosEngineSigner signs locally instead, from NativeTvosBundle.build() at step 0b -- before _copyFlutterFramework, since the copy preserves whatever signature the cache holds at that moment. It signs the extracted cache rather than a per-project copy, so it covers both the explicit embed phase and a legacy project's SwiftPM binary target resolving a symlink straight into it. Signs by SHA-1 hash, never by display name: duplicate certificates sharing a name are common and codesign --sign <name> then fails with "ambiguous (matches ... and ...)". Device builds only. Never fails the build -- a missing Developer ID warns and names ITMS-91065, since only submission needs it. Note for users: Apple lets only a team's Account Holder create a Developer ID certificate, with a per-team limit. Whether an Apple Distribution certificate would also satisfy the check is untested; the signer deliberately does not fall back to one.
engine-27c707e8b2d18b3b50cd555cd88ab9b2a01f53dc, published unsigned -- the CLI signs the engine locally with the developer's own Developer ID on every device build. The framework's bundle identifier is dev.fluttertv.Flutter, so this release is not interchangeable with an older CLI that expects io.flutter.flutter.
Flutter 3.47.2, the explicit engine embed pipeline, and local engine signing with the developer's own Developer ID.
6c13b15 to
9cf1867
Compare
|
Reviewed with fluttertv/engine#19, where the engine-side notes are. Revoked certificates are selected silently
Driven against the real parser with a synthetic revoked The CLI's only The comment at Deleting the signing call entirely keeps the suite green13 mutations inside
No test constructs The fourth survives because Merging into
|
`security find-identity -v -p codesigning` annotates a revoked certificate with `(CSSMERR_TP_CERT_REVOKED)` but still counts it in its "N valid identities found" total. The parser matched with `firstMatch` and nothing anchoring the end of the line, so the annotation was ignored and the certificate was accepted -- selected when listed first, selected when it was the only Developer ID, and accepted when named explicitly through TVOS_ENGINE_SIGNING_IDENTITY. Nothing downstream catches it. `codesign` signs with a revoked certificate and exits 0, and the CLI's own `--verify` runs on the embedded copy after the app re-sign, so it never sees the artifact's signature. The failure surfaces at submission instead. Anchor the match and reject any trailing annotation, not only revocation: an annotation we do not recognise is a reason to skip a certificate rather than to trust it. The duplicate-certificate comment now holds again, since revoked entries are gone before the renewed-certificate case is reached.
Two incompatible accounts were in the tree at once. The correct one: the engine must already carry a Developer ID Application signature when the build embeds it. The wrong one: Apple reads the app-identity signature on the embedded copy, and that suffices. The second was already settled as wrong -- 27c707e corrected one occurrence and its message says so -- but the rest stood, including both halves of the same 1.9.0 CHANGELOG section, sixty lines apart, and the comment in project.pbxproj.tmpl that ships into every generated project. Origin signing being optional keeps its conclusion but loses its reason: it is optional because the CLI signs the engine locally with the developer's own Developer ID before Xcode embeds it, not because an app-identity re-sign would satisfy the check. The embed-phase signature is not described as pointless, because it is not: a framework that reaches the bundle unsigned fails to load, and `-exportArchive` re-signs nested frameworks regardless. It is now stated as the ordinary app-identity signature, separately from the fact that it is not what satisfies ITMS-91065. Also corrects README's "signed by the SDK's provider", which matched neither account.
Four mutations at the call site left the whole suite green: deleting the signing call from build(), moving it after the staging copy, dropping the !simulator guard, and stripping the sign block from the "Embed Flutter.framework" phase. Nothing constructed NativeTvosBundle and called build(), and build() runs pod install and xcodebuild, so driving it whole would be brittle. Extract signAndStageEngine: signing and staging as one @VisibleForTesting method, because the order between them is the invariant. The staging copy preserves whatever signature the cache holds at that moment, so signing after it leaves the staged framework -- and the embedded one -- unsigned. No marker files are needed to test the ordering. _copyFlutterFramework shells out to `cp -R`, and FakeProcessManager.list enforces command order, so security -> codesign -> cp is directly observable; signing moved after the copy fails as loudly as signing deleted. Both cache variants are seeded even when only one is staged. The signer resolves the physical variant unconditionally and returns early when that directory is absent, so seeding only the simulator variant let a dropped guard pass for the wrong reason. The fourth mutation survived because the pbxproj assertions ran `contains` over the whole file, where the unrelated "Embed App.framework" phase supplies the same strings. They now run against the phase's own shellScript. Doing that exposed a live error: the assertion looked for $(PROJECT_DIR) with parens while the script uses ${PROJECT_DIR}; the paren form was being satisfied by the phase's inputPaths. Both are real and are now asserted separately. Adds the scheme's BlueprintIdentifier assertion. Nothing in Xcode reconciles that raw object id with the project, so a pbxproj edit that changes the Runner target's id leaves a pre-action supplying no build settings. Also carries the ITMS-91065 comment corrections in these two files.
|
@DenisovAV Thanks — this was a genuinely useful review, and the mutation testing in particular found things our own tests could not. All four findings are addressed in Revoked certificatesFixed. The parser used Three tests cover the cases you drove (revoked first, revoked as the only Developer ID, revoked named explicitly by hash). We reverted the parser to confirm all three fail against the old code, so they pin the behaviour rather than passing incidentally. The
The call siteFixed, and your diagnosis of why the fourth mutation survived was exactly right.
All four mutations now fail the suite:
Two things worth reporting back: Our first simulator test passed a mutated build. We'd seeded only the simulator cache, so dropping the guard hit Scoping the pbxproj assertions exposed a live error in the existing test. It asserted The The two accounts of ITMS-91065Swept across both repos — no M2 phrasing left in either. The The correction kept M2's conclusion where it was right and replaced its reason: origin signing is optional because the CLI signs locally before Xcode embeds, not because an app-identity re-sign satisfies the check. We deliberately did not rewrite those comments to say the embed-phase signature is pointless — it isn't, since a framework reaching the bundle unsigned fails to load and
|
DenisovAV
left a comment
There was a problem hiding this comment.
Verified against 718e2db.
The parser now anchors and rejects any trailing annotation rather than only CSSMERR_TP_CERT_REVOKED, which is the right generalisation — an annotation we don't recognise is a reason to skip a certificate, not to trust it. Reverting the m.group(3) != null rejection fails three tests, so they pin the behaviour rather than passing incidentally.
All four mutations that survived the previous revision are dead:
| mutation | result |
|---|---|
delete _signEngineForDeveloper() from the seam |
fails (−1) |
move it after _copyFlutterFramework |
fails (−1) |
remove the !buildInfo.simulator guard |
fails (−1) |
| strip the sign block from the Embed phase | fails (−4) |
The residual gap you flagged is real — deleting signAndStageEngine(tvosProjectDir) from build() leaves all 104 green. Reasonable to leave: a full build() drive costs more than the single call it would cover.
Retargeting to dev resolves the mirror breakage, and the pin resolves to 3.47.2, so Tag release produces v3.47.2-tvos.1.9.0.
Splitting the PROJECT_DIR assertions is correct — $(...) against inputPaths for Xcode's dependency tracking, ${...} against the script text for what the shell reads.
One note for anyone running the suite locally: 36 tests in tvos_app_bundle_test.dart fail under fs_safety.dart's createTempSync guard. Not from this PR — the same 36 fail on dev. With FLUTTER_TEST_DISABLE_FS_GUARD=true the four affected files are 104/104.
Deferring DEVELOPMENT_TEAM to its own change is reasonable; it is a design question rather than a defect in this one.
Engine signing (ITMS-91065)
Apple treats Flutter as a commonly-used third-party SDK and rejects submissions whose embedded engine is not signed by the SDK's provider. The engine must carry a
Developer ID Applicationsignature before the build embeds it. Signing the copy inside the app does not satisfy the check, and neither does the Distribution signaturexcodebuild -exportArchiveapplies.That last point is counter-intuitive, so it was measured:
-exportArchivere-signs nested frameworks unconditionally, and a stock Flutter iOS app ships its engine signedApple Development: <the developer>with no trace ofFLUTTER.IO LLC— and passes. So the authority on the embedded copy is not what Apple reads.What decided it, same app and pipeline, one afternoon:
The bundle identifier made no difference (one rejected build shipped
dev.fluttertv.Flutterand was rejected naming the same path).What this PR does
Published artifacts stay unsigned — no maintainer identity in a public release, no release gated on one person's certificate.
TvosEngineSignersigns locally instead, fromNativeTvosBundle.build()at step 0b, before_copyFlutterFramework(the copy preserves whatever signature the cache holds at that moment). It signs the extracted cache, covering both the explicit embed phase and a legacy project's SwiftPM binary target that symlinks straight into it.codesign --sign <name>then fails withambiguous (matches … and …).--timestamp --options runtime, matching how flutter.dev signs its own engine.TVOS_ENGINE_SIGNING_IDENTITY/TVOS_ENGINE_SKIP_SIGNINGto override or disable.Untested: whether an
Apple Distributioncertificate would also satisfy the check. The signer deliberately does not fall back to one — a wrong-type signature is not obviously better than none and would hide the real problem — but if it works, that removes the Account Holder requirement.Explicit embed pipeline
Also in this branch: the engine is embedded by the app itself rather than by Xcode from a SwiftPM
.binaryTarget, matching what upstream Flutter returned to in flutter/flutter#181739. The generatedFlutterFrameworkpackage is empty; a "Prepare Flutter framework" scheme pre-action stages the engine intoBUILT_PRODUCTS_DIR, and an "Embed Flutter.framework" phase puts it in the bundle. Pre-existing projects keep the old path and are warned.Flutter 3.47.2
Pins
d3b14c876900e553bc736ca19295fc09e3853e8eand engineengine-27c707e8b2d18b3b50cd555cd88ab9b2a01f53dc. Requires the companion PR fluttertv/engine#19 — those artifacts usedev.fluttertv.Flutterand are not interchangeable with an older CLI.Testing
dart analyzeclean.flutter-tvos build tvos --releasere-applies it, through to the exported IPA.Not yet confirmed: the submissions cleared Apple's automated check but none has reached
BETA_APPROVED. Worth waiting for that before tagging a release.🤖 Generated with Claude Code