Remote erase on Android (§6.26)

Android had no part of this: no type, no route, no fetch. The relay's
half was complete and iOS had the crypto, so a lost Android phone was
the one that could not be erased at all.

DeviceWipe in the engine, signing and verifying the same canonical
string as the iOS half, `fnwipe:v1:<device>:<seconds>`. A vector in
the tests takes a signature the SWIFT implementation produced and
verifies it here, and the iOS suite takes one produced here. Not a
comparison of the two signatures: CryptoKit randomises Ed25519 signing
and this does not, so they never match and never need to. What interop
needs is that each side verifies what the other signed.

The poll asks once a session and stops there if it erased, rather than
carrying on into a vault it has just emptied. The account public key it
checks against comes out of the address this device already stored, not
out of anything the relay just said.

Issuing is on the Devices card and asks for the passphrase, which is
what somebody holding the phone does not have. A passphrase that does
not derive this account's published address is refused before anything
reaches the relay. The dialog shows DeviceWipe.HONEST_DESCRIPTION,
kept word for word with the iOS copy: two apps describing one guarantee
differently is how somebody ends up believing the stronger sentence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-20 16:56:01 -04:00
co-authored by Claude Opus 5
parent 9bc8cdcb23
commit ef1a8138aa
7 changed files with 544 additions and 0 deletions
@@ -3545,6 +3545,11 @@ fun RegisteredDevicesSection(
) {
LaunchedEffect(Unit) { model.refreshDevices() }
var confirming by remember { mutableStateOf<String?>(null) }
// §6.26, the device an erase is being issued for, and the passphrase that
// signs it. Held here and nowhere else: it is not stored, which is what
// makes this the one thing somebody holding the phone cannot do with it.
var erasing by remember { mutableStateOf<String?>(null) }
var passphrase by remember { mutableStateOf("") }
Text("Registered devices", style = MaterialTheme.typography.titleMedium)
Text(
@@ -3614,6 +3619,14 @@ fun RegisteredDevicesSection(
Text("Really remove", color = MaterialTheme.colorScheme.error)
}
} else {
// Erasing tells the device to destroy what it holds.
// Removing takes it off the account. For a phone
// somebody else is carrying, the first is the one that
// matters.
androidx.compose.material3.TextButton(
onClick = { erasing = device.deviceId; passphrase = "" },
modifier = Modifier.testTag("erase-${device.deviceId}"),
) { Text("Erase", color = MaterialTheme.colorScheme.error) }
androidx.compose.material3.TextButton(
onClick = { confirming = device.deviceId },
modifier = Modifier.testTag("remove-${device.deviceId}"),
@@ -3622,6 +3635,57 @@ fun RegisteredDevicesSection(
}
}
}
erasing?.let { target ->
androidx.compose.material3.AlertDialog(
onDismissRequest = { erasing = null },
title = { Text("Erase ${target.take(8)}?") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(Space.sm)) {
Text(
io.flamenet.e2e.DeviceWipe.HONEST_DESCRIPTION,
style = MaterialTheme.typography.bodySmall,
)
Text(
"The instruction is signed with a key derived from your " +
"passphrase. This server cannot make one, and neither can " +
"anybody holding the device.",
style = MaterialTheme.typography.bodySmall,
color = Palette.SecondaryText,
)
androidx.compose.material3.OutlinedTextField(
value = passphrase,
onValueChange = { passphrase = it },
label = { Text("Your passphrase") },
singleLine = true,
visualTransformation =
androidx.compose.ui.text.input.PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth().testTag("erase-passphrase"),
)
}
},
confirmButton = {
KitSecondaryButton(
text = "Erase that device",
onClick = {
model.issueDeviceWipe(target, passphrase)
passphrase = ""
erasing = null
},
tag = "erase-confirm",
danger = true,
)
},
dismissButton = {
KitChip(
text = "Cancel",
selected = false,
onClick = { erasing = null; passphrase = "" },
tag = "erase-cancel",
)
},
)
}
}
/**
@@ -2908,6 +2908,61 @@ class MessengerViewModel(
fetched.onSuccess { list -> update { it.copy(registeredDevices = list) } }
}
/**
* §6.26, sign an erase for one of this account's own devices and hand it
* to the relay, which holds it until that device next connects.
*
* The account key is derived here from the passphrase and kept nowhere:
* a signed-in session cannot issue one of these on its own, and neither
* can anybody who picked the phone up. The derived key is checked against
* the address this account already published before anything is sent, so a
* mistyped passphrase says so here rather than leaving an order on the
* relay that every device will ignore.
*/
fun issueDeviceWipe(
deviceId: String,
passphrase: String,
) = viewModelScope.launch {
val current = session ?: return@launch
val handle = signedIn()?.handle
if (handle.isNullOrEmpty()) {
update { it.copy(error = "This device does not know the account's handle.") }
return@launch
}
update { it.copy(busy = true, error = null) }
val outcome =
withContext(Dispatchers.IO) {
runCatching {
val seed =
AccountKdf.accountKeySeed(
passphrase,
AccountKdf.localAccountSalt(handle),
)
val address = current.conversations.myAddress
val published = address?.substringBefore('@')?.let { FNID.decode(it) }
require(
published != null &&
published.contentEquals(Ed25519.fromSeed(seed).publicKey),
) { "That passphrase is not this account's." }
val order =
io.flamenet.e2e.DeviceWipe.issue(
device = deviceId,
at = System.currentTimeMillis() / 1000,
accountSeed = seed,
)
current.relay.publishWipeOrder(deviceId, order.toJson())
}
}
outcome
.onSuccess {
update {
it.copy(busy = false, error = io.flamenet.e2e.DeviceWipe.HONEST_DESCRIPTION)
}
}.onFailure { failure ->
update { it.copy(busy = false, error = describe(failure)) }
}
}
fun removeDevice(deviceId: String) =
viewModelScope.launch {
val current = session ?: return@launch
@@ -8,6 +8,8 @@ import io.flamenet.e2e.Attachment
import io.flamenet.e2e.ContactCard
import io.flamenet.e2e.Content
import io.flamenet.e2e.DeviceIdentity
import io.flamenet.e2e.DeviceWipe
import io.flamenet.e2e.FNID
import io.flamenet.e2e.SealedRoster
import io.flamenet.e2e.Vault
import org.json.JSONObject
@@ -1271,6 +1273,40 @@ class Session(
*/
var onCallSignal: ((Long, io.flamenet.e2e.Content.CallSignal, Long) -> Unit)? = null
/**
* §6.26, checked once a session: the order is a round trip and the poll
* runs every few seconds.
*/
private var checkedWipeOrder = false
/**
* Ask the relay whether this device has been told to erase itself, and do
* it if the instruction is genuinely this account's.
*
* The relay holds the instruction and cannot read it: it is signed by the
* account key, which is derived from the passphrase and which the relay
* never sees. So the decision is entirely this device's, and the account
* public key it checks against comes out of the address this device
* already stored rather than out of anything the relay just said.
*
* @return true when this device erased itself.
*/
fun honourPendingWipe(): Boolean {
val instruction = relay.pendingWipeOrder() ?: return false
val wipe = DeviceWipe.fromJson(instruction) ?: return false
val address = conversations.myAddress ?: return false
val accountKey = FNID.decode(address.substringBefore('@')) ?: return false
if (!DeviceWipe.isValid(wipe, identity.deviceId, accountKey)) {
System.err.println(
"fnmsg-wipe: refused an order that is not signed for this device by this account",
)
return false
}
System.err.println("fnmsg-wipe: honouring an erase order, forgetting everything held here")
vault.forgetEverything()
return true
}
fun receive(waitSeconds: Int = 0): Set<String> {
// Keep this phone reachable by strangers. Each new sender's first
// message takes a one-time prekey, and nothing topped the pool back
@@ -1281,6 +1317,13 @@ class Session(
// to fail a poll either: a rotation that cannot reach the relay is
// retried on the next one.
runCatching { conversations.rotateSignedPreKeyIfDue() }
// §6.26. Once a session, like the key-log checks: a phone that is
// never opened is never wiped, which is the honest limit of this and
// what the copy says.
if (!checkedWipeOrder) {
checkedWipeOrder = true
if (runCatching { honourPendingWipe() }.getOrDefault(false)) return emptySet()
}
return absorb(conversations.poll(waitSeconds = waitSeconds))
}
@@ -0,0 +1,122 @@
package io.flamenet.messenger.app
import java.io.File
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* §6.26. The relay has served a wipe order since it was written and Android had
* none of it: no type, no route, no fetch. A lost or stolen phone could not be
* erased, though the relay's half was complete and iOS had the crypto.
*
* The wiring, read off the sources. The cryptography is proven properly in
* `engine`'s DeviceWipeTest, including a vector signed by the iOS half.
*/
class ALostPhoneCanBeErasedTest {
private fun app(name: String): String {
val f = File("src/main/kotlin/io/flamenet/messenger/app/$name")
assertTrue(f.isFile, "cannot find $name, this test reads the sources")
return f.readText()
}
private fun client(name: String): String {
val f = File("../client/src/main/kotlin/io/flamenet/client/$name")
assertTrue(f.isFile, "cannot find $name")
return f.readText()
}
/** The routes that did not exist. */
@Test
fun `the client can ask for an order and publish one`() {
val relay = client("RelayClient.kt")
assertTrue(relay.contains("fun pendingWipeOrder(): String?"), "nothing fetches an order")
assertTrue(relay.contains("fun publishWipeOrder("), "nothing can issue one")
}
/** Something asks, on the poll, so a found phone hears about it. */
@Test
fun `the poll asks for an order`() {
val session = app("Session.kt")
assertTrue(
session.contains("fun honourPendingWipe(): Boolean"),
"nothing fetches the wipe order",
)
val receive =
session
.substringAfter("fun receive(waitSeconds: Int = 0): Set<String> {")
.substringBefore("\n }")
assertTrue(
receive.contains("honourPendingWipe()"),
"the poll does not ask, so a lost phone is never told to erase itself",
)
}
/** And it checks the signature rather than trusting the relay. */
@Test
fun `the order is checked before it is obeyed`() {
val fn =
app("Session.kt")
.substringAfter("fun honourPendingWipe(): Boolean {")
.substringBefore("\n }")
assertTrue(
fn.contains("DeviceWipe.isValid("),
"the order is obeyed without checking who signed it, which would let the " +
"relay erase any device it liked",
)
assertTrue(
fn.contains("FNID.decode("),
"the account key comes from somewhere other than this account's own " +
"published address",
)
assertTrue(fn.contains("vault.forgetEverything()"), "nothing is actually erased")
}
/** Issuing needs the passphrase, because the key is not stored. */
@Test
fun `issuing asks for the passphrase and checks it is this account's`() {
val fn =
app("MessengerViewModel.kt")
.substringAfter("fun issueDeviceWipe(")
.substringBefore("\n fun ")
assertTrue(
fn.contains("AccountKdf.accountKeySeed("),
"the erase is issued without deriving the account key from a passphrase",
)
assertTrue(
fn.contains("Ed25519.fromSeed(seed).publicKey"),
"the derived key is never compared with this account's published address, " +
"so a typo leaves an order on the relay that every device ignores",
)
}
/** The screen says what an erase cannot do, not only what it can. */
@Test
fun `the screen shows the honest description`() {
assertTrue(
app("ChatScreens.kt").contains("DeviceWipe.HONEST_DESCRIPTION"),
"the screen promises an erase without saying what it cannot do",
)
assertTrue(
app("ChatScreens.kt").contains("erase-passphrase"),
"nothing asks for the passphrase, so a found phone could erase the others",
)
}
/** A wipe stops the poll rather than carrying on into a vault it erased. */
@Test
fun `a honoured wipe ends the receive rather than polling on`() {
val receive =
app("Session.kt")
.substringAfter("fun receive(waitSeconds: Int = 0): Set<String> {")
.substringBefore("\n }")
assertTrue(
receive.contains("return emptySet()"),
"the poll continues after erasing the vault it was about to write to",
)
assertFalse(
receive.indexOf("honourPendingWipe()") > receive.indexOf("conversations.poll("),
"the wipe is checked after the poll has already written to the vault",
)
}
}
@@ -133,6 +133,37 @@ class RelayClient(
)
}
// -- Remote erase (§6.26) ------------------------------------------------.
/**
* `GET /e2e/wipe`, the erase instruction waiting for THIS device.
*
* The relay filters on the device id in the credential, so this asks no
* question about any other device. Null when there is nothing waiting, and
* also when the relay does not carry orders at all: that answers 501, and
* one too old to know the route answers 404. Both mean the same thing to a
* phone and neither is worth reporting.
*/
fun pendingWipeOrder(): String? =
runCatching { getJson("/e2e/wipe").optString("instruction").ifEmpty { null } }
.getOrNull()
/**
* `POST /e2e/wipe`, queue a signed erase for one of this account's devices.
*
* The relay checks the device belongs to the caller. The signature is for
* the device to check and is the half the relay cannot forge.
*/
fun publishWipeOrder(
device: String,
instruction: String,
) {
postJson(
"/e2e/wipe",
JSONObject().put("device", device).put("instruction", instruction),
)
}
// -- Devices and keys ----------------------------------------------------.
/** `POST /e2e/devices`, publish this device's identity and prekeys. */
@@ -0,0 +1,95 @@
package io.flamenet.e2e
import org.json.JSONObject
/**
* An instruction to erase what a lost device holds (§6.26).
*
* The relay stores it and cannot read it: it is signed by the account key,
* which is derived from the passphrase and which the relay never sees. So the
* relay's part is a pigeonhole, and the decision to act belongs entirely to the
* device that finds an order waiting for it.
*
* The canonical bytes are `fnwipe:v1:<device>:<seconds>` and must stay byte
* identical to the iOS half in `Sources/E2E/DeviceWipe.swift`, or an order
* issued on one platform is refused on the other with nothing to say why.
*/
data class DeviceWipe(
val device: String,
/** Unix SECONDS the instruction was issued. */
val at: Long,
/** Ed25519 over [canonicalBytes], base64. */
val sig: String,
) {
fun toJson(): String =
JSONObject()
.put("device", device)
.put("at", at)
.put("sig", sig)
.toString()
companion object {
/**
* Exactly what is signed: no whitespace, no JSON, no field ordering for
* three implementations to disagree about.
*/
fun canonicalBytes(
device: String,
at: Long,
): ByteArray = "fnwipe:v1:$device:$at".toByteArray(Charsets.UTF_8)
fun fromJson(text: String): DeviceWipe? =
runCatching {
val o = JSONObject(text)
DeviceWipe(
device = o.getString("device"),
// iOS encodes `at` as a Double; JSONObject reads either.
at = o.getDouble("at").toLong(),
sig = o.getString("sig"),
)
}.getOrNull()
/**
* Whether this instruction was issued by the holder of [accountPublicKey],
* for [thisDevice].
*/
fun isValid(
wipe: DeviceWipe,
thisDevice: String,
accountPublicKey: ByteArray,
): Boolean {
if (wipe.device != thisDevice) return false
val signature = runCatching { Bytes.unb64(wipe.sig) }.getOrNull() ?: return false
return Ed25519.verify(
accountPublicKey,
signature,
canonicalBytes(wipe.device, wipe.at),
)
}
/** Sign one, given the account key derived from the passphrase. */
fun issue(
device: String,
at: Long,
accountSeed: ByteArray,
): DeviceWipe =
DeviceWipe(
device = device,
at = at,
sig = Bytes.b64(Ed25519.sign(accountSeed, canonicalBytes(device, at))),
)
/**
* What a client may honestly say about it.
*
* Kept in step with the iOS copy on purpose: two apps describing the
* same guarantee differently is how a person ends up believing the
* stronger sentence.
*/
const val HONEST_DESCRIPTION =
"The instruction is stored for that device and it erases what it holds the " +
"next time it connects. If it never connects, switched off, or offline, " +
"nothing happens to it. This stops things getting worse; it is not proof " +
"that the messages are gone."
}
}
@@ -0,0 +1,134 @@
package io.flamenet.e2e
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* §6.26. A signed instruction telling one device to erase what it holds.
*
* The relay stores it and cannot read it, so every check that matters happens
* here. The canonical bytes must stay identical to the iOS half in
* `Sources/E2E/DeviceWipe.swift`; an order issued on one platform and refused
* on the other would look exactly like a phone that never connected.
*/
class DeviceWipeTest {
private val account = Ed25519.fromSeed(ByteArray(32) { 7 })
/**
* The one string both platforms have to agree on, pinned as a literal
* rather than as a round trip through this file's own builder.
*/
@Test
fun `the canonical bytes are the documented string`() {
assertEquals(
"fnwipe:v1:abcdef0123456789:1757000000",
String(DeviceWipe.canonicalBytes("abcdef0123456789", 1_757_000_000L), Charsets.UTF_8),
)
}
@Test
fun `an order this account signed for this device is honoured`() {
val order = DeviceWipe.issue("dev-a", 1_757_000_000L, account.seed)
assertTrue(DeviceWipe.isValid(order, "dev-a", account.publicKey))
}
/** The whole point: an order for somebody else's device is not mine. */
@Test
fun `an order naming another device is refused`() {
val order = DeviceWipe.issue("dev-a", 1_757_000_000L, account.seed)
assertFalse(DeviceWipe.isValid(order, "dev-b", account.publicKey))
}
/** And the relay cannot mint one, which is what makes it safe to store. */
@Test
fun `an order signed by another key is refused`() {
val impostor = Ed25519.fromSeed(ByteArray(32) { 9 })
val order = DeviceWipe.issue("dev-a", 1_757_000_000L, impostor.seed)
assertFalse(DeviceWipe.isValid(order, "dev-a", account.publicKey))
}
/** Changing the timestamp changes what was signed. */
@Test
fun `an order with its timestamp altered is refused`() {
val order = DeviceWipe.issue("dev-a", 1_757_000_000L, account.seed)
assertFalse(
DeviceWipe.isValid(order.copy(at = 1_757_000_001L), "dev-a", account.publicKey),
)
}
/** It travels as JSON through a relay that never validates it. */
@Test
fun `it survives the round trip the relay stores it as`() {
val order = DeviceWipe.issue("dev-a", 1_757_000_000L, account.seed)
val back = DeviceWipe.fromJson(order.toJson())
assertEquals(order, back)
assertTrue(DeviceWipe.isValid(back!!, "dev-a", account.publicKey))
}
/**
* iOS encodes `at` as a Double, because its own type declares it that way.
* Reading that back as a Long is the whole compatibility question.
*/
@Test
fun `an order written by the iOS encoder parses`() {
val order = DeviceWipe.issue("dev-a", 1_757_000_000L, account.seed)
val asIosWritesIt =
"""{"device":"dev-a","at":1757000000,"sig":"${order.sig}"}"""
val back = DeviceWipe.fromJson(asIosWritesIt)
assertEquals(order, back)
assertTrue(DeviceWipe.isValid(back!!, "dev-a", account.publicKey))
}
/**
* A cross-platform vector: a signature the SWIFT implementation produced
* for the same seed, device and second, verified by this one.
*
* A captured literal, not a re-derivation, and it has to be. CryptoKit's
* Ed25519 signing is randomised, so the two platforms sign the same bytes
* differently every time. Interop does not need matching signatures; it
* needs each side to verify what the other signed, and verification is
* deterministic. A test that compared the two signatures would pin an
* implementation detail that is not true, and it was written that way
* first.
*/
@Test
fun `a signature Swift produced verifies here`() {
val fromSwift =
DeviceWipe(
device = "dev-a",
at = 1_757_000_000L,
sig =
"a9xJbPIkytRXIJTkWhvu6PbmAKR6R9ggKZJxv/Be0jjQHn9y2yFw+O+Cmxl+sczB" +
"g+8GXwwUe2ZWAUVkRlhvDQ==",
)
// The same seed on both sides gives the same public half, which is the
// other thing this vector pins.
assertEquals("6kpsY+KcUgq+9VB7Ey7F+ZVHdq6+vnuSQh7qaRRG0iw=", Bytes.b64(account.publicKey))
assertTrue(
DeviceWipe.isValid(fromSwift, "dev-a", account.publicKey),
"an erase issued from an iPhone is refused here, which looks exactly " +
"like a phone that never connected",
)
assertFalse(DeviceWipe.isValid(fromSwift, "dev-b", account.publicKey))
}
/** Rubbish is broken, not hostile: null rather than a throw. */
@Test
fun `nonsense parses to nothing rather than crashing the poll`() {
assertNull(DeviceWipe.fromJson("not json"))
assertNull(DeviceWipe.fromJson("{}"))
assertNull(DeviceWipe.fromJson("""{"device":"a"}"""))
}
/** What a client may never let the absence of an erase mean. */
@Test
fun `the honest description refuses to promise the messages are gone`() {
val text = DeviceWipe.HONEST_DESCRIPTION
assertTrue(text.contains("next time it connects"))
assertTrue(text.contains("nothing happens to it"))
assertTrue(text.contains("not proof"))
}
}