Skip to content

signing: sign for distribution with an existing identity - #326

Draft
TartanLeGrand wants to merge 4 commits into
xtool-org:mainfrom
TartanLeGrand:feat/distribution-signing
Draft

TartanLeGrand wants to merge 4 commits into
xtool-org:mainfrom
TartanLeGrand:feat/distribution-signing

Conversation

@TartanLeGrand

@TartanLeGrand TartanLeGrand commented Oct 12, 2026 •

Copy link
Copy Markdown

What does this PR do?

Adds signing for distribution (App Store, TestFlight, Ad Hoc, Enterprise) with an identity and
profiles the user already has. Nothing is created or revoked on Apple's side, so it works
offline and in CI.

  • DistributionIdentity loads an Apple Distribution certificate and key from a .p12.
  • DistributionProfile reads App Store / Ad Hoc / Enterprise profiles (Mobileprovision.Digest
    now exposes kind and platforms) and checks them against the identity.
  • DistributionEntitlements derives each bundle's entitlements from its profile, narrowed to
    what the bundle asks for (like Xcode). Grants Apple issues as a string where the app asks for
    an array (e.g. associated domains *) are accepted.
  • DistributionSigner picks the most specific profile per bundle, embeds it, and signs through
    the signer dependency with .real(certificate, privateKey) and a per-bundle
    entitlementMapping, the same path AutoSigner uses.
  • CLI: xtool sign <ipa|app> and --p12/--profile/--entitlements on xtool dev build.
    The .p12 password comes from XTOOL_P12_PASSWORD (or --p12-password-env) or
    --p12-password-file, never from an argument.
  • Docs: a "Sign for distribution" article.

The .p12 is parsed with OpenSSL in CXKit (already linked through xtool-core): swift-crypto and
swift-certificates have no PKCS#12 support. Providers are loaded once, since loading them
concurrently with PKCS12_parse broke parsing in parallel tests.

How was it tested?

  • swift test on Linux: 87 tests, all passing, including 30 for this feature.
  • Fixtures are throwaway self-signed identities and hand-written profiles
    (scripts/generate-signing-fixtures.sh); no real secret is involved.
  • Regression tests on the RCodesign output: a distribution-signed iOS 17 executable carries a
    single SHA-256 CodeDirectory, and an executable with no room for LC_CODE_SIGNATURE
    fails to sign and is left untouched.

AI tool usage

How much of this PR was AI-assisted? (check one)

  • 0 - No AI was used to write code
  • 1 - I was assisted by AI. I reviewed the finished result.
  • 2 - I set the AI going and left it to it; nobody has read the result - no review, or AI review only

Besides my review, the change was validated by running it (tests, real builds, device installs and App Store Connect uploads where stated above). I will answer review questions myself.

@coderabbitai

coderabbitai Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

Copy link
Copy Markdown

Related physical-device repro while investigating #131/#326:

  • A host app plus Safari web extension builds and signs on Windows 11 / Ubuntu WSL2 with xtool 1.22.2 and Darwin SDK 27.1.
  • On a physical iOS 26.6.2 device, xtool dev run --usb reaches installation and fails while creating the placeholder for PlugIns/<extension>.appex: IXErrorDomain Code=2, nested Code=13, Failed to get bundle ID ... Missing bundle ID.
  • The signed IPA has non-empty CFBundleIdentifier values for both host and extension; the extension ID has the host ID as a prefix, is under PlugIns, uses package type XPC! and Safari's web-extension point, and has its own embedded profile. The arm64 extension binary contains __text and the handler class.
  • Retrying after a device restart gives the same error. A local experiment correcting the extension's application-identifier to match its own profile and validating both profiles did not change the failure. API-key authentication has not been tested.

Does #326's per-bundle signing work cover this IXPlaceholder / code 13 failure, or are there additional signing or packaging checks we should do first? Is there a known workaround or specific install-proxy diagnostic output you recommend?

@TartanLeGrand

Copy link
Copy Markdown
Author

Thanks for the detailed report. Short answer: I don't know whether #326 fixes this, and I have not tested a Safari web extension myself.

What I can say:

"Missing bundle ID" while creating the placeholder makes me suspect installd fails to read the extension's bundle metadata rather than its signature, but that is a guess. What would narrow it down:

  • the device log during the install (idevicesyslog, filtered on installd), which usually names the file or key it gave up on;
  • plutil -p on PlugIns/<extension>.appex/Info.plist in the packed IPA: CFBundleIdentifier, CFBundleExecutable, CFBundlePackageType, and the NSExtension dictionary (NSExtensionPointIdentifier, NSExtensionPrincipalClass);
  • whether the same IPA installs through another path, for example ideviceinstaller -i, to separate packaging from xtool's installer.

If you can share the Info.plist of the extension (identifiers redacted) and the installd lines, I am happy to compare with a widget extension that installs.

@Jorgagu

Jorgagu commented Oct 28, 2026

Copy link
Copy Markdown

Distribution signing rejects the associated domains grant of a real App Store profile.

What happens

xtool sign fails on an app that declares associated domains, signed with an App Store profile whose App ID has the Associated Domains capability:

Error: The entitlement 'com.apple.developer.associated-domains' is requested but not allowed by the profile '<profile name>'.

Xcode signs the same app for App Store distribution without complaint.

Reproduction

Identifiers replaced with placeholders.

  1. App entitlements:

    <key>com.apple.developer.associated-domains</key>
    <array>
        <string>webcredentials:example.com</string>
    </array>
  2. An App Store distribution profile for that App ID. Apple issues this grant as a plain string, not an array:

    <key>com.apple.developer.associated-domains</key>
    <string>*</string>
  3. Sign:

    xtool sign App.ipa --p12 distribution.p12 --p12-password-file p12.password \
      --profile AppStore.mobileprovision --output App-signed.ipa

