TLS / SSL

The Protocol That Secures the Internet — Wire-Level Internals, Full Handshakes, and the Machinery Underneath HTTPS

TLS (Transport Layer Security) is the cryptographic handshaking and record layer that sits between TCP and every application protocol you use daily — HTTP, SMTP, IMAP, PostgreSQL, Redis. It's been revised many times: SSL 3.0 (1996), TLS 1.0 (1999), TLS 1.1 (2006), TLS 1.2 (2008), and TLS 1.3 (2018). This page covers the full stack: the TLS record protocol, TLS 1.2 full handshakes including RSA and ECDHE key transport at the byte level, TLS 1.3's redesigned handshake, cipher suites and AEAD, HKDF key derivation, X.509 certificate chain validation, session resumption, mTLS, termination architecture, OpenSSL internals, and common bugs and gotchas.

TLS Record Protocol — The Foundation

All data flowing through a TLS connection passes through the record layer. Every record has the same 5-byte header, followed by a payload, followed (in modern TLS) by an authentication tag. The record is the unit of processing: fragmentation, compression, encryption, and decryption all operate at record boundaries.

Byte offset 0 1 2 3 4 5+ Content Type 1 byte Protocol Version 2 bytes (major.minor) Length 2 bytes (max 16384) Fragment / Encrypted N bytes TLS 1.3 encrypts all records except ClientHello. TLS 1.2 encrypts from ChangeCipherSpec onward. Max record size: 16384 bytes. Over that, the record layer fragments.

The Five Content Types

TypeValueDescription
change_cipher_spec20Signals a cipher state change. TLS 1.3 does not use it (see below).
alert21TLS Alerts — warnings and fatal errors.
handshake22Handshake messages: ClientHello, ServerHello, certificates, etc.
application_data23Actual application payload — the encrypted bytes of your HTTP request or response.
heartbeat24RFC 6520 heartbeat extension for keepalive and path MTU detection.

Protocol Versions in the Record Header

The record header's version field is always 0x0301 (TLS 1.0) for TLS 1.2 records and 0x0303 (TLS 1.2) for TLS 1.3 records — despite TLS 1.3 being version 0x0304. This is intentional: older middleboxes that inspect record headers see a "valid-looking" TLS version and don't interfere. The real version negotiation happens inside the handshake messages themselves (via the supported_versions extension in TLS 1.3).

# TLS 1.2 record from ClientHello:
0000  17 03 03 00 51 01 03 03 7c 6b 8a .. .. .. .. ..  .#..Q....|k..
       ^^-- content_type = 0x17 (23 = application_data? No, wait)
       17 = handshake (22)  ← record layer version field (0x0303 = TLS 1.2)
       03 03 = TLS 1.2  ← record header version, NOT the negotiated version
       00 51 = 81 bytes of handshake data

# TLS 1.3 ClientHello sent as a handshake record:
17 03 03 01 28 ...         ← still 0x0303 in header (middlebox compat)
       01 28 = 296 bytes

# TLS 1.3 ServerHello (encrypted handshake record):
17 03 03 00 5a ...         ← record header still says TLS 1.2
       00 5a = 90 bytes of encrypted handshake

Record Fragmentation and Max Record Size

The TLS record layer fragments messages that exceed 16384 bytes. Each fragment becomes its own record with its own header. On the receiving side, the record layer reassembles fragments before passing them up to the handshake or alert handler. This matters for large Certificate messages: a 4 KB chain is typically sent as a single record (below the limit), but a very long certificate chain or a large OCSP response stapled into the handshake can push past the limit.

# Fragmentation example: a 20,000-byte handshake message
Message: [20,000 bytes]
         │
         ▼ fragment at 16384 boundary
