From 251a9f9c4a49dcc72f1c81adc11cf67fcc3addbf Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Sat, 29 Aug 2026 09:29:55 -0400 Subject: [PATCH] Multi-device fan-out: merge the directory with fetched bundles, never choose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit listDevices returns identity-only stubs — enough for a device we already have a session with, useless for one we do not, because initiate() verifies the signed prekey's signature and a stub carries empty bytes. Choosing between the two lists meant that whenever ANY device already had a session, every device got a stub, so a device still needing one could drop out of the fan-out with no partial-delivery warning (the other device succeeded, so was non-empty). The rule is union: full bundle where the key route returned one, stub otherwise. Found while verifying a two-device pair by hand; the live suite's 'a message reaches every device a person has' passes either way, which is why it needed the hand test to surface. Co-Authored-By: Claude Opus 4.8 --- SECURITY.md | 45 +++++++++++++ .../io/flamenet/client/Conversations.kt | 23 ++++++- .../io/flamenet/client/BundleMergeTest.kt | 64 +++++++++++++++++++ 3 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 SECURITY.md create mode 100644 client/src/test/kotlin/io/flamenet/client/BundleMergeTest.kt diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..a1610dc --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,45 @@ +# Security + +## Reporting a vulnerability + +Email **security@flamenetmessenger.com**. Please do not open a public issue for +anything affecting confidentiality or authentication. We acknowledge within 72 +hours. + +## Status + +**Not independently audited.** This Android app has been reviewed in-house and +is covered by the JVM unit suites plus on-device instrumentation, alongside the shared 253-check relay conformance suite and +cross-platform interop tests that run the real engine on both sides. + +An internal adversarial audit on 2026-08-29 found and fixed a family of +sender-attribution flaws: the ratchet authenticates a *device*, and the user id +inside a sealed envelope is a claim its author chooses — the two were not bound. +Devices now bind to their user on first proven use, control traffic (delivery +keys, avatars) is refused from an unproven sender, and the key-transparency log +head is verified against a pinned issuer key. Finding that class of bug in-house +is a reason to expect more from outside review, not fewer. + +## What the app protects, and what it cannot + +Private keys are generated on the device and live in the Android Keystore, which never hands out key material. Message history is +sealed at rest. The app defends against a **malicious or compromised relay** +reading messages, and against a passive adversary recording traffic to decrypt +after a quantum computer exists (when both ends publish a post-quantum +identity — the safety number says which guarantee it covers). + +It does **not** defend against: + +- **A compromised device.** Malware with your unlock reads plaintext. The app + lock is a speed bump against someone holding your unlocked phone, not + encryption. +- **Metadata.** The relay sees who is addressed and when. Sealed sender removes + the sender from the stored row, not from the network connection. +- **Your correspondent.** Anything you send can be screenshotted, kept, or + repeated by the person you sent it to. + +## Verifying what you run + +Every byte of the shipped APK's contents reproduces from this source; only the +randomized signing block differs. See `docs/REPRODUCIBLE.md` in the relay repo +and `scripts/verify-build.py` in the Android repo. diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index 8648fb4..1f274d8 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -435,10 +435,31 @@ class Conversations( proto = o.optInt("proto", 1), ) } + // 🔴 **A stub cannot open a session, so the two lists must be merged, + // not chosen between.** `listDevices` returns identity keys only — no + // signed prekey, no signature — which is all an EXISTING session needs. + // A device with no session needs a full bundle, and `E2ESession.initiate` + // verifies the signed prekey's signature before anything else: handed a + // stub, it fails on empty bytes and that device silently drops out of + // the fan-out. Because the OTHER device still succeeded, `delivered` was + // non-empty and the partial-delivery banner never fired — so a contact + // who added a second device simply stopped receiving on it, with + // nothing anywhere saying so. + // + // Fetching full bundles for everyone on every send is what the stub + // path exists to avoid (it pops a one-time prekey per device and is + // metered per pair). So: fetch full bundles only when something needs + // establishing, then use the full bundle for any device that has one + // and the stub for the rest. val needsEstablish = stubs.any { it.deviceId != identity.deviceId && existingSession(it.deviceId) == null } - val bundles = if (needsEstablish) relay.fetchKeys(toUserId) else stubs + val bundles = if (!needsEstablish) stubs else { + val full = relay.fetchKeys(toUserId).associateBy { it.deviceId } + // Union: every device the directory knows, carrying a full bundle + // where the key route returned one. + stubs.map { full[it.deviceId] ?: it } + } guardIdentity(toUserId, bundles) val delivered = mutableListOf() val failed = LinkedHashMap() diff --git a/client/src/test/kotlin/io/flamenet/client/BundleMergeTest.kt b/client/src/test/kotlin/io/flamenet/client/BundleMergeTest.kt new file mode 100644 index 0000000..e04ea0e --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/BundleMergeTest.kt @@ -0,0 +1,64 @@ +package io.flamenet.client + +import io.flamenet.e2e.PeerBundle +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The multi-device fan-out rule (found 2026-08-29 in live two-device testing). + * + * `listDevices` returns identity-only STUBS — enough for a device we already + * have a session with, useless for one we do not, because + * `E2ESession.initiate` verifies the signed prekey's signature and a stub has + * none. Choosing between the two lists meant that whenever ANY device already + * had a session, every device got a stub, and the one still needing a session + * dropped out of the fan-out. The other device still succeeded, so nothing + * reported a partial delivery: a contact who added a second device simply + * stopped receiving on it. + * + * The rule is UNION, not choice: full bundle where one exists, stub otherwise. + */ +class BundleMergeTest { + + private fun stub(id: String) = PeerBundle( + deviceId = id, identityDH = ByteArray(32), identitySigning = ByteArray(32), + signedPreKeyId = 0, signedPreKey = ByteArray(0), + signedPreKeySignature = ByteArray(0), oneTimePreKeyId = 0, + oneTimePreKey = null, proto = 1) + + private fun full(id: String) = PeerBundle( + deviceId = id, identityDH = ByteArray(32), identitySigning = ByteArray(32), + signedPreKeyId = 7, signedPreKey = ByteArray(32) { 3 }, + signedPreKeySignature = ByteArray(64) { 4 }, oneTimePreKeyId = 1, + oneTimePreKey = ByteArray(32) { 5 }, proto = 3) + + /** The merge as `sendPlaintext` performs it. */ + private fun merge(stubs: List, fetched: List): List { + val byId = fetched.associateBy { it.deviceId } + return stubs.map { byId[it.deviceId] ?: it } + } + + @Test fun `every device the directory knows survives the merge`() { + val merged = merge(listOf(stub("old"), stub("new")), listOf(full("new"))) + assertEquals(listOf("old", "new"), merged.map { it.deviceId }, + "a device must never drop out of the fan-out") + } + + @Test fun `a device needing a session gets the full bundle`() { + val merged = merge(listOf(stub("old"), stub("new")), listOf(full("new"))) + val fresh = merged.first { it.deviceId == "new" } + assertTrue(fresh.signedPreKey.isNotEmpty() && fresh.signedPreKeySignature.isNotEmpty(), + "initiate verifies this signature; a stub fails on empty bytes") + } + + @Test fun `devices the key route omits keep their stub`() { + val merged = merge(listOf(stub("old"), stub("new")), listOf(full("new"))) + assertTrue(merged.first { it.deviceId == "old" }.signedPreKey.isEmpty()) + } + + @Test fun `no fetch means the stubs stand unchanged`() { + val stubs = listOf(stub("a"), stub("b")) + assertEquals(stubs.map { it.deviceId }, merge(stubs, emptyList()).map { it.deviceId }) + } +}