Cause

DistributionEntitlements.permits(grant:request:) compares an array with an array, a string with a string, and booleans. A String grant ("*") with a [String] request falls through to NSObject.isEqual, which returns false, so resolve throws entitlementNotInProfile.

The tests do not catch it because SigningFixtures.appstoreProfile grants <array><string>*</string></array>, while the profiles Apple generates carry <string>*</string> for this key.

Suggested fix

The two mixed cases EntitlementsReconciler.permits already handles in #324:

case let (grant as [String], request as String):
    return permits(grant: grant, request: [request])
case let (grant as String, request as [String]):
    return permits(grant: [grant], request: request)

Plus a test in DistributionEntitlementsTests: a profile granting "com.apple.developer.associated-domains": "*" and a request for ["webcredentials:example.com"] should resolve to the requested array. Switching appstoreProfile to the string form in scripts/generate-signing-fixtures.sh would make the fixture match what Apple issues.

@TartanLeGrand

Copy link
Copy Markdown
Author

Thanks @Jorgagu, confirmed and fixed in 9fed60e: permits now compares a string grant with each item of an array request (and an array grant with a string request), as EntitlementsReconciler does in #324. The test calls permits directly with "*" against ["webcredentials:example.com"] rather than switching the fixture to the string form, since regenerating the fixtures recreates every key and certificate in SigningFixtures.swift. Happy to do that too if you think the end-to-end path is worth it.

Sign with an Apple Distribution identity loaded from a .p12 and App Store,
Ad Hoc or Enterprise profiles the user already has, without talking to
Apple. Each bundle gets the most specific matching profile, and its
entitlements are derived from that profile and narrowed to what the bundle
asks for, then passed to the signer per bundle through entitlementMapping.

The .p12 is read with OpenSSL in CXKit: neither swift-crypto nor
swift-certificates can parse PKCS#12.
xtool sign re-signs an existing .ipa or .app for distribution, and
xtool dev build accepts the same --p12/--profile options. The .p12
password comes from an environment variable or a file, never from an
argument.
@TartanLeGrand
TartanLeGrand force-pushed the feat/distribution-signing branch from 9fed60e to ff04d51 Compare November 5, 2026 16:35
@TartanLeGrand

Copy link
Copy Markdown
Author

I rebased this on main now that #389 replaced zsign with RCodesign, and it got a lot smaller:

  • Dropped the zsign fork pin and the SignerImpl change. RCodesign already scopes entitlements
    by bundle path, so DistributionSigner just passes a per-bundle entitlementMapping, like
    AutoSigner, and gets the signer from the signer dependency.
  • Kept everything that does not depend on the signer: profile and identity parsing, the
    entitlement rules (including the string-vs-array grant fix), xtool sign, the dev build
    options, docs and tests.
  • The .p12 is still read with a small OpenSSL helper in CXKit, because swift-crypto and
    swift-certificates cannot parse PKCS#12. Its output is DER + unencrypted PKCS#8 PEM, which is
    what RCodesign.Identity.certificate expects.

The two questions I had with zsign are answered by tests against RCodesign:

  • Digests: an executable targeting iOS 17 gets a single SHA-256 CodeDirectory. apple-codesign
    adds a SHA-1 CodeDirectory only when the Mach-O has no platform targeting (no
    LC_BUILD_VERSION) or targets iOS < 11, so binaries xtool builds are fine.
  • Little header room: signing an unsigned executable with no room for LC_CODE_SIGNATURE fails
    with "insufficient room to write code signature load command" and leaves the file as it was.

Both stay as regression tests. The history is now three commits on top of main.

@gulian

gulian commented Nov 8, 2026

Copy link
Copy Markdown

Tried this on a real app with extensions (a widget, a notification service extension, keychain sharing, push), signing each bundle with its own App Store profile: the per-bundle profile matching works, and the build was accepted and installs from TestFlight.

One thing I had to change. The app's entitlements files say aps-environment = development, which any app installed for development needs. The App Store profile grants production, so resolve throws entitlementNotInProfile and the build stops. Xcode doesn't refuse here: when it exports for distribution, it signs with the profile's value. I made aps-environment a key whose value comes from the profile when the app asks for it (commit 2a03ace on https://github.com/gulian/xtool/tree/testflight-linux), and replaced the test that expected the rejection with one that expects production. I saw the rejection was deliberate in your tests, so tell me if you'd rather keep it and have users pass --entitlements instead.

Smaller observation: the notification service extension has no entitlements file, so it falls back to everything its profile grants, which here means keychain-access-groups with its own group plus com.apple.token. App Store Connect accepted that. I haven't checked what Xcode signs for an extension without entitlements, so just noting it in case it matters.

An app's entitlements file says aps-environment = development, as every
debug build needs. Like Xcode's export, a distribution signature claims
the profile's value (production) instead of refusing the build.
@TartanLeGrand

Copy link
Copy Markdown
Author

Thanks @gulian, agreed: matching Xcode's export is the right behaviour, and asking users to pass --entitlements just to flip aps-environment would be a trap. I cherry-picked your 2a03ace onto this branch as is (your authorship kept), and the signing tests pass with it.

On the extension without an entitlements file: you're right that it currently falls back to everything its profile grants. I'll check what Xcode signs such an extension with for distribution before changing anything, since claiming less than the profile is safer only if App Store Connect and the OS agree.

This branch has not been deployed

No deployments
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.

4 participants