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:
delegateIfNeededreturnsnil→ URLSession uses default system validation (no pinning). Allows Charles/Proxyman during development. - Production:
delegateIfNeededreturnsSSLPinningManager.shared→ FAIL-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¶
- Run a debug build against the target server.
- 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... - Copy the INTERMEDIATE and ROOT hashes → add to
intermediateCAsinSSLPinningManager.swift. - 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).
Related Docs¶
- Networking API client behavior:
../networking/api-client.md