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:
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user