diff --git a/PARITY.md b/PARITY.md index ba25675..1067190 100644 --- a/PARITY.md +++ b/PARITY.md @@ -462,43 +462,48 @@ A DM run takes 20-40 minutes per side. The group phase has never completed inside a sitting. **Budget hours, not minutes** — or give the pair a trimmed screen to work against before running it. -### 🔴 REPRODUCED CHEAPLY — and the retired-session fix does NOT close it +### Reproduced cheaply, and now fixed — verified against relay1 -2026-08-25, two `:interop:peer` accounts against `relay1`, no simulators, no UI. -Both had the retired-session fix compiled in. +2026-08-25, two `:interop:peer` accounts, no simulators, no UI. Neither polls +before sending, which is the simultaneous-initiation case. + +**Before** (retired-session retention alone): ``` -p1 send p2 first-from-P1 {"delivered":1,"failed":0,"to":408} -p2 send p1 first-from-P2 {"delivered":1,"failed":0,"to":407} # neither had polled -p1 poll {"messages":[{"text":"first-from-P2",...}]} ✅ -p2 poll {"messages":[{"text":"first-from-P1",...}]} ✅ -p1 send p2 second-from-P1 {"delivered":1,"failed":0,"to":408} -p2 send p1 second-from-P2 {"delivered":1,"failed":0,"to":407} -p1 poll {"messages":[]} ❌ -p2 poll {"messages":[]} ❌ +p1/p2 send delivered:1, failed:0 both first messages arrive ✅ +p1/p2 send delivered:1, failed:0 +p1/p2 poll {"messages":[]} {"messages":[]} ❌ both lost ``` -**First messages cross. The second exchange is lost in both directions, silently, -with the relay reporting `delivered:1, failed:0`.** - -This is the same failure seen between two simulators, with every iOS variable -removed — two Kotlin clients and a live relay. **Reproduce it in about two -minutes** rather than the 20-40 the simulator pair costs: +**After**: ``` -./gradlew -q :interop:peer -Pargs="p1.json create

" -./gradlew -q :interop:peer -Pargs="p2.json create

" -# add both ways, then send both ways WITHOUT polling, then poll, then send again +p1/p2 poll second-from-Q2 / second-from-Q1 ✅ both arrive ``` -⚠️ **Retaining the retired session is necessary but NOT sufficient**, which the -commit that added it does not say clearly enough. The receive side can now open -the old chain; what this shows is that the loop still does not close. The -likely remaining gap is on the SEND side — after adopting the peer's chain, -each client encrypts on the adopted one while the peer is still reading the one -it kept, so neither is listening where the other is talking. Resolving -simultaneous initiation properly means both sides converging on the SAME chain -by a deterministic rule (Signal breaks the tie on the identity keys), not each -adopting the other's. +🔴 **What the first attempt got wrong, and it is worth remembering.** Retaining +the retired session was necessary; storing the WRONG OBJECT made it useless. +`DoubleRatchet` is a `final class` on iOS and `E2ESession` is a class on +Android, so the failed `decrypt` attempt that proves the peer is on another +chain **mutates the very instance it was handed** — skipped keys consumed, chain +stepped. Archiving that instance stores a chain that can no longer open +anything, which is indistinguishable from not archiving at all. The copy in the +vault was never handed to a decrypt, so it is the pristine one: **re-read it +before retiring it.** -⚠️ Do not treat the ratchet-overwrite commit as having fixed this. +⚠️ Second thing the first attempt got wrong: **which slot the winner belongs in +is not the same question as which chain opened it.** When the RETIRED chain is +the one that opens an envelope, it must be written back to the retired slot — +promoting it to current swaps the two and discards the chain we are sending on. +Each branch now writes back deliberately. + +⚠️ Reproduce in ~2 minutes rather than the 20-40 a simulator pair costs: + +``` +peer q1.json create

; peer q2.json create

+peer q1.json add

; peer q2.json add

+peer q1.json send

a; peer q2.json send

b # neither polled +peer q1.json poll; peer q2.json poll +peer q1.json send

c; peer q2.json send

d # the exchange that used to die +peer q1.json poll; peer q2.json poll +``` diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index a72855f..1da07e1 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -305,20 +305,45 @@ class Conversations( // chain the receiver has thrown away. val existing = existingSession(fromDevice) val retired = runCatching { vault.loadRetiredSession(fromDevice) }.getOrNull() - val opening = openWith(existing, envelope) - ?: openWith(retired, envelope) - // Only now build from the prekey block, and keep the chain - // being replaced — the peer is still sending on it. - ?: openWith(E2ESession.respond(identity, envelope), envelope) - ?: throw IllegalStateException("no session could open this envelope") - val plaintext = opening.plaintext - val session = opening.session - if (session !== existing) { - if (existing != null) vault.saveRetiredSession(fromDevice, existing) - vault.saveSession(fromDevice, session) - } else { - vault.saveSession(fromDevice, session) + // 🔴 **Which slot the winner belongs in is not the same question + // as which chain opened it**, and conflating the two swaps them. + // Each branch writes back deliberately. + val fromCurrent = openWith(existing, envelope) + val fromRetired = if (fromCurrent == null) openWith(retired, envelope) else null + val fromFresh = + if (fromCurrent == null && fromRetired == null) + openWith(E2ESession.respond(identity, envelope), envelope) + else null + + val plaintext = when { + fromCurrent != null -> { + vault.saveSession(fromDevice, fromCurrent.session) + fromCurrent.plaintext + } + fromRetired != null -> { + // The peer is still on the chain we retired. Advance the + // RETIRED slot and leave the current one alone — + // promoting it here would swap the two and lose the + // chain we are actually sending on. + vault.saveRetiredSession(fromDevice, fromRetired.session) + fromRetired.plaintext + } + fromFresh != null -> { + // ⚠️ **Reload before retiring.** `existing` was handed to + // a decrypt that FAILED, and a failed decrypt leaves the + // object part-advanced — archiving that instance stores a + // chain that can no longer open anything. The copy in the + // vault is untouched, so re-read it. + if (existing != null) { + existingSession(fromDevice)?.let { + vault.saveRetiredSession(fromDevice, it) + } + } + vault.saveSession(fromDevice, fromFresh.session) + fromFresh.plaintext + } + else -> throw IllegalStateException("no session could open this envelope") } val fromUser = opened?.claimedFromUser ?: message.fromUser val content = Content.parse(plaintext)