Skip to content

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

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

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

Conversation

@TartanLeGrand

@TartanLeGrand TartanLeGrand commented Oct 12, 2026 •

Copy link
Copy Markdown

Depends on xtool-org/zsign#3, #4 and #5. Until zsign tags a release that includes them, the last commit points Package.swift at a branch in my fork that combines the three (by revision) so that CI can build; it will become .upToNextMinor(from: "<tag>").

What does this PR do?

Lets xtool sign an app for App Store or Ad Hoc distribution with an identity and profiles the user already has, fully offline: nothing is created or revoked on the developer account.

  • xtool sign <ipa|app> --p12 <file> --profile <file> [--profile ...] [--entitlements <plist>] [-o <ipa>] re-signs an existing build into a new .ipa, leaving the input untouched.
  • xtool dev build takes the same options (--p12, --p12-password-env, --p12-password-file, --profile, --entitlements), also with --ipa. --p12 together with --sign is rejected.
  • The .p12 password comes from XTOOL_P12_PASSWORD, another variable named with --p12-password-env, or a file (--p12-password-file). Never from an argument.
  • Each bundle (app and extensions) gets the profile matching its bundle ID (an exact ID wins over a wildcard), its own embedded.mobileprovision, and its own entitlements.

Library side (Sources/XKit/Signer/Distribution/):

  • DistributionIdentity loads the .p12 through a small OpenSSL helper (Sources/CXKit/pkcs12_identity.c), which also loads the legacy provider (once) so archives exported by Keychain Access (RC2-40/3DES) work. Wrong password, invalid file and missing key are distinct errors. Development and expired certificates are refused.
  • DistributionProfile refuses development, expired and non-iOS profiles, a team mismatch, and a certificate missing from DeveloperCertificates. Mobileprovision now decodes App Store profiles (they have no ProvisionedDevices, which used to fail decoding) and exposes their kind and platforms.
  • DistributionEntitlements takes the profile as the source of truth: application-identifier and the team identifier come from the profile, get-task-allow is always false, beta-reports-active is carried over, other capabilities are claimed only if the app asks for them (existing signature or --entitlements), or everything the profile grants if the app asks for nothing. Wildcards are resolved. Asking for something the profile does not allow is an error. This matches what Xcode does on export.
  • DistributionSigner validates everything, embeds the profiles and calls the existing Signer.

This also removes the root-only filter in Signer.sign (the #131 workaround), so development signing of apps with extensions now uses each bundle's entitlements as well; that part is what needs zsign#3. SignerLayoutTests covers zsign#5 (a binary with too little room fails without being modified; a binary whose first section is not __text signs), and the signing tests expect the single SHA-256 CodeDirectory of zsign#4.

Docs: a "Distribution" article, linked from the main page.

Out of scope: creating distribution certificates and App Store profiles through the App Store Connect API. A safe version needs a "never revoke" policy that is easier to review separately.

How was it tested?

Linux x86_64, Podman, Swift 6.4, XTL_CI=1 (warnings as errors), against the combined zsign branch:

  • swift build --product xtool and swift test: Test run with 40 tests in 5 suites passed (28 new): profile parsing and kinds, bundle ID matching, refusal of development profiles and certificates, expired certificate and profile, team mismatch, certificate not in profile, wrong .p12 password and garbage input, entitlements derivation, no get-task-allow, wildcard resolution, narrowing, rejection of entitlements the profile does not allow, signer preconditions, signing a fake app with a fake extension using two profiles and reading back each executable's entitlements from its signature, and the two layout tests. Test fixtures are throwaway self-signed identities and hand-made CMS-signed profiles, regenerated by scripts/generate-signing-fixtures.sh.
  • SwiftLint 0.65.1 --strict: 0 violations in 296 files.
  • A legacy RC2-40/3DES .p12 loads.
  • Real distribution signing of a production Tauri app (app plus a WidgetKit extension), built on Linux, with an Apple Distribution certificate and one App Store profile per bundle: the signed entitlements of both bundles are identical, key for key and value for value, to those of the Xcode export of the same app (the extension without the app's aps-environment), and the requirements blob and CodeResources match. Uploaded to App Store Connect: builds VALID in about 2 minutes, then installed through TestFlight on an iPhone with iOS 27. Before zsign#4 the same upload was rejected with 90034.
  • An Xcode export re-signed with xtool sign with the same identity: also VALID.

Found while preparing this PR: loading the OpenSSL providers on every call raced with PKCS12_parse on other threads (parallel tests failed with p12MissingContent); they are now loaded once.

Not tested: macOS and iOS builds. The helper includes openssl/provider.h; it still has to be confirmed that the OpenSSL xcframework used on Apple platforms ships it and the legacy provider (if not, only legacy .p12 files would fail, with invalidP12). Ad Hoc installs were not tried.

Related: #324 (development signing) has a similar "does the profile allow this" check; whichever lands second can share it. Fixes #131. Part of #117.

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.

Add DistributionSigner, which signs an app with a user-supplied .p12
identity and App Store or Ad Hoc provisioning profiles, without any
call to Apple (nothing is created or revoked).

- Load identities from PKCS#12 archives (including legacy RC2/3DES ones).
- Expose the kind of a provisioning profile (development, ad hoc, App
  Store, enterprise); App Store profiles have no ProvisionedDevices.
- Reject development profiles and certificates, expired inputs, team
  mismatches and profiles that don't include the certificate.
- Derive entitlements from the profile, never including get-task-allow,
  and reject requested entitlements the profile does not allow.
- Refuse apps with extensions until the signer can apply per-bundle
  entitlements (xtool-org#131), rather than produce an invalid signature.
`xtool sign <ipa|app> --p12 ... --profile ...` re-signs an existing
build for distribution. `xtool dev build` accepts the same options.
The .p12 password comes from an environment variable or a file, never
from an argument.
Drop the root-only filter in Signer.sign (xtool-org#131) and let
DistributionSigner sign app extensions with their own profile and
entitlements.

Requires a Zupersign that honours the bundle_path of each entitlements
entry (xtool-org/zsign, not released yet): bump the zsign requirement
in Package.swift once it is.
Compared with an Xcode-exported IPA, entitlements now match bundle by
bundle: get-task-allow is explicitly false, and capabilities are only
claimed when the app asks for them (beta-reports-active is always
carried over). An app with no entitlements of its own still gets
everything its profile grants.
Loading the default and legacy providers on every call raced with
PKCS12_parse running on other threads: the parse succeeded without a
key or certificate, and parallel tests failed with p12MissingContent.
Points at a branch combining xtool-org/zsign#3, xtool-org#4 and xtool-org#5 until zsign
tags a release including them.
@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

Comment @coderabbitai help to get the list of available commands.

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.

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.

zsign signer doesn't support entitlementMapping

2 participants