Merge branch 'test/ios-two-simulator-and-surface' into 'main'

One sign-up helper, because five copies had the same bug

See merge request paulhitt/flamenet-messenger-ios!35
This commit is contained in:
Paul Hitt
2026-08-25 21:39:35 +00:00
5 changed files with 454 additions and 8 deletions
@@ -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<String> =
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)
}
@@ -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`() {
@@ -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`() {