From a9ec8e98bbf0c703a2cd14bcc11f2f760f21f902 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Tue, 25 Aug 2026 15:55:19 -0400 Subject: [PATCH 1/9] One sign-up helper, because five copies had the same bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven of 52 UI tests failed in one run with "sign-up did not complete". The app was right and the tests were wrong, and the failure dump said so: signup.password value: • ← one character signup.confirm value: ••••••••••••••••• ← all seventeen signup.submit Disabled iOS puts "Use Strong Password?" over the first `.newPassword` field, and from the first keystroke on it swallows what follows. Every sign-up helper in this target called `dismissPasswordSuggestionIfPresent()` — at the end, after both passwords had been typed, by which point there is nothing left to save. The app then correctly refused a form whose two passwords disagreed, and the report blamed sign-up for it. It is non-deterministic, which is why it reads as flake: a fifth suite running identical keystrokes passed in the same run. It is not flake and it is not machine load. There were five hand-rolled copies of that helper — four here, plus one I wrote for the new two-simulator suite, which reproduced the bug immediately and is what makes the point. Now there is one, `XCUIApplication.completeSignUp`, and it dismisses the panel when the field takes focus, reads both fields back, repairs a short one, and fails naming the cause instead of the app. Two new suites: TwoSimulatorUITests — two simulators, two accounts, one relay. Both sides run the same test with the handles swapped, so neither has to start first; the peer lookup retries, which is the rendezvous. A direct message crosses in both directions and a group created on one device arrives on the other. This is weaker evidence than CrossPlatformUITests and says so in its own header: both halves are the same binary and will agree when both are wrong. What it proves that nothing else does is that a message crosses two devices, two Keychains and two ratchets and lands in the app's own UI. AppSurfaceUITests — the screens nothing was opening. Reactions, unsend, in-chat search, mute, the voice-note recorder, the stories strip, the groups section, the safety number and five of the seven settings screens had no test at all. Deliberately shallow and it says so: this catches a screen that crashes or fails to push, not one that is wrong. Also here, each found while running the above: - DEVELOPMENT_TEAM was "2W7DA3A8W2", the free personal team on the build Mac, while the App Store provisioning profile that already exists for net.flamenet.messenger belongs to M5923Y84P5 — the team that ships hittfitness and shepherdgiving, valid to 2027-06-04. A personal team cannot produce a distribution build, so every archive for TestFlight would have failed naming the certificate rather than the team ID that put it out of reach. - Scripts/generate.sh, because .gitignore excludes the whole .xcodeproj and SwiftPM keeps Package.resolved inside it. Since the WebRTC dependency landed a fresh checkout had no pins at all: resolve against the network and get a different build tomorrow, or disable resolution and fail outright. Both unacceptable for something going to testers. The pins live in Scripts/ now. - rounds.sh ran an iOS *build* and called it a round. The 45 XCUITest cases never ran. It runs them now — and refuses to score a run that executed zero tests as a pass, which xcodebuild will happily report after a runner crashes and relaunches: Test Suite 'TwoSimulatorUITests' passed Executed 0 tests, with 0 failures Co-Authored-By: Claude Opus 5 --- PARITY.md | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/PARITY.md b/PARITY.md index 6c49ca8..4b2d3a5 100644 --- a/PARITY.md +++ b/PARITY.md @@ -17,7 +17,7 @@ screen renders. | Protocol engine | `Sources/E2E`, 15 files | `engine`, 10 files | | App layer | ~9,700 lines, 45 files | accounts, contacts, chat, photos | | Tests | XCUITest + JS interop | 114 unit + 37 cross-engine + 15 on-device + live round trips | -| Ships to | nobody (no signing team) | a debug APK that installs and runs | +| Ships to | TestFlight, once a distribution cert is minted (see below) | a debug APK that installs and runs; the release keystore is configured | Android's engine is **verified against the web engine in both directions** (`engine/test/interop-kotlin.mjs`), which is a stronger claim than the iOS app @@ -334,3 +334,26 @@ for the app to settle before deciding anything. first takes the message and the other reports zero, which reads as "nothing was sent". Run a single `autoreply` at a time. +## Signing, on both sides (2026-08-25) + +**iOS was pinned to the wrong Apple team, and that — not a missing membership — +is what made TestFlight unreachable.** `project.yml` carried +`DEVELOPMENT_TEAM: "2W7DA3A8W2"`, the free personal team attached to the iCloud +address on the build Mac. Meanwhile an **App Store provisioning profile for +`net.flamenet.messenger` already exists**, issued to team `M5923Y84P5` — the +team that ships hittfitness and shepherdgiving — and valid until 2027-06-04. A +personal team cannot produce a distribution build at all, so every archive would +have failed at signing while naming the certificate rather than the team ID that +put it out of reach. The team is now `M5923Y84P5`. + +⚠️ **Still outstanding: no distribution certificate in the build Mac's +keychain.** `security find-identity -v -p codesigning` returns exactly one +identity, the personal-team *development* one. Signing in to Xcode with the App +Store Connect account mints the distribution certificate and the matching +profile; nothing in this repository can do it, and nothing here should try. + +**Android needs none of that.** The release `signingConfig` reads +`~/.flamenet-android-signing.properties` — present on the build Mac since +2026-08-23 — so `:app:assembleRelease` produces a signed APK that can go +straight to a tester. That asymmetry is why Android reaches testers first, and +it has nothing to do with which app is more finished. From 95659f4685d4f95a10845cd84ecdb703f39a08d3 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Tue, 25 Aug 2026 16:30:00 -0400 Subject: [PATCH 2/9] Record the two reproducible iOS pair symptoms in PARITY Both reproduce on an idle machine, so neither is load. One-way delivery between two fresh accounts, and a group create that throws while the sheet sits open. The evidence that rules out the first wrong theory is written down with them, so nobody re-derives it. Co-Authored-By: Claude Opus 5 --- PARITY.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/PARITY.md b/PARITY.md index 4b2d3a5..b629064 100644 --- a/PARITY.md +++ b/PARITY.md @@ -357,3 +357,30 @@ profile; nothing in this repository can do it, and nothing here should try. 2026-08-23 — so `:app:assembleRelease` produces a signed APK that can go straight to a tester. That asymmetry is why Android reaches testers first, and it has nothing to do with which app is more finished. + +## Two reproducible symptoms in the iOS pair (2026-08-25) + +Found running `TwoSimulatorUITests` against `relay1`. **Both reproduce with the +machine idle**, so neither is the load artifact they were first taken for. + +**1. One-way delivery between two fresh accounts.** Alpha receives bravo's +message — test1 passes on alpha's side, 113s. Bravo never shows alpha's, across +a full three-minute wait, on two separate runs. The direction is stable. + +Already ruled out: `E2EInbox.transmit` throws before recording anything when the +recipient has no device, and `send` appends to the thread only *after* +`transmit` returns. So alpha's line existing in alpha's thread is positive +evidence the relay accepted envelopes addressed to bravo's device. Bravo +therefore either never fetched them or could not open them. ⚠️ The first reading +of this — "the app records messages it could not deliver" — was **wrong**, and +reading `E2EInbox` is what disproved it. Do not act on that theory. + +**2. Creating a group does not produce a group.** The sheet stayed open past two +minutes; the tree captured at failure shows Home, the buddy row present, and the +Groups section reading "No groups yet. Start one with your buddies." +`createGroup` appends to `groups` on success and `groups` is `@Published`, so a +successful create would have rendered a row. It did not — the create threw. + +⚠️ Both concern sending **to** the second account. Suggestive, not a diagnosis. +The next run needs the thread dump (added) plus the relay's own view of what is +queued for that device. From 18499ae746d43236de79fb7b4d51526e877e5b11 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Tue, 25 Aug 2026 16:35:55 -0400 Subject: [PATCH 3/9] Narrow the delivery failure to the receive side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The headless peer tool settles the direction. iOS sent to a real shell-driven account and it arrived: testAMessageReachesANonIOSClient passed (39.2s) peer poll -> "hello from iOS to the peer tool", from_user 403 So the send path and the relay are fine, and neither explains two iOS clients failing one way. What differs there is that both sides initiate a session simultaneously; the peer tool never initiates, which is why it works. Suspect is E2EInbox.swift:995. `ingest` returning true means "advance the cursor" — consume the envelope — and false means hold it for the next poll. The retry branch is taken only when `existing == nil`. A recipient that already created its own outgoing session has `existing != nil`, so an envelope it cannot yet decrypt is skipped forever. That is exactly the simultaneous- initiation case. Recorded as a hypothesis with its evidence, not as a fix. It has not been instrumented, and this file's history includes a previous advance-uncondition- ally bug that lost messages silently, so the area deserves care rather than a quick patch. Co-Authored-By: Claude Opus 5 --- PARITY.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/PARITY.md b/PARITY.md index b629064..329da7b 100644 --- a/PARITY.md +++ b/PARITY.md @@ -384,3 +384,50 @@ successful create would have rendered a row. It did not — the create threw. ⚠️ Both concern sending **to** the second account. Suggestive, not a diagnosis. The next run needs the thread dump (added) plus the relay's own view of what is queued for that device. + +### Narrowed 2026-08-25: it is the RECEIVE side, and only when both sides opened first + +The headless peer tool settles the direction. `qz89925` (user 402), a real +account driven from a shell, was sent to by the iOS app: + +``` +CrossPlatformUITests/testAMessageReachesANonIOSClient passed (39.2s) +peer poll → {"messages":[{"text":"","from_user":403}, + {"text":"hello from iOS to the peer tool","from_user":403}]} +``` + +✅ **iOS → non-iOS delivery works.** The sender encrypts correctly and the relay +delivers. (The empty first message is the §14.4 delivery-key prelude, as +designed.) So neither the send path nor the relay explains the two-simulator +failure. + +**What differs in the two-simulator case is that both sides initiate a session +with each other at the same time** — each signs up, opens the chat, and sends +before either has received anything. The peer tool never initiates, which is +exactly why it works. + +🔴 **Suspect: `E2EInbox.swift:995`.** + +```swift +// Transient: a non-prekey message arrived before the establishing +// prekey created the session. Do NOT advance -- retry on the next poll. +guard let r = ratchet else { return existing == nil ? false : true } +``` + +`ingest` returning **true means "advance the cursor"** — the envelope is +consumed. `false` holds the cursor so the next poll retries it. The condition +returns `false` (retry) only when `existing == nil`. A recipient that has +already created its OWN outgoing session to that device has `existing != nil`, +so an envelope it cannot yet decrypt returns **true and is skipped forever**. + +That is precisely the simultaneous-initiation case, and it matches every +observation: the peer tool (`existing == nil`) receives fine; a second iOS +client that sent first does not. The comment above the line describes holding +the cursor, which is what the `false` branch does — the `existing != nil` branch +does the opposite. + +⚠️ **Hypothesis, not a confirmed diagnosis.** It has not been instrumented, and +the neighbouring code deliberately archives retired sessions, so there may be an +intended reason for the asymmetry. Verify before changing: the same file's +history shows a previous "advance unconditionally" bug that caused silent +message loss, so this area has form. From fbe09d0b61a0af2138332318a9e52904b8fa099c Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Tue, 25 Aug 2026 16:38:27 -0400 Subject: [PATCH 4/9] Disprove my own suspect, and narrow to true simultaneous initiation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `existing != nil` theory was wrong, twice over. `ratchet` is nil at E2EInbox.swift:995 only when the envelope is not type `prekey`, so an inbound prekey never reaches that guard and it cannot drop a first message. And measurement settles it: testARoundTripWithANonIOSClient passed in 54.2s, with iOS sending FIRST — creating its own outgoing session — and then receiving and rendering the peer's reply. peer add qrt90210 -> {"added":"qrt90210"} peer send -> {"delivered":1,"failed":0,"to":404} iOS round trip -> passed (54.234s) Both directions now proven in isolation. What is left is the case neither experiment covers: both sides sending before either has processed the other's prekey, which is what two simulators racing through sign-up produces and what a shell-driven peer never does. The leading hypothesis moves to line 1006, which overwrites the session built from an inbound prekey without archiving the one it replaces — saveArchivedRatchet appears once in the file and only re-saves something already archived. Recorded with what would confirm it, and with an explicit warning not to patch on reasoning alone. Co-Authored-By: Claude Opus 5 --- PARITY.md | 57 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 37 insertions(+), 20 deletions(-) diff --git a/PARITY.md b/PARITY.md index 329da7b..ad775ef 100644 --- a/PARITY.md +++ b/PARITY.md @@ -406,28 +406,45 @@ with each other at the same time** — each signs up, opens the chat, and sends before either has received anything. The peer tool never initiates, which is exactly why it works. -🔴 **Suspect: `E2EInbox.swift:995`.** +### Disproved, and narrowed further (same day) -```swift -// Transient: a non-prekey message arrived before the establishing -// prekey created the session. Do NOT advance -- retry on the next poll. -guard let r = ratchet else { return existing == nil ? false : true } +🔴 **The `existing != nil` theory is WRONG.** The suspect was +`E2EInbox.swift:995`, where `ingest` returns `true` — consume the envelope — +rather than holding the cursor, whenever the recipient already has its own +session. Two things kill it: + +1. `ratchet` is nil there **only when the envelope is not type `prekey`**. An + inbound prekey envelope always builds a session and never reaches that guard, + so it cannot drop a first message. +2. Measured. `testARoundTripWithANonIOSClient` **passed in 54.2s**: iOS signed + up, sent first — creating its own outgoing session — and then received and + rendered the peer's reply. + +``` +peer add qrt90210 → {"added":"qrt90210"} +peer send → {"delivered":1,"failed":0,"to":404} +iOS round trip → passed (54.234s) ``` -`ingest` returning **true means "advance the cursor"** — the envelope is -consumed. `false` holds the cursor so the next poll retries it. The condition -returns `false` (retry) only when `existing == nil`. A recipient that has -already created its OWN outgoing session to that device has `existing != nil`, -so an envelope it cannot yet decrypt returns **true and is skipped forever**. +✅ So **non-iOS → iOS works even when the iOS client already holds an outgoing +session to that device.** Both directions are proven in isolation. -That is precisely the simultaneous-initiation case, and it matches every -observation: the peer tool (`existing == nil`) receives fine; a second iOS -client that sent first does not. The comment above the line describes holding -the cursor, which is what the `false` branch does — the `existing != nil` branch -does the opposite. +**What is left is genuine simultaneous initiation.** In the round trip the peer +received iOS's prekey and established its session *before* replying, so both +sides ended up on one chain. In the two-simulator case both sign up, both open +the chat, and both send **before either has processed the other's prekey** — so +each creates its own session and then meets a message on a different chain. -⚠️ **Hypothesis, not a confirmed diagnosis.** It has not been instrumented, and -the neighbouring code deliberately archives retired sessions, so there may be an -intended reason for the asymmetry. Verify before changing: the same file's -history shows a previous "advance unconditionally" bug that caused silent -message loss, so this area has form. +⚠️ **Leading hypothesis, not confirmed:** `E2EInbox.swift:1006` calls +`saveRatchet` on the session built from an inbound prekey **without archiving +the session it replaces**. `saveArchivedRatchet` appears exactly once in the +file, at line 914, and that call only re-saves an already-archived ratchet — +nothing ever archives a *live* session before overwriting it. The +archived-ratchet fallback at line 911 exists precisely for "messages sent on the +old chain before the peer reset", which is this situation, but it can only help +if something was archived. + +To confirm, instrument both clients' ratchet ids across a concurrent send. Do +not patch on this reasoning alone: the file's history includes an +advance-unconditionally bug that lost messages silently, and a careless fix here +trades one silent loss for another. From b6519faada549bce7cbe9f450f9a593699d0d7fd Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Tue, 25 Aug 2026 16:42:34 -0400 Subject: [PATCH 5/9] Say what a two-simulator run actually costs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roughly one second of test time per minute of wall clock, measured with the host healthy — 38 runnable processes, load 27. The cost is XCUITest snapshotting the whole accessibility tree per query against a home screen carrying a stories strip, a buddy list, a groups section and a tab bar. A DM run is 20-40 minutes per side and the group phase has never completed in one sitting. Worth knowing before someone schedules this as a quick check. Co-Authored-By: Claude Opus 5 --- PARITY.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/PARITY.md b/PARITY.md index ad775ef..818c6c6 100644 --- a/PARITY.md +++ b/PARITY.md @@ -448,3 +448,16 @@ To confirm, instrument both clients' ratchet ids across a concurrent send. Do not patch on this reasoning alone: the file's history includes an advance-unconditionally bug that lost messages silently, and a careless fix here trades one silent loss for another. + +### Why confirming it is expensive + +⚠️ **A two-simulator run advances roughly one second of test time per minute of +wall clock**, and this is not machine load — measured with the host healthy +(38 runnable processes, load 27). The cost is `Capturing element debug +description`: XCUITest snapshots the entire accessibility tree on every query, +and this app's home screen carries a stories strip, a buddy list, a groups +section and a tab bar. + +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. From 999ab2bd260c42541c91006d765764f05fef8c02 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Tue, 25 Aug 2026 16:53:19 -0400 Subject: [PATCH 6/9] Keep the chain a re-establishment replaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When both sides open the same chat before either has processed the other's prekey — two people tapping a conversation at the same moment — each runs X3DH and each ends up adopting the other's chain to read what arrived. From then on the peer sends on the chain being replaced. Overwriting it without a copy does not lose one message; it makes that direction undecryptable for the rest of the conversation, silently, because an envelope that will not open is skipped rather than reported. **iOS.** `E2EInbox` had the fallback already — `loadArchivedRatchet` is tried before building from a prekey, and its comment describes exactly this situation. It was unreachable: `saveArchivedRatchet` appeared once in the file, and that call only re-saves a ratchet that was already archived. Nothing ever put a live session there. It does now, immediately before the session it replaces is overwritten. **Android was worse, and had no fallback at all.** `Conversations.poll` read val session = existingSession(fromDevice) ?: E2ESession.respond(identity, envelope) which looks like a fallback and is not one: with a stored session present that was the ONLY thing tried, `decrypt` threw for a peer on another chain, and the catch swallowed it. `respond` was never reached. Now three chains are tried in order — the current one, the retired one, then a fresh one built from the prekey block — and the replaced chain is kept. ⚠️ The watermark still advances on a total failure, deliberately. An envelope none of the three chains could open will not open next poll either, and holding the cursor there would wedge the inbox behind one bad message. What made this lossy was never the watermark; it was that only one chain was ever tried. `Vault` grows a retired-session slot under its own prefix — deliberately not `SESSION_PREFIX`, or `knownPeers()` would list every retired chain as a peer — and `forgetAllSessions` clears it too, since a sign-out that left chain keys behind would keep exactly what it claims to erase. Tests, and the one that matters is the storage-level one: - `VaultTest.a retired session is kept and still opens its chain` — asserts the ADOPTED session cannot open the retired chain before asserting the retired one can, so it cannot pass by accident. **Verified load-bearing**: neutering `saveRetiredSession` fails it, restoring it passes. - `VaultTest.forgetting sessions forgets the retired ones as well`. - `E2ESessionTest.simultaneous initiation does not strand the conversation` — covers the engine primitives and asserts the SECOND exchange, the one that used to die. ⚠️ It holds both sessions in local variables, so it passes with or without the storage fix; it is not evidence for it. 111 engine tests, 0 failures. Both platforms compile. ⚠️ This is a real correctness defect and is fixed on its merits. Its relationship to the one-way loss seen between two simulators is NOT established — that symptom is the first message, and both first messages open fine either way. Do not close that investigation on this commit. Co-Authored-By: Claude Opus 5 --- .../io/flamenet/client/Conversations.kt | 60 ++++++++++++++-- .../src/main/kotlin/io/flamenet/e2e/Vault.kt | 31 ++++++++- .../kotlin/io/flamenet/e2e/E2ESessionTest.kt | 44 ++++++++++++ .../test/kotlin/io/flamenet/e2e/VaultTest.kt | 69 +++++++++++++++++++ 4 files changed, 197 insertions(+), 7 deletions(-) diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index b243d18..a72855f 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -290,12 +290,36 @@ class Conversations( val envelope = opened?.envelope ?: Envelope.fromPayload(message.payload) val fromDevice = opened?.claimedFromDevice ?: message.fromDevice - val session = existingSession(fromDevice) - ?: E2ESession.respond(identity, envelope) - // Nothing below this line runs unless the ratchet opened it, - // which is what turns the claim above into a fact. - val plaintext = session.decrypt(envelope) - vault.saveSession(fromDevice, session) + + // 🔴 **Three chains to try, not one.** This used to be + // `existingSession(fromDevice) ?: E2ESession.respond(...)`, + // which reads as a fallback and is not one: when a stored + // session existed it was the ONLY thing tried, `decrypt` threw + // for a peer on a different chain, and the catch below swallowed + // it. `respond` was never reached. + // + // That happens whenever both sides run X3DH before either has + // processed the other's prekey — two people opening the same + // chat at once, which is ordinary behaviour. Each adopts the + // other's chain, and from then on every message arrives on a + // 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) + } val fromUser = opened?.claimedFromUser ?: message.fromUser val content = Content.parse(plaintext) consumeControl(fromUser, content) @@ -303,6 +327,13 @@ class Conversations( } catch (_: Exception) { // Deliberately swallowed and skipped. One undecryptable message // is not a reason to stop reading the inbox. + // + // ⚠️ The watermark still advances below, on purpose: an + // envelope that none of the three chains could open will not + // open on the next poll either, and holding the cursor on it + // would wedge the inbox behind one bad message. What made this + // lossy before was not the watermark — it was that only ONE + // chain was ever tried. } vault.inboxWatermark = message.id } @@ -406,6 +437,23 @@ class Conversations( private fun existingSession(peerDeviceId: String): E2ESession? = runCatching { vault.loadSession(peerDeviceId) }.getOrNull() + /** A session that opened an envelope, and what came out of it. */ + private class Opened(val session: E2ESession, val plaintext: String) + + /** + * Try one chain, and report failure as null rather than as an exception. + * + * ⚠️ **Decrypting mutates the ratchet**, so each candidate has to be a + * session object the caller is willing to keep — never the same instance + * tried twice. A failed attempt leaves that object in an indeterminate + * state and it is dropped, which is why the caller reloads rather than + * reusing. + */ + private fun openWith(session: E2ESession?, envelope: Envelope): Opened? { + if (session == null) return null + return runCatching { Opened(session, session.decrypt(envelope)) }.getOrNull() + } + /** * The session for a peer device: the stored one, or a new handshake. * diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt index 8ff0853..a76d524 100644 --- a/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt +++ b/engine/src/main/kotlin/io/flamenet/e2e/Vault.kt @@ -46,6 +46,11 @@ class Vault(private val storage: Storage, private val key: SecretKey) { private const val WATERMARK = "watermark" private const val CREDENTIAL = "credential" private const val SESSION_PREFIX = "session/" + + // ⚠️ Must NOT start with SESSION_PREFIX, or `knownPeers()` would list + // every retired chain as a peer and `sessionName` collisions would put + // two sessions in one record. + private const val RETIRED_PREFIX = "retired-session/" private const val HISTORY_PREFIX = "history/" private const val TRUST_PREFIX = "trust/" private const val KEYLOG_PIN = "keylog-pin" @@ -154,6 +159,26 @@ class Vault(private val storage: Storage, private val key: SecretKey) { fun saveSession(peerDeviceId: String, session: E2ESession) = writeJson(sessionName(peerDeviceId), session.archive()) + /** + * The session a re-establishment retired, kept so its in-flight messages + * can still be opened. + * + * 🔴 When both sides run X3DH before either has processed the other's + * prekey — two people opening the same chat at once — each adopts the + * other's chain. Everything the peer sends from then on travels on the + * chain being replaced, so overwriting it without a copy makes that + * direction undecryptable for the rest of the conversation, silently. + * + * ⚠️ One retired session per device: a second re-establishment displaces + * the first. Keeping the most recent is the right trade, because the older + * chain's messages have had longer to arrive. + */ + fun loadRetiredSession(peerDeviceId: String): E2ESession? = + readJson(RETIRED_PREFIX + peerDeviceId)?.let(E2ESession::fromArchive) + + fun saveRetiredSession(peerDeviceId: String, session: E2ESession) = + writeJson(RETIRED_PREFIX + peerDeviceId, session.archive()) + fun knownPeers(): List = storage.names().filter { it.startsWith(SESSION_PREFIX) }.map { it.removePrefix(SESSION_PREFIX) } @@ -357,6 +382,10 @@ class Vault(private val storage: Storage, private val key: SecretKey) { fun forgetEverything() = storage.names().forEach(storage::delete) /** Forget conversations but keep this device's identity. */ + /** ⚠️ Retired sessions too — they hold chain keys, and a sign-out that + * left them behind would keep exactly what it claims to erase. */ fun forgetAllSessions() = - storage.names().filter { it.startsWith(SESSION_PREFIX) }.forEach(storage::delete) + storage.names() + .filter { it.startsWith(SESSION_PREFIX) || it.startsWith(RETIRED_PREFIX) } + .forEach(storage::delete) } diff --git a/engine/src/test/kotlin/io/flamenet/e2e/E2ESessionTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/E2ESessionTest.kt index 9aeb20b..145c6a3 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/E2ESessionTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/E2ESessionTest.kt @@ -86,6 +86,50 @@ class E2ESessionTest { assertEquals("and back", a.decrypt(restoredB.encrypt("and back"))) } + /** + * 🔴 **Both sides open the chat at once.** + * + * Each runs X3DH before either has seen the other's prekey, so each holds + * its own chain and each is about to be handed a message on the other's. + * This is ordinary behaviour — two people tapping a conversation at the + * same moment — not an edge case. + * + * The failure it guards is not the first message. Both first messages open + * fine, because `respond` builds the peer's chain on demand. What breaks is + * everything AFTER the swap: each side now sends on the chain the other + * threw away, and a client that kept only the newest session cannot open + * any of it. Silently — an envelope that will not decrypt is skipped, not + * reported. + * + * So this asserts the second exchange, which is the one that used to die. + */ + @Test + fun `simultaneous initiation does not strand the conversation`() { + val alice = DeviceIdentity.create() + val bob = DeviceIdentity.create() + + // Neither has heard from the other yet; both start a chain. + val aOwn = E2ESession.initiate(alice, bundleFor(bob)) + val bOwn = E2ESession.initiate(bob, bundleFor(alice)) + val aFirst = aOwn.encrypt("from alice") + val bFirst = bOwn.encrypt("from bob") + + // Each adopts the other's chain to read the message that arrived, and + // RETIRES its own rather than dropping it — the peer is still on it. + val aAdopted = E2ESession.respond(alice, bFirst) + assertEquals("from bob", aAdopted.decrypt(bFirst)) + val bAdopted = E2ESession.respond(bob, aFirst) + assertEquals("from alice", bAdopted.decrypt(aFirst)) + + // The second exchange. Alice replies on the chain she adopted; Bob has + // that chain only as the one he started, so he needs the retired copy. + val aSecond = aAdopted.encrypt("alice again") + assertEquals("alice again", bOwn.decrypt(aSecond)) + + val bSecond = bAdopted.encrypt("bob again") + assertEquals("bob again", aOwn.decrypt(bSecond)) + } + /** Skipped keys are the in-flight messages; losing them at a restart loses those. */ @Test fun `messages in flight across a restart still open`() { diff --git a/engine/src/test/kotlin/io/flamenet/e2e/VaultTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/VaultTest.kt index b976285..de858a0 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/VaultTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/VaultTest.kt @@ -69,6 +69,75 @@ class VaultTest { assertEquals(listOf(alice.deviceId), Vault(storage, secret).knownPeers()) } + /** + * 🔴 **The chain a re-establishment replaces must survive it.** + * + * Both sides open the chat at once, so each runs X3DH before seeing the + * other's prekey and each adopts the other's chain to read what arrived. + * From then on the peer sends on the chain being replaced. A client that + * stored only the newest session could not open any of it — silently, + * because an envelope that will not decrypt is skipped, not reported. + * + * This is the storage half of the fix. `E2ESessionTest` covers the engine + * primitives, and passes with or without it, because a test holding both + * sessions in local variables never exercises what a client keeps. + */ + @Test + fun `a retired session is kept and still opens its chain`() { + val storage = MapStorage() + val secret = key() + val alice = DeviceIdentity.create() + val bob = DeviceIdentity.create() + + // Bob starts his own chain before hearing from Alice. + val bobsOwn = E2ESession.initiate(bob, bundleFor(alice)) + val bobsFirst = bobsOwn.encrypt("from bob") + Vault(storage, secret).saveSession(bob.deviceId, bobsOwn) + + // Alice's message arrives on a different chain, so Alice's is adopted + // and Bob's own is retired rather than dropped. + val alices = E2ESession.initiate(alice, bundleFor(bob)) + val alicesFirst = alices.encrypt("from alice") + val adopted = E2ESession.respond(bob, alicesFirst) + assertEquals("from alice", adopted.decrypt(alicesFirst)) + + val vault = Vault(storage, secret) + vault.saveRetiredSession(bob.deviceId, vault.loadSession(bob.deviceId)!!) + vault.saveSession(bob.deviceId, adopted) + + // Alice is still on the chain Bob started — she answers on it. + val onTheRetiredChain = E2ESession.respond(alice, bobsFirst) + assertEquals("from bob", onTheRetiredChain.decrypt(bobsFirst)) + val reply = onTheRetiredChain.encrypt("bob, this is the old chain") + + val fresh = Vault(storage, secret) + assertNull(runCatching { fresh.loadSession(bob.deviceId)!!.decrypt(reply) }.getOrNull(), + "the adopted session must NOT open the retired chain — otherwise this " + + "test would pass without the retired copy existing at all") + assertEquals("bob, this is the old chain", + fresh.loadRetiredSession(bob.deviceId)!!.decrypt(reply)) + } + + /** Signing out must take the retired chains too — they hold chain keys. */ + @Test + fun `forgetting sessions forgets the retired ones as well`() { + val storage = MapStorage() + val secret = key() + val alice = DeviceIdentity.create() + val bob = DeviceIdentity.create() + + val a = E2ESession.initiate(alice, bundleFor(bob)) + Vault(storage, secret).saveSession(bob.deviceId, a) + Vault(storage, secret).saveRetiredSession(bob.deviceId, a) + + Vault(storage, secret).forgetAllSessions() + + assertNull(Vault(storage, secret).loadSession(bob.deviceId)) + assertNull(Vault(storage, secret).loadRetiredSession(bob.deviceId)) + assertTrue(storage.records.keys.none { it.contains("session") }, + "a record still names a session: ${storage.records.keys}") + } + /** What is on disk must not be readable, or none of this was worth doing. */ @Test fun `nothing recognisable is written in the clear`() { From e62317b6f36e8855a3f2afa99a72af4142972dab Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Tue, 25 Aug 2026 17:08:34 -0400 Subject: [PATCH 7/9] Reproduce the loss without simulators, and show the fix is not sufficient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two :interop:peer accounts against relay1, both running the retired-session fix. Neither polls before sending, which is the simultaneous-initiation case: p1 send / p2 send -> delivered:1, failed:0 (each on its own chain) p1 poll / p2 poll -> both first messages arrive p1 send / p2 send -> delivered:1, failed:0 p1 poll / p2 poll -> {"messages":[]} / {"messages":[]} The second exchange is lost in both directions, silently, with the relay reporting success. Same failure as the two-simulator run with every iOS variable removed, and it reproduces in about two minutes instead of forty. So retaining the retired session is necessary and not sufficient. The receive side can now open the old chain; the loop still does not close. The remaining gap looks to be on the send side — after adopting the peer's chain each client encrypts on the adopted one while the peer still reads the one it kept, so neither is listening where the other is talking. Resolving this properly means both sides converging on the SAME chain by a deterministic tie-break, not each adopting the other's. Recorded so the earlier commit is not read as having closed it. Co-Authored-By: Claude Opus 5 --- PARITY.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/PARITY.md b/PARITY.md index 818c6c6..ba25675 100644 --- a/PARITY.md +++ b/PARITY.md @@ -461,3 +461,44 @@ section and a tab bar. 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 + +2026-08-25, two `:interop:peer` accounts against `relay1`, no simulators, no UI. +Both had the retired-session fix compiled in. + +``` +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":[]} ❌ +``` + +**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: + +``` +./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 +``` + +⚠️ **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. + +⚠️ Do not treat the ratchet-overwrite commit as having fixed this. From 4f15beb16060cb7fc594195029c90505fb5db758 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Tue, 25 Aug 2026 17:12:23 -0400 Subject: [PATCH 8/9] 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) From 02e5c45cd4cfd6282e3fc690f8265725f4d91198 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Tue, 25 Aug 2026 17:13:59 -0400 Subject: [PATCH 9/9] A test that could actually have caught it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `a retired session is kept and still opens its chain` passed while the fix was broken, because it retires a PRISTINE session — which is not what the receive path holds. By the time that path decides to retire anything it has just proved the peer is on another chain by handing this session an envelope it could not open, and E2ESession is a class, so that attempt consumed skipped keys and stepped the chain. The new test reproduces that state and asserts both halves: the damaged instance must NOT still open its chain (or the test proves nothing), and the copy re-read from the vault must. It fails against the first version of the fix and passes against this one. 13 VaultTest cases, 112 engine tests, 0 failures. Both platforms compile. Co-Authored-By: Claude Opus 5 --- .../test/kotlin/io/flamenet/e2e/VaultTest.kt | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/engine/src/test/kotlin/io/flamenet/e2e/VaultTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/VaultTest.kt index de858a0..003acc3 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/VaultTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/VaultTest.kt @@ -118,6 +118,64 @@ class VaultTest { fresh.loadRetiredSession(bob.deviceId)!!.decrypt(reply)) } + /** + * 🔴 **A failed decrypt damages the session object it was handed, so the + * thing that gets retired must be re-read from the vault.** + * + * This is the bug the first version of the retirement fix shipped with, and + * the reason `a retired session is kept and still opens its chain` did not + * catch it: that test retires a PRISTINE session, which is not what the + * receive path has in its hand. The real path has just proved the peer is on + * another chain — by handing this session an envelope it could not open — + * and `E2ESession` is a class, so that attempt consumed skipped keys and + * stepped the chain. Retiring THAT instance stores something that can no + * longer open anything, which looks exactly like not retiring at all. + * + * Asserts both halves: the mutated instance fails, the re-read one works. + */ + @Test + fun `the session retired after a failed decrypt must be re-read, not reused`() { + val storage = MapStorage() + val secret = key() + val alice = DeviceIdentity.create() + val bob = DeviceIdentity.create() + + // Bob's own chain, stored, with a message Alice will later answer on it. + val bobsOwn = E2ESession.initiate(bob, bundleFor(alice)) + val bobsFirst = bobsOwn.encrypt("from bob") + Vault(storage, secret).saveSession(bob.deviceId, bobsOwn) + + val alicesReplyOnBobsChain = E2ESession.respond(alice, bobsFirst).let { + it.decrypt(bobsFirst) + it.encrypt("answering on the chain bob started") + } + + // The receive path: Bob's stored session is handed a foreign envelope + // and fails. THIS is the instance the buggy version retired. + val foreign = E2ESession.initiate(alice, bundleFor(bob)).encrypt("different chain") + val damaged = Vault(storage, secret).loadSession(bob.deviceId)!! + assertNull(runCatching { damaged.decrypt(foreign) }.getOrNull(), + "the foreign envelope must NOT open, or this test proves nothing") + + // Retiring the damaged instance loses the chain. + val wrong = MapStorage().also { storage.records.forEach(it.records::put) } + Vault(wrong, secret).saveRetiredSession(bob.deviceId, damaged) + assertNull( + runCatching { + Vault(wrong, secret).loadRetiredSession(bob.deviceId)!! + .decrypt(alicesReplyOnBobsChain) + }.getOrNull(), + "a session damaged by a failed decrypt should NOT still open its chain — " + + "if this ever starts passing, the hazard is gone and this test can go") + + // Re-reading the vault copy keeps it. This is the fix. + val pristine = Vault(storage, secret).loadSession(bob.deviceId)!! + Vault(storage, secret).saveRetiredSession(bob.deviceId, pristine) + assertEquals("answering on the chain bob started", + Vault(storage, secret).loadRetiredSession(bob.deviceId)!! + .decrypt(alicesReplyOnBobsChain)) + } + /** Signing out must take the retired chains too — they hold chain keys. */ @Test fun `forgetting sessions forgets the retired ones as well`() {