TLS 1.3 session resumption for packages/tls: the design
Status: built (#286), its results in §11. Designed as below, then built; what building it found is §11, and the sections it corrected say so in place. Pools of tickets (§12) answer §9’s question 2 after hooks measured a burst.
The design, as first written: #210 measured the pure client’s full handshake at about 2.8 ms of CPU in
cancho-hooks, 4 to 7 times OpenSSL’s, with no resumption (docs/tls-hooks.md§9).docs/tls-pure.md§7.2 left resumption out “until it is designed with its hazard: a resumed session skips verification”. This is that design. Its claims are measured on the machine named, or say they are arithmetic from measured parts; where a later PR finds one false, that PR corrects it here, in place.
1. Where the 2.8 ms goes
Each operation measured alone, on the machine of #210’s handshake measurement (a 6-vCPU Linux VM on an Apple M4 Max, LLVM backend,
cancho 7c1bd08), with the existing benchmarks:
| Operation | Command | Cost | In a full handshake | Total |
|---|---|---|---|---|
| X25519 | scripts/curve25519_bench.py (200 rounds) | 0.569 ms | twice: the client’s share, then the shared secret | 1.14 ms |
| ECDSA P-256 verification | scripts/ecdsa_bench.py | 0.817 ms | twice: the CA’s signature on the leaf, and CertificateVerify | 1.63 ms |
| sum | 2.77 ms |
hooks’ https_cost.py measured a full handshake at 2.9 ms per delivery with a floor of 0.03 to 0.12 ms without TLS
(cancho-hooks docs/pure-tls.md). So these four operations are the handshake: everything else (hashing, the key schedule,
the AEAD, parsing) is a few percent. That decides what resumption can save. A certificate chain with an intermediate adds one
verification per certificate; an RSA chain costs differently and was not measured.
2. The options, and what each saves
The savings are arithmetic from §1, not measured end to end. §8 makes measuring them the build’s gate.
| Option | What it removes | Handshake after it | Forward secrecy | Size of the change |
|---|---|---|---|---|
A. PSK with (EC)DHE (psk_dhe_ke, RFC 8446 §4.2.9) | both ECDSA verifications: no Certificate, no CertificateVerify | about 1.2 ms (2.3 times cheaper) | kept: a fresh X25519 exchange | the protocol change below |
B. PSK only (psk_ke) | all four operations | about 0.1 ms | lost for resumed connections: the PSK alone protects them | the same, minus the key share |
| C. Cache a verified chain | the CA signature on a leaf verified before (same bytes, same trust store) | about 2.0 ms | kept | small, no protocol change |
| D. Keep the connection (in hooks) | the whole handshake, for every delivery after the first on that connection | 0.01 ms a request (docs/tls-nonblocking.md §8.2) | kept | a change to hooks’ attempt table, deadlines and retry accounting, not to TLS |
The recommendation is A. It keeps forward secrecy, which matters for webhooks: a payload is the customer’s data, and a
ticket stolen later should not decrypt it. B saves more
and is refused: a client that offers only psk_ke gives up forward secrecy for every resumed connection, and the extra 1.1 ms is
not worth that. C is compatible with A and helps the connections A cannot (the first, and servers that issue no ticket), so it is
§9’s question 3. D is the larger saving and belongs to hooks (docs/tls-nonblocking.md §10.4 already says “resumption first”).
What A gives hooks, by the same arithmetic: about 1.2 ms a resumed delivery, about 800 https deliveries a second a core,
against about 340 now and 4,000 to 5,000 for OpenSSL resumed on the same machine. OpenSSL stays faster (its X25519 took 0.038 ms
against this client’s 1.08 ms on the Xeon of docs/x25519.md §6), and resumption does not change that.
3. The hazard, and the rules that bound it
A resumed connection skips certificate verification. The server proves it holds the PSK instead, and the PSK came from a connection that was verified. So a ticket is a stored verdict, and every way that verdict could go stale is a rule:
- Bound to the name. A ticket is offered only for the exact host name it was received for (the SNI and the name checked
against the certificate). Never for another name, whatever the address. (
docs/tls-nonblocking.md§10.4 said this for OpenSSL.) - Bound to the trust store. The engine counts its
trustcalls. A ticket records the count when it was stored, and is not offered after the trust store changes: a root removed must not stay trusted through a ticket. - Bound to the certificate’s life. A ticket records the leaf’s
notAfter, and is not offered after it, even if the server’sticket_lifetimesays it may be. A full handshake would refuse an expired leaf, and a resumption must not accept one. - Bounded in time. The shortest of the server’s
ticket_lifetime, RFC 8446’s 7-day ceiling (§4.6.1), and the caller’s own limit (§4,max_age_s), which defaults to 1 hour. As built, the caller’s limit is counted from the full handshake that verified the server, not from the ticket: a resumed connection inherits that time, so a chain of resumptions, each issuing a new ticket, never outlives the verification it started from (§11). Why 1 hour: a certificate revoked is still trusted by every ticket issued before; there is no revocation checking at all (docs/tls-pure.md), and the window should be short. - Used once. A ticket is removed when it is offered. RFC 8446 Appendix C.4 says clients SHOULD NOT reuse a ticket, so a passive observer cannot link connections. A server that sends several tickets lets the client keep a few (§4).
- No early data. 0-RTT is not offered: its data can be replayed (RFC 8446 §8), and hooks’ requests are not idempotent on the receiver’s side.
- TLS 1.3 only. TLS 1.2 resumption (session ids, RFC 5077 tickets) is not offered. A TLS 1.2 connection is always a full handshake.
- Nothing about it is trusted from the server. The ServerHello’s
pre_shared_keymust name the one identity offered (index 0), the suite must hash with the PSK’s hash,psk_dhe_kemeans akey_sharemust be there, and a server that does not resume is a full handshake with the full verification, never a failure. - Bound to the client identity. Since #386 (
docs/tls-parity.md§6.5): a ticket records the engine’s trust generation, which adding, replacing or removing a client identity raises astrustdoes, so a ticket saved before the identity changed is not offered after. A resumed connection sends no certificate; the server carried the client’s identity in the ticket. - Bound to the client certificate’s life. Since #386: the ticket’s
notAfter(rule 3) is the earlier of the server leaf’s and the client leaf’s, when a client certificate was sent. - Not bound to the ALPN offer. Since #386 (
docs/tls-parity.md§6.6): RFC 8446 ties a ticket to a protocol only for early data, which is not sent; a resumed handshake carries ALPN again and the client checks that choice against this connection’s offer.
4. The interface
hooks’ OpenSSL module already has the shape (save_session, open(..., session), free_session, one integer a session). The
engine keeps the secrets, so the caller holds a handle, never a PSK:
tls.save(engine, slot) -> handle | 0 // after a connection: keep its newest ticket (0: none, or not resumable)
tls.start_with(engine, slot, host, now_unix_ms, handle) -> 0 | refusal // offer the ticket if every rule of §3 allows; else a full handshake
tls.forget(engine, handle) // the endpoint changed, or the caller is done with it; overwrites the secret
tls.resumed(engine, slot) -> bool // known once established
tls.set_ticket_max_age(engine, seconds) // §3 rule 4; default 3,600
As built (§11), two more: tls.set_resumption(engine, on), without which no ClientHello says the client can resume, and
tls.open_with_tickets(heap, slots, tickets), for a table of a size other than slots.
- Storage is the engine’s, bounded: a ticket table of
slotsentries (64 for hooks), each up to 2,048 bytes of ticket plus the PSK and §3’s fields. A ticket over 2,048 bytes is not stored (the connection is fine; it is not resumable), so a server cannot make the engine grow. That is about 140 KiB for 64, beside the 11.2 MiB of slots (docs/tls-pure.md§7.4). The 2,048 is a guess at what real servers send; the build measures what each server of §5’s matrix sends and corrects it here. - A handle is a generation-tagged index, so a handle that was forgotten, or whose entry was reused, is refused (
0), never someone else’s ticket. Changed by §12: a handle names a pool, a number the engine issues and never reuses, so a forgotten or spent handle still names nothing of anyone else’s. startis unchanged.start_withwith handle 0 isstart.
5. The protocol work in packages/tls
- NewSessionTicket is kept, not dropped (
message.choparses it and drops it today):ticket_lifetime,ticket_age_add,ticket_nonce, the ticket, and anearly_dataextension, which is ignored. - The resumption secret (RFC 8446 §7.1:
resumption_master_secret, from the master secret and the transcript through the client’s Finished), and each ticket’s PSK,HKDF-Expand-Label(resumption_master_secret, "resumption", ticket_nonce, Hash.length). - The ClientHello, when resuming:
psk_key_exchange_modeswithpsk_dhe_keonly; akey_shareas now;pre_shared_keylast, with one identity, itsobfuscated_ticket_age(ticket_age_addadded, mod 2^32), and the binder: an HMAC over the truncated ClientHello with the binder key (RFC 8446 §4.2.11.2). - The server’s choice: a ServerHello with
pre_shared_keyselecting 0 goes straight to EncryptedExtensions and Finished, no Certificate or CertificateVerify, with the PSK in the key schedule (early_secretfrom the PSK, not zeros). Without it, the full handshake, verified. - HelloRetryRequest with a PSK: the second ClientHello recomputes the binder over the new transcript (§4.2.11.2), and the
ticket age is recomputed. Corrected (review finding E-6, #209): the age is not recomputed. The second ClientHello sends the
obfuscated_ticket_agecomputed atstart_pskagain (client.cho,send_client_hello), since the engine is given no clock between the two flights. So it is understated by the HelloRetryRequest’s round trip, within the tolerance RFC 8446 §4.2.11.1 leaves to the server, as the first connection’s duration overstates it (§11). Only the binder is recomputed.
Refusals, each with its tag (CLAUDE.md), all tls- and all a failed connection:
- the ServerHello selects an identity other than 0 (
tls-illegal-psk, a new tag); pre_shared_keyin a ServerHello with a suite whose hash is not the PSK’s (tls-illegal-psk);pre_shared_keyin a ServerHello to a ClientHello that offered none (tls-unsupported-extension, alert 110, as RFC 8446 §4.2 says for any extension the client did not send. Corrected: this saidtls-illegal-psk, and the code sentillegal_parameter, until the OpenSSL differential showed OpenSSL sending the RFC’s alert);- a resumed ServerHello without
key_share(tls-key-share:psk_kewas not offered); - a Certificate or CertificateRequest after a resumed ServerHello (
tls-unexpected-message); - a NewSessionTicket that does not parse is still
tls-decode-error, as now.
6. In cancho-hooks
tlsx maps its save_session, free_session and open(..., session) onto §4, as src/tls.cho maps them onto OpenSSL’s. The
generated build changes in one place (open’s session argument is kept, not removed), and tests/sessions_test.py runs on both
builds. docs/pure-tls.md’s “differences” row for resumption goes, and the cost table gains a measured “resumed” row.
7. How it is tested
- The key schedule against RFC 8448 §3 and §4. §3’s simple handshake issues a ticket, and §4 resumes with it. Both use
TLS_AES_128_GCM_SHA256, which the client has since #207 (docs/tls-core.md§6.1 could not use them before for that reason). The resumption master secret, the PSK, the binder key and the binder are checked byte for byte. §4 is a 0-RTT resumption, and this client sends no early data, so its traffic is not replayed; its values are what is checked. - Interop: resumption against every server of
docs/tls-assurance.md§5 that issues TLS 1.3 tickets: the row resumes, with no Certificate on the wire, and fetches the body. - Differential: the same resumption by
openssl s_client -sess_out/-sess_inand by this client against the same server, and §3’s rules againstscripts/tls_liar.py: a liar that selects identity 1, omitskey_share, changes the suite’s hash, sends a Certificate after resuming, sends a 64 KiB ticket, a ticket with a lifetime over 7 days. - The rules, each a test that fails if broken: a ticket never offered to another name, after a
trustcall, after the leaf’snotAfter, aftermax_age_s, or twice. - Fuzzing:
fuzz_flight(docs/tls-assurance.md§3) gains NewSessionTicket bodies, and a new harness resumes from a fixed ticket into a fuzzed server flight. - Mutants: at least 12 killed, every survivor argued.
- The timing test is unchanged: no new secret-dependent arithmetic, but the binder’s HMAC compares nothing (the server checks it, not this client).
8. Gates
The build PR shows, each with its command:
- §7’s tests, on both backends;
- a resumed handshake’s CPU, measured, beside §2’s arithmetic (about 1.2 ms), with the machine;
- hooks:
sessions_test.pypassing on both builds,https_both.pywith the resumption difference gone, and the cost table’s resumed row; docs/tls-pure.md§7.2 corrected (resumption is no longer left out), and the ticket table’s memory in §7.4.
9. Open questions, for a person
max_age_s’s default. Proposed: 1 hour (§3 rule 4), because there is no revocation checking. A day would resume more often for an endpoint delivered to rarely.- Several tickets per server. Proposed: keep only the newest. RFC 8446 Appendix C.4’s single use needs a new ticket per connection, and servers send one or two after each handshake, so the newest is always fresh for the next connection. Corrected, and decided: pools (§12). The premise holds for one connection at a time, not for several at once: hooks runs up to 8 deliveries to one endpoint together, they all start from the one saved ticket, and only the first may use it. A burst to one endpoint resumed 401 of 600 deliveries (§11).
- Option C (cache a verified chain) as well. Proposed: not now. It saves 0.8 ms on connections A cannot resume, and it is a cache of a security verdict with its own rules (§3’s rules 2 and 3 again). Decide after measuring how often A resumes.
- Resumption on by default in hooks’ pure build. Proposed: yes, as OpenSSL’s is (
tls-resume, default on).
10. Not done here
- Any code. After this: the protocol and the engine API (
packages/tls), then hooks’ adapter and the measurement. - 0-RTT, TLS 1.2 resumption,
psk_ke: refused by §3, not deferred. - Keeping connections open in hooks (option D): the bigger saving, and hooks’ design to make.
- Making X25519 faster: the 1.14 ms that resumption keeps. That is arithmetic work in
std.x25519andstd.field25519, and is measured there.
11. What building it found, and the results
Built:
packages/tls: the ClientHello’spsk_key_exchange_modesandpre_shared_keywith its binder, the ServerHello’spre_shared_key(tls-illegal-psk, new), a resumed handshake with no Certificate, the resumption master secret, and NewSessionTicket kept with its PSK (message.cho,client.cho,slot.cho).- The engine’s ticket table with generation-tagged handles and §3’s rules (
tls.cho). - Tests: ten lying-server cases (
scripts/tls_liar.py, now 77), twelve cases of the engine’s rules (scripts/tls_tickets.py, replayed byconformance/tls.rsthroughtests/programs/tls_tickets.cho), a resumption row per TLS 1.3 server inscripts/tls_interop.py, a ticket offered byfuzz_clienton inputs of odd length, and mutants.
What it found, and corrected here:
- A server need not send a ticket to a client that does not say it can resume. The first ClientHello, with no ticket yet,
sent no
psk_key_exchange_modes. OpenSSL, nginx, GnuTLS and wolfSSL sent tickets anyway, and resumed. Go’s and rustls’s servers sent none, as RFC 8446 §4.2.9 allows, so their resumption rows completed and resumed 0 of 8. Fixed withtls.set_resumption(engine, true): every ClientHello then advertisespsk_dhe_ke. It is off by default, so a client that never saves a ticket claims nothing it does not do, and every recording from before (the eleven traces, the 66 lying-server connections, the 64 streams) replays byte for byte as it did. - A ticket must carry its own host name. The first version compared the stored ticket’s name against the slot’s copy of the
host, which
close_notifyin both directions overwrites with the other secrets. Every ticket saved after a clean close then failed rule 1, and the engine fell back to a full handshake, silently. OpenSSL’ss_server -tlsextdebugshowed nopre_shared_keyon the wire. The name is now kept beside the ticket. - Rule 4 is counted from the verification (§3, corrected in place).
- A ticket’s “received” time is the start of the connection that got it: the engine has no clock between
startandsave. So its age is overstated by the handshake’s duration, which servers tolerate (RFC 8446 §4.2.11.1 leaves the window to them). Corrected: that start was first kept in whole seconds, which overstated the age by up to another 999 ms. wolfSSL 5.6.6 refuses a ticket whose age is more than 1,000 ms above its own count (internal.c, “Allow +/- 1000 milliseconds on ticket age”; below it allows 11 s) and answers with a full handshake. With eight handshakes at once on a busy CI runner, its resume row once resumed 6 of 8. The start is now kept in milliseconds. - A lifetime over 7 days is capped, not refused (§7’s liar case keeps it for 604,800 seconds).
Results:
-
Interop (
scripts/tls_interop.py, a Linux VM on an Apple M4 Max): the resumption row resumes 8 of 8 on Gocrypto/tls, rustls, wolfSSL, nginx and GnuTLS, andtls_many resumeresumes 4 of 4 againstopenssl s_server. BoringSSL’s server would not link on that VM (aarch64); CI runs its row on x86-64. -
The binder and every secret are checked by servers that are not this code. OpenSSL refuses a resumption whose binder is wrong, and the lying server (Python on RFC 8446 alone) checks the binder, after a HelloRetryRequest too, and asserts that the PSK the client derived from each ticket is its own.
-
The rules: thirteen cases, each deciding from the ClientHello’s bytes alone whether the ticket was offered, including the obfuscated age. The thirteenth, added after wolfSSL’s resume row failed once in CI (above), saves a ticket 999 ms into a second and checks its age 60 s later is 60,000 ms. The code that kept whole seconds fails it with 60,999.
-
Mutants (
scripts/tls_mutants.py, now 89): 89 killed, the 89th a ticket age counted from a whole second. Corrected: this said 87 killed and 1 equivalent (apre_shared_keyaccepted when none was offered, refused by the hash check with the same tag). Since the unoffered case has its own alert (above), that mutant changes the tag and is killed. Writing them found two gaps, closed here: no case resumed under SHA-384 (now one does), and the other host name in the rule cases was a different length, so the length check alone refused it (now it is the same length). The runner now replays the rule cases for every mutant, and a mutant listed as equivalent must survive or the run fails. -
Fuzzing:
fuzz_clientoffers a fixed ticket on inputs of odd length, and the ticket parser isfuzz_messages’s case 6. 20 minutes each offuzz_client,fuzz_messagesandfuzz_flightfrom the committed corpus: 8,459,281 executions, no crash and no hang;fuzz_clientreached 1,824 edges, against 1,767 before. -
In
cancho-hooks(itsdocs/pure-tls.md): a resumed delivery costs about 1.35 ms of the service’s CPU against 3.0 ms for a full one. Under a burst to one endpoint fewer deliveries resume than with OpenSSL (401 of 600, against all of them), because a ticket is used once (rule 5) and hooks keeps one per endpoint, where OpenSSL reuses one session for every concurrent connection. §9’s question 2 is where that is decided. Decided: pools, §12. -
Cost, measured (the VM of §1;
tls_many, 64 connections, againstopenssl s_server -tls1_3 -www, which choseTLS_AES_256_GCM_SHA384; the client’s CPU over one round, two rounds resumed, and two rounds against-num_tickets 0; median of 5):the client’s CPU a connection a full handshake 3.26 ms a resumed one 1.63 ms The saving is 1.63 ms, which is §1’s two ECDSA verifications (2 × 0.817 ms) to the hundredth. The rest of each connection is the same in both: X25519, and
tls_many’s request of one full 16 KiB record and the page back, under software AES-GCM (docs/tls-parity.md§3.1). That is why the resumed connection costs 1.63 ms and not §2’s 1.2: §2 counted the handshake alone. A first run against-no_ticketgave a full handshake of 2.14 ms: OpenSSL’s TLS 1.3 server still resumed 43 of 64 through its session cache with tickets “off”, so that baseline was not full handshakes.-num_tickets 0is.
12. Pools: several tickets for one server
The problem (§9’s question 2, §11’s burst). A handle names one ticket, and a ticket is used once (rule 5). A caller that keeps one handle a server, as hooks keeps one an endpoint, resumes one connection of a burst: the others find the handle spent and make full handshakes. Every connection of the burst gets a ticket of its own, but each new one replaces the last.
The design: a handle names a pool. The interface keeps its four calls and its integer:
tls.save_to(engine, slot, pool) -> pool | 0 // keep the slot's ticket in `pool` (0, or a handle never issued: a new pool)
tls.save(engine, slot) -> pool | 0 // save_to(engine, slot, 0), as before
tls.start_with(engine, slot, host, now_ms, pool) // offer the newest ticket of the pool that §3's rules allow, used once
tls.forget(engine, pool) // every ticket of the pool, overwritten
tls.set_tickets_per_pool(engine, n) // at most n tickets a pool; 1 unless set
- A pool is a number the engine issues (1, 2, 3, …, never reused), and each entry of the table records its pool. A handle
is no longer a generation-tagged index: a pool that was forgotten, or all of whose tickets were used or replaced, holds
nothing, so
start_withmakes a full handshake, never with another pool’s ticket.save_tointo such a pool fills it again, which is what a caller holding one handle a server wants. start_withtakes the newest ticket of the pool that passes §3’s rules, and overwrites it. A ticket of the pool that fails a rule is overwritten too, as a refused handle’s ticket was: none of the rules becomes true later except a clock that went back (rule 4’snow >= received), and a ticket from before a clock went back is not worth keeping.- A pool holds at most
ntickets (set_tickets_per_pool): a save into a full pool overwrites its oldest. Withn= 1, the default, a pool is today’s handle: the newest ticket replaces the one before. hooks sets 8, its cap on deliveries in flight to one endpoint (itsdocs/design.md), so a burst of 8 can find 8 tickets. - The table stays one table of
ticketsentries, shared by every pool: a save into a full table overwrites the entry the clock hand points at, as before. A pool cannot hold more thann, so one busy server takes at mostnentries from the rest. - Nothing in §3 changes. Every rule is checked per ticket; a pool is only which tickets a handle may name. The host name (rule 1) is still checked against the ticket, so a caller that files two servers under one pool offers neither the other’s.
- Cost.
start_withandsave_towalk the table (hooks: 1,024 entries of 12 integers), against a handshake’s 1.6 ms.
How it is tested. tests/programs/tls_tickets.cho gains S <pool> (save_to) and P <n> (set_tickets_per_pool), and
scripts/tls_tickets.py cases for: a pool of three resuming three connections, newest first, then none; a pool over its size
losing its oldest; forget emptying a pool; a pool’s spent ticket beside a good one; a pool refilled after it was emptied; and a
handle never issued. The liar’s server issues each ticket with a different identity, so a case reads from the ClientHello which
ticket was offered. Mutants for each of those decisions. In hooks: the burst of §11 again, against the 401 of 600.