Android: refuse a key we have reason to distrust

Android had the transparency log's verifier and nothing that called it. The
parity table listed trust state, verification, identity-change acceptance, and
key-log enforcement as absent, and they were — so the relay could hand out a
key it minted, the conversation would encrypt to it, and every byte on the wire
would be correctly encrypted to the wrong person. A log nothing checks records
the substitution faithfully and is never read.

Two independent checks, because neither is sufficient alone.

**Pinning, always on.** Every identity key a peer offers is compared against
what was pinned on first contact; a send to a changed identity is refused until
a person accepts it. The comparison is "every offered key is already pinned",
not "any" — a relay adding one key of its own ALONGSIDE the real ones is the
attack, and the fact that all the genuine devices still work is exactly what
makes it invisible. There is a test for that case specifically.

**The log, opt-in.** `require-key-log` on the Account screen makes a send
demand that the relay prove the recipient's key appears in the log it
publishes. Off by default for the same reason the web engine's is: a relay
predating the log has none, and refusing every peer on those deployments is a
worse failure than the one being prevented.

`KeyLog.verifyConsistency` did not exist in the Kotlin engine. Inclusion alone
is worth very little — a relay that rewrites history reissues perfectly valid
inclusion proofs against its new tree, honest proofs about a dishonest log.
Consistency against a head this device already pinned is the one thing it
cannot produce. Ported from `engine/src/keylog.js`, which is normative, rather
than re-derived: a re-derivation that accepts too much passes every honest test
and fails only against an attacker.

Head gossip rides along with messages and compares a peer's view against ours,
which is what turns a per-device check into a detectable fork — alone, each
client only knows the relay was consistent with itself.

Tested, 100 engine / 52 client, none failing. Consistency is checked over EVERY
pair of prefix sizes in the published vectors, because the traversal has a
special case when the old size is a power of two: an implementation that gets
that wrong still passes 3 -> 5 and fails 2 -> 5. The proof builder the test
uses is trustworthy because the tree it builds is the one whose roots already
matched the published vectors at every size.

The refusals are what carry the weight, so each has its own test: a head going
backwards, two roots at one size, a tampered proof node, a served key absent
from the log, an inclusion proof for somebody else's leaf, and a rewritten log
that cannot be reconciled with a pinned root. A trust check that accepts
everything passes every happy-path test ever written.

Reachable from the UI, per the standing rule. The thread shows identity state
and the safety number; the changed state offers accept and nothing else,
because an "ignore" affordance would be a way to silence the one signal in this
product that means interception. `CrudSurfaceTest` asserts the controls exist.

