Test the scheduled half of key transparency

`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 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-30 09:43:37 -04:00
co-authored by Claude Opus 4.8
parent a40cc2d2d4
commit 6f5c2a7a24
@@ -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<String, ByteArray>()
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>): 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<ByteArray>, m: Int, n: Int): List<ByteArray> =
if (m == n) emptyList() else sub(ls, m, n, true)
private fun sub(ls: List<ByteArray>, m: Int, n: Int, complete: Boolean): List<ByteArray> {
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))
}
}