Skip to content

Key Transparency Log

The Key Transparency Log (KTL) is an append-only Merkle tree recording identity-key bindings (wallet address → public key). Its purpose is to make a key server that substitutes a user’s key leave permanent, externally checkable evidence.

It does not do that yet. In the closed Beta the log is a record, not a proof: roots are not signed, and the client does not verify inclusion proofs. What authenticates a contact today is the out-of-band safety-number comparison described in Contact verification — read that page for the assurance you actually have right now.

Sources: zentalk/src/services/ktl/ (client verifier and auto-verification), zentalk/src/api/ktl.ts (API client), with the tree builder in zentalk-api/pkg/ktl/.

Property State
The log records identity-key bindings Append happens on the central key-publish path
Merkle roots are signed No. No code signs a root; signed_by is read with a COALESCE default and never written
The client checks inclusion proofs automatically No. NEXT_PUBLIC_KTL_ENABLED is false in every shipped configuration
The client pins a previous root or requests a consistency proof No. Neither exists — there is no consistency endpoint and no pinned root
An independent witness co-signs roots No. General availability, not Beta
The public read endpoints answer Only where ZENTALK_KTL_ENABLED=true; the shipped default is false, and all three endpoints then return 503

Two consequences follow, and together they are why the client check stays off rather than being switched on as it stands:

  • A passing proof would prove the wrong thing. The client fetches the tree root and the inclusion proof from the same server in the same round trip. A server that wanted to substitute a key can append the substituted key to its own log, publish a root over it, and return a proof that verifies. With no signature on the root, no earlier root pinned, and no consistency proof requested, a verified verdict would mean this server is consistent with itself — not this key is the right one. Detecting equivocation needs a signed root and a second observer; neither is deployed.
  • In the default storage mode the log stays empty by construction. The shipped default is mesh-only storage, where key bundles are published to the mesh and never to the central API — and the central publish handler is the only writer to the log. Enabling the client check in that mode returns not-in-log for every peer, which is silence, not assurance.
Mechanism How it runs What it gives you
Safety numbers and SAS Manual, out of band The check the closed Beta relies on. Both sides compare a value derived from the pinned identity keys over a channel the key server does not control. Nothing else in the stack detects a split view
TOFU identity pinning Automatic, local, no network request The first identity key seen for a peer is pinned locally; a later change raises a warning, and an attacker-forced session rebuild cannot silently replace the pin
X3DH signed prekeys Automatic, cryptographic Tampered prekeys under a known identity key

If you take one action from this page, make it the safety-number comparison for the contacts that matter to you.

The gap it is meant to close: X3DH authenticates the keys a client fetched, but cannot establish whether the key server presented the same key for that user to every other client.

Attack Without a transparency log With a verified log (not yet the closed Beta)
Server silently substitutes a user’s identity key Detected only when users compare safety numbers 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 against a signed root; verification fails
Log operator rewrites history Leaf hashes chain via prev_leaf_hash, and a consistency proof against a pinned earlier root exposes the rewrite
Log operator shows different roots to different users (split view) Detectable only through out-of-band comparison or an independent witness — a transparency log is tamper-evident, not tamper-proof

The right-hand column is the design target. Reaching it takes the work listed under Before this page can claim verification. Until then, the split-view row applies to every row: out-of-band contact verification is the check that holds. This residual is tracked in the threat model in the zentalk-web repository (THREAT_MODEL.md, adversary A2).

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 (flag) Return the latest Merkle root (unsigned)
/api/v1/ktl/verify/{seq} GET Public (flag) Fetch a historical entry by sequence number with its inclusion proof

A 503 response means the feature is disabled server-side — the shipped default — and the client maps it to a typed “disabled” state, hiding verification UI rather than showing a false failure. See the REST API for response schemas.

The lookup endpoint takes a wallet address in cleartext in the path and is called under the caller’s session. That is one of the reasons the automatic per-peer check is not enabled: every other key lookup the client performs is address-blinded, and a per-peer lookup on the central API would be the only one that is not. When automatic verification does ship, the inclusion proof will ride on the key-bundle response the client already fetches, rather than on a lookup of its own.

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

signed_by is reserved and always empty. It exists in the schema for the signing work described below; no code writes it today. Do not build a verifier that treats its presence as meaningful — and do not treat merkle_root as an authenticated value.

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.

The leaf covers the X25519 identity key. The Ed25519 key that safety numbers and TOFU pinning are computed over is bound to it by a separate identity-binding signature, not by the leaf.

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.

Implemented in the TypeScript verifier and not enabled in any shipped configuration. When it runs, the client re-computes everything locally and never trusts a server-provided leaf hash:

  1. Fetch the latest root and the wallet’s entries (in parallel, from the same server — see the caveat above).
  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.

The auto-verifier (ktl/auto-verify.ts) is gated on NEXT_PUBLIC_KTL_ENABLED and returns disabled before any network call, which is what every shipped build does today. The table describes the behavior a build with the flag enabled would show:

Status Meaning UI consequence
verified Proof valid, key matches (or first sighting recorded) No warning. Bounded by the caveat above: it attests server self-consistency, not key authenticity
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 — the state for every peer in mesh-only mode Discreet “cannot verify”
disabled Feature flag off or server 503 Verification UI hidden

Note that a changed verdict does not depend on the log: the local TOFU pin raises the same warning without any network request, and it is the path that is live today.

Each item is a precondition for the next, and each is checkable from the outside:

  1. One tree construction. API and node implementations aligned on RFC 6962, with shared test vectors.
  2. Signed roots. Roots signed under a domain-separated signed-tree-head format, with the public key pinned in the client build, and a separate key from any credential the API instances hold. Only from here does “verified” stop overstating.
  3. Consistency proofs. A consistency endpoint plus a client that pins the last root it saw and refuses to move to a root that does not extend it. Without this, a signed root is a signed snapshot and a rewrite stays invisible.
  4. Proofs delivered with key bundles. The inclusion proof travels on the key-bundle response the client already fetches, so verification adds no per-peer request and works in mesh-only mode.
  5. Independent witnesses. Witness co-signing and an external monitor, which is what makes a split view publicly observable rather than merely detectable by two users who happen to compare.

Steps 1–4 are Beta-plus work; step 5 is general availability. This page will state which of them are live rather than describing the end state as if it had arrived.

The two mechanisms are designed to work together, but they are not interchangeable and they are not both available. Safety numbers and SAS codes are the out-of-band human check, they run today, and they are the only thing in the stack that detects a split view. Machine-checked transparency is the automation that is meant to reduce how often a human has to do that — it is not a replacement, and in the closed Beta it is not yet an addition either.