PQ CRYPTA PLATFORM

๐Ÿ  Main

๐Ÿงช Interactive Apps

๐Ÿ“ฐ News

๐Ÿ›ก๏ธ PQ Crypta Proxy

๐Ÿ‘ค Account

โŸจ QUANTUM ERROR PORTAL โŸฉ

Navigate the Error Dimensions

PQ Crypta Logo

A Session Ticket Per Encapsulation

ML-KEM-1024 sealing TLS 1.3 resumption state — construction, wire format, and limits

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:

  1. Encapsulate to its own public key. This yields a 1568-byte ciphertext and a fresh 32-byte shared secret โ€” new per ticket, never reused.
  2. 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.
  3. 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.
Ticket sealing: encapsulate to the server's own ML-KEM public key, derive an AES key with HKDF, seal the resumption state with the KEM ciphertext as associated data ML-KEM-1024 public key encapsulate() per ticket shared secret (32 B) ciphertext (1568 B) HKDF-SHA384 context label AES-256-GCM seal AAD = KEM ciphertext ยท random 96-bit nonce as AAD resumption state plaintext in
Every arrow on the left is per-ticket. Only the keypair itself is shared across tickets.

Wire format

version(1) || ct_len(2) || ct(1568) || nonce(12) || sealed(n + 16)

All lengths big-endian. Which makes the fixed overhead:

FieldBytesWhy
version1A ticket not starting with this is refused rather than parsed, so a future format cannot be misread as this one.
ct_len2Checked against the FIPS 203 constant before any slicing.
ct1568The ML-KEM-1024 ciphertext. Also the AEAD's associated data.
nonce12Random per ticket, not a counter.
GCM tag16Included in the sealed body.
Overhead1599Against 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:

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.