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:
@@ -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`() {
|
||||
|
||||
Reference in New Issue
Block a user