Introduction
https://yuhi-sa.github.io/en/posts/20260614_cryptography_roadmap/1/ listed “a detailed walkthrough of the TLS 1.3 handshake” as a placeholder for a future article. This article implements it. TLS 1.3 is a protocol built by combining pieces already covered in earlier articles: the ECDH key exchange from https://yuhi-sa.github.io/en/posts/20260702_elliptic_curve_cryptography/1/, the AES-GCM cipher from https://yuhi-sa.github.io/en/posts/20260703_aes_symmetric_crypto/1/, and the ECDSA/EdDSA signatures from https://yuhi-sa.github.io/en/posts/20260704_digital_signature/1/. Here we trace concretely how those pieces combine into a secure session key, via the HKDF key-derivation function’s derivation and Python implementation.
The TLS 1.3 Handshake at a Glance (1-RTT)
TLS 1.2 and earlier required two round trips (2-RTT) to agree on keys; TLS 1.3 compresses this to one round trip (1-RTT), thanks to an “optimistic” design where the client sends a candidate key-exchange public key in its very first message.
| Step | Sender | Content |
|---|---|---|
| 1 | Client → Server | ClientHello: supported cipher suites, an ECDHE public key (key_share), and supported groups (e.g., X25519) |
| 2 | Server → Client | ServerHello: returns its ECDHE public key. At this point both sides can compute the shared ECDHE secret |
| 3 | Server → Client | {EncryptedExtensions, Certificate, CertificateVerify, Finished}: everything from here on is encrypted with the handshake traffic key |
| 4 | Client → Server | Finished: client-side handshake completion notice |
| 5 | Both | Switch to application traffic keys and begin communication |
Key exchange completes as of step 2, so the client can start preparing encrypted application data without waiting for step 3 (real TLS 1.3 also has a 0-RTT resumption feature, but this article focuses on the basic 1-RTT flow).
ECDHE Key Exchange: X25519
The key exchange TLS 1.3 mandates is ECDHE (Ephemeral Elliptic-curve Diffie-Hellman), and most implementations use Curve25519 (X25519). As covered in https://yuhi-sa.github.io/en/posts/20260702_elliptic_curve_cryptography/1/, client and server each generate an ephemeral key pair and derive the same shared secret from their own private key and the other’s public key via scalar multiplication.
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
client_priv = X25519PrivateKey.generate()
server_priv = X25519PrivateKey.generate()
shared_secret_client = client_priv.exchange(server_priv.public_key())
shared_secret_server = server_priv.exchange(client_priv.public_key())
assert shared_secret_client == shared_secret_server
Output:
client-computed shared secret: 40b650f3f437f715c5843e0c3660e9acdbbbe1855350ab5a3c87bd22344d402c
server-computed shared secret: 40b650f3f437f715c5843e0c3660e9acdbbbe1855350ab5a3c87bd22344d402c
match: True
Both sides independently computed identical shared secrets. But this shared secret isn’t used directly as an encryption key. TLS 1.3 runs it through a chain of HKDF key-derivation calls to safely derive several purpose-specific keys.
HKDF: Extract-and-Expand Key Derivation (RFC 5869)
HKDF has two stages.
HKDF-Extract: extracts a statistically uniform pseudorandom key (PRK) from the input key material (IKM).
\[ \text{PRK} = \text{HMAC-Hash}(\text{salt}, \text{IKM}) \tag{1} \]HKDF-Expand: generates the required length of output key material (OKM) from the PRK and context info.
\[ T(0) = \varnothing, \qquad T(i) = \text{HMAC-Hash}(\text{PRK},\ T(i-1) \Vert \text{info} \Vert i) \tag{2} \] \[ \text{OKM} = T(1) \Vert T(2) \Vert \cdots \quad \text{(truncated to the first L bytes)} \tag{3} \]Python implementation:
import hmac, hashlib
def hkdf_extract(salt, ikm, hash_len=32):
if not salt:
salt = b"\x00" * hash_len
return hmac.new(salt, ikm, hashlib.sha256).digest()
def hkdf_expand(prk, info, length, hash_len=32):
n = -(-length // hash_len) # ceiling division
t = b""
okm = b""
for i in range(1, n + 1):
t = hmac.new(prk, t + info + bytes([i]), hashlib.sha256).digest()
okm += t
return okm[:length]
Numerical Verification: Matching RFC 5869 Test Vectors
Using RFC 5869 Test Case 1 (fixed salt/IKM/info, 42-byte output), we compared the from-scratch implementation above against cryptography.hazmat.primitives.kdf.hkdf.HKDF.
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes
salt = bytes.fromhex("000102030405060708090a0b0c")
ikm = bytes([0x0b] * 22)
info = bytes.fromhex("f0f1f2f3f4f5f6f7f8f9")
prk_scratch = hkdf_extract(salt, ikm)
okm_scratch = hkdf_expand(prk_scratch, info, 42)
hkdf_lib = HKDF(algorithm=hashes.SHA256(), length=42, salt=salt, info=info)
okm_lib = hkdf_lib.derive(ikm)
OKM (scratch) : 3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865
OKM (cryptography): 3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865
match: True
The from-scratch implementation and the standard library agree exactly. With this verified HKDF as a foundation, we build TLS 1.3’s own key schedule on top of it.
The TLS 1.3 Key Schedule (RFC 8446 §7.1)
TLS 1.3 defines a thin wrapper around HKDF called HKDF-Expand-Label that attaches a label, and chains it repeatedly to derive keys in stages.
HkdfLabel is a structured byte string packing the length, the label string ("tls13 " + label), and context (usually a transcript hash of prior messages). The key schedule proceeds as follows:
0
|
v
PSK -> HKDF-Extract = Early Secret
|
+-----> Derive-Secret(., "derived", "")
|
v
(EC)DHE -> HKDF-Extract = Handshake Secret
|
+-----> Derive-Secret(., "c hs traffic", transcript) = client_handshake_traffic_secret
+-----> Derive-Secret(., "s hs traffic", transcript) = server_handshake_traffic_secret
For a normal full handshake without a pre-shared key (PSK), the Early Secret’s input IKM is a zero-filled byte string.
import struct
def hkdf_expand_label(secret, label, context, length):
full_label = b"tls13 " + label
hkdf_label = (struct.pack(">H", length) + bytes([len(full_label)]) + full_label
+ bytes([len(context)]) + context)
return hkdf_expand(secret, hkdf_label, length)
def derive_secret(secret, label, messages_hash):
return hkdf_expand_label(secret, label, messages_hash, 32)
zero_key = b"\x00" * 32
empty_hash = hashlib.sha256(b"").digest()
early_secret = hkdf_extract(b"\x00", zero_key)
derived_early = derive_secret(early_secret, b"derived", empty_hash)
handshake_secret = hkdf_extract(derived_early, shared_secret_client)
transcript_hash = hashlib.sha256(b"ClientHello||ServerHello (mock transcript)").digest()
client_hs_traffic_secret = derive_secret(handshake_secret, b"c hs traffic", transcript_hash)
server_hs_traffic_secret = derive_secret(handshake_secret, b"s hs traffic", transcript_hash)
client_write_key = hkdf_expand_label(client_hs_traffic_secret, b"key", b"", 16) # AES-128-GCM key
client_write_iv = hkdf_expand_label(client_hs_traffic_secret, b"iv", b"", 12)
Output:
handshake_secret : a2b298340a3f2cca87265e560a30a34d83101fc756b17d17dc249de535c2020e
client_hs_traffic_secret : 0430d76b951a157b85dd83362dc3c4fca59f8b861de863bd20a78365a898e702
server_hs_traffic_secret : 3056eba25fdda8d7a9630e4f2de5d6f37c48adb19301a4c7ccd6966398cba60b
client_write_key (16B) : 6091cfaa9bb4cedc772cef8aea5e5a93
client_write_iv (12B) : 025b4bcfb3454a951f3c9ab9
From a single X25519 shared secret, chained HKDF calls derived several purpose-specific keys — separate handshake traffic secrets for client and server, and from those, an encryption key and IV. Including the transcript hash as key-derivation input means that any tampering with the handshake messages changes every subsequent derived key, cryptographically guaranteeing the integrity of the entire handshake.
Record Protection via AEAD: Encrypting and Decrypting with Derived Keys
We confirmed the derived client_write_key / client_write_iv can actually encrypt and decrypt a handshake record with AES-128-GCM.
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
aead = AESGCM(client_write_key)
plaintext = b"Finished message (mock TLS 1.3 handshake record)"
aad = b"\x17\x03\x03\x00\x50" # mock TLSCiphertext record header as AAD
ciphertext = aead.encrypt(client_write_iv, plaintext, aad)
recovered = aead.decrypt(client_write_iv, ciphertext, aad)
assert recovered == plaintext
decrypted matches : True
We confirmed the end-to-end flow actually works, from ECDHE key exchange through the HKDF key schedule to AEAD encryption. The nonce-uniqueness requirement for AEAD discussed in https://yuhi-sa.github.io/en/posts/20260703_aes_symmetric_crypto/1/ is met in real TLS 1.3 by combining client_write_iv with a per-record sequence number (this article handles only a single record for simplicity).
Key Differences from TLS 1.2
| Aspect | TLS 1.2 | TLS 1.3 |
|---|---|---|
| Handshake round trips | 2-RTT | 1-RTT (0-RTT on resumption) |
| Key exchange | RSA key transport or (EC)DHE | (EC)DHE mandatory (forward secrecy enforced) |
| Symmetric cipher | CBC mode etc. (padding-oracle risk) | AEAD mandatory (AES-GCM / ChaCha20-Poly1305) |
| Key derivation | TLS-specific PRF | HKDF (RFC 5869-compliant) |
| Deprecated features | RC4, SHA-1, static RSA key exchange | Removed |
The biggest design change is that RSA key transport — where compromising the server’s private key retroactively decrypts all past traffic — is gone, and forward secrecy via (EC)DHE is now mandatory. The “use a fresh ephemeral key every session” design covered in https://yuhi-sa.github.io/en/posts/20260614_diffie_hellman/1/ is no longer optional in TLS 1.3 — it’s required.
Observing This in Practice
# Observe an actual TLS 1.3 handshake
openssl s_client -connect example.com:443 -tls1_3 -msg
# Check the negotiated cipher suite
openssl s_client -connect example.com:443 -tls1_3 2>/dev/null | grep "Cipher is"
To inspect decrypted packets in Wireshark, set the SSLKEYLOGFILE environment variable to have your browser or OpenSSL client dump its key log — you’ll see the same kinds of keys derived in this article (e.g., CLIENT_HANDSHAKE_TRAFFIC_SECRET) from real traffic.
Related Articles
- Cryptography Roadmap: Classical Ciphers, Symmetric Keys, RSA, Diffie-Hellman, Elliptic Curves, Hashing, Signatures, and TLS in Python - This article implements one of this hub’s “planned for the future” placeholders.
- Elliptic Curve Cryptography (ECC): Math and Python Implementation - The theoretical foundation for this article’s ECDHE key exchange (X25519).
- SHA-256 and HMAC in Python - Explains the construction and length-extension-attack resistance of the HMAC-SHA256 used repeatedly inside this article’s HKDF.
- Post-Quantum Cryptography in Python: Learning With Errors (LWE) and Regev Encryption - Provides context for how this article’s ECDHE handshake could eventually migrate to Kyber-based key exchange.
- AES / ChaCha20 Symmetric Cryptography: Theory and Python Implementation - Details the AEAD encryption (AES-GCM) that uses the keys derived here.
- Digital Signatures (ECDSA / EdDSA / RSA-PSS): Theory and Python Implementation - The signature schemes used in the handshake’s CertificateVerify step.
- Diffie-Hellman Key Exchange: Theory and Implementation - The foundational (pre-elliptic-curve) key exchange and forward-secrecy concept underlying ECDHE.
- OAuth 2.0 / OpenID Connect: Theory and Implementation - An authorization protocol built on top of TLS, connected here through its own use of public-key cryptography for JWT signature verification.
References
- Rescorla, E. (2018). The Transport Layer Security (TLS) Protocol Version 1.3. RFC 8446.
- Krawczyk, H., & Eronen, P. (2010). HMAC-based Extract-and-Expand Key Derivation Function (HKDF). RFC 5869.
- Thomson, M., & Turner, S. (2018). Illustrated TLS 1.3 Connection (tls13.xargs.org).
- Rescorla, E., et al. (2018). Example Handshake Traces for TLS 1.3. RFC 8448 (test vectors).