pq-ratchet
A post-quantum hybrid double ratchet for Rust.
Extends the [Signal Double Ratchet] with an ML-KEM-768 (NIST FIPS 203) post-quantum layer, following the SCKA epoch model from Signal's 2023 SPQR deployment.
An attacker must break both X25519 and ML-KEM-768 to compromise any session key.
The problem with classical ratchets
The classical Signal Double Ratchet is broken by a sufficiently powerful quantum computer via Shor's algorithm on X25519. Adding a post-quantum KEM is non-trivial because ML-KEM is a one-shot KEM -- it doesn't have the commutativity property of Diffie-Hellman -- so naively inserting it into the ratchet breaks out-of-order delivery.
This crate implements Signal's solution: the SCKA epoch model, where the ML-KEM round-trip is tied to each DH ratchet step. All messages in one epoch use the same PQ base material, so the symmetric chain ratchet handles reordering as usual.
Architecture
Each DHRatchet step performs two hybrid root-KDF invocations:
Receiving chain: KDF_RK(rk, DH(old_dh, their_dh) || decap(their_ct))
Sending chain: KDF_RK(rk', DH(new_dh, their_dh) || encap(their_ek).ss)
Where:
DH(...)= X25519 shared secret (32 bytes)decap(their_ct)= ML-KEM-768 shared secret from decapsulating their ciphertextencap(their_ek).ss= ML-KEM-768 shared secret from encapsulating to their EK
The hybrid input DH || PQ (64 bytes) is fed into HKDF-SHA256 with domain-separated
info strings. The protocol degrades gracefully to classical-only security if no PQ
material is present in a header (first message, or peer without PQ support).
┌──────────────────────────────────────────────────────┐
│ Each DHRatchet step │
│ │
old_rk ──► HKDF-SHA256(DH(old,peer) ║ PQ_recv) ──► ckr, new_rk │
│ │
new_rk ──► HKDF-SHA256(DH(new,peer) ║ PQ_send) ──► cks, new_rk'│
└──────────────────────────────────────────────────────┘
PQ_recv = ML-KEM-768.Decaps(our_dk, header.pq_ct)
PQ_send = ML-KEM-768.Encaps(header.pq_ek).ss
Usage
use HybridRatchet;
use ;
use thread_rng;
let mut rng = thread_rng;
let shared_secret = ; // from PQXDH / X3DH
// Bob's ratchet key (from his prekey bundle)
let bob_dh_sk = random_from_rng;
let bob_dh_pk = from;
// Initialise
let mut alice = init_sender;
let mut bob = init_receiver;
// Alice → Bob
let = alice.ratchet_encrypt.unwrap;
let mk_bob = bob.ratchet_decrypt.unwrap;
assert_eq!;
// Use mk_alice / mk_bob with ChaCha20-Poly1305 or AES-256-GCM
// Authenticate the header as AEAD additional data
Header authentication
Every ratchet message must be authenticated. Pass the header as AEAD additional data.
Header::encode() produces a canonical byte string for this; the peer uses
Header::decode() to reconstruct the header from wire bytes:
let = alice.ratchet_encrypt.unwrap;
let aad = header.encode; // authenticate this alongside the ciphertext
// Receiver:
let header = decode.unwrap;
let mk = bob.ratchet_decrypt.unwrap;
Session persistence
HybridRatchet::to_bytes() serializes the full session state (all key material) to a
Zeroizing<Vec<u8>> using a versioned binary format. from_bytes() restores it:
// Persist
let blob = alice.to_bytes; // Zeroizing<Vec<u8>>
store_encrypted_to_disk; // caller encrypts at rest
// Restore
let blob = load_and_decrypt_from_disk;
let alice = from_bytes.unwrap;
Enable the optional serde feature for Serialize/Deserialize on HybridRatchet:
= { = "0.2", = ["serde"] }
The serde representation is the same binary blob, compatible with any serde format that handles byte sequences (bincode, messagepack, etc.).
Security properties
| Property | Provided by |
|---|---|
| Forward secrecy | X25519 DH ratchet + message-key deletion |
| Post-quantum forward secrecy | ML-KEM-768 per-epoch key exchange |
| Break-in recovery | Both DH and PQ ratchets regenerate keys |
| Out-of-order delivery | Symmetric chain + skipped-key cache |
| Hybrid security | Attacker must break X25519 and ML-KEM-768 |
What this crate does NOT provide:
- Encryption (bring your own AEAD)
- Authentication (authenticate headers as AEAD additional data)
- Initial key agreement (use X3DH or PQXDH)
- A security audit
Crate parameters
| Parameter | Value |
|---|---|
| PQ KEM | ML-KEM-768 (NIST FIPS 203, 128-bit post-quantum security) |
| Classical DH | X25519 |
| Root KDF | HKDF-SHA256, 64-byte hybrid IKM |
| Chain KDF | HKDF-SHA256 with domain-separated labels |
| Skipped-key cache limit | 1,000 entries |
| EK size (in header) | 1,184 bytes |
| CT size (in header) | 1,088 bytes |
Security notice
This crate has not been independently audited. The underlying ml-kem crate
(RustCrypto) also carries this caveat. Do not deploy in production without a
security review.
References
- Signal Double Ratchet Specification
- Signal PQXDH Specification
- Signal SPQR Blog Post
- NIST FIPS 203 -- ML-KEM
- RustCrypto ml-kem
License
GPL-3.0-only -- see LICENSE.