QS3 — Forward-Secret Post-Quantum Ratchet
QS3 is a KEM double ratchet: a session protocol that gives every message its own single-use key, heals itself after a device compromise, and stays hybrid (classical + post-quantum) end to end. Only standardized primitives are used — the novelty is the composition, not the math. Try the live demo →
Why QS3 exists
The QS2 envelope encrypts each item to a recipient's long-termkey — right for storage at rest, but sharp-edged for conversations: if that one private key ever leaks, everything ever encrypted to it becomes readable, including traffic an adversary recorded years earlier (“harvest now, decrypt later”). QS3 removes the single point of failure:
| Scenario | QS2 envelope | QS3 ratchet |
|---|---|---|
| Long-term key leaks | All past + future traffic exposed | Past traffic safe; heals going forward |
| Session state stolen | — | Delivered messages stay sealed (forward secrecy) |
| Thief keeps listening | — | Locked out after one round trip (healing) |
| One message key leaks | Whole item | Exactly one message |
Primitives
| Role | Primitive | Standard |
|---|---|---|
| Handshake KEM (post-quantum) | ML-KEM-768 | NIST FIPS 203 |
| Handshake KEM (classical) | X25519 | RFC 7748 |
| Ratchet KEM (per turn) | ML-KEM-768 | NIST FIPS 203 |
| Key derivation | HKDF-SHA-256 | RFC 5869 |
| AEAD (header as AAD) | AES-256-GCM | FIPS 197 / SP 800-38D |
| Packet signatures (optional) | ML-DSA-65 | NIST FIPS 204 |
1 · Handshake
The initiator knows the responder's long-term QS2 hybrid public key (from the QVault directory) and performs the same dual encapsulation QS2 uses. The transcript is mixed into the KDF, binding the session to this exact exchange:
ss_pq , kem_ct = ML-KEM-768.Encaps(bob.kemPub)
eph = X25519.KeyGen()
ss_ec = X25519(eph.priv, bob.xPub)
RK₀ ‖ CK_A→B = HKDF-SHA-256(
ikm = ss_pq ‖ ss_ec ‖ kem_ct ‖ eph.pub ‖ bob.xPub,
info = "qs3-init-v1", out = 64 bytes)RK₀is the root key; the other half is the initiator's first send chain. The INIT packet carries the initiator's first ratchet public key and already contains the first encrypted message.
2 · The ratchet turn
A turnstarts whenever a party sends after having received. The sender generates a fresh ML-KEM-768 key pair, encapsulates to the peer's newest ratchet key, and evolves the root key. This fresh entropy every turn is what heals a compromise:
RK' ‖ CK_send = HKDF-SHA-256(salt = RK, ikm = ss, info = "qs3-ratchet-v1", out = 64)
3 · Message keys
Within a chain, keys advance one-way per message and are erased after use. Current state cannot re-derive past keys — that is the forward-secrecy mechanism:
MK = HKDF-SHA-256(CK, info = "qs3-mk-v1", 32) ← encrypts exactly one message CK' = HKDF-SHA-256(CK, info = "qs3-ck-v1", 32) ← replaces CK; old CK erased
Headers carry (msgNum, prevChainLen), so out-of-order messages are handled by banking skipped keys (bounded at 512, single-use — replay of a consumed packet is rejected). Packets may be signed with ML-DSA-65; signatures give explicit sender authentication at the cost of deniability.
Healing timeline
Alice Bob Thief (stole Bob's state at T) T ●───state snapshot──────●═══════════════ has RK, chain keys, ratchet priv T+1 A → B same chain continues readable — window still open T+2 B → A turn: Bob mints a NEW key pair new private key never leaves Bob T+3 A → B turn: Alice encapsulates to Bob's new key thief lacks it → blind T+3…∞ every later packet unreadable ✔
Forward secrecy is the mirror image: nothing sent before the theft can be recovered from the stolen state, because those message keys were already deleted and chains cannot run backwards.
Packet formats
magic = 51 53 03 ('Q' 'S' 0x03) lengths are big-endian uint32
INIT [3B magic][1B type=0x01][1B flags]
[4B kemCtLen][kemCt(1088)] [32B eph X25519 pub]
[4B ratchetPubLen][ratchetPub(1184)]
[12B iv][4B ctLen][ct+tag] [4B sigLen][sig]
MSG [3B magic][1B type=0x02][1B flags]
[4B ratchetPubLen][ratchetPub(1184)]
[4B kemCtLen][kemCt(1088 on a turn, 0 within a turn)]
[4B prevChainLen][4B msgNum]
[12B iv][4B ctLen][ct+tag] [4B sigLen][sig]The AAD is every byte before iv — a tampered header fails AEAD even though the header is not encrypted. The signature covers every byte before the signature block.
API
import { Qs3Session, hybridGenerateKeypair } from '@quantashield/pqc-core'
// Bob publishes a long-term QS2 hybrid key (e.g. via the QVault directory)
const bobIdentity = await hybridGenerateKeypair()
// Alice starts a session — the first message rides inside the INIT packet
const { session: alice, packet } = await Qs3Session.initiate(
bobIdentity.publicKey,
new TextEncoder().encode('hello bob'),
{ signingPrivateKey: aliceDsa.privateKey, peerSigningPublicKey: bobDsa.publicKey },
)
// Bob accepts
const { session: bob, result } = await Qs3Session.accept(
packet, bobIdentity.privateKey,
{ signingPrivateKey: bobDsa.privateKey, peerSigningPublicKey: aliceDsa.publicKey },
)
// Converse — the ratchet is automatic
const reply = await bob.encrypt(new TextEncoder().encode('hi alice'))
const { plaintext, signatureValid } = await alice.decrypt(reply)
// Persist between launches (state contains secrets — encrypt at rest)
const blob = alice.export()
const restored = Qs3Session.import(blob, { peerSigningPublicKey: bobDsa.publicKey })Failure semantics: decrypt() is transactional — a packet that fails signature verification, AEAD, replay, or parsing throws and leaves the session state untouched (no ratchet poisoning).