Retire the pristine session, not the one a failed decrypt mutated

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 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-25 17:12:23 -04:00
co-authored by Claude Opus 5
parent e62317b6f3
commit 4f15beb160
2 changed files with 73 additions and 43 deletions
@@ -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)