All articles

Contact Discovery

Algorithm: The Transparency Log

13 min read

Several articles across these series reach a point where they need a fact to be the same fact for everybody — the discovery key served for a phone number, the roster of devices on an account, the list of live token-issuing keys — and each of them discharges that obligation with the same four words: "committed to the transparency log." None of them says what the log is. This article is the debt being paid.

Prerequisites: Guarding the Directory, whose last defense (authenticated publication) is exactly where this one picks up.


1. The leak that authentication does not close

Article 05 §5 closes directory poisoning: you cannot publish an entry for a presence_id unless you hold a phone-ownership certificate for it. That stops a stranger claiming your slot. Read it again and notice who is not covered.

The certificate is checked by the directory server. The directory is served by the directory server. Nothing in that arrangement constrains the directory server. It can accept your honest, certificate-backed publication, store it, serve it faithfully to nine hundred people — and serve one substituted key to the tenth. That tenth person's client resolves your number, gets an X-Wing public key the operator generated, and seals its first handshake to the operator instead of to you. Every signature checks out. The victim sees a normal contact appearing normally.

This is the shape worth staring at, because it is not a cryptographic break and no amount of better cryptography in the lookup fixes it. The lookup answers honestly; it answers a question whose answer the server chooses. The attack is targeted, silent, and deniable — the row can be swapped back afterwards. Its one weakness is that it requires the operator to say different things to different people, and that is the seam a transparency log is built to open.


2. What a log changes

A transparency log does not make the operator honest. It makes the operator consistent, by removing the ability to answer per-victim.

The mechanism is an append-only authenticated dictionary. Every binding the directory will ever serve is first inserted into one structure; that structure is periodically hashed down to a single root; the root is signed and published; and every lookup comes back with a proof folding the answer you were given up to that published root. Two properties follow. The operator cannot serve a binding that is not in the tree, because it cannot produce the proof. And it cannot serve you a different tree, because the root is a single public value other people are also watching.

Substituting one victim's key therefore stops being a database edit and becomes a commitment to a second, incompatible history — a fork. Forks are loud. That is the whole trade: the operator's cheapest attack goes from invisible to detectable.


3. What is actually in a row

The dictionary is a sparse Merkle tree over Poseidon2 (KoalaBear field), and a row is a (label, value) pair. Both halves are less obvious than they look.

The label is not the phone number. A published, world-readable directory keyed by presence_id would undo the entire premise of the series: the tree is a durable artifact, so anything recoverable from it is recoverable forever, including by a future adversary with a quantum computer and a decade of patience. So the label is a keyed pseudorandom function of the identifier under a symmetric directory key the server holds:

label = directory_label(k_dir, phone_id, version)          // Poseidon2, KoalaBear

Symmetric is a deliberate divergence. Key-transparency systems conventionally label with a VRF, whose public key lets anyone check the label was computed correctly — but public-key means a structure Shor's algorithm eventually eats: harvest the tree today, invert the labels in 2040. A symmetric PRF has no public key to break, so a harvested tree stays opaque. The implementation calls this everlasting label privacy, and it is why the production system is a from-scratch aZKS rather than an off-the-shelf VRF-labeled one.

Which immediately creates a problem. If the label key is secret and symmetric, nobody outside the server can check that a label really is the PRF of the identifier you asked about. The operator could derive your victim's label under a different key and hand you a perfectly valid inclusion proof for a row that has nothing to do with the number you queried. So label correctness is proved in zero knowledge: every lookup carries a FRI-STARK (Plonky3) whose public values are (key_commitment, label, phone_id, version) and whose statement is "this label is the PRF of this id at this version, under the key committed by key_commitment". That commitment —

key_commitment = Poseidon2("KCMT" ‖ k_dir ‖ r)

— is a 64-byte constant pinned in the app binary (SocialBackendConfig.ktKeyCommitment). Every proof must verify under it, so there is exactly one directory key, the same for everyone, and the server cannot equivocate on the key any more than it can on the root.

The value is a commitment, not the plaintext. For a discovery row the committed plaintext is "pixie:kt-D-leaf:v1" ‖ discovery_xwing_pubkey ‖ discovery_inbox ‖ version, hashed into the leaf. A client that holds a candidate binding re-commits it and compares; a client that holds nothing learns nothing.

A footnote on secrets that are only nominally secret. All of this rests on k_dir staying secret: recover it and the published tree becomes a membership oracle for any number — precisely the harvest attack the symmetric PRF was chosen to prevent. It is KDIR_LEN = 8 KoalaBear limbs, roughly 248 bits, and for a while it was one limb, about 31 bits, recoverable from a single (phone_id, label) pair — which is exactly what a lookup proof hands out. That defect and a companion one in the prover's randomness are taken up in What a Lookup Proof Binds §7. Neither changed a single verification result, which is why neither was visible from outside.


