A TLS 1.3 session ticket is the server handing a client its own resumption state, sealed under a key only the server holds. Conventionally that key is one long-lived symmetric secret โ a STEK โ shared by every ticket the server issues until it rotates.
This edge does it differently: every ticket carries its own ML-KEM-1024 encapsulation, and the key that seals it is derived from that encapsulation's shared secret. No two tickets share a sealing key.
Before the honest part gets buried: the server holds both halves of the keypair. An attacker who reads server memory recovers tickets either way. What changes is which algorithm protects the material and that each ticket is independently keyed โ not that this is post-quantum forward secrecy. The limits section is not an afterthought.
Why rustls had no ticketer at all
rustls ships no default ticketer. Before this module the proxy issued no tickets,
which meant the pqc_session_tickets setting described something that
did not exist โ a configuration flag for absent behaviour. Building one was the
only way to make the setting true.
That is also why the design was free to be unusual. There was no existing ticket format to stay compatible with; tickets are opaque to clients by construction, so the server can put whatever it likes inside as long as it can open it again.
The construction: KEM-DEM
At startup the server generates one ML-KEM-1024 (FIPS 203) keypair and keeps both halves. Then, for each ticket:
- Encapsulate to its own public key. This yields a 1568-byte ciphertext and a fresh 32-byte shared secret โ new per ticket, never reused.
- Derive an AEAD key. HKDF-SHA384 over that shared secret under a fixed context label, producing an AES-256-GCM key. The KEM output is never used directly as a cipher key.
- Seal the resumption state. AES-256-GCM with a random 96-bit nonce, using the KEM ciphertext as associated data โ so a ticket cannot be re-paired with a different encapsulation.
Wire format
version(1) || ct_len(2) || ct(1568) || nonce(12) || sealed(n + 16)
All lengths big-endian. Which makes the fixed overhead:
| Field | Bytes | Why |
|---|---|---|
| version | 1 | A ticket not starting with this is refused rather than parsed, so a future format cannot be misread as this one. |
| ct_len | 2 | Checked against the FIPS 203 constant before any slicing. |
| ct | 1568 | The ML-KEM-1024 ciphertext. Also the AEAD's associated data. |
| nonce | 12 | Random per ticket, not a counter. |
| GCM tag | 16 | Included in the sealed body. |
| Overhead | 1599 | Against roughly a few dozen bytes for a conventional STEK-sealed ticket. |
That overhead is the honest cost and it is not small. It buys a smaller resumption packet than a full handshake would need regardless, and it is paid once per ticket rather than per connection โ but a deployment counting bytes should know the number before enabling this rather than after.
Rotation, and what actually bounds exposure
rustls's ProducesTickets trait is explicit that ticket lifetime must
be enforced "by key rolling and erasure, not by storing a lifetime in the
ticket". So there is no expiry field in the format above โ a ticket expires because
the key that opens it stopped existing.
A rotation generates a fresh keypair and retains exactly one previous decapsulation key, so a ticket issued moments before a roll still opens. Anything older fails to decapsulate and the client falls back to a full handshake, which is a slower connection and not an error. This edge rolls every 12 hours.
Erasing the old key is what limits the damage a stolen ticket can do. Not the KEM. That distinction is the point of the next section.
What this does not buy
The server holds both the encapsulation and decapsulation keys. It has to โ it is sealing data to itself for later. Which means:
- This is not post-quantum forward secrecy. An attacker who compromises the server and reads memory gets the decapsulation key and opens every ticket that key covers. A symmetric STEK fails exactly the same way.
- The KEM is not protecting against a network attacker. Tickets never travel unprotected; they are inside the TLS record layer already. The KEM protects them at rest in the ticket itself.
- Forward secrecy here is bounded by rotation, not by ML-KEM. Twelve hours of tickets share one keypair. Shortening the lifetime shortens the window; changing the KEM does not.
What genuinely changes: the algorithm protecting ticket key material is a lattice KEM rather than a symmetric secret, and each ticket gets an independent sealing key instead of every ticket in a rotation window sharing one. If you are required to demonstrate that no long-lived symmetric secret protects resumption state, that is a real property. If you were hoping for quantum-resistant forward secrecy on resumption, this is not that, and nothing that keeps state server-side can be.
Parsing hostile input
decrypt() receives fully attacker-controlled bytes. Every length is
validated before it is used to slice: the version byte, the ciphertext length
against the FIPS 203 constant, and the total length against header plus nonce plus
a minimum 16-byte tag โ all with checked arithmetic, so a crafted length cannot
overflow into a valid-looking offset.
Every failure returns the same None. A caller learns "not a valid
ticket" and never which check failed, so the function is not an oracle for
probing ticket structure.
Implementation: pqcrypta-proxy,
src/pqc_tickets.rs. Enabled here with
pqc_session_tickets = true and a 43200-second key lifetime.