How do you validate the x5c certificate chain of an App Store JWS, and what does the leaf certificate prove?
Predict first
Which of these chain and signature checks pass on the generated demo PKI?
import Foundation
import Security
import CryptoKit
// a throw-away demo PKI (root > intermediate > leaf) generated with openssl; not Apple's
let rootB64 = "MIIBQTCB6KADAgECAgkAudr+QDhpWD4wCgYIKoZIzj0EAwIwFDESMBAGA1UEAwwJRGVtbyBSb290MB4XDTI2MTEwNjA1NDM0OFoXDTM2MTEwMzA1NDM0OFowFDESMBAGA1UEAwwJRGVtbyBSb290MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEqFvjzFgEBpL6nrH2wv2teDuvak1TtqPAWDeSQlK8v76ya1DZiT20bYupt+SpLHIuU50t6g9QphvoKSWSdrKg/aMjMCEwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwCgYIKoZIzj0EAwIDSAAwRQIgY8fKIcdgn1/mLJVx4Rr1L6h9wsP6OGrFMxaSd2aD2C8CIQCKpdi8vlslYjj1HHrC0GdPiJCtCIid5knYqhBK58pULQ=="
let intB64 = "MIIBVjCB/KADAgECAgkA+EQNixOT6OUwCgYIKoZIzj0EAwIwFDESMBAGA1UEAwwJRGVtbyBSb290MB4XDTI2MTEwNjA1NDM0OFoXDTM1MDEyMzA1NDM0OFowEzERMA8GA1UEAwwIRGVtbyBpbnQwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASi7NwQHfCnoKzAa9FU23yQ6+P0jt32ke7/wCbR85hFohqEzP3/3REkCKujPH/Pw9FVwd/0Thf4cCL53Tm+0wEVozgwNjASBgNVHRMBAf8ECDAGAQH/AgEAMA4GA1UdDwEB/wQEAwIBBjAQBgoqhkiG92NkBgJjBAIFADAKBggqhkjOPQQDAgNJADBGAiEA22hPnbgogI4GZdoOJfgcQWx7VT2rP8lRamxzf0UwUQUCIQCozREQA8Aun+4J+PjucWiMr/WA4twom5Ch02F8owzkhw=="
let leafB64 = "MIIBTjCB9qADAgECAgkA6ofioGNNO0YwCgYIKoZIzj0EAwIwEzERMA8GA1UEAwwIRGVtbyBpbnQwHhcNMjYxMTA2MDU0MzQ4WhcNMzUwMTIzMDU0MzQ4WjAUMRIwEAYDVQQDDAlEZW1vIGxlYWYwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARzqYLyROFjrcUQ0ziXIGiOzTWoUHcLjVvlDw1izolJnj9DwbeMfVd44N8MN6yMY963kqzTouS9ewNmxOi8F9rSozIwMDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDAQBgoqhkiG92NkBgsBBAIFADAKBggqhkjOPQQDAgNHADBEAiAf1/MOVDgW/m3LPKIDbI06Kp67HyrsYPkQrZ/MdW/2HAIgGumUj85RQNy6+6tQ0B7jxO2z/uJKCIEpk+4kGxPvmwY="
let leafKeyPEM = """
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg+Uy9Y1eHSWdNACUy
JfWwfXDaaIVkE8j4OuBFHJ7YF9mhRANCAARzqYLyROFjrcUQ0ziXIGiOzTWoUHcL
jVvlDw1izolJnj9DwbeMfVd44N8MN6yMY963kqzTouS9ewNmxOi8F9rS
-----END PRIVATE KEY-----
"""
func cert(_ b64: String) -> SecCertificate { SecCertificateCreateWithData(nil, Data(base64Encoded: b64)! as CFData)! }
let root = cert(rootB64), mid = cert(intB64), leaf = cert(leafB64)
func chainOK(_ chain: [SecCertificate], anchor: SecCertificate) -> Bool {
var trust: SecTrust?
SecTrustCreateWithCertificates(chain as CFArray, SecPolicyCreateBasicX509(), &trust)
SecTrustSetAnchorCertificates(trust!, [anchor] as CFArray)
SecTrustSetAnchorCertificatesOnly(trust!, true)
return SecTrustEvaluateWithError(trust!, nil)
}
let leafOID: [UInt8] = [0x06, 0x0A, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x63, 0x64, 0x06, 0x0B, 0x01]
func hasOID(_ c: SecCertificate, _ oid: [UInt8]) -> Bool {
let der = [UInt8](SecCertificateCopyData(c) as Data)
return der.indices.contains { i in i + oid.count <= der.count && Array(der[i..<i + oid.count]) == oid }
}
print("full chain to the pinned root:", chainOK([leaf, mid, root], anchor: root))
print("intermediate missing:", chainOK([leaf], anchor: root))
var bad = [UInt8](SecCertificateCopyData(leaf) as Data); bad[bad.count - 5] ^= 0xFF
if let tampered = SecCertificateCreateWithData(nil, Data(bad) as CFData) {
print("tampered leaf:", chainOK([tampered, mid, root], anchor: root))
} else { print("tampered leaf: cannot be parsed") }
print("leaf has the marker OID:", hasOID(leaf, leafOID), "| intermediate has it:", hasOID(mid, leafOID))
let pub = SecKeyCopyExternalRepresentation(SecCertificateCopyKey(leaf)!, nil)! as Data
let leafPub = try! P256.Signing.PublicKey(x963Representation: pub)
let priv = try! P256.Signing.PrivateKey(pemRepresentation: leafKeyPEM)
let msg = Data("header.payload".utf8)
let sig = try! priv.signature(for: msg)
print("signature verifies with the certificate's key:", leafPub.isValidSignature(sig, for: msg))
print("changed data verifies:", leafPub.isValidSignature(sig, for: Data("header.payload2".utf8)))
- full chain true, missing intermediate false, tampered false, OID in the leaf only, signature true, changed data false
- a chain without the intermediate is still trusted
- the tampered leaf is trusted
Mental model. A passport check: the document is only as good as the issuing country you already recognise, and the page you inspect must carry the right stamp.
The x5c chain is only meaningful if it ends at a root you already trust: download Apple's root (Apple Root CA G3 for current App Store signing, from Apple's PKI page) and compile it into the server, and never accept a root presented in the token itself. Validate that the chain builds from leaf through intermediate to that pinned root, that every certificate is within its validity dates, and that the leaf carries the Apple marker extension for App Store receipt signing and the intermediate carries its own marker OID (Apple's documented OIDs sit under 1.2.840.113635.100.6; take the exact values from Apple's current documentation or library). Revocation checking (OCSP) is optional and a policy choice. Only then use the leaf's public key to verify the JWS signature.
The check builds a throw-away root, intermediate and leaf with openssl (with a marker OID in the leaf standing in for Apple's), and runs it through Security's SecTrust with the root as the only anchor: the full chain is trusted, a chain missing its intermediate is not, a tampered leaf is not, and the marker OID is found in the leaf and not in the intermediate. A signature made with the leaf's private key verifies with the public key extracted from the certificate and fails for different data. The OID search here is a byte search on the DER, which is enough for a demo; a production check should parse extensions properly. Apple's real chain, its real OIDs, revocation and expiry handling were not exercised, since they need Apple-issued tokens. This is the point where hand-rolled code goes wrong, so use the official libraries for production.
The chain only works when every link up to the pinned anchor is present and unmodified.
Common traps
- Trusting the last certificate in
x5cas the root. Compare against your own pinned copy, or any forger can ship their own chain. - Verifying the chain but not the leaf's purpose. A valid Apple certificate for something else must not sign App Store data.
They might follow up with
- What if a certificate expires? Apple rotates intermediates; refresh pinned roots and tolerate expiry per documented policy.
- Should this run on the device? StoreKit already does; the server needs its own copy of the check.
Related questions
- 28.4.1 How is a StoreKit JWS built, and what must a server check before trusting signedTransactionInfo?
- 28.1.3 What does a .verified StoreKit result prove, what does it not prove, and when would you read…
- 12.2.2 How do TLS and App Transport Security protect a request, and what is certificate pinning, and why is it…
- 14.4.3 What can jailbreak detection and pinning actually achieve, and what are their limits?
← How is a StoreKit JWS built, and what must a server check before… How do App Store Server Notifications V2 behave, and how do you… →