A TLS 1.3 server for packages/tls: the design
Status: steps 1 to 5 built: the signer (
docs/ecdsa-sign.md), the TLS 1.3 server (§10, as built), the example the broker and the gateway copy,examples/tls_echo(§11), session tickets (§12) and client certificates (§13); its open questions (§9) answered as proposed (2026-10-07). Not independently reviewed (#209).packages/tlsis a client (docs/tls-pure.md). Two programs of the toolbox need the other side:cancho-mqtt, a broker whose clients connect on 8883, andcancho-gateway, a reverse proxy that terminates HTTPS. Both are at the design stage and both list TLS as out of scope because “it needs foreign code and would make the authority report unbounded”. A server inpackages/tlsremoves that reason. This document is the design; its numbers are measured where it names a measurement, and are otherwise arithmetic from measured parts, marked so. Where a later PR finds a claim false, that PR corrects it here, in place.
1. What a server needs that the client does not have
Most of the client is reused as it stands: the record layer and its AEADs (tls_record: ChaCha20-Poly1305 and AES-GCM, on the
hardware path since #334), the key schedule, the handshake message codec (tls_message), the transcript hash, the slots, the
DRBG, and the key exchanges (std.x25519, std.ecdh on P-256 and P-384, both constant time). What is new:
| the client today | a server needs | |
|---|---|---|
| a signature with a secret key | verifies only | signs CertificateVerify in every full handshake. No signing in std is constant time today: std.ecdsa verifies only, std.rsa verifies only, and std.ed25519.sign branches on secret data (docs/ed25519.md, “Not constant-time”). This is the prerequisite, §3. Built since: std.ecdsa_sign (docs/ecdsa-sign.md). |
| a private key | none | read from a PEM file, held in the engine, never readable back (§4) |
| the handshake state machine | ClientHello out, ServerHello in | ClientHello in, choose, ServerHello to Finished out, client Finished in (§5) |
| choices | the server makes them | suite, group, HelloRetryRequest, certificate by SNI, ALPN (§5.2) |
| attacker input at scale | one server, chosen by the program | any peer that can reach the port, many at once, each able to cost a signature (§7) |
2. Scope
2.1 Version 1
- TLS 1.3 only. A ClientHello without
0x0304insupported_versionsis refused with aprotocol_versionalert (tls-server-version). TLS 1.2 is §9’s question 1. - All three TLS 1.3 suites. The server picks, in its own order: on a machine where
hw_aes_gcm()is true,TLS_AES_128_GCM_SHA256first, thenTLS_CHACHA20_POLY1305_SHA256, thenTLS_AES_256_GCM_SHA384; without it, ChaCha20 first (the software AES-GCM is several times slower:docs/crypto-builtins.md). This is OpenSSL’s and Go’s rule. - Groups X25519, P-256, P-384, in that order of preference. The server uses the first of its groups the client sent a key
share for; if none, but the client’s
supported_groupsnames one, it sends one HelloRetryRequest for it (stateful: no cookie). A second ClientHello that still has no share for it is refused (tls-server-retry-share). Browsers send anX25519MLKEM768share with an X25519 share; the server ignores the hybrid and takes the X25519 one. - One certificate type: ECDSA P-256 (
ecdsa_secp256r1_sha256), signed in constant time (§3). It is what Let’s Encrypt and most ACME clients issue on request, and what every TLS 1.3 client must verify (RFC 8446 §9.1). P-384, Ed25519 and RSA-PSS keys are later (§8), each behind its own constant-time signer. - Certificates chosen by SNI: up to 16 identities (a chain and its key, with the names it serves), the first being the
default for a ClientHello without
server_nameor with a name no identity has. An identity can be replaced while the engine runs (a renewed certificate): connections already started keep the old one. - ALPN: the program gives a list (
http/1.1for the gateway,mqttfor the broker if it wants one). A client that offers ALPN with no protocol in the list is refused withno_application_protocol(RFC 7301 §3.2,tls-server-alpn); a client that offers none is accepted and the program sees none. - Middlebox compatibility (RFC 8446 Appendix D.4): the legacy session id is echoed and a
change_cipher_specrecord is sent after the ServerHello, as OpenSSL does, so a client in compatibility mode is not surprised. As built: once, after the first ServerHello or HelloRetryRequest the server sends (so after a HelloRetryRequest, not again before the ServerHello), and whatever the session id; every TLS 1.3 client must drop one (RFC 8446 §5).
2.2 Not in version 1, and why
| why not yet | where | |
|---|---|---|
| client certificates (mutual TLS) | MQTT deployments use them, so it is wanted; it is a second verification path (x509_verify without a host name) and an authorization interface | step 4 (§8), §9’s question 2; designed in §13 |
| built: saves the signature, which is most of the server’s cost (§6); stateless tickets with a ticket key and its rotation | step 5 (§8), §12 | |
| 0-RTT early data | replayable by design; no program here needs it | never, unless a design argues for it |
| TLS 1.2 | a second server state machine; who needs it is §9’s question 1 | step 6, if answered yes |
| renegotiation, compression, PSK without (EC)DHE, post-handshake authentication | not in TLS 1.3, or not needed | never |
| OCSP stapling, certificate transparency | the server would send what it is given; nothing reads it here | if a program asks |
3. The prerequisite: ECDSA P-256 signing in constant time
3.1 The pieces already constant time
std.ecdh multiplies a point by a secret scalar in constant time (docs/ecdh.md §2): a four-bit window over complete
formulas, the table read under masks, std.bigmod’s register arithmetic with masked reductions, and a timing test that found
and fixed a leak. std.bigmod.inverse is Fermat’s a^(n-2): it branches on the exponent’s bits, and n - 2 is public, so it is
constant time in a. ECDSA signing is built from these:
k = the nonce (§3.2), 1 <= k < n
(x, _) = k·G std.ecdh's scalar multiplication
r = x mod n; r == 0 -> next k public once the signature is sent
s = k^-1 · (e + r·d) mod n bigmod: inverse (public exponent), mul, add; d and k secret
s == 0 -> next k
The new code is a fixed-base multiplication (the existing variable-base one, given G; a fixed-base table is a later optimisation) and a few register operations mod n. What stays variable time is on public values only: the r == 0 and s == 0 tests (probability about 2^-256), and the encoding of the signature.
Corrected (step 1): “a few register operations” left one out. bigmod.load_reg, the only way into a register, skips zero
bytes and compares with n limb by limb, stopping at the first that differs: a branch on a private key or a nonce. ECDH never
loads its scalar into a register, so nothing had needed more. bigmod.load_secret places bytes by position only
(docs/ecdsa-sign.md §2.2).
3.2 The nonce
A biased or repeated nonce gives the key away. The nonce is RFC 6979’s deterministic nonce with added randomness (§3.6 there): HMAC-DRBG over SHA-256, seeded with the key, the message hash and 32 bytes from the engine’s DRBG. Deterministic so a bad DRBG cannot repeat a nonce for two messages; hedged so that a fault injected into one signature does not recur for the same message. RFC 6979 Appendix A.2.5’s P-256 vectors test the deterministic half, with the added randomness empty.
3.3 After signing
The server verifies its own signature with std.ecdsa.verify before sending it (about 0.8 ms, docs/tls-resumption.md §1),
and refuses the handshake with tls-server-sign-check if it fails. A fault during signing then sends nothing that would help
recover the key. §9’s question 3 asks whether that cost stays.
3.4 How it is tested
- RFC 6979 A.2.5 vectors, and 10,000 signatures from random keys and messages, each verified by OpenSSL (
openssl dgst -verify) and bystd.ecdsa; each with a bit flipped, refused by both. - A dudect timing test,
scripts/ecdsa_sign_timing.py, the shape ofscripts/ecdh_timing.py: a fixed key against random keys, and a key of 1 against random ones (the case that foundstd.ecdh’s leak). The gate is |t| below 4.5 at 10^6 measurements, as there. - The value-barrier audit (
docs/value-barrier.md) over the new functions. - Mutants over the new code (
scripts/mutate.py), every one killed or argued equivalent.
Corrected (step 1): there is no scripts/mutate.py; each crypto module has its own mutants script, and the signer’s is
scripts/ecdsa_sign_mutants.py. OpenSSL verifying a signature does not show that its nonce is RFC 6979’s, so the 10,000
signatures are also compared byte for byte with RFC 6979 written in Python (docs/ecdsa-sign.md §5.2). Results:
docs/ecdsa-sign.md §5 and §6.
3.5 The check against the identity’s table (B2 of #378, #380)
Added after #397, when the check of §3.3 had become a quarter of a full handshake (docs/tls-performance.md §10.4): the
design is docs/p256-fast.md §11, measured before it was chosen. The key that verifies every signature the server makes is
always the same, its identity’s, so each identity keeps a table of its public point, built once, and the check verifies against
it: 2.03 times cheaper (687k to 339k cycles on gram), the full server handshake 24% fewer instructions and 1.85 to 1.46
times s_server’s cycles (docs/p256-fast.md §11.4, §11.5).
- Where: in the identity’s stride of the configuration (
tls_identity, the engine’sids), after the leaf’s notAfter:o_table,p256_vf.table_len()= 10,305 bytes. One per identity, engine-wide; no slot holds one. The configuration grows from 314,106 to 478,986 bytes of address space for its 16 identities; an identity never loaded is never written, so it is not resident. - When:
tls_identity.load, the body ofadd_identityandreplace_identity, builds it from the public point the key parser derived (p256_vf.build_table), once every check has passed and while the identity is written. 151 to 159 µs on the M4. A replacement builds the new key’s; a refused one changes nothing, the table included. A build that failed (it cannot, for a point the parser derived) would leave zeros, which match no point, and the check would verify without a table. - What it is: public data, the multiples of the public point; the private key is not read to build it. The check is the same
verification of the same signature as §3.3, on public data, with the same answers:
sign_checkedis given the table in the point’s place (it begins with the point,docs/p256-fast.md§11.2), bytls_identity.check_key, which gives the point instead when the table is not the point’s. - Cleanup:
tls.closewipes the whole configuration (tls_identity.wipe), tables included.tls.residuecounts the bytes of the tables of identities not in use, which nothing may write.
4. Keys and certificates
- Formats. A chain as PEM certificates, leaf first. A key as PEM
PRIVATE KEY(PKCS#8, unencrypted) orEC PRIVATE KEY(SEC 1), P-256 only; anything else is refused with its tag (tls-server-key-type,tls-server-key-format). The engine checks that the key is the leaf’s (its public point equals the certificate’s) before it accepts the identity (tls-server-key-mismatch). - The engine never reads files. The program reads them (it holds the capability), and gives the bytes; so the authority report shows the reads where the program makes them, and the engine stays a pure package.
- Held, not shown. There is no function that returns the key. Replacing or removing an identity overwrites its key, best
effort, as
tls.dropdoes with session keys (docs/tls-pure.md§7.3). - The chain is not verified by the server. It is sent as given; a chain that clients refuse is the operator’s to fix.
The engine checks only that the leaf parses, that its key is P-256, and that it is not expired at the time the identity is
added (
tls-server-cert-expired), so a stale file fails at start, not at the first client. - As built, the room (
packages/tls/identity.cho): a chain of at most 16 KiB as Certificate sends it (each certificate’s DER and 5 bytes), names of at most 1 KiB, an ALPN list of at most 512 bytes; over them,tls-server-chain,tls-server-namesandtls-server-alpn-list. A chain block that does not decode, or no certificate at all, istls-server-chaintoo. 16 identities take 274 KiB of the engine, allocated byopen_serveronly.
5. The interface and the protocol
5.1 The interface
The same shape as the client: slots, bytes in and bytes out, no socket and no capability inside.
srv = tls.open_server(heap, slots) // an Engine whose slots are server slots
tls.seed(srv, entropy) // 32 bytes, as for the client
id = tls.add_identity(srv, chain_pem, key_pem, names, now_unix_ms) // names: "a.example b.example *.example"
tls.replace_identity(srv, id, chain_pem, key_pem, now_unix_ms)
tls.set_alpn(srv, "http/1.1") // a list, in the server's order of preference
tls.serve(srv, slot, now_unix_ms) // a new connection in `slot` (*as built: not `accept`, below*)
... tls.feed / take / send / recv / event / finish / eof / drop, as for a client ...
tls.server_name(srv, slot, out) // the SNI the client sent, once established
tls.alpn(srv, slot, out) // the protocol chosen
A separate Server type was considered, with the shared calls duplicated; one Engine with a role per slot keeps one set of
byte-moving calls and the existing tests of them. An engine is all clients or all servers: accept on a client engine and
start on a server engine are refused (tls-role).
Corrected (step 2): accept is a builtin’s name (tcp_accept’s family: accept itself, edition 2), and a function may not
take one, so the call is tls.serve. tls-role also refuses trust on a server engine and add_identity, replace_identity,
set_alpn, server_name and alpn on a client one. server_name and alpn answer once the ClientHello is answered, not only
once established. Added, for a program’s log and the interop matrix: tls.suite, tls.group and tls.retried (what the
connection negotiated), and tls.alert_received (the alert the peer sent, when failure is tls-alert), on either role.
5.2 The handshake
-
ClientHello, reassembled up to 16 KiB (an ML-KEM-768 share alone is 1,184 bytes, so a browser’s ClientHello is a few KiB; step 2 records the largest of the interop clients; measured: Chromium’s, 1,818 bytes with its X25519MLKEM768 share; of the command-line clients curl’s, 512; OpenSSL and mosquitto 308, wolfSSL 395, Go 240, §10.4), parsed with the bounds
tls_messagealready applies to server messages, plus the rules the server owns:(corrected: RFC 8446 §4.2.1 says a server MUST NOT negotiate withlegacy_version0x0303legacy_versiononcesupported_versionsis there, andopenssl s_servertakes 0x0301; it is not read);0x0304insupported_versions; compressionnullonly;signature_algorithmsincludingecdsa_secp256r1_sha256; no extension twice;pre_shared_keylast if present (and ignored in version 1, withpsk_key_exchange_modes);early_dataignored, so its data is skipped as RFC 8446 §4.2.10 allows for a server that rejects it, up to 16 KiB, then refused. Each refusal is an alert and a tag (§5.4).As built, the rules the list left out:
signature_algorithms,supported_groupsandkey_shareall present (tls-server-missing-extension, RFC 8446 §9.2); a share of this server’s groups at its group’s length, at most one a group, and only for a group insupported_groups, and onehost_nameinserver_name(tls-server-illegal-parameter, which also takes a compression method other than null andpre_shared_keynot last); an ALPN list of non-empty names. A share of a group the server does not have (an X25519MLKEM768 hybrid, GREASE) is skipped, as is any extension it does not use. Aserver_nameover 255 bytes is kept as none, so the default identity answers it. The rules about what is offered are checked after the whole message is read, in a fixed order: version, compression, a missing extension, a share outsidesupported_groups, suite, signature scheme, group. The second ClientHello, after a HelloRetryRequest, must keep the session id, still offer the suite the retry named, send a share of the group it named, and not offer early data (tls-server-retry-share; RFC 8446 §4.1.2 lets a server check this). Records: onechange_cipher_specof01is dropped after the first ClientHello and before the client’s Finished (Appendix D.4), and any other istls-unexpected-message; a ClientHello, a Finished or a KeyUpdate must end its record (§5.1), checked before the message is handled, so nothing is sent for one that does not; a plaintext alert is read after the server’s flight, since a client that refuses the flight before it has the handshake key alerts in the clear. Early data is skipped as RFC 8446 §4.2.10 says for a server that rejects it: after the flight, every record that does not open under the client’s handshake key is dropped until one opens, and before a second ClientHello everyapplication_datarecord is; at most 16 KiB of ciphertext in all, thentls-server-early-data-size. -
Choose: suite, group (or HelloRetryRequest, then back to 1 once), identity by SNI, ALPN.
-
ServerHello,
change_cipher_spec, then under the handshake keys EncryptedExtensions (ALPN,server_nameempty if it was used), Certificate, CertificateVerify (§3), Finished, all in one flight fromtake. -
Client Finished checked in constant time against the expected MAC; then the application keys, and
eventsays established.
The handshake needs one ECDHE key pair and one shared secret on the chosen group, and one signature.
5.3 Limits
A slot holds one connection. A program that accepts more connections than slots holds them unaccepted or closes them; the
engine does not queue. The ClientHello must arrive within the program’s own deadline; the engine has no clock of its own
beyond the now_unix_ms it is given.
5.4 Refusal tags
Each refusal sends the alert RFC 8446 names and has a tag beginning tls-server-: version, suite, group,
retry-share, sigalg, alpn, client-hello-format, client-hello-length, extension-repeat, early-data-size,
finished, sign-check, key-type, key-format, key-mismatch, cert-expired, identities-full, and tls-role. The
existing record-layer tags (tls-record-*) apply unchanged.
As built (packages/tls/record.cho, slot.cho): six more, for refusals the list had no tag for, and the alerts. The
“record-layer tags” are the client’s (tls-unexpected-message, tls-record-overflow, tls-bad-record-mac,
tls-decode-error, tls-protocol-version, tls-key-share, tls-too-many-messages, tls-alert, tls-peer-closed);
there is no tls-record-*.
| tag | alert | when |
|---|---|---|
tls-server-version | protocol_version (70) | no supported_versions, or one without 0x0304, or no extensions at all |
tls-server-suite | handshake_failure (40) | no TLS 1.3 suite |
tls-server-group | handshake_failure (40) | no share and no supported group of X25519, P-256 or P-384 |
tls-server-sigalg | handshake_failure (40) | no ecdsa_secp256r1_sha256 |
tls-server-retry-share | illegal_parameter (47) | the second ClientHello changed what it may not, or has no share of the group asked for |
tls-server-extension-repeat | illegal_parameter (47) | an extension twice |
tls-server-illegal-parameter | illegal_parameter (47) | added: a value RFC 8446 forbids (§5.2 above) |
tls-server-missing-extension | missing_extension (109) | added: no signature_algorithms, supported_groups or key_share |
tls-server-alpn | no_application_protocol (120) | ALPN offered, none in common |
tls-server-client-hello-format | decode_error (50) | a ClientHello that does not parse |
tls-server-client-hello-length | decode_error (50) | a ClientHello over 16 KiB |
tls-server-early-data-size | unexpected_message (10) | over 16 KiB of early data |
tls-server-finished | decrypt_error (51) | the client’s Finished |
tls-server-sign-check | internal_error (80) | the signature did not verify before it was sent |
tls-server-key-type, -key-format, -key-mismatch, -cert-expired, -identities-full | none: add_identity’s answer | §4 |
tls-server-chain, -names | none: add_identity’s answer | added: §4, as built |
tls-server-alpn-list | none: set_alpn’s answer | added: a name over 255 bytes, or a list over 512 |
tls-server-no-identity | none: serve’s or replace_identity’s answer | added: no identity added, or none of that number |
tls-role | none | a call for the other role (§5.1) |
tls-server-binder | decrypt_error (51) | added (§12.5): a ticket this server made, passing every rule, whose PSK binder does not match |
tls-server-ticket-config | none: set_tickets’ answer | added (§12.5): a count over 8 or a lifetime outside one second to seven days |
tls-server-ticket-key | none: set_ticket_keys’ answer | added (§12.5): no key, a length that is not a whole number of 32-byte keys, or more than 4 |
tls-pool | none: serve’s answer | an engine opened with open_server_with_areas had every work area leased, so the connection is not started (the program keeps it queued) (docs/tls-memory.md §7.3) |
6. Cost
Corrected (#380, docs/p256-fast.md §9): the signature is 59 µs on the M4 and the check of §3.3 104 µs; the 1.5 ms and 0.82 ms below are main before that change.
The signature is measured (step 1, docs/ecdsa-sign.md §7); the total is arithmetic from measured parts, and step 2
measures it. One full handshake on the server, P-256 certificate:
| operation | cost | source |
|---|---|---|
| X25519 key pair and shared secret | 1.14 ms | docs/tls-resumption.md §1 (6-vCPU Linux VM on an M4 Max) |
ECDSA P-256 sign (std.ecdsa_sign.sign) | 1.5 ms on an Intel i7-1260P, 0.82 ms on an Apple M4 Max | measured, LLVM backend, docs/ecdsa-sign.md §7 |
| verifying the signature (§3.3) | 1.5 ms on the i7-1260P, 0.80 ms on the M4 Max | measured: sign_checked less sign, same section |
| total | about 4 ms of CPU on the i7, about 2.8 ms on the M4 | the X25519 row is the M4 VM’s; the i7’s X25519 is not measured |
Corrected (step 1): the estimate was “about 1.3 to 2.6 ms” to sign and 3 to 5 ms in all. Measured, the signature is at the
bottom of that range, and the check of §3.3 costs as much as the signature on the i7 (not “about a fifth of the cost” as
§9’s question 3 has it; on the M4 it is about 0.8 ms, as estimated). OpenSSL 3.0.13 signs in 23 µs on the same i7
(openssl speed ecdsap256, 44,132 a second), 65 times faster.
Resumed handshakes (step 5, §12.9): 1.52 ms against 3.15 ms of examples/tls_echo on the M4, and about a third to a half of a
full handshake on an x86-64 box too loaded to give an absolute figure.
Measured (step 2), in place of the estimate. python3 scripts/tls_server_cost.py <tls_serve> 20: the server
(tests/programs/tls_serve.cho, LLVM backend, echo mode, one P-256 identity) under openssl s_time -new, full
handshakes one after another for 20 seconds a row, the server process’s CPU (user and system, /proc/<pid>/stat)
divided by the handshakes s_time completed. The machine: Ubuntu 24.04, linux-aarch64, in Docker’s 6-vCPU VM on the
Apple M4 Max of docs/tls-assurance.md §6.1, the host busy with other builds (load average 4 to 5 of 6). Suite
AES-128-GCM (the server’s choice: the CPU has AES instructions). Two runs:
| client’s share | ms of server CPU a handshake | handshakes a second a core |
|---|---|---|
| X25519 | 3.86, 4.06 | 259, 246 |
| P-256 | 3.44, 3.55 | 290, 282 |
| P-384 | 6.09, 6.06 | 164, 165 |
| P-521, then a HelloRetryRequest to P-256 | 3.65, 3.47 | 274, 288 |
openssl s_server 3.0.13, X25519, the same client | 0.20 | 4,975 |
So about 3.5 to 4 ms a handshake on X25519 or P-256, 6 ms on P-384: inside the design’s 3 to 5 ms for the first two,
and above step 1’s arithmetic for the M4 (2.8 ms), since the measured figure is the whole process (the socket, the
poller, the transcript, HKDF and the records) and not the three operations alone. A HelloRetryRequest costs nothing
measurable (one more message hashed). OpenSSL’s server is 17 to 20 times cheaper, at the bottom of the “10 to 50
times” said here. Not measured: x86-64 (the i7 of docs/ecdsa-sign.md would be slower: its signature and check are 3 ms
against the M4’s 1.6), and the Cranelift backend.
Measured later, x86-64 and with the profile inside (tls-performance.md §3.3): tls_echo on an i7-1260P costs 4.51 ms of CPU a handshake in a quiet minute and 16.5M cycles (OpenSSL’s s_server 1.09M, 15 times less); the two X25519 operations, the signature and the check are 89% of it, and malloc/free/brk of the 64 KiB region chunks 5.7%.
Corrected (step 3, §11.4): about 0.9 ms of the X25519 row is tls_serve’s own loop, not the handshake. On the same
machine, in one session, alternating, examples/tls_echo costs 3.0 ms a handshake where tls_serve costs 3.8 to
3.9 ms, with the same engine, client and identity. Where tls_serve spends the difference was not investigated; the
engine’s share is at most the 3.0 ms.
The paragraph that follows was the estimate’s: about 250 full handshakes a second a core on the i7, by the arithmetic above. OpenSSL’s P-256 signing is tens of microseconds with its NIST-prime
arithmetic; this server’s handshake is in the order of 10 to 50 times OpenSSL’s. For the two programs that is acceptable:
an MQTT client and an HTTP keep-alive connection handshake once and then stay. What changes it is in §8: session tickets
(no signature on a resumed connection), a fixed-base table for k·G, and arithmetic specialised to the NIST primes
(docs/ecdsa.md §5.4). Memory: a server slot is the client slot (about 179 KiB, docs/tls-pure.md §7.4; 263 KiB, and 119 KiB resident for an established connection, until #383: docs/tls-memory.md) plus nothing
significant; each identity holds its chain (a few KiB) and key in the engine. As built: five words a slot; 274 KiB of identities and 75 KiB
of key-parsing work in a server engine (§10.1).
With a client certificate (§13.17, measured). A handshake that verifies a client’s chain and its CertificateVerify retires 50.6 million
more user-space instructions with a P-256 key under the CA than the 75.6 million of a handshake with none (x86-64, perf stat), and 25.1 million
more for each intermediate; on the M4 it costs 1.7 ms more (3.23 to 4.94 ms), and 2.5 ms more with an intermediate. P-384
(+3.2 ms on the M4), RSA-2048 (+1.0) and Ed25519 (+4.2) cost differently. A program that bounds handshakes by rate (§11.2) sets the rate from
this figure when it asks for certificates.
7. Threat model, the server’s side
docs/tls-pure.md §4 holds for the record layer. What a server adds:
- CPU exhaustion. A client can make the server sign by sending one ClientHello, and abandon the connection. At about 4 ms a
handshake, 250 ClientHellos a second take a core. The engine cannot bound this: it does not see the network. The program
does, and the design asks each to bound handshakes started per second and in progress, with a counter the engine keeps
(
tls.handshakes_in_progress). The broker and the gateway design their limits with it. As built (step 3, §11.2):examples/tls_echobounds both, delays the excess rather than refusing it, and gives a handshake a deadline; the whole server process measured 3.0 ms a handshake on the M4 (about 330 ClientHellos a second take a core, not 250) and 5.5 ms on CI’s x86-64 (about 180). - Memory exhaustion. Bounded by the slots: a ClientHello cannot grow a slot past its 16 KiB reassembly limit.
- Timing of the key. §3. The record layer and the key exchanges are constant time already.
- Choosing the identity. SNI is attacker-chosen: a name with no identity gets the default, never an error that tells names
apart, and the name is compared as bytes after lower-casing ASCII, as
packages/x509/names.chodoes for the client. - Parsing. The ClientHello is now the input every peer controls. It goes to the fuzz harness (
scripts/tls_fuzz.py) and the AFL setup (scripts/fuzz_afl.py) with a corpus of real ClientHellos (OpenSSL, Go, curl, Firefox, Chrome, mosquitto), and the gate is no panic and no trap. As built (§10.5):scripts/tls_fuzz.py --servermutates the lying client’s honest connections; AFL++ runs two harnesses,fuzz_hello(the parser) andfuzz_server(the engine fromserve), from the ClientHellos of OpenSSL, curl, Go, wolfSSL, mosquitto and Chromium. Firefox’s is not in it: no Firefox could be run here.
8. Steps, each its own PR with its gates
std.ecdsa.signon P-256 (§3; built asstd.ecdsa_sign, sincestd.ecdhimportsstd.ecdsaand the signer needsstd.ecdh’s ladder:docs/ecdsa-sign.md§1), the key parsers (§4), with the vectors, the 10,000 OpenSSL cross-checks, the timing test, the audit and the mutants. Gate: all pass; the timing test’s |t| below 4.5.- The TLS 1.3 server (§5), with:
- interop:
openssl s_client, curl, Gocrypto/tls, wolfSSL’s client, and mosquitto’s clients (mosquitto_pubover 8883, against a cancho echo program), each across the three suites and three groups, with and without HelloRetryRequest; - a lying client:
scripts/tls_liar_client.py, the shape ofscripts/tls_liar.py, one case per refusal tag and per rule of §5.2; - a differential: the same malformed ClientHellos to
openssl s_server, the alerts compared (anEXPECTEDtable for the cases where the RFC allows either); - the fuzz corpus of §7, mutants over the new code, and the cost of §6 measured. Gate: every case passes; the cost measured and written here.
- interop:
- An example that the two programs can copy:
examples/tls_echo(a server overstd.connsand a poller), and a section indocs/http-server.mdon servingpackages/http-serverover TLS. Built (§11): the example, its tests and its cost;http.servercould not take bytes that did not come from its own sockets; corrected: it can now (docs/http-server.md§11, a byte-fed mode), andexamples/https_hellois the HTTPS server built on it. - Client certificates: CertificateRequest, the client’s chain verified against a configured trust store with no host name, the verified subject and SANs given to the program. §9’s question 2 decides whether it moves before step 3. Built (§13), after step 5, on the tickets’ head.
- Session tickets: stateless, sealed with a ticket key from the DRBG, rotated,
psk_dhe_keonly (a fresh key exchange every time, so forward secrecy stays), never 0-RTT. Saves the signature and its check: about 2 to 3.5 ms of the 3 to 5. Built (§12, #379): the design is §12.1 to §12.10, what was built and measured §12.11 to §12.15. - TLS 1.2, ECDHE-ECDSA with AEAD suites and the extended master secret required, if §9’s question 1 says so.
- More key types, each with its own constant-time signer and timing test: P-384, Ed25519 (on
std.field25519, which is constant time, replacingstd.ed25519’s big-number signing), RSA-PSS (needs blinding;std.bigmod.pow_modbranches on its exponent, so it cannot take a private exponent as it is).
9. Open questions, for a person
Decided: all five as proposed (2026-10-07).
- TLS 1.2 for the broker. Many embedded MQTT clients (older mbedTLS and wolfSSL builds on microcontrollers) speak TLS 1.2 only. Proposed: version 1 is 1.3 only; the broker’s design counts the clients it must serve, and step 6 is built if any need 1.2.
- Client certificates before the example? Proposed: no; step 4 follows step 3, unless the broker’s design makes them its default way to authenticate, in which case step 4 moves ahead of step 3.
- Verify every signature before sending it (§3.3): 0.8 ms a handshake, about a fifth of the cost. Proposed: yes, until step 7’s fixed-base and NIST-prime work makes signing cheap enough that the check’s share is worth measuring again.
- One engine type, or two (§5.1). Proposed: one, with a role.
- Independent review. The client is “not independently reviewed” (#209). A server that signs with a long-lived key is a larger exposure than a client with ephemeral keys. Proposed: the server carries the same notice, and the broker and the gateway say so in their READMEs until #209 is answered.
10. As built: step 2, the server
PR #339. The numbers are measured, each with the command that gives it; where building found this document wrong, the section that said so is corrected in place, marked “corrected (step 2)” or “as built”.
10.1 What was built
| File | Module | What |
|---|---|---|
packages/tls/hello.cho | tls_hello | the ClientHello parsed with the rules of §5.2, the choices (suite, group, retry group, ALPN), and the messages a server sends: ServerHello, HelloRetryRequest, EncryptedExtensions, Certificate, CertificateVerify |
packages/tls/server.cho | tls_server | one server connection: start, feed and its record loop, the key schedule, the flight, the one function that calls the signer, the client’s Finished compared in constant time, KeyUpdate |
packages/tls/identity.cho | tls_identity | a server engine’s configuration in one byte slice: 16 identities (key, public point, names, the certificate_list as Certificate sends it) and the ALPN list |
packages/tls/tls.cho | tls | open_server, add_identity, replace_identity, set_alpn, serve, server_name, alpn, handshakes_in_progress, suite, group, retried, alert_received; the role; feed sends a server slot to tls_server |
packages/tls/slot.cho, record.cho, message.cho | the server’s states, flags and five slot fields; its 24 refusal codes and their alerts; hrr_random made public |
Reused unchanged: the record layer and its AEADs, tls_slot’s transcript, keys, record queue, fail and forget, the
ECDH share a HelloRetryRequest needs (new_ecdh_share: the P-256 or P-384 scalar comes from the X25519 secret by HKDF, as for
the client), and tls_client‘s take, send, recv, finish, event, peer_eof and drop, which serve a server slot as
they are, and its finished_mac, on_alert and compact_recv, made public for the server. client.cho changes in nothing
else, so its mutants’ texts still match.
What is not shared, and why. The record loop (feed, on_record, the handshake reassembly) is the server’s own,
about 200 lines the shape of the client’s. The client’s loop calls the client’s message handler, and cancho
has no function values (docs/function-values.md), so sharing it would mean tls_client importing tls_server, and every
client build (tls_driver, hooks) taking the server and x509_key with it. The server’s loop also differs where it must:
early data, a plaintext alert after the flight, one change_cipher_spec between the ClientHello and the Finished, and the
“message ends its record” rule checked before a message is handled.
The signer. tls_server.sign is the one caller: SHA-256 of CertificateVerify’s content, ecdsa_sign.sign_checked under
the identity’s key, hedged with 32 bytes of the engine’s DRBG drawn at serve (RFC 6979 §3.6), verified under the identity’s
public point before it is used (§3.3, tls-server-sign-check), then ecdsa_sign.to_der. Its work is the slot’s std.ecdh
work area, which the key exchange has finished with. Each connection draws 96 bytes at serve: the ServerHello random, the
X25519 secret, and the hedge; all three are in the slot’s keys, which forget and drop overwrite.
Memory. A server slot is the client’s (docs/tls-core.md §9.1) and five words. (#383: and an established connection holds 4.5 KiB of it, docs/tls-memory.md.) A server engine adds its identities, 274 KiB
for 16, and 75 KiB of work for the key parser (std.ecdh’s), and gives up the client’s 1 MiB of roots; a client engine adds
nothing (open allocates neither).
Editions. server.cho is edition 7, for hw_aes_gcm(); identity.cho edition 6, as x509_key is.
10.2 Interop
python3 scripts/tls_server_interop.py <tls_serve>, in the image scripts/interop/server.Dockerfile describes (Ubuntu 24.04
on linux-aarch64, in Docker on the M4 Max of docs/tls-assurance.md §6.1), tests/programs/tls_serve.cho built with the
LLVM backend. Each row is one connection: the client verifies the chain and the name against the row’s CA, its data comes
back, and the server’s own line for the connection says the suite, group, name and protocol the row asked for, and
whether a HelloRetryRequest came first. For a retry row the client’s only share is P-521’s, with the group after it in
supported_groups. curl and mosquitto have no option for TLS 1.3’s groups or suites; they were set through an
OPENSSL_CONF. 88 rows, 88 ok:
| Client | Version | Rows | What |
|---|---|---|---|
openssl s_client | OpenSSL 3.0.13 | 23 | 3 suites × 3 groups, each direct and after a HelloRetryRequest; SNI choosing the second identity, a wildcard name, no SNI (the default); ALPN http/1.1 agreed; ALPN h3 alone refused, no_application_protocol |
| curl | 8.5.0, on OpenSSL 3.0.13 | 18 | 3 × 3 × 2, an HTTP request to the server’s http mode |
Go crypto/tls | 1.22.2 | 9 | 3 groups × 2 (Go does not let a client choose TLS 1.3’s suites: the server’s, AES-128-GCM); SNI; ALPN h2,mqtt agreed on mqtt, the server’s order; ALPN h3 refused |
| wolfSSL | 5.6.6 | 19 | 3 × 3 × 2 (scripts/interop/wolfssl_client.c), ALPN mqtt |
| mosquitto | 2.0.18 | 19 | 3 × 3 × 2, each row mosquitto_sub receiving the server’s message and mosquitto_pub publishing one the server prints, against tls_serve’s mqtt mode; ALPN mqtt |
Not run: Firefox and Chrome (no browser in the image). A Chromium ClientHello was caught instead (the Claude desktop
app’s built-in browser, a Chromium, sent to a listener on this machine) and answered by the server in the driver: it chose
AES-128-GCM, X25519 over the X25519MLKEM768 hybrid, and h2. It is in the fuzzing corpus (§10.5).
10.3 The lying client
python3 scripts/tls_liar_client.py <server driver> tests/vectors/tls/liar_client.txt: 110 connections, each a client
that changes one thing, recorded and replayed byte for byte on both backends by conformance/tls_server.rs (and the honest
ones again with the client’s bytes fed one byte a line). The client is pyca/cryptography’s primitives and RFC 8446, written
apart from the server; it checks every byte the server sends, the CertificateVerify signature under the leaf’s key
included. 29 end ok: 26 honest connections (every suite and group, a HelloRetryRequest to each group, SNI by name, by wildcard, unknown and
absent, ALPN in the server’s order and ignored without a list, early data skipped before and after a HelloRetryRequest
and at exactly 16 KiB, a ClientHello in one-byte records and one of exactly 16 KiB, a KeyUpdate answered, close_notify both
ways, legacy_version 0x0301, GREASE, a hybrid share, pre_shared_key ignored), the server’s suite order with and without AES
instructions, and an identity replaced and a replacement refused. The other 81 each end with their tag and, for a connection, the alert §5.4 names, in the clear or under the key the server then holds. Every
tag of §5.4 is reached but tls-server-sign-check, which needs the signer to fault; its path is shown by the mutant
that checks the signature under another identity’s point (§10.6), which every honest connection kills.
10.4 The differential against openssl s_server
python3 scripts/tls_server_differential.py: the lying client’s connections decided on a ClientHello (72 of the 110: the
client’s bytes there do not depend on the server’s) sent as recorded to openssl s_server 3.0.13 with the same identity,
groups, suites and ALPN list, and the outcomes compared. 59 agree, 7 differ in the alert only, 6 differ as EXPECTED
says, 0 otherwise.
| Case | packages/tls | OpenSSL | Why |
|---|---|---|---|
| a host name of 300 bytes | accepted, the default identity | unrecognized_name | RFC 6066 §3 allows either; this server never tells names apart (§7) |
| an unknown extension twice | illegal_parameter | accepted | RFC 8446 §4.2: no extension twice; OpenSSL checks only those it knows |
| two X25519 shares | illegal_parameter | accepted | RFC 8446 §4.2.8 lets a server refuse it |
| a ClientHello over 16 KiB (two cases) | decode_error | waits, or accepts | this server’s limit (§5.2); OpenSSL’s is larger |
| a second ClientHello with another session id | illegal_parameter | accepted | RFC 8446 §4.1.2 lets a server check it |
The alerts that differ: an empty supported_versions and an odd cipher_suites length (decode_error here, protocol_version
there); two host names (illegal_parameter against decode_error); a low-order X25519 share (illegal_parameter against
internal_error); a record of version 2.0 (protocol_version against none); a fatal alert or close_notify instead of a
ClientHello (none here, unexpected_message there). Found by it, and fixed: this server refused a ClientHello whose
legacy_version was 0x0301, as §5.2 said to; OpenSSL takes it, and RFC 8446 §4.2.1 forbids a server to negotiate with that
field once supported_versions is there. §5.2 is corrected, and the case is an honest one.
10.5 Fuzzing
-
AFL++ (
scripts/fuzz_afl.py, 4.09c in the image of §10.2, one core a harness), two new harnesses:fuzz_hello, a ClientHello’s body throughtls_hello.client_helloand every choice made from it, andfuzz_server, the engine fromservewith a fixed identity, fed a client’s bytes in chunks the input names (odd-length inputs without an ALPN list). Seeded from the ClientHellos of §10.2’s clients and Chromium (tests/vectors/fuzz/server/,hello/). Two runs, the second resuming the first’s queue on the final code:Harness Executions Hours Per second Edges Crashes Hangs fuzz_hello11,501,751 0.75 4,246 130 of 348 0 0 fuzz_server1,391,722 1.25 309 1,283 of 11,069 0 0 total 12,893,473 0 0 No crash and no hang.
fuzz_serveris slow because every input loads the identity and signs: 3 ms of each 3.2.--minimizekept 74 and 131 inputs, committed with the six real ClientHellos beside them. CI’stls-assurancejob fuzzes both for two minutes on x86-64 (its first run: 352,994 and 18,029 executions, 0 crashes, 0 hangs). -
Mutation (
scripts/tls_fuzz.py --server): 20,000 of the lying client’s honest connections, one line of the client’s bytes mutated in each (bits flipped, bytes set, cut short, slices duplicated or dropped, bytes inserted, a length set), replayed: 0 traps. -
The corpus is a regression test:
conformance/tls_fuzz.rsruns every committed input of both harnesses on both backends.
10.6 Mutants
python3 scripts/tls_server_mutants.py <cancho>: 71 of 71 killed, none argued equivalent. Each is one bug in
hello.cho, server.cho, identity.cho, or the server’s part of tls.cho and slot.cho, against the 110 recorded
connections. Two survived a first run, and each got the case that kills it: a leaf of another curve was caught later
anyway, by the key’s match against the certificate (a P-384 leaf with a key that is not PEM now says key-type, not
key-format), and handshakes_in_progress was never read mid-handshake. The client’s scripts/tls_mutants.py still kills
103 of 103, after two of its mutants’ texts were brought up to date with set_read_keys and set_write_keys as the
hardware AES change (#334) left them: on main the script stopped with “the text occurs 0 times”.
10.7 The existing suites
Unchanged by this PR, run again: the client’s 84 lying-server cases and 20 ticket cases re-recorded byte for byte identical;
scripts/tls_differential.py 59 agree, 17 alert only, 8 as documented, 0 otherwise, and --handshakes 21 of 21;
scripts/x509_matrix.py 35 cases, 0 wrong; scripts/publish_packages.py --check with the server’s three modules
published.
10.8 Where this differs from the design
tls.acceptistls.serve(a builtin ownsaccept), §5.1.legacy_versionis not read, §5.2 (the differential found it).- Six refusal tags more than §5.4 named, and the “record-layer tags” are the client’s, §5.4.
- The record loop is the server’s own, not the client’s, §10.1.
suite,group,retriedandalert_receivedadded, for logs.- An identity’s chain is at most 16 KiB, names 1 KiB, the ALPN list 512 bytes, §4.
10.9 Not done, and not verified
tls-server-sign-checkis not reached by any input: it needs the signer to fault. A mutant reaches its path.- Firefox’s and Chrome’s own clients were not run; a Chromium ClientHello was answered in the driver, not over a socket to a completed handshake.
- x86-64: the cost ran on linux-aarch64 only. CI’s
tls-assurancejob (ubuntu-latest, x86-64) runs the interop matrix (88 of 88 there too; its Go sends a second share, so its threeretryrows are taken directly, which the harness allows for Go alone), the differential (the same 59, 7 and 6) and two minutes of each fuzzer. - Timing: no new secret-dependent code here but the Finished comparison, which is the client’s pattern; the signer’s timing is step 1’s. No dudect test of the server’s handshake as a whole.
- Not independently reviewed (#209), as the client; the broker and the gateway must say so in their READMEs.
11. As built: step 3, the example
PR #346. The numbers are measured, each with the command that gives it.
11.1 What was built
examples/tls_echo/, four files, edition 6, no foreign code (corrected, http-server.md §11: it was three; examples/https_hello needed the same
pieces, so what was in echo.cho and tls_echo.cho and has nothing to do with an echo moved, unchanged, to front.cho):
| File | Module | What |
|---|---|---|
tls_echo.cho | (root) | main: the engine, the identities, the listener, the signals, and the loop; usage |
front.cho | tls_front | what any program that terminates TLS here has: the options and their parsing (std.flags), the per-slot state, the log, admission (the bounds), accepting, writing the engine’s bytes, ending a connection, reload |
echo.cho | echo_loop | the loop: one Poller, std.conns, each connection in the same slot of the table and of the engine; the echo with back-pressure (pump), the timeouts, the stop |
identity.cho | echo_identity | an identity’s chain.pem, key.pem and names read beneath the directory handle and given to add_identity or replace_identity; the key’s buffer overwritten after |
tls_echo --port <n> --dir <directory> [--identity <subdirectory>]... [--alpn <p1,p2>]
[--connections <n>] [--handshakes <n>] [--rate <per second>] [--handshake-timeout <ms>] [--idle <ms>]
[--per-address <n>] [--per-address-rate <per second>] [--areas <n>]
It logs, per connection, what was negotiated and how it ended:
conn 7 peer=127.0.0.1:51109 established suite=TLS_AES_128_GCM_SHA256 group=x25519 sni=echo.lex-sys.test alpn=echo hrr=no waited=0 handshake=5
conn 7 peer=127.0.0.1:51109 closed ok in=11 ms=1009
The authority report is bounded and pinned by conformance/tls_echo.rs: args, clock, conn_accept, conn_read,
conn_write, dir_read, err_write, file_read, fs_read(""), heap, io_write, net_in(""), poll,
signals("HUP,INT,TERM"), signals_read; no ffi, no foreign symbol. The other examples pin theirs in a test, not a
file, and so does this one.
Reading the files, as narrowly as the language allows. narrow takes a literal, and the operator names the
directory, so a path read is fs_read("") whatever the program does (docs/agent-toolbox.md §2.2). What the language
does allow is to spend it once: main reads 32 bytes of /dev/urandom and opens the directory with open_dir, and
the rest of the program, the reload included, is handed the Dir and never the Fs. So serve, run and reload
say dir_read and file_read, not fs_read: the code that faces the network can read beneath that one directory and
nothing else, one component at a time and following no link (docs/directory-handles.md). The cost is that a symbolic
link there is refused (ELOOP): certbot’s live/ is links into archive/ and a Kubernetes secret volume is links into
..data/, so a deployment copies the files in (a certbot --deploy-hook does, and sets the permissions this process
needs at the same time). A program that knew its directory at build time could narrow to the literal instead
(Fs("/etc/cancho-mqtt")), and its row would say so.
(Built, PR #364, narrowing-into-several.md §11.1: examples/tls_echo_fixed is that program. Its main is
narrow(fs, "/dev/urandom", "/etc/cancho/tls_echo"), one narrow for the two paths it reads, so its pinned report
(conformance/tls_echo_fixed.rs) has fs_read("/dev/urandom") and fs_read("/etc/cancho/tls_echo") where tls_echo’s has
fs_read(""), and the same labels otherwise. It shares serve and the loop with tls_echo; the cost is that the directory is
fixed when it is built and --dir is refused.)
11.2 The two decisions
Reload is SIGHUP, not a file’s modification time. Both are possible today (dir_stat answers an mtime beneath a
Dir, #263). The signal is chosen because a renewal replaces two files, and a poll can see one changed and not the
other: the engine refuses that pair (tls-server-key-mismatch, and the old identity keeps serving), so a poll is not
unsafe, but it reloads at a moment nobody chose, logs a refusal for a correct deployment, and costs a stat per file
per interval. A signal is sent by the deploy hook after both files are in place, which is the moment the operator
means, and it is what nginx, HAProxy and mosquitto do. On SIGHUP every identity is read again and given to
tls.replace_identity; a connection whose ClientHello was answered keeps the certificate it was sent, and a refused
replacement is logged (reload 0 refused tls-server-key-mismatch) with the old identity still serving. Names are not
reloaded (replace_identity keeps them); a change of names is a restart.
The handshake bounds delay; the table refuses. Every connection is accepted into a slot of the table and a queue,
unwatched, so its ClientHello waits in the kernel and costs nothing. A queued connection starts (tls.serve, then it
is watched) when fewer than --handshakes are in progress (tls.handshakes_in_progress) and fewer than --rate were
started in this second; oldest first. A connection over the table (--connections) is accepted and closed at once,
before any TLS. Each phase has --handshake-timeout (queued, and in progress, separately), so a peer that connects and
sends nothing holds a handshake place for that long and no longer; an established connection gets close_notify after
--idle. Why delay and not refuse: a burst of honest clients (a broker restarted, a thousand MQTT clients reconnecting
at once) is the common case of “too many handshakes”, and delaying it costs each a wait while refusing costs each a
retry with backoff; the attacker’s case is bounded either way, by the same two numbers. The alternative of leaving the
excess in the kernel’s listen queue is not available: there is no way to stop watching a Listener short of closing it
(poller_remove takes a Conn), so a listener that is not accepted from wakes poller_wait at once, every time.
Per address (conn-peer.md). Every line the example prints about a connection says who it is from
(peer=<address>:<port>: conn 3 peer=127.0.0.1:51109 established ..., refused 7 peer=... full), from conns.peer
asked once after the connection is put in its slot. Two more bounds count under the peer’s key (addr.key: an IPv4
address, an IPv6 /64), both off unless given: --per-address <n> closes a connection at once, as the table does
(refused <id> peer=... per-address), when <n> from its key are already held; --per-address-rate <n> leaves a queued
connection queued while <n> handshakes from its key were already started in this second, and does not hold up the
connections behind it. The second keeps a table of 1,024 keys per one-second window; a 1,025th key in a window waits for
the next. A client behind a proxy or NAT is the proxy’s or the NAT’s address and counts with everything behind it.
The defaults: 256 connections, 32 handshakes in progress, 100 started a second, 10 s to finish a handshake, 60 s idle. Corrected on this PR: the rate’s default was 200, “most of one core” at 4 ms; CI’s x86-64 runner measured 5.5 ms a handshake (§11.4), where 200 a second is more than the one core the loop has and the bound bounds nothing. At 100 it is a third of a core on the M4 (34%) and 59% on that runner, both measured. A deployment sets it from its own machine’s figure.
Work areas (--areas <n>, at least 2, default --connections; docs/tls-memory.md §7). The engine keeps what an established
connection needs, 4.5 KiB, for every connection, and leases the rest, a handshake’s scratch and the record buffers, for as long as a
handshake runs or bytes are in flight, clearing it when it comes back. With the default there is an area for every connection and nothing
is ever refused or delayed for want of one; the address space is what it was. With --areas smaller, a queued connection is delayed
until its handshake can have an area (at most half of the areas, rounded down, are in handshakes at once, so traffic always has one); an
established connection that needs an area for a record and finds none waits (the engine takes nothing, and the bytes are offered again);
and one that holds an area for longer than --handshake-timeout (half a record and nothing more, or bytes it does not read) is ended
record-timeout, so one byte every --idle cannot keep an area. Size it for the connections that can be in a handshake or hold a record at
one instant, not for the connections. --connections (and so --handshakes and --per-address) may now be up to 1,048,576; it was 4,096,
the width of a queue entry’s slot field, until tls-memory.md measured 10,000.
Back-pressure. A connection’s socket is read only when everything read before it has been echoed and written, so
a peer that does not read its echo stops being read, and its slot’s three 16 KiB buffers are the most it can make the
server hold. A stop (SIGINT, SIGTERM) refuses new connections, closes queued and handshaking ones, sends
close_notify on established ones, and exits once those are written or after two seconds.
11.3 Tests
-
conformance/tls_echo.rs, incargo teston both CI targets, againstpackages/tls’s own client (tests/programs/tls_many.cho), so it needs no TLS library outside the repository: the authority report pinned; 64 connections at once with at most 4 handshakes in progress, each verifying the chain and the name, sending 16,384 bytes, getting them back and getting close_notify when idle; a reload that takes and one that is refused; then 8 more connections on the renewed identity; thenSIGTERM, exit status 0. -
python3 scripts/tls_echo_test.py <tls_echo>, in CI’stls-assurancejob (linux-x86_64) and run here in the image of §10.2 (linux-aarch64) and on macOS 26 (darwin-aarch64, OpenSSL 3.6): 8 of 8 cases on each of the three:case what suitesopenssl s_client, 3 suites × 3 groups and a HelloRetryRequest (P-521 then P-256): the echo comes back, the log line says the suite, group, SNI, ALPN andhrrasked formany200 connections at once (Python ssl), 20 echoes of 1 to 20,000 bytes each, every byte compared, each closedokwith close_notify both waysreloada connection opened before SIGHUPkeeps echoing; one after it gets the renewed certificate (by serial); after a refused reload the renewed one still servesbound--handshakes 2: two peers that send nothing hold both places; an honest client is delayed 1.26 s, not refused, and completes when their 1.5 s deadline frees onerate--rate 5: 15 clients at once all complete in 2.0 s, the last having waited 2.0 sfull--connections 4: a fifth connection is closed at once (0 ms), before a handshakeidle--idle 1000: close_notify after 1.00 sshutdownSIGTERMwith 10 connections: 10 close_notify, exit 0peerthe established, closed and refused lines each say peer=127.0.0.1:<the client's source port>per-address--per-address 3: a fourth connection from 127.0.0.1 is closed at once (0 ms) asper-address; a client from 127.0.0.2 (Linux only; macOS has no second loopback address, and the step says it was skipped) is served; a place freed by a close is reusableaddr-rate--per-address-rate 2: six clients from one address complete in 2.0 s (the last waited 1.99 s); a client from 127.0.0.2 meanwhile took 48 ms (Linux)areas--areas 16: 1,000 clients are all served (their handshakes delayed to the areas free, not refused), 6 of the 1,000 established connections used at once, all closedok; on Linux the server’s resident memory for the 1,000 idle ones is measured and must be under 60 KiB each (17.8 on arm64; 1,000 and not 120 because CI’s transparent huge pages count the areas in use in 2 MiB units)stall--areas 2: a peer that sends half a record and stops is endedrecord-timeoutafter--handshake-timeout, while another connection is echoed on the second area;--areas 1is a usage errorcurl and mosquitto are not in it: they speak HTTP and MQTT, which an echo answers with their own request; their interop with this engine is §10.2’s matrix.
-
Mutants. Eight single edits to
echo.cho, each built and run against the case that should catch it; 8 of 8 killed: the handshake bound ignored (bound: not delayed), the rate ignored (rate), no handshake deadline (bound), no idle timeout (idle, andconformance/tls_echo.rs), a stop that leaves established connections to the drain (shutdown), a reload that adds an identity instead of replacing it (reload: the old certificate still served), the connection ended as soon as the peer’s close_notify is read (many), and no table bound (full). -
Found while building it, both in the example and fixed there:
tls.eventsaysclosedas soon as the peer’s close_notify is read, before the server’s own is queued, so a loop that ends a connection onclosedcloses the socket without answering (Python’ssslsawUNEXPECTED_EOFon everyunwrap); the example ends a connection onclosedonly once it has queued its own. And a client’s Finished and its end of file read in one turn made the connection fail before the loop had logged it established (openssl s_time’s connections were never logged); the socket is now read only when nothing else moved.
11.4 Cost
python3 scripts/tls_echo_test.py <tls_echo> --cost 20: openssl s_time -new (OpenSSL 3.0.13, its default X25519
share, the server’s AES-128-GCM) for 20 s a row, the server’s CPU from /proc/<pid>/stat divided by the handshakes.
LLVM backend; the machine of §6 (Ubuntu 24.04, linux-aarch64, Docker’s 6-vCPU VM on the Apple M4 Max, the host’s load
average 2 to 4):
| clients, server bounds | handshakes a second | server CPU | ms of CPU a handshake |
|---|---|---|---|
| 1, none | 316 | 94% of a core | 2.98 |
| 4, none | 341 | 104% of a core (one thread: saturated) | 3.04 |
4, --rate 200 (the first default) | 210 (4,204 in 20 s, 21 windows) | 66% of a core | 3.15 |
4, the defaults (--rate 100) | 105 (2,104 in 20 s) | 34% of a core | 3.27 |
So about 3.0 ms of CPU a full handshake, 330 to 340 handshakes a second on the one core the loop runs on, and the
rate bound holds a flood of handshakes to the share of a core it was set for. Beside it, alternating in one session,
tls_serve (§6) under the same client measured 3.82 and 3.94 ms against the example’s 3.03 and 2.99: the correction
in §6.
x86-64, CI’s tls-assurance job on this PR (GitHub’s ubuntu-latest runner, OpenSSL 3.0.13, the same command), the
first measurement of the server on x86-64:
| clients, server bounds | handshakes a second | server CPU | ms of CPU a handshake |
|---|---|---|---|
| 1, none | 170 | 94% of a core | 5.49 |
| 4, none | 189 | 104% of a core | 5.53 |
4, --rate 200 | 188 | 104% of a core: the bound above what the core can do | 5.55 |
4, the defaults (--rate 100), the next run | 105 (2,104 in 20 s) | 59% of a core | 5.58 |
About 5.5 ms a handshake there, 1.8 times the M4’s, in line with docs/ecdsa-sign.md §7’s signer (1.5 ms on an
i7 against 0.82 on the M4, and as much again for the check). It is why the rate’s default is 100, not 200 (§11.2). The
8 cases passed there too (many in 1.9 s).
11.5 Not done, and not verified
http.serverover TLS: built after this section (docs/http-server.md§11): the package has a byte-fed mode andexamples/https_hellois this example with it where the echo is.- More than one core. One thread, as the design’s programs are today; a second core is a second process on the
same port, which
tcp_listen’sSO_REUSEPORTflag allows, not tried. - A flood from many addresses was not run; the bounds are per process, not per peer. Corrected
(
conn-peer.md):std.connsnow gives the address (conns.peer), andexamples/tls_echohas the per-address bounds (--per-address,--per-address-rate,front.cho), tested with clients from two addresses (scripts/tls_echo_test.py:per-address,addr-rate). A flood from many addresses against the table of 1,024 keys is still not run. - Cranelift: the tests in CI build the example with the LLVM backend. Built with Cranelift by hand on darwin-aarch64,
it passes the same 8 cases (
manyin 3.4 s against LLVM’s 0.8); its cost is not measured. - Not independently reviewed (#209), as the engine.
12. Session tickets: the design
Step 5 of §8, issue #379, part of #378. Written before the code. The sizes are arithmetic from the layout below and are checked by the build (§12.10); no cost is claimed here, §12.9 holds what is measured. Where building finds this false, the section that said it is corrected in place.
What it buys. A full handshake costs 3.0 to 5.5 ms of CPU, of which the certificate signature and its check are 2 to 3.5
ms (§6, §11.4). A client that comes back should not pay them again. A resumed handshake still does one key exchange and
no signature: the same shape as the client’s option A (docs/tls-resumption.md §2), seen from the server.
12.1 The ticket: stateless, sealed
A ticket is the server’s own state, handed to the client and read back (RFC 8446 §2.2, §4.6.1; RFC 5077’s idea). The server keeps nothing per ticket, so a restart, a second process and a second host resume each other’s tickets when they hold the same ticket key and the same identity (§12.3).
ticket = key_name (16) || salt (32) || AEAD(subkey, nonce = 0, aad = version || key_name || salt, plaintext) || tag (16)
subkey = HKDF-Expand(HKDF-Extract(salt, ticket_key), "cancho tls ticket v1", 32)
plaintext (version 1) =
version (1) = 1 the format; a ticket of another version is not read
auth (1) = 0 how the client was authenticated: 0 none (§12.5 g)
issue_ms (8) the engine's clock when the ticket was made
expiry_ms (8) issue_ms + lifetime, never past the certificate's notAfter
age_add (4) this ticket's ticket_age_add, so the client's obfuscated age can be undone
suite (2) the cipher suite of the connection
hash_len (1), psk the resumption PSK of this ticket, 32 or 48 bytes
fp (32) SHA-256 of the leaf certificate (DER) of the identity that issued it
sni_len (1), sni the host name the client sent, lowercased, as the engine kept it (0 bytes: none)
alpn_len (1), alpn the protocol chosen, if any
- The AEAD is ChaCha20-Poly1305 (
std.chacha20): constant time in software on every CPU, so a ticket opens in the same time whatever the key, and the same code on both backends. - A nonce that cannot repeat: no nonce is shared. The 12-byte nonce is zero, and the key is not: every ticket has its
own subkey, derived from the ticket key and 32 fresh bytes (
salt) of the engine’s DRBG. A (key, nonce) pair then seals one message because the key is new each time, and two tickets share a subkey only if two draws of 256 bits agree. This is why a counter was rejected: a counter in each process collides across the processes of §12.3, and a 96-bit random nonce under one shared key reaches a 2^-32 collision after 2^32 tickets, which a busy fleet makes in days, and a repeated ChaCha20-Poly1305 nonce gives away the Poly1305 key and the XOR of two PSKs. The cost is one HKDF-Extract and Expand (a few HMAC-SHA-256 blocks) a ticket. - The ticket is 64 bytes of overhead and at most 681 bytes in all: 16 + 32 + (1+1+8+8+4+2+1+48+32+1+255+1+255 = 617)
- 16 = 681 at the largest (a 255-byte name and protocol, SHA-384); typically a name of 20 bytes and
h2: about 250. The client keeps tickets of up to 2,048 bytes (docs/tls-resumption.md§4), so every ticket fits.
- 16 = 681 at the largest (a 255-byte name and protocol, SHA-384); typically a name of 20 bytes and
- What the client sees is opaque bytes. It learns the lifetime,
ticket_age_addand a nonce (the index of the ticket on the connection) in the clear, all of which it knows anyway. - The PSK is RFC 8446’s:
HKDF-Expand-Label(resumption_master_secret, "resumption", ticket_nonce, Hash.length), withticket_noncethe one byte index of the ticket on the connection, so tickets of one connection have different PSKs. Storing the PSK in the ticket, not the resumption master secret, means a ticket opens to exactly one thing and a stolen ticket’s contents give nothing about its siblings.
12.2 The ticket key, and its rotation
- A key is 32 bytes. Its name is the first 16 bytes of HMAC-SHA-256(key, “cancho tls ticket key name v1”), so the name is a function of the secret and every host given the same key computes the same name; nothing else needs to be agreed.
- The engine holds up to 4 keys: one current, which seals, and up to 3 previous, which only open. A previous key
has an
open_untilof the moment it stopped being current plus the ticket lifetime: a ticket it sealed is not valid longer than that anyway (the ticket carries its own expiry), so the key is dead weight afterwards and refuses (unknown). That is the lifetime bound: a key is useful for at most two lifetimes (one as current, one as previous). - The engine makes the key when the program gives none: 32 bytes from its DRBG, the first time a connection is
served with tickets on, and again every lifetime (so a ticket sealed at the end of a key’s life is still opened
during the next). The program calls
tls.set_time(engine, now_unix_ms)orserve, and the engine rotates on its own clock. A key made this way is the engine’s alone: it is never shown, as the identity keys are not (§4). So tickets made by it are good for this process only, and for a restart none survive. - The program supplies keys for anything wider than one process:
tls.set_ticket_keys(engine, keys, now_ms)takesn× 32 bytes (1 ≤ n ≤ 4), the first current and the rest previous. It is declarative: the engine reconciles with what it holds (a key already held keeps its history; a key not listed is overwritten; a listed key it did not hold opens tickets for one lifetime fromnow_ms), and a call with the keys it already has changes nothing, so a program may call it on everySIGHUP. A program that supplies keys turns the engine’s own rotation off; rotating is then the operator’s: prepend a new key to the file, keep the old ones for a lifetime, drop them.tls.rotate_ticket_key(engine, now_ms)is the single-process version: draw a key, make it current, keep the old as previous. - A fleet gives every process the same file. The processes agree on the key name by construction (above), so one process’s ticket opens in another, provided they also hold the same identity (§12.3). The ticket key is the only secret they share, and it protects tickets, not the sessions of the full handshake. The file is as sensitive as an identity’s key: anyone who has the ticket key can make tickets, and so can make a client believe, for the lifetime, that it is resuming a session it never had. (They cannot read past sessions: a resumed connection does a fresh key exchange, §12.5 a.)
12.3 What a ticket is bound to, and what it is refused for
A ticket is a stored verdict that a full handshake happened, as the client’s tickets are (docs/tls-resumption.md §3):
every way the verdict could be stale is a rule. The model is the client’s eight rules; the server’s are these. A ticket
that fails any rule is not an error: the server ignores pre_shared_key and does a full handshake, which is always
possible and always safe, and the connection’s verdict says why (§12.7). The exceptions are the two failures RFC 8446
demands as aborts, below.
| rule | the client’s rule it mirrors | the decision | |
|---|---|---|---|
| a | psk_dhe_ke only. The ClientHello’s psk_key_exchange_modes must list psk_dhe_ke (1). A hello that lists only psk_ke (0) is a full handshake; pre_shared_key without psk_key_exchange_modes is missing_extension (RFC 8446 §4.2.9: a MUST). The ServerHello always carries a key_share. Tickets are sent only to a client that listed psk_dhe_ke, as Go’s and rustls’s servers do (§4.2.9 says a server SHOULD NOT send tickets the client’s modes cannot use). | rule 8 (the ServerHello must carry a key_share; a resumption without one is refused) | A fresh X25519, P-256 or P-384 exchange on every resumption: forward secrecy is kept; the ticket key and the PSK together do not give a resumed session’s keys. |
| b | Never early data. early_data in a ClientHello that offers a ticket is accepted as an extension and not accepted as 0-RTT: EncryptedExtensions has no early_data, and the client’s early records are skipped by trial decryption (RFC 8446 §4.2.10), up to 16 KiB, as §5.2 already does for a full handshake. A NewSessionTicket never carries early_data (max_early_data_size), so no compliant client sends any. | rule 6 | |
| c | Lifetime and age. expiry_ms is issue_ms + lifetime, capped at the leaf’s notAfter; a ticket is refused (expired) at or after it. The ClientHello’s obfuscated_ticket_age minus the ticket’s age_add, mod 2^32, is the age the client claims in ms; the server’s own age is now - issue_ms; they must agree to within 30 s (age). A ticket from the future (another host’s clock ahead) is the same test: its server age is negative and the two ages differ by more than the window. The lifetime sent is the setting (default 1 hour) capped at 7 days (RFC 8446 §4.6.1) and at the certificate’s remaining life. | rules 3 and 4 | The window is wide because the client’s age is counted from when it received the ticket (this package’s client counts from the start of the connection that got it: docs/tls-resumption.md §11) and the server’s from when it issued it, and the engine’s clock is the one the program last gave it. It is not a replay defence (§12.4), only a freshness one. |
| d | Bound to the name, the identity, the suite. The ticket carries the host name the client sent and a SHA-256 of the issuing identity’s leaf certificate. A resumption must send the same name (name), and the identity that name selects now must have that same certificate (identity): replace_identity with a different certificate refuses every ticket of that identity, because the fingerprint changes; with the same certificate (a reload that found nothing new) it refuses none, and tickets stay good across a process restart that loads the same files. The suite chosen for the new connection must hash as the ticket’s (suite, RFC 8446 §4.2.11: the PSK’s hash must be the cipher suite’s); the ALPN protocol the new connection chooses must be the one the ticket records (alpn). | rules 1, 2, 3 (name, trust store, certificate) | The fingerprint, not an identity number or a generation counter: a counter is per process and would make §12.3’s fleet refuse its own tickets, and a number can be reused. The certificate’s chain is not part of the fingerprint: the leaf is what the client verified the name against, and a changed intermediate is the operator’s affair (§4). |
| e | Replay. §12.4. | rule 5 (used once) | A ticket is not single use on the server; the argument and the bound are in §12.4. |
| f | The binder, in constant time. The binder is recomputed over the transcript through the ClientHello’s pre_shared_key truncated before its binders (RFC 8446 §4.2.11.2) and compared with every byte examined, as the client’s Finished is (§10.1). A ticket that opened and passed the rules above but whose binder is wrong aborts the handshake (RFC 8446 §4.2.11: the server MUST abort) with decrypt_error (tls-server-binder; §6.2 names that alert for a PSK binder that does not validate), after a HelloRetryRequest too (the transcript then holds it). OpenSSL aborts too, but with illegal_parameter (47); the differential, §12.12, records it. | the client’s own binder check, which the client does not need | An unopenable ticket is no abort (the server cannot know it was ours): a full handshake. Only a ticket the server authored and a binder that does not match are an abort: nothing a bystander can use as an oracle, since opening the ticket needs the ticket key. |
| g | No client certificate yet. A resumed session skips every step of a full handshake that is not the PSK’s proof, among them the client’s certificate (#384). The ticket’s auth byte is 0 today because this server asks for none. When #384 lands, a connection whose client authenticated carries a ticket only if the program asks for it, and the ticket then holds the client’s certificate fingerprint and notAfter, is refused after the client trust store changes, and its auth is 1. Until then a ticket with auth ≠ 0 is refused (auth), so an older server that meets a newer server’s ticket in a fleet never resumes a connection the client authenticated and the older cannot represent. | rule 2 (trust store) and the “verify-nothing” hazard of docs/tls-resumption.md §3 | The safe default for #384 is no tickets on authenticated connections; the conservative path costs a client-authenticated peer a full handshake (the signature is the server’s, which is the cost this document removes, but the client’s certificate chain verification is the other half, and it too is saved only if the ticket carries the verdict). |
| h | How many, when, and KeyUpdate. §12.6. |
12.4 Replay (rule e)
What RFC 8446 requires. Replay protection is a requirement of early data: §8 (“Replay Attacks on 0-RTT”) exists because
0-RTT data is accepted before the handshake completes and can be delivered twice. The three defences it describes
(single-use tickets, a ClientHello record, freshness checks) are for a server that accepts early data. A server that
never accepts early data and only accepts psk_dhe_ke has no data that a replay can deliver twice. Section 8 asks it for
nothing further, and §4.6.1 does not require a server to make tickets single use (it asks the client to use each once,
Appendix C.4, to avoid linking).
Why a replayed resumption is harmless. An attacker who records a ClientHello with a valid pre_shared_key and sends it
again can make the server do three things: open the ticket, compute a key exchange against the client’s old key share,
and send its flight. It cannot continue: the handshake keys depend on the DHE secret, which needs the client’s ephemeral
private key, so the attacker can neither read the server’s EncryptedExtensions nor produce the client Finished, and the
server’s flight is encrypted to a key only the original client could derive. The server then waits for a Finished that never
comes. Nothing was authenticated, no application data was exchanged, no state changed. What the attacker gains is one key
exchange of the server’s CPU a replay (about 1.1 ms; a fresh full ClientHello costs the attacker nothing more and the
server a signature besides, so the replay is cheaper for the server than the attack the program must already bound): it is
inside the handshake bounds the program sets (§7: tls.handshakes_in_progress, the rate, the per-address limits).
The bound. (1) The ticket’s expiry (lifetime, 1 hour by default) bounds the whole ticket. (2) Because the ClientHello
repeats the original age, a captured ClientHello is accepted only while the server’s own age is within 30 s of the
age it claims: after that, age refuses it, so a recording is replayable for 30 seconds, not for the lifetime. (A client
that reuses a ticket gives a fresh age each time and is accepted until expiry: that is a client reusing a ticket, which the
packages/tls client does not do, and which the server cannot tell from a replay and does not need to.) (3) A program that
wants single use can keep state of its own, a set of binder values seen in the window, and refuse a repeat before serve
(the binder is in the ClientHello it reads); the engine keeps none, because state shared by a fleet is exactly what a
stateless ticket exists to avoid. Tested with a lying client that replays one ticket N times, and a captured
ClientHello twice, in and out of the window (§12.8).
What this does not claim: a stolen ticket key is a different attack (§12.2), and a replay is not a denial-of-service defence: that is the program’s bounds.
12.5 The refusals that are not fallbacks, and the tags
The ones that end the connection, each a tls-server- tag and the alert RFC 8446 names, all reached by a lying-client
case:
| tag | alert | when |
|---|---|---|
tls-server-binder | decrypt_error (51) | the ticket opened and passed the rules, and the binder does not match (RFC 8446 §4.2.11 abort, §6.2 alert) |
tls-server-missing-extension (existing) | missing_extension (109) | pre_shared_key without psk_key_exchange_modes (§4.2.9) |
tls-server-client-hello-format (existing) | decode_error (50) | pre_shared_key or psk_key_exchange_modes that does not parse; a number of binders that is not the number of identities is tls-server-illegal-parameter (47) |
The configuration calls answer tls-server-ticket-config (a count over 8 or a lifetime outside 1 s to 7 days) and
tls-server-ticket-key (a key list that is empty, not a multiple of 32 bytes or over 4 keys), on the line, as
set_alpn’s tls-server-alpn-list does.
12.6 Number of tickets, NewSessionTicket, KeyUpdate (rule h)
- Count.
counttickets (0 turns tickets off and is the default; at most 8;--tickets 2in the examples, OpenSSL’s number) are sent on each connection that listedpsk_dhe_ke, after the client’s Finished has been checked (RFC 8446 §4.6.1: a NewSessionTicket is a post-handshake message, under the server’s application key), in the same turn as the check, so a client that starts to read finds them. A resumed connection is sentcountnew tickets too (a new ticket per connection keeps the client’s use of a ticket single, as the client does). They are not sent on a connection that failed. eventsayswant_writewhile the engine has output, so a connection that ends its handshake with tickets queued does not reportestablisheduntil they are taken. Found by the interop run, not by design:tests/programs/tls_serve.choanswered a request only wheneventsaidestablishedafter a read, and a client that sent its Finished and its request in one segment (OpenSSL does) hung in about one connection in six with tickets on and never without. The engine is as documented (docs/tls-pure.md§2.2: the event once the output is taken); the program, which has data waiting, must not wait for the event (the harness now answers whenever it has data), andexamples/tls_echoandexamples/https_hello, which flush before they look at the event, never had it: 1,440 connections of 120 clients at once, 1,320 resumed, no error.- A connection that never reads them costs
count× about 250 to 700 bytes of the slot’s output, whichfeedalready bounds (it stops reading while less than 1 KiB is free). - KeyUpdate. A ticket is made once, at the end of the handshake, under the server’s first application key; it does not depend on a later key. A KeyUpdate from the client changes the keys of records after it, not the ticket, which is already queued under the old; a NewSessionTicket is never sent after one. No interplay.
- Settings:
tls.set_tickets(engine, count, lifetime_s): count 0 is off (the default, so a program that has not asked sends none, and the 110 recorded connections of §10.3 are unchanged), lifetime 3,600 s unless set.
12.7 The interface and the engine’s verdict
tls.set_tickets(engine, count, lifetime_s) -> 0 | tls-server-ticket-config // server engines: tls-role otherwise
tls.set_ticket_keys(engine, keys, now_ms) -> 0 | tls-server-ticket-key // n x 32 bytes, first current (§12.2)
tls.rotate_ticket_key(engine, now_ms) -> 0 | tls-no-entropy // draw a key, make it current
tls.set_time(engine, now_ms) -> 0 // the engine's clock for tickets (§12.3 c)
tls.ticket_keys(engine) -> int // how many keys open tickets now
tls.resumed(engine, slot) -> bool // as for a client
tls.ticket_verdict(engine, slot) -> int // why a connection did or did not resume
tls.ticket_verdict_tag(verdict) -> "tls-ticket-..."
The clock only moves forward, and any value forward is taken (decided after review). A jump of more than a lifetime
cannot be told from a server that was idle that long, which is common (no connection for a night), so refusing it would stop
the clock of an honest server; and a wrong value far ahead fails safe: every ticket made then is dated in the future and its
client’s age is refused (age), every ticket made before is expired, the engine’s own key is not replaced until real time
passes its date, and nothing is ever accepted that a correct clock would refuse. The cost of the mistake is full handshakes
until the real time catches up, which is the program’s to avoid: it gives the engine the same clock it gives serve.
The engine keeps no clock of its own (§5.3): tickets are issued and checked at the time the program last gave it,
through serve, set_time or set_ticket_keys. A program with connections that last longer than the lifetime calls
set_time each turn of its loop, as the example does; otherwise a ticket’s issue time would be the start of its
connection. The verdict is a small integer with a tag, for logs, tests and the differential; it is not a failure. The
verdicts, in the order the rules are tried (§12.3): resumed, off, no-psk-dhe-ke, unknown (not our key, a key past its
open_until, or a size no ticket has), tampered (our key, and the AEAD says no), format, auth, expired, age,
name, identity, suite, alpn, and none for a hello with no pre_shared_key. At most the first 4 identities of
a pre_shared_key are tried, a ticket of more than 681 bytes is unknown without being opened, so a hello cannot make
the server do more than 4 small AEAD opens.
12.8 How it will be tested (the results are §12.12)
- Unit and conformance, both backends: the ticket module sealed and opened (every field, every rule), run through
tests/programs/tls_server_driver.cho’s new lines and recorded with the rest ofliar_client.txt(crates/cancho/tests/conformance/tls_server.rs). - The lying client (
scripts/tls_liar_client.py, one case per rule): an expired ticket, a wrong binder, a wrong name, a wrong suite hash, a tampered and a truncated ticket, a ticket from a rotated-out key, a ticket after an identity was replaced (and after it was reloaded unchanged), an early-data attempt with a good ticket,psk_keoffered,pre_shared_keywithout modes, a ticket replayed N times and a ClientHello replayed in and out of the age window, a ticket of 15,000 bytes (a 64 KiB one cannot be sent: a ClientHello over 16 KiB is refused before any ticket is read, §5.2), a wrong age, an honest resumption (every suite, each group, after a HelloRetryRequest). Every refusal reached, every verdict reached. - Interop (
scripts/tls_server_interop.py): OpenSSL (s_client -sess_out/-sess_in), curl, Go, wolfSSL, GnuTLS, rustls, mosquitto if its clients resume, andpackages/tls’s own client resumes against the server. - Differential against
openssl s_server: the same resumption ClientHellos, the outcome (resumed or not, the alert) compared. - Fuzz: the corpora gain ClientHellos with a ticket;
fuzz_helloandfuzz_serverrun them. - Mutants:
scripts/tls_server_mutants.pygains one mutant a decision above. - Cost: a resumed handshake against a full one, on x86-64 and arm64 (§12.9).
12.9 Cost
Measured (the expectation written before the build was a resumed handshake of about one key exchange, 1.1 to 1.5 ms,
against 3.0 to 5.5 ms). python3 scripts/tls_echo_test.py <tls_echo> --cost 6 --tickets --rounds 7: the server of
examples/tls_echo (LLVM backend, one thread, a P-256 identity, AES-128-GCM chosen by the server on both machines), a Python
ssl client making connections one at a time for 6 s, the server’s CPU (user and system, /proc/<pid>/stat or ps) divided
by the handshakes its own established lines count. Three rows, alternating, 7 rounds each, so a machine whose speed
changes over the minutes moves all three together; the median and the best of the 7:
| full, tickets off | full, 2 tickets sent | resumed | resumed / full | |
|---|---|---|---|---|
| arm64: Apple M4 Max, macOS 26.2, native; the host busy with other builds (load average 11 to 17) | 3.15 ms (best 3.07) | 3.20 ms (best 3.12) | 1.52 ms (best 1.50) | 0.48 |
x86-64: Intel i7-1260P, Linux, pinned to core 6 (taskset -c 6), the box shared (load average 4 to 10) | 9.74 ms (best 5.47) | 9.63 ms (best 6.82) | 3.46 ms (best 2.91) | 0.35 of the median, 0.53 of best to best |
- A resumed handshake costs about half a full one on arm64 (1.52 against 3.15 ms), and about a third to a half on x86-64.
What it still pays is the key exchange (the X25519 key pair and shared secret, 1.14 ms on the M4 of
docs/tls-resumption.md§1) and the key schedule, the records and the transcript: 1.5 ms, inside the 1.1 to 1.5 expected. - Issuing two tickets on a full handshake costs 1.6% on arm64 (3.20 against 3.15 ms) and nothing a noisy x86-64 box can see (9.63 against 9.74; the best of 7 is 6.82 against 5.47, which is the box).
- The x86-64 figures move more than a factor of three between runs: five single runs of the same rows (an earlier form of the script, with
openssl s_time -newfor the full handshakes), minutes apart, gave a full handshake of 19.6, 19.9, 12.5, 5.2 and 5.8 ms and a resumed one of 8.3, 7.0, 4.7, 3.2 and 2.5 while other jobs on the box came and went (load average 4 to 10 on 16 cores; the other cores are theirs), and CI’s runner measured 5.5 ms for a full handshake of the same code in §11.4. The ratio ranged from 0.35 to 0.62 and the absolute figures are the box’s; the quiet runs, 5.2 and 5.8 ms full against 3.2 and 2.5 resumed, are those of CI. The rowtls_servegives (python3 scripts/tls_server_cost.py <tls_serve> 6 --tickets --rounds 9, whose loop costs more than the example’s, §6) is the same shape: X25519, full 14.7 ms median (best 7.5), 15.4 with tickets (best 7.6), resumed 6.3 (best 3.3), 0.43; P-256, full 21.3 (best 15.6), resumed 7.0 (best 2.8). - Not measured: the Docker linux-aarch64 of §6 and §11.4 (its disk was full of other jobs’ images when the run was due); the Cranelift backend; a resumption through a HelloRetryRequest (it costs one more message hashed, §6).
12.10 Memory
Per slot: nothing new. A server slot reuses the client’s resumption fields it never uses: the PSK and the resumption master
secret (among the keys forget overwrites), and the area that holds a client’s ticket for the 36 bytes of randomness a ticket
needs, a salt and a ticket_age_add, neither a secret, overwritten when the tickets are made. Per engine: the ticket keys and settings,
4 keys of 72 bytes and 8 bytes of header, 296 bytes, allocated by open_server only; and an identity gains its
certificate fingerprint and notAfter, 40 bytes each, 640 bytes for 16. A ticket needs no storage on the server.
12.11 What was built
| File | What |
|---|---|
packages/tls/ticket.cho (tls_ticket) | the ticket (§12.1): sealed and opened; the ring of 4 ticket keys, make_current, set_keys, the engine’s own rotation (due); the rules (§12.3) as one function, check; the settings; the verdicts and their tags |
packages/tls/hello.cho | the ClientHello’s pre_shared_key and psk_key_exchange_modes parsed (ch_psk_*, psk_entry), the ServerHello’s pre_shared_key, the NewSessionTicket encoded |
packages/tls/server.cho | try_resume (the identities tried, the rules, the binder), the key schedule from the PSK, a flight with no Certificate, issue_tickets after the client’s Finished |
packages/tls/identity.cho | an identity records its leaf’s SHA-256 and notAfter |
packages/tls/tls.cho | set_tickets, set_ticket_keys, rotate_ticket_key, set_time, ticket_keys, ticket_verdict, ticket_verdict_tag; the engine’s clock; resumed answers for a server slot |
packages/tls/record.cho, slot.cho | three tags (tls-server-binder, -ticket-config, -ticket-key) and the alert of the first |
examples/tls_echo, examples/https_hello | --tickets <n>, --ticket-lifetime <s>, --ticket-keys <file>; SIGHUP reads the key file again; tls.set_time each turn of the loop; `resumed=yes |
server.cho is 880 lines, ticket.cho 655, tls.cho 1,025; no file passes 2,000.
12.12 Tests and results
All commands from the repository root; the Linux ones in the image of §10.2 (Ubuntu 24.04, linux-aarch64, Docker on the Apple
M4 Max), the x86-64 ones on a Linux box (an Intel i7-1260P, taskset -c 6).
-
The lying client (
python3 scripts/tls_liar_client.py <server driver> tests/vectors/tls/liar_client.txt): 190 connections, the 110 of §10.3 unchanged byte for byte (but the role case, which gained four calls) and 80 new, inscripts/tls_liar_tickets.py: 61 endok(resumptions, and fallbacks each checked against the verdict the engine gave:Uin the driver) and 19 end in a refusal or a configuration answer (6tls-server-binder, 6tls-server-client-hello-format, 2tls-server-ticket-key, and one each of-finished,-illegal-parameter,-missing-extension,-ticket-configandtls-no-entropy), listed below by rule; 7 of them added after review (a key listed twice, and the sixmidway:cases of §12.14). Recorded;conformance/tls_server.rsreplays every one on both backends, and the recording made on x86-64 is identical to the one made on arm64.rule cases honest a full handshake sent 2 tickets with distinct PSKs; resumed under AES-256-GCM (SHA-384, a 48-byte PSK), ChaCha20 and AES-128, with X25519 and P-256, after a HelloRetryRequest, for the second identity with ALPN, the good ticket second of three and fifth of five (only four are tried), one ticket used three times, a chain of resumptions, early data offered and skipped, a ticket cut to the certificate’s remaining life, one sealed by the lying client itself in §12.1’s format (a cross-implementation check of the format and the key schedule), the clock moved during a long connection a (psk_dhe_ke only) psk_keonly: full, no ticket sent; no modes at all: none sent;pre_shared_keywithout modes:missing_extension; an empty mode listb (no early data) the early-data attempt above; a NewSessionTicket never carries an extension (asserted for every ticket the client reads) c (lifetime, age) expired, at exactly the expiry, one millisecond before; age 60 s high, 31 s low, 29 s off; from a clock 60 s ahead; a lifetime cut by the certificate d (name, identity, suite) another name, no name, replace_identitywith a new certificate (refused) and with the same one (kept), the SHA-384 offered a SHA-256 ticket, another ALPN protocol, a made-up fingerprinte (replay) one ticket three times; a captured ClientHello replayed inside the window (resumed) and 60 s later ( age)f (binder) wrong first byte, wrong last byte only, one byte too long, wrong PSK, after a HelloRetryRequest with the retry left out of the transcript, a forged ticket with a wrong binder; and a wrong binder on a ticket that fails a rule is not an abort keys previous key opens, a dropped key does not, the same keys given again change nothing, a ring of four (the fifth rotation evicts the oldest; three leave it), the end of a previous key to the millisecond, the engine’s own rotation after exactly a lifetime, supplied keys never replaced by the engine’s tampering one bit of the text, the tag, the salt; truncated by 10 and to 40 bytes; the key name; 15,000 bytes; one byte appended; a key holder’s ticket of version 2, with trailing bytes, a 20-byte PSK, auth1settings count 9, lifetime 0 and 8 days, keys of 0, 31, 33 and 160 bytes refused; rotation before the engine is seeded; the ticket calls on a client engine ( tls-role); the count raised mid-handshakemalformed identities and binders of different numbers, an identity running past its list, an empty identity (alone and first of two), a binder of 31 bytes (alone and two) -
Mutants (
python3 scripts/tls_server_mutants.py target/release/cancho): 146 of 146 killed, none argued equivalent: the 71 of §10.6 and 75 new (3 after review: the identity bound at the Finished, a repeated key,save_toon a server), one per decision of §12.3, the key ring, the parser and the settings. A first run killed 140 and left three, each a gap in the cases and not in the code: a binder of 31 bytes was caught by the list’s own minimum before the per-binder one; an empty identity alone was too short for the list’s; and the “binder too long” case had a wrong binder anyway, because the lying client’s own binder was computed over a header one byte short (a bug in the test, found by the mutant that survived it). Each got a case that only its mutant’s line can fail.scripts/tls_mutants.py(the client’s) still kills 103 of 103. -
Interop (
python3 scripts/tls_server_tickets_interop.py <tls_serve> --many <tls_many>,tls_servewith tickets on; CI’stls-assurancejob runs it, and the ticket differential below, on x86-64 since review): 16 rows, 16 ok:client rows openssl s_client -sess_out/-sess_in, OpenSSL 3.0.13AES-128, AES-256 and ChaCha20 with X25519; AES-128 with P-256 and P-384; a HelloRetryRequest first: each resumes ( Reused, TLSv1.3)curl 8.5.0 two URLs in one run resume Go crypto/tls1.22.23 connections: full, resumed, resumed wolfSSL 5.6.6 the same GnuTLS gnutls-cli --resume3.8.3resumes rustls 0.23 on ring full, resumed, resumed ( handshake_kind)mosquitto 2.0.18 does not resume: libmosquitto never sets a session on its SSL object; two mosquitto_subconnections are both full, and the row asserts it, so a version that resumes fails it and says sopackages/tls’s own client (tls_many resume)8 connections, then 8 resumed two server processes sharing a key file a session from A resumes at B; at a third process with another key, a full handshake with the verdict unknowna ticket of 2 s lifetime, 3.5 s later a full handshake: OpenSSL’s client does not offer an expired session ( none); a client that did is the lying client’sexpiredThe example’s own live tests (
scripts/tls_echo_test.py,scripts/https_hello_test.py, the last item of this list) add Python’sssl. -
The differential (
python3 scripts/tls_server_tickets_differential.py <tls_serve>, againstopenssl s_server3.0.13 with the same identity and tickets on): the same client behaviour against each server’s own tickets, 12 cases: 9 agree, 1 differs in the alert only, 2 differ asEXPECTEDsays. They agree on an honest resumption, a ticket used twice,psk_keonly,pre_shared_keywithoutpsk_key_exchange_modes(both abort,missing_extension), a ticket with one bit changed, a truncated one, one of 15,000 bytes, early data offered (both resume and skip it), and a good ticket second of two behind a made-up one. A wrong binder: this serverdecrypt_error(51, the alert RFC 8446 §6.2 names for a binder that does not validate), OpenSSLillegal_parameter(47). The two that differ on purpose: OpenSSL resumes a ticket for another host name (it does not compare it; this server does), and one whose claimed age is 60 s off (OpenSSL does not check the age without early data; this server does, within 30 s).scripts/tls_server_differential.py(the ClientHello cases of §10.4) is unchanged: 59 agree, 7 alert, 6 as documented, skipping the ticket cases, which are several connections. -
Fuzzing (
scripts/fuzz_afl.py, AFL++ 4.09c in the image, one core a harness):fuzz_servernow runs with tickets on under a fixed key (tests/programs/fuzz_server_fixture.cho), and both harnesses are seeded with 13 ClientHellos that resume (scripts/tls_server_fuzz_seeds.py, intests/vectors/fuzz/server/andhello/): a ticket that is right, one off in each field, a wrong binder, several identities,psk_keonly, no modes, early data, SHA-384, after a HelloRetryRequest. Each seed reaches the verdict it is named for (checked with the driver’sUline). A first campaign on the final code:fuzz_hello2,915,676 executions (4,165 a second, 155 of 418 edges),fuzz_server454,125 executions (303 a second, 1,457 of 12,172 edges, against 1,283 of 11,069 before tickets), 0 crashes, 0 hangs; CI’stls-assurancejob, two minutes each on x86-64: 530,333 and 27,638 executions, the same edges, 0 and 0. The server’s edges had stopped growing at 1,457 within the two minutes of CI (the seeds start the fuzzer at the binder and the key schedule); a longer second campaign was due and did not run: the Docker VM’s disk was full of other jobs’ images when it was started.conformance/tls_fuzz.rsreplays the whole committed corpus on both backends.scripts/tls_fuzz.py --server(one line of the lying client’s bytes mutated, bits flipped, cuts, insertions, lengths set) now takes the 55 ticket cases that endokbeside the 26 honest connections: 40,000 mutated connections over 81 recorded handshakes, 0 traps. -
Live tests of the examples (
scripts/tls_echo_test.py, 4 new cases of 15, all ok;scripts/https_hello_test.py, 1 new of 20; andconformance/tls_echo.rs,https_hello.rsagainstpackages/tls’s own client): a session kept by Python’ssslresumes three times from one session; a session from one process resumes at another that holds the same key file, not at one with another; a rotation bySIGHUP(a new key on top) keeps the old session, a second rotation that drops its key refuses it, a file that is not hex is refused (reload tickets refused tls-server-ticket-key) and the keys stay; a certificate renewed and loaded bySIGHUPrefuses the tickets of the old one and a reload of the same certificate keeps them; a session offered after its 2 s lifetime does not resume.
12.14 Where this differs from the design, and what building it found
- The ticket is 681 bytes at most, not 705: the arithmetic of §12.1 was right and its first sum wrong (corrected in place).
- A ticket from the future is not a separate test. The first draft had
age < -windowbeside the difference of the two ages; the mutant that removed it survived, because with a client age that is never negative the difference alone is more than the window whenever the server’s age is less than minus the window. The clause was dead code and is gone. eventand the tickets’ output (§12.6): the interop run hung one connection in six in the test harness, not in the engine; the harness is fixed and §12.6 says so.- The wrong-binder alert is
decrypt_errorhere andillegal_parameterin OpenSSL (the differential, above); §12.3 f says which the RFC names. - The lying client’s own binder was wrong by one byte in its first version of the padded case (§12.12). The server was right to refuse; it was refusing for the wrong reason, and only a mutant that removed the right reason showed it.
- After review (#394): a key listed twice in
set_ticket_keyswas placed twice, and when the ring was full the copyfindmet first could be a stale one, so a lifetime later the current key’s tickets were refused: a repeated key is nowtls-server-ticket-key.save_to,saveandforgeton a server engine touched the server’s own slot fields (the name the client sent, the tickets’ randomness): they now answer 0, andstart_withtls-role. The tickets bound the identity’s fingerprint andnotAfteras they were at the client’s Finished, so areplace_identitybetween the ClientHello and the Finished bound a certificate the client was never sent: both are now recorded when the ClientHello is answered (b_srv_fp,i_srv_not_after). The class, state read at one message and trusted at a later one, has its own cases (midway:inscripts/tls_liar_tickets.py): the identity, the ticket keys and the clock changed between the server’s answer to the ClientHello and the client’s Finished, on a full and on a resumed handshake, each asserting the outcome and, by resuming one, what the tickets bind; and the client’s ticket calls made on the server engine at that point. tls.ticket_keyswas not in the design’s interface; the program that supplies keys wants to log how many open.- mosquitto does not resume (§12.12), against the issue’s hope that it would; it is the client’s, not this server’s.
12.15 Not done, and not verified
- A resumption count or a rate limit. A ticket resumes as often as its client offers it, until it expires (§12.4); the engine counts nothing. A program that wants a single-use ticket keeps the binders it has seen, or does not send tickets.
- Client certificates (#384). Tickets carry
auth0 and refuse anything else (§12.3 g); what a ticket of an authenticated client holds is #384’s design. - P-384, Ed25519 and RSA identities (step 7): a ticket binds a leaf’s SHA-256, which is key-type independent, so nothing here changes; not exercised.
- 0-RTT: refused, not deferred (§2.2).
- TLS 1.2 tickets (RFC 5077): step 6, if it is ever built.
- Browsers: Chrome and Firefox were not run, here as in §10.9; their ClientHello carries
psk_key_exchange_modesand resumes with the standard extension, and the interop matrix has OpenSSL, curl, Go, wolfSSL, GnuTLS and rustls. - Timing of the ticket path: no dudect test. The AEAD tag and the binder are compared with every byte examined; the ticket key is never an index or a branch; the rules compare public values. The key schedule from a PSK is the client’s.
- Independent review: none (#209), as the engine.
- A long soak. The fuzzers ran for the times in §12.12; no multi-day run.
13. Client certificates (mutual TLS): the design
Step 4 of §8, issue #384. Written before the code. Sizes are arithmetic from the layout below and are checked by the
build (§13.11); costs are arithmetic from measured primitives and marked so, and §13.12 is where they are measured. The
resumption rules (§13.8) are decided here against the design of session tickets (§12, issue #379), read from its branch
(tls-server-tickets, 230decd); the built tickets (PR #394) arrived before this section’s code, which is therefore built on
#394’s head and says so. Where building finds a statement here false, the section that made it is corrected in place.
What it is for. Many MQTT fleets authenticate a device by the certificate it presents, and a gateway can pass a
verified client identity to the service behind it (cancho-mqtt PR #22, section 7d). The engine does one thing:
it authenticates, the program authorizes. After a client has been handled, the engine can say “a CA of the store you
gave me vouched for this key, for client authentication, at the time the handshake ran, and the client proved it holds
that key”. It never says what that identity may do.
13.1 Modes
tls.client_auth_mode(engine, mode), on a server engine, before or between connections:
| mode | the server | a client with no certificate | a client with a certificate that does not verify |
|---|---|---|---|
0 off (the default) | sends no CertificateRequest: every byte of §5.2 is as it was, and the 110 recorded connections replay unchanged | accepted, no identity | n/a: none was asked for, an unsolicited Certificate is tls-unexpected-message |
1 optional | sends a CertificateRequest | accepted, no identity: peer_verified answers 0 | refused, with the alert of the chain’s fault |
2 required | sends a CertificateRequest | refused: certificate_required (116), tls-server-client-cert-required | refused, as above |
- Optional is not “verify if convenient”. A certificate that is presented and wrong ends the connection in both modes
(RFC 8446 §4.4.2.4 lets a server continue only without a certificate, “at its discretion”;
openssl s_server -verifydoes the same once it is given-verify_return_error, measured in §13.16, without which it goes on after a verification error). A program inoptionalmode must testpeer_verifiedbefore it trusts an identity: no identity is not an error is the point of the mode. - The mode is per engine, taken when a connection is served.
servecopies it into the slot’s flags, so changing the mode does not change a handshake in progress. A mode per identity or per host name (a gateway that wants certificates onapi.exampleand not onwww.example) is not in version 1: the workaround is two engines on two listeners, and the asker that makes it worth a field in the identities (§4) has not come. Decide when one does. - A mode other than off needs a store.
client_auth_mode(engine, 1 or 2)before anytrust_clientsis refused,tls-server-client-store, so a program cannot ask for certificates and trust nothing. (A store is never emptied once loaded: §13.3.)
13.2 What the server sends, and reads
CertificateRequest (RFC 8446 §4.3.2), in the server’s encrypted flight, after EncryptedExtensions and before Certificate, added to the transcript like the rest (so the server’s Finished covers it):
| field | value | why |
|---|---|---|
certificate_request_context | empty | post-handshake authentication is not offered (§13.9); the context is only for it |
signature_algorithms (13) | ecdsa_secp256r1_sha256 (0x0403), ecdsa_secp384r1_sha384 (0x0503), rsa_pss_rsae_sha256/384/512 (0x0804, 0x0805, 0x0806), ed25519 (0x0807) | the schemes the server verifies for CertificateVerify: one per key type of §13.4. Not rsa_pkcs1_* (forbidden in TLS 1.3 for CertificateVerify), not ecdsa_secp521r1_sha512 (no P-521 verifier in check_signature), not rsa_pss_pss_* (packages/x509 reads rsaEncryption keys only) |
signature_algorithms_cert (50) | the six above and rsa_pkcs1_sha256/384/512 (0x0401, 0x0501, 0x0601) | the schemes the CA signatures of the chain may use. Without it the first list governs the chain too (RFC 8446 §4.2.3), and a client whose intermediate is signed sha256WithRSAEncryption, which x509_verify accepts, would be told not to send it |
certificate_authorities (47) | the subject Name (DER) of each root of the store, when the list is at most 8,192 bytes, else the extension is left out | the client picks the certificate it holds that one of these issued (Go’s GetClientCertificate default does; OpenSSL, wolfSSL and mosquitto send theirs regardless). It is a hint: the verification of §13.5 is the check. Left out when large, as RFC 8446 §4.2.4 leaves it optional. It tells any peer that connects the names of the CAs; they are not secret, and the server’s own chain names its CA already. |
oid_filters (48) | not sent | the extension filters certificate extensions by value; no client in the interop matrix reads it, and the checks it could express (EKU, key usage) are made by the verifier |
The CertificateRequest is at most 4 + 1 + 2 + 18 + 24 + (6 + 8,192) bytes, 8,247, and the flight’s buffer grows by
that (respond sizes it from tls_identity.chain_cap()).
The client’s flight, read in the states wait_client_certificate and wait_client_verify that precede
wait_client_finished when a request was sent:
- Certificate (RFC 8446 §4.4.2). Its
certificate_request_contextmust be empty (non-empty:tls-server-illegal-parameter, the alertillegal_parameter); a CertificateEntry extension istls-unsupported-extension(none was asked for); the message is at most 16,384 bytes, checked when its header arrives, before it is read (x509-chain-too-large, the alertbad_certificate); at most 5 certificates (tls_message.certificate’s bound of 8 is the client’s; here the leaf, up to 3 intermediates and the root) andmax_intermediatesis 3.- An empty certificate_list is the client saying it has none.
optional: the connection goes on to Finished with no identity.required:tls-server-client-cert-required, alert 116. - Any other message in this state, Finished included, is
tls-unexpected-message: a client must answer the request with a Certificate, empty if it has none (RFC 8446 §4.4.2).
- An empty certificate_list is the client saying it has none.
- CertificateVerify (§4.4.3), only after a non-empty Certificate: any other message there (a Finished, or a
CertificateVerify after an empty Certificate) is
tls-unexpected-message. The signature is over 64 spaces,TLS 1.3, client CertificateVerify, a zero byte, and the transcript hash through the client’s Certificate (the server’s flight, with the CertificateRequest, and the Certificate just read).- The scheme must be one the request listed, else
tls-server-client-sigalg, the alertillegal_parameter(RFC 8446 §4.2.3); a scheme that is listed but does not fit the key (ECDSA P-384 key,ecdsa_secp256r1_sha256) istls-bad-certificate-verify, alertdecrypt_error, astls_slot.check_signatureanswers it for the client. - The signature is checked by
tls_slot.check_signature, the code the client runs on the server’s: ECDSA over P-256 and P-384 (std.ecdsa), RSA-PSS (std.rsa), Ed25519 (std.ed25519), all verifiers, no secret touched (§4.2 ofdocs/tls-pure.md: the right-hand column).
- The scheme must be one the request listed, else
- Finished as before (§10.1, constant time), with the MAC over the transcript through the client’s
CertificateVerify, which is what RFC 8446 §4.4.4 says and
finished_macalready computes from the running transcript: the two new messages are added to it as they are accepted. The application secrets were derived at the end of the server’s flight, from the transcript through the server’s Finished (§7.1), as they must be; nothing there changes.
13.3 The trust store
- Its own call, its own bytes.
tls.trust_clients(engine, pem) -> roots | refusalreplaces the client store with the certificates of a PEM bundle. It is nottls.trust: that call is the client engine’s, for the roots of servers, with room for the system bundle (1 MiB); on a server engine it is refused today (tls-role, §5.1) and stays refused. Lettingtrustmean “the clients’ roots” on a server engine would put the one call that takes the system bundle next to the one that must never take it (docs/x509-verify.md§10.3: public CAs have issued certificates with bothserverAuthandclientAuth, and 4 of the 128 system roots carry an EKU that would refuse every client certificate underclientAuth).trust_clientson a client engine istls-role. - The engine cannot tell a public CA from a private one, and says so; two tripwires make the mistake loud. The
store holds at most 32 roots and 32,768 bytes (
x509_verify.store_load’s format; 128 roots of the system bundle take 138,350 bytes, §8.2 ofdocs/x509-verify.md). Corrected while building: the design said 65,536 bytes. The store is staged in a region before it replaces the old one (a refused bundle must leave the store as it was), and a region chunk is 64 KiB: analloc_sliceof 65,536 traps (docs/websocket-spike.md§4). 32 KiB holds 32 roots of up to 1 KiB. A bundle over either is refused whole,tls-server-client-store, so pointing the call at/etc/ssl/certs/ca-certificates.crtfails at the first call. It is a heuristic, not a guarantee (32 public roots would fit), and the documentation says it is one. - Strict, unlike
trust.trustskips a block it cannot read, so one odd root does not lose the rest (docs/x509.md§5.3). A client store is a file an operator wrote for this purpose: a block that is not a certificate this package reads (a private key pasted into the file, a certificate with an algorithm the verifier refuses) is a mistake to report, not to skip.trust_clientsrefuses it,tls-server-client-store, and the store is as it was. A bundle with no certificate is refused too, so the store is never empty after the first successful call. - Replacement at run time, as
replace_identity(§4, §11.2):trust_clientsagain replaces the store and bumpstls.client_generation(engine), which counts successful replacements (0: none yet). A handshake reads the store when its Certificate is handled, not when it began: a CA removed from the file stops vouching for every handshake that has not yet verified its chain, including ones in progress. (replace_identitylets a started connection keep the old identity because the certificate it was sent is already on the wire; nothing is on the wire here but the CA names, a hint.) A connection that has verified keeps its verdict, and records the generation it was verified under:tls.peer_verified(engine, slot)answers it (§13.6). A program that revokes by replacing the store walks its slots and closes those whose generation is older thanclient_generation. The example does (§13.10). Added after review (§13.22): a handshake whose chain verified under one generation and whose Finished arrives under another is refused,tls-server-client-store-replaced, so a client cannot hold its Finished past a replacement and be established under the store it replaced. The generation saturates at 2^32 - 1 rather than wrapping to 0, whichpeer_verifiedreads as “no identity”. - Revocation is not checked (
docs/tls-pure.md§4.1: out of scope, and the client says the same of servers): a client whose certificate was revoked is accepted until it expires or the CA leaves the store. The program’s answer is the one it must have anyway: a deny list on the identity it is given (the fingerprint, §13.6), which applies to every connection, resumed ones included (§13.8). - Where it lives. In the engine’s
cfgslice,tls_identity’s, after the 16 identities: the mode (1 byte), the generation (4), the store’s length (3), and the store (32,768).cfgis already what everyfeedis given, so no signature changes. +32,776 bytes an engine (a server engine is 274 KiB of identities already), allocated byopen_serveronly; a client engine and every slot are unchanged.
13.4 The key types, and what is checked on the chain
Accepted client keys, each with its CertificateVerify scheme: ECDSA P-256 (ecdsa_secp256r1_sha256),
ECDSA P-384 (ecdsa_secp384r1_sha384), RSA (rsa_pss_rsae_sha256/384/512; rsa.work_len() bounds the modulus,
x509-key-size for one it refuses), Ed25519 (ed25519). Refused, with x509-unsupported-algorithm (alert 43): a P-521
key, an RSASSA-PSS-OID key, any other.
The chain is x509_verify.verify_chain(store, certs, ranges, now, 3, purpose_client_auth(), leaf)
(docs/x509-verify.md §10): the path to a root of the store, every signature, every validity, cA and pathLen,
keyUsage digitalSignature and EKU clientAuth (the leaf, every intermediate, and the root when it has one;
anyExtendedKeyUsage alone is refused), and name constraints over every SAN. No name is checked: there is none to
expect. leaf is the view of the verified certificate, from which the identity of §13.6 is read; on a refusal it is
zeros (§10.3), so a program that ignored the answer would read nothing. The refusals are the verifier’s tags
(x509-expired, x509-not-yet-valid, x509-unknown-issuer, x509-key-usage, x509-name-constraint, x509-path-too-long,
x509-bad-signature, x509-not-ca, x509-chain-too-large, x509-decode) with their alerts (tls_slot.alert_for:
unknown_ca 48, certificate_expired 45, unsupported_certificate 43, bad_certificate 42), unchanged.
A leaf that is itself in the store (a pinned device certificate, self-signed) is a question for the verifier, not for
this design; §13.11’s tests measure what verify_chain answers and this section records it.
13.5 The clock
The chain is checked at the time the program gave serve (now_unix_ms, its clock_unix_ms), kept in the slot as
seconds. A handshake that has waited in the program’s queue is served when it starts (§11.2), so the time is the
handshake’s, to within the program’s handshake deadline; the engine has no clock of its own (§5.3). Validity is exact
(notBefore <= now <= notAfter), as the verifier does everywhere: no skew allowance. A program whose devices have wrong
clocks and present certificates “not yet valid” sees x509-not-yet-valid and the alert certificate_expired
(RFC 8446 §6.2 has no other for it), and fixes the device or the CA’s notBefore. When tickets land, the clock is the
later of this and tls.set_time (§12.7).
13.6 The verified identity, given to the program
An established connection (and one closed after it, for a program that logs on close; never a failed one) can be asked,
in the program’s own slot:
tls.peer_verified(engine, slot) -> generation | 0 // 0: no client certificate (off, or optional and none sent)
tls.peer_certificate(engine, slot, out) -> n // the leaf's DER; n <= 16,384; out too small: tls-server-peer-buffer
tls.peer_subject(engine, slot, out) -> n // the subject Name's DER (SEQUENCE of RDNs), 0 if the leaf has none
tls.peer_serial(engine, slot, out) -> n // the serial number's content bytes
tls.peer_fingerprint(engine, slot, out) -> 32 // SHA-256 of the leaf's DER, computed here, not by the client
tls.peer_not_after(engine, slot) -> seconds // the leaf's notAfter
tls.peer_san(engine, slot, at, entry) -> next | 0 // subjectAltName one GeneralName at a time, x509_verify.san_next
// entry[0] tag, entry[1], entry[2] the content's range in peer_certificate's DER
tls.client_generation(engine) -> n // §13.3
- Zero bytes of slot. The leaf is kept in the slot’s
b_leaf(16,384 bytes, which a server slot never used: it holds the server’s leaf for a client) and its length ini_leaf_len; the view is parsed again for each call (x509.parse, once a call). So no slot grows, and the chain buffer is the handshake reassembly buffer the slot already has (64 KiB,hs_cap), bounded by §13.2’s 16,384 and five certificates. The only allocation is the verifier’s own region, freed when the message is handled: the message copy (at most 16,388 bytes), the ranges, a 40-word view a certificate, the signature work area. Corrected bydocs/tls-memory.md§11: after #395b_leafis in the work area, and keeping the leaf there kept the area for the connection’s life: a verified idletls_echoconnection was 126.0 KiB resident (arm64; 127.7 on x86-64) at 10,000 connections where one without a client certificate is 17.0, and--areasended itrecord-timeout. A leaf of up to 2,048 bytes is now copied, when the client’s Finished has verified, to a room of its own for the slot (2,048 bytes of address space a server slot, written only for a verified connection), and the area goes back; a larger leaf keeps the area as before. peer_verifiedis the gate. Every other call answers 0 bytes when it is 0 or the connection is not established (or closed after establishment), so anoptionalconnection with no certificate, a failed one and a slot that was reused read nothing:startzeroes the length, and the leaf’s bytes are never read past it.- The subject and the SANs are the CA’s words. The engine adds nothing: no common name, no “identity string”, no
decision. A function that picks a common name out of the Name is
docs/x509-verify.md§10.1’s “until a program asks”;examples/tls_echohas one (a Name printer for its log line, §13.10), and if the broker and the gateway both copy it, it moves intopackages/x509. - The authority of the SAN is the store’s. A name constraint on the CA limits the SANs it can vouch for (§10.3), but a CA with no constraint vouches for any subject: a program that authorizes by subject or SAN must trust each CA of its store for every subject it could carry. The fingerprint is the identity that does not depend on that.
- Authentication is not authorization (§13 opening): the program decides what
peer_fingerprint, a SAN or a subject may do, on every connection, resumed ones included.
13.7 The refusal tags
New, besides the verifier’s x509-* (§13.4) and the existing tls-* that the flight can also raise:
| tag | alert | when |
|---|---|---|
tls-server-client-cert-required | certificate_required (116) | required, and the client’s Certificate has no certificate |
tls-server-client-sigalg | illegal_parameter (47) | the client’s CertificateVerify names a scheme the CertificateRequest did not list |
tls-server-client-store | none: trust_clients’s or client_auth_mode’s answer | a bundle with no certificate, a block that is not one, over 32 roots or 32,768 bytes; or a mode asked for with no store |
tls-server-client-auth-config | none: client_auth_mode’s answer | a mode other than 0, 1, 2 |
tls-server-peer-buffer | none: peer_certificate’s answer | out shorter than the certificate |
tls-server-client-store-replaced | certificate_unknown (46) | the store was replaced (trust_clients) after the client’s Certificate verified and before its Finished (§13.22) |
tls-role | none | client_auth_mode, trust_clients or a peer_ call on a client engine |
Reused unchanged: tls-server-illegal-parameter (a non-empty request context), tls-unsupported-extension (a
CertificateEntry extension), tls-decode-error (a Certificate or CertificateVerify that does not parse),
tls-unexpected-message (the wrong message, or a Certificate nobody asked for), tls-bad-certificate-verify,
tls-server-finished, and x509-chain-too-large for the message, certificate count or leaf over their bounds. The alert
mapping of tls_slot gains the two alerts above; every other refusal maps as it does.
13.8 Resumption (§12): the decision
A resumed session skips verification: the client proves it holds the PSK, which a connection that was verified made
(docs/tls-resumption.md §3’s hazard, seen from the other side). On the client side a ticket is a stored verdict about a
server, and eight rules say when the verdict is stale. For a server the verdict is about a client, and it matters
more: the identity is what the program authorizes with, and a ticket that carried a different identity, or a stale one,
is an authorization bug. The rules, with the client’s number each mirrors:
| rule | the client’s rule | ||
|---|---|---|---|
| R1 | No ticket carries a client identity unless the program asked for it. A connection whose client was verified is issued tickets only if tls.set_client_tickets(engine, on) was called (a #379 call; off by default). With it off, the server sends no NewSessionTicket on that connection, and a client that returns makes a full handshake and presents its certificate again. This is the default #379’s rule g sets (§12.3), and it is what main has: tickets do not exist yet | rule 2 (the trust store), the hazard | The cost of the safe default is the whole point of §6 for authenticated peers: they do the signature and the chain, 4 to 8 ms on x86-64. An MQTT device that connects once a day does not mind; a gateway whose clients reconnect in bursts does, and turns it on |
| R2 | The ticket holds the identity, not a pointer to one. With set_client_tickets on, the sealed ticket (§12.1) carries auth = 1, the client’s leaf DER, verified_at, and the client store’s digest (R3). A resumed connection reads its identity from the ticket’s leaf with the same code that reads a verified one (b_leaf, the same peer_ calls), so the program is given byte for byte the identity of the connection that was verified, or the resumption is refused: there is no third thing, and a ticket cannot name an identity the server did not verify, because it opens only under the ticket key | the host name is kept beside the ticket (§11) | A leaf over 1,024 bytes is not carried: the ticket would be at most about 1.8 KiB (§12.1’s 681-byte largest, plus the leaf, a time and a digest) of the 2,048 the packages/tls client keeps (docs/tls-resumption.md §4), and no NewSessionTicket is sent for that connection (the verdict is client-too-large, not an error). Measured leaf sizes belong in §13.12 (a P-256 leaf with one SAN: about 400 bytes) |
| R3 | Refused after the client store changed. The ticket carries SHA-256 of the store’s loaded bytes (the digest, 32 bytes, not the generation). A resumption is a full handshake when the digest of the store now differs | rule 2 | A counter would be per process: a fleet sharing a ticket key (§12.2) that loads the same file would refuse each other’s tickets after any one restart. A digest changes exactly when the file’s roots do, and a reload that found the same file refuses nothing. tls.client_generation (§13.3) is for the program’s own use |
| R4 | Refused when the client’s certificate has expired, and a ticket never outlives it: its expiry is capped at the leaf’s notAfter, as §12.3 c caps it at the server certificate’s | rule 3 | |
| R5 | Bounded from the verification, not from the ticket. The ticket’s life is also capped at verified_at + client_max_age (default 1 hour, tls.set_client_ticket_max_age), where verified_at is the time of the full handshake that verified the client; a resumed connection’s new tickets inherit it. A chain of resumptions never outlives the verification it started from | rule 4 and its “as built” correction | There is no revocation (§13.3); this is the window |
| R6 | Required means required. In required mode a ticket with auth = 0 (issued when the mode was off or optional and the client sent none) is not accepted: full handshake, which asks for the certificate. In optional either is accepted, and a resumed auth = 0 connection has no identity, as the original had none. In off a ticket with auth = 1 is not accepted either (auth): a program that turned client authentication off asked for connections with no identity, and peer_verified does not answer non-zero for one | the client’s refuse-by-rule shape | The auth verdict is the one #379’s rule g already reserves |
| R7 | No early data, as §12.3 b: a resumption never accepts 0-RTT, so nothing the client authenticates is replayable | rule 6 | |
| R8 | A replay is harmless for identity, as §12.4: the ClientHello’s binder proves the PSK, and the handshake keys need the client’s ephemeral key, so a recorded resumption yields no session and no identity | rule 5 | The identity is given to the program only once Finished has verified |
| R9 | A different identity on a ticket is not a thing: a resumed connection does not run verify_chain and does not read a certificate from the client, so the client cannot change who it is by what it sends after the ClientHello | rule 8 |
- What is built, and what is not (as built, on #394’s head). Built here: R1 (no ticket to a client that presented a
certificate:
issue_ticketssends none whentls_clientauth.certified), R6’s required half (with client certificatesrequired, a ticket is not accepted:try_resumeanswers the verdictauth, the existingtls-ticket-authof §12.7, and the handshake is a full one that asks for the certificate), R7, R8 and R9 (nothing new is needed: §12.3 b and §12.4 hold, and a resumed handshake runs noverify_chain), and one rule §13.8 did not state: a resumed handshake sends no CertificateRequest (RFC 8446 §4.3.2: a server that has chosen a PSK must not), sooptionalon a resumption means no identity, as the ticket’s connection had none (serveset the flag; the resumption clears it). Not built: R2 to R5, the opt-in that lets a ticket carry the client’s identity (tls.set_client_tickets): there is no such call, so there is no ticket withauth = 1andoffmode’s half of R6 has nothing to refuse. The three cases of the recording that test the built rules (§13.16) fail if the hook is removed, as three mutants show. - The interface the opt-in will need (when a program asks for it):
plaintextgainsclient_len (2)and the DER after it;authtakes 1 only when the call is on;ticket_verdictgainsclient-store,client-expiredandclient-too-large. A ticket withauth != 0is refused today (auth), which is already §12.3 g. - What the program sees on a resumed connection is the same calls (§13.6), and they answer “no client certificate”: a resumed connection has none, until the opt-in is built.
13.9 What is not offered
- Post-handshake authentication (RFC 8446 §4.6.2). The
post_handshake_authextension (49) in a ClientHello is ignored (a server need not answer it), and the server never sends a CertificateRequest after the handshake. A Certificate, CertificateVerify or Finished message from the client on an established connection istls-unexpected-message(on_messageanswers it for every type it does not expect). So a program that wants a client certificate for one URL of many closes and reconnects, or runs a second listener (§13.1). - Renegotiation: TLS 1.3 has none; a ClientHello on an established connection is
tls-unexpected-message, as today. - TLS 1.2 client certificates: there is no TLS 1.2 server (§2.2).
- A certificate chosen by SNI: the request is the same for every identity (§13.1).
- OCSP, CRLs: §13.3.
13.10 The examples
examples/tls_echo and examples/https_hello gain:
--client-ca <name> a file of the client CAs' PEM certificates, beneath --dir (tls_echo, https_hello)
or in the fixed directory (tls_echo_fixed); mode `optional`
--require-client-cert mode `required` (with --client-ca)
and each connection line gains client=<subject> (an RFC 4514-style rendering of the verified subject’s RDNs, ASCII
only, anything else escaped, - when there is none) and fp=<first 16 hex digits of the fingerprint>:
conn 7 peer=127.0.0.1:51109 established suite=TLS_AES_128_GCM_SHA256 group=x25519 sni=echo.lex-sys.test alpn=echo hrr=no client=CN=device-17 fp=3fa1c0de9b21aa07 waited=0 handshake=7
- The authority report does not change. The file is read beneath the
Dirthe program already holds (file_read,dir_read), by the same function that reads an identity’s files, andtls_echo_fixedreads it from the directory its onenarrow(fs, "/dev/urandom", "/etc/cancho/tls_echo")already names. The report’s label set is what §11.1 pins.conformance/tls_echo.rs’s pinned list is unchanged; a test asserts it. SIGHUPreloads the store with the identities:trust_clientsagain, a refused file logged (reload client-ca refused tls-server-client-store) with the old store still serving; after a successful change the loop closes every established connection whosepeer_verifiedis older thanclient_generation(closed revoked), because a program that replaces the store to revoke means it.- The log line is the program’s, not the engine’s: the engine gives bytes; the example formats them.
13.11 How it will be tested
- Interop (corrected as built: its own script,
scripts/tls_server_clientauth_interop.py, which usestls_server_interop.py’s authority, server and image): a CA, client certificates in every key type and chain length,requiredandoptionalservers:openssl s_client -cert/-key, curl--cert/--key, Gotls.Config.Certificates(scripts/interop/go_client.gogains a client certificate), wolfSSL (wolfssl_client.cgainswolfSSL_CTX_use_certificate_chain_file), mosquitto’s--cert/--key, in modesoff,optional,required, with chains of one and two certificates, P-256, P-384, RSA-PSS (RSA-2048) and Ed25519 keys.packages/tls‘s own client cannot present a certificate yet (#386); its row is added when it can. Each row: the client completes, and the server’s line carries the identity the row’s certificate has, equal to what OpenSSL reports for the same certificate (openssl x509 -fingerprint -sha256; the SANs from-ext subjectAltName, in order; the subject’s DER from pyca/cryptography, which parses the same bytes; and, in the examples’ tests,client=against-nameopt RFC2253). - The lying client (
scripts/tls_liar_client_auth.py, the shape oftls_liar_client.py, which it imports; the driver gainsG,H,P): one case per rule: no certificate when required, an empty Certificate when optional (accepted, no identity), a wrong CertificateVerify (a flipped bit; a signature over another transcript; the server’s context string), a wrong scheme (not listed), a listed scheme that does not fit the key, an untrusted CA, an expired leaf, a not-yet-valid one, an EKU ofserverAuthonly, a key usage withoutdigitalSignature, a name constraint violated, a chain over each bound (6 certificates, a message over 16 KiB, a leaf over 16 KiB), a self-signed leaf, a certificate for a different key than the one that signs, a CertificateVerify without a Certificate, a Finished in place of the Certificate, a non-empty request context, a CertificateEntry extension, a duplicate Certificate, a Certificate after Finished, handshake bytes replayed from another connection, the store replaced between the request and the Certificate (refused) and after establishment (kept, generation older), and the honest ones for each key type. - A differential against
openssl s_server(-Verify 5for required,-verify 5 -verify_return_errorfor optional; corrected as built: its own script,scripts/tls_server_clientauth_differential.py): the client’s flight is built from the server’s, so the harness plays a live client against both servers (the lying client’s logic over a socket) and compares the outcome (established or the alert) for each malformed flight; anEXPECTEDtable for the cases the RFC allows either. - Fuzz:
fuzz_serverand its corpus gain client flights (a full honest handshake with a certificate: the server’s random is fixed by the harness’s seed, so the client’s bytes replay);scripts/tls_fuzz.py --servermutates the client-auth cases;fuzz_hellois unchanged (the ClientHello is). The corpus is a regression test. Corrected as built:fuzz_servercannot reach the client’s flight, which is encrypted under keys the fuzzer’s bytes would have to derive, so a mutated ciphertext is abad_record_macand the Certificate parser is never reached. The new harnessfuzz_clientauthcalls the handlers (tls_clientauth.on_certificate,on_certificate_verify) with the fuzzer’s bytes as the message, over a slot in the stateserveleaves it in;fuzz_serverserves with client authentication on for inputs of length 2 or 3 mod 4, which reaches the CertificateRequest and the new states;tls_fuzz.py --servermutates the lines of the client’s bytes of the honest client-certificate connections too, and a mutated ciphertext there is the samebad_record_mac, so its value is the framing, not the certificate. - Mutants (
scripts/tls_server_mutants.py, which now replays both recordings): one per decision above (the purpose passed to the verifier, the context string, the transcript the signature covers, the optional/required test, the sigalg list test, the strict store, the 16 KiB bound, the certificate count, the identity gate,max_intermediates), all killed or argued equivalent. - The existing suites unchanged: the 110 recorded connections replay byte for byte (mode off adds no byte), the
client’s suites,
x509_matrix.py,publish_packages.py --check.
13.12 Cost and memory
Arithmetic from measured parts first; the measurement is §13.17. A client-authenticated full handshake does one more
chain verification and one more signature verification than §6’s: ECDSA P-256 verification is 0.82 ms on the M4 and 1.5 ms
on the i7 (docs/ecdsa-sign.md §7), so a client leaf signed by a root of the store, with a P-256 key: two verifications,
1.6 ms (M4) to 3.0 ms (i7); with an intermediate: three, 2.5 to 4.5 ms. The issue’s “about 1.6 to 2.5 ms” is the M4’s.
The handshake rate bound of §11.2 (100 a second) is then a larger share of a core for authenticated peers; the example’s
--rate default is not changed, and a program with certificates sets it from its own figure.
Memory: per slot 0 bytes (§13.6); per engine +32,776 bytes (§13.3); the flight’s transient buffer +8,247.
Corrected by docs/tls-memory.md §11: 0 bytes of slot layout, but a verified connection kept its work area; since §11 a
server engine has 2,048 bytes of address space a slot for a verified client’s leaf, resident only where one is kept.
As built: a test asserts the three sizes (L in tests/programs/tls_server_driver.cho, the case memory of the recording): a
slot’s ints are 10,245 words and its bytes 187,703 (10,241 and 187,191 before #396 gave the client its own certificates), both exactly what they are on main without this change (measured by
building the same program against origin/main’s packages/tls and against this tree’s), and the engine’s cfg is 280,690
- 640 (the tickets’ fingerprint and notAfter in each of the 16 identities, §12.10) + 32,776 = 314,106 bytes.
13.13 to 13.22: as built
PR #384’s. Numbers are measured, each with the command that gives it.
13.13 What was built
| File | Module | What |
|---|---|---|
packages/tls/clientauth.cho | tls_clientauth | new, 199 lines: begin (the mode and the clock at serve), the CertificateRequest, on_certificate (the empty list, the bounds, verify_chain for clientAuth, the leaf kept in b_leaf), on_certificate_verify, established, and the identity (verified, generation, certificate, fingerprint) |
packages/tls/identity.cho | tls_identity | the engine’s configuration gains the clients’ store after the 16 identities: the mode, the generation, load_client_store (strict, all or nothing), client_authorities |
packages/tls/hello.cho | tls_hello | certificate_request and the six schemes of a client’s CertificateVerify |
packages/tls/server.cho, slot.cho, record.cho, tls.cho | the flight with the request, the states wait_client_certificate and wait_client_verify, the dispatch, the client’s Certificate and CertificateVerify sharing a record with what follows, the alerts, five tags, and tls.client_auth, trust_clients, client_generation, peer_verified, peer_certificate, peer_subject, peer_serial, peer_fingerprint, peer_not_after, peer_san |
No file is over 2,000 lines (the largest, examples/tls_echo/front.cho, is 1,311). scripts/publish_packages.py republished the
stores. A new module, not a growth of server.cho, so that session tickets (§12), which edit server.cho, hello.cho,
identity.cho, slot.cho and tls.cho, meet this change in as few places as the shared flight allows.
13.14 Where building found the design wrong
- The store is 32 KiB, not 64 (§13.3, corrected in place): it is staged in a region, whose chunk is 64 KiB.
- “Strict” needed a count (§13.3):
x509_verify.store_loadskips a blockpem_nextdoes not return as a certificate, and does not count aPRIVATE KEYblock, or one cut short, as skipped. The store is refused unless the number of-----BEGINmarkers equals the number of roots stored. openssl s_timecannot measure a chain (§13.17): it sends the leaf alone, so a chain of two is refused withx509-unknown-issuerwhiles_timecounts the handshake as complete. The cost script uses Python’sssland an echo.- Go honors
certificate_authorities(§13.2): a stranger’s certificate no listed CA issued is not sent, so the server sees a client with none (certificate_requiredwhen required, an anonymous connection when optional). It is the hint working. - A pinned device certificate is not a CA (§13.4): a self-signed leaf with no
basicConstraintsin the store isx509-not-ca; the same certificate withcAset is its own anchor and is accepted. OpenSSL,openssl s_server -CAfile, accepts both: §13.20’s first question.
13.15 Interop
python3 scripts/tls_server_clientauth_interop.py <tls_serve>, in the image of §10.2 (Ubuntu 24.04, linux-aarch64, in Docker on the
M4 Max): tls_serve with --client-ca <file> required, with optional, and with none, and the clients presenting a certificate of
each of five kinds: P-256 under the CA, P-256 under an intermediate (a chain of two), P-384, RSA-2048 (RSA-PSS) and Ed25519.
Each leaf has a country, an organization and a common name, and four kinds of subjectAltName. 75 rows, 75 ok:
| Client | Version | Rows | What |
|---|---|---|---|
openssl s_client -cert -key [-cert_chain] | OpenSSL 3.0.13 | 15 | 5 kinds x required and optional; no certificate refused (tls-server-client-cert-required, certificate_required) when required and accepted with no identity when optional; a stranger’s refused (x509-unknown-issuer, unknown_ca) in both; a certificate offered to a server that asks for none: accepted, no identity |
curl --cert --key | 8.5.0, on OpenSSL 3.0.13 | 15 | the same |
Go tls.Config.Certificates | 1.22.2 | 15 | the same, but a stranger’s certificate is not sent (above) |
wolfSSL wolfSSL_CTX_use_certificate_chain_file | 5.6.6 | 15 | the same |
mosquitto --cert --key | 2.0.18 | 15 | the same, each row mosquitto_sub and mosquitto_pub |
A row passes when the client completes and its data comes back, and the identity tls_serve prints for the connection (what the
program is given through tls.peer_*) is what OpenSSL reports for the same certificate: the fingerprint is openssl x509 -fingerprint -sha256’s, the SANs are openssl x509 -ext subjectAltName‘s in order, and the subject’s DER is the certificate’s.
The 75 rows ran before this work was put on #394’s head (the engine’s client-certificate code is the same). On the merged tree, on
x86-64 (gram: Ubuntu, OpenSSL 3.5.5, curl; no Go, wolfSSL or mosquitto there) the OpenSSL and curl rows: 30 of 30; CI’s
tls-assurance job runs all five clients on it.
The examples’ tests (below) compare the log line’s client= with openssl x509 -nameopt RFC2253, for a subject with a comma,
quotes, a +, a leading # and a trailing space, a ;, <, >, a backslash and a non-ASCII letter: they agree.
Not run: packages/tls‘s own client (it cannot present a certificate: #386), Firefox, Chrome, Windows’ schannel.
13.16 The lying client, the differential, fuzzing, mutants
- The lying client (
python3 scripts/tls_liar_client_auth.py <driver> <out.txt>): 81 connections, replayed byte for byte on both backends byconformance/tls_client_auth.rs(the honest ones again with the client’s bytes fed one byte a line, and the identity compared). 32 endok: the honest connections (5 key types, the three RSA-PSS schemes, chains of one, two and three certificates, a leaf with four kinds of SAN and a serial with its top bit set, a leaf with none, the flight in one record, after a HelloRetryRequest, withpost_handshake_authoffered and ignored, with ChaCha20 and no change_cipher_spec,optionalwith and without a certificate,off, a store of two CAs, a store whose names are exactly 8,192 bytes and one of 8,193, a name constraint satisfied, the store replaced after the connection, a device that is its own CA, a replacement refused while the client held its Finished) the memory sizes and the three cases of §13.8 (a client with a certificate is sent no ticket; an anonymous client’s ticket resumes with no CertificateRequest and no identity; a ticket from an optional connection is refused when certificates are required, the client presenting its own); the other 49 each end with their tag (six of them on a configuration call) and, for a connection, the alert §13.7 names. One case per rule of §13.2 to §13.4, and one for each item of the task’s list: no certificate when required; an empty Certificate when optional; a wrong CertificateVerify (a bit flipped, another transcript, the server’s context string, another key’s); a scheme the request did not list and one that does not fit the key; an untrusted CA; expired; not yet valid; EKUserverAuthonly; a key usage withoutdigitalSignature; a name constraint violated; a chain over each bound (six certificates, four intermediates, a message over 16 KiB, a header saying 16,385 bytes); a self-signed leaf; a certificate for a key other than the one that signs; a CertificateVerify without a Certificate; Finished for the Certificate; a request context; a CertificateEntry extension; a duplicate Certificate; a Certificate after the handshake; the flight replayed from another connection; the store replaced between the request and the Certificate, and between the CertificateVerify and the Finished (by the same CA and by another, §13.22); the engine’s clock moved past the leaf’snotAfterafterserve. Every tag of §13.7 is reached, and the recording regenerates byte for byte on arm64 (pyca/cryptography 41) and x86-64 (46): the CAs are Ed25519, the client’s ECDSA nonce is hashed from key and message, its RSA-PSS salt is fixed. - The differential (
python3 scripts/tls_server_clientauth_differential.py <tls_serve>): the client’s flight depends on the server’s, so the lying client plays 45 cases live againsttls_serveandopenssl s_server -Verify 5(-verify 5 -verify_return_errorfor optional), outcomes compared: 34 agree, 8 differ in the alert only, 3 differ asEXPECTEDsays, 0 otherwise, the same on OpenSSL 3.0.13 (arm64) and 3.5.5 (x86-64). The alerts that differ: a request context (47 here, 50 there), a certificate that is not DER (42, 50), a message over 16 KiB (42, 50), a P-521 key (43, 10), a name constraint (42, 46), a scheme that fits the key’s family but not its curve (51, 47) and an Ed25519 key under an ECDSA scheme (51, 47), a leaf not yet valid (45, 42). The three that differ in the outcome are this server’s policy: more than five certificates, more than three intermediates (OpenSSL at depth 5 accepts them), and a non-CA self-signed leaf pinned in the store (§13.14). - Fuzz.
fuzz_clientauth(§13.11), AFL++ 4.09c, one core each, 0.42 h each: 773,208 executions ofclientauth(1,125 of 4,655 edges) and 330,362 ofserver(1,368 of 11,532, with client authentication on for a third of its inputs): no crash and no hang. The queue was minimised to 249 inputs, committed beside the 23 seeds (a good Certificate and CertificateVerify for each key type, and the lying client’s refusals);conformance/tls_fuzz.rsruns them on both backends.tls_fuzz.py --server(§13.11), on the tree with the tickets: 20,000 mutated connections over 48 recorded handshakes, 0 traps (x86-64), and again after the rebase on #394’s head of 2026-10-08 (commit ecc5772c): 20,000 over 107, 0 traps. The AFL++ counts above are of the tree before that rebase and were not repeated at the review’s head; CI’stls-assuranceruns each harness two minutes at every head (§13.22). - Mutants.
python3 scripts/tls_server_mutants.py <cancho> [--shard k/n]replays the recordings of §10.3, §12 and this section: 211 mutants, 208 killed, 3 equivalent (argued in the script), 0 survived onmainafter #394, #395 and #396 (five shards: three on the M4, two on gram; one mutant, the certificate given whether or not verified, survived the first run on #395’s areas and is killed by a check added totls_pool.cho clientcert, run again; the output is committed,tests/vectors/tls/client_auth/mutants.txt; sincedocs/tls-memory.md§11 the file is that change’s run: 227 mutants, 223 killed, 4 equivalent). When the recordings do not tell a mutant apart, the script also builds and runstls_pool.cho clientcertagainst it. 146 aremain’s and 65 are new: the purpose, the context string, each message’s place in the transcript, the optional/required test, the bounds, the strict store, the identity gate, the states, the alerts, eachtls-rolerefusal, the clock, and the three hooks of §13.8. The three equivalent: a leaf over the room (the message bound already holds it), a blockstore_loadskipped (the count of-----BEGINmarkers already refuses it), and the clockbeginstores (through the engine the slot’s clock is never behind it).
13.17 Cost
python3 scripts/tls_server_clientauth_cost.py <tls_serve> 10 5: the method of §6 and §11.4 (the server’s CPU time from /proc/<pid>/stat,
or ps, divided by the handshakes completed) with another load: Python’s ssl, because openssl s_time sends the leaf of a client
certificate and never the intermediate behind it (§13.14) and counts a handshake complete before the server has judged the
client. Each handshake is a TLS 1.3 connection with an X25519 share, a byte out and back (so the server has accepted the client),
and a close; 10 s a cell, 5 rounds with the rows interleaved. The suite is AES-128-GCM (the server’s choice).
Apple M4 Max, macOS 26, arm64 natively (the Docker VM’s disk was full, §13.21), OpenSSL 3.6.4’s Python client, no other work of ours on the machine (others were: the load average was 16 to 23), milliseconds of server CPU a handshake, median of 5 rounds (minimum to maximum):
| ms | min to max | more than none | |
|---|---|---|---|
| no client authentication | 3.23 | 3.11 to 3.99 | |
| optional, the client sends none | 3.17 | 3.06 to 3.35 | -0.06 (the CertificateRequest and an empty Certificate: not measurable) |
| required, P-256 key under the CA (a chain of one) | 4.94 | 4.84 to 5.62 | +1.71 |
| required, P-256 key under an intermediate (a chain of two) | 5.76 | 5.67 to 6.66 | +2.53 |
| required, P-384 key | 6.40 | 6.33 to 7.36 | +3.16 |
| required, RSA-2048 key (RSA-PSS) | 4.26 | 4.09 to 4.91 | +1.03 |
| required, Ed25519 key | 7.47 | 6.95 to 8.36 | +4.23 |
The design’s arithmetic (§13.12) holds on the M4: two P-256 verifications (the CA’s signature on the leaf, the client’s
CertificateVerify) at the 0.82 ms of docs/ecdsa-sign.md §7 are 1.64 ms against 1.71 measured; a third for an intermediate, 2.46
against 2.53. The other key types are the CertificateVerify’s verification beyond the CA’s one P-256: about 2.4 ms for P-384, 0.2 ms for
RSA-2048 (public exponent 65537), and 3.4 ms for Ed25519, the most expensive key a client can present here; these three are the
remainders after the CA’s verification, and were not timed alone.
x86-64 (gram: linux-x86_64, an Intel P-core, taskset -c 6, perf stat): milliseconds there are not a measurement: core 6 and
its hyper-thread sibling were in use by other work throughout (the script measures the others’ share of the cpu and of its sibling and
refused all 35 cells: 43 to 81% of the cpu and 100% of the sibling), and the server’s CPU time per handshake came out at 15 to 42 ms
against §11.4’s 5.5. What does not depend on who else is on the machine is the user-space instructions the server retires a handshake
(perf stat -e cpu_core/instructions/u -p <server>; five rounds each within 0.2 of the median):
| millions of instructions | more than none | |
|---|---|---|
| no client authentication | 75.63 | |
| optional, the client sends none | 75.68 | +0.05 |
| required, P-256 under the CA | 126.27 | +50.64 |
| required, P-256 under an intermediate | 151.35 | +75.72 |
| required, P-384 | 170.22 | +94.59 |
| required, RSA-2048 | 106.54 | +30.91 |
| required, Ed25519 | 152.76 | +77.13 |
Each P-256 verification is 25.2 million instructions (+50.64 for two, +75.72 for three), so on x86-64 a client certificate with a P-256 key
costs two thirds of the 75.6 million of a handshake with none, and one more third for each intermediate. The M4’s ratio is near it (4.94 against 3.23 is 1.53
times; 126.27 against 75.63 is 1.67). CI’s tls-assurance job runs the script on an x86-64 runner (ubuntu-latest, not pinned) and
its log has milliseconds: on the shared runner (3 rounds, milliseconds of server CPU a handshake, run 37767095238): no client authentication 5.96, optional with none sent 5.99, required with P-256 9.43 (+3.47), P-256 under an intermediate 11.08 (+5.12), P-384 12.07 (+6.10), RSA-2048 8.27 (+2.31), Ed25519 11.37 (+5.41). The runner is slower than the M4 and shared, so the ratio (9.43 against 5.96 is 1.58 times) is the figure to carry, and it matches the instruction counts.
13.18 The examples
examples/tls_echo, examples/https_hello and examples/tls_echo_fixed take --client-ca <file> and --require-client-cert (§13.10),
and the connection’s line gains client=<subject> fp=<16 hex digits> for a verified client. The authority report is unchanged: the
three tests that pin it (conformance/tls_echo.rs, https_hello.rs, tls_echo_fixed.rs) pass as they were. The live tests,
python3 scripts/tls_echo_test.py <tls_echo> clientauth and python3 scripts/https_hello_test.py <https_hello> clientauth
(CI’s tls-assurance job runs every case of both): a client with a certificate echoes (or gets /hello/mutual), and the line carries
the subject and fingerprint OpenSSL reports; no certificate when required and a stranger’s end tls-server-client-cert-required
and x509-unknown-issuer; SIGHUP with another CA replaces the store, the old client’s established connection ends closed revoked, its next connection is refused and the new CA’s is served; a file the engine refuses (reload client-ca refused tls-server-client-store) leaves the store as it was; with no --require-client-cert a client with none is served and logged with none.
All cases ok on x86-64 (gram): tls_echo_test.py 12 of 12.
13.19 What the examples do not do
They log the identity and nothing else: no authorization, since an echo has none to make. A deny list on the fingerprint, a map from a
SAN to a topic, a role from a common name: all the program’s, from tls.peer_*.
13.20 Open questions, for a person
- Pinned device certificates. A fleet that gives each device a self-signed certificate and lists them in the broker’s file
(mosquitto’s
cafiledoes, and so doesopenssl s_server -CAfile) is refused here: a leaf with nocAin the store isx509-not-ca(§13.14). Accepting it is a change inx509_verify(a store entry that equals the leaf is the anchor), not inpackages/tls. Proposed: yes, as its own change with its own matrix, asked for by cancho-mqtt if the broker’s design wants it. - Tickets on authenticated connections (§13.8): off by default (R1), on by an opt-in once #379 is in. Proposed as designed.
- A mode per identity (§13.1). Decide when a program asks.
- The 32-root tripwire (§13.3) is a heuristic against the system bundle. Proposed: keep, and say so.
13.21 Not done, and not verified
- The opt-in that lets a ticket carry the client’s identity (§13.8, R2 to R5): not built; no program has asked, and the default (no ticket to an authenticated client) is the safe one. R1, R6 and the rest are built and tested.
- Cost on arm64 Docker: the shared Docker VM’s disk was full (98 GB, other work), so the arm64 figures are the Apple M4 Max natively (macOS 26), not Linux in Docker.
- A second core, a flood of authenticated handshakes: the rate bound is the example’s, as in §11.
- Not independently reviewed (#209), as the engine; reviewed once by an agent on PR #398 (§13.22).
13.22 After review: a replacement while a client holds its Finished
A review of PR #398 found that revocation by replacing the store missed handshakes in flight: the generation was recorded
when the client’s Certificate verified, the identity went live at Finished, and nothing compared them in between; the
examples swept only established connections on SIGHUP. A client that held its Finished past the operator’s SIGHUP was
then established, and served, under the CA the operator had withdrawn. Fixed:
- The engine.
tls_clientauth.established, called when the client’s Finished has verified and before any ticket or application key, refuses when the slot’s generation is not the store’s:tls-server-client-store-replaced, alert certificate_unknown (46), the connection failed. A new tag rather thanx509-unknown-issuer: the chain was not found wanting, the store it was judged under is gone, and a replacement by the very same file refuses too (the engine does not compare stores; the client connects again). A replacement that was refused does not move the generation, and the handshake completes. Three cases of the recording (the same CA, another CA, a refused replacement), two mutants (the comparison removed, the alert changed), and the tag in the coverage test. - The generation saturates at 2^32 - 1 instead of wrapping to 0 (
identity.cho,load_client_store), whichpeer_verifiedwould read as “no identity” and the examples’stalewould miss. The examples’staleis nowg > 0 && g != client_generation. Past 2^32 - 1 replacements (one a second for 136 years) a replacement no longer marks older connections stale; this is said, not handled. - The examples check
stalewhen a connection becomes established too, and close it asrevokedwithout serving it (tls_echo’spump,https_hello’s): the engine already refuses that case, so this is the program’s own check, and unreachable through the engine as built.tls_echo_fixedsharestls_echo’sserveand loop (examples/tls_echo/echo.cho), so it has the same store, reload and revocation; it differs only in where the directory comes from. tls.trust_clients’s comment said 64 KiB; the cap is 32 KiB (§13.3), corrected.
On main, after #394 and #396 merged (this branch’s own commits rebased onto main at 47ceb199’s successors). Two
collisions were found and fixed, neither visible as a merge conflict:
tls.client_authwas taken. #396 addedtls.client_auth(engine, slot)on the client engine (what became of a CertificateRequest the client received,docs/tls-parity.md§6.3). This section’s mode call is nowtls.client_auth_mode(engine, mode); everything else in §13 is as written, and the examples,tls_serveand the driver’sGuse the new name.i_not_afterwas #394’s. The final #394 records the serving identity’s notAfter ini_srv_not_after, an alias of the client’si_not_after, at the ClientHello, to bound the tickets’ lifetime; this section had kept the verified client leaf’s notAfter in that same field at the client’s Certificate. A client that presented a certificate is sent no ticket (R1), so no ticket was mis-dated, but the two now have their own fields: the client leaf’s isi_peer_not_after, an alias of the client’si_verified_at, which a server slot does not use. Still 0 words of slot added: a slot is 10,245 words and 187,703 bytes,main’s sizes (#396’s, and #392’s longer key schedules:keys_len1,984), asserted by the recording’smemorycase.- Refusal codes: #394 has -64 to -66, this section -67 to -71 and -73 (-72 is #395’s
tls-pool, sotls-server-client-store-replacedmoved to -73), #396 -120 to -132; flags: this section’s 32,768 to 262,144, #396’s 16,777,216; states 23 and 24 are this section’s. No overlap. - The cases of this kind (state read at one message and trusted at a later one) are named
midway:in the recording, as #394’s are inliar_client.txt;scripts/tls_liar_tickets.pypoints to them.
After #395 (a core per slot, a pool of work areas: docs/tls-memory.md §7). Rebased again onto main with #395. Its rule:
what is read after the handshake goes in the slot’s core, what only the handshake uses goes in the work area, which goes back
cleared. Placed:
| field | where | why |
|---|---|---|
i_peer_generation (alias of i_context_len, word 16), i_leaf_len (14), i_peer_not_after (alias of i_verified_at, word 23), the flags f_auth_request to f_client_auth (in i_flags) | core | read by peer_verified, peer_not_after and the identity gate after the handshake |
the leaf DER, b_leaf (up to 16,384 bytes) | area, kept | peer_certificate, peer_subject, peer_serial, peer_fingerprint and peer_san read it after the handshake; it is up to 16 KiB, which no core has room for |
the client’s Certificate as received (b_hs), the verifier’s region | area, transient | used while the Certificate is handled |
Superseded by docs/tls-memory.md §11, which measured what this paragraph did not and changed it: a verified leaf of up to
2,048 bytes is kept in a room of its own a slot, and the connection gives its area back after the handshake. The paragraph
is as #398 merged it. So a connection whose client was verified keeps its work area while it lives: tls_slot.keeps_area is true for a
server slot with f_client_auth and not failed, the hook #395 left for this (docs/tls-memory.md §7.1). Its area goes back
when it is dropped or fails, and cleared with the leaf (clear_leaf, in every release and in drop); tls.residue
counts a server’s free areas’ b_leaf too. An optional connection with no certificate, a failed one, and every server
without client authentication keep #395’s core-only cost. What a held area costs was not measured here: it is what a
connection holds mid-handshake (docs/tls-memory.md §10.4), against an idle connection’s core. With fewer areas than slots,
verified connections hold areas and a serve with none free is tls-pool (back-pressure, as for a handshake). Not built,
and the obvious next step if mutual TLS fleets need #395’s density: keep a verified leaf that fits a few hundred bytes to
2 KiB in the core (a device’s P-256 certificate is ~500 bytes) and keep the area only for a larger one; or the identity
fields alone (fingerprint, subject, serial, SANs) in an engine-wide table. tests/programs/tls_pool.cho clientcert
(conformance/tls_pool.rs, both backends): every client verified with its area kept, tls-pool when verified connections
hold every area, drops give the areas back with residue 0 after every step, and the store replaced before the client’s
Finished fails the handshake with its area cleared.