Chapter 28: TLS 1.3 migration
Once the endpoint’s TLS stack already supports X25519MLKEM768, adding the hybrid is a configuration change and the protocol negotiation handles the rest. Where a component does not yet support the hybrid, the work begins instead with a runtime upgrade, a vendor-supplied path, or an architectural change that routes post-quantum traffic around the component. Ch 27 gave the construction: the combiner, the wire format, the at-least-one-holds security argument. Ch 28 gives the operator’s playbook for the same endpoint under an internal deadline, on a live fleet, with monitoring and rollback.
The threat the rollout blunts is harvest now, decrypt later, applied to session confidentiality. An attacker who records ciphertext today can decrypt it later if a large-scale quantum computer becomes available. Ch 1 walks that model. X25519MLKEM768 defends the recorded session against that adversary. It does not defend against an active attacker who can authenticate as the server today, leaked TLS secrets, compromised session-ticket or pre-shared-key (PSK) material, or a forged certificate chain that enables a live man-in-the-middle (MITM) attack. Certificate-level and PKI migration are covered in Ch 29, for a hierarchy the team owns. Operator tooling for this chapter lives at solutions/ch28-tls-migration/.
A tls_endpoint_api that must move by 2031
Section titled “A tls_endpoint_api that must move by 2031”The Ch 25 CBOM records a tls_endpoint_api touchpoint with the following starting state: TLS 1.2, ECDHE-ECDSA-AES256-GCM-SHA384, curve P-256, deployed 2022-06-15. The record’s exposure label is public, and the endpoint is subject to the organization’s post-quantum migration plan. That plan tracks the “key target dates for migration activities” that NCSC 2025 sets out (UK National Cyber Security Centre, 2025). Ch 25’s opening lists them, and Ch 30’s context section places them in the full timetable. The plan’s first wave, the highest-priority migrations due by 2031, includes this endpoint.
Week one of the rollout is an inventory pass. Every client and server in the path of the endpoint gets classified ready, gated, or blocked. Ready means the deployed version already supports X25519MLKEM768. Gated means the library supports the hybrid at a newer version than the runtime currently running. Blocked means the library is absent from the version matrix, so the classifier knows of no version that supports the hybrid. The inventory sets the order of operations for configuration, canary, and rollout.
TLS 1.3 group negotiation
Section titled “TLS 1.3 group negotiation”TLS 1.3 negotiates a single NamedGroup for key establishment through the supported_groups and key_share extensions (RFC 9846 §4.3.7, §4.3.8 (Rescorla, 2026)). The client lists supported groups in preference order and may provide key shares for a subset of them. The server selects one mutually supported group, either by accepting an offered key share in ServerHello or by requesting a different mutually supported group with HelloRetryRequest. The selection policy is server-side and need not honor the client’s order. Negotiation integrity is provided by the TLS 1.3 transcript hash: any modification to the handshake messages after they enter the transcript invalidates the derived keys. An attacker cannot rewrite the chosen group silently once both sides complete the handshake.
This chapter cites RFC 9846 rather than RFC 8446. RFC 9846 respecified TLS 1.3 in July 2026, retaining the same protocol version number and obsoleting RFC 8446 (Rescorla, 2018, 2026). Its Section 1.2 lists the technical changes and, before the list, retires the term “master” for secrets. Four of those changes and the renaming reach an operator running this rollout.
| Change (RFC 9846 §1.2) | What it means on a rollout |
|---|---|
| KeyShare reuse forbidden (§4.3.8) | Generate a fresh share per connection; still accept a peer that reuses |
| TLS 1.0 and 1.1 must not be negotiated | Pairs with the MinProtocol floor set below |
general_error alert added (117) | A new alert value dashboards will not recognize |
| Extension length bounds corrected | Parser-level only, no configuration change |
| ”master secret” renamed “main secret” | Field and log names, not the wire |
The KeyShare rule is the one with operational teeth here. ML-KEM keygen costs more than an X25519 keygen, which is the pressure that makes caching a key share look attractive on a busy fleet. RFC 9846 §4.3.8 says clients and servers MUST NOT reuse a key share for multiple connections, and, because RFC 8446 permitted reuse, that receiving implementations MUST permit reuse by a peer. The requirement is deliberately one-sided: tighten your own behavior, tolerate a peer that has not.
Ch 27 gives the combiner argument, and it is conditional. Bindel, Brendel, Fischlin, Goncalves, and Stebila 2019 prove their dual-PRF combiner IND-CCA-secure when the dual PRF and PRF it is built from are secure and at least one component is an IND-CCA KEM (Bindel et al., 2019, sec. 3.2). X25519MLKEM768 is not literally that construction. It is key agreement inside the TLS 1.3 handshake, and its X25519 half is raw Diffie-Hellman, which as a KEM “does not formally satisfy IND-CCA2 security” but is safe for ephemeral key exchange in TLS 1.3 (Stebila et al., 2026, sec. 2). RFC 9954 matches its construction to the dual-PRF combiner when the shared secrets have fixed lengths (Stebila et al., 2026, sec. 6), and RFC 10024 says the security analysis “relies crucially on the TLS 1.3 message transcript” (Kwiatkowski et al., 2026, sec. 6).
Step-by-step deployment
Section titled “Step-by-step deployment”The runnable package is at solutions/ch28-tls-migration/.
Fleet assessment
Section titled “Fleet assessment”Classify each component on the path against this version-minimum table before touching server config:
| Component class | Library / product | Earliest version | Source |
|---|---|---|---|
| Server TLS stack | OpenSSL | 3.5.0 (April 2025) | (OpenSSL Project, 2025; The OpenSSL Project Authors, 2025) |
| Server TLS stack | nginx (via OpenSSL 3.5+) | through the OpenSSL backend | (van de Sande, 2025) |
| Client runtime | Go crypto/tls | Go 1.24 (February 2025) | (The Go Authors, 2025; Valsorda, 2024) |
| Web browser | Chrome (Desktop) | Chrome 131 (November 2024) | (Adrian et al., 2024; Bommana, 2024) |
| Web browser | Firefox (Desktop, TCP) | Firefox 132 (October 2024) | (Mozilla, 2024; Schanck, 2024) |
| Web browser | Firefox (Desktop, HTTP/3) | Firefox 135 (February 2025) | (Mozilla, 2025) |
| Platform TLS | iOS, iPadOS, macOS | OS 26 family | (Apple, 2025) |
Chrome 124 (April 2024) shipped an earlier non-standard group (Adrian et al., 2024; Chrome for Developers, 2024), registered with IANA as X25519Kyber768Draft00 (IANA, 2026). By September 2024 Chrome had enabled it for all desktop clients and announced that Chrome 131 would replace it with the IANA-registered X25519MLKEM768 group (Adrian et al., 2024; Kwiatkowski et al., 2026). That group was specified first by the individual draft draft-kwiatkowski-tls-ecdhe-mlkem, from August 2024, then by the working-group draft draft-ietf-tls-ecdhe-mlkem from March 2025, and was published on the Standards Track as RFC 10024 in August 2026 (IETF Datatracker, 2026). The registry entry carried the same name and codepoint across publication, so no deployed configuration changed.
IANA marks X25519MLKEM768 (codepoint 0x11EC) as Recommended = Y, and the NIST-curve hybrids SecP256r1MLKEM768 (0x11EB) and SecP384r1MLKEM1024 (0x11ED) as Recommended = N (IANA, 2026). That N is not a cryptographic deprecation signal: the registry says an N “does not necessarily mean that it is flawed” (IANA, 2026). RFC 10024 says the P-256 hybrid “supports use cases that require both shared secrets to be generated by FIPS-approved mechanisms” (Kwiatkowski et al., 2026, sec. 2). The P-384 hybrid “is intended for high-security environments that require FIPS-approved mechanisms with an increased security margin”, and any of the three groups “may be implemented in a FIPS-approved way” (Kwiatkowski et al., 2026, sec. 2). RFC 10024 Section 7.4 obsoletes the Draft00 codepoints outright, and the registry now carries X25519Kyber768Draft00 (25497) and SecP256r1Kyber768Draft00 (25498) as OBSOLETE with Recommended = D, so a deployment still advertising either should remove it (IANA, 2026; Kwiatkowski et al., 2026, sec. 7.4).
# Block 1: pedagogical slice of fleet_assessment.classify (stdlib only).LIBRARY_MINIMUMS = { "openssl": (3, 5, 0), "go-crypto-tls": (1, 24, 0),}
def parse_version(value): return tuple(int(p) for p in value.split("."))
def classify(inventory): rows = [] for component, library, version in inventory: parsed = parse_version(version) minimum = LIBRARY_MINIMUMS.get(library) if minimum is None: rows.append((component, library, version, "blocked")) elif parsed >= minimum: rows.append((component, library, version, "ready")) else: rows.append((component, library, version, "gated")) return rows
inventory = [ ("edge-lb", "openssl", "3.5.2"), ("app-server", "openssl", "3.3.0"), ("backend-peer", "go-crypto-tls", "1.24.1"), ("sidecar", "envoy", "1.30.0"),]
for row in classify(inventory): print(" | ".join(str(x) for x in row))# ==> edge-lb | openssl | 3.5.2 | ready# ==> app-server | openssl | 3.3.0 | gated# ==> backend-peer | go-crypto-tls | 1.24.1 | ready# ==> sidecar | envoy | 1.30.0 | blockedEvery Python block this chapter prints is also a standalone file in the companion repository, under chapter-code/ch28/, one file per block. Appendix C covers the clone and the environment they run on.
The gated row is the app server on OpenSSL 3.3, which needs a runtime upgrade to OpenSSL 3.5.0 or newer. The blocked row is the Envoy sidecar at the version this deployment runs. The classifier’s matrix has no Envoy entry, because when this inventory was taken in April 2026 Envoy documented no X25519MLKEM768 support path. A gated row is a runtime-upgrade task; a blocked row needs either a vendor-supplied upgrade path or an architectural change that routes post-quantum traffic around the component. The sidecar’s blocked row is the matrix’s blind spot: it came from the matrix, not from Envoy. In its default, non-FIPS build, Envoy supported the hybrid at a newer release than the one running, so the sidecar met this chapter’s definition of gated when the inventory was taken (Envoy project, 2026a).
Production inventory should prefer feature detection over version strings where possible: run the deployed binary, inspect the loaded TLS library, and verify that X25519MLKEM768 is actually accepted and negotiated rather than relying on a version comparison alone. The inline parse_version does not handle build suffixes such as 3.5.0-fips or 3.5.0+vendor, distro backports, or BoringSSL-derived stacks. Neither does the package’s classify, which raises ValueError on a suffixed version, so those components need the feature detection above.
tls_endpoint_api path. A gated row needs a runtime upgrade. A blocked row has no entry in the classifier's version matrix, and the sidecar's turns out to need only an upgrade and a configuration change.Server configuration
Section titled “Server configuration”An OpenSSL 3.5+ server meant to prefer the hybrid should put X25519MLKEM768 in a tuple of its own, ahead of the classical fallbacks X25519 and secp256r1. OpenSSL’s group list, which the Groups= directive sets, takes tuples separated by /, each a colon-separated list of groups, and the server works through the tuples in order. Within one tuple, a key share the client already sent beats a HelloRetryRequest, with or without server preference. So if the hybrid shares a tuple with X25519, a client that supports the hybrid but sent only an X25519 key share gets X25519. In a tuple of its own the hybrid wins, because the server asks that client to retry with it (The OpenSSL Project Authors, 2025):
# OpenSSL 3.5+ system-wide config: enforce TLS 1.3, prefer the PQ hybrid.[system_default_sect]MinProtocol = TLSv1.3Groups = X25519MLKEM768/X25519:secp256r1The [system_default_sect] section also applies to OpenSSL clients on the host that read the default configuration. A * before a group name makes a client send a key share for that group, and with no * anywhere it sends a key share for the leftmost group only, here the hybrid (The OpenSSL Project Authors, 2025). A server that supports only classical groups then answers with a HelloRetryRequest and one more round trip (Rescorla, 2026, sec. 4.2.1). MinProtocol likewise rules out the host’s outbound TLS 1.2 connections.
An nginx server block backed by OpenSSL 3.5+ sets the same list with ssl_ecdh_curve. nginx hands the value to OpenSSL’s SSL_CTX_set1_curves_list (van de Sande, 2025), which is a synonym of SSL_CTX_set1_groups_list (The OpenSSL Project Authors, 2025), so the tuple syntax and the selection rule are OpenSSL’s:
# nginx server block: X25519MLKEM768 in its own tuple, classical groups as fallback.server { listen 443 ssl; ssl_protocols TLSv1.3; ssl_ecdh_curve X25519MLKEM768/X25519:secp256r1;}With the hybrid in a tuple of its own, the server negotiates X25519MLKEM768 with every client that supports it, at the cost of one HelloRetryRequest for a client that sent only a classical key share (The OpenSSL Project Authors, 2025). Test that case directly: openssl s_client -groups X25519MLKEM768:*X25519 lists the hybrid but sends only an X25519 key share (The OpenSSL Project Authors, 2025). Against this configuration the handshake should end on X25519MLKEM768. On OpenSSL 3.6.2, -msg shows the retry as a second ClientHello, the one a client sends in answer to a HelloRetryRequest (Rescorla, 2026, sec. 4.2.2).
Clients that do not offer the hybrid land on X25519 or secp256r1 under TLS 1.3 graceful-fallback semantics. Conforming clients fall back cleanly, but the canary exists to catch misconfiguration, middlebox intolerance, oversized ClientHello flights, and implementation bugs. The three-group list is the rollout configuration. The steady-state configuration, for an endpoint that requires post-quantum protection once the client mix supports it, drops the classical fallbacks and advertises X25519MLKEM768 alone. The reasoning is in the cryptanalysis section.
# Block 2: pedagogical slice of lint_nginx_groups (stdlib only).import re
HYBRID = "X25519MLKEM768"
def lint_nginx(config_text): for i, raw in enumerate(config_text.splitlines(), start=1): line = raw.strip() if not line or line.startswith("#"): continue m = re.match(r"ssl_ecdh_curve\s+(.+)$", line) if m: value = m.group(1).rstrip(";") groups = [g.strip() for g in re.split(r"[/:]", value)] if HYBRID not in groups: return [("blocker", "hybrid-missing", i)] return [] raise ValueError("no ssl_ecdh_curve directive")
good = "server {\n ssl_ecdh_curve X25519MLKEM768/X25519:secp256r1;\n}"bad = "server {\n ssl_ecdh_curve X25519:secp256r1;\n}"
print("good:", lint_nginx(good))print("bad:", lint_nginx(bad))# ==> good: []# ==> bad: [('blocker', 'hybrid-missing', 2)]The full package lint also covers the OpenSSL Groups= form, flags a classical group that shares the hybrid’s tuple or sits in an earlier one (hybrid-not-first-preference), and flags duplicate codepoints (duplicate-codepoint).
Progressive rollout
Section titled “Progressive rollout”The rollout follows a five-phase sequence: shadow (capture ClientHello offers in logs without changing server behavior), 1 percent canary (enable X25519MLKEM768 on one percent of traffic), 10 percent, 50 percent, 100 percent. At each phase the operator watches three signals:
| Signal | Expected value | Rollback trigger |
|---|---|---|
| Handshake-success rate by NamedGroup | ≥99.9 percent on X25519MLKEM768 | a rate below 99.9 percent in any one five-minute window |
| ML-KEM validation failure rate | 0 percent on controlled clients, a low background on server vantage | sustained nonzero rate on previously stable traffic |
| P99 handshake latency on the group | baseline + keyshare delta, both measured on this fleet, plus one round trip on retried handshakes | sustained rise above the pre-agreed P99 budget |
A per-window NamedGroup rollup over the TLS connection log gives the hybrid adoption share, which is none of the table’s three signals. Block 3 ingests a synthetic log (timestamps in seconds and IANA codepoints 0x001D for X25519 and 0x11EC for X25519MLKEM768 (IANA, 2026)) and emits per-window totals and the hybrid percentage. Cloudflare reported at the end of October 2025 that over half of human-initiated traffic on its network used post-quantum key agreement (Westerbaan, 2025). A healthy canary shows the hybrid percentage climbing toward the fraction of capable clients in the traffic mix.
The table’s first signal, the handshake-success rate, needs two counts that a log of timestamps and codepoints does not carry: handshakes attempted and handshakes failed, per group per window. A handshake that fails before the server picks a group has no negotiated group, so attributing it needs the groups its ClientHello offered. A ClientHello that a middlebox drops never reaches the server, so the server’s counts show it only as missing traffic. A window with fewer than 1,000 handshakes on the group drops below 99.9 percent on a single failure, so give the trigger a minimum count.
The second signal comes from the client and server validation checks in RFC 10024 §4.2 (Kwiatkowski et al., 2026). A server MUST run the FIPS 203 Section 7.2 encapsulation-key check on the client’s key and abort with illegal_parameter if it fails (National Institute of Standards and Technology, 2024). A client MUST abort with illegal_parameter on a ciphertext-length mismatch and with internal_error on any other decapsulation failure. The alert alone does not say which check failed. RFC 10024 requires the same illegal_parameter when the X25519 half fails its checks (§4.2 and §4.3), and RFC 9846 uses it for many fields that are syntactically valid but incorrect (Kwiatkowski et al., 2026; Rescorla, 2026).
On controlled clients the validation failure rate is zero by construction; on a server vantage point it stays near zero across stable traffic, with a low background from internet scans, malformed clients, and middlebox corruption. A sustained nonzero step on previously stable traffic is the rollback trigger, with diagnosis spanning implementation bugs, active attack, fuzzing or scanning, telemetry-pipeline regressions, and middlebox corruption.
# Block 3: pedagogical slice of rollup (stdlib only).from collections import Counter
MONITORED = {"0x001D": "X25519", "0x11EC": "X25519MLKEM768"}
def rollup(records, window_seconds): if window_seconds <= 0: raise ValueError("window_seconds must be positive") sorted_records = sorted(records) origin = sorted_records[0][0] last = sorted_records[-1][0] out = [] cursor = origin i = 0 n = len(sorted_records) while cursor <= last: end = cursor + window_seconds counts = Counter() total = 0 while i < n and sorted_records[i][0] < end: _, code = sorted_records[i] total += 1 if code in MONITORED: counts[code] += 1 i += 1 out.append((cursor, total, dict(counts))) cursor = end return out
records = [ (0, "0x11EC"), (30, "0x11EC"), (60, "0x001D"), (300, "0x11EC"), (330, "0x11EC"), (360, "0x11EC"),]
for start, total, counts in rollup(records, window_seconds=300): pct = 100.0 * counts.get("0x11EC", 0) / total if total else 0.0 print(f"t={start}s total={total} X25519MLKEM768={pct:.1f}%")# ==> t=0s total=3 X25519MLKEM768=66.7%# ==> t=300s total=3 X25519MLKEM768=100.0%The rollup is the operator’s adoption measure, read beside the table’s three signals. A sudden drop in the hybrid percentage on previously stable traffic is either a client-population shift (normal variance, worth logging) or a silent server-side configuration regression (rollback territory). Five IANA codepoints matter on the post-quantum rollout path: 0x001D (X25519), 0x0017 (secp256r1), 0x11EB (SecP256r1MLKEM768), 0x11EC (X25519MLKEM768), and 0x11ED (SecP384r1MLKEM1024) (IANA, 2026). The runnable package tracks all five; the inline covers only the two codepoints in the synthetic log.
Rollback is the inverse of the forward operation: remove the X25519MLKEM768 tuple from the Groups= or ssl_ecdh_curve list, redeploy the server configuration, and the next full TLS handshake falls back to X25519. No key material changes. No session is invalidated: TLS 1.3 sessions use ephemeral keys, and existing resumption tickets still validate. The only visible effect is that the NamedGroup rollup for new full handshakes shifts back toward classical X25519.
Treat resumed sessions separately in monitoring. A resumed TLS 1.3 connection inherits from the original full handshake through the PSK (RFC 9846 §2.2 (Rescorla, 2026)), and what it adds depends on the mode. Under psk_ke there is no new key exchange, and 0-RTT early data is protected by the PSK alone, so neither is a fresh X25519MLKEM768 negotiation. Under psk_dhe_ke, which OpenSSL uses unless SSL_OP_ALLOW_NO_DHE_KEX is set, the resumption negotiates a group and performs a fresh key exchange, which may be the hybrid. A dashboard that counts every resumption as post-quantum therefore overstates coverage. Count a resumption only when its own negotiated group is 0x11EC, and never count early data. During a post-quantum-required cutover, log full handshakes and resumptions separately, set ticket lifetimes deliberately, and rotate ticket keys (or flush outstanding tickets) when a clean policy boundary is required.
Cryptanalysis: negotiation downgrade and the HNDL scope
Section titled “Cryptanalysis: negotiation downgrade and the HNDL scope”The combiner-level attack surface is covered in Ch 27’s cryptanalysis section. KDF weakness, shared-state randomness failure, and combiner implementation bugs all apply to any X25519MLKEM768 deployment and are not repeated here. The chapter-specific surface is at the group-list level.
A server that advertises X25519MLKEM768 alongside classical X25519 to a client that sends key shares for both should select the hybrid when its own preference puts it first, but RFC 9846 leaves that policy to the server. Its Section 4.3.8 notes that choosing from the key shares the client sent may give a group the client ranks below one both support, in exchange for saving a HelloRetryRequest. A server that wants to respect the client’s order SHOULD first choose a group from supported_groups and then send a ServerHello or a HelloRetryRequest (Rescorla, 2026, sec. 4.3.8).
In OpenSSL and nginx-on-OpenSSL deployments only the order of the tuples in the Groups= / ssl_ecdh_curve list is a strict server preference. Within a tuple, a key share the client already sent beats a HelloRetryRequest whichever side’s preference applies, and the server-preference option only decides whose order breaks ties (The OpenSSL Project Authors, 2025). Put the hybrid in a tuple of its own and verify the selected group under the deployed configuration.
Go crypto/tls from Go 1.24 enables X25519MLKEM768 by default but explicitly ignores the order of Config.CurvePreferences and applies an internal preference order regardless of configuration (The Go Authors, 2025; Valsorda, 2024). Operators relying on Go-side preference cannot tune the order through the standard library. BoringSSL and rustls each carry their own preference policy and should be verified the same way. The operator-facing test is the same in every stack: complete a representative handshake and confirm the server-selected group.
TLS 1.3 transcript binding defends against an on-path tamperer through two mechanisms. The server’s CertificateVerify signs the transcript hash computed over the ClientHello bytes as the server received them (RFC 9846 §4.5.2 (Rescorla, 2026)). The Finished MAC on both sides is keyed from transcript-derived secrets (RFC 9846 §4.5.3 (Rescorla, 2026)). A client whose ClientHello bytes were modified in flight computes a transcript hash that disagrees with the server’s. CertificateVerify or Finished fails at the client, and the client aborts. A pure on-path MITM that strips X25519MLKEM768 from a ClientHello therefore cannot complete a downgraded handshake.
The failure mode transcript binding does not prevent is a policy bypass. A server that advertises X25519MLKEM768 as an optional group alongside classical X25519 will cleanly negotiate classical with any client that never offered the hybrid, including an adversary that impersonates a legacy classical-only client. No tampering of a capable client’s handshake is required, both sides’ transcripts agree, and there is nothing for CertificateVerify or Finished to catch. This is standard backward-compatibility behavior for the staged rollout, and it is an organizational-policy risk only on endpoints that have committed to post-quantum protection. FREAK and Logjam (both 2015) are the historical cases of a server’s retained export-grade option, 512-bit RSA and 512-bit Diffie-Hellman respectively. Both also needed an attacker to tamper with the handshake, which this bypass does not.
The operational conclusion is that an endpoint requiring post-quantum protection must enforce the hybrid as a hard requirement, not as an optional alternative. Enforcement is a server-side stance. Chrome, Firefox, Go crypto/tls, and OpenSSL all advertise classical groups alongside the hybrid in their default supported_groups list, so a server that picks a classical group negotiates cleanly with no client-visible error. A server that requires the hybrid must reduce its advertised group list to X25519MLKEM768 only and reject handshakes from classical-only clients. The rollout configuration from the earlier server-configuration subsection is a staged posture; the steady state on a post-quantum-required endpoint is hybrid-only.
The threat X25519MLKEM768 defends against is harvest now, decrypt later, applied to session confidentiality. An adversary who records ciphertext today and gains access to a large-scale quantum computer later cannot recover the session key, because doing so requires breaking ML-KEM, and the combiner argument from Ch 27 says breaking X25519 alone is not enough. TLS 1.3 sessions use ephemeral key exchange, so later compromise of the server’s certificate private key does not retroactively decrypt recorded sessions on its own.
The rollout does not defend against an active attacker who can authenticate as the server today, against endpoint compromise during the session, against leakage of TLS secrets (key-log files, debug exports), or against compromised session-ticket or PSK material. It does not defend against a forged certificate chain that enables live MITM either. Certificate-chain migration, including signature-scheme updates across the certificate authority (CA) chain, is covered in Ch 29 for a hierarchy the team owns. A publicly trusted chain is governed by the CA/Browser Forum’s Baseline Requirements instead, and Ch 29 gives the dated state of the Forum’s post-quantum ballots.
Tradeoffs
Section titled “Tradeoffs”Four dimensions capture the operational cost of an X25519MLKEM768 deployment against a pure-classical TLS 1.3 baseline:
| Dimension | X25519 baseline | Hybrid | Delta |
|---|---|---|---|
| ClientHello keyshare | 32 B | 1216 B | +1184 B (Kwiatkowski et al., 2026) |
| ServerHello keyshare | 32 B | 1120 B | +1088 B (Kwiatkowski et al., 2026) |
| Server CPU / handshake | X25519 only | X25519 + ML-KEM-768 encaps | tens of microseconds, from Kyber768 (Avanzi et al., 2021) |
| Round-trip count | 1-RTT | 1-RTT or 2-RTT | none, or +1 RTT after a HelloRetryRequest |
Bandwidth: the keyshare totals in the table come from the Ch 27 wire-format breakdown (1184-byte ML-KEM encapsulation key plus 32-byte X25519 public key in the ClientHello, 1088-byte ML-KEM ciphertext plus 32-byte X25519 ephemeral in the ServerHello) (Kwiatkowski et al., 2026). A long-lived TLS 1.3 connection amortizes the cost over the session. A short-lived connection fleet (mobile cellular handoff, IoT heartbeat, high-latency satellite) pays the cost once per handshake, so the per-percentile latency delta is worth measuring against the deployment’s target.
CPU: the Kyber round-3 specification, the direct antecedent of FIPS 203, reports Kyber768 enc on one core of a 3.492 GHz Intel Core i7-4770K (Table 2) (Avanzi et al., 2021). The AVX2 implementation takes 67,624 cycles, about 19 microseconds, and the C reference implementation 235,260 cycles, about 67 microseconds. Those are measurements of Kyber768 and not of ML-KEM-768: FIPS 203 publishes no cycle counts, so the submission’s figures stand in for the standardized scheme rather than measuring it. The part is a 2013 Haswell and the code is the submission’s own, so neither figure measures the library you deploy on a current core. OpenSSL’s manual says X25519MLKEM768 offers “CPU performance comparable to the associated ECDH group” (The OpenSSL Project Authors, 2025). For a typical HTTPS endpoint serving keep-alive connections, server CPU is not the binding constraint; keyshare bandwidth and the segment count in the initial handshake flight are.
Latency: the hybrid adds no round trip when the client’s first ClientHello carries a key share for the group the server selects. A HelloRetryRequest costs an additional round trip, and when it selects a group, the client’s second ClientHello carries a key share only for that group (Rescorla, 2026, sec. 4.3.8). The server configuration above, which puts the hybrid in a tuple of its own, sends one to a client that lists the hybrid but sent only an X25519 key share.
For a client that sends the hybrid share in its first ClientHello, what changes is the size of that first flight. Measured with openssl s_client -tls1_3 on OpenSSL 3.6.2 and counted as TLS record bytes, the 5-byte record header included, a ClientHello offering only X25519 is 215 bytes on the wire. Offering X25519MLKEM768 it is 1393 bytes, and 1443 bytes once a 15-character server name, an Application-Layer Protocol Negotiation (ALPN) list of h2 and http/1.1, and X25519 as a second offered group sit alongside the hybrid share. A client that also sends a key share for that second group, so that a server preferring X25519 needs no HelloRetryRequest, adds 36 bytes and reaches 1479.
Subtracting the 215-byte ClientHello from the 1393-byte one leaves 1178 bytes, not the 1184 in the table’s keyshare row. OpenSSL 3.6.2 adds a 6-byte ec_point_formats extension when it offers a classical elliptic-curve group such as X25519, and leaves it out when the hybrid is the only group, which accounts for the difference.
The budget those numbers meet depends on the path. A 1500-byte maximum transmission unit carries 1460 bytes of TCP payload per segment over IPv4 with no options and 1440 over IPv6. The fixed headers are 20 bytes for IPv4, 40 for IPv6, and 20 for TCP, and any option in either header comes out of the payload (Borman, 2012). So the 1443-byte ClientHello fits one IPv4 segment with 17 bytes to spare and does not fit one IPv6 segment, and the 1479-byte one fits neither. That margin, rather than the microseconds of keygen, is the deployment hazard. The ClientHello almost always fit in a single packet, so some middleboxes and load balancers came to assume it always would, and post-quantum key shares broke that assumption (Westerbaan, 2025). Measure the P99 handshake-time delta on the affected traffic before and after enabling the hybrid.
Compatibility: a client that does not offer X25519MLKEM768 silently lands on X25519 under TLS 1.3 group negotiation. A server can ship the hybrid with graceful fallback to the classical group, and no conforming client fails the negotiation. The breakage that remains is the class the canary phases exist to catch: misconfiguration, middlebox intolerance, and implementation bugs. TLS 1.2 has no standard X25519MLKEM768 group. NIST SP 800-227 (final, September 2025) gives approved procedures for key establishment using FIPS 203 ML-KEM (National Institute of Standards and Technology, 2025). Any endpoint that requires post-quantum protection must enforce TLS 1.3 minimum.
Where Chapter 28 ends and Chapter 29 picks up
Section titled “Where Chapter 28 ends and Chapter 29 picks up”This chapter moved one endpoint’s key exchange and stopped there. The group list changed, the keyshare grew, the rollout table’s three signals told the operator when to advance, the NamedGroup rollup measured adoption, and the whole operation stayed reversible by deleting one name from a configuration file. Nothing in it touched a key that outlives a session: TLS 1.3 key exchange is ephemeral, so a rolled-back hybrid leaves no artifact behind.
The certificate does outlive the session, and that is Chapter 29. It takes the jwt_signing touchpoint from the same Ch 25 inventory, under a 2033 deadline rather than this chapter’s 2031. What it migrates is signatures rather than key agreement: a CA hierarchy the team owns, the JWKS keys and the tokens they sign, and the code-signing pipeline. A publicly trusted chain is not among them. The composite construction Ch 27 derived is the transition primitive there, which is where id-MLDSA65-Ed25519-SHA512 stops being a profile and becomes a chain-wide cost.
The asymmetry between the two operations is what carries forward. A hybrid key exchange can be rolled back in a redeploy because nothing signed under it survives. A signature migration cannot: every certificate issued under the new chain stays in the field for its full validity, and a bootloader signed in 2026 may still be verified in 2040. Chapter 29 spends most of its length on that consequence, from deprecation and compatibility windows down to the choice between stateful LMS and XMSS for firmware and stateless SLH-DSA for commercial software signing.
Exercises
Section titled “Exercises”-
Configure a local
openssl s_serveron OpenSSL 3.5+ to advertise X25519MLKEM768, and verify withopenssl s_client -groups X25519MLKEM768that the handshake completes and selects the hybrid. Report the server-selected group from the handshake output. -
Run the named-group rollup from
solutions/ch28-tls-migrationover a 24-hour CSV fixture that you generate yourself (monotonic timestamps, mixed codepoints 0x001D and 0x11EC). Identify the hour with the largest percentage-point jump in the hybrid fraction and state whether the jump is consistent with a canary flip (server configuration change) or a traffic-mix shift (new client population entering the hour). -
Extend
classifyin thetls_migrationpackage undersolutions/ch28-tls-migrationto add anunsupportedfourth status, for a library known to have no PQC support at any version, as distinct fromblocked, which records only that the classifier holds no entry for that library. The caller should pass anunsupported_librariesset alongside the library-minimum matrix. Update the Chapter 28 test suite to cover the new status. -
Write a one-page rollback runbook for the failure mode “handshake-success-rate on X25519MLKEM768 drops below 99.9 percent on the 10 percent canary for any five-minute window”. The runbook should name the trigger, the action (remove the X25519MLKEM768 tuple from the group list and redeploy), the recovery condition (success rate returns above 99.9 percent on the classical groups for three consecutive five-minute windows), and the post-mortem template.
-
Pick one of Ch 26’s six architectural areas where the X25519MLKEM768 rollout is the direct action, and one where it is adjacent but not the action. Refer to the specific Ch 26 subsection and explain why.
Worked solutions and editorial notes for these exercises are in Appendix D, Chapter 28. A separate track, for rebuilding rather than reading: the package exercises/ch28-tls-migration has every function the chapter teaches replaced by a stub. Run PQC_IMPL=exercises pytest tests/ch28 to grade your version against the suite that proves the reference one.
References
Section titled “References”Last updated: