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:
co-authored by
Claude Opus 5
parent
76d485fb55
commit
0330a7b84a
@@ -100,8 +100,8 @@ From `E2E/E2EInbox.swift`, which is the real feature list.
|
|||||||
| Groups: create, list, members, leave | ⚪ |
|
| Groups: create, list, members, leave | ⚪ |
|
||||||
| Group text and images | ⚪ |
|
| Group text and images | ⚪ |
|
||||||
| Delivery keys + rotation | ⚪ |
|
| Delivery keys + rotation | ⚪ |
|
||||||
| Trust state, verification, identity-change acceptance | ⚪ |
|
| Trust state, verification, identity-change acceptance | ✅ pinned in the vault, surfaced on the thread |
|
||||||
| Key-log enforcement and head gossip | ⚪ |
|
| Key-log enforcement and head gossip | ✅ opt-in enforcement, gossip detects a fork |
|
||||||
| Clear thread | ✅ keeps the session, so the chat still works |
|
| Clear thread | ✅ keeps the session, so the chat still works |
|
||||||
| History at rest (encrypted, device-only key) | ⚪ |
|
| History at rest (encrypted, device-only key) | ⚪ |
|
||||||
| Presence (read and set) | ✅ shown from the relay, never inferred |
|
| 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
|
⚠️ Point `-derivedDataPath` at a directory ending `.noindex`. Spotlight will
|
||||||
otherwise index every build product, which is a large share of the above.
|
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
|
## Known flakiness
|
||||||
|
|
||||||
⚠️ **The emulator's network drops occasionally** — seen twice as "Failed to
|
⚠️ **The emulator's network drops occasionally** — seen twice as "Failed to
|
||||||
|
|||||||
@@ -112,6 +112,9 @@ class CrudSurfaceTest {
|
|||||||
compose.onNodeWithTag("save-password").assertExists()
|
compose.onNodeWithTag("save-password").assertExists()
|
||||||
compose.onNodeWithTag("sign-out").assertExists()
|
compose.onNodeWithTag("sign-out").assertExists()
|
||||||
compose.onNodeWithTag("delete-account").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()
|
compose.onNodeWithTag("nav-contacts").performClick()
|
||||||
awaitTag("add-contact", 20_000)
|
awaitTag("add-contact", 20_000)
|
||||||
@@ -132,6 +135,14 @@ class CrudSurfaceTest {
|
|||||||
compose.onNodeWithTag("send").assertExists()
|
compose.onNodeWithTag("send").assertExists()
|
||||||
compose.onNodeWithTag("attach").assertExists()
|
compose.onNodeWithTag("attach").assertExists()
|
||||||
compose.onNodeWithTag("back").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 -------------------------------------------------------------
|
// -- Helpers -------------------------------------------------------------
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import androidx.compose.material3.Divider
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
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.Color
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.platform.testTag
|
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.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
@@ -450,6 +453,50 @@ fun ColumnScope.AccountScreen(
|
|||||||
|
|
||||||
Divider()
|
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
|
// 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
|
// 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.
|
// 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(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier.fillMaxWidth().weight(1f).testTag("messages"),
|
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.DeviceIdentity
|
||||||
import io.flamenet.e2e.Vault
|
import io.flamenet.e2e.Vault
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import io.flamenet.client.IdentityTrust
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -50,6 +51,17 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
val openChat: Chat? = null,
|
val openChat: Chat? = null,
|
||||||
val busy: Boolean = false,
|
val busy: Boolean = false,
|
||||||
val error: String? = null,
|
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
|
) : Phase
|
||||||
|
|
||||||
enum class Screen { CONTACTS, NOTIFICATIONS, ACCOUNT }
|
enum class Screen { CONTACTS, NOTIFICATIONS, ACCOUNT }
|
||||||
@@ -67,6 +79,17 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
val lines: List<Session.Line>,
|
val lines: List<Session.Line>,
|
||||||
val peerId: Long? = null,
|
val peerId: Long? = null,
|
||||||
val group: io.flamenet.client.RelayClient.Group? = 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)
|
val thread = Vault.threadForUser(peerId)
|
||||||
update {
|
update {
|
||||||
it.copy(
|
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
|
// Opening the thread is what marks it read. Nothing is sent to
|
||||||
// the relay for this — read state is local, because a read
|
// the relay for this — read state is local, because a read
|
||||||
// receipt is a disclosure and this product does not make it by
|
// 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 --------------------------------------------------------------
|
// -- Groups --------------------------------------------------------------
|
||||||
|
|
||||||
fun refreshGroups() = viewModelScope.launch {
|
fun refreshGroups() = viewModelScope.launch {
|
||||||
@@ -595,7 +678,21 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
"${sent.delivered.size + sent.failed.size} devices.",
|
"${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 identity: DeviceIdentity,
|
||||||
private val vault: Vault,
|
private val vault: Vault,
|
||||||
private val relay: RelayClient,
|
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. */
|
/** One decrypted message, interpreted, and where it came from. */
|
||||||
@@ -132,8 +140,47 @@ class Conversations(
|
|||||||
fun fetchAttachment(ref: Attachment.Ref): ByteArray =
|
fun fetchAttachment(ref: Attachment.Ref): ByteArray =
|
||||||
Attachment.open(relay.downloadAttachment(ref.id), ref)
|
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 {
|
private fun sendPlaintext(toUserId: Long, text: String): Sent {
|
||||||
val bundles = relay.fetchKeys(toUserId)
|
val bundles = relay.fetchKeys(toUserId)
|
||||||
|
guardIdentity(toUserId, bundles)
|
||||||
val delivered = mutableListOf<String>()
|
val delivered = mutableListOf<String>()
|
||||||
val failed = LinkedHashMap<String, String>()
|
val failed = LinkedHashMap<String, String>()
|
||||||
val outgoing = mutableListOf<RelayClient.ToDevice>()
|
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
|
val got = rootFromInclusion(leaf, index, size, proof) ?: return false
|
||||||
return Bytes.constantTimeEquals(got, root)
|
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 CREDENTIAL = "credential"
|
||||||
private const val SESSION_PREFIX = "session/"
|
private const val SESSION_PREFIX = "session/"
|
||||||
private const val HISTORY_PREFIX = "history/"
|
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. */
|
/** The two kinds of thread, named once so they cannot drift apart. */
|
||||||
fun threadForUser(userId: Long) = "user:$userId"
|
fun threadForUser(userId: Long) = "user:$userId"
|
||||||
@@ -154,6 +156,61 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
|
|||||||
fun knownPeers(): List<String> =
|
fun knownPeers(): List<String> =
|
||||||
storage.names().filter { it.startsWith(SESSION_PREFIX) }.map { it.removePrefix(SESSION_PREFIX) }
|
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 -----------------------------------------------------
|
// -- Inbox watermark -----------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -203,6 +203,115 @@ class PublishedVectorsTest {
|
|||||||
assertEquals(8, KeyLog.split(9))
|
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.
|
* The reference tree, built here rather than in the engine.
|
||||||
*
|
*
|
||||||
@@ -217,4 +326,28 @@ class PublishedVectorsTest {
|
|||||||
val k = KeyLog.split(leaves.size)
|
val k = KeyLog.split(leaves.size)
|
||||||
return KeyLog.nodeHash(rootOf(leaves.subList(0, k)), rootOf(leaves.subList(k, 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user