A v2 peer was being sent a post-quantum ratchet step, and v3 was never advertised #31

Merged
paulhitt merged 2 commits from 2-v3-cross-engine into main 2026-08-31 17:01:59 +00:00
paulhitt commented 2026-08-31 17:00:43 +00:00 (Migrated from gitlab.com)

Closing the §13 cross-engine gap (paulhitt/flamenet-e2e!2) turned up two problems in the
ratchet I merged this afternoon. One of them would have broken every existing
Android conversation.

The bug: a v2 peer was sent a v3 ratchet step

E2ESession.initiate turned v3 on whenever the peer offered a PQ prekey:

DoubleRatchet.initiator(sharedSecret, bundle.signedPreKey, chosen?.publicKey)

Every v2 device offers one. Offering a prekey is a claim to §12, not §13. So
this build would attach pqdh/pqct to messages for 0.6 — the released
build
— whose ratchet has no post-quantum anything.

It fails twice over, which is why it would have read as a network problem:

root key theirs derives without ss under _v1; ours used _v3
AAD their canonical header omits both fields entirely

The first message never opens and never would.

Now gated on a proven claim, as iOS already does: chosen != null && bundle.proto >= 3.
bundle.proto alone is the relay-s word for it; chosen is non-null only when
the capability signature over that same proto verified (§12.7).

The test asserts on the absence of a header field. Nothing else catches
this — every message still decrypts in a self-test, because both sides of one
engine agree about the mistake.

The ratchet was inert anyway

PROTOCOL_VERSION was 2. iOS advertises 3 and gates on the peer doing the same,
so an iOS↔Android session negotiated v2 and no message ever carried a ratchet
step
, whatever either engine implemented.

Now 3 — moved in the same change as the evidence rather than ahead of it. A
version is a claim other devices act on: peers pin the highest proven version and
the pin is monotonic (§13.4), so advertising 3 without honouring it strands every
contact that believed it.

Tests that had to move

  • a capability signature does not transfer between versions edited a bundle to
    proto = 3 to prove the signature would not follow — a hardcoded neighbour
    that stopped testing anything once the engine published 3. Uses
    PROTOCOL_VERSION + 1.
  • The v2-gating test needs a peer that has genuinely proven 2, which this engine
    no longer produces. copy(proto = 2) is not enough — the capability
    signature commits to the version — so publishedV2 re-signs for it.
  • New: the published bundle claims 3 and proves it. Bumping the constant
    without the signature following produces a bundle every peer silently reads as
    v1, with nothing reporting an error.

Also fixed

the sealed blob reveals nothing was the same coin flip as its iOS twin —
sealing ids 41/42 and searching the base64 for "41"/"42", ~14% chance of
a meaningless failure. It fired during this run. The iOS one was fixed this
morning; this was missed because only one engine was looked at.

Verification

606 tests, 0 failures      (engine 275, client 277, app 54)
:app:assembleDebug         BUILD SUCCESSFUL
cross-engine               56 passed, 0 failed
Closing the §13 cross-engine gap (paulhitt/flamenet-e2e!2) turned up two problems in the ratchet I merged this afternoon. One of them would have broken every existing Android conversation. ### The bug: a v2 peer was sent a v3 ratchet step `E2ESession.initiate` turned v3 on whenever the peer offered a PQ prekey: ```kotlin DoubleRatchet.initiator(sharedSecret, bundle.signedPreKey, chosen?.publicKey) ``` **Every v2 device offers one.** Offering a prekey is a claim to §12, not §13. So this build would attach `pqdh`/`pqct` to messages for **0.6 — the released build** — whose ratchet has no post-quantum anything. It fails twice over, which is why it would have read as a network problem: | | | |---|---| | root key | theirs derives without `ss` under `_v1`; ours used `_v3` | | AAD | their canonical header omits both fields entirely | The first message never opens and never would. Now gated on a **proven** claim, as iOS already does: `chosen != null && bundle.proto >= 3`. `bundle.proto` alone is the relay-s word for it; `chosen` is non-null only when the capability signature over that same proto verified (§12.7). The test asserts on the **absence** of a header field. Nothing else catches this — every message still decrypts in a self-test, because both sides of one engine agree about the mistake. ### The ratchet was inert anyway `PROTOCOL_VERSION` was 2. iOS advertises 3 and gates on the peer doing the same, so an iOS↔Android session negotiated v2 and **no message ever carried a ratchet step**, whatever either engine implemented. Now 3 — moved in the same change as the evidence rather than ahead of it. A version is a claim other devices act on: peers pin the highest proven version and the pin is monotonic (§13.4), so advertising 3 without honouring it strands every contact that believed it. ### Tests that had to move - `a capability signature does not transfer between versions` edited a bundle to `proto = 3` to prove the signature would not follow — a hardcoded neighbour that stopped testing anything once the engine published 3. Uses `PROTOCOL_VERSION + 1`. - The v2-gating test needs a peer that has genuinely proven 2, which this engine no longer produces. `copy(proto = 2)` is **not** enough — the capability signature commits to the version — so `publishedV2` re-signs for it. - New: the published bundle claims 3 **and proves it**. Bumping the constant without the signature following produces a bundle every peer silently reads as v1, with nothing reporting an error. ### Also fixed `the sealed blob reveals nothing` was the same coin flip as its iOS twin — sealing ids `41`/`42` and searching the base64 for `"41"`/`"42"`, ~14% chance of a meaningless failure. **It fired during this run.** The iOS one was fixed this morning; this was missed because only one engine was looked at. ### Verification ``` 606 tests, 0 failures (engine 275, client 277, app 54) :app:assembleDebug BUILD SUCCESSFUL cross-engine 56 passed, 0 failed ```
paulhitt commented 2026-08-31 17:01:06 +00:00 (Migrated from gitlab.com)

mentioned in merge request flamenet-e2e!2

mentioned in merge request flamenet-e2e!2
paulhitt commented 2026-08-31 17:01:59 +00:00 (Migrated from gitlab.com)

mentioned in commit cc51854fdd

mentioned in commit cc51854fdd4b1458005fc83fbfa07e92fb55d9f9
paulhitt (Migrated from gitlab.com) merged commit cc51854fdd into main 2026-08-31 17:01:59 +00:00
paulhitt commented 2026-09-09 19:01:59 +00:00 (Migrated from gitlab.com)

changed the description

changed the description
Sign in to join this conversation.