Lab 9.2 — Certificate Pinning

Goal: Add TrustKit-style SPKI pinning to a sample API client, then verify with mitmproxy that the pinning blocks man-in-the-middle interception.

Time: ~2 hours

Prerequisites:

  • Phase 9 chapter 9.3 (Network Security & TLS Pinning)
  • mitmproxy installed (brew install mitmproxy)
  • openssl (preinstalled on macOS)
  • An iOS device or simulator on the same Wi-Fi as your Mac

Setup

  1. Clone the starter:
git clone https://github.com/bl9/swift-engineer-labs.git
cd swift-engineer-labs/09-security/certificate-pinning/starter
open APIPinningLab.xcodeproj
  1. The starter app calls https://httpbin.org/get via a plain URLSession. No pinning, no delegate.

Tasks

Task 1 — Extract the SPKI hash for httpbin.org

openssl s_client -servername httpbin.org -connect httpbin.org:443 < /dev/null 2>/dev/null \
  | openssl x509 -pubkey -noout \
  | openssl pkey -pubin -outform DER \
  | openssl dgst -sha256 -binary \
  | openssl enc -base64

Note the result — that’s your primary pin. Also extract pins for at least one intermediate or root in the chain as backup, so cert renewal doesn’t break you:

openssl s_client -servername httpbin.org -showcerts -connect httpbin.org:443 < /dev/null 2>/dev/null \
  | awk '/BEGIN CERT/,/END CERT/' \
  | csplit -s -f cert- - '/BEGIN CERT/' '{*}'
# For each cert-NN, extract the SPKI hash
for f in cert-*; do
  openssl x509 -in "$f" -pubkey -noout \
    | openssl pkey -pubin -outform DER \
    | openssl dgst -sha256 -binary \
    | openssl enc -base64
done

Task 2 — Implement the pinning delegate

import CryptoKit

final class PinnedDelegate: NSObject, URLSessionDelegate {
    let pins: Set<String>

    init(pins: Set<String>) { self.pins = pins }

    func urlSession(
        _ session: URLSession,
        didReceive challenge: URLAuthenticationChallenge,
        completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
    ) {
        guard
            challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
            let trust = challenge.protectionSpace.serverTrust
        else {
            completionHandler(.performDefaultHandling, nil)
            return
        }

        var error: CFError?
        guard SecTrustEvaluateWithError(trust, &error) else {
            completionHandler(.cancelAuthenticationChallenge, nil)
            return
        }

        let count = SecTrustGetCertificateCount(trust)
        for i in 0..<count {
            guard let cert = SecTrustGetCertificateAtIndex(trust, i),
                  let publicKey = SecCertificateCopyKey(cert),
                  let keyData = SecKeyCopyExternalRepresentation(publicKey, nil) as Data?
            else { continue }
            let hash = Data(SHA256.hash(data: keyData)).base64EncodedString()
            if pins.contains(hash) {
                completionHandler(.useCredential, URLCredential(trust: trust))
                return
            }
        }
        completionHandler(.cancelAuthenticationChallenge, nil)
    }
}

Task 3 — Wire the session

let session = URLSession(
    configuration: .default,
    delegate: PinnedDelegate(pins: ["PRIMARY...", "BACKUP..."]),
    delegateQueue: nil
)

Run the app; the request should succeed. Network tab in Xcode should show 200 OK from httpbin.

Task 4 — Verify with mitmproxy

# Terminal 1
mitmproxy --listen-port 8080

On your device:

  1. Settings → Wi-Fi → tap (i) → Configure Proxy → Manual → Server: your Mac’s LAN IP, Port: 8080
  2. Visit http://mitm.it in Safari, follow instructions to install the mitmproxy CA profile, then Settings → General → About → Certificate Trust Settings → enable for mitmproxy

Now re-run your app. Without pinning the request would appear in mitmproxy’s flow list. With pinning correctly implemented, the request should fail — URLError with cancelled or secureConnectionFailed. Verify both:

  1. Temporarily disable pinning → request appears in mitmproxy
  2. Re-enable pinning → request fails before reaching mitmproxy (you’ll see a TLS handshake failure in mitmproxy’s event log, not in the flow list)

Task 5 — Add pinning failure metrics

Add an analytics callback that reports pinning-failure events distinct from other network errors. Differentiate by checking the URLError code and the certificate-validation context. Verify your local logger captures pinning-specific failures during the mitmproxy test.

Task 6 — Remote kill-switch

Add a PinningConfig struct fetched from a remote endpoint (mock it with a hardcoded true for the lab). When enforce = false, the delegate should call .performDefaultHandling and let the system trust prevail. Verify the switch works by toggling the value, restarting, and confirming mitmproxy can now intercept.

Stretch goals

  1. TrustKit integration — replace the hand-rolled delegate with TrustKit’s pinning policy. Compare LOC and feature set.
  2. Multi-host pinning — pin different hosts to different pin sets. The hand-rolled delegate currently doesn’t dispatch by host; add that.
  3. Synthetic monitoring — write a unit test that uses URLProtocol stubbing to feed in a server trust object built from a fake cert, and verifies the delegate correctly rejects it.
  4. App Attest — additionally require an App Attest assertion on each request as a second wall.

Notes

  • After the lab, remove the proxy settings from your device. Leaving mitmproxy trusted is itself an M5 violation.
  • httpbin.org occasionally rotates certs. If the lab stops working in a few months, refresh your pin extraction.
  • The hand-rolled delegate above is for learning; production apps should use TrustKit for the additional features (per-host config, reporting, backup pin support out of the box).

Next: Lab 9.3 — Security Audit