Key Transparency Log
The Key Transparency Log (KTL) is an append-only Merkle tree recording every identity-key binding (wallet address → public key). Clients verify cryptographic inclusion proofs against a signed tree root, so a server that substitutes a user’s key to mount a man-in-the-middle attack leaves permanent, detectable evidence.
Sources: zentalk/src/services/ktl/ (client verifier and auto-verification), zentalk/src/api/ktl.ts (API client), with the authoritative tree builder in zentalk-api/pkg/ktl/.
What it protects against
Section titled “What it protects against”X3DH authenticates the keys a client fetched — it cannot establish whether the key server presented the same key for that user to every other client. The KTL closes that gap:
| Attack | Without KTL | With KTL |
|---|---|---|
| Server silently substitutes a user’s identity key | Undetected unless users compare safety numbers manually | Key change appears as a new log entry; clients flag the divergence automatically |
| Server serves a fabricated key that was never registered | Undetected | No inclusion proof exists; verification fails |
| Log operator rewrites history | — | Leaf hashes chain via prev_leaf_hash; a rewrite breaks every subsequent proof |
API endpoints
Section titled “API endpoints”| Endpoint | Method | Auth | Purpose |
|---|---|---|---|
/api/v1/ktl/lookup/{wallet} |
GET | Bearer | Return all log entries for a wallet, each with its inclusion proof |
/api/v1/ktl/root/latest |
GET | Public | Return the latest signed Merkle root |
/api/v1/ktl/verify/{seq} |
GET | Public | Fetch a historical entry by sequence number with its inclusion proof, for external auditors |
A 503 response means the feature is disabled server-side; the client maps this to a typed “disabled” state and hides verification UI rather than showing a false failure. See the REST API for response schemas.
Latest root response
Section titled “Latest root response”{ "epoch": 42, "last_seq": 1337, "merkle_root": "<base64>", "signed_by": "<key identifier>", "created_at": 1750000000}Tree construction
Section titled “Tree construction”Leaf format
Section titled “Leaf format”Each entry binds a wallet to a public key at a sequence number, chained to the previous entry for that wallet:
leaf_hash = SHA-256( be_u64(seq) || utf8(wallet_address) || 0x00 || public_key || be_u32(key_version) || prev_leaf_hash )The 0x00 delimiter between wallet and public key is essential to the security of the construction: without it, an attacker controlling either field could craft two distinct inputs with identical concatenations.
Node hashing
Section titled “Node hashing”Leaf and interior nodes are domain-separated to prevent second-preimage attacks:
leaf_node = SHA-256( 0x00 || leaf_hash )inner_node = SHA-256( 0x01 || left || right )A level with an odd number of nodes is padded by duplicating its last node, so a leaf’s position parity alone determines whether the proof sibling goes left or right.
Client verification flow
Section titled “Client verification flow”The client re-computes everything locally; it never trusts a server-provided leaf hash:
- Fetch the latest signed root and the wallet’s entries (in parallel).
- Recompute the leaf hash from the entry’s raw fields and compare it to the claimed
leaf_hash— mismatch verdict:leaf-mismatch. - Walk the inclusion proof from the leaf to the root, consuming one sibling per level, and compare the computed root against
merkle_rootin constant time — mismatch verdict:root-mismatch. - Malformed inputs and absent proofs yield
bad-inputs/missing-proof— the entry is treated as unverifiable, never as verified.
The TypeScript verifier’s byte layout mirrors the Go implementation exactly; any drift breaks verification by construction.
Automatic verification
Section titled “Automatic verification”Manual checks only run when a user opens the safety-number sheet — too late to detect a silent key substitution. The auto-verifier (ktl/auto-verify.ts) runs in the background on incoming messages:
- Enabled by
NEXT_PUBLIC_KTL_ENABLED=true. - Fires on the first message from a peer and whenever the locally cached identity key diverges from the latest KTL entry; re-checks are debounced to once per 5 minutes per peer, with in-flight coalescing so concurrent decrypts share one round trip.
- Never blocks rendering — callers do not await the check.
- Verified bindings (
wallet,public_key,seq,verifiedAt) persist in secure storage.
| Status | Meaning | UI consequence |
|---|---|---|
verified |
Proof valid, key matches (or first sighting recorded) | No warning |
changed |
Proof valid but the key differs from the local cache | Prominent “identity key changed” warning; prompt re-verification |
unverifiable |
Inclusion proof failed — the log itself is suspect | Distinct warning: the log cannot be trusted, which is not the same claim as “the key changed” |
not-in-log |
No entry for this wallet yet | Discreet “cannot verify” |
disabled |
Feature flag off or server 503 | Verification UI hidden |
Relationship to contact verification
Section titled “Relationship to contact verification”KTL verification is automatic and machine-checked, but bounded by the split-view caveat. Safety numbers and SAS codes provide the out-of-band human check that closes the remaining gap; the two mechanisms are designed to be used together.
