Skip to content

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 vrfHash string. The iOS client inherently trusts the vrfHash matches the user identity but strictly verifies that this hash correctly anchors the publicKey into 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 index and the sequence of Base64 inclusionHashes used to walk up the Merkle Tree.
  • Block 2 (Signed Checkpoint): Contains the treeSize, the rootHash, 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"
According to RFC 6962, the leaf hash is computed by prepending 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.