Keep the chain a re-establishment replaces
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b6519faada
commit
999ab2bd26
@@ -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<String> =
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user