How do you compute a public-key (SPKI) pin, how does the system evaluate a trust object with a custom anchor, and what does that prove?
Predict first
Does the pin match, and when does the system trust a self-signed certificate?
import Foundation
import CryptoKit
import Security
let cert = SecCertificateCreateWithData(nil, Data(base64Encoded: "MIIBWTCCAQCgAwIBAgIJANYf0gp8mYhCMAoGCCqGSM49BAMCMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0yNjExMDUxMTU1NDJaFw0yNzExMDUxMTU1NDJaMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABKFjlAYfdH5YTR9wUqj7qrhG0swSFMtX3mYdmWTuTafhE/yj77blZ3Qp8obMqDlgs1n1Lczn0x2XE8I/OlyvGOyjOzA5MBQGA1UdEQQNMAuCCWxvY2FsaG9zdDATBgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMAoGCCqGSM49BAMCA0cAMEQCIEyKHNyKMNaISLOurXEb74vNgGI4XG8Q0bw7asdOjF6lAiA3VEywjVWYFgAxj8YbJ5rGNUsgeco5ClgM/fAesUeg+Q==")! as CFData)!
// ASN.1 prefix of a SubjectPublicKeyInfo for an uncompressed P-256 point
let prefix: [UInt8] = [0x30,0x59,0x30,0x13,0x06,0x07,0x2a,0x86,0x48,0xce,0x3d,0x02,0x01,0x06,0x08,0x2a,0x86,0x48,0xce,0x3d,0x03,0x01,0x07,0x03,0x42,0x00]
let point = SecKeyCopyExternalRepresentation(SecCertificateCopyKey(cert)!, nil)! as Data
print("point bytes:", point.count)
print("SPKI pin:", Data(SHA256.hash(data: Data(prefix) + point)).base64EncodedString())
func evaluate(anchored: Bool, host: String) -> String {
var trust: SecTrust?
SecTrustCreateWithCertificates(cert, SecPolicyCreateSSL(true, host as CFString), &trust)
SecTrustSetVerifyDate(trust!, Date(timeIntervalSince1970: 1798761600) as CFDate)
if anchored {
SecTrustSetAnchorCertificates(trust!, [cert] as CFArray)
SecTrustSetAnchorCertificatesOnly(trust!, true)
}
var error: CFError?
return SecTrustEvaluateWithError(trust!, &error) ? "trusted" : "rejected: " + (error! as Error).localizedDescription
}
print("system roots:", evaluate(anchored: false, host: "localhost"))
print("cert as anchor:", evaluate(anchored: true, host: "localhost"))
print("anchor, wrong host:", evaluate(anchored: true, host: "evil.test"))
- point bytes: 65 / SPKI pin: DdL8Tgyirop3BxM5gEoZ48fIbmy7Q5EINSmtqm08Rzg= / system roots: rejected: “localhost” certificate is not trusted / cert as anchor: trusted / anchor, wrong host: rejected: “localhost” certificate name does not match input
- cert as anchor: rejected / system roots: trusted
- point bytes: 64 / anchor, wrong host: trusted
Mental model. A pin is a photograph of the key rather than of the whole passport: renewing the passport does not change the key, so the photograph still matches, but you must know which frame of the photograph you are hashing.
Pinning by public key survives certificate renewal as long as the key pair is reused, which is why it is preferred over pinning the certificate (Q317). The pin is the SHA-256 of the certificate’s SubjectPublicKeyInfo: the ASN.1 wrapper that holds the algorithm identifier and the key bytes. SecCertificateCopyKey and SecKeyCopyExternalRepresentation give only the raw key (for P-256, a 65-byte uncompressed point), so you must prepend the fixed 26-byte ASN.1 header for that key type before hashing. The program’s result, DdL8Tgyirop3BxM5gEoZ48fIbmy7Q5EINSmtqm08Rzg=, is identical to what openssl x509 -pubkey | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | base64 prints for the same certificate (I ran that command separately). RSA keys need a different header, which is where most hand-rolled pinning code goes wrong; the declarative NSPinnedDomains Info.plist key (iOS 14+) computes this for you.
The trust evaluation itself uses SecTrust. A self-signed certificate is rejected against the system roots; set the certificate (or your own CA) as the only anchor with SecTrustSetAnchorCertificates and SecTrustSetAnchorCertificatesOnly(true) and it is trusted; and the host name is still checked, so the same trust object fails for another name (all three checked). That is pinning to a CA you control, a good fit for a private backend. Two practical gotchas, both found while preparing the check: a certificate generated by the macOS LibreSSL needs ec_param_enc:named_curve, or the system cannot extract its key, and Apple’s validator rejects certificates with a validity over about 398 days or without a server-auth extended key usage as “not standards compliant”. The check fixes the verification date so it keeps passing after the certificate expires. Pinning is still defeated on a device where an attacker can hook your process (Q350), and a rotation without a backup pin becomes an outage. Not run: an actual TLS server with URLSessionDelegate.
The SPKI hash needs the ASN.1 header; a custom anchor makes the certificate trusted but the host name is still checked.
Common traps
- Hashing the raw key bytes without the ASN.1 header. You get a hash no other tool will reproduce.
- Setting an anchor without
AnchorCertificatesOnly. The system roots are still trusted alongside it. - Pinning with no backup key. A key change then locks out every installed copy.
They might follow up with
- How do you cross-check a pin? Recompute it with OpenSSL from the same certificate.
- When is a custom anchor better than a leaf pin? When you run your own CA and rotate leaf certificates often.
Related questions
- 14.7.2 What exactly does App Transport Security block, which Info.plist keys unlock what, and what do you see when…
- 14.6.3 How do P-256 and Ed25519 signatures differ in CryptoKit: sizes, encodings, determinism, and interoperating…
- 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?
What exactly does App Transport Security block, which Info.plist keys… →