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
@@ -573,7 +573,21 @@ class Conversations(
// where the key route returned one.
stubs.map { full[it.deviceId] ?: it }
}
guardIdentity(toUserId, bundles)
// 🔴 **The send path had no logging at all, and that is why a message
// that never left the phone was indistinguishable from one that did.**
// The relay records refusals, so an absent POST looks identical to a
// successful one from the server side; only this side knows.
System.err.println("fnmsg-send: to=$toUserId stubs=${stubs.size} " +
"bundles=${bundles.size} mine=${identity.deviceId}")
try {
guardIdentity(toUserId, bundles)
} catch (e: Exception) {
System.err.println("fnmsg-send: REFUSED by guardIdentity " +
"(${e.javaClass.simpleName}: ${e.message}) — nothing will be sent. " +
"This fires when the peer's identity key changed, e.g. after they " +
"reinstalled or the account was recreated.")
throw e
}
val delivered = mutableListOf<String>()
val failed = LinkedHashMap<String, String>()
val outgoing = mutableListOf<RelayClient.ToDevice>()
@@ -594,6 +608,8 @@ class Conversations(
// above call this once per member with no try around it — throwing
// would abandon everyone after the first person who is signed out.
if (bundles.none { it.deviceId != identity.deviceId }) {
System.err.println("fnmsg-send: NOT SENDING — the recipient has no " +
"device other than ours (bundles=${bundles.map { it.deviceId }})")
failed["user:$toUserId"] = "not signed in on any device"
return Sent(delivered, failed, sealed = false)
}
@@ -626,6 +642,15 @@ class Conversations(
bundle.deviceId, wrap(pre, bundle, sealed))
}
val envelope = session.encrypt(text)
// ⚠️ Protocol shape only — version and whether the post-quantum
// half of the ratchet step is on the wire. A v3 peer refuses a
// step without it, and nothing on this side reported what we
// actually emitted.
System.err.println(
"fnmsg-send: envelope v${envelope.version} ${envelope.type} " +
"n=${envelope.header.n} pqdh=${envelope.header.pqdh != null} " +
"pqct=${envelope.header.pqct != null} " +
"sessionVersion=${session.version}")
// Saved BEFORE the network call. If the relay accepts the
// envelope and we then crash, the peer has a message our
// ratchet has not advanced past, and nothing either side can
@@ -640,7 +665,19 @@ class Conversations(
}
}
if (outgoing.isEmpty()) {
System.err.println("fnmsg-send: NOT SENDING — every device failed to " +
"encrypt: $failed")
}
if (outgoing.isNotEmpty()) {
System.err.println("fnmsg-send: POSTing ${outgoing.size} envelope(s) to " +
"/e2e/messages for user $toUserId sealed=$sealed " +
"toDevices=${outgoing.map { it.deviceId }.distinct()}")
// 🔴 The OUTCOME, not just the attempt. "POSTing" told us the code
// reached the call; it did not tell us whether the request left the
// phone. A DNS blip or a dropped connection throws here, and every
// caller above treats a thrown send as a failed message without
// ever saying what failed.
try {
relay.sendEnvelopes(
toUserId, outgoing, identity.deviceId,
@@ -651,7 +688,15 @@ class Conversations(
deliveryKey = if (peerToken == null) peerKey?.let(Wire::b64) else null,
deliveryToken = peerToken,
)
} catch (e: RelayException.Unauthorized) {
System.err.println("fnmsg-send: relay ACCEPTED ${outgoing.size} envelope(s)")
} catch (e: Exception) {
if (e !is RelayException.Unauthorized) {
System.err.println("fnmsg-send: SEND FAILED " +
"(${e.javaClass.simpleName}: ${e.message}) — the message did " +
"not leave this phone")
throw e
}
@Suppress("NAME_SHADOWING") val e = e
// 🔴 **A refused capability means the batch was revoked.**
// Retrying with the rest of it for ever is how a conversation
// quietly stops working, so the batch goes and the SAME
@@ -1081,6 +1126,11 @@ class Conversations(
openWith(E2ESession.respond(identity, envelope), envelope)
} else null
System.err.println(
"fnmsg-recv: envelope ${message.id} opened by " +
(if (fromCurrent != null) "current" else if (fromRetired != null) "retired"
else if (fromFresh != null) "FRESH handshake" else "nothing") +
" (sealed=$sealed, handshake=${envelope.handshake != null})")
val plaintext = when {
fromCurrent != null -> {
vault.saveSession(fromDevice, fromCurrent.session)
@@ -1170,7 +1220,22 @@ class Conversations(
sealed = sealed, senderProven = senderProven,
at = message.createdAt,
fromAddress = fromAddress)
} catch (_: Exception) {
} catch (failure: Exception) {
// 🔴 **Skipped, but never again in silence.** Swallowing this
// whole is what made Android's half of every delivery
// investigation unreadable: the relay says delivered, the app
// shows nothing, and the one place that knew why threw the
// reason away. The message is still skipped — that part was
// right — but it now says which envelope, from which device,
// and what actually failed.
//
// ⚠️ The exception type and its message only. Neither carries
// plaintext or key material; `IdentityChangedException` carries
// a user id and the rest are fixed strings.
System.err.println(
"fnmsg-recv: DROPPED envelope ${message.id} from " +
"${message.fromDevice.take(8)} — " +
"${failure.javaClass.simpleName}: ${failure.message}")
// Deliberately swallowed and skipped. One undecryptable message
// is not a reason to stop reading the inbox.
//
@@ -50,17 +50,40 @@ class RelayAuth(
* A valid token, minting one when the cache is empty or close to expiry.
* Null when this deployment does not issue them.
*/
/** Plain Kotlin module: no android.util.Log here. On device this lands in
* logcat as `W/System.err`, which is what matters — it is READABLE. */
private fun warn(message: String) = System.err.println("fnmsg-auth: $message")
fun token(now: Long = System.currentTimeMillis() / 1000): String? {
if (unavailable) return null
cached?.let { if (it.expiresAt - SLACK_SECONDS > now) return it.token }
val minted = try {
mint(deviceId, Wire.b64(identityKey.publicKey))
} catch (_: RelayException.NotFound) {
} catch (e: RelayException.NotFound) {
// 🔴 **Say it.** Every failure here used to be swallowed in
// silence, and the consequence was invisible: with no token the
// client falls back to Basic, the relay refuses `/e2e/*` — which it
// does by design — and the user sees "the call failed", "could not
// finish setting up", or an inbox that 410s and wipes itself. Four
// hours of debugging produced symptoms and no cause, because the
// one line that knew what went wrong discarded it.
warn("no token: relay has no /account/token route; " +
"falling back to Basic, which /e2e/* refuses. Calls and device " +
"registration will fail until this is fixed." + " [" + e + "]")
unavailable = true
return null
} catch (_: Exception) {
} catch (e: Exception) {
warn("no token: minting failed (${e.javaClass.simpleName}: " +
"${e.message}); falling back to Basic, which /e2e/* refuses.")
return null
} ?: return null
} ?: run {
// ⚠️ A non-2xx that is not 404 arrives here as null, not as a
// throw, so this branch is NOT unreachable — it was the quietest
// path of the three.
warn("no token: /account/token answered a non-success " +
"status; falling back to Basic, which /e2e/* refuses.")
return null
}
cached = Cached(minted.token, now + minted.lifetimeSeconds)
return minted.token
}