diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index e6a9b69..9e57172 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -3545,6 +3545,11 @@ fun RegisteredDevicesSection( ) { LaunchedEffect(Unit) { model.refreshDevices() } var confirming by remember { mutableStateOf(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(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", + ) + }, + ) + } } /** diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index 803c94b..cfb3bdd 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -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 diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index 886de8d..7c06ef6 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -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 { // 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)) } diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/ALostPhoneCanBeErasedTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/ALostPhoneCanBeErasedTest.kt new file mode 100644 index 0000000..fee532e --- /dev/null +++ b/app/src/test/kotlin/io/flamenet/messenger/app/ALostPhoneCanBeErasedTest.kt @@ -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 {") + .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 {") + .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", + ) + } +} diff --git a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt index db49d6d..4816d13 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt @@ -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. */ diff --git a/engine/src/main/kotlin/io/flamenet/e2e/DeviceWipe.kt b/engine/src/main/kotlin/io/flamenet/e2e/DeviceWipe.kt new file mode 100644 index 0000000..ec218aa --- /dev/null +++ b/engine/src/main/kotlin/io/flamenet/e2e/DeviceWipe.kt @@ -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::` 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." + } +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/DeviceWipeTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/DeviceWipeTest.kt new file mode 100644 index 0000000..867a0e6 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/DeviceWipeTest.kt @@ -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")) + } +}