Chapter 2 · Cryptography· Advanced· ~24 min

TLS & PKI

The full TLS 1.3 key schedule, HelloRetryRequest, post-handshake authentication, Encrypted Client Hello, and famous CVEs.

You will learn

  • The exact HKDF formulas in the TLS 1.3 key schedule
  • When HelloRetryRequest fires and why it costs a round trip
  • How post-handshake authentication (RFC 8446 sec 4.6.2) works
  • Encrypted Client Hello (ECH) and what it hides
  • The pattern in famous TLS CVEs: Heartbleed, ROBOT, Lucky 13, FREAK, DROWN

The key schedule, exactly

TLS 1.3's key schedule (RFC 8446 sec 7.1):

0       = string of zeros, Hash.length bytes
PSK     = pre-shared key or 0 if none
DHE     = ECDH/DH shared secret

Early Secret      = HKDF-Extract(salt=0, IKM=PSK)
Derive-Secret(S, label, msgs) = HKDF-Expand-Label(S, label, Transcript-Hash(msgs), Hash.length)

Handshake Secret  = HKDF-Extract(salt=Derive-Secret(Early Secret, "derived", ""), IKM=DHE)
Master Secret     = HKDF-Extract(salt=Derive-Secret(Handshake Secret, "derived", ""), IKM=0)

client_handshake_traffic_secret = Derive-Secret(Handshake Secret, "c hs traffic", CH..SH)
server_handshake_traffic_secret = Derive-Secret(Handshake Secret, "s hs traffic", CH..SH)
client_application_traffic_secret_0 = Derive-Secret(Master Secret, "c ap traffic", CH..server Finished)
server_application_traffic_secret_0 = Derive-Secret(Master Secret, "s ap traffic", CH..server Finished)
exporter_master_secret             = Derive-Secret(Master Secret, "exp master", CH..server Finished)
resumption_master_secret           = Derive-Secret(Master Secret, "res master", CH..client Finished)

Three observations:

  • Every derived secret mixes in a transcript hash. A protocol-state confusion (replay, mix-up) changes the transcript and the keys diverge.
  • "Derived" is computed with an empty transcript before being used as the next Extract salt. This makes the chain composable.
  • The exporter master secret lets applications derive their own keys (channel binding, EAP-TLS) tied to this specific TLS connection.

HelloRetryRequest

If the client's ClientHello does not contain a key_share for any group the server supports, the server responds with HelloRetryRequest: "send me a key_share for X25519 this time." The client recomputes and resends ClientHello. Total: two round trips instead of one.

Browsers historically optimised by speculatively offering several key_shares in the first ClientHello (P-256 plus X25519), so HRR almost never fires for popular curves. Post-quantum has put HRR back in play: clients now also offer X25519+ML-KEM hybrids, which servers may or may not support.

Post-handshake authentication

RFC 8446 sec 4.6.2: after the main handshake completes, the server can request client authentication mid-connection by sending a CertificateRequest. The client responds with its certificate, CertificateVerify, and Finished. The new identity is bound to this connection going forward.

Used for: APIs that escalate from anonymous to authenticated on demand, mTLS where the client certificate is only required for specific operations. Rare in browsers; common in API gateways.

Encrypted Client Hello (ECH)

The unsolved leak in TLS 1.3 is the SNI hostname, which is sent in plaintext so the server can pick the right certificate. ECH encrypts the inner ClientHello (including SNI) using a public key the server publishes in DNS (HTTPS RR or SVCB).

Steps:

  1. Client looks up the server's HTTPS RR, finds an ECH public key.
  2. Client crafts an inner ClientHello with the real SNI and other extensions.
  3. Client encrypts that inner ClientHello with the ECH key.
  4. Client sends an outer ClientHello with a generic "client-facing" SNI (e.g., the CDN's catch-all hostname) and the encrypted inner ClientHello as an extension.
  5. Server decrypts the inner, picks the right cert based on the real SNI, completes the handshake using inner-ClientHello parameters.

Effect: on-path observers see only the client-facing SNI. ECH is shipping in Cloudflare-fronted sites and major browsers in 2024-2026.

Famous TLS CVEs and their lessons

  • Heartbleed (CVE-2014-0160): missing bounds check in OpenSSL's heartbeat extension let attackers read up to 64KB of server memory per request. Lesson: bounds checks in C are not optional, even in security-critical code. Drove adoption of memory-safe languages and fuzzing.
  • ROBOT (2017): revival of Bleichenbacher's 1998 attack on PKCS#1 v1.5 RSA encryption. Many vendors had subtly broken constant-time decryption. Lesson: deprecate PKCS#1 v1.5; OAEP and PSS are the modern defaults.
  • FREAK (CVE-2015-0204): TLS clients accepted weak 512-bit "export" RSA keys due to a state-machine bug. Lesson: remove export ciphers entirely; do not just disable them in lists.
  • DROWN (CVE-2016-0800): shared RSA keys between TLS and the long-deprecated SSLv2 let an attacker decrypt TLS sessions via a Bleichenbacher attack on SSLv2. Lesson: never share keys between protocols; remove deprecated protocols at the server, not just the client.
  • Lucky 13 (CVE-2013-0169): timing differences in CBC padding validation leaked plaintext. Drove the AEAD-only design of TLS 1.3.

The pattern: TLS the protocol is solid. TLS implementations across hundreds of vendors are where the bugs live. Modern TLS libraries (rustls, BoringSSL, Go's crypto/tls) are written in memory-safe or carefully-audited C with extensive testing.