Key Transparency (KT) — iOS Implementation Guide¶
Overview¶
To guarantee End-to-End Encryption (E2EE) integrity, eCall implements a Key Transparency (KT) mechanism. This system prevents Man-in-the-Middle (MITM) attacks and server compromises by verifying that a peer's public key exists in an append-only, cryptographically verifiable Merkle Tree logging system.
The iOS implementation natively verifies the Checkpoint Signature and Merkle Inclusion Proofs using Apple's CryptoKit without relying on bulky external C/C++ libraries.
Why Key Transparency? (The MITM Problem)¶
To truly understand the value of Key Transparency, we must compare it against traditional API key fetching mechanisms which suffer from a fatal flaw: Blind Trust in the Server.
sequenceDiagram
box rgb(50, 15, 15) Scenario A: Traditional App (Vulnerable to MITM)
participant Alice as iOS App (Caller)
participant API as Main API (Hacked)
participant Hacker as Hacker
end
Alice->>API: 1. "Give me Bob's Public Key"
API->>Hacker: 2. (Server is compromised by Insider/Hacker)
Hacker-->>API: 3. Replaces Bob's Key with Hacker's Key
API-->>Alice: 4. Returns Hacker's Key (Claiming it's Bob's)
note left of Alice: ❌ Blind Trust:<br/>Alice cannot verify ownership.
Alice->>Hacker: 5. Encrypts and sends Call stream to Fake Key
note right of Hacker: 🔓 E2EE BROKEN:<br/>Hacker silently decrypts the call.
box rgb(15, 40, 25) Scenario B: eCall with Key Transparency (Secure)
participant Client as eCall iOS (Caller)
participant KT as KT Server (Hacked)
end
Client->>KT: 1. "Give me Bob's Key + Mathematical Proof"
KT-->>Client: 2. Returns Hacker's Key + Fake Proof
note right of Client: 🛡️ Zero-Trust:<br/>Client does NOT blindly trust.
Client->>Client: 3. KTVerifier performs Merkle Tree calculation
Client->>Client: 4. Signature validation against Pinned Key FAILS
note over Client: 🛑 E2EE SECURED:<br/>MITM Detected. Call Blocked! Architecture & Responsibilities¶
The KT Log runs on the backend (using Go Trillian / c2sp specifications). When an iOS client wants to call a peer, it requests their public key. The server responds with: 1. publicKey: The peer's Ed25519 public key. 2. proof: A formatted string containing the Merkle inclusion proof and signed tree checkpoint. 3. vrfHash: A pre-computed Verifiable Random Function hash of the user's identity.
Important (iOS optimization): To avoid shipping complex ECVRF (Verifiable Random Function) crypto libraries to iOS, the backend performs the ECVRF verification locally and provides the resulting
vrfHashstring. The iOS client inherently trusts thevrfHashmatches the user identity but strictly verifies that this hash correctly anchors thepublicKeyinto the signed Merkle Tree.
iOS Execution Flow (Sequence)¶
sequenceDiagram
participant Call as CallManager / UseCase
participant TKS as TKSAPIService
participant Server as KT Server (stg-kt)
participant Verifier as KTVerifier (iOS)
Call->>TKS: fetchPeerKeys(targets)
note right of TKS: Bypass Main API Proxy<br/>Zero-Trust Pattern
TKS->>Server: POST /api/get-public-keys
Server-->>TKS: JSON (publicKey, proof, vrfHash)
alt Fetch Failed
TKS-->>Call: Error
Call-->>Call: 🛑 Block Call
else Fetch Success
TKS-->>Call: return KTFetchResponse
Call->>Verifier: verifyPeerKey(publicKey, proof, vrfHash)
rect rgb(40, 44, 52)
note right of Verifier: Zero-Trust Local Verification
Verifier->>Verifier: 1. Parse proof string (Tlog format)
Verifier->>Verifier: 2. Verify Checkpoint Ed25519 Signature<br/>(Against hardcoded KT_PUBLIC_KEY)
Verifier->>Verifier: 3. Compute LeafHash: SHA256(0x00 + vrfHash + publicKey)
Verifier->>Verifier: 4. Compute Merkle Root: SHA256(0x01 + left + right)
Verifier->>Verifier: 5. Assert: Computed Root == Signed Checkpoint Root Hash
end
alt Verification Failed
Verifier-->>Call: throw KTError
Call-->>Call: 🛑 Block Call (Anti-MITM)
else Verification Passed
Verifier-->>Call: Success
Call-->>Call: ✅ Proceed to generate E2EE Session
end
end The Verification Algorithm¶
To legally and technically prove the non-repudiation and anti-MITM attributes of the app, the iOS client performs hardcore mathematical validation. Below is the exact Cryptographic Execution Flowchart running on the device.
flowchart TD
classDef crypto fill:#1a1c29,stroke:#e63946,stroke-width:2px,color:#fff
classDef input fill:#f8f9fa,stroke:#adb5bd,stroke-width:1px,color:#212529
classDef check fill:#e9c46a,stroke:#e76f51,stroke-width:2px,color:#212529
classDef success fill:#2a9d8f,stroke:#264653,stroke-width:2px,color:#fff
classDef fail fill:#e63946,stroke:#780000,stroke-width:2px,color:#fff
subgraph inputs[Inputs from STG-KT Server]
R1[publicKey]:::input
R2[vrfHash]:::input
R3[tlog-proof String]:::input
end
subgraph parsing[Format Parser]
R3 -->|Split by double newline| P1[Inclusion Proof Block]
R3 -->|Split by double newline| P2[Signed Checkpoint Block]
P1 --> E1[Extract: index, inclusionHashes array]
P2 --> E2[Extract: origin, treeSize, rootHash, Ed25519 Signature]
end
subgraph sigcheck[1. Checkpoint Verification]
E2 --> SigStr[Construct Target String:<br/>origin + treeSize + rootHash]
E2 --> SigBytes[Extract Raw 64-byte Signature]
SigStr --> EdVerify
SigBytes --> EdVerify
PinnedKey[(Pinned KT_PUBLIC_KEY<br/>32-byte Ed25519)] -.-> EdVerify{CryptoKit.Curve25519<br/>Verify Signature}:::crypto
EdVerify -->|Invalid| Abort1[Block: Signed Root is Fake]:::fail
end
subgraph leafhash[2. Leaf Hash Computation]
R1 --> LFormat
R2 --> LFormat
EdVerify -->|Valid Checkpoint| LFormat["Format bytes:<br/>vrfHash + '\\n' + publicKey + '\\n'"]
LFormat --> LSha{"Compute RFC6962 Leaf:<br/>SHA256( 0x00 || FormattedLeaf )"}:::crypto
end
subgraph merklepath[3. Merkle Root Re-calculation]
E1 --> Loop
LSha --> Loop[("Loop through inclusionHashes<br/>calculating path to root")]
Loop --> InnerHash{"Inner Node Hash:<br/>SHA256( 0x01 || left || right )"}:::crypto
InnerHash -.-> Loop
Loop --> ComputedRoot[Computed Merkle Root]
end
subgraph finalcheck[4. Zero-Trust Assertion]
ComputedRoot --> Compare{"ComputedRoot == rootHash?"}:::check
E2 -->|rootHash| Compare
Compare -->|Mismatch| Abort2["Block: MITM / Proof Tampered"]:::fail
Compare -->|Match| Safe["Mathematical Proof Confirmed:<br/>Key is Authenticated & Safe"]:::success
end The verification process happens in KTVerifier.shared.verifyPeerKey(). If any step fails, the call is blocked immediately.
Step 1: Parse the Proof Format¶
The proof string is formatted using the c2sp.org/tlog-proof@v1 standard. It is split into two blocks separated by a double newline (\n\n).
- Block 1 (tlog & inclusion path): Contains the leaf
indexand the sequence of Base64inclusionHashesused to walk up the Merkle Tree. - Block 2 (Signed Checkpoint): Contains the
treeSize, therootHash, and an Ed25519 signature over this text.
Step 2: Verify Checkpoint Signature¶
The checkpoint prevents the server from presenting fake Merkle Roots. - Payload: The 3 lines of text containing the origin name, tree size, and root hash (without blank lines). - Signature Extraction: Extract the 68-byte signature string. The first 4 bytes are a Key ID (discarded), and the remaining 64 bytes are the raw Ed25519 signature. - Verification: Verified using CryptoKit and a hardcoded, pinned Ed25519 Public Key injected via (KT_PUBLIC_KEY). If the server is compromised and signs a fake root with a different key, this step catches it.
Step 3: Compute Leaf Hash¶
We construct the exact byte sequence of the Trillian leaf entry:
entryText = vrfHashBase64 + "\n" + publicKeyBase64 + "\n"
0x00: leafHash = SHA256( 0x00 + entryText )
Step 4: Compute Merkle Root¶
Using the leafHash, the index, the treeSize, and the extracted inclusionHashes, we iteratively hash our way up to the root. - Inner nodes are computed via RFC 6962: SHA256( 0x01 + leftNode + rightNode ).
Step 5: Assert Root Match¶
The computed Merkle Root is compared against the rootHash extracted from the server's signed checkpoint. - If they match, the publicKey is untampered and cryptographically bound to the transparency log.
Eager KT Caching & Call-Time Trust Validation Lifecycle¶
The lifecycle of Key Transparency in eCall combines Proactive Pre-warming and Trust-On-First-Use (TOFU) validation to balance extreme security with Zero Latency call initiation.
1. Friendship Establishment (Eager Caching)¶
When User A adds User B, or when User B accepts User A's friend request: 1. The standard REST API returns an array of publicKeyHashs strings representing the devices of the peer. 2. Immediately in the background, a proactive request is sent to the KT Server fetching the corresponding Merkle Proofs for these hashes. 3. Upon receiving the KT response, the client executes the KTVerifier algorithm (verifying signatures and Merkle trees to detect Provider hacks). - If KT is mathematically compromised, the keys are discarded safely. - If the mathematical proof succeeds, the verified public keys' VRF hashes are safely committed to the local PeerTrustStore. This anchors the peer's identity locally.
2. Call Initiation (Trust Overrides)¶
Whenever a user initiates a call: 1. The app fetches the latest real-time Key Transparency proofs from the KT Server. 2. The KTVerifier runs again. If the signature verification FAILS (Mathematically compromised provider or Man-in-the-Middle), the call is blocked, and an alert may warn the user of an eavesdropping attempt. 3. If the mathematical check succeeds, the app validates the Trust Override: It cross-references the fetched public keys with the local PeerTrustStore cached during the Friend request phase. - Match (.match or .noCacheFound for old friends): The call initiates immediately. - Mismatch (.mismatch): The peer has added or removed a device (or an attacker injected a device). A security popup warns the user. - Accept & Continue: The user accepts the risk. The system OVERRIDES the old local PeerTrustStore cache with the newly verified list of keys, and seamlessly executes startCall using the existing WebRTC signaling flow.
End-to-End Business Flow (User Journey)¶
To visualize this pipeline simply for Business, Product Managers and Non-Tech members, here is the functional workflow representing the user experience:
sequenceDiagram
autonumber
actor A as Caller (You)
participant REST as Main Backend API
participant KT as Key Transparency Server
box rgb(40, 44, 52) Local Device Storage
participant TS as PeerTrustStore
end
actor B as Callee (Friend)
rect rgb(235, 245, 255)
note right of A: Phase 1: Eager Caching (Friendship Established)
A->>REST: Add Friend / Accept Friend
REST-->>A: [publicKeyHashs] list
A->>KT: Fetch Public Keys & Merkle Proofs<br>(using target hashes)
KT-->>A: Raw KT Response
A->>A: Mathematical Verification<br>(Signatures & Merkle Tree)
alt KT is Hacked (ECDSA fails)
A-->>A: Drop Data (Reject)
else KT is Safe
A->>TS: Save Verified Hashes to Cache (Anchor Trust)
end
end
rect rgb(235, 255, 240)
note right of A: Phase 2: Call Initiation (Zero-Trust Validation)
A->>A: User taps "Call"
A->>KT: Fetch Public Keys & Merkle Proofs
KT-->>A: Raw KT Response
A->>A: Mathematical Verification
alt KT is Hacked / Tampered
A->>A: SHOW POPUP: "Dangerous / Appears Eavesdropped"
A-->>A: Abort / Block Call
else KT is Safe
A->>TS: Compare Fetched Keys against Cached Keys
alt Keys Match exactly
A->>B: Start Encrypted Call (Seamless)
else Keys Mismatch (Device Added/Removed or injected)
A->>A: SHOW POPUP: "Identity keys changed"
alt User taps "End Call"
A-->>A: Call Aborted
else User taps "Accept & Continue"
A->>TS: Override Cache with New Keys
A->>B: Start Encrypted Call
end
end
end
end Adversarial Threat Model & Mitigations¶
The KTVerifier is designed to withstand multiple attack vectors, mapped directly to Unit Tests in KTVerifierTests.swift:
| Threat | Attack Vector | Mitigation (iOS Result) |
|---|---|---|
| Man-in-the-Middle | Attacker intercepts API and replaces publicKey with their own. | leafHash diverges → Computed root doesn't match signed root. Throws .invalidInclusionProof. |
| Identity Spoofing | Compromised backend returns Alice's key but Bob's vrfHash. | leafHash diverges → Throws .invalidInclusionProof. |
| Log Rewriting (Tampering) | Attacker changes rootHash or treeSize in the checkpoint text. | Signature over the checkpoint becomes invalid. Throws .signatureVerificationFailed. |
| Spoofed Provider | Attacker spins up a fake KT server and signs fake trees with their own Private Key. | CryptoKit rejects the signature because the pinned KT_PUBLIC_KEY does not match. Throws .signatureVerificationFailed. |
| Crash / DoS Attacks | Malformed HTTP response, garbage strings, missing boundaries. | Safe parser architecture catches nil bounds. Throws .invalidNoteFormat or .invalidBase64 instead of crashing. |
Pinned Keys Management¶
The KTVerifier relies on the pinned string logCheckpointVKeyBase64. This string is a 32-byte Ed25519 raw public key. Warning: Every time the backend KT infrastructure rotates its signing keys, this Base64 string in KTVerifier.swift MUST be updated, or all clients will fail verification and be unable to make calls.