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:
co-authored by
Claude Opus 4.8
parent
0034a57389
commit
1e96af0122
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user