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:
co-authored by
Claude Opus 4.8
parent
130edd7019
commit
251a9f9c4a
@@ -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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user