From 6f5c2a7a245988bb2b721f7dfc051b92a6b592d4 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Sun, 30 Aug 2026 09:43:37 -0400 Subject: [PATCH] Test the scheduled half of key transparency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `verifyLogConsistencyIfDue` and `consistent` were at 0%, and `verifyHeadSignature` at 7%. These are what make transparency act rather than merely record: checking the log only while establishing a session leaves an established conversation unchecked indefinitely, which is exactly the conversation worth attacking, so a relay that rewound or forked between two bundle fetches would go unseen โ€” possibly for ever. Two failure directions matter equally here and pull against each other. A check that never fires misses a real fork. A check that treats every network blip as a fork cries wolf until the alarm means nothing, which is worse than no alarm. So half of these twenty-five tests assert that something is NOT reported: an unreachable relay, a failed consistency fetch, an unsigned head from a self-hoster who cannot sign, a malformed signature, and a peer who is merely further ahead than we are. The other half are the refusals: a log that shrank, the same size under a different root, growth whose consistency proof does not close the gap, a head signed by a key other than the pinned issuer, and a peer on an irreconcilable branch. Also that the alarm stands โ€” a relay that forked once does not become trustworthy because the next request looked fine. Proofs are computed from a real eight-leaf tree rather than stubbed, so "the proof verifies" means the same thing here as on the wire. Trust 73.9% -> 98.1% lines. Co-Authored-By: Claude Opus 4.8 --- .../client/TrustLogConsistencyTest.kt | 403 ++++++++++++++++++ 1 file changed, 403 insertions(+) create mode 100644 client/src/test/kotlin/io/flamenet/client/TrustLogConsistencyTest.kt diff --git a/client/src/test/kotlin/io/flamenet/client/TrustLogConsistencyTest.kt b/client/src/test/kotlin/io/flamenet/client/TrustLogConsistencyTest.kt new file mode 100644 index 0000000..a16d0b4 --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/TrustLogConsistencyTest.kt @@ -0,0 +1,403 @@ +package io.flamenet.client + +import io.flamenet.e2e.DeviceIdentity +import io.flamenet.e2e.Ed25519 +import io.flamenet.e2e.KeyLog +import io.flamenet.e2e.Vault +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import javax.crypto.KeyGenerator +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The scheduled half of key transparency (ยง19.3, ยง19.4, audit S4/M1). + * + * ๐Ÿ”ด **`verifyLogConsistencyIfDue` and `consistent` were at 0%, and + * `verifyHeadSignature` at 7%.** These are what make transparency *act* rather + * than merely record. Checking the log only while establishing a session + * leaves an established conversation unchecked indefinitely, which is exactly + * the conversation worth attacking โ€” so a relay that rewound or forked between + * two bundle fetches would go unseen, possibly for ever. + * + * Two failure directions matter equally here and pull against each other. A + * check that never fires misses a real fork. A check that treats every network + * blip as a fork cries wolf until the alarm means nothing, which is worse than + * no alarm โ€” so half of these tests assert that something is NOT reported. + */ +class TrustLogConsistencyTest { + + private class MapStorage : Vault.Storage { + val records = LinkedHashMap() + override fun read(name: String) = records[name] + override fun write(name: String, bytes: ByteArray) { records[name] = bytes } + override fun delete(name: String) { records.remove(name) } + override fun names() = records.keys.toList() + } + + private lateinit var server: MockWebServer + private lateinit var vault: Vault + private lateinit var identity: DeviceIdentity + + @BeforeTest fun start() { + server = MockWebServer().also { it.start() } + vault = Vault(MapStorage(), + KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()) + identity = DeviceIdentity.create() + } + + @AfterTest fun stop() = server.shutdown() + + private fun trust() = + Trust(identity, vault, RelayClient(server.url("/").toString(), "a", "b")) + + private fun json(body: String) = MockResponse() + .setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body) + + // -- A real tree, so proofs are genuine rather than stubbed -------------- + + private val leaves = (0 until 8).map { KeyLog.leafHash(byteArrayOf(it.toByte())) } + + private fun root(ls: List): ByteArray = when { + ls.size == 1 -> ls[0] + else -> { + val k = KeyLog.split(ls.size) + KeyLog.nodeHash(root(ls.take(k)), root(ls.drop(k))) + } + } + + private fun consistencyProof(ls: List, m: Int, n: Int): List = + if (m == n) emptyList() else sub(ls, m, n, true) + + private fun sub(ls: List, m: Int, n: Int, complete: Boolean): List { + if (m == n) return if (complete) emptyList() else listOf(root(ls.take(n))) + val k = KeyLog.split(n) + return if (m <= k) sub(ls.take(k), m, k, complete) + root(ls.subList(k, n)) + else sub(ls.subList(k, n), m - k, n - k, false) + root(ls.take(k)) + } + + private fun rootB64(n: Int) = Wire.b64(root(leaves.take(n))) + + private fun pin(size: Int) = vault.saveKeyLogPin(Vault.KeyLogPin(size, rootB64(size))) + + private fun headBody(size: Int, root: String = rootB64(size), sig: String = "") = + """{"size":$size,"root":"$root","audience":"r"${if (sig.isEmpty()) "" else ",\"signature\":\"$sig\""}}""" + + private fun proofBody(m: Int, n: Int) = + """{"proof":[${consistencyProof(leaves, m, n).joinToString(",") { "\"${Wire.b64(it)}\"" }}]}""" + + // -- Scheduled consistency ---------------------------------------------- + + @Test + fun `with nothing pinned there is nothing to check`() { + val t = trust() + t.verifyLogConsistencyIfDue() + assertFalse(t.forkDetected) + assertEquals(0, server.requestCount) // and it did not go to the network + } + + @Test + fun `an honestly grown log advances the pin`() { + pin(4) + server.enqueue(json(headBody(8))) + server.enqueue(json(proofBody(4, 8))) + + val t = trust() + t.verifyLogConsistencyIfDue() + + assertFalse(t.forkDetected) + assertEquals(8, vault.loadKeyLogPin()!!.size) + assertEquals(rootB64(8), vault.loadKeyLogPin()!!.root) + } + + /** + * ๐Ÿ”ด A log that got SMALLER is a rewind, and no proof can excuse it. This + * is the cheapest attack to attempt and the one an unscheduled check would + * never see. + */ + @Test + fun `a log that shrank is reported as a fork`() { + pin(8) + server.enqueue(json(headBody(4))) + + val t = trust() + t.verifyLogConsistencyIfDue() + + assertTrue(t.forkDetected) + // the pin is NOT advanced onto the smaller log + assertEquals(8, vault.loadKeyLogPin()!!.size) + } + + @Test + fun `the same size with a different root is a fork`() { + pin(4) + server.enqueue(json(headBody(4, root = Wire.b64(ByteArray(32) { 0x5A })))) + + val t = trust() + t.verifyLogConsistencyIfDue() + assertTrue(t.forkDetected) + } + + @Test + fun `the same size with the same root is fine`() { + pin(4) + server.enqueue(json(headBody(4))) + + val t = trust() + t.verifyLogConsistencyIfDue() + assertFalse(t.forkDetected) + } + + /** + * ๐Ÿ”ด A head that does not extend our pin, with a proof that does not close + * the gap, is the actual split-view attack. + */ + @Test + fun `growth whose consistency proof does not verify is a fork`() { + pin(4) + server.enqueue(json(headBody(8))) + server.enqueue(json("""{"proof":["${Wire.b64(ByteArray(32) { 1 })}"]}""")) + + val t = trust() + t.verifyLogConsistencyIfDue() + + assertTrue(t.forkDetected) + assertEquals(4, vault.loadKeyLogPin()!!.size) + } + + /** + * โš ๏ธ The network is allowed to be down. Reporting a fork because a fetch + * failed would raise a standing, unclearable alarm on a working relay. + */ + @Test + fun `an unreachable relay is not a fork`() { + pin(4) + server.enqueue(MockResponse().setResponseCode(503).setBody("down")) + + val t = trust() + t.verifyLogConsistencyIfDue() + + assertFalse(t.forkDetected) + assertEquals(4, vault.loadKeyLogPin()!!.size) + } + + @Test + fun `a failed consistency fetch is not a fork either`() { + pin(4) + server.enqueue(json(headBody(8))) + server.enqueue(MockResponse().setResponseCode(500).setBody("boom")) + + val t = trust() + t.verifyLogConsistencyIfDue() + assertFalse(t.forkDetected) + } + + /** + * It self-throttles because it is called from the poll loop. Without that + * it would fetch the head on every poll, which is a request per few + * seconds per device for a check that only needs to be hourly. + */ + @Test + fun `the sweep throttles itself between runs`() { + pin(4) + server.enqueue(json(headBody(4))) + + val t = trust() + val now = 1_000_000_000L + t.verifyLogConsistencyIfDue(now) + assertEquals(1, server.requestCount) + + t.verifyLogConsistencyIfDue(now + 60_000) // a minute later + assertEquals(1, server.requestCount, "should not have re-fetched") + } + + @Test + fun `the sweep runs again once the interval has passed`() { + pin(4) + server.enqueue(json(headBody(4))) + server.enqueue(json(headBody(4))) + + val t = trust() + val now = 1_000_000_000L + t.verifyLogConsistencyIfDue(now) + t.verifyLogConsistencyIfDue(now + 3_600_001) + assertEquals(2, server.requestCount) + } + + // -- Head signatures (ยง19.3, audit M1) ---------------------------------- + + private fun signedHead(size: Int, signer: Ed25519.KeyPair): String { + val sig = Ed25519.sign(signer, "fnkt-v1|$size|${rootB64(size)}".toByteArray()) + return headBody(size, sig = Wire.b64(sig)) + } + + @Test + fun `a correctly signed head pins the issuer key on first sight`() { + val issuer = Ed25519.generate() + pin(4) + server.enqueue(json(signedHead(4, issuer))) + server.enqueue(json("""{"issuerPublicKey":"${Wire.b64(issuer.publicKey)}"}""")) + + val t = trust() + t.verifyLogConsistencyIfDue() + + assertFalse(t.forkDetected) + assertEquals(Wire.b64(issuer.publicKey), Wire.b64(vault.keyLogIssuerPin()!!)) + } + + /** + * ๐Ÿ”ด Pinned, not fetched-and-trusted. Once an issuer key is pinned, a head + * signed by anyone else is a fork โ€” that is the whole value of pinning it. + */ + @Test + fun `a head signed by a different key than the pinned one is a fork`() { + val issuer = Ed25519.generate() + val impostor = Ed25519.generate() + vault.saveKeyLogIssuerPin(issuer.publicKey) + pin(4) + server.enqueue(json(signedHead(4, impostor))) + + val t = trust() + t.verifyLogConsistencyIfDue() + assertTrue(t.forkDetected) + } + + /** + * โš ๏ธ Silent when nothing is signed. A deployment that cannot sign is + * weaker, not hostile, and refusing it outright is a worse failure than the + * one prevented โ€” self-hosters would simply be locked out. + */ + @Test + fun `an unsigned head is accepted rather than refused`() { + pin(4) + server.enqueue(json(headBody(4))) + + val t = trust() + t.verifyLogConsistencyIfDue() + assertFalse(t.forkDetected) + } + + @Test + fun `a malformed signature is ignored rather than treated as a fork`() { + pin(4) + server.enqueue(json(headBody(4, sig = "!!!not base64!!!"))) + + val t = trust() + t.verifyLogConsistencyIfDue() + assertFalse(t.forkDetected) + } + + @Test + fun `a signed head with no published issuer key is left alone`() { + val issuer = Ed25519.generate() + pin(4) + server.enqueue(json(signedHead(4, issuer))) + server.enqueue(json("""{}""")) // descriptor publishes nothing + + val t = trust() + t.verifyLogConsistencyIfDue() + assertFalse(t.forkDetected) + assertEquals(null, vault.keyLogIssuerPin()) + } + + // -- Gossip (ยง19.4) ------------------------------------------------------ + + @Test + fun `a peer reporting a different root at the same size proves a fork`() { + pin(4) + val t = trust() + t.gossip(4, Wire.b64(ByteArray(32) { 0x11 })) + assertTrue(t.forkDetected) + } + + @Test + fun `a peer agreeing at the same size is not a fork`() { + pin(4) + val t = trust() + t.gossip(4, rootB64(4)) + assertFalse(t.forkDetected) + } + + /** + * โš ๏ธ Different sizes are not evidence by themselves โ€” one of us is simply + * behind. Treating that as a fork would fire constantly in normal use. + */ + @Test + fun `a peer merely further ahead is not a fork`() { + pin(4) + server.enqueue(json(proofBody(4, 8))) + val t = trust() + t.gossip(8, rootB64(8)) + assertFalse(t.forkDetected) + } + + @Test + fun `a peer ahead on an irreconcilable branch is a fork`() { + pin(4) + server.enqueue(json("""{"proof":["${Wire.b64(ByteArray(32) { 2 })}"]}""")) + val t = trust() + t.gossip(8, rootB64(8)) + assertTrue(t.forkDetected) + } + + @Test + fun `gossip against an unreachable relay is not a fork`() { + pin(4) + server.enqueue(MockResponse().setResponseCode(503).setBody("down")) + val t = trust() + t.gossip(8, rootB64(8)) + assertFalse(t.forkDetected) + } + + @Test + fun `gossip with nothing pinned does nothing`() { + val t = trust() + t.gossip(4, rootB64(4)) + assertFalse(t.forkDetected) + assertEquals(0, server.requestCount) + } + + /** + * ๐Ÿ”ด A relay that forked once does not become trustworthy because the next + * request looked fine. The alarm is standing, and nothing here clears it. + */ + @Test + fun `a detected fork is not cleared by later good news`() { + pin(4) + val t = trust() + t.gossip(4, Wire.b64(ByteArray(32) { 0x11 })) + assertTrue(t.forkDetected) + + t.gossip(4, rootB64(4)) + assertTrue(t.forkDetected, "the alarm must stand") + } + + // -- headPayload --------------------------------------------------------- + + @Test + fun `there is nothing to gossip before anything is pinned`() { + assertEquals(null, trust().headPayload()) + } + + @Test + fun `the gossip payload carries our pinned head`() { + pin(4) + val payload = trust().headPayload()!! + assertTrue(payload.contains("\"kind\":\"kt_head\"")) + assertTrue(payload.contains("\"size\":4")) + assertTrue(payload.contains(rootB64(4))) + } + + // -- forget -------------------------------------------------------------- + + @Test + fun `forgetting a contact drops its pinned identities`() { + vault.saveTrust(7, Vault.Trust(listOf("ik"), verified = true)) + trust().forget(7) + assertEquals(null, vault.loadTrust(7)) + } +}