From 4f15beb16060cb7fc594195029c90505fb5db758 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Tue, 25 Aug 2026 17:12:23 -0400 Subject: [PATCH] Retire the pristine session, not the one a failed decrypt mutated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retained-session fix was necessary and did not work, and a live two-peer reproduction is what showed it: both second-exchange messages still vanished with delivered:1, failed:0. Cause. `DoubleRatchet` is a final class on iOS and `E2ESession` is a class on Android, so the failed decrypt that PROVES the peer is on another chain mutates the instance it was handed — skipped keys consumed, chain stepped. Both fixes then archived that same instance. Storing a part-advanced chain is indistinguishable from storing nothing: it cannot open what the peer sends. The vault copy was never handed to a decrypt, so re-read it before retiring it. Second cause, Android only. Which slot the winner belongs in is not the same question as which chain opened it. When the retired chain opens an envelope it has to be written back to the RETIRED slot; promoting it swaps the two and discards the chain we are sending on. Each branch now writes back deliberately instead of sharing one `if (session !== existing)`. Verified against relay1, two peer accounts, no simulators: before p1/p2 poll -> {"messages":[]} / {"messages":[]} after p1/p2 poll -> second-from-Q2 / second-from-Q1 The repro is in PARITY.md and takes about two minutes. Co-Authored-By: Claude Opus 5 --- PARITY.md | 65 ++++++++++--------- .../io/flamenet/client/Conversations.kt | 51 +++++++++++---- 2 files changed, 73 insertions(+), 43 deletions(-) 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)