A session we ANSWER can now be v3, and receive failures say why

`E2ESession.respond` hard-coded `version = if (pq == null) 1 else 2`, so
a session this device answered could never be v3 — while `initiate`
twelve lines above correctly returns 3 whenever the post-quantum ratchet
is on. A conversation iOS started therefore ran as v3 on one side and v2
on the other, and every message this device sent into it was refused by
the peer as a downgrade (§13.4) and dropped on arrival. iOS to Android
worked; Android to iOS did not, for as long as the pairing existed.

Kotlin talking to Kotlin never noticed, because both ends drive the
ratchet from their own PQ state and ignore the label. Only a peer that
enforces the version sees it. `HybridSessionTest` asserted the wrong
value — `assertEquals(2, bobSide.version)`, two lines below requiring
the initiator to be 3 — so the suite passed while encoding the defect.
Corrected, with the reason recorded next to it.

The version now follows the initiator rather than assuming 3 whenever PQ
is present: a v2 peer sends a PQ block too, and answering that in v3
breaks the pairing in the other direction.

Receive-side failures are no longer swallowed. `catch (_: Exception) {}`
threw away the reason for every undecryptable envelope, which left this
half of every delivery investigation unreadable — the relay says
delivered, the app shows nothing, and the one place that knew why
discarded it. The envelope is still skipped; it now names the id, the
device and the exception. Sends report the protocol shape they emit, and
receives report which of the three chains opened them.

Also here:

  - proguard-rules.pro, which did not exist while build.gradle.kts
    referenced it. R8 accepts a missing entry in silence, so release
    builds ran on the platform defaults and answering a call aborted the
    process inside WebRTC's JNI_OnLoad — it resolves Java classes by
    name, and R8 had renamed them. The build now fails if the file is
    missing rather than shipping without it.
  - Server refusals read as sentences. `RelayException.Http` had no case
    in `describe()`, so choosing a taken handle showed "relay returned
    409:" and a JSON fragment, which says nothing about picking another
    name. Transport failures showed a Java networking string naming an
    address the person never typed.
  - Switching between signing in and creating an account clears the last
    error. It belonged to the other mode and stayed on screen, so taking
    the advice it gave looked like it had failed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-01 09:12:28 -04:00
co-authored by Claude Opus 4.8
parent db6ad2a8ce
commit ee17d773b7
10 changed files with 296 additions and 20 deletions
@@ -173,7 +173,24 @@ class E2ESession private constructor(
associatedData = X3DH.associatedData(
theirIdentitySigning, identity.identitySigningPublic, pq?.second),
pendingHandshake = null,
version = if (pq == null) 1 else 2,
// 🔴 **Honour what the initiator declared; this could never
// return 3.** `initiate` above sets 3 whenever the PQ ratchet
// is on, but responding capped at 2 — so a session this device
// ANSWERED was labelled v2 while the far side ran it as v3.
// Every message then arrived at a v3 peer without the
// post-quantum material a v3 step requires, and was refused as
// a downgrade (§13.4) — correctly, and for ever. iOS-initiated
// conversations were one-way: iOS to Android worked, Android to
// iOS was silently dropped on arrival.
//
// ⚠️ Reads the initiator's version rather than assuming 3
// whenever PQ is present: a v2 peer sends a PQ block too, and
// answering it in v3 breaks the pairing in the other direction.
version = when {
pq == null -> 1
envelope.version >= 3 -> 3
else -> 2
},
)
return session
}
@@ -98,7 +98,19 @@ class HybridSessionTest {
val delivered = Envelope.fromPayload(envelope.toPayload())
val bobSide = E2ESession.respond(bob, delivered)
assertEquals(2, bobSide.version)
// 🔴 **3, and asserting 2 here is what hid a real bug for weeks.**
// The initiator is v3 two assertions above; a responder that answers
// the same handshake as v2 disagrees with its own peer about which
// protocol the session runs. Kotlin-to-Kotlin never noticed, because
// both ends drive the ratchet off their own PQ state and ignore the
// label — so this test round-tripped happily while encoding the defect.
//
// A v3 peer that does enforce it does notice: iOS refuses a ratchet
// step with no post-quantum material as a downgrade, so every message
// Android sent into an iOS-initiated session was dropped on arrival and
// the conversation ran one way only.
assertEquals(3, bobSide.version,
"answering a v3 handshake makes a v3 session, not a v2 one")
assertEquals("post-quantum hello", bobSide.decrypt(delivered))
assertContentEquals(session.associatedData, bobSide.associatedData,
"both sides must bind the same ciphertext digest")