Skip to content

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/.

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
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.

GET /api/v1/ktl/root/latest
{
"epoch": 42,
"last_seq": 1337,
"merkle_root": "<base64>",
"signed_by": "<key identifier>",
"created_at": 1750000000
}

Each entry binds a wallet to a public key at a sequence number, chained to the previous entry for that wallet:

Leaf hash (must match Go ktl.ComputeLeafHash)
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.

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.

The client re-computes everything locally; it never trusts a server-provided leaf hash:

  1. Fetch the latest signed root and the wallet’s entries (in parallel).
  2. Recompute the leaf hash from the entry’s raw fields and compare it to the claimed leaf_hash — mismatch verdict: leaf-mismatch.
  3. Walk the inclusion proof from the leaf to the root, consuming one sibling per level, and compare the computed root against merkle_root in constant time — mismatch verdict: root-mismatch.
  4. 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.

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

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.