[16384-byte fragment #1] [3616-byte fragment #2]
    [header: len=16384]   [header: len=3616]

# Reassembly: both fragments must belong to the same message
# (same content type, sequential). TLS stacks reassemble before
# parsing the handshake message.

TLS 1.3 Removes ChangeCipherSpec — Why?

In TLS 1.2, ChangeCipherSpec is a separate handshake message (content type 20) that signals "everything after this point is encrypted." It's sent by both client and server just before Finished. This created a design problem: the two ChangeCipherSpec messages and two Finished messages are the only encrypted records in TLS 1.2 — everything before them (including certificates) is plaintext. TLS 1.3 removes ChangeCipherSpec entirely because key derivation happens inline with the handshake: once the server processes ClientHello's key_share, it can derive handshake keys and encrypt from ServerHello onward. The server's Finished message is already encrypted. No ChangeCipherSpec required.

TLS 1.2 Handshake — Full Byte-Level Flows

TLS 1.2 has two primary key exchange methods: RSA key transport (the client encrypts a pre-master secret with the server's RSA public key) and ECDHE key exchange (both sides contribute an ephemeral DH share, giving forward secrecy). We walk through both in detail.

Handshake Phases Overview

  1. Phase 1 — Negotiation: ClientHello → ServerHello (agree on cipher suite, TLS version, random values)
  2. Phase 2 — Key Exchange: RSA: server sends certificate + key exchange; ECDHE: both sides exchange DH parameters
  3. Phase 3 — Key Confirmation: ChangeCipherSpec + Finished (both sides verify key agreement)
  4. Phase 4 — Application Data: Encrypted channel established

TLS 1.2 with RSA Key Transport

In RSA key transport, the server sends its RSA public key in its certificate. The client generates a random 48-byte pre-master secret (PMS), encrypts it under the server's RSA public key, and sends it in a ClientKeyExchange message. The server decrypts it with the private key. Both sides then derive the master secret from the PMS and client/server randoms. This is the classic "TLS RSA" mode. It has no forward secrecy: if the server's RSA private key is later compromised, every recorded session is decryptable.

CLIENT SERVER Phase 1 ClientHello (TLS 1.2, cipher_suites, client_random, session_id) Cipher suites e.g. TLS_RSA_WITH_AES_128_CBC_SHA ServerHello (cipher_suite chosen, server_random, session_id) Phase 2 Certificate (server's X.509 cert chain, RSA pubkey inside) Certificate chain: leaf → intermediate(s) → root CA ServerHelloDone (no key exchange params needed for RSA) Phase 3 ClientKeyExchange (encrypted PMS: RSA(Opaque(PMS))) Client generates 48-byte PMS → encrypts with server's RSA pubkey → sends ciphertext Phase 4 Both sides derive: master_secret = PRF(PMS, "master secret", client_random + server_random) key_block = PRF(master_secret, "key expansion", server_random + client_random) key_block → client_write_MAC_key, server_write_MAC_key, client_write_key, server_write_key Phase 5 ChangeCipherSpec (client: switch to encryption) Finished (HMAC of all handshake messages so far) ChangeCipherSpec + Finished PMS = 48 random bytes generated by client master_secret = PRF(PMS, "master secret", client_random | server_random) No forward secrecy: if RSA private key is stolen later → all sessions decryptable

TLS 1.2 RSA: The Pre-Master Secret

# Client generates PMS:
ClientHello.random   = 32 bytes (sent in ClientHello)
ServerHello.random   = 32 bytes (sent in ServerHello)
PMS                  = 48 bytes: [0x03, 0x03] || random(46 bytes)
                      (first 2 bytes must be TLS version, e.g. 0x03 0x03)

# RSA encryption (PKCS#1 v1.5):
pms_encrypted = RSA_public_encrypt(PMS)   → 256 bytes (for 2048-bit RSA)

# ClientKeyExchange message structure:
struct {
    select (KeyExchangeAlgorithm) {
        case rsa:
            EncryptedPreMasterSecret encrypted_pre_master_secret;
    };
} ClientKeyExchange;

struct {
    opaque RSAEncryptedPreMasterSecret[256];
} ClientKeyExchange;  // for 2048-bit RSA

# Server receives, decrypts with RSA private key:
PMS = RSA_private_decrypt(encrypted_pre_master_secret)
master_secret = PRF(PMS, "master secret", client_random || server_random)

# TLS PRF (TLS 1.2):
master_secret = PRF(PMS, "master secret", client_random || server_random)
# PRF uses HMAC with SHA-256 (or SHA-384 depending on cipher suite)
# Split into two halves: LHS = HMAC-SHA256, RHS = HMAC-SHA256
# A1 = HMAC(seed, "A" || seed); A2 = HMAC(seed, A1); ... chain

TLS 1.2 with ECDHE Key Exchange (Forward Secrecy)

In ECDHE, the server's certificate contains an RSA (or ECDSA) key just for authentication — not for key transport. Both client and server send ephemeral DH public keys. The server sends its in a ServerKeyExchange message; the client sends its in ClientKeyExchange. The shared secret is derived from these DH values, giving forward secrecy: even if the server's long-term private key is compromised later, past sessions remain secure because the ephemeral DH key was destroyed after the handshake.

CLIENT SERVER ClientHello (TLS 1.2, ECDHE cipher suites, supported_groups extension) ServerHello (chosen cipher suite, server_random) Certificate (server cert — RSA/ECDSA for AUTHENTICATION only) ServerKeyExchange (ECDH params: curve, server_ephemeral_pubkey, signature over params) Server signs: Sign(server_random || client_random || server_ecdh_params) with long-term private key ServerHelloDone ClientKeyExchange (client_ephemeral_pubkey) Client derives: PMS = ECDH(client_ephemeral_privkey, server_ephemeral_pubkey) ChangeCipherSpec Finished (HMAC over full transcript) ChangeCipherSpec Finished Application Data (encrypted) ★ Forward secrecy: ephemeral DH keys discarded after handshake. ★ Server's long-term key only AUTHENTICATES the handshake; key exchange is separate.

TLS 1.2 ECDHE Byte-Level: ServerKeyExchange and ClientKeyExchange

# ServerKeyExchange for ECDHE (TLS 1.2):
struct {
    select (KeyExchangeAlgorithm) {
        case ec_diffie_hellman:
            ECParameters  server_ec_params;
            DigitallySigned server_dh_params;
    };
} ServerKeyExchange;

struct {
    ECCurveType  curve_type;    // 0x03 = named_curve
    NamedCurve   namedcurve;    // 0x0017 = secp256r1, 0x001d = x25519
    opaque        point;        // server's ephemeral EC public key (uncompressed: 0x04 || X || Y)
} ECParameters;

// Server signs the DH params so client can verify server's identity:
struct {
    SignatureAndHashAlgorithm algorithm;  // e.g. rsa_sha256 = 0x0401
    opaque                    signature;  // 256 bytes for RSA-2048
} DigitallySigned;

# Signature covers:
Sign(server_private_key,
     client_random || server_random || server_ec_params)  // params not just pubkey

# ClientKeyExchange for ECDHE:
struct {
    select (KeyExchangeAlgorithm) {
        case ec_diffie_hellman:
            ECDHClientPublicValue client_ec_public_value;  // same encoding as server's point
    };
} ClientKeyExchange;

# After both sides have the ephemeral public values:
PMS = ECDH(compute_shared_secret,
           client_ephemeral_pubkey, server_ephemeral_pubkey,
           client_ephemeral_privkey, server_ephemeral_privkey)
# For secp256r1: shared_x coordinate of (client_pub * server_priv) = (server_pub * client_priv)
# For x25519: shared_secret = X25519(client_ephemeral_privkey, server_ephemeral_pubkey)

master_secret = PRF(PMS, "master secret", client_random || server_random)

TLS 1.2 Handshake Summary: RSA vs ECDHE

PropertyTLS 1.2 RSA Key TransportTLS 1.2 ECDHE
Server cert used forBoth authentication AND key transportAuthentication only
ServerKeyExchange neededNo (cert contains RSA key)Yes (DH params + signature)
ClientKeyExchange contentEncrypted PMS (256 bytes for RSA-2048)Ephemeral EC public key (~32–65 bytes)
Forward secrecyNo — RSA private key compromise decrypts allYes — ephemeral keys destroyed after handshake
RTT to first data2 RTT (full handshake)2 RTT (full handshake)
Common in practicePre-2016 deployments; legacy clientsModern deployments (post-2016)

TLS 1.3 Handshake — The Redesigned Protocol

TLS 1.3 (RFC 8446) redesigns the handshake from scratch. The headline improvements: 1-RTT for new connections (down from 2), 0-RTT for resumption, mandatory forward secrecy (RSA key exchange removed), and encryption of nearly all handshake messages from the server's ServerHello onward. The protocol is split into four sub-protocols:

  1. Handshake Protocol — ClientHello through Finished. Produces traffic keys.
  2. Alert Protocol — Errors, close_notify, warnings.
  3. Application Data Protocol — Encrypted payload.
  4. Heartbeat Protocol — Keepalive (RFC 6520).

The 1-RTT Handshake with ECDHE (X25519)

The key insight: the client attaches its ephemeral DH share to ClientHello. The server responds with ServerHello containing its ephemeral DH share. Both sides can derive handshake_traffic_secret at that point. From ServerHello onward, everything is encrypted. The server's Finished message is encrypted. The client's Finished is encrypted. No ChangeCipherSpec messages exist.

CLIENT SERVER 0-RTT ClientHello version=0x0303 (record compat) | supported_versions=0x0304 (real version) key_share: {group=x25519, key_exchange=32 bytes} | supported_groups: [x25519, secp256r1, ...] signature_algorithms: [rsa_pss_rsae_sha256, ecdsa_secp256r1_sha256, ...] sni | alpn: [h2, http/1.1] | psk_key_exchange_modes | early_data (0-RTT) ClientHello is the ONLY fully unencrypted message in TLS 1.3 1-RTT begins ServerHello version=0x0303 | cipher_suite=TLS_AES_128_GCM_SHA256 | server_random[32] key_share: {group=x25519, key_exchange=32 bytes} | supported_versions Both sides derive: handshake_secret = HKDF-Extract(ECDH(client_pub,server_priv), 0) --- all handshake messages below are ENCRYPTED (handshake keys) --- EncryptedExtensions (ALPN reply, server_name, early_data extension if 0-RTT) Certificate (server's X.509 chain — encrypted!) CertificateVerify (signature over transcript hash: Sign(server_privkey, transcript_hash)) Finished (HMAC: Verify_Digest(handshake_secret, transcript_hash)) Key Schedule handshake_secret → HKDF-Expand-Label(.,"c hs traffic",...) = client_handshake_traffic_secret HKDF-Expand-Label(.,"s hs traffic",...) = server_handshake_traffic_secret Finished + [CertificateRequest + Certificate (if client cert)] App keys ready handshake_secret → HKDF(.,"derived",...) → master_secret → client_application_traffic_secret_0 + server_application_traffic_secret_0 Application Data (encrypted under application traffic keys)

The Transcript Hash and Finished Message

Every Finished message is an HMAC computed over the transcript hash — the cryptographic hash of all handshake messages exchanged so far, in order. This is the mechanism that binds the encryption keys to the exact set of messages that occurred:

# Transcript for Finished:
# Handshake messages, in order, as they appeared on the wire
transcript = hash(ClientHello) || hash(ServerHello) ||
             hash(EncryptedExtensions) || hash(Certificate) ||
             hash(CertificateVerify)

# client_finished = HMAC(client_handshake_traffic_secret, transcript)
# server_finished = HMAC(server_handshake_traffic_secret, transcript)

# If an attacker modified any handshake message in transit:
#   → transcript hash differs → keys differ → Finished MAC fails
#   → connection terminates

# TLS 1.3 Finished uses HMAC with the negotiated hash (SHA-256 by default)
verify_data = HMAC(finished_key, transcript_hash)
# Finished.verify_data length = 32 bytes (SHA-256) or 48 bytes (SHA-384)

HelloRetryRequest: When the Server Wants a Different Group

If the client guesses the wrong key share group — e.g., it sends x25519 but the server requires secp256r1 for compliance — the server responds with a HelloRetryRequest. This looks syntactically like a ServerHello (same message type) but contains only a selected_group extension. The client retries ClientHello with that group. This adds one extra round trip (making it 1.5 RTT for that specific case), but the server avoids the cost of computing an DH share for an unsupported group.

# ClientHello with wrong group:
ClientHello
  supported_versions: 0x0304
  key_share:
    entry: group=x25519, key_exchange=32 bytes
  supported_groups: [x25519, secp256r1]

# Server rejects x25519, requires secp256r1 (FIPS compliance):
ServerHello
  cipher_suite: TLS_AES_128_GCM_SHA256
  selected_group: secp256r1      ← HelloRetryRequest
  # no key_share in the first ServerHello

# Client retries:
ClientHello
  key_share:
    entry: group=secp256r1, key_exchange=65 bytes  ← uncompressed EC point

# Server responds with its secp256r1 share:
ServerHello
  key_share:
    entry: group=secp256r1, key_exchange=65 bytes

# Total RTT: 1.5 (one extra round trip for HRR)

TLS 1.3 vs TLS 1.2: Full Comparison

PropertyTLS 1.2TLS 1.3
RTT to first data (new conn)2 RTT1 RTT
Certificate messages encryptedNo — plaintextYes — under handshake keys
ChangeCipherSpec usedYes (2 messages)No — removed
RSA key transportYes (no forward secrecy)No — removed
Key exchange optionsRSA, DHE_RSA, ECDHE_RSA, ECDHE_ECDSA, ...Only ECDHE (all forward secret)
CBC cipher suites availableYes (many broken)No — only AEAD
Static RSA in key exchangeYes — worst security modelN/A
Downgrade protectionNoneDOWNGRD sentinel in ServerHello.random
Session resumption (new)Session ID / ticket (2 RTT)PSK with 1 RTT
0-RTT resumptionNoYes — data in ClientHello flight
Signature algorithmsBundled in cipher suiteNegotiated separately via extension
Protocol versions definedRSA, DHE_RSA, ECDHE_RSA, ...5 AEAD suites only

Cipher Suites — What They Mean and How to Read Them

A cipher suite is a named bundle of cryptographic algorithms. In TLS 1.2, a cipher suite encodes four choices: key exchange algorithm, authentication algorithm, bulk encryption cipher and mode, and PRF/MAC. In TLS 1.3, it's simplified to just (AEAD + hash), while key exchange and signatures are negotiated independently. Reading a cipher suite name like TLS_AES_128_GCM_SHA256 or TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA requires knowing the encoding scheme.

How to Decode Any Cipher Suite Name

# TLS 1.2 cipher suite naming:
TLS _ KEY_EXCHANGE _ AUTH _ WITH _ BULK_CIPHER _ MAC
         ↑             ↑        ↑    ↑               ↑
       RSA             RSA     WITH  AES_128_CBC    SHA

TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
  ECDHE      = key exchange: ephemeral DH with EC curve
  RSA        = auth: server's cert is RSA
  AES_128_GCM= bulk: AES-128 in GCM mode (AEAD)
  SHA256     = PRF/MAC: HMAC-SHA256 for PRF in key derivation

# Key exchange codes:
RSA      = RSA key transport (no FS)  ← removed in TLS 1.3
DHE      = finite-field DH (no FS unless Ephemeral)
ECDHE    = ephemeral EC Diffie-Hellman (always FS)

# Auth codes:
RSA, ECDSA, DSS (DSA signature — deprecated)

# Bulk cipher codes:
AES_128_CBC, AES_256_CBC, AES_128_GCM, AES_256_GCM,
3DES_EDE_CBC, RC4_40, RC4_128, CHACHA20_POLY1305

# MAC / PRF codes:
SHA, SHA256, SHA384    ← in TLS 1.2 PRF
MD5                    ← deprecated

# TLS 1.3 cipher suite naming (simpler):
TLS _ AEAD _ KEY_SIZE _ MODE _ HASH
TLS_AES_128_GCM_SHA256
  AES_128_GCM = AES-128-GCM (AEAD, 16-byte tag)
  SHA256       = HKDF-Hash (for key derivation)

# The five TLS 1.3 suites:
0x1301  TLS_AES_128_GCM_SHA256       ← most common
0x1302  TLS_AES_256_GCM_SHA384       ← AES-256 + SHA-384
0x1303  TLS_CHACHA20_POLY1305_SHA256 ← mobile / no AES-NI
0x1304  TLS_AES_128_CCM_SHA256       ← IoT (smaller code)
0x1305  TLS_AES_128_CCM_8_SHA256    ← IoT with 8-byte auth tag

AEAD: What Authenticated Encryption with Associated Data Means

AEAD (Authenticated Encryption with Associated Data) is the modern block cipher mode required in TLS 1.3. It provides both confidentiality (encryption) and authenticity (the authentication tag). "Associated data" means parts of the record (like the sequence number and content type) are included in the tag but not encrypted — this prevents attackers from flipping bits in the content type or length fields without detection.

AES-GCM Internals

AES-GCM (Galois/Counter Mode) combines counter-mode encryption with a Carter-Wegman MAC built over GHASH (a hash over the ciphertext and AAD). Each record gets a unique 12-byte nonce constructed from the connection's IV plus the record's explicit nonce:

# AES-GCM in TLS 1.3:
# Nonce construction (per record):
nonce = implicit_iv (from key derivation) XOR explicit_nonce (from record header)
# implicit_iv is 12 bytes derived per key; explicit_nonce is 8 bytes from the record
# Result: full 12-byte nonce for AES-CTR, unique per record

# Encryption:
1. Derive per-record key: AEAD_KEY = HKDF-Expand-Label(key, "tls13 aesgcm", "", 16)
2. Build nonce: 12 bytes = 4-byte implicit-IV XOR 8-byte explicit
3. Encrypt: AES-CTR(plaintext, key, nonce)
4. Authenticate: GHASH(AAD, ciphertext) → 16-byte tag
   AAD = content_type || protocol_version || length  (5 bytes, before encryption)
# Full output: nonce || ciphertext || tag  (appended after length in record)

# Decryption:
1. Build nonce from implicit_iv XOR explicit
2. Verify tag first (constant-time compare)
3. If tag valid → decrypt; if not → alert bad_record_mac

# Why constant-time tag comparison matters:
# If attacker flips a byte in the ciphertext, the tag comparison
# rejects it — but must NOT leak WHERE it failed (timing side channel)
# OpenSSL uses CONSTANT_TIME_DECODE to compare tags.

ChaCha20-Poly1305

ChaCha20-Poly1305 (RFC 8439) is the AEAD for devices without AES hardware acceleration. It runs ChaCha20 stream cipher with a Poly1305 MAC. On a phone without AES-NI, it's 3–5x faster than AES-GCM in software. Cloudflare and Google added it to TLS precisely so mobile clients could avoid expensive software AES. The name breaks down:

  • ChaCha20: stream cipher by Bernstein, 20 rounds, operates on a 4×4 matrix of 32-bit words. Key = 256 bits, IV = 96 bits.
  • Poly1305: Carter-Wegman MAC, one-time key per record, produces 128-bit tag. Key is derived from ChaCha20 stream.
# ChaCha20-Poly1305 in TLS 1.3:
# Key derivation:
chacha20_key = HKDF-Expand-Label(key, "tls13 chacha", "", 32)

# Nonce: 96 bits (12 bytes) — constructed differently than AES-GCM
# Uses client_write_iv / server_write_iv + 4-byte record sequence number

# ChaCha20: 20-round ARX (Addition, Rotation, XOR) cipher
# Each block: diagonalization, column rounds, diagonal rounds
# 32-byte key || 12-byte nonce || 64-byte counter → keystream

# Poly1305: polynomial MAC over the ciphertext
# key = first 32 bytes of ChaCha20(key, nonce, 0)
# poly1305_tag = Σ (block_i * r^i) mod (2^130-5)
# Result: 16-byte tag

# Combined: ciphertext || 16-byte Poly1305 tag appended

HKDF — The Key Derivation Function Inside Every TLS Connection

TLS 1.3 uses HKDF (HMAC-based Key Derivation Function, RFC 5869) everywhere. Every secret derived during the handshake — handshake traffic keys, application traffic keys, exporter master secrets, resumption master secrets — is produced by HKDF. The TLS 1.3 key schedule is a strict tree: every child secret is derived from a parent secret via HKDF-Extract and HKDF-Expand-Label.

HKDF Theory: Extract + Expand

# RFC 5869:
HKDF-Extract(salt, IKM) → PRK (pseudorandom key)
  PRK = HMAC-Hash(salt, IKM)
  # In TLS 1.3, the IKM is the (EC)DH shared secret, salt is 32 zero bytes

HKDF-Expand-Label(secret, label, context, length) → OKM
  # TLS 1.3 labeling (RFC 8446 Section 7.1):
  struct {
    uint16 length;
    opaque label[7] = "tls13 " || label;    # "tls13 " is 6 bytes, then label
    opaque context[_hash_length];            # transcript hash at this stage
  } HkdfLabel;

  # Inner HMAC:
  HKDF-Expand(secret, info, length) = HMAC-Hash(secret, info || 0x01)
  where info = HkdfLabel struct serialized

# Simplified TLS 1.3 notation:
Derive-Secret(Secret, Label, Transcript) =
  HKDF-Expand-Label(Secret, Label, Transcript, HashLen)

# Example: derive client_handshake_traffic_secret
client_handshake_traffic_secret =
  Derive-Secret(handshake_secret,
               "c hs traffic",    # label = "c hs traffic"
               Transcript)         # = hash(ClientHello..ServerHello)

The TLS 1.3 Key Schedule, Fully Annotated

# Step 1: Early Secret (from PSK, or 0 if no PSK)
early_secret = HKDF-Extract(PSK, 0)   # or HKDF-Extract(0, 0) if no PSK

binder_key         = Derive-Secret(early_secret, "ext binder", "")
client_early_secret= Derive-Secret(early_secret, "c e traffic", "")
# NOTE: client_early_traffic_secret is only used in 0-RTT mode

# Step 2: Handshake Secret (from ECDHE shared secret)
handshake_secret = HKDF-Extract(ECDH_shared_secret, early_secret)

client_handshake_traffic_secret =
  Derive-Secret(handshake_secret, "c hs traffic", Transcript)
server_handshake_traffic_secret =
  Derive-Secret(handshake_secret, "s hs traffic", Transcript)

# Step 3: Master Secret
# Derive-Secret with "derived" label clears the old secret's type
derived_secret = Derive-Secret(handshake_secret, "derived", "")

master_secret = HKDF-Extract(derived_secret, 0)

# Step 4: Application Traffic Secrets
client_application_traffic_secret_0 =
  Derive-Secret(master_secret, "c ap traffic", Transcript)
server_application_traffic_secret_0 =
  Derive-Secret(master_secret, "s ap traffic", Transcript)

# Step 5: Resumption and Export
exporter_master_secret =
  Derive-Secret(master_secret, "exp master", Transcript)

resumption_master_secret =
  Derive-Secret(master_secret, "res master", "")

# Key Update (mid-session):
# client_application_traffic_secret_N =
#   HKDF-Expand-Label(client_application_traffic_secret_N-1,
#                     "traffic upd", "", HashLen)

Why This Matters: Key Compromise and Transcript Binding

The key schedule has two critical security properties. First, every key is derived from the transcript hash at that point — so if any handshake message is tampered with, the derived keys diverge and Finished MAC verification fails, terminating the connection. Second, the tree structure means that compromise of an early secret doesn't expose later secrets (provided HKDF is a PRF): compromise of a client_early_traffic_secret doesn't expose the handshake_secret or master_secret.

X.509 Certificates and Chain Validation

TLS uses X.509 certificates (RFC 5280) to bind a public key to a hostname. The server presents a certificate chain: leaf → intermediates → root. The client validates the chain, checks the hostname, and checks for revocation. This section walks through the full validation pipeline.

The X.509 Certificate Structure (DER-encoded ASN.1)

# A certificate is an ASN.1 DER structure:
Certificate ::= SEQUENCE {
  tbsCertificate        TBSCertificate,    # the actual data
  signatureAlgorithm    AlgorithmIdentifier,
  signatureValue        BIT STRING         # the CA's signature over tbsCertificate
}

TBSCertificate ::= SEQUENCE {
  version         [0] Version DEFAULT v1,
  serialNumber         CertificateSerialNumber,
  signature           AlgorithmIdentifier,
  issuer              Name,
  validity            Validity,
  subject             Name,
  subjectPublicKeyInfo SubjectPublicKeyInfo,
  issuerUniqueID  [1] IMPLICIT UniqueIdentifier OPTIONAL,
  subjectUniqueID [2] IMPLICIT UniqueIdentifier OPTIONAL,
  extensions      [3] Extensions OPTIONAL
}

# Example: what you see in a browser's certificate viewer
Subject: C=US, ST=California, L=San Francisco, O=Cloudflare\, Inc.,
         CN=cloudflare.com
Issuer: C=US, O=Let's Encrypt, CN=R3
Validity: Not Before: 2024-01-01 00:00:00, Not After: 2024-04-01 00:00:00
Serial: 04:7A:BC:D2:8E:...
Subject Public Key Info:
  Algorithm: RSA Encryption
  Public Key: 2048-bit modulus, e=65537
  # In PEM:
  # -----BEGIN PUBLIC KEY-----
  # MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKB...
  # -----END PUBLIC KEY-----
Signature Algorithm: SHA256withRSA
Extensions:
  Subject Alternative Name: DNS:cloudflare.com, DNS:*.cloudflare.com
  Basic Constraints: CA:FALSE
  Extended Key Usage: TLS Web Server Authentication

Certificate Chain Validation Step-by-Step

  1. Build the chain. Start with the leaf certificate. For each cert, find its issuer in the trust store (root CAs are pre-installed; intermediates are delivered with the handshake or found via AIA - Authoriry Information Access in the leaf). Build the full path to a trusted root.
  2. Verify each signature. For each cert in the chain (leaf through intermediate), verify the signature using the issuer's public key. Root signatures are self-signed and verified against the locally-trusted root CA certificate.
  3. Check validity windows. notBefore ≤ now ≤ notAfter for each cert. Revocation or expiration mid-session is a hard failure.
  4. Check hostname. The leaf's Subject Alternative Name (SAN) extension must contain the hostname being connected to. Wildcards like *.cloudflare.com match exactly one label. The Common Name (CN) field is ignored for hostname checks in modern clients (RFC 2818).
  5. Check revocation status. CRL (Certificate Revocation List) or OCSP (Online Certificate Status Protocol). Soft-fail is the default (skip on network error); must-staple makes it hard-fail.
  6. Check Certificate Transparency (CT). Most leaf certificates include SCTs (Signed Certificate Timestamps) embedded as extension. Chrome enforces CT policy: it rejects certificates without enough embedded SCTs from approved CT logs.
  7. Check name constraints. If an intermediate CA has name constraints (e.g., Permitted DN: C=US), the leaf's subject must comply.

Certificate Transparency and SCTs

Certificate Transparency (RFC 6962) is an append-only log of issued certificates. CAs issue certificates with embedded SCTs (Signed Certificate Timestamps) from CT logs. Clients like Chrome verify that the presented certificate has SCTs from approved logs — if a certificate was issued but not logged, Chrome will reject it. This makes it possible to detect unauthorized issuance (like the DigiNotar incident in 2011).

# SCT embedded in certificate extension:
SignedCertificateTimestamp ::= STRUCTURE {
    sct_version      INTEGER,    # v1 = 0
    log_id            OCTET STRING,  # 32-byte SHA-256 of log's identity
    timestamp         INTEGER,       # milliseconds since epoch
    extensions        OCTET STRING,  # usually empty
    signature         CRT signature over sct_data
}

# The TLS layer carries SCTs in two places:
# 1. In the X.509 certificate extension (RFC 6962 Section 3.3)
#    extension: 1.3.6.1.4.1.11129.2.4.2 (type: signed_certificate_timestamp)
# 2. In the TLS handshake via signed_certificate_timestamp extension (RFC 6962 Section 3.3)
#    (for multi-cert scenarios where cert is already issued)

# Chrome CT policy: must have at least 2 valid SCTs from approved logs

CRL, OCSP, and OCSP Stapling

# CRL (Certificate Revocation List):
# A CA periodically publishes a CRL — a signed list of revoked serial numbers
CRL ::= {
    issuer            Name,
    lastUpdate        Time,
    nextUpdate        Time,    # when next CRL is published
    revokedCertificates [SEQUENCE of {
        serialNumber    CertificateSerialNumber,
        revocationDate  Time,
        reason          ReasonCode  OPTIONAL
    }]
}

# Client downloads CRL from the CRL Distribution Points (CDP) extension in the cert
# Downside: CRL is large (thousands of revoked certs), download is slow

# OCSP (Online Certificate Status Protocol):
# Client sends OCSP request to CA's OCSP responder:
OCSPRequest ::= {
    tbsRequest: {
        version: 1,
        requestorName: Name,    # usually empty
        requestList: [{
            certID: { hashAlgo, issuerNameHash, issuerKeyHash, serialNumber },
        }]
    },
    signatureOptional: signature over tbsRequest
}

OCSPResponse ::= {
    responseStatus: successful,
    responseBytes: {
        responseType: id-pkix1-ocsp-basic,
        response: {
            tbsResponseData: {
                version: 1,
                producedAt: Time,
                responses: [{
                    certID: { ... },
                    certStatus: good | revoked | unknown,
                    revocationTime: GeneralizedTime OPTIONAL,
                    thisUpdate: Time,
                    nextUpdate: Time OPTIONAL
                }]
            },
            signature: CA_signature  # over tbsResponseData
        }
    }
}

# OCSP stapling: server embeds OCSP response in TLS handshake
# CertificateStatus extension: type = ocsp (1)
#   { ocspResponse: DER-encoded OCSPResponse }
# Client sees fresh(ish) OCSP status without contacting CA directly

TLS Alert Protocol — Errors, Close Notify, and Downgrade

TLS alerts are records with content type 21 (alert). They carry a 2-byte payload: a level (warning = 1, fatal = 2) and a description code. Fatal alerts cause immediate connection termination. Warning alerts allow the connection to continue. The most important alert is close_notify.

Alert Codes

CodeNameLevelDescription
0close_notifyfatalClean close. Sent before closing the TCP connection. Must be acknowledged.
10unexpected_messagefatalReceived an inappropriate message (e.g., application_data before Finished).
20bad_record_macfatalAEAD tag verification failed. Indicates ciphertext integrity failure or wrong key.
21decryption_failedfatalTLS 1.2: PRF decryption failed (RSA PMS decryption, Finished verify, etc.)
22record_overflowfatalRecord payload exceeds 16384 + 2048 bytes (protocol limit).
30handshake_failurefatalCould not negotiate acceptable cipher suite parameters.
40no_certificatewarningSSL 3.0 only — client has no certificate. Not used in TLS.
41bad_certificatefatalCertificate was malformed, failed validation, or is revoked.
42unsupported_certificatefatalCertificate type not supported (e.g., DSA cert when RSA expected).
43certificate_revokedfatalCertificate explicitly revoked.
44certificate_expiredfatalCertificate not within validity window.
45certificate_unknownfatalOther certificate error (chain building, name constraints, etc.).
46illegal_parameterfatalField in handshake was invalid (bad extension, wrong cipher suite, etc.).
47unknown_cafatalIssuer of certificate not found in trust store.
48access_deniedfatalCertificate valid but client does not have access rights.
49decode_errorfatalMessage encoding invalid (could indicate attack).
50decrypt_errorfatalGeneral cryptographic error (signature failed, DH params invalid, etc.).
51export_restrictionfatalExport cipher suite negotiation failed.
56protocol_versionfatalProtocol version not supported (e.g., TLS 1.2 server receiving TLS 1.3 ClientHello).
57insufficient_securityfatalServer requires stronger security than client offered.
70internal_errorfatalImplementation error. Should not be sent externally.
86inappropriate_fallbackfatalServer rejects fallback because client offered lower version than it supports.
90user_canceledwarningHandshake cancelled by user/application. Connection close follows.
100no_renegotiationwarningClient requests renegotiation; server declines. TLS 1.3 removed renegotiation entirely.
109missing_extensionfatalClientHello or ServerHello missing required extension.
110unsupported_extensionfatalServer received an extension it does not understand.
112unrecognized_namefatalServer has no certificate for the SNI name requested.
120bad_certificate_status_responsefatalOCSP stapling response invalid or stapling server unreachable with must-staple cert.
116certificate_requiredfatalServer requires client certificate but none provided (for mTLS).

Close Notify: The Correct Way to End a TLS Connection

close_notify (alert code 0) is the TLS-native way to end a connection. The sender must not close the underlying TCP connection until it has sent close_notify and received the peer's close_notify (or an equivalent alert). This prevents truncation attacks — an attacker who could cut the connection after the last legitimate application-data record could make it appear the data ended earlier than it did. The close_notify guarantees both sides have seen all data.

# Correct TLS shutdown sequence:
# Side A (initiator of close):
1. Send Alert(level=warning, description=close_notify)
2. Wait for peer's close_notify
3. Close TCP connection (send FIN)

# Side B (receiver of close_notify):
1. Receive close_notify alert
2. Send Alert(level=warning, description=close_notify)
3. Close TCP connection

# Wrong: just close TCP without close_notify
# This creates a truncation risk:
#   attacker cuts TCP after last legitimate byte
#   receiver thinks data ended prematurely
#   application interprets truncated data as valid

Downgrade Protection: The DOWNGRD Sentinel

TLS 1.3 adds downgrade protection to prevent attacks where a man-in-the-middle forces a client and server to fall back to TLS 1.2 (or 1.1) when both support 1.3. The last 8 bytes of ServerHello.random are set to a known value if the server is capable of 1.3 but is responding with 1.2 due to a downgrade:

# TLS 1.3 downgrade sentinel bytes in ServerHello.random:
DOWNGRD[8] = 0x44 0x4F 0x57 0x4E 0x47 0x52 0x44 0x01
# "DOWNGRD" followed by 0x01

# Client check:
if (ServerHello.server_version == TLS 1.2 OR lower AND
    server_tls_1_3_capable == true AND
    server_random[24:32] == DOWNGRD[8]):
    abort("downgrade detected")

# This prevents attacks where:
# 1. MITM intercepts ClientHello (which advertises TLS 1.3)
# 2. MITM replaces it with a TLS 1.2 ClientHello
# 3. Server responds with TLS 1.2 ServerHello
# 4. Client would believe it got a legitimate TLS 1.2 connection
# But: client sees DOWNGRD bytes and knows server supports 1.3 → abort

Session Resumption — IDs, Tickets, and PSK

Performing a full TLS handshake every time is expensive: another 1–2 RTT, CPU for key exchange and signature verification, and bandwidth for the certificate chain. TLS provides three session resumption mechanisms to avoid this cost.

TLS 1.2: Session ID and Session Ticket

Session ID (server-side state)

In TLS 1.2 with session ID resumption, the server stores the master secret and handshake parameters under a session ID. The client stores the session ID. On reconnect, the client sends the session ID in ClientHello. If the server recognizes it, it resumes with the cached master secret — skipping the certificate exchange and key exchange entirely.

# TLS 1.2 session ID resumption:
# Full handshake (first connection):
ClientHello
  session_id: [empty or 32-byte random]
  cipher_suites: [TLS_AES_128_GCM_SHA256, ...]
  ↓
ServerHello
  session_id: abcdef12...   ← server stores "abcdef12..." → master_secret
  cipher_suite: TLS_AES_128_GCM_SHA256
# ... full handshake ...
# master_secret cached under session_id

# Resumption (second connection):
ClientHello
  session_id: abcdef12...   ← "I'd like to resume this session"
  cipher_suites: [TLS_AES_128_GCM_SHA256, ...]
  ↓
ServerHello
  session_id: abcdef12...   ← "I know this session"
  cipher_suite: TLS_AES_128_GCM_SHA256
  ↓
  (no certificates, no key exchange!)
ChangeCipherSpec
Finished
  ← encrypted under keys derived from cached master_secret
# Total RTT: 1 (same as full handshake but much smaller data)

# Server state problem: N concurrent sessions = N cached master_secrets
# Load balancer problem: session dies if request goes to different server

Session Ticket (stateless server)

RFC 4507 introduced session tickets to solve the server-side state problem. The server encrypts the session state (master secret, cipher suite, etc.) into a ticket and sends it to the client. The client stores the ticket. On resumption, the client presents the ticket; the server decrypts it and recovers the session. The server can be stateless — all state is in the ticket, which the server decrypts with a ticket encryption key (TEK).

# TLS 1.2 session ticket:
# NewSessionTicket message (sent from server after handshake):
NewSessionTicket
  ticket: ENC_TEK(session_state)   # encrypted with Ticket Encryption Key
  ticket_lifetime_hint: 7200       # seconds, client should discard after this
# session_state includes: master_secret, cipher_suite, client_random, server_random

# Client on resumption:
ClientHello
  session_id: ""           ← empty
  session_ticket: ticket   ← the encrypted blob from NewSessionTicket

# Server decrypts ticket:
session_state = DEC_TEK(ticket)
# Now has master_secret and can resume — stateless on server side

# Security: TEK must be rotated (forward secrecy for resumption)
# If TEK is compromised, attacker can decrypt any ticket → impersonate any session
# Best practice: rotate TEK every few hours, old TEKs can decrypt old tickets

TLS 1.3: PSK Resumption (1-RTT) and 0-RTT

TLS 1.3 unifies session ID and session ticket into PSK (Pre-Shared Key) resumption. The PSK is a shared secret established during a previous handshake (derived from the resumption_master_secret). The server issues NewSessionTicket messages containing the PSK identity and a lifetime.

# TLS 1.3 PSK resumption (1-RTT):
# After full handshake, server sends:
NewSessionTicket
  ticket: ticket_nonce          # identity for this PSK
  ticket_lifetime: 86400        # 1 day in seconds
  ticket_age_add: 0x...         # random, used to compute ticket age
  ticket_extensions: ...        # early_data extension if 0-RTT enabled
  # The PSK = HKDF-Expand-Label(resumption_master_secret,
  #                             "", ticket_nonce, 32)

# Client on reconnect:
ClientHello
  pre_shared_key (PSK extension):
    identities: [
      { ticket: ticket_0, obfuscated_ticket_age },
      { ticket: ticket_1, obfuscated_ticket_age }
    ]
    # obfuscated_ticket_age = ticket_age + ticket_age_add (mod 2^32)
    # Used by server to check if ticket is too old

  key_share: ephemeral X25519 pubkey   ← still sends key_share (for new ECDHE too)
  psk_key_exchange_modes: [psk_dhe_ke, psm_ke]  # which modes supported

# Server selects PSK (usually the oldest valid one):
ServerHello
  pre_shared_key: ticket_0  ← index of selected identity
  key_share: server ephemeral pubkey   ← if psk_dhe_ke mode

# Derivation with PSK (psk_dhe_ke):
# Both PSK and a new ECDHE share contribute to the handshake secret
# PSK = pre-shared secret from previous handshake
# ECDHE = new ephemeral key exchange (adds forward secrecy!)
handshake_secret = HKDF-Extract(
    ECDH(client_ephemeral, server_ephemeral),
    PSK
)
# This is the key difference from TLS 1.2 tickets:
# TLS 1.3 PSK resumption still gets forward secrecy via new ECDHE key exchange
# TLS 1.2 tickets had NO forward secrecy (master_secret was static)

0-RTT: Send Data in the First Flight

0-RTT goes further than 1-RTT resumption: the client sends application data in the same flight as ClientHello, encrypted under a key derived purely from the PSK (no new ECDHE in this specific connection, unlike psk_dhe_ke). This eliminates all round trips for resumed connections. The cost: no forward secrecy (data encrypted under PSK-derived key only), and replay risk.

# TLS 1.3 0-RTT resumption:
ClientHello
  pre_shared_key: identities with valid PSK
  early_data: extension indicating 0-RTT desired
  key_share: ephemeral X25519 (if also doing psk_dhe_ke)  ← optional
  (Application Data)     ← encrypted under client_early_traffic_secret

ServerHello
  pre_shared_key: selected ticket
  early_data: extension (server accepts 0-RTT)
  key_share: server ephemeral  ← if psk_dhe_ke

{EncryptedExtensions}
  early_data: accepted          ← server confirms 0-RTT

{Finished}         ← encrypted under handshake keys
[Application Data] ← encrypted under application keys

# The problem with 0-RTT:
# - client_early_traffic_secret is derived ONLY from PSK (no ECDHE)
# - If PSK is compromised, 0-RTT data is decryptable
# - If server doesn't deduplicate ClientHellos, attacker can REPLAY the request

# Anti-replay with anti-replay cache (e.g., Cloudflare's implementation):
# Key = hash(ClientHello.random || PskBinder)
# First ClientHello with this key → accepted, data processed
# Second ClientHello with same key → rejected (duplicate)

Mutual TLS (mTLS) — Client Certificates

Standard TLS only authenticates the server (via its certificate). Mutual TLS (mTLS) adds client authentication: the server requests a certificate from the client, and the client must present a valid cert signed by a CA the server trusts. mTLS is the standard identity mechanism in zero-trust networks, service meshes, and VPN alternatives.

How mTLS Works in TLS 1.3

# Server requests client certificate via CertificateRequest:
struct {
    opaque certificate_request_context;
    Extension extensions<0..2^16-1>;  # must include supported_signature_algo
} CertificateRequest;

# extensions typically include:
# - signature_algorithms: [ecdsa_secp256r1_sha256, rsa_pss_rsae_sha256, ...]
# - authority_names: [list of acceptable CA distinguished names]
#   client cert must be issued by one of these CAs (or a sub-CA)

# Client responds with:
Certificate
  client_cert_chain: [client_cert, intermediate1, ...]
CertificateVerify
  signature over transcript_hash using client's private key
Finished

# Server validates:
1. Certificate chain against server's trust store
2. Hostname check: client's cert must have SAN matching the service name
3. Signature on CertificateVerify (proves client has private key matching cert)
4. authority_names: client's issuing CA must be in server's allowed list

# Note: the CertificateRequest is sent ENCRYPTED (after ServerHello in TLS 1.3)
# In TLS 1.2, CertificateRequest is plaintext

Service Mesh: SPIFFE/SPIRE and Short-Lived Certificates

In production service meshes (Istio, Linkerd, Consul Connect), workloads don't have long-lived client certificates. Instead, a central authority (SPIFFE/SPIRE) issues short-lived certificates (often 1–24 hours) to each workload. A sidecar proxy (like Envoy or Linkerd's proxy) handles the TLS handshake, rotating certs automatically. This is where mTLS gets interesting: the identity is bound to the workload's SPIFFE ID (spiffe://cluster.local/ns/default/sa/default), not an IP address or human identity.

PropertyTLS (server auth only)mTLS (both sides)
Client certificate requiredNoYes
Server certificate requiredYesYes
Trust store on serverRoot CAs for public webCA for client certs (often internal)
Identity for clientNone (IP or anonymous)X.509 subject / SAN
Use casePublic web APIs, HTTPSService mesh, zero-trust, VPN replacement
Certificate rotationAnnual (public CAs)Hourly/daily (internal CAs, automated)

TLS Termination — Where, How, and the Hardware Offload Story

TLS termination is the point in the infrastructure where encrypted traffic is decrypted and passed (in plaintext) to the application layer. Where you terminate TLS has major implications for latency, security, compliance, and cost.

Where to Terminate

Direct on the application server

The application server (Nginx, Envoy, your Go/Python/Node process) terminates TLS directly. Simple, low latency (no extra hop), good for small deployments. The downside: CPU overhead for cryptographic operations can dominate when you're handling many connections. On a CPU-constrained machine, TLS can consume 10–30% of CPU time.

Load balancer (L4/L7)

A load balancer (AWS ALB, Cloudflare, HAProxy, Envoy) terminates TLS and forwards plaintext to backend services. This centralizes certificate management and offloads crypto. The tradeoff: plaintext on the internal network is a security risk if the network is compromised. Use mTLS between the LB and backends to mitigate this.

Sidecar proxy (service mesh)

In a service mesh like Istio or Linkerd, every pod has a sidecar proxy (Envoy) that terminates TLS for all inbound and outbound traffic. The application code sees plaintext, unaware of TLS. The sidecar handles certificate rotation, mTLS enforcement, and policy. This is the Kubernetes pattern for zero-trust: all service-to-service communication is mTLS, but the application developer doesn't manage certificates.

Reverse proxy (CDN, WAF)

Cloudflare, Fastly, and AWS CloudFront terminate TLS at the edge, close to users. They then connect to origin over a TLS tunnel (Cloudflare's tunnel, or just HTTPS to the origin). Benefits: DDoS protection, caching, WAF rules applied before traffic reaches origin. Cost: all traffic passes through the CDN's infrastructure, which is a concern for data sovereignty in regulated industries.

Hardware Crypto Offload: AES-NI and AVX

Modern CPUs include hardware support for AES and vector operations that makes crypto dramatically faster. On servers handling millions of TLS connections, this matters enormously.

# AES-NI: hardware instructions for AES
# Available on Intel Westmere (2010+) and AMD Zen (2017+)
# Key benefit: AES-GCM throughput goes from ~100 MB/s (software) to ~2 GB/s (AES-NI)

# AES-NI instructions:
AESENC  - one round of AES encryption
AESENCLAST - final round
AESDEC  - one round of AES decryption
AESDECLAST - final round
AESKEYGENASSIST - key expansion assist

# TLS record encryption with AES-NI:
# Before AES-NI: ~100-200 cycles per 16-byte block (software lookup tables)
# With AES-NI:  ~10-20 cycles per 16-byte block

# AVX (Advanced Vector Extensions):
# AVX2 and AVX-512 provide 256-bit and 512-bit vector registers
# ChaCha20 implementation benefits heavily from AVX2:
#   4 streams in parallel, using 256-bit YMM registers
#   ~3x faster than scalar code on Intel Skylake

# GHASH (AES-GCM's polynomial multiplication):
# Also hardware-accelerated via CLMUL (Carry-less Multiplication) instruction
# CLMUL on Intel Sandy Bridge+: ~3x faster than software GHASH

# What OpenSSL uses:
OpenSSL 1.1+ auto-selects hardware crypto when available
$ openssl engine -t
(cryptodev) BSD cryptodev engine
(dynamic) Dynamic SSL engine
(gost) GOST engine

$ openssl speed -elapsed -evp aes-128-gcm
# On a modern Intel Xeon:
#                                op/s
# aes-128-gcm (hardware):   ~10,000,000
# aes-128-gcm (software):   ~1,200,000

Session Tickets and Key Rotation for Forward Secrecy

When you run TLS on multiple servers behind a load balancer, session ticket encryption keys (TEKs) must be shared across all servers so any server can decrypt any ticket. But sharing TEKs for too long defeats forward secrecy: if a TEK is compromised, all sessions issued with that TEK can be decrypted. The solution: rotate TEKs regularly (every few hours is reasonable), and issue session tickets with a maximum lifetime of 24 hours.

OpenSSL Internals — SSL_CTX, SSL, BIO, Engine

OpenSSL is the reference TLS implementation, used in Apache, Nginx, curl, and most server-side software. Its internal architecture centers on three key objects: SSL_CTX (configuration context), SSL (per-connection state), and BIO (I/O abstraction). Understanding how they connect is essential for debugging and tuning.

The Object Hierarchy

# SSL_CTX: the global configuration context
# Created once per process, shared across all connections
SSL_CTX *ctx = SSL_CTX_new(TLS_method());

# Configure minimum TLS version:
SSL_CTX_set_min_proto_version(ctx, TLS1_2_VERSION);

# Load certificate chain:
SSL_CTX_use_certificate_chain_file(ctx, "/path/to/fullchain.pem");
SSL_CTX_use_PrivateKey_file(ctx, "/path/to/privkey.pem", SSL_FILETYPE_PEM);

# Configure cipher suites:
SSL_CTX_set_cipher_list(ctx, "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256");

# Configure trust store:
SSL_CTX_load_verify_locations(ctx, "/etc/ssl/certs/ca-certificates.crt", NULL);

# Session ticket key rotation (critical for forward secrecy):
SSL_CTX_set_tlsext_ticket_keys(ctx, ticket_key_data, ticket_key_len);
# Or: rotate via SSL_CTX_sessions_timeout() and external ticket encryption

# SSL: per-connection object
# Created from context; one SSL per TCP connection
SSL *ssl = SSL_new(ctx);

# Associate with a BIO (network socket):
BIO *bio = BIO_new_socket(fd, BIO_NOCLOSE);
SSL_set_bio(ssl, bio, bio);

# Or with a custom BIO chain for testing:
BIO *bio = BIO_new(BIO_s_mem());
SSL_set_bio(ssl, bio, bio);

# Perform TLS handshake:
int ret = SSL_accept(ssl);   // server-side: wait for client hello
int ret = SSL_connect(ssl);  // client-side: initiate handshake

# Check handshake result:
if (ret <= 0) {
    int err = SSL_get_error(ssl, ret);
    // ERR_print_errors_fp(stderr);
}

// Read/write application data:
int n = SSL_read(ssl, buf, sizeof(buf));    // decrypts and returns plaintext
int n = SSL_write(ssl, buf, n);             // encrypts and sends ciphertext

// Graceful shutdown:
SSL_shutdown(ssl);  // sends close_notify, receives peer's close_notify

BIO Chain: How OpenSSL Does I/O

BIO (Basic I/O) is OpenSSL's I/O abstraction layer. It allows the same TLS code to work with network sockets, memory buffers, files, or custom I/O backends. An SSL object has a read BIO and a write BIO. Data written to the SSL object goes through the TLS record layer, emerges as TLS records, and gets written to the write BIO. Similarly, data read from the read BIO goes through TLS decryption and emerges as plaintext in SSL_read.

# The BIO stack for a typical server:
TCP socket (fd)
  ↑
BIO_s_socket()  ← wraps file descriptor, does read()/write()
  ↑
SSL object      ← TLS record layer: encrypt/decrypt, handshake
  ↑
Application     ← SSL_read(), SSL_write()

# For testing: memory BIO
BIO *rbio = BIO_new(BIO_s_mem());  // read from memory
BIO *wbio = BIO_new(BIO_s_mem());  // write to memory
SSL_set_bio(ssl, rbio, wbio);

# BIO_s_mem(): data written to SSL appears in wbio's buffer
# Data can be fed into rbio to drive SSL state machine

# Filter BIOs: BIO_push chains filters
BIO *bio = BIO_new(BIO_s_socket());
BIO_push(BIO_new(BIO_f_buffer()), bio);  // adds buffering layer
# Read/write operations first hit buffer BIO, then socket BIO

# Custom BIO: implement read/write via callbacks
BIO_METHOD *my_method = BIO_meth_new(BIO_TYPE_SOURCE_SINK, "my_bio");
BIO_meth_set_read_ex(my_method, my_bio_read_ex);
BIO_meth_set_write_ex(my_method, my_bio_write_ex);

The Engine API: Pluggable Crypto Backends

OpenSSL's Engine API allows crypto operations to be offloaded to external hardware (HSMs, TPMs, PKCS#11 tokens, hardware security modules). An engine provides the implementation for specific algorithms. The most common use: loading a private key from an HSM rather than from an PEM file on disk.

# Engine API for HSM-backed private keys:
# Load the engine shared library
ENGINE_load_builtin_engines();

# Use dynamic engine to load PKCS#11 engine (OpenSC, etc.)
ENGINE *e = ENGINE_by_id("dynamic");
ENGINE_ctrl_cmd_string(e, "SO_PATH", "/usr/lib/engines/engine_pkcs11.so", 0);
ENGINE_ctrl_cmd_string(e, "ID", "pkcs11", 0);
ENGINE_ctrl_cmd_string(e, "LOAD", NULL, 0);

# Initialize the PKCS#11 module and find the key:
ENGINE_ctrl_cmd_string(e, "MODULE_PATH", "/usr/lib/opensc-pkcs11.so", 0);

# Get the RSA key from slot 0, ID 1:
RSA *rsa = ENGINE_load_cert(e, NULL);  // or ENGINE_init + key lookups
# Or via EVP_PKEY:
EVP_PKEY *pkey = ENGINE_load_private_key(e, "pkcs11:...", NULL, NULL);
SSL_CTX_use_PrivateKey(ctx, pkey);

# Cloudflare uses the QUIC-via-BoringSSL approach:
# BoringSSL's engine substitutes all crypto operations with optimized
# implementations. TLS record processing is inlined for performance.

SSL_CTX Memory and Session Cache

# Session cache: server-side cache of completed handshakes
# Reduces CPU and latency for returning clients
SSL_CTX_set_session_cache_mode(ctx, SSL_SESS_CACHE_NO_AUTO_CLEAR);

# Configure cache size and timeout:
SSL_CTX_sess_set_cache_size(ctx, 1024 * 1024);    // max 1M entries
SSL_CTX_set_timeout(ctx, 300);                    // 5-minute session timeout
SSL_CTX_set_psk_client_callback(ctx, my_psk_cb);  // for PSK client-side

# Session ticket callbacks (for custom ticket encryption):
SSL_CTX_set_tlsext_ticket_key_cb(ctx, ticket_key_callback);
// Callback returns: 1 = ticket accepted, 0 = ticket not recognized, -1 = error

# Memory model:
# SSL_CTX: one per process, ~50-200 KB (cipher list, certs, trust store)
# SSL: one per TCP connection, ~5-20 KB
# SSL_SESSION: per session, ~200-500 bytes
# Session cache: up to millions of entries → use LRU eviction

# Connection lifecycle:
# 1. SSL_new() → SSL object allocated from SSL_CTX's freelist
# 2. SSL_accept() / SSL_connect() → handshake state machine
# 3. SSL_read() / SSL_write() → record I/O
# 4. SSL_shutdown() → alert exchange + cleanup
# 5. SSL_free() → return SSL object to freelist for reuse

Common TLS Bugs and Gotchas

TLS misconfigurations are the most common source of TLS security failures. This section covers the real-world bugs that appear repeatedly in audits, penetration tests, and incident postmortems.

Certificate Hostname Mismatches

The server presents a certificate whose Subject Alternative Name (SAN) doesn't cover the hostname the client used to connect. This can happen because:

  • The wrong cert was loaded (prod cert on staging, or vice versa)
  • A wildcard cert was used outside its domain scope (*.example.com doesn't cover example.com)
  • SNI mismatch: multiple certs on one IP, the load balancer chose the wrong one based on plaintext SNI
  • Clients silently ignore CN (Common Name) for hostname verification; the SAN must be present and correct
# Example mismatch: connecting to api.example.com but cert only has:
Subject: CN=www.example.com
SAN: DNS:www.example.com, DNS:*.example.com
# api.example.com is NOT covered (not same as *.example.com)
# Result: SSL_ERROR_CERTHostnameMismatch in OpenSSL

# Fix: ensure cert has SAN entries for every hostname served
# For multi-tenant SaaS with thousands of domains: use SAN cert with SNI
# or ACME-protocol automation (Let's Encrypt) to provision per-domain certs

Weak or Deprecated Cipher Suites

Many servers are still configured with weak cipher suites for backward compatibility. RC4 (code 0x004C) is broken: a motivated attacker can recover plaintext after enough encryptions due to biases in the RC4 keystream. Export-grade ciphers (like TLS_RSA_EXPORT_WITH_DES40_CBC_SHA) allow a man-in-the-middle to decrypt traffic by brute-forcing the 40-bit export key. CBC mode ciphers are vulnerable to padding oracle attacks (POODLE, Lucky13) when implemented incorrectly.

# Weak ciphers to disable:
RC4_*                              # RC4 biases — plaintext recovery after ~2^25 encs
TLS_RSA_*_DES_*                    # 56-bit DES — brute force in hours
TLS_DHE_*_3DES_*                   # 168-bit 3DES — 64-bit birthday attack → 2^84
TLS_ECDHE_*_NULL_*                 # No encryption (just DH auth + NULL cipher)
TLS_RSA_*_NULL_*                   # No encryption (just RSA)
EXPORT_*                           # 40/56 bit export grade — trivial to break

# Check your configuration:
$ openssl ciphers -v 'ALL:!aNULL:!eNULL'
# Shows all available ciphers sorted by strength

# Prefer these (order by preference):
TLS_AES_256_GCM_SHA384
TLS_CHACHA20_POLY1305_SHA256
TLS_AES_128_GCM_SHA256
TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256

# Scan a server for weak ciphers:
$ nmap --script ssl-enum-ciphers -p 443 target.com

Self-Signed Certificates in Production

A self-signed certificate in production means the client must trust it explicitly, bypassing the normal PKI trust chain. This is fine for internal services where you control both sides (testing, development, IoT). It's a serious vulnerability in production because:

  • Any attacker with a valid certificate (any CA-issued cert, even for a different domain) can perform a MITM attack if the client trusts self-signed
  • It trains users to click through certificate warnings ("just accept this cert")
  • No revocation infrastructure works for self-signed certs
  • No Certificate Transparency — unauthorized issuance goes undetected

Fix: use Let's Encrypt (free, automated, 90-day certs) or your internal CA (for private networks with a controlled trust store).

Certificate Expiry and Automation

Expired certificates are the #1 cause of TLS outage in production. A cert expires at notAfter — midnight UTC on that date — and all TLS connections immediately fail. The fix is automated renewal: use ACME (Let's Encrypt, Buypass, etc.) or a centralized PKI. The critical operational task: monitoring expiration dates and having a process for emergency renewal.

# Check certificate expiration:
$ openssl s_client -connect example.com:443 

Intermediate Certificate Missing from Chain

The server sends its leaf certificate but forgets the intermediate CA certificate(s) in the chain. Clients that don't have the intermediate cached can't build the chain to the root, and validation fails. This is invisible to the operator unless they check with tools like openssl s_client -showcerts or SSL Labs.

# Check chain completeness:
$ openssl s_client -connect example.com:443 -showcerts 2>/dev/null | \
  grep -E "subject=|issuer="
# Should see: leaf → intermediate → (root if sent, often not needed)

# Check what clients see:
$ ssllabs.com scan → "Chain issues: incomplete chain"
# Fix: concatenate leaf + intermediate into single PEM:
$ cat leaf.crt intermediate.crt > /etc/ssl/certs/fullchain.pem
$ openssl x509 -noout -in fullchain.pem -subject -issuer
# First: subject=example.com, issuer=Let's Encrypt R3
# Second: subject=Let's Encrypt R3, issuer=ISRG Root X1

Private Key on Disk Without Protection

TLS private keys stored with 0644 permissions (world-readable) are a critical failure. Anyone who can read the key can impersonate the server. Even if the key is encrypted with a passphrase, many deployment patterns copy the decrypted key to a temp file readable by the web server process.

# WRONG: world-readable private key
$ ls -la /etc/ssl/private/server.key
-rw-r--r-- 1 root root 2240 Apr  1 12:00 server.key  ← anyone can read

# RIGHT: 0600, owned by root
$ ls -la /etc/ssl/private/server.key
-rw------- 1 root root 2240 Apr  1 12:00 server.key  ← root only

# BEST: HSM-backed key (never leaves secure hardware)
ENGINE_load_dynamic();
EVP_PKEY *pkey = ENGINE_load_private_key(e, "pkcs11:...", NULL, NULL);
# Key operations happen INSIDE the HSM; private key never exposed to application

# Cloudflare, Google, and others use HSMs for their root keys.
# For workload certs, software is fine (automated rotation limits exposure).

Renegotiation Vulnerability (CVE-2009-3555)

A 2009 vulnerability in TLS renegotiation allowed an attacker to inject plaintext prefix into an established TLS session by causing the client to renegotiate while the attacker held the connection open. The fix (RFC 5746) added a renegotiation_info extension that binds the renegotiated handshake to the original one. TLS 1.3 removed renegotiation entirely to avoid this class of issue.

If you see Secure Renegotiation IS NOT supported in an SSL scan, the server is either very old or intentionally disabled renegotiation. The former is an issue; the latter is fine (just update). Never disable renegotiation without ensuring clients don't need it.

HTTP Downgrade and HSTS

Even with TLS correctly configured, an HTTP response delivered over HTTPS can contain a Location: http:// redirect that sends users to the plaintext version. Or a MITM can inject a meta-refresh or 302 redirect to HTTP. HSTS (HTTP Strict Transport Security, RFC 6797) tells browsers: "only ever connect to this domain over HTTPS, for the next N seconds." This prevents downgrade attacks after the first visit.

# HSTS header:
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
# max-age: 2 years (required for preload submission)
# includeSubDomains: include all subdomains
# preload: submit to hstspreload.org for browser hardcoding

# HSTS preload requirements:
# 1. max-age ≥ 31536000 (1 year)
# 2. includeSubDomains directive
# 3. Must serve all subdomains over HTTPS (no HTTP fallback)
# 4. Valid HSTS on root domain AND all subdomains
# 5. Submit to https://hstspreload.org

Ticket Key Rotation and Forward Secrecy

If you serve TLS from multiple servers and share session ticket keys for stateless resumption, those keys must be rotated regularly. An attacker who obtains a ticket encryption key can decrypt any session ticket and impersonate the associated session. The risk window equals the TEK lifetime. Rotate TEKs every few hours and ensure old TEKs are retired within 24–48 hours.

# OpenSSL ticket key rotation:
# Ticket key = 48 bytes: 16-byte key_name || 16-byte hmac_key || 16-byte aes_key
# When rotating:
# 1. Generate new 48-byte key
# 2. Start issuing new tickets with new key
# 3. Continue accepting old keys during grace period (e.g., 1 hour)
# 4. After grace period, stop accepting old keys
# 5. Delete old keys from memory

# In Nginx:
ssl_session_tickets off;  # or manage ticket keys via shared memory
# Note: Nginx doesn't expose ticket key rotation without reload;
# use a lua or nginx plus API for hot rotation

# In HAProxy:
# ticket keys in configuration, reload to rotate
# Or use stats socket for runtime key rotation

Misconfigured OCSP Must-Staple

A certificate with the OCSP Must-Staple extension (RFC 6961) tells the server: "this connection must include a fresh OCSP response." If the server doesn't staple the OCSP response, the client fails the connection. This is good for security (guaranteed freshness), but bad if the OCSP responder is down — then all connections fail. The fix is redundant stapling: serve two OCSP responses (current + backup), and use a reliable OCSP responder (or use CRLite / OCSPMoT instead).

TLS 1.3 vs TLS 1.2 — Key Numbers

Round trips (new conn, TLS 1.3)
1
Round trips (new conn, TLS 1.2)
2
Round trips (0-RTT resumption)
0
TLS 1.3 cipher suites
5 (all AEAD)
TLS 1.2 cipher suites (valid)
300+
Default key exchange (1.3)
X25519
Cert chain typical size
2–4 KB
TLS 1.3 adoption (web)
~75% (2025)
PQ hybrid overhead (X25519+ML-KEM)
~1.2 KB extra
AES-GCM throughput (AES-NI)
~2 GB/s per core
ChaCha20-Poly1305 (no AES-NI)
~1 GB/s per core
TLS record max size
16384 bytes

FAQ

Why is the TLS 1.3 record header version still 0x0303 (TLS 1.2)?

For backward compatibility with middleboxes that inspect the record layer version field and drop connections for "unknown" versions. TLS 1.3's real version is communicated inside ClientHello via the supported_versions extension (0x0304 for TLS 1.3). The record layer version field is syntactic camouflage: it says "TLS 1.2" so middleboxes that only look at the header don't interfere. This is documented in RFC 8446 Appendix D.

Why does TLS 1.3 have no ChangeCipherSpec? Didn't we need that to switch to encrypted mode?

In TLS 1.2, ChangeCipherSpec was needed because key exchange happened BEFORE you switched cipher states. You finished RSA or DH key exchange, then sent ChangeCipherSpec to say "everything after this point uses the keys we just agreed on." TLS 1.3 moves key derivation inline: once the server computes the ECDHE shared secret from ClientHello's key_share, it immediately derives handshake keys and encrypts ServerHello. The state transition happens as part of the normal handshake flow — no separate signal needed. Removing ChangeCipherSpec also removes a message type that could be used to confuse the state machine.

What's the difference between session_id (1.2), session_ticket (1.2), and PSK (1.3)?

In TLS 1.2: session_id resumption was server-side state — the server cached the master secret under a session ID. session_ticket was the same idea but the server encrypted the session state (master secret, cipher suite, etc.) and gave it to the client to present back — stateless on the server. In TLS 1.3: both unified into PSK. The server issues NewSessionTicket messages containing a PSK identity; the client offers it back in the pre_shared_key extension with a binder HMAC that proves it knows the PSK. TLS 1.3 PSK resumption adds a new ECDHE key exchange, giving forward secrecy even on resumed connections — TLS 1.2's session tickets had none (the master_secret was static across resumptions).

How does OCSP stapling actually work at the wire level?

The server embeds the OCSP response in a CertificateStatus extension in ServerHello (TLS 1.2) or in an EncryptedExtensions message (TLS 1.3). The extension type is 0x0005 (status_request in TLS 1.2, translated to status_request in the handshake extensions). The client receives and validates the OCSP response: it checks the signature is from the CA's OCSP signing key, the producedAt is recent (within max-age), and the certStatus is "good." If the stapled response is missing or stale and the certificate has must-staple, the client fails the connection.

Is TLS 1.2 with ECDHE safe to use in 2025?

Yes — TLS 1.2 with ECDHE (e.g., ECDHE-RSA-AES128-GCM-SHA256) is still considered secure when properly configured (no RC4, no CBC modes, no weak DH groups). The main reasons to prefer TLS 1.3 are: 1 RTT vs 2, cleaner security properties (no RSA key transport, no CBC modes, mandatory forward secrecy), and simpler cipher suite negotiation. But if you have clients that don't support 1.3, TLS 1.2 with a modern ECDHE suite is not a security problem. Disable TLS 1.2 only when you can confirm all clients support 1.3.

How does the Finished message prove anything?

The Finished message is a HMAC over the full transcript hash (all handshake messages so far), computed using the handshake traffic secret derived from the shared key. If an attacker modified any handshake message in transit, the transcript hash would differ, and the HMAC computed with the correct secret would not match. Since the attacker doesn't know the handshake traffic secret (they don't have the ECDHE shared secret), they cannot produce a valid Finished. The client verifies the server's Finished; the server verifies the client's Finished. After both Finished messages are verified, both sides know that: (a) the key exchange succeeded, and (b) no handshake messages were tampered with.

Why do my browser dev tools show "TLS 1.3 (TLS_AES_128_GCM_SHA256, X25519, RSA)"?

That's four orthogonal fields displayed as one label: protocol version (TLS 1.3), AEAD + hash (TLS_AES_128_GCM_SHA256), key exchange group (X25519), and certificate signature algorithm (RSA — the server cert is signed with RSA-PSS or RSA-PKCS1v1.5). All four are negotiated independently in TLS 1.3. You'd ideally see ECDSA for the signature (smaller, faster) rather than RSA, but RSA is still the most common on the web because most certificate authorities issue RSA-2048 certificates.

How do I disable TLS 1.2 on a server safely?

Audit first. Most modern stacks (browsers, mobile OSes, API clients) support TLS 1.3 since 2018. Old Java versions (<8u251), embedded devices, and corporate proxy-MITM boxes may not. Use your CDN's TLS version dashboard or Wireshark captures to check what clients connect with. For a public API with known clients, drop 1.2 immediately. For a website with broad consumer traffic, add a 6-month deprecation warning header (TLS-1.2-Deprecation: 2025-06-01) and monitor 1.2 connection rates before disabling.

What's stopping us from switching to pure post-quantum (ML-KEM) and dropping ECDHE?

Trust. ML-KEM (NIST standardized August 2024 as FIPS 203) is new. If a flaw is found — like the 2022 SIDH break that killed a previous PQ key exchange candidate — pure-PQ deployments would need emergency migration. Hybrid (X25519 + ML-KEM-768) gives belt-and-suspenders: an attacker needs to break both classical ECDH and the lattice problem. Once ML-KEM has 5–10 years of public cryptanalysis without flaws, pure-PQ becomes the default. Chrome enabled X25519MLKEM768 by default in 2024, but still falls back to pure X25519 on rejection.

Why does the TLS certificate chain contain an intermediate CA cert, not just the leaf?

The root CA is pre-installed in your operating system's trust store (Mozilla, Apple, Microsoft maintain these lists). The server only needs to send the chain from the leaf to but NOT including the root — the client already trusts the root. Sending the root would waste bandwidth and is unusual. The chain sent is: leaf → intermediate CA(s) → (root is NOT sent, but the client finds it in trust store). If there's no intermediate (self-signed leaf), the chain is just one cert — but this requires the client to already trust that cert as a root, which only works for private CAs.