diff --git a/PARITY.md b/PARITY.md index 7767c67..21df381 100644 --- a/PARITY.md +++ b/PARITY.md @@ -100,8 +100,8 @@ From `E2E/E2EInbox.swift`, which is the real feature list. | Groups: create, list, members, leave | ⚪ | | Group text and images | ⚪ | | Delivery keys + rotation | ⚪ | -| Trust state, verification, identity-change acceptance | ⚪ | -| Key-log enforcement and head gossip | ⚪ | +| Trust state, verification, identity-change acceptance | ✅ pinned in the vault, surfaced on the thread | +| Key-log enforcement and head gossip | ✅ opt-in enforcement, gossip detects a fork | | Clear thread | ✅ keeps the session, so the chat still works | | History at rest (encrypted, device-only key) | ⚪ | | Presence (read and set) | ✅ shown from the relay, never inferred | @@ -212,6 +212,43 @@ believing a slow or flaky XCUITest run. ⚠️ Point `-derivedDataPath` at a directory ending `.noindex`. Spotlight will otherwise index every build product, which is a large share of the above. +## §19 on Android, 2026-08-25 + +Pinning, verification, identity-change acceptance, key-log enforcement and head +gossip now exist and are tested. What each one is worth: + +**Pinning is always on.** Every identity key a peer offers is compared against +what was pinned the first time, and a send to a changed identity is refused +until a person accepts it. ⚠️ The comparison is `all offered keys are pinned`, +not `any` — a relay adding one key of its own alongside the real ones is the +attack, and every genuine device still working is what makes it invisible. + +**Key-log enforcement is opt-in** (`require-key-log` on the Account screen), +for the same reason the web engine's is: a relay older than the log has none, +and refusing every peer there is a worse failure than the one being prevented. + +**`verifyConsistency` did not exist in the Kotlin engine and now does.** The +engine had inclusion only, which on its own is worth very little: a relay that +rewrites history reissues perfectly valid inclusion proofs against its new +tree. Consistency against a pinned head is the check it cannot satisfy. + +Tested: 100 engine / 52 client, none failing. The consistency tests run 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 it wrong still passes at 3→5 and fails at 2→5. The refusals are tested +explicitly: a shrinking head, 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. + +⚠️ **Not yet exercised against a live relay.** The unit tests drive a real HTTP +server with hand-built responses; nothing has yet enforced §19 end to end +against `relay1`. That is the next thing to do here, and until it is done this +is "implemented and unit-tested", not "proven in the field". + +⚠️ **Sealed sender is still absent**, so the relay continues to learn who is +talking to whom. Key transparency and sealed sender protect different things +and neither substitutes for the other. + ## Known flakiness ⚠️ **The emulator's network drops occasionally** — seen twice as "Failed to diff --git a/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrudSurfaceTest.kt b/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrudSurfaceTest.kt index e9b4f82..b773427 100644 --- a/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrudSurfaceTest.kt +++ b/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrudSurfaceTest.kt @@ -112,6 +112,9 @@ class CrudSurfaceTest { compose.onNodeWithTag("save-password").assertExists() compose.onNodeWithTag("sign-out").assertExists() compose.onNodeWithTag("delete-account").assertExists() + // §19 enforcement is a user-facing choice, so it has to have a control. + // A security setting reachable only from code is a setting nobody has. + compose.onNodeWithTag("require-key-log").assertExists() compose.onNodeWithTag("nav-contacts").performClick() awaitTag("add-contact", 20_000) @@ -132,6 +135,14 @@ class CrudSurfaceTest { compose.onNodeWithTag("send").assertExists() compose.onNodeWithTag("attach").assertExists() compose.onNodeWithTag("back").assertExists() + + // Identity state, on the thread it describes rather than buried in a + // settings screen. A freshly added contact is PINNED — trust on first + // use — so the state line and a way to reach the number both exist. + compose.onNodeWithTag("identity-state").assertExists() + compose.onNodeWithTag("show-safety-number").performClick() + compose.onNodeWithTag("safety-number").assertExists() + compose.onNodeWithTag("toggle-verified").assertExists() } // -- Helpers ------------------------------------------------------------- diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index 586ed93..b0b2737 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -23,6 +23,7 @@ import androidx.compose.material3.Divider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -37,6 +38,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.testTag +import io.flamenet.client.IdentityTrust +import io.flamenet.e2e.SafetyNumber import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import java.text.SimpleDateFormat @@ -450,6 +453,50 @@ fun ColumnScope.AccountScreen( Divider() + // -- Security (§19) ------------------------------------------------------ + + Text("Security", style = MaterialTheme.typography.titleMedium) + + if (phase.forkDetected) { + // Above the toggle, not below: this is a statement about the server the + // toggle configures, and it changes what the toggle means. + Text( + "⚠️ This relay showed two different key logs.", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.testTag("fork-detected"), + ) + Text( + "Two devices compared what this server told each of them about its key log, and the " + + "answers cannot both be true. A server doing that can hand different people " + + "different keys for the same person. Treat conversations here as compromised " + + "until you have verified safety numbers in person.", + style = MaterialTheme.typography.bodySmall, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text("Require published keys", modifier = Modifier.weight(1f)) + Switch( + checked = phase.requireKeyLog, + onCheckedChange = model::setRequireKeyLog, + modifier = Modifier.testTag("require-key-log"), + ) + } + Text( + "Refuse to send unless this server can prove the recipient's keys appear in the public " + + "log it publishes. Stronger, but older servers keep no log and every message to " + + "them will fail. Your keys are always compared against what you saw before, " + + "whether this is on or off.", + style = MaterialTheme.typography.bodySmall, + ) + + Divider() + // Sign-out lives here rather than under the contact list, because this is // where people look for it — and because it is destructive enough to sit // beside the other destructive things rather than under a list of friends. @@ -519,6 +566,10 @@ fun ColumnScope.ChatScreen(model: MessengerViewModel, chat: MessengerViewModel.P } } + // Identity, directly under the name it belongs to. Groups have many + // identities and no single answer, so this is for one-to-one threads only. + if (chat.peerId != null) IdentityPanel(model, chat) + LazyColumn( state = listState, modifier = Modifier.fillMaxWidth().weight(1f).testTag("messages"), @@ -613,3 +664,106 @@ private fun Bubble(line: Session.Line, model: MessengerViewModel) { } } } + +/** + * What we know about the other side's keys, and the two things a person can do. + * + * ⚠️ **The changed state is not a warning banner to be dismissed.** It offers + * exactly one action — accept — because the only way past it is a decision + * somebody makes on purpose. An "ignore" affordance here would be a way to + * silence the one signal in the product that means an interception. + * + * ⚠️ The safety number is shown, not hidden behind a menu. A number nobody can + * find is a number nobody compares, and an uncompared number verifies nothing. + */ +@Composable +private fun ColumnScope.IdentityPanel( + model: MessengerViewModel, + chat: MessengerViewModel.Phase.Chat, +) { + val peerId = chat.peerId ?: return + var showNumber by remember(peerId) { mutableStateOf(false) } + + if (chat.trust == IdentityTrust.CHANGED) { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp).testTag("identity-changed"), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + "This person's security keys changed.", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + // Both readings, in the order that matters. Naming only the benign + // one trains people to tap through it. + Text( + "That happens when someone reinstalls or adds a device — and it is also what " + + "an intercepted conversation looks like. Compare safety numbers with them " + + "on a channel this app does not control before you accept.", + style = MaterialTheme.typography.bodySmall, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton( + onClick = { model.acceptIdentityChange(peerId) }, + modifier = Modifier.testTag("accept-identity"), + ) { Text("Accept new keys") } + TextButton( + onClick = { showNumber = !showNumber }, + modifier = Modifier.testTag("show-safety-number"), + ) { Text(if (showNumber) "Hide number" else "Show number") } + } + } + } else { + Row( + modifier = Modifier.fillMaxWidth().testTag("identity-state"), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + when (chat.trust) { + IdentityTrust.VERIFIED -> "Verified" + IdentityTrust.PINNED -> "Keys unchanged since you met" + else -> "No keys seen yet" + }, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f).testTag("trust-${chat.trust.name.lowercase()}"), + ) + if (chat.safetyNumber != null) { + TextButton( + onClick = { showNumber = !showNumber }, + modifier = Modifier.testTag("show-safety-number"), + ) { Text(if (showNumber) "Hide number" else "Safety number") } + } + } + } + + if (showNumber) { + chat.safetyNumber?.let { number -> + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + SafetyNumber.format(number), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.testTag("safety-number"), + ) + Text( + "Read this to each other in person or on a call. If it matches, mark them " + + "verified. ⚠️ It covers their first device only — someone signed in on " + + "several has more than one number.", + style = MaterialTheme.typography.bodySmall, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton( + onClick = { model.setVerified(peerId, chat.trust != IdentityTrust.VERIFIED) }, + modifier = Modifier.testTag("toggle-verified"), + ) { + Text(if (chat.trust == IdentityTrust.VERIFIED) "Unmark verified" + else "Mark verified") + } + } + } + } + } +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index 774582f..765e2db 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -11,6 +11,7 @@ import io.flamenet.client.RelayException import io.flamenet.e2e.DeviceIdentity import io.flamenet.e2e.Vault import kotlinx.coroutines.Dispatchers +import io.flamenet.client.IdentityTrust import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -50,6 +51,17 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { val openChat: Chat? = null, val busy: Boolean = false, val error: String? = null, + /** Refuse peers whose keys the relay cannot prove it logged (§19). */ + val requireKeyLog: Boolean = false, + /** + * The relay was caught serving two different logs. + * + * Sticky once set, and deliberately not clearable from the UI: a + * relay that forked does not become trustworthy because the next + * request looked fine, and a dismissible warning is one people + * dismiss. + */ + val forkDetected: Boolean = false, ) : Phase enum class Screen { CONTACTS, NOTIFICATIONS, ACCOUNT } @@ -67,6 +79,17 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { val lines: List, val peerId: Long? = null, val group: io.flamenet.client.RelayClient.Group? = null, + /** + * What we believe about the other side's identity keys (§19). + * + * ⚠️ Meaningless for a group and left [IdentityTrust.UNKNOWN] + * there: a group has many identities and one badge cannot speak + * for all of them. Showing a reassuring tick that covers some + * unknown subset of the members would be worse than showing none. + */ + val trust: IdentityTrust = IdentityTrust.UNKNOWN, + /** The number both people read aloud. Null until something is pinned. */ + val safetyNumber: String? = null, ) /** @@ -308,7 +331,11 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { val thread = Vault.threadForUser(peerId) update { it.copy( - openChat = Phase.Chat(thread, peerName, current.history(thread), peerId = peerId), + openChat = Phase.Chat( + thread, peerName, current.history(thread), peerId = peerId, + trust = current.conversations.trust.state(peerId), + safetyNumber = current.conversations.trust.safetyNumber(peerId), + ), // Opening the thread is what marks it read. Nothing is sent to // the relay for this — read state is local, because a read // receipt is a disclosure and this product does not make it by @@ -329,6 +356,62 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { } } + // -- Identity trust (§19) ------------------------------------------------ + + /** + * Record that two people compared safety numbers out of band. + * + * ⚠️ There is no way to do this "in the app" and there should not be. If a + * channel existed for the two devices to confirm each other through the + * relay, the relay could answer for both — which is exactly the party the + * comparison is meant to exclude. + */ + fun setVerified(peerId: Long, verified: Boolean) = viewModelScope.launch { + val current = session ?: return@launch + withContext(Dispatchers.IO) { current.conversations.trust.setVerified(peerId, verified) } + update { + it.copy(openChat = it.openChat?.takeIf { c -> c.peerId == peerId } + ?.copy(trust = current.conversations.trust.state(peerId)) ?: it.openChat) + } + } + + /** + * Accept that someone's identity really did change. + * + * Re-pins what the relay now serves and drops the verified tick, because + * the previous comparison was about a key nobody uses any more. + */ + fun acceptIdentityChange(peerId: Long) = viewModelScope.launch { + val current = session ?: return@launch + val outcome = withContext(Dispatchers.IO) { + runCatching { current.conversations.trust.acceptIdentityChange(peerId) } + } + outcome.onFailure { failure -> update { it.copy(error = describe(failure)) } } + update { + it.copy( + error = if (outcome.isSuccess) null else it.error, + openChat = it.openChat?.takeIf { c -> c.peerId == peerId }?.copy( + trust = current.conversations.trust.state(peerId), + safetyNumber = current.conversations.trust.safetyNumber(peerId), + ) ?: it.openChat, + ) + } + } + + /** + * Turn key-log enforcement on or off. + * + * ⚠️ Off by default, and that is not laziness. A relay older than the + * transparency log has no log to prove anything against, so enforcing it + * everywhere would refuse every peer on those deployments — a worse + * failure than the one being prevented, and one the user cannot fix. + * Pinning is always on regardless; this is the stricter, opt-in half. + */ + fun setRequireKeyLog(required: Boolean) { + session?.conversations?.trust?.requireKeyLog = required + update { it.copy(requireKeyLog = required) } + } + // -- Groups -------------------------------------------------------------- fun refreshGroups() = viewModelScope.launch { @@ -595,7 +678,21 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { "${sent.delivered.size + sent.failed.size} devices.", ) } - }.onFailure { failure -> update { it.copy(error = describe(failure)) } } + }.onFailure { failure -> + update { + it.copy( + error = describe(failure), + // A refusal changes what we believe about them, so the + // badge has to catch up in the same breath — otherwise the + // header still says "pinned" while the send just failed + // because it is not. + openChat = chat.peerId?.let { peer -> + it.openChat?.copy(trust = current.conversations.trust.state(peer)) + } ?: it.openChat, + forkDetected = current.conversations.trust.forkDetected, + ) + } + } } /** diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index 583c1b8..8fb48f4 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -26,6 +26,14 @@ class Conversations( private val identity: DeviceIdentity, private val vault: Vault, private val relay: RelayClient, + /** + * Identity pinning and key-log enforcement (§19). + * + * Constructed here rather than passed in by every call site, and never + * null: a send path with an optional safety check is a send path that ends + * up shipped with it optional-ly absent. + */ + val trust: Trust = Trust(identity, vault, relay), ) { /** One decrypted message, interpreted, and where it came from. */ @@ -132,8 +140,47 @@ class Conversations( fun fetchAttachment(ref: Attachment.Ref): ByteArray = Attachment.open(relay.downloadAttachment(ref.id), ref) + /** + * Refuse to encrypt to keys we have reason to distrust. + * + * 🔴 **Before the first envelope, not after.** Checking afterwards means + * the ciphertext is already on the relay addressed to whatever key it + * offered, and the check becomes a notification that it is too late. + * + * Two failures, and they are different in kind: + * + * - [IdentityChangedException] — a key changed under someone we already + * know. Only a human can say whether that was a new phone or an + * interception, so this stops and asks rather than guessing. Accepting + * is [Trust.acceptIdentityChange]. + * - [KeyNotLoggedException] / [KeyLogForkException] — the relay cannot + * prove the key it served is in the log it publishes. Raised only when + * `trust.requireKeyLog` is on, because a relay predating the log has + * none and refusing everyone there is the worse failure. + * + * ⚠️ A fork already detected by gossip fails the send outright, whatever + * this particular bundle looks like. A relay caught serving two logs has + * disqualified itself; continuing to talk through it because the current + * request happens to verify is the mistake this whole section exists to + * avoid. + */ + private fun guardIdentity(toUserId: Long, bundles: List) { + val theirs = bundles.filter { it.deviceId != identity.deviceId } + if (theirs.isEmpty()) return + + if (trust.record(toUserId, theirs.map { it.identitySigning }) == IdentityTrust.CHANGED) { + throw IdentityChangedException(toUserId) + } + if (!trust.requireKeyLog) return + if (trust.forkDetected) { + throw KeyLogForkException("a fork was already detected on this relay") + } + theirs.forEach { trust.enforceKeyLog(it, toUserId) } + } + private fun sendPlaintext(toUserId: Long, text: String): Sent { val bundles = relay.fetchKeys(toUserId) + guardIdentity(toUserId, bundles) val delivered = mutableListOf() val failed = LinkedHashMap() val outgoing = mutableListOf() diff --git a/client/src/main/kotlin/io/flamenet/client/Trust.kt b/client/src/main/kotlin/io/flamenet/client/Trust.kt new file mode 100644 index 0000000..2d62f96 --- /dev/null +++ b/client/src/main/kotlin/io/flamenet/client/Trust.kt @@ -0,0 +1,289 @@ +package io.flamenet.client + +import io.flamenet.e2e.DeviceIdentity +import io.flamenet.e2e.KeyLog +import io.flamenet.e2e.PeerBundle +import io.flamenet.e2e.SafetyNumber +import io.flamenet.e2e.Vault + +/** + * What we believe about someone's identity keys. + * + * Ordered by how much a person has actually done, not by severity: [CHANGED] + * is last because it is the only one that needs them. + */ +enum class IdentityTrust { + /** Never seen. Nothing to compare against, so nothing can be said. */ + UNKNOWN, + + /** Seen before and unchanged since. Nobody has checked it out of band. */ + PINNED, + + /** Two people compared safety numbers and said they matched. */ + VERIFIED, + + /** + * A key we have never seen is being offered for someone we have. + * + * ⚠️ **This is not "they got a new phone" and it is not "you are being + * attacked".** It is exactly the state where those two are + * indistinguishable to software, which is why it stops and asks a human + * rather than picking one. + */ + CHANGED, +} + +/** The relay showed two different logs, or a log that shrank. */ +class KeyLogForkException(detail: String) : + RelayException("the relay's key log does not add up: $detail") + +/** A served identity key is not in the log the relay itself publishes. */ +class KeyNotLoggedException(val deviceId: String) : + RelayException("device $deviceId offered a key the relay never logged") + +/** Someone's identity changed and the change has not been accepted. */ +class IdentityChangedException(val userId: Long) : + RelayException("identity keys changed for user $userId") + +/** + * Identity pinning and key-transparency enforcement (spec §19). + * + * 🔴 **This is the part that makes the transparency log worth having.** A log + * nothing checks is a log that faithfully records the substitution and is + * never read; the relay hands out a key it minted, the conversation encrypts + * to it, and every byte on the wire is correctly encrypted to the wrong + * person. Two independent checks are needed and neither is sufficient alone: + * + * - **Pinning** catches a key changing under a person you already talk to, + * but says nothing the first time you meet them. + * - **The log** says a key was published where the world can see it, but a + * relay that rewrites history reissues valid proofs against the new tree — + * so inclusion is only meaningful against a head this device already + * pinned and can demand a consistency proof for. + * + * ⚠️ Deliberately not automatic. [requireKeyLog] is off by default for the + * same reason the web engine's is: a relay predating the log has none, and + * refusing every peer on a deployment that cannot answer is a worse failure + * than the one being prevented. Pinning is always on — it costs nothing and + * needs no cooperation from the relay. + */ +class Trust( + private val identity: DeviceIdentity, + private val vault: Vault, + private val relay: RelayClient, +) { + + /** Refuse a peer whose served key is not provably in the relay's log. */ + var requireKeyLog: Boolean = false + + /** + * Set when gossip proved the relay showed two different logs (§19.4). + * + * A standing condition rather than a thrown error: a fork is discovered + * while processing inbound traffic, where there is nothing to fail, and it + * stays true once discovered. Nothing here clears it — a relay that forked + * once does not become trustworthy because the next request looked fine. + */ + var forkDetected: Boolean = false + private set + + // -- Pinning ------------------------------------------------------------- + + /** + * Compare what the directory just offered against what we pinned. + * + * Trust on first use: with nothing pinned, the offered keys are taken and + * recorded. That is the weakest link in the whole scheme and it is a + * deliberate one — the alternative is a client that cannot start a + * conversation without an out-of-band channel it does not have. + */ + fun record(userId: Long, offered: List): IdentityTrust { + val encoded = offered.map(Wire::b64) + val existing = vault.loadTrust(userId) + if (existing == null || existing.identities.isEmpty()) { + if (encoded.isEmpty()) return IdentityTrust.UNKNOWN + vault.saveTrust(userId, Vault.Trust(encoded, verified = false)) + return IdentityTrust.PINNED + } + // ⚠️ EVERY offered key must already be pinned — not "any". A relay + // adding one key of its own alongside the real ones would otherwise + // pass, and that is precisely the attack: the victim's real devices + // still work, so nothing looks wrong, while a device the attacker + // controls receives a copy of everything. + if (!encoded.all { existing.identities.contains(it) }) return IdentityTrust.CHANGED + return if (existing.verified) IdentityTrust.VERIFIED else IdentityTrust.PINNED + } + + /** Trust as it stands, for UI that has not triggered a fetch. */ + fun state(userId: Long): IdentityTrust { + val t = vault.loadTrust(userId) ?: return IdentityTrust.UNKNOWN + if (t.identities.isEmpty()) return IdentityTrust.UNKNOWN + return if (t.verified) IdentityTrust.VERIFIED else IdentityTrust.PINNED + } + + /** + * The number both people read to each other. + * + * ⚠️ Covers the FIRST pinned key only. Someone with several devices has + * several identity keys and one number cannot represent all of them — + * per-device numbers are the honest fix and are not done here. Saying so + * matters: a number that silently describes one of three devices invites + * people to believe they checked more than they did. + */ + fun safetyNumber(userId: Long): String? { + val theirs = vault.loadTrust(userId)?.identities?.firstOrNull() ?: return null + return SafetyNumber.compute(identity.identitySigningPublic, Wire.unb64(theirs)) + } + + /** Record that two people compared numbers out of band. */ + fun setVerified(userId: Long, verified: Boolean) { + val current = vault.loadTrust(userId) ?: return + vault.saveTrust(userId, current.copy(verified = verified)) + } + + /** + * Accept a changed identity — they really did reinstall or add a device. + * + * ⚠️ Re-pins AND drops the verified flag. The old comparison was about a + * key that is no longer in use and says nothing about the new one; leaving + * the tick in place would show "verified" for something nobody verified. + */ + fun acceptIdentityChange(userId: Long) { + val offered = relay.fetchKeys(userId).map { it.identitySigning } + if (offered.isEmpty()) return + vault.saveTrust(userId, Vault.Trust(offered.map(Wire::b64), verified = false)) + } + + /** Forget a person entirely — used when they are removed as a contact. */ + fun forget(userId: Long) = vault.forgetTrust(userId) + + // -- Key transparency ---------------------------------------------------- + + /** + * Enforce §19 for one served bundle. Throws rather than returning a verdict: + * there is no sensible way to continue with a key that failed this. + */ + fun enforceKeyLog(bundle: PeerBundle, userId: Long, fnid: String? = null) { + val head = head() + + val pin = vault.loadKeyLogPin() + if (pin != null) { + // A log that shrank is not a log. + if (pin.size > head.size) { + throw KeyLogForkException("head went backwards, ${pin.size} -> ${head.size}") + } + if (pin.size == head.size) { + if (pin.root != head.root) { + throw KeyLogForkException("two different roots at size ${head.size}") + } + } else if (!consistent(pin.size, head.size, pin.root, head.root)) { + throw KeyLogForkException("no consistency proof from ${pin.size} to ${head.size}") + } + } + + // ⚠️ Recomputed from the bundle we were served, NEVER read from the + // relay's own reply. Taking the leaf the relay names would let it echo + // its own bytes back and satisfy the check against anything at all. + // + // `head.audience` rather than the host we dialled: the leaf was written + // by whichever relay signed this head, and the qualified subject embeds + // that relay's own name. + val want = Wire.b64( + KeyLog.leafHash( + KeyLog.canonicalLeaf( + kind = "publish", + subject = KeyLog.logSubject(fnid, head.audience, userId), + deviceId = bundle.deviceId, + ikSigB64 = Wire.b64(bundle.identitySigning), + ikDhB64 = Wire.b64(bundle.identityDH), + ) + ) + ) + + val entries = relay.logDeviceEntries(bundle.deviceId).optJSONArray("entries") + ?: throw KeyNotLoggedException(bundle.deviceId) + var index = -1 + for (i in 0 until entries.length()) { + if (entries.getJSONObject(i).optString("leaf") == want) { + index = entries.getJSONObject(i).getInt("index"); break + } + } + if (index < 0) throw KeyNotLoggedException(bundle.deviceId) + + val inc = relay.logInclusion(index.toLong(), head.size.toLong()) + val included = inc.optString("leaf") == want && KeyLog.verifyInclusion( + leaf = Wire.unb64(inc.getString("leaf")), + index = inc.getInt("index"), + size = inc.getInt("size"), + proof = inc.strings("proof").map(Wire::unb64), + root = Wire.unb64(head.root), + ) + if (!included) throw KeyNotLoggedException(bundle.deviceId) + + vault.saveKeyLogPin(Vault.KeyLogPin(head.size, head.root)) + } + + // -- Gossip (§19.4) ------------------------------------------------------ + + /** + * Our view of the log, to ride along with a message. + * + * Gossip is what turns a per-device check into a detectable fork. Alone, + * each client only knows the relay was consistent *with itself*; two + * clients comparing heads make split-view serving visible, because + * reconciling them needs a proof that cannot exist. + */ + fun headPayload(): String? { + val pin = vault.loadKeyLogPin() ?: return null + return """{"fnc":1,"kind":"kt_head","size":${pin.size},"root":"${pin.root}"}""" + } + + /** Compare a peer's view of the log against ours. */ + fun gossip(size: Int, root: String) { + val pin = vault.loadKeyLogPin() ?: return + if (size == pin.size) { + if (root != pin.root) forkDetected = true + return + } + // Different sizes are not evidence by themselves — one of us is simply + // behind. The relay has to reconcile them, and if it served two + // branches it cannot. + val older = if (size < pin.size) size else pin.size + val newer = if (size < pin.size) pin.size else size + val olderRoot = if (size < pin.size) root else pin.root + val newerRoot = if (size < pin.size) pin.root else root + val ok = try { + consistent(older, newer, olderRoot, newerRoot) + } catch (_: Exception) { + // ⚠️ A failed fetch is NOT a fork. The network is allowed to be + // down, and calling that a fork cries wolf until the warning means + // nothing — which is worse than not warning at all. + return + } + if (!ok) forkDetected = true + } + + // -- Reading the relay's log --------------------------------------------- + + private data class Head(val size: Int, val root: String, val audience: String) + + private fun head(): Head = relay.logHead().let { + Head(it.getInt("size"), it.getString("root"), it.optString("audience")) + } + + private fun consistent(from: Int, to: Int, oldRoot: String, newRoot: String): Boolean { + val c = relay.logConsistency(from.toLong(), to.toLong()) + return KeyLog.verifyConsistency( + from = from, + to = to, + proof = c.strings("proof").map(Wire::unb64), + oldRoot = Wire.unb64(oldRoot), + newRoot = Wire.unb64(newRoot), + ) + } + + private fun org.json.JSONObject.strings(name: String): List { + val a = optJSONArray(name) ?: return emptyList() + return (0 until a.length()).map { a.getString(it) } + } +} diff --git a/client/src/test/kotlin/io/flamenet/client/TrustTest.kt b/client/src/test/kotlin/io/flamenet/client/TrustTest.kt new file mode 100644 index 0000000..38b9edb --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/TrustTest.kt @@ -0,0 +1,278 @@ +package io.flamenet.client + +import io.flamenet.e2e.DeviceIdentity +import io.flamenet.e2e.KeyLog +import io.flamenet.e2e.Vault +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.json.JSONObject +import javax.crypto.KeyGenerator +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Pinning and key-log enforcement (§19), against a real HTTP server. + * + * The cases that matter here are the refusals. A trust check that accepts + * everything passes every happy-path test ever written, and the only way to + * know it does anything is to hand it something it must reject. + */ +class TrustTest { + + 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) + + private fun key(seed: Byte) = ByteArray(32) { seed } + + // -- Pinning ------------------------------------------------------------- + + @Test + fun `a first sighting is pinned rather than doubted`() { + val t = trust() + assertEquals(IdentityTrust.UNKNOWN, t.state(7)) + assertEquals(IdentityTrust.PINNED, t.record(7, listOf(key(1)))) + assertEquals(IdentityTrust.PINNED, t.state(7)) + } + + @Test + fun `the same keys offered again stay pinned`() { + val t = trust() + t.record(7, listOf(key(1), key(2))) + assertEquals(IdentityTrust.PINNED, t.record(7, listOf(key(1), key(2)))) + } + + @Test + fun `a subset is still the same person — devices go away`() { + val t = trust() + t.record(7, listOf(key(1), key(2))) + // One device removed is not an identity change. Treating it as one + // would fire the alarm every time somebody signs out on their tablet. + assertEquals(IdentityTrust.PINNED, t.record(7, listOf(key(1)))) + } + + /** + * 🔴 The attack. A relay adds ONE key of its own alongside the real ones. + * + * Every genuine device still works, so nothing looks wrong to either + * person, while a device the attacker controls receives a copy of + * everything. A check written as "any offered key is pinned" accepts this. + */ + @Test + fun `an extra unseen key alongside the real ones is a change`() { + val t = trust() + t.record(7, listOf(key(1), key(2))) + assertEquals(IdentityTrust.CHANGED, t.record(7, listOf(key(1), key(2), key(9)))) + } + + @Test + fun `a wholly different key is a change`() { + val t = trust() + t.record(7, listOf(key(1))) + assertEquals(IdentityTrust.CHANGED, t.record(7, listOf(key(9)))) + } + + @Test + fun `verifying survives a restart, because it is in the vault`() { + trust().also { it.record(7, listOf(key(1))); it.setVerified(7, true) } + assertEquals(IdentityTrust.VERIFIED, trust().state(7)) + } + + @Test + fun `accepting a change drops the verified tick`() { + val t = trust() + t.record(7, listOf(key(1))) + t.setVerified(7, true) + assertEquals(IdentityTrust.VERIFIED, t.state(7)) + + // The relay now serves a different device for the same person. + server.enqueue(json("""{"devices":[${deviceWire("dev-new", key(9))}]}""")) + t.acceptIdentityChange(7) + + // Re-pinned, but NOT still verified: the old comparison was about a key + // nobody uses any more and says nothing about this one. + assertEquals(IdentityTrust.PINNED, t.state(7)) + assertEquals(IdentityTrust.PINNED, t.record(7, listOf(key(9)))) + } + + @Test + fun `a safety number needs something pinned first`() { + val t = trust() + assertNull(t.safetyNumber(7)) + t.record(7, listOf(key(1))) + assertEquals(60, t.safetyNumber(7)?.length) + } + + // -- Key-log enforcement ------------------------------------------------- + + @Test + fun `a head that goes backwards is a fork`() { + vault.saveKeyLogPin(Vault.KeyLogPin(9, Wire.b64(key(3)))) + server.enqueue(json("""{"size":4,"root":"${Wire.b64(key(3))}","audience":"r"}""")) + assertFailsWith { + trust().enforceKeyLog(bundle("d1", key(1)), 7) + } + } + + @Test + fun `two different roots at one size is a fork`() { + vault.saveKeyLogPin(Vault.KeyLogPin(4, Wire.b64(key(3)))) + server.enqueue(json("""{"size":4,"root":"${Wire.b64(key(8))}","audience":"r"}""")) + assertFailsWith { + trust().enforceKeyLog(bundle("d1", key(1)), 7) + } + } + + /** + * 🔴 A key the relay serves but never logged must be refused. + * + * This is the whole point of the mechanism: the relay is free to hand out + * whatever key it likes, and the client's job is to notice that no such + * publication exists in the log the relay itself publishes. + */ + @Test + fun `a served key that is not in the log is refused`() { + server.enqueue(json("""{"size":1,"root":"${Wire.b64(key(3))}","audience":"r"}""")) + server.enqueue(json("""{"device_id":"d1","size":1,"entries":[]}""")) + val e = assertFailsWith { + trust().enforceKeyLog(bundle("d1", key(1)), 7) + } + assertEquals("d1", e.deviceId) + } + + @Test + fun `a logged key on a one-leaf tree is accepted and pins the head`() { + val b = bundle("d1", key(1)) + val leaf = expectedLeaf(b.deviceId, key(1), "r", 7) + // A single-leaf tree: the root IS the leaf, so no proof is needed and + // the arithmetic cannot hide a mistake. + server.enqueue(json("""{"size":1,"root":"$leaf","audience":"r"}""")) + server.enqueue(json("""{"device_id":"d1","size":1,"entries":[{"index":0,"kind":"publish","leaf":"$leaf"}]}""")) + server.enqueue(json("""{"index":0,"size":1,"leaf":"$leaf","root":"$leaf","proof":[]}""")) + + trust().enforceKeyLog(b, 7) + + val pin = vault.loadKeyLogPin() + assertEquals(1, pin?.size) + assertEquals(leaf, pin?.root) + } + + /** + * ⚠️ The relay must not be able to pass by echoing its own bytes back. + * + * Here the log lists an entry and the inclusion response is internally + * consistent — but for a leaf that is not the one this bundle implies. + */ + @Test + fun `an inclusion proof for someone else's leaf is refused`() { + val other = Wire.b64(KeyLog.leafHash("some other publication".toByteArray())) + server.enqueue(json("""{"size":1,"root":"$other","audience":"r"}""")) + server.enqueue(json("""{"device_id":"d1","size":1,"entries":[{"index":0,"kind":"publish","leaf":"$other"}]}""")) + assertFailsWith { + trust().enforceKeyLog(bundle("d1", key(1)), 7) + } + } + + // -- Gossip -------------------------------------------------------------- + + @Test + fun `no pin means nothing to gossip and nothing to conclude`() { + val t = trust() + assertNull(t.headPayload()) + t.gossip(4, Wire.b64(key(8))) + assertFalse(t.forkDetected) + } + + @Test + fun `a peer reporting a different root at our size is a fork`() { + vault.saveKeyLogPin(Vault.KeyLogPin(4, Wire.b64(key(3)))) + val t = trust() + t.gossip(4, Wire.b64(key(8))) + assertTrue(t.forkDetected) + } + + @Test + fun `a peer agreeing with us is not a fork`() { + vault.saveKeyLogPin(Vault.KeyLogPin(4, Wire.b64(key(3)))) + val t = trust() + t.gossip(4, Wire.b64(key(3))) + assertFalse(t.forkDetected) + } + + /** + * ⚠️ An unreachable relay is not evidence of anything. + * + * Crying fork whenever the network is down trains everyone to ignore the + * warning, which is worse than not having it. + */ + @Test + fun `a failed consistency fetch is not treated as a fork`() { + vault.saveKeyLogPin(Vault.KeyLogPin(2, Wire.b64(key(3)))) + server.enqueue(MockResponse().setResponseCode(503).setBody("down")) + val t = trust() + t.gossip(9, Wire.b64(key(8))) + assertFalse(t.forkDetected) + } + + @Test + fun `the gossip payload names our pinned head`() { + vault.saveKeyLogPin(Vault.KeyLogPin(4, "ROOTB64")) + val payload = JSONObject(trust().headPayload()!!) + assertEquals("kt_head", payload.getString("kind")) + assertEquals(4, payload.getInt("size")) + assertEquals("ROOTB64", payload.getString("root")) + } + + // -- Helpers ------------------------------------------------------------- + + private fun bundle(deviceId: String, ikSig: ByteArray) = + io.flamenet.e2e.PeerBundle.fromWire(JSONObject(deviceWire(deviceId, ikSig))) + + private fun deviceWire(deviceId: String, ikSig: ByteArray): String = JSONObject() + .put("device_id", deviceId) + .put("ik_sig", Wire.b64(ikSig)) + .put("ik_dh", Wire.b64(key(2))) + .put("spk_id", 1) + .put("spk", Wire.b64(key(4))) + .put("spk_sig", Wire.b64(ByteArray(64) { 5 })) + .put("opk", JSONObject().put("id", 1).put("pub", Wire.b64(key(6)))) + .put("proto", 1) + .toString() + + private fun expectedLeaf(deviceId: String, ikSig: ByteArray, audience: String, userId: Long) = + Wire.b64(KeyLog.leafHash(KeyLog.canonicalLeaf( + kind = "publish", + subject = KeyLog.logSubject(null, audience, userId), + deviceId = deviceId, + ikSigB64 = Wire.b64(ikSig), + ikDhB64 = Wire.b64(key(2)), + ))) + +} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/KeyLog.kt b/engine/src/main/kotlin/io/flamenet/e2e/KeyLog.kt index 08ba927..46bb8ee 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/KeyLog.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/KeyLog.kt @@ -105,4 +105,66 @@ object KeyLog { val got = rootFromInclusion(leaf, index, size, proof) ?: return false return Bytes.constantTimeEquals(got, root) } + + /** + * Prove the log of size `n` still contains everything the log of size `m` did. + * + * 🔴 **Inclusion alone is not enough, and this is the half that says so.** + * A relay that rewrites history can reissue perfectly valid inclusion + * proofs against its new tree — every one of them verifies, because they + * are honest proofs about a dishonest log. What it cannot do is produce a + * consistency proof between the tree a client already pinned and the tree + * it would rather serve, because no such proof exists. Checking only + * inclusion buys nothing against the attacker this is for. + * + * A faithful port of `engine/src/keylog.js`, which is normative, and + * checked against it by the interop harness. The traversal is subtle + * enough that a re-derivation is a liability: a version that accepts too + * much passes every honest test and fails only against an attacker. + * + * ⚠️ When `m` is a power of two the first node is the old root itself and + * is **not transmitted** — the proof begins one node later. Missing that + * yields a verifier that rejects honest proofs at exactly the sizes a log + * spends most of its life at. + */ + fun verifyConsistency( + from: Int, + to: Int, + proof: List, + oldRoot: ByteArray, + newRoot: ByteArray, + ): Boolean { + if (from <= 0 || from > to) return false + if (from == to) return proof.isEmpty() && Bytes.constantTimeEquals(oldRoot, newRoot) + + var fn = from - 1 + var sn = to - 1 + while (fn and 1 == 1) { fn = fn shr 1; sn = sn shr 1 } + if (proof.isEmpty()) return false + + var fr: ByteArray + var sr: ByteArray + val rest: List + if (fn == 0) { + fr = oldRoot; sr = oldRoot; rest = proof + } else { + fr = proof[0]; sr = proof[0]; rest = proof.drop(1) + } + + for (c in rest) { + if (sn == 0) return false + if (fn and 1 == 1 || fn == sn) { + fr = nodeHash(c, fr) + sr = nodeHash(c, sr) + while (fn != 0 && fn and 1 == 0) { fn = fn shr 1; sn = sn shr 1 } + } else { + sr = nodeHash(sr, c) + } + fn = fn shr 1 + sn = sn shr 1 + } + return sn == 0 && + Bytes.constantTimeEquals(fr, oldRoot) && + Bytes.constantTimeEquals(sr, newRoot) + } } diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index 13fb722..890ec47 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -47,6 +47,8 @@ class Vault(private val storage: Storage, private val key: SecretKey) { private const val CREDENTIAL = "credential" private const val SESSION_PREFIX = "session/" private const val HISTORY_PREFIX = "history/" + private const val TRUST_PREFIX = "trust/" + private const val KEYLOG_PIN = "keylog-pin" /** The two kinds of thread, named once so they cannot drift apart. */ fun threadForUser(userId: Long) = "user:$userId" @@ -154,6 +156,61 @@ class Vault(private val storage: Storage, private val key: SecretKey) { fun knownPeers(): List = storage.names().filter { it.startsWith(SESSION_PREFIX) }.map { it.removePrefix(SESSION_PREFIX) } + // -- Identity trust (§19) ------------------------------------------------ + // + // ⚠️ **Encrypted at rest like everything else here, and that is the point + // rather than a nicety.** These records are what the client compares a + // served key against. Somewhere an attacker can rewrite them is somewhere + // an attacker can pin their own key and have the comparison agree with + // them for ever afterwards — silently, because a match is what "fine" + // looks like. + + private fun trustName(userId: Long) = TRUST_PREFIX + userId + + /** + * What we have seen this person's devices use, and whether a human checked. + * + * `identities` is every identity key pinned for them, base64. Plural + * because a person is not a device: someone with a phone and a tablet has + * two, and both are legitimately theirs. + */ + data class Trust(val identities: List, val verified: Boolean) + + fun loadTrust(userId: Long): Trust? = readJson(trustName(userId))?.let { o -> + val a = o.optJSONArray("identities") ?: org.json.JSONArray() + Trust( + identities = (0 until a.length()).map { a.getString(it) }, + verified = o.optBoolean("verified", false), + ) + } + + fun saveTrust(userId: Long, trust: Trust) = writeJson(trustName(userId), JSONObject() + .put("identities", org.json.JSONArray(trust.identities)) + .put("verified", trust.verified)) + + fun forgetTrust(userId: Long) = storage.delete(trustName(userId)) + + // -- Key-log pin (§19.3) ------------------------------------------------- + + /** + * The head this device last accepted, and the reason a rewritten log is + * detectable at all. + * + * Trust on first use: absent, the first head seen is taken and pinned. + * Present, every later head must be provably an extension of it — so a + * relay that rewrites history has to produce a consistency proof between + * two trees where none exists, which is the one thing it cannot do. + */ + data class KeyLogPin(val size: Int, val root: String) + + fun loadKeyLogPin(): KeyLogPin? = readJson(KEYLOG_PIN)?.let { + KeyLogPin(it.getInt("size"), it.getString("root")) + } + + fun saveKeyLogPin(pin: KeyLogPin) = writeJson(KEYLOG_PIN, JSONObject() + .put("size", pin.size) + .put("root", pin.root)) + // -- Inbox watermark ----------------------------------------------------- /** diff --git a/engine/src/test/kotlin/io/flamenet/e2e/PublishedVectorsTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/PublishedVectorsTest.kt index c11bff2..f8bf816 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/PublishedVectorsTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/PublishedVectorsTest.kt @@ -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, m: Int, n: Int): List { + require(m in 1..n && n <= leaves.size) + return subProof(leaves.subList(0, n), m, true) + } + + private fun subProof(leaves: List, m: Int, isOldRoot: Boolean): List { + 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)) + } + } }