4. One tree, three namespaces

There is one tree, one root, one signed head — and three different mechanisms committing to it. They are kept from colliding by the crudest possible means: label length.

ns label input bytes what the row commits
D phone_id ‖ u32(version) 36 a contact's X-Wing discovery key and inbox
I "I" ‖ account_kt_handle ‖ u32(version) 37 an account's signing keys and its device-chain head
K "K" ‖ u32(version) 5 a digest of credential-svc's live OPRF key set

D is what the discovery resolve gate reads before a first handshake is sealed to a stranger's key. I backs identity-key transparency and the clone detection in §7. K is read by the admission token mint, which refuses to issue against a key list the log does not hold.

The one-byte prefixes exist to make the byte-strings length-disjoint, so a proof minted for one namespace can never be replayed as an answer in another. All three resolve through the same verifier, so that is a real requirement rather than a tidiness one.

Sharing a tree has a payoff: one root means one thing to keep fresh, one thing to gossip about, and one place a fork shows up. Three separate logs would be three independent opportunities to equivocate.

Entries are immutable. Rotating your discovery key does not overwrite version 3; it appends version 4. Old versions stay, which is what makes a complete, gap-free history provable — and what makes "here is a key that was inserted for your number in April" a checkable statement.


5. The head, the chain, and the heartbeat

Epochs are cut on a clock — KT_EPOCH_SECS, hourly by default. Each cut folds the pending queue into the tree and emits a signed tree head: epoch number, root, tree size, timestamp, and a prev_sth_hash linking it to the previous head. The signature is hybrid — Ed25519 and ML-DSA-65, both required, verified against keys pinned in the app.

The chain matters as much as the signature. A signed root proves the log said it; prev_sth_hash proves where in the sequence it said it. A client keeps its last trusted head and refuses one whose epoch has gone backwards — the rollback defense.

An hour is a long time to wait to become reachable, so a publish also nudges a cut (KT_EPOCH_DEBOUNCE_SECS, two seconds). The rule pointing the other way is subtler. A tick with nothing pending would naturally be a no-op — but a log that stops emitting is a problem, because a validly-signed root from last month is still validly signed. The rollback guard catches an epoch that moves backwards; it does nothing about one that simply stops, and under a frozen root every check a client makes passes honestly, because at that epoch the stale answer really was the latest one. A quiet log and a log freezing one client's view are the same observation.

So an idle tick cuts an epoch anyway once the newest head is older than KT_HEARTBEAT_SECS, an hour. An empty cut is a legitimate append-only no-op — nothing staged, root unchanged, every existing proof still verifying — which is what makes the heartbeat cheap enough to be unconditional, and what makes silence itself a signal. The client half of the property is a bound on the head's age (maxSTHAge, three hours against an hourly beat, so one missed tick does not flip the fleet) plus the gossip loop in §7, which pulls a frozen client forward onto an epoch its own server is then visibly behind; What a Lookup Proof Binds §6 takes that apart.

Staleness is reported as unavailable, never as evidence. An honestly-down log looks identical from the client's side, and treating it as compromise would take the whole network down with it.


6. Why the directory serves only what the log can already prove

Here is a failure mode that exists because the client fails closed — a gate creating its own outage.

A publish writes three things: the servable directory row, the advertised version counter, and a pending tree entry. The first two are visible to every resolver immediately; the third is not provable until an epoch is cut. In the window between, the directory hands out a binding at version N that /v1/kt/lookup returns 404 for — and the fail-closed resolve gate reads "the directory served me a binding the log has never heard of" as exactly what it is built to read it as: substitution evidence. It refuses to send the handshake. That window used to be up to a full hour and it opened on every publish, so an established contact went dark whenever the peer relaunched.

The fix is to make the directory strictly behind the log rather than ahead of it. Migration 0023 adds discovery_published, a folded view that publish_epoch writes as it drains each namespace-D entry into the tree, in the same transaction as the signed head; /v1/discovery/lookup reads that instead of the raw publish table, so a resolver is only ever handed a binding already committed at a signed root. The raw table keeps its meaning — the latest published intent — and still answers "what is my own current inbox".


7. Detection needs someone to look

Everything so far makes substitution detectable. Detection is a verb with a subject, and a log with no readers is a filing cabinet. Two loops supply the subject.

