iosandroidmobileci

Mobile Code Signing — The Complete Visual Guide (iOS + Android)

Share X in
Mobile Code Signing — The Complete Visual Guide (iOS + Android)

</>

Certificates, keychains & partition lists, provisioning profiles, .p12 files — and the Android mirror. With a real CI experiment.

iOS Code Signing

What code signing actually does

Every iOS executable must carry a cryptographic signature before the kernel will run it. The signature answers two questions on every launch: has this binary been modified since it was signed (integrity), and who signed it (authenticity).

🔒Integrity

The signer hashes every file in the bundle into _CodeSignature/CodeResources plus a CodeDirectory of page hashes over the Mach-O itself. One flipped byte → invalid signature → process killed.

🪪Authenticity

The signature is made with a private key whose certificate chains back to Apple. Only identities Apple issued can sign code that iOS will trust.

📜Policy

Entitlements (push, iCloud, HealthKit…) are embedded in the signature. The kernel (AMFI) checks that your profile actually grants them at install time and runtime.

The Apple trust chain

Apple Root CA burned into every Apple device WWDR Intermediate CA Apple Worldwide Developer Relations iPhone Distribution: YourBank Ltd (TEAM) issued via developer portal, valid 1 year Signed .app / .xcarchive signature = sign(CodeDirectory, private key)

The WWDR certificate Older Xcode setups required you to manually download the Apple Worldwide Developer Relations intermediate into your keychain — without it, codesign -v reported “CSSMERR_TP_CERT_REVOKED / unable to build chain to self-signed root”. Modern Xcode ships it, but CI images built by hand still trip over this.

Identities: CSR, certificates and .p12 files

A signing identity is a pair: a private key in your keychain + an Apple-issued certificate binding your identity to that key. You never send Apple the private key — you send a Certificate Signing Request.

Generate a CSR locally

Keychain Access (or openssl req -new -key priv.pem -out CSR.csr) creates a request signed with a fresh 2048-bit key pair. The private key never leaves the machine.

Apple issues the certificate

Upload the CSR to developer.apple.com → Apple signs your public key with its WWDR CA → you download a .cer. Development certs and distribution certs are separate identities.

Import back into the same keychain

Opening the .cer binds the certificate to the original private key — now Keychain Access shows a proper “signing identity”.

Export as .p12 for CI

