diff --git a/PARITY.md b/PARITY.md index 38111c8..d4feeab 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 @@ -356,3 +356,176 @@ 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. + +## 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. + +### 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. + +### Disproved, and narrowed further (same day) + +🔴 **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) +``` + +✅ So **non-iOS → iOS works even when the iOS client already holds an outgoing +session to that device.** Both directions are proven in isolation. + +**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. + +⚠️ **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. + +### 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. + +### Reproduced cheaply, and now fixed — verified against relay1 + +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/p2 send delivered:1, failed:0 both first messages arrive ✅ +p1/p2 send delivered:1, failed:0 +p1/p2 poll {"messages":[]} {"messages":[]} ❌ both lost +``` + +**After**: + +``` +p1/p2 poll second-from-Q2 / second-from-Q1 ✅ both arrive +``` + +🔴 **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.** + +⚠️ 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 a7ce2c3..78b1709 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -381,12 +381,61 @@ 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() + + // 🔴 **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) consumeControl(fromUser, content) @@ -394,6 +443,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 } @@ -497,6 +553,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 dd3b985..0f8b463 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" @@ -155,6 +160,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) } @@ -423,6 +448,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..003acc3 100644 --- a/engine/src/test/kotlin/io/flamenet/e2e/VaultTest.kt +++ b/engine/src/test/kotlin/io/flamenet/e2e/VaultTest.kt @@ -69,6 +69,133 @@ 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)) + } + + /** + * 🔴 **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`() { + 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`() {