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:
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).
§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 main2026-08-31 15:18:48 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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/PQpendingon the session,KDF_RK_v3overdh_out || sswithits 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:
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
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.
mentioned in commit
16ebf04d4cchanged the description