⚠️ Not yet exercised against a live relay — the tests drive a real HTTP server
with hand-built responses. Implemented and unit-tested, not proven in the
field. ⚠️ Sealed sender is still absent, so the relay still learns who talks to
whom; these protect different things.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-25 13:48:44 -04:00
co-authored by Claude Opus 5
parent 76d485fb55
commit 0330a7b84a
10 changed files with 1169 additions and 4 deletions
+39 -2
View File
@@ -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
@@ -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 -------------------------------------------------------------
@@ -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")
}
}
}
}
}
}
@@ -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<Session.Line>,
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,
)
}
}
}
/**
@@ -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<PeerBundle>) {
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<String>()
val failed = LinkedHashMap<String, String>()
val outgoing = mutableListOf<RelayClient.ToDevice>()
@@ -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<ByteArray>): 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<String> {
val a = optJSONArray(name) ?: return emptyList()
return (0 until a.length()).map { a.getString(it) }
}
}
@@ -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<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)
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<KeyLogForkException> {
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<KeyLogForkException> {
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<KeyNotLoggedException> {
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<KeyNotLoggedException> {
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)),
)))
}
@@ -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<ByteArray>,
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<ByteArray>
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)
}
}
@@ -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<String> =
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<String>, 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 -----------------------------------------------------
/**
@@ -203,6 +203,115 @@ class PublishedVectorsTest {
assertEquals(8, KeyLog.split(9))
}
// -- §19 consistency -----------------------------------------------------
@Test
fun `a consistency proof verifies between every pair of prefix sizes`() {
val keyLog = vectors.getJSONObject("keyLog")
val leaves = keyLog.getJSONArray("leafHashes")
val all = (0 until leaves.length()).map { leaves.bytesAt(it) }
// Every pair, not a sample. The traversal has a special case when the
// old size is a power of two — the old root leads the proof implicitly
// rather than being transmitted — so an implementation that gets that
// wrong still passes at 3 -> 5 and fails at 2 -> 5.
for (m in 1..all.size) {
for (n in m..all.size) {
val proof = consistencyProof(all, m, n)
assertTrue(
KeyLog.verifyConsistency(
from = m, to = n, proof = proof,
oldRoot = rootOf(all.subList(0, m)),
newRoot = rootOf(all.subList(0, n)),
),
"honest proof rejected for $m -> $n",
)
}
}
}
@Test
fun `a log that shrank is refused`() {
val leaves = vectors.getJSONObject("keyLog").getJSONArray("leafHashes")
val all = (0 until leaves.length()).map { leaves.bytesAt(it) }
assertFalse(
KeyLog.verifyConsistency(
from = 4, to = 2, proof = consistencyProof(all, 2, 4),
oldRoot = rootOf(all.subList(0, 4)),
newRoot = rootOf(all.subList(0, 2)),
),
"a head going backwards must never verify",
)
}
/**
* 🔴 The attack this whole mechanism exists to stop.
*
* A relay that rewrites history can still issue perfectly valid INCLUSION
* proofs against its new tree — they are honest proofs about a dishonest
* log. What it cannot do is reconcile the new tree with one a client
* already pinned. If this test ever passes, the log is decorative.
*/
@Test
fun `a rewritten log cannot be reconciled with a pinned root`() {
val leaves = vectors.getJSONObject("keyLog").getJSONArray("leafHashes")
val honest = (0 until leaves.length()).map { leaves.bytesAt(it) }
// Same size, one substituted leaf: the relay swapping a published key.
val rewritten = honest.toMutableList()
rewritten[1] = KeyLog.leafHash("a key the relay minted".toByteArray())
assertFalse(
KeyLog.verifyConsistency(
from = 2, to = honest.size,
proof = consistencyProof(rewritten, 2, rewritten.size),
oldRoot = rootOf(honest.subList(0, 2)), // what we pinned
newRoot = rootOf(rewritten), // what it serves now
),
"a substituted leaf must break consistency against the pinned root",
)
}
@Test
fun `a tampered proof node is refused`() {
val leaves = vectors.getJSONObject("keyLog").getJSONArray("leafHashes")
val all = (0 until leaves.length()).map { leaves.bytesAt(it) }
val proof = consistencyProof(all, 2, 5).toMutableList()
proof[0] = KeyLog.leafHash("not the sibling".toByteArray())
assertFalse(
KeyLog.verifyConsistency(
from = 2, to = 5, proof = proof,
oldRoot = rootOf(all.subList(0, 2)),
newRoot = rootOf(all),
),
)
}
@Test
fun `equal sizes verify only when the roots match and nothing is proved`() {
val leaves = vectors.getJSONObject("keyLog").getJSONArray("leafHashes")
val all = (0 until leaves.length()).map { leaves.bytesAt(it) }
val root = rootOf(all.subList(0, 3))
assertTrue(KeyLog.verifyConsistency(3, 3, emptyList(), root, root))
// Two different roots at one size is the definition of a fork.
assertFalse(KeyLog.verifyConsistency(3, 3, emptyList(), root, rootOf(all.subList(0, 2))))
// A proof where none is possible is not a proof.
assertFalse(KeyLog.verifyConsistency(3, 3, listOf(root), root, root))
}
@Test
fun `a zero or negative old size is refused`() {
val leaves = vectors.getJSONObject("keyLog").getJSONArray("leafHashes")
val all = (0 until leaves.length()).map { leaves.bytesAt(it) }
val root = rootOf(all)
// An empty tree has no root to be consistent with, and treating 0 as
// "nothing pinned yet" here would let a caller skip the check by
// passing a size it never actually saw.
assertFalse(KeyLog.verifyConsistency(0, 5, emptyList(), root, root))
assertFalse(KeyLog.verifyConsistency(-1, 5, emptyList(), root, root))
}
/**
* The reference tree, built here rather than in the engine.
*
@@ -217,4 +326,28 @@ class PublishedVectorsTest {
val k = KeyLog.split(leaves.size)
return KeyLog.nodeHash(rootOf(leaves.subList(0, k)), rootOf(leaves.subList(k, leaves.size)))
}
/**
* The RFC 6962 consistency proof between two prefix sizes.
*
* Built here for the same reason as [rootOf], and trustworthy for the same
* reason: it is made of [KeyLog.split] and [KeyLog.nodeHash], and the tree
* it describes is the one whose roots already matched the published
* vectors at every size. A builder that disagreed with the spec would have
* failed that test first.
*/
private fun consistencyProof(leaves: List<ByteArray>, m: Int, n: Int): List<ByteArray> {
require(m in 1..n && n <= leaves.size)
return subProof(leaves.subList(0, n), m, true)
}
private fun subProof(leaves: List<ByteArray>, m: Int, isOldRoot: Boolean): List<ByteArray> {
if (m == leaves.size) return if (isOldRoot) emptyList() else listOf(rootOf(leaves))
val k = KeyLog.split(leaves.size)
return if (m <= k) {
subProof(leaves.subList(0, k), m, isOldRoot) + rootOf(leaves.subList(k, leaves.size))
} else {
subProof(leaves.subList(k, leaves.size), m - k, false) + rootOf(leaves.subList(0, k))
}
}
}