Docs / QS3 Protocol

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:

ScenarioQS2 envelopeQS3 ratchet
Long-term key leaksAll past + future traffic exposedPast traffic safe; heals going forward
Session state stolenDelivered messages stay sealed (forward secrecy)
Thief keeps listeningLocked out after one round trip (healing)
One message key leaksWhole itemExactly one message

Primitives

RolePrimitiveStandard
Handshake KEM (post-quantum)ML-KEM-768NIST FIPS 203
Handshake KEM (classical)X25519RFC 7748
Ratchet KEM (per turn)ML-KEM-768NIST FIPS 203
Key derivationHKDF-SHA-256RFC 5869
AEAD (header as AAD)AES-256-GCMFIPS 197 / SP 800-38D
Packet signatures (optional)ML-DSA-65NIST 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).

Threat model & limitations

Metadata is visible
Packet sizes, timing, and the sender/recipient graph are not hidden. QS3 protects content, not traffic analysis.
Serialized state is sensitive
export() output contains the root key, chain keys, and ratchet private key. Store it encrypted at rest, exactly like long-term identities.
Healing needs a round trip
Between a state theft and the victim’s next send, the thief can read the current chain. The live demo makes this window visible.
Bounded skipped-key cache
Messages delayed past 512 positions become undecryptable — a deliberate trade against memory-exhaustion attacks.
Signatures vs deniability
ML-DSA-65 signatures give strong sender authentication but forfeit deniability. Run unsigned for deniable messaging.
Not formally verified
QS3 follows the well-studied double-ratchet shape with KEM substitution (the direction taken by Signal’s PQ work and Apple iMessage PQ3), and its test suite attacks the claimed properties directly — but it has not undergone independent cryptographic review.
See it run
A real Alice↔Bob ratchet in your browser, with a state-theft experiment.
Open the QS3 Lab →