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
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user