Skip to content

Sign the engine with the developer's own Developer ID; embed it explicitly; Flutter 3.47.2 - #73

Merged
MAUstaoglu merged 7 commits into
devfrom
engine-embed-pipeline
Sep 2, 2026
Merged

Sign the engine with the developer's own Developer ID; embed it explicitly; Flutter 3.47.2#73
MAUstaoglu merged 7 commits into
devfrom
engine-embed-pipeline

Conversation

@MAUstaoglu

@MAUstaoglu MAUstaoglu commented Aug 29, 2026

Copy link
Copy Markdown
Member

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 Application signature before the build embeds it. Signing the copy inside the app does not satisfy the check, and neither does the Distribution signature xcodebuild -exportArchive applies.

That last point is counter-intuitive, so it was measured: -exportArchive re-signs nested frameworks unconditionally, and a stock Flutter iOS app ships its engine signed Apple Development: <the developer> with no trace of FLUTTER.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:

Builds Engine artifact Outcome
4 builds across Flutter 3.41.4, 3.44.5, 3.47.2 unsigned ITMS-91065 within ~2 min of external submission
2 builds, 3.47.2 Developer ID signed cleared the automated check

The bundle identifier made no difference (one rejected build shipped dev.fluttertv.Flutter and 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. TvosEngineSigner signs locally instead, from NativeTvosBundle.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.

  • Signs by SHA-1 hash, never display name — duplicate certificates sharing a name are common, and codesign --sign <name> then fails with ambiguous (matches … and …).
  • --timestamp --options runtime, matching how flutter.dev signs its own engine.
  • Device builds only; re-signs only when the identity differs.
  • Never fails the build — a missing Developer ID warns and names ITMS-91065, since only submission needs it.
  • TVOS_ENGINE_SIGNING_IDENTITY / TVOS_ENGINE_SKIP_SIGNING to override or disable.

⚠️ For users: Apple lets only a team's Account Holder create a Developer ID certificate, with a per-team limit. An Admin on someone else's team will need one made for them. Documented in the new README "Code signing" section.

Untested: whether an Apple Distribution certificate 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 generated FlutterFramework package is empty; a "Prepare Flutter framework" scheme pre-action stages the engine into BUILT_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 d3b14c876900e553bc736ca19295fc09e3853e8e and engine engine-27c707e8b2d18b3b50cd555cd88ab9b2a01f53dc. Requires the companion PR fluttertv/engine#19 — those artifacts use dev.fluttertv.Flutter and are not interchangeable with an older CLI.

Testing

  • 11 new unit tests for the signer; full suite 426 passing, dart analyze clean.
  • Simulator gate green: launch, hot reload/restart effective, platform identity, 10 FFI symbols.
  • Device matrix green on a physical Apple TV (debug/profile/release).
  • Verified end-to-end by stripping every signature from the cache and confirming an ordinary flutter-tvos build tvos --release re-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

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.
@MAUstaoglu
MAUstaoglu force-pushed the engine-embed-pipeline branch from 6c13b15 to 9cf1867 Compare August 29, 2026 15:28
@MAUstaoglu
MAUstaoglu requested a review from DenisovAV August 29, 2026 15:32
@DenisovAV

DenisovAV commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Reviewed with fluttertv/engine#19, where the engine-side notes are.

Revoked certificates are selected silently

security find-identity -v -p codesigning annotates revoked certs (CSSMERR_TP_CERT_REVOKED) and still counts them in "N valid identities found" — two of ten on this machine. The regex at tvos_engine_signing.dart:62 does not reject the suffix, and :131-139 filters by name prefix before taking .first.

Driven against the real parser with a synthetic revoked Developer ID Application line: selected when listed first, selected when it is the only one, accepted when named via TVOS_ENGINE_SIGNING_IDENTITY. Real codesign with such a cert and the signer's exact flags exits 0. Only trace is a printTrace.

The CLI's only codesign --verify runs on the embedded copy after the app re-sign, so it never sees the revoked signature. And because a revoked cert shows Authority=(unavailable), _isSignedBy returns false and the cache is re-signed on every build.

The comment at :137 — "Duplicates here are the renewed-certificate case … either will verify" — does not hold for a revoked cert. The same function never consults DEVELOPMENT_TEAM, so with two Developer IDs installed, project B can get team A's certificate.

Deleting the signing call entirely keeps the suite green

13 mutations inside TvosEngineSigner were all killed — the unit layer is solid. One level up, each of these leaves 426/426 passing:

mutation result
delete the _signEngineForDeveloper() call from build() 426 pass
move it after _copyFlutterFramework, breaking the stated ordering 426 pass
remove the !buildInfo.simulator guard 426 pass
strip the sign block from "Embed Flutter.framework" 426 pass

No test constructs NativeTvosBundle and calls .build(); _signEngineForDeveloper appears nowhere under test/.

The fourth survives because tvos_app_bundle_test.dart:406-407 asserts contains('codesign --force --sign') over the whole pbxproj, and the unrelated "Embed App.framework" phase supplies that string by itself — confirmed by stripping that phase too, at which point the assertions fail. stripJitPayload and pbxprojLacksBuildModeGuard are the pattern to follow: static helpers tested directly.

Merging into main breaks Sync main

Simulated both merge styles: after either, the next dev commit hits ::error::main … is not an ancestor and exits 1. "Tag release" chains off a successful Sync main, so the 1.9.0 tag goes with it. Fast-forwarding dev to main's new tip before the next dev push avoids it.

Two incompatible accounts of what ITMS-91065 reads

M1 — the Developer ID must be on the engine before embedding; an app-identity re-sign of the embedded copy does not satisfy Apple. CHANGELOG.md:10-13, engine/build.sh:814-819.

M2 — Apple reads the app-identity signature on the embedded copy and that suffices. CHANGELOG.md:69-72, test/general/tvos_app_bundle_test.dart:369-373, project.pbxproj.tmpl:238, plus six places in the engine repo.

27c707e's commit message says it "corrects the neighbouring comment, which claimed an app-identity re-sign of the embedded copy was what satisfied ITMS-91065" — M2 is already settled as wrong. One occurrence was fixed; the rest stand, including the template comment that ships into every generated project, and CHANGELOG.md, where M1 and M2 sit sixty lines apart from two commits of this PR.

README.md:162 says "signed by the SDK's provider", which matches neither account nor tvos_engine_signing.dart:1.

The staged engine and the shipped engine come from different paths

The pre-action stages PROJECT_DIR/Flutter/Flutter.framework into BUILT_PRODUCTS_DIR — what plugin targets compile against — while the Embed phase copies from PROJECT_DIR. Upstream reads its embed from BUILT_PRODUCTS_DIR and stages from a Run Script phase as well as a pre-action, so a missed pre-action self-heals on the next build; here it does not.

Narrow in practice: needs an Xcode-started build, the pre-action entirely absent (custom scheme, or xcodebuild -target), and stale DerivedData. Nothing detects it when it happens. The scheme test checks the pre-action exists but not that its BlueprintIdentifier matches the pbxproj.

Measurement worth having on record

Ran the template's embed script against the real artifact in two states, Developer-ID-signed and unsigned, and diffed the output. Info.plist, CodeResources, the Mach-O before LC_CODE_SIGNATURE, the SuperBlob and the CodeDirectory are identical. The only difference in the shipped bundle is 4,156 bytes of unreferenced padding past the blob's declared length, naming no signer.

No test covers the property, so a future change to the embed phase can remove whatever preserves it without anything failing until a rejection days later.

Verified good

3.47.2 sync: zero patch files changed; upstream's 17 commits across 44 files touch none of the 58 the patch set does. Details on #19.

Rename: strings on the shipped binary finds no bare io.flutter.flutter.

`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.
@MAUstaoglu
MAUstaoglu changed the base branch from main to dev September 2, 2026 11:01
@MAUstaoglu

Copy link
Copy Markdown
Member Author

@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 718e2db, 2452a03, 6e16955.

Revoked certificates

Fixed. The parser used firstMatch with nothing anchoring end-of-line, so the annotation was simply ignored. It now anchors and rejects any trailing annotation, not only CSSMERR_TP_CERT_REVOKED — an annotation we don't recognise is a reason to skip a certificate rather than trust it.

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 :137 duplicate-certificate comment holds again now that revoked entries are gone before that logic is reached.

DEVELOPMENT_TEAM is deliberately not addressed here. You're right that with two Developer IDs installed project B can get team A's certificate, but that's a design decision rather than a defect, and we'd rather make it separately than fold it into this PR.

The call site

Fixed, and your diagnosis of why the fourth mutation survived was exactly right.

build() runs pod install and xcodebuild, so driving it whole would have been brittle. Instead we extracted signAndStageEngine as a @visibleForTesting seam — signing and staging together, because the order between them is the invariant. No marker files were needed: _copyFlutterFramework shells out to cp -R, and FakeProcessManager.list enforces command order, so securitycodesigncp is directly observable.

All four mutations now fail the suite:

mutation before now
delete _signEngineForDeveloper() from build() 426 pass fails
move it after _copyFlutterFramework 426 pass fails
remove the !buildInfo.simulator guard 426 pass fails
strip the sign block from "Embed Flutter.framework" 426 pass fails

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 signIfPossible's early return on a missing directory instead of attempting to sign — green for the wrong reason. Both variants are now seeded, and the helper carries a comment explaining why.

Scoping the pbxproj assertions exposed a live error in the existing test. It asserted $(PROJECT_DIR)/Flutter/Flutter.framework with parens; the phase's script uses ${PROJECT_DIR} with braces. The paren form was being satisfied by the phase's own inputPaths. Both are real, so they're now asserted separately — inputPaths for Xcode's dependency tracking, ${...} for what the shell reads.

The BlueprintIdentifier assertion is in, verified by renaming the Runner target id and watching it fail naming both files.

The two accounts of ITMS-91065

Swept across both repos — no M2 phrasing left in either. The project.pbxproj.tmpl comment that ships into every generated project was the worst of them.

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 -exportArchive re-signs regardless. They now state it's the ordinary app-identity signature, separately from the fact that it isn't what satisfies ITMS-91065.

Sync main

Good catch, and it would have bitten. This PR now targets dev rather than main, so the mirror stays intact and Tag release can fire. We also confirmed the flutter pin resolves to 3.47.2, so the tag will be v3.47.2-tvos.1.9.0 and the version-resolution step won't hard-fail.

Your note, not a finding

Taken seriously, and left as-is deliberately. Since nobody can currently name what preserves the property, we avoided asserting a mechanism anywhere in the new comments — they state the observed A/B and nothing more. You're right that a regression there would surface as a rejection days later with no test catching it.

One thing to flag honestly: we're proceeding to tag without waiting for BETA_APPROVED, against your suggestion. That's a maintainer call rather than a disagreement with your reasoning.

Residual gap

A mutation deleting signAndStageEngine(...) from build() itself would still pass. The seam shrinks the untested surface to a single call rather than eliminating it; covering that honestly needs a full build() drive, which didn't look worth it here. Flagging it rather than leaving you to find it.

@DenisovAV DenisovAV left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@MAUstaoglu
MAUstaoglu merged commit cc9caf5 into dev Sep 2, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants