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:
co-authored by
Claude Opus 5
parent
76d485fb55
commit
0330a7b84a
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user