Multi-device fan-out: merge the directory with fetched bundles, never choose

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 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-29 09:29:55 -04:00
co-authored by Claude Opus 4.8
parent 130edd7019
commit 251a9f9c4a
3 changed files with 131 additions and 1 deletions
@@ -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<String>()
val failed = LinkedHashMap<String, String>()
@@ -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<PeerBundle>, fetched: List<PeerBundle>): List<PeerBundle> {
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<PeerBundle>()).map { it.deviceId })
}
}