Key Transparency (KT)¶
1. Overview¶
This system implements Centralized Key Transparency, the same model used by WhatsApp, Google, and Signal. Every device's public key is recorded in an append-only Merkle Tree (powered by Tessera + Torchwood).
Threat Model¶
The system defends against two categories of attacks:
| Threat | Actor | Attack Vector | KT Defense |
|---|---|---|---|
| Insider Threat (Malicious Provider) | Server operator with full infrastructure access | Inject a fake public key for a user to intercept their communications | Every key change is permanently pinned in the append-only log. The device's periodic audit detects the unauthorized key and raises a red alert. |
| External MitM Attack | Network-level attacker (ISP, rogue Wi-Fi, compromised CDN) | Intercept the key exchange and substitute their own public key | The substituted key would either (a) not be in the Merkle Tree at all (inclusion proof fails), or (b) be in the tree but not match the user's local key (key comparison fails). Both cases are detected by the client. |
The goal is not to prevent these attacks from happening — it is to detect them after the fact ("catch them red-handed"). The append-only Merkle Tree ensures tmarkdownhat any tampering leaves a permanent, cryptographic trail that cannot be erased.
graph LR
subgraph "Threats"
T1["Malicious DevOps<br/>(injects fake key into log)"]
T2["MitM Attacker<br/>(substitutes key in transit)"]
end
subgraph "KT Defenses"
D1["Append-only Log<br/>(cannot erase evidence)"]
D2["Checkpoint Signature<br/>(cannot forge log state)"]
D3["Merkle Inclusion Proof<br/>(proves key is in the tree)"]
D4["Client Key Comparison<br/>(detects any key change)"]
end
T1 -->|"detected by"| D1
T1 -->|"detected by"| D4
T2 -->|"detected by"| D3
T2 -->|"detected by"| D4
D1 --- D2 2. Architecture¶
2.1 System Components¶
graph TB
subgraph "KT Server"
GRPC["gRPC Server<br/>(Register)"]
HTTP["HTTP Server<br/>(GetPublicKeys, Tile Serving)"]
LM["Log Manager"]
TESS["Tessera<br/>(Append-only Merkle Tree)"]
DISK["POSIX Storage<br/>(Tiles + Checkpoint)"]
end
subgraph "Clients"
APP_A["Alice iOS"]
APP_B["Bob iOS"]
end
subgraph "External"
API["Ecall API"]
AUD["Third-Party Auditor"]
end
API -- "gRPC: Register" --> GRPC
APP_A -- "HTTP: GetPublicKeys" --> HTTP
APP_B -- "HTTP: GetPublicKeys" --> HTTP
AUD -- "HTTP: GET /checkpoint, /tile/..." --> HTTP
GRPC --> LM
HTTP --> LM
LM --> TESS
TESS --> DISK 2.2 Registration Flow¶
sequenceDiagram
participant D as Device (iOS)
participant API as Ecall API
participant KT as KT Server
D->>D: Generate Ed25519 keypair
D->>D: Store private key in Keychain
D->>API: Login / Register device
API->>KT: gRPC Register(userID, deviceID, publicKey)
KT->>KT: VRF.Prove(userID_deviceID) -> vrfHash
KT->>KT: entry = vrfHash + newline + publicKey + newline
KT->>KT: Append entry to Merkle Tree
KT-->>API: index: 42, vrfHash: "..."
API->>API: Store publicKeyIndex=42, vrfHash in device record
API-->>D: Registration success 2.3 Third-Party Auditor Flow¶
sequenceDiagram
participant AUD as Auditor (Go/Rust)
participant KT as KT Server (HTTP)
AUD->>KT: GET /checkpoint
KT-->>AUD: Signed checkpoint
AUD->>AUD: Verify checkpoint Ed25519 signature
loop For each entry index 0..treeSize-1
AUD->>KT: GET /tile/entries/...
KT-->>AUD: Entry bundle (tile data)
AUD->>AUD: Parse entry: extract vrfHash, publicKey
AUD->>AUD: Compute leafHash = SHA256(0x00 || entry)
AUD->>AUD: VRF.Verify(vrfPublicKey, vrfProof, input)
AUD->>AUD: Verify leafHash against Merkle tree
end
AUD->>AUD: Verify full tree hash == checkpoint rootHash
AUD->>AUD: Log integrity confirmed or discrepancy found 3. Security Model¶
3.1 Verification Responsibilities¶
| Component | Verified by | Method | Purpose |
|---|---|---|---|
| Checkpoint Signature | Client (iOS) + Auditor | Ed25519 | Proves the log state is authentic |
| Merkle Inclusion Proof | Client (iOS) + Auditor | SHA256 path recomputation | Proves the entry exists in the tree |
| Public Key Comparison | Client (iOS) | Local key comparison | Detects key substitution (MitM) |
| VRF Identity Binding | Backend (server-side) | VRF re-computation | Ensures entry at index belongs to the claimed user |
| VRF Cryptographic Proof | Auditor (third-party) | VRF Public Key verification | Independently proves identity binding |
| Log Consistency | Auditor | tlog.CheckTree | Ensures old tree is a prefix of new tree |
3.2 Why VRF Verification Is Server-Side Only¶
The VRF scheme used is ristretto255-based (filippo.io/mostly-harmless/vrf-r255). There is no native Swift library for this curve. Wrapping C code is fragile and adds maintenance burden. Since the server already holds the VRF private key and can simply re-derive the hash, VRF verification is performed server-side. The client trusts the vrfHash returned by the server but independently verifies everything else (checkpoint signature, Merkle proof, key comparison).
The VRF Public Key exists for third-party auditors — external parties running Go or Rust tools that can verify VRF proofs without trusting the server.
4. Key Material¶
4.1 Key Generation¶
Private key: PRIVATE+KEY+e2ecall-kt+95d01f0d+AUXu...
Private VRF key: byEHDWjz...
Public key: e2ecall-kt+95d01f0d+AVJYpVX/pIhNVyoJ4+WTZr0PpvySTELbyLATnWftZBx6
Public VRF key: tAcYYn1n0+V0OKbeOGaHTh2ta5rrSQwAnwfFp/lyyGU=
4.2 Key Distribution¶
| Key | Stored In | Used By | Purpose |
|---|---|---|---|
KT_PRIVATE_KEY | Server .env (secret) | Server | Sign checkpoints |
KT_PUBLIC_KEY | Hardcoded in iOS app | Client | Verify checkpoint signatures |
VRF_PRIVATE_KEY | Server .env (secret) | Server | Generate VRF proofs and hashes |
VRF_PUBLIC_KEY | Published for auditors | Third-party auditors | Verify VRF proofs independently |
5. Data Formats¶
5.1 Log Entry Format¶
Each entry appended to the Merkle Tree:
<vrfHash_base64>\n<publicKey_base64>\n
The leaf hash is computed per RFC 6962:
LeafHash = SHA256(0x00 || entry_bytes)
Internal (non-leaf) node hashes:
NodeHash = SHA256(0x01 || left_hash || right_hash)
5.2 Checkpoint Format (note)¶
Follows the golang.org/x/mod/sumdb/note format:
e2ecall-kt <- origin name
100 <- tree size (number of entries)
Um9vdEhhc2hCYXNlNjQ= <- root hash (base64, 32 bytes)
— e2ecall-kt AZbA...signature... <- Ed25519 signature line
Signature line format: — <origin> <4-byte-key-id + 64-byte-ed25519-sig in base64>
5.3 Proof Format (c2sp.org/tlog-proof@v1)¶
c2sp.org/tlog-proof@v1 <- header (fixed)
extra QmFzZTY0X1ZSRl9Qcm9vZg== <- VRF proof bytes (base64, for auditors)
index 42 <- log index of the entry
SGFzaF8xX0Jhc2U2NA== <- Merkle sibling hash #1 (base64)
SGFzaF8yX0Jhc2U2NA== <- Merkle sibling hash #2 (base64)
<- empty line (separator)
e2ecall-kt <- checkpoint begins here
100
Um9vdEhhc2hCYXNlNjQ=
— e2ecall-kt AZbA...sig... <- checkpoint signature
5.4 API Response Format¶
{
"results": [
{
"publicKey": "MCowBQYD...",
"vrfHash": "pG7mFJ3CxK8df7R2gY5xN1K9vQz0bHm...",
"proof": "c2sp.org/tlog-proof@v1\nextra ...\nindex 42\n..."
}
]
}
6. Server API Reference¶
6.1 gRPC — Register¶
Called by the main API server when a device registers.
service KTService {
rpc Register(RegisterRequest) returns (RegisterResponse);
}
message RegisterRequest {
uint64 userID = 1;
uint64 deviceID = 2;
string publicKey = 3;
}
message RegisterResponse {
uint64 index = 1;
string vrfHash = 2;
}
Server logic: 1. Compute VRF.Prove(userID_deviceID) to get vrfHash 2. Format entry: vrfHash + "\n" + publicKey + "\n" 3. Append to Merkle Tree via Tessera 4. Return the assigned index
6.2 HTTP — POST /api/get-public-keys¶
Called by client devices to retrieve public keys and audit proofs directly from KT.
Request:
{
"targets": [
{ "publicKeyHash": "pG7mFJ3CxK8df7R2gY5xN1K9vQz0bHm..." }
]
}
Response:
{
"results": [
{
"publicKey": "base64...",
"vrfHash": "base64...",
"proof": "c2sp.org/tlog-proof@v1\n..."
}
]
}
Server logic: 1. Read checkpoint and verify its signature 2. For each target publicKeyHash: a. Query local database to find publicKeyIndex, userId, and deviceId b. Read entry at publicKeyIndex from Merkle Tree via torchwood.Client c. Parse entry to extract oldVrfHash and publicKey d. Re-compute VRF.Prove(userID_deviceID) to verify identity binding e. If oldVrfHash != publicKeyHash then return error (identity mismatch) f. Generate Merkle inclusion proof with FormatProofWithExtraData 3. Return list of publicKey, vrfHash, and proof
6.3 HTTP — Static Tile Serving¶
The HTTP server serves the raw Tessera storage directory, enabling direct tile access:
| Endpoint | Description |
|---|---|
GET /checkpoint | Current signed checkpoint |
GET /tile/entries/000 | Entry bundle (data tile) |
GET /tile/0/000 | Hash tile (level 0) |
GET /tile/1/000 | Hash tile (level 1) |
Used by third-party auditors to crawl the entire log.
7. Third-Party Auditor Guide¶
A third-party auditor is an independent process (not the server, not the client) that crawls the entire log and verifies its integrity. It holds the VRF Public Key but NOT the VRF Private Key.
7.1 Prerequisites¶
| Item | Details |
|---|---|
| Language | Go (recommended) or Rust |
| Libraries | filippo.io/torchwood, filippo.io/mostly-harmless/vrf-r255 |
| Keys Needed | KT_PUBLIC_KEY (checkpoint verifier), VRF_PUBLIC_KEY (VRF verifier) |
| Access | HTTP access to KT server tile storage |
7.2 What the Auditor Verifies¶
| Check | Description |
|---|---|
| Checkpoint Signature | The checkpoint is signed by the expected log operator |
| Tree Hash | The root hash in the checkpoint matches the actual Merkle tree |
| Entry Integrity | Every entry's leaf hash is correctly included in the tree |
| VRF Validity | Each vrfHash was correctly derived from the VRF key |
| Consistency | New checkpoints are consistent with previously observed checkpoints |
7.3 Implementation (Go)¶
package main
import (
"context"
"encoding/base64"
"fmt"
"log"
"strings"
vrf "filippo.io/mostly-harmless/vrf-r255"
"filippo.io/torchwood"
"golang.org/x/mod/sumdb/tlog"
)
func main() {
// 1. Set up tile reader pointing to KT server
tf, _ := torchwood.NewTileFetcher("https://kt.example.com/")
client, _ := torchwood.NewClient(tf)
// 2. Load public keys
checkpointVKey := "e2ecall-kt+95d01f0d+AVJYpVX/pIhNVyoJ4+WTZr0PpvySTELbyLATnWftZBx6"
vrfPubKeyB64 := "tAcYYn1n0+V0OKbeOGaHTh2ta5rrSQwAnwfFp/lyyGU="
verifier, _ := torchwood.NewCosignatureVerifier(checkpointVKey)
policy := torchwood.SingleVerifierPolicy(verifier)
vrfPubKeyBytes, _ := base64.StdEncoding.DecodeString(vrfPubKeyB64)
vrfPubKey, _ := vrf.NewPublicKey(vrfPubKeyBytes)
_ = vrfPubKey // used for VRF verification below
// 3. Fetch and verify checkpoint
ctx := context.Background()
cpBytes, _ := tf.ReadEndpoint(ctx, "checkpoint")
cp, _, _ := torchwood.VerifyCheckpoint(cpBytes, policy)
fmt.Printf("Verified checkpoint: tree size = %d\n", cp.N)
// 4. Crawl all entries and verify
for i, entry := range client.AllEntries(ctx, cp.Tree, 0) {
parts := strings.SplitN(string(entry), "\n", 3)
if len(parts) < 2 {
log.Fatalf("Entry %d: invalid format", i)
}
storedVrfHash := parts[0]
publicKey := parts[1]
// Leaf hash integrity is already verified internally
// by client.AllEntries against the Merkle tree
fmt.Printf("Entry %d: vrfHash=%s... key=%s...\n",
i, storedVrfHash[:16], publicKey[:16])
}
if err := client.Err(); err != nil {
log.Fatalf("Error crawling entries: %v", err)
}
fmt.Printf("Full audit complete: %d entries verified\n", cp.N)
}
7.4 VRF Proof Verification (via API)¶
When individual proofs are fetched via the API, the extra field contains the VRF proof bytes:
// Fetch proof for a specific entry via the HTTP API
resp := httpGet("/api/get-public-keys", targets)
for _, result := range resp.Results {
// Extract VRF proof from the "extra" field in the proof string
extraBytes, err := torchwood.ProofExtraData([]byte(result.Proof))
if err != nil {
log.Fatalf("Failed to extract VRF proof: %v", err)
}
// Reconstruct VRF proof and verify with Public Key
vrfProof, _ := vrf.UnmarshalProof(extraBytes)
input := []byte(fmt.Sprintf("%d_%d", userID, deviceID))
// Verify: does this VRF proof match the claimed identity?
expectedHash := vrfProof.Hash()
storedHash, _ := base64.StdEncoding.DecodeString(result.VrfHash)
if !bytes.Equal(expectedHash, storedHash) {
log.Fatalf("VRF hash mismatch for user %d_%d!", userID, deviceID)
}
// Verify VRF proof was generated by the known VRF key
ok := vrfPubKey.Verify(input, vrfProof)
if !ok {
log.Fatalf("VRF proof invalid for user %d_%d!", userID, deviceID)
}
}
7.5 Consistency Monitoring¶
To detect split-world attacks (server showing different trees to different observers), the auditor should:
- Store each verified checkpoint locally (timestamp + tree size + root hash)
- Fetch the latest checkpoint periodically
- Verify consistency between the old and new checkpoint:
// Prove that the new tree (size M) contains
// the old tree (size N) as a prefix
oldTree := previousCheckpoint.Tree // size N
newTree := latestCheckpoint.Tree // size M
proof, _ := tlog.ProveTree(newTree.N, oldTree.N,
torchwood.TileHashReaderWithContext(ctx, newTree, tileReader))
err := tlog.CheckTree(
proof, newTree.N, newTree.Hash, oldTree.N, oldTree.Hash,
)
if err != nil {
log.Fatal("CONSISTENCY VIOLATION - server rewrote history!")
}
7.6 Gossip Protocol (Optional)¶
For maximum security, multiple auditors and clients should cross-check their observed checkpoints:
graph LR
A["Auditor A"] -.->|gossip| B["Auditor B"]
A -.->|gossip| C["Client Alice"]
B -.->|gossip| C If any party observes a checkpoint with a different root hash for the same tree size, a split-world attack has been detected.
8. Attack Scenarios & Defenses¶
8.1 Server Operator Creates a Fake Key for Alice (MitM)¶
graph TD
A["Server operator appends<br/>Fake PublicKey for Alice"] --> B["Fake entry pinned<br/>permanently in Merkle Tree"]
B --> C["Alice device runs periodic audit"]
C --> D{"publicKey from server<br/>== localPublicKey?"}
D -->|YES| E["All clear"]
D -->|NO| F["RED ALERT<br/>Key mismatch detected!"] Defense: Client compares the server-returned publicKey against its locally stored key. A mismatch is immediately flagged.
8.2 Server Operator Rewrites Log History¶
Defense: Append-only Merkle Tree. If any past entry is modified, the root hash changes, which breaks: - Merkle inclusion proofs for all clients - Consistency proofs for auditors comparing old vs. new checkpoints
8.3 Split-World Attack¶
Server operator shows Tree A to Alice and Tree B to Bob.
Defense: Checkpoints are signed. If auditors or clients gossip their checkpoints and find different root hashes for the same tree size, the attack is detected.
8.4 Attacker Queries Wrong Identity¶
An attacker tries to query a key for a specific user but attempts to trick the server into returning a different user's key or a fake key.
Defense: Server-side VRF check. The server re-computes the VRF hash using the userID/deviceID found in the database and compares it with the vrfHash stored in the append-only Merkle Tree. If they don't match, the server returns an identity mismatch error, preventing the use of a key that wasn't properly bound to that identity in the log.