A v2 peer was being sent a post-quantum ratchet step

Proving the v3 ratchet across engines turned up a bug in shipping it, and this
one would have broken every existing Android conversation.

`E2ESession.initiate` turned the v3 ratchet on whenever the peer offered a PQ
prekey. Every v2 device offers one — offering a prekey is a claim to §12, not to
§13. So a build with this change would attach `pqdh`/`pqct` to messages for
0.6, the released build, whose ratchet has no post-quantum anything.

That fails twice over, which is why it would have looked like a network
problem. Their root step derives without `ss` and under the `_v1` info string
while ours used `_v3`, so the keys diverge; and their canonical header omits the
two fields, so the AAD does not match either. The first message never opens and
never would.

Gated on a PROVEN claim now, the way the iOS engine already does it:
`chosen != null && bundle.proto >= 3`. `bundle.proto` on its own is the relay's
word for it, but `chosen` is non-null only when the capability signature over
that same proto verified (§12.7), so the pair is a claim the peer actually made.

The test asserts on the absence of a header field, which is the only way to
catch this — every message still decrypts in a self-test, because both sides of
one engine agree with each other about the mistake.

The interop driver now publishes a real v3 bundle: `pqspk` under
`FlamenetE2E_PQSPK_v2`, and `caps_sig` over `FlamenetE2E_Caps_v2 ‖ uint8(3)`.
Both were needed before the web engine would negotiate anything but v1 — which
is why the harness had been quietly proving v1 and nothing else. It can still
publish a v1 bundle on request, because the fleet still holds devices that speak
it and that coverage must not disappear the day v3 lands.

605 tests, 0 failures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-31 12:55:44 -04:00
co-authored by Claude Opus 4.8
parent 0034a57389
commit 1e96af0122
3 changed files with 125 additions and 10 deletions
@@ -90,17 +90,30 @@ class E2ESession private constructor(
.put("spk_id", bundle.signedPreKeyId)
.put("opk_id", bundle.oneTimePreKeyId)
// 🔴 **Offering a PQ prekey is not a claim to §13.** Every v2 device
// publishes one; only a v3 device can read a PQ ratchet step. Sending
// one to a v2 peer breaks the conversation twice over — their root
// step derives without `ss` under the `_v1` info string while ours
// used `_v3`, and their canonical header omits `pqdh`/`pqct` so the
// AAD does not match either. The first message never opens.
//
// ⚠️ `bundle.proto` alone is the relay's word for it. `chosen` is
// non-null only when the capability signature over THIS proto
// verified (§12.7), so the pair together is a proven claim to v3.
val postQuantumRatchet =
if (chosen != null && bundle.proto >= 3) chosen.publicKey else null
return E2ESession(
// 🔴 `chosen?.publicKey` is the PQ prekey X3DH just encapsulated
// to, and the ratchet has to start from that exact key or the
// responder cannot decapsulate the first step (§13.2).
ratchet = DoubleRatchet.initiator(
initiated.sharedSecret, bundle.signedPreKey, chosen?.publicKey),
initiated.sharedSecret, bundle.signedPreKey, postQuantumRatchet),
associatedData = X3DH.associatedData(
identity.identitySigningPublic, bundle.identitySigning,
initiated.pqCiphertext),
pendingHandshake = handshake,
version = initiated.version,
version = if (postQuantumRatchet != null) 3 else initiated.version,
// Rides the first envelope only. Every later `msg` is
// byte-identical to v1, because the KEM secret is already
// folded into the root key and the associated data binds the
@@ -203,6 +203,34 @@ class HybridSessionTest {
assertEquals("spk", moved.provenPostQuantumPreKey()?.kind)
}
/**
* 🔴 **A v2 peer must not be sent a v3 ratchet step.**
*
* The released fleet advertises `proto: 2`: it does post-quantum key
* AGREEMENT and has no post-quantum ratchet. Attaching `pqdh`/`pqct` to a
* message for one of those devices breaks the conversation twice over —
* their root step derives without `ss` and under the `_v1` info string
* while ours used `_v3`, and their canonical header omits the two fields
* entirely so the AAD does not match either. The first message never opens
* and never will.
*
* ⚠️ Offering a PQ prekey is NOT the same claim as implementing §13. Every
* v2 device offers one. The version has to be what gates this, which is
* what §13.4 says and what the iOS engine already does.
*/
@Test
fun `a peer that only claims v2 is not sent a post-quantum ratchet step`() {
val alice = DeviceIdentity.create()
val bob = DeviceIdentity.create()
// A real v2 bundle: PQ prekeys published, capability signed for 2.
val session = E2ESession.initiate(alice, published(bob))
val envelope = session.encrypt("hello")
assertNull(envelope.header.pqdh,
"a v2 peer was sent a v3 ratchet step it cannot read")
assertNull(envelope.header.pqct,
"a v2 peer was sent a PQ ciphertext it cannot decapsulate")
}
// MARK: - Persistence
/**