Dissecting the TLS 1.3 Handshake in Python: ECDHE, the HKDF Key Schedule, and AEAD

Dissect the TLS 1.3 1-RTT handshake per RFC 8446: perform ECDHE key exchange with X25519, implement RFC 5869's HKDF-Extract/Expand from scratch and verify it matches cryptography.hazmat.primitives.kdf.hkdf.HKDF exactly, derive the TLS 1.3 key schedule (Early Secret to Handshake Secret) via HKDF-Expand-Label, and run a full AES-GCM encrypt/decrypt round trip with the derived keys — all executed and verified in Python.

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.

StepSenderContent
1Client → ServerClientHello: supported cipher suites, an ECDHE public key (key_share), and supported groups (e.g., X25519)
2Server → ClientServerHello: returns its ECDHE public key. At this point both sides can compute the shared ECDHE secret
3Server → Client{EncryptedExtensions, Certificate, CertificateVerify, Finished}: everything from here on is encrypted with the handshake traffic key
4Client → ServerFinished: client-side handshake completion notice
5BothSwitch 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.

\[ \text{HKDF-Expand-Label}(\text{secret}, \text{label}, \text{context}, L) = \text{HKDF-Expand}(\text{secret},\ \text{HkdfLabel}, L) \tag{4} \]

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

AspectTLS 1.2TLS 1.3
Handshake round trips2-RTT1-RTT (0-RTT on resumption)
Key exchangeRSA key transport or (EC)DHE(EC)DHE mandatory (forward secrecy enforced)
Symmetric cipherCBC mode etc. (padding-oracle risk)AEAD mandatory (AES-GCM / ChaCha20-Poly1305)
Key derivationTLS-specific PRFHKDF (RFC 5869-compliant)
Deprecated featuresRC4, SHA-1, static RSA key exchangeRemoved

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.

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).