From a213ad8d5d9eec76dbd42770381535a23a920d25 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Tue, 25 Aug 2026 14:31:07 -0400 Subject: [PATCH] Android: sealed sender, so the relay stops learning who talks to whom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Key transparency stopped the relay substituting keys. It did nothing about the relay watching who writes to whom, which is the metadata most people actually leak. ยง14 moves the sender's identity inside the ciphertext: the relay learns who a message is for and nothing about who it is from. Verified against relay1 rather than asserted. A sealed submission is accepted unauthenticated, the stored row comes back with `from_user = 0` โ€” the relay saying it does not know โ€” and the recipient still reads the message with the sender recovered from inside the seal. Cross-engine verified in BOTH directions against the normative web engine, and that mattered: the seal is asymmetric in practice, since one side derives from an ephemeral it generated and the other from one it was handed, so a byte-order or AAD mistake can easily break only one direction and a single-direction test picks the half that works. The interop harness gained `seal` and `open_sealed` ops for it; 43 assertions, none failing. ๐Ÿ”ด **The seal is confidentiality, never authentication.** The recipient's `IK_dh` is published to the world, so anyone can produce a well-formed seal claiming to be from anybody. The claimed sender picks a session and nothing else; the ratchet opening it is what makes it a fact, and the identity pin is what refuses an impostor. There is a test that forges a sender deliberately, so that if somebody later renames these fields into something confident the reason they are claims is still written down and still executing. โš ๏ธ **The first message to a new contact cannot be sealed.** Sealing needs the recipient's delivery key and that key travels inside an E2E message, so a prior exchange is required. `Sent.sealed` reports which happened rather than leaving the UI free to imply anonymity that was not achieved โ€” a silent fall back to an unsealed send is exactly what ยง14.5 warns about. โš ๏ธ **Rotation is blunt, and the copy says so.** There is no sender to block, so the only lever is replacing the delivery key, which cuts off every contact until the next time they are written to. Reachable from Account, asserted by `CrudSurfaceTest`, and live-tested: a submission presenting a retired key is refused by the relay. The relay stores only `SHA-256(key)`. The key itself never passes through the relay from its owner, which is the only reason an unauthenticated sealed submission can be authorised without naming who is submitting. Tested: 108 engine, 67 client, 43 interop, none failing, live suites enabled. Co-Authored-By: Claude Opus 5 --- PARITY.md | 40 +++- .../flamenet/messenger/app/CrudSurfaceTest.kt | 3 + .../io/flamenet/messenger/app/ChatScreens.kt | 18 ++ .../messenger/app/MessengerViewModel.kt | 27 +++ .../io/flamenet/client/Conversations.kt | 158 +++++++++++++++- .../kotlin/io/flamenet/client/RelayClient.kt | 23 ++- .../flamenet/client/LiveSealedSenderTest.kt | 146 +++++++++++++++ .../kotlin/io/flamenet/e2e/SealedSender.kt | 171 ++++++++++++++++++ .../src/main/kotlin/io/flamenet/e2e/Vault.kt | 56 ++++++ .../io/flamenet/e2e/SealedSenderTest.kt | 171 ++++++++++++++++++ .../main/kotlin/io/flamenet/interop/Main.kt | 36 ++++ 11 files changed, 832 insertions(+), 17 deletions(-) create mode 100644 client/src/test/kotlin/io/flamenet/client/LiveSealedSenderTest.kt create mode 100644 engine/src/main/kotlin/io/flamenet/e2e/SealedSender.kt create mode 100644 engine/src/test/kotlin/io/flamenet/e2e/SealedSenderTest.kt diff --git a/PARITY.md b/PARITY.md index 7212b96..6c49ca8 100644 --- a/PARITY.md +++ b/PARITY.md @@ -37,7 +37,7 @@ everything above the protocol. | Safety numbers | โœ… | โœ… | cross-engine verified | | Transparency log (`KeyLog`) | โœ… | โœ… | canonical leaf + inclusion proofs, cross-engine verified | | Account KDF / fnid | โœ… | โœ… | both pinned to the published vector file | -| Sealed sender | โœ… | โšช | `E2E/SealedSender.swift` | +| Sealed sender | โœ… | โœ… | cross-engine verified, and the relay records no sender | | Content envelopes (ยง6.6โ€“ยง6.9) | โœ… | โœ… | attachments, replies, reactions, groups, calls โ€” one codec | | Attachment encryption | โœ… | โœ… | verified end to end; relay holds only ciphertext | | Envelope + wire codec | โœ… | โœ… | cross-engine verified at the envelope level | @@ -99,7 +99,7 @@ From `E2E/E2EInbox.swift`, which is the real feature list. | Stories, and seen-state | โšช | | Groups: create, list, members, leave | โšช | | Group text and images | โšช | -| Delivery keys + rotation | โšช | +| Delivery keys + rotation | โœ… published as a digest, rotatable from Account | | Trust state, verification, identity-change acceptance | โœ… pinned in the vault, surfaced on the thread | | Key-log enforcement and head gossip | โœ… opt-in enforcement, gossip detects a fork | | Clear thread | โœ… keeps the session, so the chat still works | @@ -282,9 +282,39 @@ asserted to come back CHANGED against the real relay too. address". Baking that in would be permanent, and the next successful lookup would then read as the address changing โ€” the alarm, for a network blip. -โš ๏ธ **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. +## Sealed sender, 2026-08-25 + +โœ… **The relay no longer learns who sent a message.** Verified against relay1: +a sealed submission is accepted unauthenticated, the stored row carries +`from_user = 0`, and the recipient still reads it with the sender recovered +from inside the seal. + +Cross-engine verified in **both directions** against the normative web engine +(`engine/test/interop-kotlin.mjs`, 43 assertions) โ€” a seal made by Kotlin opens +in JS and vice versa, the inner envelope survives, and a seal addressed to one +device does **not** open on another. + +๐Ÿ”ด **The seal is confidentiality, never authentication.** The recipient's +`IK_dh` is published to the world, so anyone can produce a well-formed seal +claiming to be from anybody. The claimed sender selects a session and nothing +more; the ratchet opening it is what makes it a fact. There is a test that +forges a sender on purpose so that nobody later "tidies" the naming into +something that looks authoritative. + +โš ๏ธ **The first message to a new contact cannot be sealed**, and `Sent.sealed` +reports which it was rather than letting the UI imply otherwise. Sealing needs +the recipient's delivery key, and that key travels inside an E2E message โ€” so +there has to be a prior exchange. Claiming anonymity for that first message +would be lying about the one thing the feature is for. + +โš ๏ธ **Rotation is the only lever against a sealed spammer, and it is blunt.** +There is no sender to block, so the recipient replaces the delivery key and +every contact loses sealed access until the next time they are written to. +Reachable from Account; the copy says so. + +The relay stores only `SHA-256(key)` โ€” the key itself reaches contacts inside +E2E messages and never passes through the relay from its owner, which is the +only reason an unauthenticated submission can be authorised at all. ## Known flakiness diff --git a/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrudSurfaceTest.kt b/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrudSurfaceTest.kt index b773427..cd04295 100644 --- a/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrudSurfaceTest.kt +++ b/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrudSurfaceTest.kt @@ -115,6 +115,9 @@ class CrudSurfaceTest { // ยง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() + // ยง14.4 rotation is the only lever against a sealed spammer, so it has + // to be reachable โ€” a lever nobody can find is not a lever. + compose.onNodeWithTag("rotate-delivery-key").assertExists() compose.onNodeWithTag("nav-contacts").performClick() awaitTag("add-contact", 20_000) 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 b0b2737..cd8a986 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -495,6 +495,24 @@ fun ColumnScope.AccountScreen( style = MaterialTheme.typography.bodySmall, ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text("Sealed sender", modifier = Modifier.weight(1f)) + TextButton( + onClick = model::rotateDeliveryKey, + modifier = Modifier.testTag("rotate-delivery-key"), + ) { Text("New delivery key") } + } + Text( + "Messages you send are addressed to the recipient without naming you, once they have " + + "your delivery key. Replacing it is the only way to stop someone who is abusing " + + "it โ€” and it cuts off every contact until you next message them.", + style = MaterialTheme.typography.bodySmall, + ) + Divider() // Sign-out lives here rather than under the contact list, because this is 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 5d1c274..36f9d4f 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -431,6 +431,26 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { update { it.copy(requireKeyLog = required) } } + /** + * Replace the delivery key, cutting off anyone holding the old one. + * + * โš ๏ธ Blunt on purpose and worth saying so in the UI: every contact loses + * the ability to reach us sealed until the next time we write to them. It + * is the only lever ยง14.4 provides against a sealed spammer, because there + * is no sender to block. + */ + fun rotateDeliveryKey() = viewModelScope.launch { + val current = session ?: return@launch + val outcome = withContext(Dispatchers.IO) { + runCatching { current.conversations.rotateDeliveryKey() } + } + update { + it.copy(error = outcome.exceptionOrNull()?.let(::describe) + ?: "Delivery key replaced. Contacts can reach you sealed again " + + "once you next message them.") + } + } + // -- Groups -------------------------------------------------------------- fun refreshGroups() = viewModelScope.launch { @@ -725,6 +745,13 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { private fun startPolling() { polling?.cancel() polling = viewModelScope.launch { + // Make sure the relay holds a digest for this account before the + // first poll. Without one, every contact that tries to reach us + // sealed is rejected โ€” and a sealed submission is rejected by the + // relay, so the sender learns nothing useful about why. + withContext(Dispatchers.IO) { + runCatching { session?.conversations?.ensureDeliveryKey() } + } while (true) { val current = session if (current != null) { diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index 16216a4..b243d18 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -6,6 +6,7 @@ import io.flamenet.e2e.DeviceIdentity import io.flamenet.e2e.E2ESession import io.flamenet.e2e.Envelope import io.flamenet.e2e.PeerBundle +import io.flamenet.e2e.SealedSender import io.flamenet.e2e.Vault /** @@ -42,6 +43,15 @@ class Conversations( val fromUser: Long, val fromDevice: String, val content: Content, + /** + * Whether this arrived sealed (ยง14). + * + * โš ๏ธ For a sealed message [fromUser] and [fromDevice] come from inside + * the blob, and are trustworthy **only because the inner envelope + * decrypted** under a session those identities selected. Never surface + * a sealed message's sender on a path that skips that. + */ + val sealed: Boolean = false, ) { val text: String get() = content.text } @@ -57,6 +67,16 @@ class Conversations( * be absurd. */ val attachment: Attachment.Ref? = null, + /** + * Whether the relay was told who sent this (ยง14). + * + * Reported rather than assumed: sealing needs the recipient's delivery + * key, which arrives inside an E2E message, so the first exchange with + * a new contact is necessarily unsealed. A UI that implied anonymity + * for that message would be lying about the one thing this feature is + * for. + */ + val sealed: Boolean = false, ) { val reachedSomeone: Boolean get() = delivered.isNotEmpty() } @@ -190,10 +210,26 @@ class Conversations( val failed = LinkedHashMap() val outgoing = mutableListOf() + // Seal only if we hold their key. A silent fall back to an + // unsealed send is the failure ยง14.5 warns about, so the outcome is + // returned rather than assumed โ€” the UI can then say which it was + // instead of implying anonymity that was not achieved. + val peerKey = deliveryKeyFor(toUserId) + + // Hand this contact OUR key on first contact so they can reply sealed. + // Rides the ratchet as an ordinary content envelope, so the relay + // cannot tell it from a photo (ยง14.4). + val prelude = ourDeliveryKeyEnvelopeFor(toUserId) + for (bundle in bundles) { if (bundle.deviceId == identity.deviceId) continue // never talk to ourselves try { val session = sessionFor(bundle) + if (prelude != null) { + val pre = session.encrypt(prelude) + outgoing += RelayClient.ToDevice( + bundle.deviceId, wrap(pre, bundle, peerKey != null)) + } val envelope = session.encrypt(text) // Saved BEFORE the network call. If the relay accepts the // envelope and we then crash, the peer has a message our @@ -201,15 +237,21 @@ class Conversations( // do recovers it. The reverse โ€” saved, not sent โ€” costs one // unused message key and nothing else. vault.saveSession(bundle.deviceId, session) - outgoing += RelayClient.ToDevice(bundle.deviceId, envelope) + outgoing += RelayClient.ToDevice( + bundle.deviceId, wrap(envelope, bundle, peerKey != null)) delivered += bundle.deviceId } catch (e: Exception) { failed[bundle.deviceId] = e.message ?: e::class.simpleName.orEmpty() } } - if (outgoing.isNotEmpty()) relay.sendEnvelopes(toUserId, outgoing, identity.deviceId) - return Sent(delivered, failed) + if (outgoing.isNotEmpty()) { + relay.sendEnvelopes( + toUserId, outgoing, identity.deviceId, + deliveryKey = peerKey?.let(Wire::b64), + ) + } + return Sent(delivered, failed, sealed = peerKey != null) } /** @@ -231,15 +273,33 @@ class Conversations( for (message in incoming) { try { - val envelope = Envelope.fromPayload(message.payload) - val session = existingSession(message.fromDevice) + // ๐Ÿ”ด A sealed message arrives with NO sender the relay could + // vouch for โ€” the transport's from_user/from_device are absent + // or meaningless. The blob names a sender, and anyone holding + // this device's published IK_dh can write whatever they like + // there. So the claim is used ONLY to pick a session, and it is + // the ratchet opening that decides who this actually was. + val sealed = SealedSender.isSealed(message.payload) + val opened = if (sealed) { + SealedSender.open( + SealedSender.blobFromPayload(message.payload), + identity.identityDH.private, + identity.deviceId, + ) + } else null + + val envelope = opened?.envelope ?: Envelope.fromPayload(message.payload) + val fromDevice = opened?.claimedFromDevice ?: message.fromDevice + val session = existingSession(fromDevice) ?: E2ESession.respond(identity, envelope) + // Nothing below this line runs unless the ratchet opened it, + // which is what turns the claim above into a fact. val plaintext = session.decrypt(envelope) - vault.saveSession(message.fromDevice, session) - out += Received( - message.id, message.fromUser, message.fromDevice, - Content.parse(plaintext), - ) + vault.saveSession(fromDevice, session) + val fromUser = opened?.claimedFromUser ?: message.fromUser + val content = Content.parse(plaintext) + consumeControl(fromUser, content) + out += Received(message.id, fromUser, fromDevice, content, sealed = sealed) } catch (_: Exception) { // Deliberately swallowed and skipped. One undecryptable message // is not a reason to stop reading the inbox. @@ -265,6 +325,84 @@ class Conversations( /** Set once known; only [replenishPreKeysIfLow] needs it. */ var myUserId: Long? = null + // -- Sealed sender (ยง14) ------------------------------------------------- + + /** Wrap an envelope for one device, sealed when we can reach them that way. */ + private fun wrap(envelope: Envelope, bundle: PeerBundle, sealed: Boolean): String = + if (!sealed) envelope.toPayload() + else SealedSender.seal( + envelope = envelope, + fromUser = myUserId ?: 0L, + fromDevice = identity.deviceId, + toDevice = bundle.deviceId, + recipientIKdh = bundle.identityDH, + ).toPayload() + + private fun deliveryKeyFor(userId: Long): ByteArray? = + vault.loadDelivery()?.peers?.get(userId) + + /** + * Our delivery key as a control envelope, the first time we write to + * someone โ€” and null every time after. + * + * Marked as told BEFORE the send rather than after: a resend that repeats + * it costs a wasted envelope, while a crash between sending and marking + * would repeat it on every message for ever. + */ + private fun ourDeliveryKeyEnvelopeFor(userId: Long): String? { + val delivery = vault.loadDelivery() ?: return null + if (!delivery.published || userId in delivery.toldPeers) return null + vault.saveDelivery(delivery.copy(toldPeers = delivery.toldPeers + userId)) + return SealedSender.deliveryKeyEnvelope(delivery.key) + } + + /** Take in the control messages this layer owns, rather than showing them. */ + private fun consumeControl(fromUser: Long, content: Content) { + when (content.kind) { + Content.Kind.DELIVERY_KEY -> { + val key = content.raw?.optString("key")?.takeIf { it.isNotEmpty() } ?: return + val delivery = vault.loadDelivery() ?: return + vault.saveDelivery( + delivery.copy(peers = delivery.peers + (fromUser to Wire.unb64(key)))) + } + Content.Kind.KEY_LOG_HEAD -> { + val raw = content.raw ?: return + trust.gossip(raw.optInt("size"), raw.optString("root")) + } + else -> Unit + } + } + + /** + * Make sure this account has a delivery key and the relay has its digest. + * + * โš ๏ธ Publishing the DIGEST, never the key. Sending the key itself would let + * the relay seal to its own users and would make rotation meaningless. + */ + fun ensureDeliveryKey() { + val existing = vault.loadDelivery() + if (existing != null && existing.published) return + val delivery = existing ?: Vault.Delivery(SealedSender.newDeliveryKey(), published = false) + relay.setDeliveryKey(SealedSender.deliveryKeyDigest(delivery.key)) + vault.saveDelivery(delivery.copy(published = true)) + } + + /** + * Replace the delivery key, cutting off anyone who has the old one. + * + * โš ๏ธ **This is the only way to stop a sealed spammer, and it is blunt.** + * Every contact holding the old key loses the ability to reach us sealed + * until they are told the new one, which happens the next time we write to + * them. That is the trade ยง14.4 makes: anonymity for senders costs the + * recipient a per-contact redistribution. + */ + fun rotateDeliveryKey() { + val fresh = Vault.Delivery(SealedSender.newDeliveryKey(), published = false) + relay.setDeliveryKey(SealedSender.deliveryKeyDigest(fresh.key)) + // `toldPeers` deliberately cleared: everyone has to be told again. + vault.saveDelivery(fresh.copy(published = true)) + } + private fun existingSession(peerDeviceId: String): E2ESession? = runCatching { vault.loadSession(peerDeviceId) }.getOrNull() diff --git a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt index aaffd45..add1f7d 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt @@ -123,10 +123,29 @@ class RelayClient( * malformed body, which at least fails loudly โ€” the shape being wrong in a * way the relay tolerates is the version that costs a week. */ - fun sendEnvelopes(toUser: Long, envelopes: List, fromDevice: String): JSONObject = + /** + * @param deliveryKey the recipient's capability, base64. When present the + * submission is **sealed**: the relay is not told who is sending, and + * `fromDevice` is omitted entirely rather than sent as a courtesy. + * + * ๐Ÿ”ด **Omitting `from_device` is the whole point, not tidiness.** Sending it + * alongside a sealed payload would hand the relay the sender's identity at + * request time, so the seal would hide the sender from the stored row and + * from nobody else โ€” strictly worse than not sealing, because it looks like + * it worked. + */ + fun sendEnvelopes( + toUser: Long, + envelopes: List, + fromDevice: String, + deliveryKey: String? = null, + ): JSONObject = postJson("/e2e/messages", JSONObject() .put("to", toUser) - .put("from_device", fromDevice) + .also { body -> + if (deliveryKey != null) body.put("delivery_key", deliveryKey) + else body.put("from_device", fromDevice) + } .put("messages", JSONArray().also { array -> envelopes.forEach { array.put(JSONObject() diff --git a/client/src/test/kotlin/io/flamenet/client/LiveSealedSenderTest.kt b/client/src/test/kotlin/io/flamenet/client/LiveSealedSenderTest.kt new file mode 100644 index 0000000..419f56a --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/LiveSealedSenderTest.kt @@ -0,0 +1,146 @@ +package io.flamenet.client + +import io.flamenet.e2e.DeviceIdentity +import io.flamenet.e2e.SealedSender +import io.flamenet.e2e.Vault +import javax.crypto.KeyGenerator +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * Sealed sender (ยง14) against a relay that is actually running. + * + * โš ๏ธ Opt-in via `FN_LIVE_FLEET`, pointed at the disposable fleet. + * + * FN_LIVE_FLEET=https://relay1.flamenet.io ./gradlew :client:test + * + * The engine tests prove the seal is sound. What only a real relay can show is + * that the relay ACCEPTS an unauthenticated sealed submission, that it stores + * no sender against it, and that the recipient still gets the message โ€” which + * is the entire claim the feature makes. + */ +class LiveSealedSenderTest { + + private val base: String? = System.getenv("FN_LIVE_FLEET") + + private class MapStorage : Vault.Storage { + val records = LinkedHashMap() + 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 class Actor(base: String, tag: String) { + val handle = "sl$tag${System.currentTimeMillis() % 100_000_000}" + val account = RelayAccounts(base).signUp( + username = handle, + email = "$handle@example.invalid", + password = "a-long-enough-passphrase", + ) + val identity: DeviceIdentity = DeviceIdentity.create() + val vault = Vault( + MapStorage(), + KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(), + ) + val relay = RelayClient(base, username = account.username, appPassword = account.appPassword) + val userId: Long get() = account.userId + val conversations = Conversations(identity, vault, relay).also { it.myUserId = userId } + + init { + relay.useRelayAuth(identity) + relay.registerDevice(identity, identity.generateOneTimePreKeys(5)) + } + } + + /** + * ๐Ÿ”ด The claim, end to end: the relay carries the message and does not + * learn who sent it. + */ + @Test + fun `a sealed message arrives and the relay records no sender`() { + val url = base ?: return + val alice = Actor(url, "a") + val bob = Actor(url, "b") + + bob.conversations.ensureDeliveryKey() + alice.conversations.ensureDeliveryKey() + + // First contact is necessarily UNSEALED โ€” Alice does not hold Bob's + // delivery key yet, and the key arrives inside an E2E message. This is + // the honest shape of the feature, not a gap: sealing needs a prior + // exchange, and a client that claimed otherwise would be lying. + val first = alice.conversations.send(bob.userId, "hello, unsealed") + assertTrue(first.reachedSomeone) + assertFalse(first.sealed, "the first message to a new contact cannot be sealed") + + // Bob reads it and, with it, learns nothing yet โ€” Alice has not been + // told Bob's key. Bob replying is what hands Alice his key. + val bobRead = bob.conversations.poll() + assertTrue(bobRead.any { it.content.text == "hello, unsealed" }) + val reply = bob.conversations.send(alice.userId, "hello back") + assertTrue(reply.reachedSomeone) + + // Alice picks up Bob's delivery key from the control envelope that rode + // along with his reply. + alice.conversations.poll() + + // Now she can seal. + val sealed = alice.conversations.send(bob.userId, "this one is sealed") + assertTrue(sealed.sealed, "Alice holds Bob's delivery key and must seal") + assertTrue(sealed.reachedSomeone) + + // ๐Ÿ”ด The relay stored no sender for it. `from_user` 0 is the relay + // saying it does not know โ€” which is the whole feature. + val inbox = bob.relay.pollInbox(bob.identity.deviceId, 0, 100) + val sealedRow = inbox.last() + assertEquals(0L, sealedRow.fromUser, "the relay must not know who sent a sealed message") + assertNotEquals(alice.identity.deviceId, sealedRow.fromDevice) + assertTrue(SealedSender.isSealed(sealedRow.payload), "the payload should be a seal") + + // And Bob still reads it, with the sender recovered from inside. + val got = bob.conversations.poll().single { it.content.text == "this one is sealed" } + assertTrue(got.sealed) + assertEquals(alice.userId, got.fromUser) + assertEquals(alice.identity.deviceId, got.fromDevice) + } + + /** + * Rotation cuts off the old key, which is the only lever ยง14.4 offers + * against a sealed spammer. + */ + @Test + fun `rotating the delivery key refuses submissions holding the old one`() { + val url = base ?: return + val alice = Actor(url, "c") + val bob = Actor(url, "d") + + bob.conversations.ensureDeliveryKey() + val old = bob.vault.loadDelivery()!!.key + + // Bob replaces it. The relay now holds a digest the old key misses. + bob.conversations.rotateDeliveryKey() + assertFalse(bob.vault.loadDelivery()!!.key.contentEquals(old)) + + // A submission presenting the OLD key must be refused outright. Sent + // through the client rather than by hand, so this exercises the same + // path a real sealed send takes. + val bundle = alice.relay.fetchKeys(bob.userId).first { it.deviceId == bob.identity.deviceId } + val envelope = io.flamenet.e2e.E2ESession.initiate(alice.identity, bundle).encrypt("stale") + val blob = SealedSender.seal( + envelope, alice.userId, alice.identity.deviceId, bundle.deviceId, bundle.identityDH) + + val refused = runCatching { + alice.relay.sendEnvelopes( + bob.userId, + listOf(RelayClient.ToDevice(bundle.deviceId, blob.toPayload())), + alice.identity.deviceId, + deliveryKey = Wire.b64(old), + ) + } + assertTrue(refused.isFailure, "the relay must refuse a retired delivery key") + } +} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/SealedSender.kt b/engine/src/main/kotlin/io/flamenet/e2e/SealedSender.kt new file mode 100644 index 0000000..5761681 --- /dev/null +++ b/engine/src/main/kotlin/io/flamenet/e2e/SealedSender.kt @@ -0,0 +1,171 @@ +package io.flamenet.e2e + +import org.json.JSONObject +import java.security.SecureRandom + +/** + * Sealed sender โ€” spec ยง14. + * + * Moves the sender's identity *inside* the ciphertext, so the relay learns who + * a message is for and never who it is from. + * + * A transport feature rather than a protocol version: it changes what the + * server sees and nothing about how `SK` is derived, so it composes with v1, + * v2 and v3 unchanged. + * + * ๐Ÿ”ด **The seal is confidentiality, never authentication.** Anyone holding the + * recipient's public `IK_dh` โ€” which the relay publishes to the world โ€” can + * produce a well-formed seal claiming to be from anybody. The claimed sender is + * worth nothing until the *inner* envelope opens; the ratchet and the identity + * pin are what authenticate. Displaying, notifying on, or storing a claimed + * sender before that trades a metadata leak for free sender-spoofing, which is + * a worse product than the one being fixed. + * + * One-shot X25519 to the recipient device's published `IK_dh` rather than the + * ratchet, because a prekey message arrives before any session exists and so + * has no ratchet to use. + */ +object SealedSender { + + private val INFO = "FlamenetE2E_SealedSender_v1".toByteArray(Charsets.UTF_8) + private val ZERO32 = ByteArray(32) + private const val NONCE_LEN = 12 + private const val KEY_LEN = 32 + + private val random = SecureRandom() + + /** A sealed blob, as it rides in the transport's `payload` field. */ + data class Blob(val eph: ByteArray, val ciphertext: ByteArray) { + fun toJson(): JSONObject = JSONObject() + .put("s", 1) + .put("eph", Bytes.b64(eph)) + .put("ct", Bytes.b64(ciphertext)) + + fun toPayload(): String = + Bytes.b64(toJson().toString().toByteArray(Charsets.UTF_8)) + + override fun equals(other: Any?): Boolean = + other is Blob && eph.contentEquals(other.eph) && + ciphertext.contentEquals(other.ciphertext) + + override fun hashCode(): Int = eph.contentHashCode() * 31 + ciphertext.contentHashCode() + } + + /** + * What a seal carried, once opened. + * + * โš ๏ธ Both identity fields say `claimed` on purpose. Renaming them to + * something confident is all it would take for a caller to start believing + * them. + */ + data class Opened( + val claimedFromUser: Long, + val claimedFromDevice: String, + val envelope: Envelope, + ) + + /** + * Derive the one-shot key and nonce from a shared secret. + * + * The nonce comes out of the KDF rather than being transmitted, so there is + * nothing for a relay to grind on and no nonce to get wrong. Safe because + * the ephemeral key is fresh per seal: a repeated (key, nonce) pair under + * GCM leaks the authentication subkey, and the only way this repeats is if + * the same ephemeral is used twice. + */ + private fun derive(shared: ByteArray): Pair { + val okm = Hkdf.derive(shared, ZERO32, INFO, KEY_LEN + NONCE_LEN) + return okm.copyOfRange(0, KEY_LEN) to okm.copyOfRange(KEY_LEN, KEY_LEN + NONCE_LEN) + } + + /** + * Wrap an envelope so the relay cannot see who sent it. + * + * โš ๏ธ The AAD binds the **destination device** and the ephemeral key, which + * is what stops a relay taking one person's sealed blob and dropping it + * into somebody else's inbox: the copy would not open there. + */ + fun seal( + envelope: Envelope, + fromUser: Long, + fromDevice: String, + toDevice: String, + recipientIKdh: ByteArray, + ): Blob { + val eph = X25519.generate() + val ephPub = X25519.rawFromPublicKey(eph.public) + val (key, nonce) = derive(X25519.agree(eph.private, recipientIKdh)) + + val inner = JSONObject() + .put("from_user", fromUser) + .put("from_device", fromDevice) + .put("envelope", envelope.toJson()) + .toString() + .toByteArray(Charsets.UTF_8) + + val aad = Bytes.concat(toDevice.toByteArray(Charsets.UTF_8), ephPub) + // `Aead.seal` returns `nonce || ct || tag`; the wire carries only + // `ct || tag`, because the nonce is derived on both sides. + val box = Aead.seal(key, nonce, inner, aad) + return Blob(ephPub, box.copyOfRange(NONCE_LEN, box.size)) + } + + /** + * Open a sealed blob with this device's `IK_dh`. + * + * See the type-level warning: what comes back is a routing hint, not a fact. + */ + fun open(blob: Blob, myIKdhPrivate: java.security.PrivateKey, myDeviceId: String): Opened { + val (key, nonce) = derive(X25519.agree(myIKdhPrivate, blob.eph)) + val aad = Bytes.concat(myDeviceId.toByteArray(Charsets.UTF_8), blob.eph) + val plain = Aead.open(key, Bytes.concat(nonce, blob.ciphertext), aad) + val inner = JSONObject(String(plain, Charsets.UTF_8)) + return Opened( + claimedFromUser = inner.optLong("from_user"), + claimedFromDevice = inner.optString("from_device"), + envelope = Envelope.fromJson(inner.getJSONObject("envelope")), + ) + } + + /** + * Whether a transport payload is a sealed blob rather than a bare envelope. + * + * Both shapes arrive in the same field, so this is what decides which + * decoder to use. Deliberately structural โ€” `s == 1` and an `eph` string โ€” + * rather than a guess based on which parse throws first. + */ + fun isSealed(payload: String): Boolean = try { + val json = JSONObject(String(Bytes.unb64(payload), Charsets.UTF_8)) + json.optInt("s") == 1 && json.optString("eph").isNotEmpty() + } catch (_: Exception) { + false + } + + fun blobFromPayload(payload: String): Blob { + val json = JSONObject(String(Bytes.unb64(payload), Charsets.UTF_8)) + require(json.optInt("s") == 1) { "unsupported seal version ${json.optInt("s")}" } + return Blob(Bytes.unb64(json.getString("eph")), Bytes.unb64(json.getString("ct"))) + } + + /** + * The 32-byte capability that authorises an unauthenticated sealed + * submission to a given user (ยง14.4). + * + * โš ๏ธ **The server stores only the digest.** The key itself reaches contacts + * inside E2E messages, so the relay never sees it from its owner โ€” which is + * the only reason a sealed submission can be authorised without naming who + * is submitting. + * + * Rotating it is the only way to cut off a sealed spammer, and it costs a + * redistribution to every contact. That is the whole trade ยง14.4 makes: + * anonymity for the sender, and a blunt instrument for the recipient. + */ + fun newDeliveryKey(): ByteArray = ByteArray(32).also(random::nextBytes) + + /** What the server stores. Never send the key itself. */ + fun deliveryKeyDigest(key: ByteArray): ByteArray = Bytes.sha256(key) + + /** The control message that hands a contact this key (ยง6.6, ยง14.4). */ + fun deliveryKeyEnvelope(key: ByteArray): String = + """{"fnc":1,"kind":"${Content.Kind.DELIVERY_KEY}","key":"${Bytes.b64(key)}"}""" +} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index bfec71e..8ff0853 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -49,6 +49,7 @@ class Vault(private val storage: Storage, private val key: SecretKey) { private const val HISTORY_PREFIX = "history/" private const val TRUST_PREFIX = "trust/" private const val KEYLOG_PIN = "keylog-pin" + private const val DELIVERY_KEY = "delivery-key" /** The two kinds of thread, named once so they cannot drift apart. */ fun threadForUser(userId: Long) = "user:$userId" @@ -210,6 +211,61 @@ class Vault(private val storage: Storage, private val key: SecretKey) { fun forgetTrust(userId: Long) = storage.delete(trustName(userId)) + // -- Sealed sender (ยง14.4) ----------------------------------------------- + + /** + * This account's delivery key, and whether the relay has its digest yet. + * + * ๐Ÿ”ด **The key itself never goes to the relay โ€” only `SHA-256(key)` does.** + * It reaches contacts inside E2E messages, so the relay never sees it from + * its owner, and that is the only reason an unauthenticated sealed + * submission can be authorised without naming who is submitting. Sending + * the raw key to the server would make the whole mechanism decorative. + * + * `published` tracks the digest upload separately from the key, because a + * key generated and never published is one every contact would try to seal + * to and every seal would be rejected. + */ + data class Delivery( + val key: ByteArray, + val published: Boolean, + /** Peer user id -> their delivery key, so we can reach them sealed. */ + val peers: Map = emptyMap(), + /** Peers we have already handed OUR key to, so we send it once. */ + val toldPeers: Set = emptySet(), + ) { + override fun equals(other: Any?): Boolean = + other is Delivery && key.contentEquals(other.key) && published == other.published && + peers.keys == other.peers.keys && toldPeers == other.toldPeers + + override fun hashCode(): Int = key.contentHashCode() * 31 + published.hashCode() + } + + fun loadDelivery(): Delivery? = readJson(DELIVERY_KEY)?.let { o -> + val peers = o.optJSONObject("peers") ?: JSONObject() + val told = o.optJSONArray("told") ?: org.json.JSONArray() + Delivery( + key = java.util.Base64.getDecoder().decode(o.getString("key")), + published = o.optBoolean("published", false), + peers = peers.keys().asSequence().associate { k -> + k.toLong() to java.util.Base64.getDecoder().decode(peers.getString(k)) + }, + toldPeers = (0 until told.length()).map { told.getLong(it) }.toSet(), + ) + } + + fun saveDelivery(delivery: Delivery) { + val peers = JSONObject() + delivery.peers.forEach { (id, k) -> + peers.put(id.toString(), java.util.Base64.getEncoder().encodeToString(k)) + } + writeJson(DELIVERY_KEY, JSONObject() + .put("key", java.util.Base64.getEncoder().encodeToString(delivery.key)) + .put("published", delivery.published) + .put("peers", peers) + .put("told", org.json.JSONArray(delivery.toldPeers.toList()))) + } + // -- Key-log pin (ยง19.3) ------------------------------------------------- /** diff --git a/engine/src/test/kotlin/io/flamenet/e2e/SealedSenderTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/SealedSenderTest.kt new file mode 100644 index 0000000..167c4a6 --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/SealedSenderTest.kt @@ -0,0 +1,171 @@ +package io.flamenet.e2e + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Sealed sender (ยง14), and mostly the things it must refuse. + * + * A seal that opens for the right person is easy. The properties worth testing + * are that it does NOT open for anyone else, and that the identity it carries + * is treated as a claim. + */ +class SealedSenderTest { + + private fun pair(): Pair = + DeviceIdentity.create() to DeviceIdentity.create() + + /** A real envelope, so the test is not sealing a string. */ + private fun envelope(from: DeviceIdentity, to: DeviceIdentity, text: String): Envelope { + val oneTime = to.generateOneTimePreKeys(1).first() + val wire = to.registrationWire() + .put("opk", org.json.JSONObject().put("id", oneTime.id).put("pub", Bytes.b64(oneTime.publicKey))) + return E2ESession.initiate(from, PeerBundle.fromWire(wire)).encrypt(text) + } + + @Test + fun `a seal opens for the device it was addressed to`() { + val (alice, bob) = pair() + val env = envelope(alice, bob, "hello") + + val blob = SealedSender.seal(env, 7, alice.deviceId, bob.deviceId, bob.identityDHPublic) + val opened = SealedSender.open(blob, bob.identityDH.private, bob.deviceId) + + assertEquals(7, opened.claimedFromUser) + assertEquals(alice.deviceId, opened.claimedFromDevice) + assertEquals(env.toPayload(), opened.envelope.toPayload()) + } + + /** + * ๐Ÿ”ด The AAD binds the destination device. + * + * Without this a relay could copy one person's sealed blob into somebody + * else's inbox and it would decrypt there โ€” turning a privacy feature into + * a delivery bug that leaks messages to the wrong account. + */ + @Test + fun `a seal does not open on a device it was not addressed to`() { + val (alice, bob) = pair() + val eve = DeviceIdentity.create() + val blob = SealedSender.seal( + envelope(alice, bob, "hello"), 7, alice.deviceId, bob.deviceId, bob.identityDHPublic) + + // Even holding a valid private key, and even being handed the blob. + assertFailsWith { + SealedSender.open(blob, eve.identityDH.private, eve.deviceId) + } + } + + /** + * โš ๏ธ Bob's own key, but the AAD says another device id. + * + * Separated from the test above so the two failure reasons cannot be + * confused: this one proves the device id is in the AAD, not merely that a + * different DH key fails. + */ + @Test + fun `the destination device id is part of what is authenticated`() { + val (alice, bob) = pair() + val blob = SealedSender.seal( + envelope(alice, bob, "hello"), 7, alice.deviceId, bob.deviceId, bob.identityDHPublic) + + assertFailsWith { + SealedSender.open(blob, bob.identityDH.private, "some-other-device-id") + } + } + + @Test + fun `a tampered ciphertext is refused`() { + val (alice, bob) = pair() + val blob = SealedSender.seal( + envelope(alice, bob, "hello"), 7, alice.deviceId, bob.deviceId, bob.identityDHPublic) + val broken = blob.copy(ciphertext = blob.ciphertext.copyOf().also { it[0] = (it[0] + 1).toByte() }) + + assertFailsWith { + SealedSender.open(broken, bob.identityDH.private, bob.deviceId) + } + } + + /** + * ๐Ÿ”ด Anyone can forge the claimed sender, and this records that on purpose. + * + * The recipient's IK_dh is published to the world, so a seal claiming to be + * from anybody is trivial to make. This test exists so that if someone ever + * "fixes" the naming to look authoritative, the reason it is a claim is + * still written down and still executing. + */ + @Test + fun `anyone holding the recipient's public key can forge the claimed sender`() { + val (alice, bob) = pair() + val eve = DeviceIdentity.create() + + // Eve seals her own envelope while claiming to be Alice. Nothing stops + // her โ€” the seal is confidentiality, not authentication. + val forged = SealedSender.seal( + envelope(eve, bob, "not really from alice"), + fromUser = 7, + fromDevice = alice.deviceId, + toDevice = bob.deviceId, + recipientIKdh = bob.identityDHPublic, + ) + val opened = SealedSender.open(forged, bob.identityDH.private, bob.deviceId) + + // The seal opens and the lie is right there in the claim. + assertEquals(alice.deviceId, opened.claimedFromDevice) + // What catches it is the inner envelope: responding to it establishes a + // session with EVE's identity key, not Alice's, so the identity pin is + // what refuses her โ€” never this layer. + val session = E2ESession.respond(bob, opened.envelope) + assertEquals("not really from alice", session.decrypt(opened.envelope)) + // The handshake names EVE's identity key, which is what the recipient's + // identity pin compares against โ€” and what refuses her. + assertContentEquals( + eve.identitySigningPublic, + Bytes.unb64(opened.envelope.handshake!!.getString("ik_sig")), + ) + } + + @Test + fun `a sealed payload is distinguishable from a bare envelope`() { + val (alice, bob) = pair() + val env = envelope(alice, bob, "hello") + val blob = SealedSender.seal(env, 7, alice.deviceId, bob.deviceId, bob.identityDHPublic) + + assertTrue(SealedSender.isSealed(blob.toPayload())) + assertFalse(SealedSender.isSealed(env.toPayload())) + assertFalse(SealedSender.isSealed("not base64 at all")) + } + + @Test + fun `every seal uses a fresh ephemeral`() { + val (alice, bob) = pair() + val one = SealedSender.seal( + envelope(alice, bob, "a"), 7, alice.deviceId, bob.deviceId, bob.identityDHPublic) + val two = SealedSender.seal( + envelope(alice, bob, "b"), 7, alice.deviceId, bob.deviceId, bob.identityDHPublic) + + // The nonce is derived from the shared secret, so a repeated ephemeral + // is a repeated (key, nonce) pair โ€” which under GCM leaks the + // authentication subkey and with it the ability to forge. + assertFalse(one.eph.contentEquals(two.eph)) + } + + @Test + fun `the server only ever gets the digest of a delivery key`() { + val key = SealedSender.newDeliveryKey() + assertEquals(32, key.size) + val digest = SealedSender.deliveryKeyDigest(key) + assertContentEquals(Bytes.sha256(key), digest) + assertFalse(digest.contentEquals(key)) + + // And the control envelope hands over the KEY, because that is what a + // contact needs and the relay never sees. + val json = org.json.JSONObject(SealedSender.deliveryKeyEnvelope(key)) + assertEquals(Content.Kind.DELIVERY_KEY, json.getString("kind")) + assertContentEquals(key, Bytes.unb64(json.getString("key"))) + } +} diff --git a/interop/src/main/kotlin/io/flamenet/interop/Main.kt b/interop/src/main/kotlin/io/flamenet/interop/Main.kt index 4267bb9..a6e227b 100644 --- a/interop/src/main/kotlin/io/flamenet/interop/Main.kt +++ b/interop/src/main/kotlin/io/flamenet/interop/Main.kt @@ -11,6 +11,7 @@ import io.flamenet.e2e.Ed25519 import io.flamenet.e2e.Hkdf import io.flamenet.e2e.KeyLog import io.flamenet.e2e.SafetyNumber +import io.flamenet.e2e.SealedSender import io.flamenet.e2e.X25519 import io.flamenet.e2e.X3DH import org.json.JSONObject @@ -105,6 +106,8 @@ private fun handle(req: JSONObject): JSONObject = when (req.getString("cmd")) { req.bytes("ikm"), req.bytes("salt"), req.bytes("info"), req.getInt("length")))) "aead_open" -> JSONObject().put("plaintext", String(Aead.open( req.bytes("key"), req.bytes("box"), req.bytes("aad")), Charsets.UTF_8)) + "seal" -> sealSender(req) + "open_sealed" -> openSealed(req) "verify" -> JSONObject().put("ok", Ed25519.verify( req.bytes("public_key"), req.bytes("signature"), req.bytes("message"))) else -> JSONObject().put("error", "unknown cmd ${req.getString("cmd")}") @@ -292,6 +295,39 @@ private fun sessionRespond(req: JSONObject): JSONObject { .put("text", session.decrypt(envelope)) } +/** + * Seal an envelope for a device the web engine holds. + * + * The envelope is taken as-is from the request rather than produced here, so + * the two engines are compared on the SEAL and nothing else โ€” a mismatch in the + * ratchet would otherwise show up as a sealed-sender failure. + */ +private fun sealSender(req: JSONObject): JSONObject { + val blob = SealedSender.seal( + envelope = Envelope.fromJson(req.getJSONObject("envelope")), + fromUser = req.getLong("from_user"), + fromDevice = req.getString("from_device"), + toDevice = req.getString("to_device"), + recipientIKdh = req.bytes("recipient_ik_dh"), + ) + return JSONObject().put("blob", blob.toJson()) +} + +/** Open a blob the web engine sealed, with a device identity this side holds. */ +private fun openSealed(req: JSONObject): JSONObject { + val identity = identities.getValue(req.getString("device")) + val json = req.getJSONObject("blob") + val opened = SealedSender.open( + SealedSender.Blob(unb64(json.getString("eph")), unb64(json.getString("ct"))), + identity.identityDH.private, + identity.deviceId, + ) + return JSONObject() + .put("from_user", opened.claimedFromUser) + .put("from_device", opened.claimedFromDevice) + .put("envelope", opened.envelope.toJson()) +} + private fun sessionEncrypt(req: JSONObject): JSONObject = JSONObject() .put("envelope", liveSessions.getValue(req.getString("session")) .encrypt(req.getString("text")).toJson())