Skip to content

SSL Public Key Pinning

Goal

Prevent MITM attacks (Charles, Proxyman, mitmproxy) by validating the server TLS certificate chain against pinned public key hashes.

This project implements public-key pinning (SHA-256) via SSLPinningManager.

Canonical Code

  • Pinning implementation: ecall/Core/Security/SSLPinningManager.swift
  • REST API client uses pinning delegate: ecall/Core/Networking/APIClient.swift
  • Janus WS client uses pinning delegate: ecall/Modules/Call/Managers/Signaling/JanusSocketClient.swift
  • STOMP client forwards challenges to pinning manager: ecall/Modules/Call/Managers/Signaling/STOMPClient.swift

Where Pinning is Applied

Channel How it is pinned Notes
REST API URLSession(delegate: SSLPinningManager.shared) Used by APIClient
STOMP (WSS) STOMPClient implements urlSession(_:didReceive:...) and forwards to SSLPinningManager Signaling channel
Janus (WSS) JanusSocketClient uses URLSession(delegate: SSLPinningManager.shared) Media signaling channel

Environment Behavior

Pinning behavior is environment-dependent via delegateIfNeeded:

  • Dev/Staging: delegateIfNeeded returns nil → URLSession uses default system validation (no pinning). Allows Charles/Proxyman during development.
  • Production: delegateIfNeeded returns SSLPinningManager.sharedFAIL-CLOSED pinning enforced. Any mismatch → connection rejected (cancelAuthenticationChallenge).

Important: There is NO graceful fallback. Production mismatch = connection blocked. This is intentional — graceful fallback would allow MITM proxy tools to bypass pinning entirely.

Security Layers

SSLPinningManager enforces 3 layers of defense in Production:

Layer Check Action on Failure
1. Proxy Detection CFNetworkCopySystemProxySettings checks HTTP/SOCKS proxy Reject connection immediately
2. Chain Trust SecTrustEvaluateWithError validates cert chain against iOS trust store Reject (blocks self-signed certs)
3. Public Key Pinning SHA-256 hash of public key compared against hardcoded pins Reject (blocks proxy certs)

Pinning Strategy: Root CA + Intermediate CA + Leaf

SSLPinningManager uses a unified PinnedKeys struct with two tiers:

  • intermediateCAs — Root CA + Intermediate CA public key hashes. Stable across cert renewals (5-20+ year lifespan). Primary defense.
  • leafCerts — Leaf certificate public key hashes. Changes on every cert renewal (~90 days). Kept as secondary match.

Both tiers are combined for validation. A match on any hash in any tier passes pinning.

Why Root + Intermediate CA?

TLS certificates auto-renew every ~90 days (e.g., Let's Encrypt). If the renewal generates a new key pair, leaf hashes change. Since App Store review takes 1-3 days, hardcoded leaf-only pinning can block all users until a new build is approved.

Root CA certificates (20+ years) and Intermediate CA certificates (5-10 years) have much longer lifespans and rarely change, making them ideal for pinning in mobile apps.

90-Day Cert Renewal Safety

When Let's Encrypt auto-renews the leaf cert:

Loop iteration 0 (LEAF):         hash = NEW → no match → continue
Loop iteration 1 (INTERMEDIATE): hash = SAME → ✅ MATCH → return true
Loop iteration 2 (ROOT):         not checked — already passed

Result: App continues working. No App Store update needed.

When Does Pinning Break?

Scenario LEAF INTERMEDIATE ROOT Result Action
90-day cert renewal ❌ new ✅ same ✅ same PASS None needed
Intermediate CA rotation (~5-10 yr) ❌ new ❌ new ✅ same PASS None needed
CA provider change (e.g., Let's Encrypt → DigiCert) ❌ new ❌ new ❌ new FAIL Update hashes before switching

Validation Flow

1. Extract hostname from URLAuthenticationChallenge
2. Skip if Dev/Staging environment
3. REJECT if HTTP/SOCKS proxy detected (defense-in-depth)
4. Fetch expected pinned hashes for hostname (fail-closed if unknown host)
5. SecTrustEvaluateWithError — validate cert chain trust (blocks self-signed)
6. Read certificate chain (SecTrustCopyCertificateChain), iterate first 3 certs:
   - Index 0: Leaf (🟢)
   - Index 1: Intermediate CA (🔵)
   - Index 2: Root CA (🔴)
7. For each cert:
   - Extract SecKey public key
   - Export as bytes (SecKeyCopyExternalRepresentation)
   - Compute SHA-256 and compare with pinned hash set
8. If ANY hash matches → allow connection
9. If NO match → cancelAuthenticationChallenge (REJECT)

Proxy Detection

SSLPinningManager.isProxyDetected checks CFNetworkCopySystemProxySettings for: - HTTP proxy (kCFNetworkProxiesHTTPEnable) - SOCKS proxy (SOCKSEnable)

When a proxy is detected in Production, the connection is rejected before hash validation occurs. This is a defense-in-depth layer — even if an attacker somehow had a valid cert chain, the proxy detection would still block the connection.

Extracting Hashes

  1. Run a debug build against the target server.
  2. Check Xcode console for logs like:
    🔍 [🟢 LEAF] Certificate 0 hash for api.ecall.org: abc123...
    🔍 [🔵 INTERMEDIATE] Certificate 1 hash for api.ecall.org: def456...
    📋 COPY THIS 🟢 LEAF HASH (api.ecall.org): abc123...
    
  3. Copy the INTERMEDIATE and ROOT hashes → add to intermediateCAs in SSLPinningManager.swift.
  4. Copy the LEAF hash → add to leafCerts (secondary, will change on renewal).

Note: Hash logging only appears in debug builds. Release builds strip all hash output from console to prevent information disclosure.

Operational Notes (Certificate Rotation)

  • Root + Intermediate CA hashes survive cert auto-renewal — no app update needed for 5-20+ years.
  • Leaf hashes change on renewal — not urgent since CA hashes cover it. Update when convenient.
  • Multiple hashes per tier are supported for safe rotation windows.
  • No graceful fallback — if all 3 tiers fail, connection is blocked. This is by design to prevent MITM bypass.
  • The only scenario requiring an urgent app update is changing CA provider (known in advance).
  • Networking API client behavior: ../networking/api-client.md