A .p12 (PKCS#12) bundles certificate + password-encrypted private key into one portable file — exactly what a headless Mac mini needs:

# export identity from Keychain Access, or programmatically from an existing cert+key
openssl pkcs12 -export -out dist.p12 \
               -inkey dist.key -in dist.crt \
               -certfile ca.crt -passout pass:'DummyCertPass123!'

# later, on the CI Mac — import into a dedicated keychain
security import dist.p12 -k ios-ci.keychain -P 'DummyCertPass123!' \
        -T /usr/bin/codesign -f pkcs12   # -T whitelists codesign in the ACL
Certificate typeSigned byRuns whereLifetimeTypical use
iPhone DeveloperWWDR CARegistered devices only1 yearDaily development, debugger attach
iPhone DistributionWWDR CAPer profile type ↓1 yearAd-hoc OTA, App Store, Enterprise fleets
Enterprise (In-House)WWDR CA (enterprise program)Any device1 yearInternal corporate distribution
Self-signed dummy CI-onlyYour own local CANobody — archive proof onlyYou choosePipelines with no Apple account

The keychain — and why partition lists exist

The keychain is an encrypted store managed by securityd. Every private key carries an ACL (which apps may use it) and a partition list (which signing “partitions” may touch it without user interaction). GUI users get dialogs; daemons get silence.

🔑

Locking & unlocking

Keychains lock after inactivity or sleep. A locked keychain makes every key access fail — CI must call security unlock-keychain -p … first, every build.

📋

Search list vs default

codesign resolves identities through the user search list, not just the --keychain flag. A keychain that isn’t listed yields “no identity found”; a broken default keychain can poison lookups entirely.

🧩

Partition lists

macOS 10.12+ partitions keys so that only approved tooling can use them unattended: apple: (Apple apps), apple-tool: (security tools), codesign:. Without a matching partition, a daemon’s key access either errors or hangs awaiting a dialog.

The command every iOS CI box runs once per build Apply the partition list right after importing the p12 — and put the ephemeral keychain on the search list, because --keychain alone is not enough:

# 1. fresh keychain     2. unlock     3. import     4. SEARCH LIST     5. partition fix
security create-keychain -p "$KC_PASS" ios-build.keychain
security unlock-keychain -p "$KC_PASS" ios-build.keychain
security import $P12_FILE -k ios-build.keychain -P "$P12_PASS" -T /usr/bin/codesign -f pkcs12
security list-keychains -d user -s "$KC" ~/Library/Keychains/login.keychain-db
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KC_PASS" ios-build.keychain

Provisioning profiles & App IDs

A certificate proves who signed. A provisioning profile declares what may be signed and where it may run: which App ID, which certificates, which devices, which entitlements. It lands inside the app as embedded.mobileprovision.

Anatomy of a provisioning profile

Signing Certificates allowed cert SHA-1 hashes App ID com.yourbank.app or wildcard * Devices (UDIDs) dev & ad-hoc only · ≤100/yr embedded.mobileprovision CMS-signed plist · entitlements copy · expiry checked by iOS at install AND every launch
Profile typeNeeds devices?Store submissionExpiryNote
DevelopmentYesNo~7 days (free) / 1 yrXcode auto-manages (“automatic signing”)
Ad HocYesNo1 yrDistribute builds directly to testers’ UDIDs
App StoreNoYes1 yrTestFlight + store; Apple re-signs after review
Enterprise / In-HouseNoNo1 yrAny device; $299/yr program; heavy compliance rules

App IDs An explicit App ID (com.bank.superapp) enables per-capability switches (push, Sign in with Apple, associated domains). A wildcard (com.bank.*) covers many apps but supports almost no capabilities. The bundle ID in Info.plist must match the pattern.

From source to signed archive

One xcodebuild archive compiles, links, embeds frameworks, writes the profile, and invokes codesign bottom-up: every framework and nested bundle first, the outermost app last.

# CI-grade archive invocation (Xcode 26)
xcodebuild archive \
  -project Rampage.xcodeproj \
  -scheme  Rampage \
  -destination 'generic/platform=iOS' \      # no default destination exists!
  -archivePath build/App.xcarchive \
  CODE_SIGN_STYLE=Manual \
  DEVELOPMENT_TEAM=TEAMID1234 \
  CODE_SIGN_IDENTITY="iPhone Distribution: YourBank Ltd (TEAMID1234)" \
  OTHER_CODE_SIGN_FLAGS="--keychain ios-build.keychain --timestamp=none"

# inspect the result
codesign -dv build/App.xcarchive/Products/Applications/App.app
#   Identifier=com.charcoaldesign.Rampage
#   Format=app bundle ... CodeDirectory v=20400 flags=0x0(none) location=embedded
#   Signature size=3699          TeamIdentifier=not set   ← self-signed dummies say this

xcodebuild -downloadPlatform iOS   # CI Macs often lack the platform SDK entirely

📦Inside an .xcarchive

Products/Applications/*.app · dSYMs/ crash symbols · Info.plist metadata · BCSymbolMaps. Zip it and Jenkins can archive it as a build artifact.

⏱️—timestamp=none

Normal signatures get an RFC-3161 timestamp from Apple’s TSA so they outlive the certificate. Self-signed CI identities have no TSA trust — disable timestamps or signing stalls.

Field test: partition vs no-partition on a real CI Mac

Two identical pipelines signed a copy of /bin/ls with a self-signed identity on an M4 mini running Xcode 26 / macOS Tahoe. The only variable: set-key-partition-list.

Same key, same command — one variable changed

import p12 → key NO partition list key ACL not whitelisted set-key-partition-list ✓ apple:, apple-tool:, codesign: allowed codesign BLOCKS FOREVER waits for a GUI auth dialog that cannot appear on a headless agent (or dies instantly: errSecInternalComponent) codesign rc=0 · instant CodeDirectory embedded · Signature size=3699 TeamIdentifier=not set (self-signed, expected) verified: codesign -dv ls-binary-test
RunSetupObserved behaviour
NOPARTITION · build 1ephemeral KC, poisoned default keychain presenterrSecInternalComponent, rc=1 in ~0.3 s
NOPARTITION · build 2clean search list, KC registeredsilent hang — codesign waits forever for an unanswerable GUI prompt; killed by timeout
PARTITION · build 2identical + one commandrc=0 instant signature, verification passes

Debug scars worth remembering A keychain whose password was generated randomly and then lost stays locked forever — if it’s also the default keychain it degrades every lookup. And --keychain alone does not make an identity visible: codesign walks the search list. Fix with security default-keychain -s login.keychain-db + security list-keychains -d user -s ….

Android Build & Signing

Android’s philosophy: everything is a ZIP

An APK is a plain ZIP of DEX bytecode, resources and native libs. There is no mandatory Apple gatekeeper — the platform simply refuses to install an APK whose signature doesn’t verify against the signing certificate(s) recorded during any prior install or update.

Build → sign → ship pipeline

Kotlin / Javasrc + res + manifest Gradle + R8dex · shrink · obfuscate unsigned APK/AABzipaligned apksignerv2/v3 blockkeystore key Play / deviceverify + install

Keystores: Android’s answer to the keychain + p12

Where iOS splits identity across a keychain and an exported .p12, Android folds everything into one file: a Java KeyStore holding a private key + certificate chain under a password and an alias.

# generate an upload keystore (PKCS12 format; legacy JKS is deprecated)
keytool -genkeypair -v \
  -keystore upload-keystore.p12 -storetype PKCS12 \
  -alias upload -keyalg RSA -keysize 4096 -validity 10950

# inspect anything anyone sends you
keytool -printcert -jarfile app.apk
apksigner verify --print-certs app-release.apk

# gradle: secrets come from env vars — never from git
android { signingConfigs { release {
    storeFile     = file("upload-keystore.p12")
    storePassword = System.getenv("KS_PASS")
    keyAlias      = System.getenv("KS_ALIAS")
    keyPassword   = System.getenv("KEY_PASS")
} } }

Validity matters more than on iOS Play requires the signing certificate to be valid until at least October 2033 for app updates to keep installing. Lose the keystore without Play App Signing enrolled and the app can never be updated again. Back it up like production data.

Signature schemes v1 → v4

Each generation patched a weakness of the last. apksigner picks automatically based on minSdkVersion; you should know what each buys you.

SchemeSinceSigns whatWhy it exists
v1 (JAR)Android 1.0each ZIP entry in META-INFOriginal model — but names were unsigned → the Janus vulnerability let attackers inject code while keeping valid v1 signatures
v2Android 7.0whole file, one APKSig block between entries and central directoryany byte flip breaks it; far faster install-time verification
v3Android 9v2 + signed rotation infokey rotation: new cert provably succeeds the old one (proof-of-rotation lineage)
v4Android 11merkle tree sidecar (.idsig)incremental / streaming installs, fs-verity
# explicit control instead of autodetection
apksigner sign \
  --ks upload-keystore.p12 --ks-key-alias upload \
  --v1-signing-enabled true  --v2-signing-enabled true \
  --v3-signing-enabled true \
  --min-sdk-version 24 \
  app-release-unsigned.apk

# zipalign BEFORE v2/v3 signing — after would invalidate the block
zipalign -f -p 4 in.apk out.apk && apksigner sign ...

Play App Signing: upload key vs app signing key

Google holds the actual app signing key and re-signs every artifact delivered to devices. You sign uploads with a separable upload key whose loss or expiry becomes routine to rotate instead of catastrophic.

You sign the AAB with the upload key

./gradlew bundleRelease → signed with upload-keystore. Play verifies it’s really you.

Google strips your signature and re-signs

Split APKs per density/ABI/language come back signed with the app signing key — possibly stored in Google’s hardware-backed HSMs.

Rotation stays cheap

New upload key? Update it in console. Compromised app-signing key? v3 lineage lets a new key prove succession to installed devices.

Artifacts, symbols and debug builds

📦APK vs AAB

An AAB isn’t installable — bundletool build-apks derives optimized split APKs per device. Universal-APK fallback exists for sideloading.

🐛mapping.txt ≈ dSYMs

R8 renames symbols; upload mapping.txt per release so Play Console retraces crashes — the exact analogue of iOS dSYM symbolication.

🛠️debug.keystore

Auto-created at ~/.android/debug.keystore (alias androiddebugkey). Installs signed with it can’t upgrade to a differently-signed release — signature change forces uninstall.

🤝Signature permissions

Apps signed with the same key can share UIDs and request signature-level permissions — the OS trusts them as one author.

Hardware-backed keys Generate keys inside Android’s StrongBox / TEE (setIsStrongBoxBacked(true)) and they never leave secure hardware — signing happens inside the chip, mirroring Apple’s SEP-protected keys.

Side-by-Side & CI Cheat Sheet

iOS ↔ Android translation table

Same concepts, different machinery. Keep this next to you when switching platforms mid-incident.

ConceptiOSAndroid
Private key homeKeychain item (ACL + partition list)Entry inside a KeyStore file
Portable credential bundle.p12 (cert + encrypted key)the .jks/.p12 keystore itself
Certificate issuerApple via WWDR CA (CSR flow)self-signed — nobody issues it
”Who may install?“provisioning profile (+ UDIDs)nothing pre-installed; signature continuity at update time
Capabilities grantentitlements baked into signaturemanifest permissions; some signature-protected
CLI signercodesignapksigner / historically jarsigner
Cert inspectioncodesign -dv, security find-identitykeytool -printcert, apksigner verify
Crash symbolsdSYMs → TestFlight/symbolicatemapping.txt → retrace / Play Console
Beta distributionTestFlight / Ad-Hoc OTAinternal testing track / direct APK
Store re-signingApple re-signs after reviewPlay App Signing re-signs splits
Key loss blast radiusrevoke + new profile (dev), painful (dist)catastrophic unless Play App Signing enrolled
Headless-CI pain pointlocked keychains, partition lists, GUI promptsbasically none — file-based signing just works

CI cheat sheet

🍎iOS runner bootstrap (every build)

security create-keychain -p "$KC_PASS" build-kc
security unlock-keychain -p "$KC_PASS" build-kc
security import "$P12" -k build-kc -P "$P12_PASS" \
        -T /usr/bin/codesign -f pkcs12
security list-keychains -d user -s build-kc
security set-key-partition-list \
  -S apple-tool:,apple:,codesign: -s -k "$KC_PASS" build-kc
# …build… then delete-keychain

🤖Android runner bootstrap

# secret arrives base64'd from vault/CI variables
echo "$KEYSTORE_B64" | base64 -d > release.keystore
./gradlew bundleRelease \
  -PKS_PASS="$KS_PASS" \
  -PKS_ALIAS=upload
apksigner verify --print-certs *.aab 2>/dev/null || \
  bundletool validate

Common errors, decoded

You seePlatformActually meansFix direction
errSecInternalComponentiOSkey ACL/partition denies non-interactive useunlock + set-key-partition-list
codesign silently hangsiOSGUI auth prompt on headless agentsame as above — the prompt IS the bug
no identity foundiOSkeychain missing from search list / expiredlist-keychains -s … ; check cert dates
Unable to find a destinationiOSplatform SDK absent or no destination givenxcodebuild -downloadPlatform iOS; -destination generic/platform=iOS
INSTALL_PARSE_FAILED_NO_CERTIFICATESAndroidv2+ required but only v1 presentapksigner with min-sdk-aware schemes
INSTALL_FAILED_UPDATE_INCOMPATIBLEAndroidinstalled build signed by different keyuninstall, or restore correct keystore
Execution failed for task :signReleaseBundleAndroidkeystore path/password wrong in CI envcheck base64 round-trip + env names

The one-paragraph mental model iOS: Apple owns the root of trust; you prove membership (certificate) and scope (profile), and macOS guards your private key behind ACLs and partition lists that must be explicitly opened for machines without hands.
Android: there is no issuer — you are the root of trust from day one; the platform only guarantees that today’s update was signed by the same entity as yesterday’s install, and modern schemes + Play App Signing exist to keep that promise manageable.

Share this post X in

Enjoyed this post?

Get the next one in your inbox — only when I ship something worth reading.

Newsletter form not configured.

Or follow on Substack for the newsletter.

Comments via GitHub Discussions

Comments not configured. Set GISCUS env vars to enable.