Android: refuse a key we have reason to distrust

Android had the transparency log's verifier and nothing that called it. The
parity table listed trust state, verification, identity-change acceptance, and
key-log enforcement as absent, and they were — so the relay could hand out a
key it minted, the conversation would encrypt to it, and every byte on the wire
would be correctly encrypted to the wrong person. A log nothing checks records
the substitution faithfully and is never read.

Two independent checks, because neither is sufficient alone.

**Pinning, always on.** Every identity key a peer offers is compared against
what was pinned on first contact; a send to a changed identity is refused until
a person accepts it. The comparison is "every offered key is already pinned",
not "any" — a relay adding one key of its own ALONGSIDE the real ones is the
attack, and the fact that all the genuine devices still work is exactly what
makes it invisible. There is a test for that case specifically.

**The log, opt-in.** `require-key-log` on the Account screen makes a send
demand that the relay prove the recipient's key appears in the log it
publishes. Off by default for the same reason the web engine's is: a relay
predating the log has none, and refusing every peer on those deployments is a
worse failure than the one being prevented.

`KeyLog.verifyConsistency` did not exist in the Kotlin engine. Inclusion alone
is worth very little — a relay that rewrites history reissues perfectly valid
inclusion proofs against its new tree, honest proofs about a dishonest log.
Consistency against a head this device already pinned is the one thing it
cannot produce. Ported from `engine/src/keylog.js`, which is normative, rather
than re-derived: a re-derivation that accepts too much passes every honest test
and fails only against an attacker.

Head gossip rides along with messages and compares a peer's view against ours,
which is what turns a per-device check into a detectable fork — alone, each
client only knows the relay was consistent with itself.

Tested, 100 engine / 52 client, none failing. Consistency is checked over EVERY
pair of prefix sizes in the published vectors, because the traversal has a
special case when the old size is a power of two: an implementation that gets
that wrong still passes 3 -> 5 and fails 2 -> 5. The proof builder the test
uses is trustworthy because the tree it builds is the one whose roots already
matched the published vectors at every size.

The refusals are what carry the weight, so each has its own test: a head going
backwards, two roots at one size, a tampered proof node, a served key absent
from the log, an inclusion proof for somebody else's leaf, and a rewritten log
that cannot be reconciled with a pinned root. A trust check that accepts
everything passes every happy-path test ever written.

Reachable from the UI, per the standing rule. The thread shows identity state
and the safety number; the changed state offers accept and nothing else,
because an "ignore" affordance would be a way to silence the one signal in this
product that means interception. `CrudSurfaceTest` asserts the controls exist.

⚠️ Not yet exercised against a live relay — the tests drive a real HTTP server
with hand-built responses. Implemented and unit-tested, not proven in the
field. ⚠️ Sealed sender is still absent, so the relay still learns who talks to
whom; these protect different things.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-25 13:48:44 -04:00
co-authored by Claude Opus 5
parent 76d485fb55
commit 0330a7b84a
10 changed files with 1169 additions and 4 deletions
@@ -203,6 +203,115 @@ class PublishedVectorsTest {
assertEquals(8, KeyLog.split(9))
}
// -- §19 consistency -----------------------------------------------------
@Test
fun `a consistency proof verifies between every pair of prefix sizes`() {
val keyLog = vectors.getJSONObject("keyLog")
val leaves = keyLog.getJSONArray("leafHashes")
val all = (0 until leaves.length()).map { leaves.bytesAt(it) }
// Every pair, not a sample. The traversal has a special case when the
// old size is a power of two — the old root leads the proof implicitly
// rather than being transmitted — so an implementation that gets that
// wrong still passes at 3 -> 5 and fails at 2 -> 5.
for (m in 1..all.size) {
for (n in m..all.size) {
val proof = consistencyProof(all, m, n)
assertTrue(
KeyLog.verifyConsistency(
from = m, to = n, proof = proof,
oldRoot = rootOf(all.subList(0, m)),
newRoot = rootOf(all.subList(0, n)),
),
"honest proof rejected for $m -> $n",
)
}
}
}
@Test
fun `a log that shrank is refused`() {
val leaves = vectors.getJSONObject("keyLog").getJSONArray("leafHashes")
val all = (0 until leaves.length()).map { leaves.bytesAt(it) }
assertFalse(
KeyLog.verifyConsistency(
from = 4, to = 2, proof = consistencyProof(all, 2, 4),
oldRoot = rootOf(all.subList(0, 4)),
newRoot = rootOf(all.subList(0, 2)),
),
"a head going backwards must never verify",
)
}
/**
* 🔴 The attack this whole mechanism exists to stop.
*
* A relay that rewrites history can still issue perfectly valid INCLUSION
* proofs against its new tree — they are honest proofs about a dishonest
* log. What it cannot do is reconcile the new tree with one a client
* already pinned. If this test ever passes, the log is decorative.
*/
@Test
fun `a rewritten log cannot be reconciled with a pinned root`() {
val leaves = vectors.getJSONObject("keyLog").getJSONArray("leafHashes")
val honest = (0 until leaves.length()).map { leaves.bytesAt(it) }
// Same size, one substituted leaf: the relay swapping a published key.
val rewritten = honest.toMutableList()
rewritten[1] = KeyLog.leafHash("a key the relay minted".toByteArray())
assertFalse(
KeyLog.verifyConsistency(
from = 2, to = honest.size,
proof = consistencyProof(rewritten, 2, rewritten.size),
oldRoot = rootOf(honest.subList(0, 2)), // what we pinned
newRoot = rootOf(rewritten), // what it serves now
),
"a substituted leaf must break consistency against the pinned root",
)
}
@Test
fun `a tampered proof node is refused`() {
val leaves = vectors.getJSONObject("keyLog").getJSONArray("leafHashes")
val all = (0 until leaves.length()).map { leaves.bytesAt(it) }
val proof = consistencyProof(all, 2, 5).toMutableList()
proof[0] = KeyLog.leafHash("not the sibling".toByteArray())
assertFalse(
KeyLog.verifyConsistency(
from = 2, to = 5, proof = proof,
oldRoot = rootOf(all.subList(0, 2)),
newRoot = rootOf(all),
),
)
}
@Test
fun `equal sizes verify only when the roots match and nothing is proved`() {
val leaves = vectors.getJSONObject("keyLog").getJSONArray("leafHashes")
val all = (0 until leaves.length()).map { leaves.bytesAt(it) }
val root = rootOf(all.subList(0, 3))
assertTrue(KeyLog.verifyConsistency(3, 3, emptyList(), root, root))
// Two different roots at one size is the definition of a fork.
assertFalse(KeyLog.verifyConsistency(3, 3, emptyList(), root, rootOf(all.subList(0, 2))))
// A proof where none is possible is not a proof.
assertFalse(KeyLog.verifyConsistency(3, 3, listOf(root), root, root))
}
@Test
fun `a zero or negative old size is refused`() {
val leaves = vectors.getJSONObject("keyLog").getJSONArray("leafHashes")
val all = (0 until leaves.length()).map { leaves.bytesAt(it) }
val root = rootOf(all)
// An empty tree has no root to be consistent with, and treating 0 as
// "nothing pinned yet" here would let a caller skip the check by
// passing a size it never actually saw.
assertFalse(KeyLog.verifyConsistency(0, 5, emptyList(), root, root))
assertFalse(KeyLog.verifyConsistency(-1, 5, emptyList(), root, root))
}
/**
* The reference tree, built here rather than in the engine.
*
@@ -217,4 +326,28 @@ class PublishedVectorsTest {
val k = KeyLog.split(leaves.size)
return KeyLog.nodeHash(rootOf(leaves.subList(0, k)), rootOf(leaves.subList(k, leaves.size)))
}
/**
* The RFC 6962 consistency proof between two prefix sizes.
*
* Built here for the same reason as [rootOf], and trustworthy for the same
* reason: it is made of [KeyLog.split] and [KeyLog.nodeHash], and the tree
* it describes is the one whose roots already matched the published
* vectors at every size. A builder that disagreed with the spec would have
* failed that test first.
*/
private fun consistencyProof(leaves: List<ByteArray>, m: Int, n: Int): List<ByteArray> {
require(m in 1..n && n <= leaves.size)
return subProof(leaves.subList(0, n), m, true)
}
private fun subProof(leaves: List<ByteArray>, m: Int, isOldRoot: Boolean): List<ByteArray> {
if (m == leaves.size) return if (isOldRoot) emptyList() else listOf(rootOf(leaves))
val k = KeyLog.split(leaves.size)
return if (m <= k) {
subProof(leaves.subList(0, k), m, isOldRoot) + rootOf(leaves.subList(k, leaves.size))
} else {
subProof(leaves.subList(k, leaves.size), m - k, false) + rootOf(leaves.subList(0, k))
}
}
}