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

iOS mixed 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 nobody had it —
which is what the comparison page now says, and this is what would let that row
change.

The gap is not academic. v2 already protects a whole session against
harvest-now-decrypt-later, because 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.

Implemented to §13 rather than by reading 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.

🔴 Decapsulation happens BEFORE adopting the peer's new PQ key. They encapsulated
to the key we held going into the step, and 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 sending chain until the peer
answers. Sending it once would mean losing that one message strands the chain:
the receiver never gets the material to advance its root key and nothing later
in the chain can be opened. It is replaced by the next ratchet step, not cleared
when the peer replies — it belongs to the sending chain.

Seven tests. Six drive conversations; the seventh pins the canonical header as
bytes, because the header is the AAD and three engines must produce it
identically. A conversation test cannot catch a reordered field — both sides
would drift together. It also pins that half a PQ step falls back to the v2
shape rather than emitting a malformed header.

A peer with no PQ material still runs v2 and emits no PQ fields (§13.4), and an
archive written by an older build simply has no PQ state and stays on v2.

Closes #2

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-31 11:17:04 -04:00
co-authored by Claude Opus 4.8
parent 9ae7b5473d
commit 98342dd608
3 changed files with 334 additions and 16 deletions
@@ -47,6 +47,202 @@ class DoubleRatchetTest {
return alice to bob
}
/**
* The same pair, running protocol v3 — a fresh ML-KEM secret mixed into
* every ratchet step (§13).
*
* Bob's PQ prekey is the shared starting point: Alice encapsulates to it,
* and Bob holds its seed so he can decapsulate the first step.
*/
private fun pqPair(): Triple<DoubleRatchet, DoubleRatchet, PQKEM.KeyPair> {
val bobSigned = X25519.generate()
val aliceIdentity = X25519.generate()
val bobIdentity = X25519.generate()
val bobPQ = PQKEM.generate()
val initiated = X3DH.initiate(
aliceIdentity.private,
X3DH.Bundle(
identityKeyDH = X25519.rawFromPublicKey(bobIdentity.public),
signedPreKey = X25519.rawFromPublicKey(bobSigned.public),
oneTimePreKey = null,
),
bobPQ = bobPQ.publicKey,
)
val bobSecret = X3DH.respond(
identityPrivateDH = bobIdentity.private,
signedPreKeyPrivate = bobSigned.private,
oneTimePrivate = null,
initiatorIdentityDH = X25519.rawFromPublicKey(aliceIdentity.public),
initiatorEphemeral = initiated.ephemeralPublic,
pq = bobPQ.seed to initiated.pqCiphertext!!,
)
assertContentEquals(initiated.sharedSecret, bobSecret, "PQ X3DH disagreed")
val alice = DoubleRatchet.initiator(
initiated.sharedSecret, X25519.rawFromPublicKey(bobSigned.public), bobPQ.publicKey)
val bob = DoubleRatchet.responder(bobSecret, bobSigned, bobPQ.seed)
return Triple(alice, bob, bobPQ)
}
// MARK: - Protocol v3, the post-quantum ratchet (§13)
/**
* 🔴 **The cross-engine contract, pinned as bytes.**
*
* The header goes into the AAD, so three implementations must produce
* byte-identical JSON or nothing opens across engines. Key order is
* `dh, pn, n, pqdh, pqct`, there are no spaces, and absent PQ fields are
* omitted ENTIRELY rather than written as null (§13.3).
*
* This is the assertion that would catch a JSON library being introduced
* here, or a field being reordered — neither of which any conversation
* test can see, because both sides would drift together.
*/
@Test
fun `the canonical header is exactly the bytes the spec names`() {
val dh = ByteArray(32) { it.toByte() }
val pqdh = ByteArray(8) { (it + 100).toByte() }
val pqct = ByteArray(8) { (it + 200).toByte() }
assertEquals(
"{\"dh\":\"" + Bytes.b64(dh) + "\",\"pn\":3,\"n\":7}",
String(DoubleRatchet.Header(dh, 3, 7).canonicalJson(), Charsets.UTF_8),
"the v2 header changed shape",
)
assertEquals(
"{\"dh\":\"" + Bytes.b64(dh) + "\",\"pn\":3,\"n\":7" +
",\"pqdh\":\"" + Bytes.b64(pqdh) + "\",\"pqct\":\"" + Bytes.b64(pqct) + "\"}",
String(DoubleRatchet.Header(dh, 3, 7, pqdh, pqct).canonicalJson(), Charsets.UTF_8),
"the v3 header changed shape",
)
// ⚠️ Half a PQ step is not a PQ step. One field without the other must
// fall back to the v2 shape rather than emit a malformed header.
assertEquals(
"{\"dh\":\"" + Bytes.b64(dh) + "\",\"pn\":0,\"n\":0}",
String(DoubleRatchet.Header(dh, 0, 0, pqdh, null).canonicalJson(), Charsets.UTF_8),
"a half-populated PQ step leaked into the header",
)
}
@Test
fun `a v3 conversation going both ways stays in step`() {
val (alice, bob, _) = pqPair()
// Four direction changes: each one turns the DH ratchet AND the PQ
// ratchet in lockstep, so an ordering mistake in either compounds.
assertContentEquals("a1".toByteArray(), bob.decrypt(alice.encrypt("a1".toByteArray(), AD), AD))
assertContentEquals("b1".toByteArray(), alice.decrypt(bob.encrypt("b1".toByteArray(), AD), AD))
assertContentEquals("a2".toByteArray(), bob.decrypt(alice.encrypt("a2".toByteArray(), AD), AD))
assertContentEquals("b2".toByteArray(), alice.decrypt(bob.encrypt("b2".toByteArray(), AD), AD))
}
/**
* 🔴 **The property v3 exists for.** v2 already protects a whole session
* against harvest-now-decrypt-later, because the root chain never recovers
* once the 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. So every step must carry NEW ML-KEM
* material, not merely the handshake's.
*/
@Test
fun `every ratchet step carries a fresh post-quantum key`() {
val (alice, bob, bobPQ) = pqPair()
val first = alice.encrypt("a1".toByteArray(), AD)
assertTrue(first.header.pqdh != null && first.header.pqct != null,
"the first sending chain carried no PQ step at all")
// Encapsulated to Bob's PREKEY, which is what he can decapsulate with.
val seen = mutableListOf(Bytes.b64(first.header.pqdh!!))
bob.decrypt(first, AD)
// Bob answers: a ratchet step, so a PQ key of his own.
val reply = bob.encrypt("b1".toByteArray(), AD)
assertTrue(reply.header.pqdh != null, "Bob's ratchet step carried no PQ key")
seen += Bytes.b64(reply.header.pqdh!!)
alice.decrypt(reply, AD)
val third = alice.encrypt("a2".toByteArray(), AD)
seen += Bytes.b64(third.header.pqdh!!)
bob.decrypt(third, AD)
assertEquals(seen.size, seen.toSet().size,
"a PQ key was reused across ratchet steps: $seen")
assertTrue(seen.none { it == Bytes.b64(bobPQ.publicKey) },
"a ratchet step reused the handshake prekey instead of a fresh key")
}
/**
* ⚠️ **Repeated on every message of a chain, not just the first (§13.3).**
* Sending it once would mean losing that one message strands the entire
* chain: the receiver never gets the material to advance its root key, and
* nothing later in the chain can ever be decrypted.
*/
@Test
fun `the post-quantum step repeats until the peer answers`() {
val (alice, bob, _) = pqPair()
val first = alice.encrypt("a1".toByteArray(), AD)
val second = alice.encrypt("a2".toByteArray(), AD)
val third = alice.encrypt("a3".toByteArray(), AD)
for ((i, m) in listOf(first, second, third).withIndex()) {
assertTrue(m.header.pqct != null, "message $i of the chain dropped the PQ step")
}
assertContentEquals(first.header.pqdh, third.header.pqdh,
"the PQ key changed inside one sending chain")
// 🔴 And the whole chain still opens if the FIRST message is the one
// that goes missing — which is the loss the repetition exists for.
assertContentEquals("a2".toByteArray(), bob.decrypt(second, AD))
assertContentEquals("a3".toByteArray(), bob.decrypt(third, AD))
}
/**
* A stripped PQ step must not silently downgrade to a v1 root step. The
* header is bound into the AAD, so removing it fails the open — which is
* the difference between a downgrade attack and a broken message.
*/
@Test
fun `a stripped post-quantum step does not open`() {
val (alice, bob, _) = pqPair()
val message = alice.encrypt("a1".toByteArray(), AD)
val stripped = DoubleRatchet.Message(
DoubleRatchet.Header(message.header.dh, message.header.pn, message.header.n),
message.ciphertext,
)
assertFailsWith<Exception> { bob.decrypt(stripped, AD) }
}
/**
* ⚠️ A v3 session that lost its PQ state on a restart would keep working
* and silently fall back to X25519-only healing — nothing fails, and the
* property is simply gone.
*/
@Test
fun `a v3 session survives being archived`() {
val (alice, bob, _) = pqPair()
bob.decrypt(alice.encrypt("a1".toByteArray(), AD), AD)
val restoredBob = DoubleRatchet.fromArchive(bob.archive())
val restoredAlice = DoubleRatchet.fromArchive(alice.archive())
val reply = restoredBob.encrypt("b1".toByteArray(), AD)
assertTrue(reply.header.pqdh != null, "the restored session lost its PQ step")
assertContentEquals("b1".toByteArray(), restoredAlice.decrypt(reply, AD))
}
/**
* 🔴 A v3 device must still speak v2 to a peer that does not advertise it
* (§13.4), and the header then carries no PQ fields at all.
*/
@Test
fun `a session without post-quantum material still runs v2`() {
val (alice, bob) = pair()
val message = alice.encrypt("hello".toByteArray(), AD)
assertTrue(message.header.pqdh == null && message.header.pqct == null,
"a v2 session emitted PQ header fields")
assertContentEquals("hello".toByteArray(), bob.decrypt(message, AD))
}
@Test
fun `a message survives the trip`() {
val (alice, bob) = pair()