Carry post-quantum through every ratchet step, not just the handshake #29

Merged
paulhitt merged 1 commits from 2-pq-ratchet into main 2026-08-31 15:18:48 +00:00
paulhitt commented 2026-08-31 15:17:22 +00:00 (Migrated from gitlab.com)

Closes #2

iOS mixes a fresh ML-KEM secret into every ratchet step; Android used it only in
X3DH. A conversation gets the property only if both ends do, so no
conversation had it.

Why this is not cosmetic

v2 already protects a whole session against harvest-now-decrypt-later — the root
chain never recovers once an adversary has missed the start. What v2 lacks is
post-quantum healing: after a compromise, recovery rests entirely on X25519
steps, which a quantum adversary breaks. The session never heals against that
attacker. Signal moved past PQXDH for the same reason.

Built to the spec, not to the Swift

PQs / PQr / PQpending on the session, KDF_RK_v3 over dh_out || ss with
its own info string, and the two optional header fields (§13.2, §13.3).

Decapsulate before adopting their new PQ key. They encapsulated to the key
we held going into the step. Reversing those two derives a different root key
with no error until an AEAD open fails somewhere later.

The PQ block repeats on every message of a chain until the peer answers.
Sending it once means losing that one message strands the chain — the receiver
never gets the material to advance its root key. It is replaced by the next
ratchet step, and is not cleared when the peer replies; it belongs to the
sending chain.

Tests

Seven. Six drive conversations. The seventh pins the canonical header as
bytes
:

{"dh":"…","pn":3,"n":7}
{"dh":"…","pn":3,"n":7,"pqdh":"…","pqct":"…"}

The header is the AAD, so three engines must produce it identically. A
conversation test cannot catch a reordered field — both sides would drift
together. It also pins that a half-populated PQ step falls back to the v2 shape
rather than emitting a malformed header.

Also covered: a fresh PQ key per step (never the handshake prekey reused), the
chain surviving loss of its first message, a stripped PQ step failing to open, a
v3 session surviving archive/restore, and a peer with no PQ material still
running v2 with no PQ fields (§13.4).

Verification

:engine:test :client:test :app:testDebugUnitTest   BUILD SUCCESSFUL
DoubleRatchetTest  15/15 PASSED

Follow-on

§15 of the spec still records Android §13 as — paulhitt/flamenet-e2e#1 covers
that file, and the row should change only once a cross-engine run against the
real iOS engine has actually happened, per §10.

Closes #2 iOS mixes a fresh ML-KEM secret into every ratchet step; Android used it only in X3DH. A conversation gets the property only if **both** ends do, so no conversation had it. ### Why this is not cosmetic v2 already protects a whole session against harvest-now-decrypt-later — the root chain never recovers once an adversary has missed the start. What v2 lacks is post-quantum **healing**: after a compromise, recovery rests entirely on X25519 steps, which a quantum adversary breaks. The session never heals against that attacker. Signal moved past PQXDH for the same reason. ### Built to the spec, not to the Swift `PQs` / `PQr` / `PQpending` on the session, `KDF_RK_v3` over `dh_out || ss` with its own info string, and the two optional header fields (§13.2, §13.3). **Decapsulate before adopting their new PQ key.** They encapsulated to the key we held *going into* the step. Reversing those two derives a different root key with **no error** until an AEAD open fails somewhere later. **The PQ block repeats on every message of a chain until the peer answers.** Sending it once means losing that one message strands the chain — the receiver never gets the material to advance its root key. It is replaced by the next ratchet step, and is *not* cleared when the peer replies; it belongs to the sending chain. ### Tests Seven. Six drive conversations. The seventh pins the canonical header **as bytes**: ``` {"dh":"…","pn":3,"n":7} {"dh":"…","pn":3,"n":7,"pqdh":"…","pqct":"…"} ``` The header is the AAD, so three engines must produce it identically. A conversation test cannot catch a reordered field — both sides would drift together. It also pins that a half-populated PQ step falls back to the v2 shape rather than emitting a malformed header. Also covered: a fresh PQ key per step (never the handshake prekey reused), the chain surviving loss of its first message, a stripped PQ step failing to open, a v3 session surviving archive/restore, and a peer with no PQ material still running v2 with no PQ fields (§13.4). ### Verification ``` :engine:test :client:test :app:testDebugUnitTest BUILD SUCCESSFUL DoubleRatchetTest 15/15 PASSED ``` ### Follow-on §15 of the spec still records Android §13 as — paulhitt/flamenet-e2e#1 covers that file, and the row should change only once a cross-engine run against the real iOS engine has actually happened, per §10.
paulhitt (Migrated from gitlab.com) merged commit 16ebf04d4c into main 2026-08-31 15:18:48 +00:00
paulhitt commented 2026-08-31 15:18:51 +00:00 (Migrated from gitlab.com)

mentioned in commit 16ebf04d4c

mentioned in commit 16ebf04d4cd5b87131d25048e80254fd768545af
paulhitt commented 2026-09-09 19:02:01 +00:00 (Migrated from gitlab.com)

changed the description

changed the description
Sign in to join this conversation.