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())