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:
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user