Self-monitoring. Each device periodically walks the version history of its own row and requires every published version to open to a binding it authored (selfMonitor). A version that opens to something else — or will not verify at all — is a key someone inserted for your number, and it raises a hard alarm. Note what this does that no proof can: the log guarantees consistency, but only you know which value is correct, so the correctness check has to be run by the party who knows. Namespace I runs the same loop with a stricter notion of mine (selfMonitorIdentity): a published version counts as self-authored only if a device-signed entry in your own synced device chain reproduces exactly that leaf, which is what turns the loop into clone detection — a version the directory inserted cannot satisfy it without one of your device keys.

Cross-user gossip. Self-monitoring catches an insertion in your row. It says nothing about whether you and your contacts are looking at the same tree at all. So roughly one 1:1 send in ten carries kt_gossip { epoch, sth_hash } — the sender's trusted head, ridden inside the encrypted payload. Inside is not incidental: gossip is a cross-user check, and putting it in the envelope would hand the relay precisely the who-talks-to-whom edge the entire Relay series exists to withhold. It is best-effort and never gates delivery.

The recipient compares (ingestGossip). Same epoch, different hash: two heads exist for one epoch — a fork — and it alarms. Older epoch: nothing, an old head is evidence of nothing. Newer epoch: it does not take the peer's word, since a hostile contact could claim any epoch. It fetches that epoch's head from the server, requires the hybrid signature to verify under the KT-root keys pinned in the app, and requires the gossiped hash to match the one it was handed — a mismatch is two heads for one epoch, so it alarms — before advancing its anchor. That branch is what closes the freeze from §5, and it needed no new alarm machinery: adopting a newer head converts "your view is frozen" into "your server is serving something older than what you trust", which is the rollback the client already refuses.


8. Three things a log structurally cannot do

Worth stating plainly, because "it's in the transparency log" gets used as though it settles more than it does.

It cannot tell you a value is correct. It tells you everyone was shown the same one. If a wrong key is published, the log publishes it faithfully, proves it to everyone identically, and reports no anomaly. Consistency is not correctness, and the gap between them is filled by the self-monitor in §7 — a human-authored assertion of "that isn't mine", not a proof.

It cannot act on its own. The log produces evidence. Evidence that nobody reads changes nothing, and an attacker who is confident the monitoring is not running faces the pre-log threat model exactly. Every guarantee here is really a guarantee about the monitoring loop, with the log as its instrument.

It binds only what the verifier actually checks. This is the sharpest one. A proof bundle is a wire structure with more fields on it than the signed root commits to, and a field the verifier does not compare is a field the server may choose freely — with the STARK still verifying, the label still binding, the path still folding to the real root, and no tree write for a monitor or a gossip to see. The system shipped in exactly that state, and What a Lookup Proof Binds is the anatomy of the four bindings a proof carries, including the one that was not being checked.


9. The residual

A log makes equivocation detectable, not impossible. Nothing here prevents the server from signing a second root and serving it to one person; it only guarantees that doing so leaves an artifact which cannot be un-signed. The substitution still happens. The victim's first handshake still goes to the wrong key. What changes is that the operator must now hope nobody compares notes.

Which makes the guarantee only as good as the comparing, and the comparing is thinner than the structure suggests. Gossip rides a fraction of 1:1 messages between people who already talk to each other, so a victim who is newly onboarded, or isolated, or communicating only with the very account the operator controls may hold a forked head for a long time with no honest peer to contradict it. And "append-only" is, on the client, mostly an assumption: identity-svc will serve an append-only proof between two epochs (/v1/kt/consistency), but the client verifies only the immediate prev_sth_hash link between consecutive heads, so a device that was offline across several epochs accepts the newest signed root without checking that the epochs it skipped only ever added. Verifying that range is auditor work, and there is no auditor: no independent monitor runs, and no public witness network exists to gossip roots outside the app's own message traffic.

That is the honest shape of it. The tree is the instrument; the guarantee is a property of who picks it up. Nothing in the cryptography here makes anyone look.


References

  • B. Laurie, "Certificate Transparency," ACM Queue 2014, and RFC 6962 — the append-only-log-plus-gossip design this is an instance of.
  • M. Chase, A. Deshpande, E. Ghosh, H. Malvai, "SEEMless: Secure End-to-End Encrypted Messaging with Less Trust," CCS 2019 — the auditable key directory (aZKS) model, VRF labels included.
  • M. Melara et al., "CONIKS: Bringing Key Transparency to End Users," USENIX Security 2015 — where the self-monitoring obligation and the gossip-for-non-equivocation argument come from.
  • Guarding the Directory — the poisoning defense this article extends past the operator.
  • The Admission Token §5 — namespace K in use, and why "committed" needs version binding and v+1 absent to mean "current".
  • Known-Answer Testing — the complementary check: a log proves what a server committed to, a hidden known-answer test proves it actually used it.

Next, the anatomy of a single lookup proof and the four checks it has to survive: What a Lookup Proof Binds.