Applied Cryptography for Developers Module 12 – TLS 1.3 and certificates
TLS 1.3 key schedule and handshake secrets
Compute the TLS 1.3 key schedule from HMAC, match every secret, key and Finished value of RFC 8448, and see why 0-RTT early data can be replayed.
What you will learn
- Derive the early, handshake and main secrets of TLS 1.3 with HKDF-Extract and Derive-Secret
- Reproduce the secrets, keys, IVs and Finished values of the simple 1-RTT trace in RFC 8448
- Explain how the transcript hash makes a changed handshake message change every key
- Explain why 0-RTT early data can be replayed and how a server and an application limit the damage
Before you start
What you’ll learn · Before you start
What you will learn
- Derive the early, handshake and main secrets of TLS 1.3 with HKDF-Extract and Derive-Secret
- Reproduce the secrets, keys, IVs and Finished values of the simple 1-RTT trace in RFC 8448
- Explain how the transcript hash makes a changed handshake message change every key
- Explain why 0-RTT early data can be replayed and how a server and an application limit the damage
Before you start
On this page
In the handshake that RFC 8448 publishes, a TLS 1.3 client sends 196 bytes in the clear and the server answers with 90, and from those two messages and one 32-byte X25519 result both sides compute the same keys without sending any of them. Change a single bit of either hello on the way and the keys no longer match, so the connection fails before it carries any data.
This lesson computes that key schedule from HMAC alone, on the published trace of RFC 8448. You build the three secrets and the labelled expansions taken from them, see why the hash of the messages so far goes into every traffic secret, open the server’s first flight and check its Finished message, and then meet early data, sent before the handshake ends, which a recorded copy can replay.
Three secrets, each adding one input, produce every key of a connection
RFC 8448 publishes a complete TLS 1.3 handshake together with the private key shares of both sides, so every number here can be recomputed. After the two hellos, each side combines its own private key share with the other’s public one and gets the same 32 bytes, 8bd4054f…0f89492d, the kind of X25519 result that the key agreement lesson of the public-key module produced. Every key of the connection comes from that value, the two hellos and some zeros.
The TLS key schedule: The chain of HKDF-Extract and HKDF-Expand-Label steps by which TLS 1.3 turns a pre-shared key, the key exchange result and the handshake messages into every key of a connection. chains three secrets. Each one is an HKDF-Extract of a single new input, salted with a value taken from the secret above it, so no secret can be computed without all those above it:
| Secret | Salt | New input | What is taken from it |
|---|---|---|---|
| Early Secret | 32 zero bytes | the pre-shared key: A secret that both sides hold before a handshake, either provisioned outside TLS or derived from an earlier connection for resumption. TLS 1.3 mixes it into the Early Secret., or 32 zero bytes when there is none | the key for early data |
| Handshake Secret: The second secret of the TLS 1.3 schedule. It is HKDF-Extract of the key exchange result under a salt derived from the Early Secret, and the handshake traffic secrets come from it. | derived from the Early Secret | the key exchange result | the client’s and the server’s handshake traffic secrets |
| Main Secret: The third secret of the TLS 1.3 schedule, from which the application traffic secrets, the exporter secret and the resumption secret are taken. RFC 9846 gave it this name; earlier documents used another. | derived from the Handshake Secret | 32 zero bytes | the application traffic secrets, the exporter secret and the resumption secret |
The salt of the second and third rows is Derive-Secret(secret, "derived", ""), a step the next section takes apart. A handshake with no pre-shared key still runs the first row with zeros, because RFC 9846 says that a missing secret does not mean a skipped round. Step through the ladder on RFC 8448’s trace; each box shows the start of a value that the program below prints:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- Start from zeros. With no pre-shared key, the schedule starts with 32 zero bytes as the salt and 32 zero bytes as the input. HMAC-SHA-256 of one over the other is the Early Secret, 33ad0a1c… in RFC 8448's trace.
- Derive a salt. Derive-Secret(Early Secret, "derived", "") gives 6f2615a1…. It is not a key. It is the salt for the next extraction, so the next rung depends on everything below it.
- Mix in the key exchange. The Handshake Secret is HKDF-Extract with that salt and the 32-byte X25519 result 8bd4054f…. Only someone holding a private key share can compute it, and it is 1dc826e9… in the trace.
- Hash the hellos. Derive-Secret with the labels "c hs traffic" and "s hs traffic" and the hash 860c06ed… of the 286 bytes of both hellos gives b3eddb12… for the client and b67b7d69… for the server.
- Another salt, then zeros. Derive-Secret(Handshake Secret, "derived", "") gives 43de77e0…. The Main Secret is HKDF-Extract with that salt and 32 zero bytes: nothing new enters here, so it depends on the key exchange only through the salt.
- Hash more messages. The application traffic secrets use the same labels with "ap" and the hash 9608102a… of every message up to the server Finished, 9e40646c… for the client and a11af9f0… for the server.
- The pattern. Down the left, each rung adds one input and keeps only a salt from the rung above. To the right, each traffic secret adds the hash of the messages so far. Nothing on the right can be computed without the left.
The program recomputes the nine values of the ladder from the bytes of the two hellos, 196 and 90 bytes, and from the two X25519 key shares, and compares each with the value that RFC 8448 prints:
// The key schedule of TLS 1.3, computed from the bytes of RFC 8448 section 3: a 1-RTT handshake over X25519 with no
// pre-shared key. The inputs are the ClientHello, the ServerHello and the two key shares the RFC publishes; every value
// below is compared with the value the RFC prints for it.
import { createHash, createHmac, createPrivateKey, createPublicKey, diffieHellman } from 'node:crypto';
const bytes = (text) => Buffer.from(text.replace(/\s+/g, ''), 'hex');
const short = (b) => `${b.toString('hex').slice(0, 8)}…${b.toString('hex').slice(-8)}`;
const CLIENT_HELLO = bytes(`
010000c00303cb34ecb1e78163ba1c38c6dacb196a6dffa21a8d9912ec18a2ef
6283024dece7000006130113031302010000910000000b000900000673657276
6572ff01000100000a00140012001d0017001800190100010101020103010400
230000003300260024001d002099381de560e4bd43d23d8e435a7dbafeb3c06e
51c13cae4d5413691e529aaf2c002b0003020304000d0020001e040305030603
020308040805080604010501060102010402050206020202002d00020101001c
00024001`);
const SERVER_HELLO = bytes(`
020000560303a6af06a4121860dc5e6e60249cd34c95930c8ac5cb1434dac155
772ed3e2692800130100002e00330024001d0020c9828876112095fe66762bdb
f7c672e156d6cc253b833df1dd69b1b04e751f0f002b00020304`);
const CLIENT_PUBLIC = bytes('99381de560e4bd43d23d8e435a7dbafeb3c06e51c13cae4d5413691e529aaf2c');
const SERVER_PRIVATE = bytes('b1580eeadf6dd589b8ef4f2d5652578cc810e9980191ec8d058308cea216a21e');
const SERVER_PUBLIC = bytes('c9828876112095fe66762bdbf7c672e156d6cc253b833df1dd69b1b04e751f0f');
// The values RFC 8448 prints in section 3.
const RFC = {
shared: '8bd4054fb55b9d63fdfbacf9f04b9f0d35e6d63f537563efd46272900f89492d',
early: '33ad0a1c607ec03b09e6cd9893680ce210adf300aa1f2660e1b22e10f170f92a',
derivedForHandshake: '6f2615a108c702c5678f54fc9dbab69716c076189c48250cebeac3576c3611ba',
handshake: '1dc826e93606aa6fdc0aadc12f741b01046aa6b99f691ed221a9f0ca043fbeac',
hashHellos: '860c06edc07858ee8e78f0e7428c58edd6b43f2ca3e6e95f02ed063cf0e1cad8',
clientHandshake: 'b3eddb126e067f35a780b3abf45e2d8f3b1a950738f52e9600746a0e27a55a21',
serverHandshake: 'b67b7d690cc16c4e75e54213cb2d37b4e9c912bcded9105d42befd59d391ad38',
derivedForMain: '43de77e0c77713859a944db9db2590b53190a65b3ee2e4f12dd7a0bb7ce254b4',
main: '18df06843d13a08bf2a449844c5f8a478001bc4d4c627984d5a41da8d0402919',
};
// ---- the building blocks: HKDF-Extract, HKDF-Expand and the labelled forms of RFC 9846 section 7.1 -------------------
const hmac = (key, data) => createHmac('sha256', key).update(data).digest();
const sha256 = (data) => createHash('sha256').update(data).digest();
const extract = (salt, ikm) => hmac(salt, ikm);
const expand = (prk, info, length) => {
let block = Buffer.alloc(0);
let out = Buffer.alloc(0);
for (let counter = 1; out.length < length; counter++) {
block = hmac(prk, Buffer.concat([block, info, Buffer.from([counter])]));
out = Buffer.concat([out, block]);
}
return out.subarray(0, length);
};
const expandLabel = (secret, label, context, length) => {
const full = Buffer.from(`tls13 ${label}`);
const info = Buffer.concat([Buffer.from([length >> 8, length & 255, full.length]), full, Buffer.from([context.length]), context]);
return expand(secret, info, length);
};
const deriveSecret = (secret, label, transcriptHash) => expandLabel(secret, label, transcriptHash, 32);
// ---- the ladder ---------------------------------------------------------------------------------------------------
const zeros = Buffer.alloc(32);
const x25519 = (ownPrivate, ownPublic, peerPublic) =>
diffieHellman({
privateKey: createPrivateKey({ key: { kty: 'OKP', crv: 'X25519', d: ownPrivate.toString('base64url'), x: ownPublic.toString('base64url') }, format: 'jwk' }),
publicKey: createPublicKey({ key: { kty: 'OKP', crv: 'X25519', x: peerPublic.toString('base64url') }, format: 'jwk' }),
});
// The server combines its private key with the client's public key (the client does the mirror image).
const shared = x25519(SERVER_PRIVATE, SERVER_PUBLIC, CLIENT_PUBLIC);
const early = extract(zeros, zeros); // no pre-shared key: 32 zero bytes stand in for it
const derivedForHandshake = deriveSecret(early, 'derived', sha256(Buffer.alloc(0)));
const handshake = extract(derivedForHandshake, shared);
const hellos = sha256(Buffer.concat([CLIENT_HELLO, SERVER_HELLO]));
const clientHandshake = deriveSecret(handshake, 'c hs traffic', hellos);
const serverHandshake = deriveSecret(handshake, 's hs traffic', hellos);
const derivedForMain = deriveSecret(handshake, 'derived', sha256(Buffer.alloc(0)));
const main = extract(derivedForMain, zeros);
const rows = [
['X25519 shared secret', shared, RFC.shared],
['Early Secret', early, RFC.early],
[' derived', derivedForHandshake, RFC.derivedForHandshake],
['Handshake Secret', handshake, RFC.handshake],
['Transcript-Hash(CH, SH)', hellos, RFC.hashHellos],
[' client handshake traffic', clientHandshake, RFC.clientHandshake],
[' server handshake traffic', serverHandshake, RFC.serverHandshake],
[' derived', derivedForMain, RFC.derivedForMain],
['Main Secret', main, RFC.main],
];
console.log(`ClientHello ${CLIENT_HELLO.length} bytes, ServerHello ${SERVER_HELLO.length} bytes, hashed together: ${CLIENT_HELLO.length + SERVER_HELLO.length} bytes`);
for (const [name, value, rfc] of rows) console.log(`${name.padEnd(28)}${short(value)} equals RFC 8448: ${value.toString('hex') === rfc}`);
console.log(`${rows.filter(([, v, rfc]) => v.toString('hex') === rfc).length} of ${rows.length} values equal the RFC`); Output
ClientHello 196 bytes, ServerHello 90 bytes, hashed together: 286 bytes X25519 shared secret 8bd4054f…0f89492d equals RFC 8448: true Early Secret 33ad0a1c…f170f92a equals RFC 8448: true derived 6f2615a1…6c3611ba equals RFC 8448: true Handshake Secret 1dc826e9…043fbeac equals RFC 8448: true Transcript-Hash(CH, SH) 860c06ed…f0e1cad8 equals RFC 8448: true client handshake traffic b3eddb12…27a55a21 equals RFC 8448: true server handshake traffic b67b7d69…d391ad38 equals RFC 8448: true derived 43de77e0…7ce254b4 equals RFC 8448: true Main Secret 18df0684…d0402919 equals RFC 8448: true 9 of 9 values equal the RFC
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node ladder.mjs
All nine values equal the RFC’s. The Early Secret, 33ad0a1c…, is the same for every SHA-256 handshake without a pre-shared key, because it is made of zeros only. The Handshake Secret is the first value that depends on the key exchange, so it is the first that someone who has only watched the hellos cannot compute. The Main Secret mixes in nothing but zeros and reaches the key exchange only through its salt, which is why the ladder needs its “derived” steps.
One rename, and where TLS 1.3 stands
RFC 9846 replaces RFC 8446 as the specification of TLS 1.3. It calls the last secret of the ladder the main secret, where RFC 8446 and the trace of RFC 8448 say master secret, but it keeps the wire labels exp master and res master, so no derived value changes. RFC 9852 adds that new protocols using TLS must require TLS 1.3, and RFC 9851 puts TLS 1.2 in feature freeze: it accepts urgent security fixes and a few registry additions, and nothing else.
Once the values below a secret have been taken, RFC 9846 says that the secret itself should be erased, so that a later leak of the machine does not hand over the whole ladder.
Key idea
Each rung takes a salt from the rung above and adds one new input, so a secret lower down cannot be computed without everything above it.
Checkpoint
Check: The three secrets and what each adds
Every derivation carries a label and a context, so no two keys can be confused
The ladder used one operation twice without opening it: Derive-Secret(Early Secret, "derived", ""). The "" hides the first trap. It means “the hash of no messages”, which is 32 bytes, and not an empty context. The figure builds the exact bytes that HKDF-Expand receives for this call:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Output length. The first two bytes say how many bytes the caller wants out, as a big-endian number. For a hash-sized secret that is 32, written 00 20.
- Label length. One byte gives the length of the label that follows. The label is the 6 characters "tls13 " and the 7 of "derived", so the byte is 0d, which is 13.
- The label. The label always starts with "tls13 ", so a value derived for another protocol, or by another version, can never equal one derived here. Together these 13 bytes read 746c7331332064657269766564.
- Context length. One more length byte for the context. Derive-Secret passes the hash of the messages so far, and a hash made with SHA-256 is 32 bytes, so the byte is 20.
- The context. For the "derived" step the messages so far are none at all, so the context is the SHA-256 of the empty string, e3b0c442…. It is not an empty context, which would be a different value.
- HKDF-Expand. HKDF-Expand turns the 49 bytes into the output. It computes HMAC of the secret over the info and the counter byte 01, and with 32 bytes wanted, one block is enough. This gives 6f2615a1… for the Early Secret.
HKDF-Expand-Label: TLS 1.3's wrapper around HKDF-Expand. It builds the info from the output length, the label behind "tls13 " and a context, each framed by its length, so every use of a secret gets its own key.(Secret, Label, Context, Length) is HKDF-Expand with an info made of those parts: the output length in two bytes, the label behind the six characters tls13 with a length byte, and the context with a length byte. Derive-Secret: HKDF-Expand-Label with the transcript hash of the messages so far as its context and a hash-sized output, so a secret taken with it depends on exactly the messages exchanged up to that point.(Secret, Label, Messages) is Expand-Label with the transcript hash: The hash of the handshake messages sent and received so far, with their type and length fields but without record headers. TLS 1.3 mixes it into key derivation and covers it in its authentication messages. of the messages as its context and a hash-sized output. The same function makes the keys and IVs later, with an empty context this time, so the labels key, iv and finished are what keep those uses apart.
The program builds the info for “derived” byte by byte, checks it against the 49 bytes printed in RFC 8448, and then makes the three slips that a first implementation makes. Run it, then change a letter of the label or a byte of the context and watch the value change:
"""HKDF-Expand-Label of TLS 1.3 (RFC 9846 section 7.1), built from hmac and checked on the "derived" step of RFC 8448."""
import hashlib
import hmac
def expand(prk, info, length):
"""HKDF-Expand (RFC 5869) with SHA-256: blocks of HMAC(prk, previous block + info + counter)."""
out, block, counter = b"", b"", 1
while len(out) < length:
block = hmac.digest(prk, block + info + bytes([counter]), "sha256")
out += block
counter += 1
return out[:length]
def hkdf_label(label, context, length):
"""The info that Expand-Label hands to HKDF-Expand: length, label and context, each label and context behind a length byte."""
full = b"tls13 " + label
return length.to_bytes(2, "big") + bytes([len(full)]) + full + bytes([len(context)]) + context
def expand_label(secret, label, context, length):
return expand(secret, hkdf_label(label, context, length), length)
EARLY_SECRET = bytes.fromhex("33ad0a1c607ec03b09e6cd9893680ce210adf300aa1f2660e1b22e10f170f92a")
RFC_INFO = "00200d746c7331332064657269766564" + "20e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
RFC_DERIVED = "6f2615a108c702c5678f54fc9dbab69716c076189c48250cebeac3576c3611ba"
def short(data):
return data.hex()[:8] + "…" + data.hex()[-8:]
# Derive-Secret(Early Secret, "derived", "") is Expand-Label with the hash of the empty string as its context.
empty_hash = hashlib.sha256(b"").digest()
info = hkdf_label(b"derived", empty_hash, 32)
derived = expand_label(EARLY_SECRET, b"derived", empty_hash, 32)
print(f'info for "derived": {len(info)} bytes')
print(f" {info[:2].hex()} output length, {int.from_bytes(info[:2], 'big')}")
print(f" {info[2:3].hex()} label length, {info[2]}")
print(f" {info[3:16].hex()} the label, {info[3:16].decode()!r}")
print(f" {info[16:17].hex()} context length, {info[16]}")
print(f" {short(info[17:])} the context, the SHA-256 of the empty string")
print(f"info equals the RFC's: {info.hex() == RFC_INFO}")
print(f"derived secret {short(derived)} equals the RFC's: {derived.hex() == RFC_DERIVED}")
print("\nThree slips, each giving an unrelated value")
slips = {
'without the "tls13 " prefix': expand(EARLY_SECRET, (32).to_bytes(2, "big") + bytes([7]) + b"derived" + bytes([32]) + empty_hash, 32),
"with an empty context instead of the hash": expand_label(EARLY_SECRET, b"derived", b"", 32),
"with the length in one byte": expand(EARLY_SECRET, bytes([32, 13]) + b"tls13 derived" + bytes([32]) + empty_hash, 32),
}
for name, value in slips.items():
print(f" {name:<42}{short(value)} equals the RFC's: {value.hex() == RFC_DERIVED}") Output
info for "derived": 49 bytes 0020 output length, 32 0d label length, 13 746c7331332064657269766564 the label, 'tls13 derived' 20 context length, 32 e3b0c442…7852b855 the context, the SHA-256 of the empty string info equals the RFC's: True derived secret 6f2615a1…6c3611ba equals the RFC's: True Three slips, each giving an unrelated value without the "tls13 " prefix 787a78d4…8e196e6f equals the RFC's: False with an empty context instead of the hash d65dba9d…b247f663 equals the RFC's: False with the length in one byte c5753f93…00557a46 equals the RFC's: False
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 expand_label.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The info is 49 bytes: 2 for the length, 1 and 13 for the label, 1 and 32 for the context. It equals the RFC’s, and so does the derived secret, 6f2615a1…. Each slip returns 32 plausible bytes that equal nothing, with no error to say that anything is wrong, because HKDF accepts any info and a one-byte difference in it changes every byte of the output. The only protection is a test against the RFC’s values, which is what the lab at the end of this lesson uses.
An empty context for the derived step
Wrong: expand_label(secret, b"derived", b"", 32), because the empty string in the RFC looks like no context. Right: pass the 32-byte SHA-256 of the empty string, e3b0c442…, which is what hashing no messages gives. The wrong version runs and returns a value, just not the right one.
Checkpoint
Check: Labels and contexts in Expand-Label
The transcript hash makes a changed message change every key
Suppose someone on the path changes one bit of the ServerHello before the client reads it. The two sides now hold different 90-byte messages. What should the keys do about it? The program derives the server’s handshake traffic secret from what the server sent and from what the client received, once with the TLS 1.3 schedule and once with a weaker one that expands the same labels but leaves the hash of the messages out:
"""One changed bit of the ServerHello, seen by the client only: does it change the keys? TLS 1.3 against a schedule that forgets the transcript."""
import hashlib
import hmac
CLIENT_HELLO = bytes.fromhex("""
010000c00303cb34ecb1e78163ba1c38c6dacb196a6dffa21a8d9912ec18a2ef
6283024dece7000006130113031302010000910000000b000900000673657276
6572ff01000100000a00140012001d0017001800190100010101020103010400
230000003300260024001d002099381de560e4bd43d23d8e435a7dbafeb3c06e
51c13cae4d5413691e529aaf2c002b0003020304000d0020001e040305030603
020308040805080604010501060102010402050206020202002d00020101001c
00024001""")
SERVER_HELLO = bytes.fromhex("""
020000560303a6af06a4121860dc5e6e60249cd34c95930c8ac5cb1434dac155
772ed3e2692800130100002e00330024001d0020c9828876112095fe66762bdb
f7c672e156d6cc253b833df1dd69b1b04e751f0f002b00020304""")
SHARED_SECRET = bytes.fromhex("8bd4054fb55b9d63fdfbacf9f04b9f0d35e6d63f537563efd46272900f89492d") # RFC 8448 section 3
# Change these to flip another bit of the ServerHello that the client receives: BYTE from 0 to 89, BIT from 0 to 7.
BYTE, BIT = 6, 0 # byte 6 is the first byte of the ServerHello's 32-byte random
def sha256(data):
return hashlib.sha256(data).digest()
def expand(prk, info, length):
out, block, counter = b"", b"", 1
while len(out) < length:
block = hmac.digest(prk, block + info + bytes([counter]), "sha256")
out += block
counter += 1
return out[:length]
def expand_label(secret, label, context, length):
full = b"tls13 " + label
info = length.to_bytes(2, "big") + bytes([len(full)]) + full + bytes([len(context)]) + context
return expand(secret, info, length)
def handshake_secret():
early = hmac.digest(bytes(32), bytes(32), "sha256")
return hmac.digest(expand_label(early, b"derived", sha256(b""), 32), SHARED_SECRET, "sha256")
def server_traffic_tls13(client_hello, server_hello):
"""TLS 1.3: the label is combined with the hash of the messages exchanged so far."""
return expand_label(handshake_secret(), b"s hs traffic", sha256(client_hello + server_hello), 32)
def server_traffic_weak(client_hello, server_hello):
"""A schedule that expands the same labels but leaves the messages out."""
return expand_label(handshake_secret(), b"s hs traffic", b"", 32)
seen_by_server = SERVER_HELLO
changed = bytearray(SERVER_HELLO)
changed[BYTE] ^= 1 << BIT
seen_by_client = bytes(changed)
print(f"The server sent {len(seen_by_server)} bytes of ServerHello; the client received them with bit {BIT} of byte {BYTE} flipped.\n")
print(f"{'design':<30}{'server derives':<20}{'client derives':<20}same key?")
results = {}
for name, schedule in [("TLS 1.3 (transcript hashed)", server_traffic_tls13), ("weak (no transcript)", server_traffic_weak)]:
server_key = schedule(CLIENT_HELLO, seen_by_server)
client_key = schedule(CLIENT_HELLO, seen_by_client)
results[name] = server_key == client_key
print(f"{name:<30}{server_key.hex()[:8] + '…' + server_key.hex()[-8:]:<20}{client_key.hex()[:8] + '…' + client_key.hex()[-8:]:<20}{results[name]}")
print('\nTest "a changed hello must change the keys":')
for name, same in results.items():
print(f" {name:<30}{'FAIL: the change goes unnoticed' if same else 'PASS: the two sides cannot talk'}") Output
The server sent 90 bytes of ServerHello; the client received them with bit 0 of byte 6 flipped. design server derives client derives same key? TLS 1.3 (transcript hashed) b67b7d69…d391ad38 ad0201ec…3f19a165 False weak (no transcript) 00721d22…b339ba8f 00721d22…b339ba8f True Test "a changed hello must change the keys": TLS 1.3 (transcript hashed) PASS: the two sides cannot talk weak (no transcript) FAIL: the change goes unnoticed
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 tamper.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
With the TLS 1.3 schedule the two sides derive b67b7d69… and ad0201ec…: they share no key, and the client cannot open the first record the server encrypts. With the weaker schedule both sides derive 00721d22… and the keys say nothing about the change. The test at the bottom of the output is worth keeping in a suite for any protocol of your own: a changed hello must change the keys. It passes for TLS 1.3 and fails for the weak design.
Flip the last bit instead
Set BYTE, BIT = 89, 7 in the program and press Run. The TLS 1.3 keys still differ and the weak keys still match, wherever the bit falls.
The transcript hash is the SHA-256 of the handshake messages in order, each with its 4-byte header of type and length and none of the 5-byte record headers (RFC 9846 section 4.1). For the handshake traffic secrets it covers the ClientHello and the ServerHello, the 286 bytes of the trace. One case changes the input: after a HelloRetryRequest, the first ClientHello is replaced in the hash by a synthetic message that holds its hash, so a server can answer without keeping the original. Later messages build on the same hash. The CertificateVerify signature and the Finished MAC each cover the transcript up to their own position, so a signature made in one handshake does not verify in another.
Key idea
Every traffic secret hashes the messages so far, so a message changed on the way gives the two sides different keys.
Checkpoint
Check: Why the transcript hash is in every key
The server flight opens with the derived key and ends with a Finished check
A traffic secret is not yet a key. Each direction takes a key and an IV from it with the labels key and iv and an empty context. The ServerHello of the trace chose TLS_AES_128_GCM_SHA256, code 0x1301, so the key is 16 bytes, the IV 12 and the hash SHA-256. Each record’s nonce is its 64-bit sequence number, padded on the left with zeros and XORed into the IV, so the first record uses the IV itself (RFC 9846 section 5.3). A counter nonce of this kind cannot repeat under one key, because when a sequence number would wrap the connection has to rekey or end. Follow the server’s first flight from the wire to the checks the client makes:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- ClientHello. The client sends 196 bytes in the clear. They carry its X25519 key share, the cipher suites it accepts and its random value. Both hellos go into the transcript hash from now on.
- ServerHello. The server answers with 90 bytes, also in the clear, carrying its own key share. Everything after this message can be encrypted, because both sides can now compute the Handshake Secret.
- Client derives keys. The client combines its private key share with the server's public one, gets 8bd4054f… and derives the same handshake traffic secrets from the hash of the two hellos.
- The encrypted flight. The server sends one 679-byte record. Under the server handshake key it holds EncryptedExtensions, the Certificate, a CertificateVerify signature and the server Finished, 657 bytes of messages in all.
- Client checks the flight. The record only opens with the key that the right transcript produces. The client then verifies the CertificateVerify signature with the certificate's key and the Finished MAC with a key from the server handshake traffic secret.
- Client Finished. The client sends its own Finished, a MAC over the transcript up to the server Finished under a key from the client handshake traffic secret. It proves the client saw the same messages.
- Application keys. Both sides now hash every message up to the server Finished and derive the application traffic secrets from the Main Secret. The client's is 9e40646c… and the server's is a11af9f0…
- Application data. Data flows both ways under the application keys. The handshake keys protect nothing after this point, and each secret above them can be erased.
The program plays the client. From the two hellos it derives the server’s handshake key and IV, opens the one 679-byte record, splits the 657 bytes inside into messages and checks the two proofs that they hold:
// What a TLS 1.3 client does with the server's first flight, on the bytes of RFC 8448 section 3: derive the server's
// handshake keys from the two hellos, decrypt and authenticate the flight, check the CertificateVerify signature and
// the Finished MAC, then derive the application secrets and the client's own Finished. The inputs are the two hellos,
// the X25519 secret of the key exchange and the one encrypted record the server sent.
import { X509Certificate, constants, createDecipheriv, createHash, createHmac, timingSafeEqual, verify } from 'node:crypto';
const bytes = (text) => Buffer.from(text.replace(/\s+/g, ''), 'hex');
const short = (b) => `${b.toString('hex').slice(0, 8)}…${b.toString('hex').slice(-8)}`;
const CLIENT_HELLO = bytes(`
010000c00303cb34ecb1e78163ba1c38c6dacb196a6dffa21a8d9912ec18a2ef
6283024dece7000006130113031302010000910000000b000900000673657276
6572ff01000100000a00140012001d0017001800190100010101020103010400
230000003300260024001d002099381de560e4bd43d23d8e435a7dbafeb3c06e
51c13cae4d5413691e529aaf2c002b0003020304000d0020001e040305030603
020308040805080604010501060102010402050206020202002d00020101001c
00024001`);
const SERVER_HELLO = bytes(`
020000560303a6af06a4121860dc5e6e60249cd34c95930c8ac5cb1434dac155
772ed3e2692800130100002e00330024001d0020c9828876112095fe66762bdb
f7c672e156d6cc253b833df1dd69b1b04e751f0f002b00020304`);
const SHARED_SECRET = bytes('8bd4054fb55b9d63fdfbacf9f04b9f0d35e6d63f537563efd46272900f89492d');
const SERVER_RECORD = bytes(`
17030302a2d1ff334a56f5bff6594a07cc87b580233f500f45e489e7f33af35e
df7869fcf40aa40aa2b8ea73f848a7ca07612ef9f945cb960b4068905123ea78
b111b429ba9191cd05d2a389280f526134aadc7fc78c4b729df828b5ecf7b13b
d9aefb0e57f271585b8ea9bb355c7c79020716cfb9b1183ef3ab20e37d57a6b9
d7477609aee6e122a4cf51427325250c7d0e509289444c9b3a648f1d71035d2e
d65b0e3cdd0cbae8bf2d0b227812cbb360987255cc744110c453baa4fcd61092
8d809810e4b7ed1a8fd991f06aa6248204797e36a6a73b70a2559c09ead68694
5ba246ab66e5edd8044b4c6de3fcf2a89441ac66272fd8fb330ef8190579b368
4596c960bd596eea520a56a8d650f563aad27409960dca63d3e688611ea5e22f
4415cf9538d51a200c27034272968a264ed6540c84838d89f72c24461aad6d26
f59ecaba9acbbb317b66d902f4f292a36ac1b639c637ce343117b65962224531
7b49eeda0c6258f100d7d961ffb138647e92ea330faeea6dfa31c7a84dc3bd7e
1b7a6c7178af36879018e3f252107f243d243dc7339d5684c8b0378bf30244da
8c87c843f5e56eb4c5e8280a2b48052cf93b16499a66db7cca71e4599426f7d4
61e66f99882bd89fc50800becca62d6c74116dbd2972fda1fa80f85df881edbe
5a37668936b335583b599186dc5c6918a396fa48a181d6b6fa4f9d62d513afbb
992f2b992f67f8afe67f76913fa388cb5630c8ca01e0c65d11c66a1e2ac4c859
77b7c7a6999bbf10dc35ae69f5515614636c0b9b68c19ed2e31c0b3b66763038
ebba42f3b38edc0399f3a9f23faa63978c317fc9fa66a73f60f0504de93b5b84
5e275592c12335ee340bbc4fddd502784016e4b3be7ef04dda49f4b440a30cb5
d2af939828fd4ae3794e44f94df5a631ede42c1719bfdabf0253fe5175be898e
750edc53370d2b`);
// ---- HKDF-Extract, HKDF-Expand and the labelled forms, as in the previous program -----------------------------------
const hmac = (key, data) => createHmac('sha256', key).update(data).digest();
const sha256 = (...parts) => createHash('sha256').update(Buffer.concat(parts)).digest();
const expand = (prk, info, length) => {
let block = Buffer.alloc(0);
let out = Buffer.alloc(0);
for (let counter = 1; out.length < length; counter++) {
block = hmac(prk, Buffer.concat([block, info, Buffer.from([counter])]));
out = Buffer.concat([out, block]);
}
return out.subarray(0, length);
};
const expandLabel = (secret, label, context, length) => {
const full = Buffer.from(`tls13 ${label}`);
return expand(secret, Buffer.concat([Buffer.from([length >> 8, length & 255, full.length]), full, Buffer.from([context.length]), context]), length);
};
const deriveSecret = (secret, label, transcriptHash) => expandLabel(secret, label, transcriptHash, 32);
const EMPTY = Buffer.alloc(0);
const zeros = Buffer.alloc(32);
// ---- the server's handshake keys, from the hellos alone ---------------------------------------------------------------
const early = hmac(zeros, zeros);
const handshake = hmac(deriveSecret(early, 'derived', sha256(EMPTY)), SHARED_SECRET);
const serverHandshake = deriveSecret(handshake, 's hs traffic', sha256(CLIENT_HELLO, SERVER_HELLO));
const key = expandLabel(serverHandshake, 'key', EMPTY, 16);
const iv = expandLabel(serverHandshake, 'iv', EMPTY, 12);
console.log(`server handshake key ${short(key)}, iv ${short(iv)}`);
// ---- decrypt the record: AES-128-GCM, the record header as associated data, the sequence number XORed into the iv -------
const header = SERVER_RECORD.subarray(0, 5);
const sealed = SERVER_RECORD.subarray(5);
const open = (recordKey, recordIv) => {
// Record 0: the sequence number is 0, and 0 XOR iv is the iv itself.
const decipher = createDecipheriv('aes-128-gcm', recordKey, recordIv).setAAD(header).setAuthTag(sealed.subarray(-16));
return Buffer.concat([decipher.update(sealed.subarray(0, -16)), decipher.final()]);
};
const inner = open(key, iv);
const flight = inner.subarray(0, -1); // the last byte is the real content type: 0x16, handshake
console.log(`record: ${SERVER_RECORD.length} bytes, authenticated; inner content type 0x${inner.at(-1).toString(16)}, ${flight.length} bytes of handshake messages`);
const NAMES = { 8: 'EncryptedExtensions', 11: 'Certificate', 15: 'CertificateVerify', 20: 'Finished' };
const messages = {};
for (let at = 0; at < flight.length; ) {
const size = 4 + flight.readUIntBE(at + 1, 3);
messages[NAMES[flight[at]]] = flight.subarray(at, at + size);
console.log(` ${NAMES[flight[at]].padEnd(20)}${size} bytes`);
at += size;
}
// ---- the same record, opened with the keys that a ServerHello changed by one bit would give ---------------------------------
const changed = Buffer.from(SERVER_HELLO);
changed[6] ^= 1; // one bit of the ServerHello random
const otherSecret = deriveSecret(handshake, 's hs traffic', sha256(CLIENT_HELLO, changed));
let opens = true;
try {
open(expandLabel(otherSecret, 'key', EMPTY, 16), expandLabel(otherSecret, 'iv', EMPTY, 12));
} catch {
opens = false;
}
console.log(`with the keys from a ServerHello changed by one bit, the record opens: ${opens}`);
// ---- CertificateVerify: a signature over the transcript hash, made with the certificate's key ---------------------------
const certMessage = messages.Certificate;
const certDer = certMessage.subarray(11, 11 + certMessage.readUIntBE(8, 3));
const certificate = new X509Certificate(certDer);
const toBeSigned = Buffer.concat([Buffer.alloc(64, 0x20), Buffer.from('TLS 1.3, server CertificateVerify\0'), sha256(CLIENT_HELLO, SERVER_HELLO, messages.EncryptedExtensions, messages.Certificate)]);
const verifyMessage = messages.CertificateVerify;
const signature = verifyMessage.subarray(8, 8 + verifyMessage.readUInt16BE(6));
const signatureOk = verify('sha256', toBeSigned, { key: certificate.publicKey, padding: constants.RSA_PKCS1_PSS_PADDING, saltLength: 32 }, signature);
console.log(`CertificateVerify: scheme 0x${verifyMessage.readUInt16BE(4).toString(16).padStart(4, '0')}, ${signature.length}-byte signature verifies with the certificate's key: ${signatureOk}`);
// ---- Finished: a MAC over the transcript up to CertificateVerify, keyed from the server's handshake traffic secret ----------
const upToVerify = sha256(CLIENT_HELLO, SERVER_HELLO, messages.EncryptedExtensions, messages.Certificate, messages.CertificateVerify);
const expected = hmac(expandLabel(serverHandshake, 'finished', EMPTY, 32), upToVerify);
const received = messages.Finished.subarray(4);
console.log(`Transcript-Hash up to CertificateVerify ${short(upToVerify)}`);
console.log(`Finished verify_data ${short(received)} matches the MAC we computed: ${timingSafeEqual(expected, received)}`);
// ---- application secrets, the client's Finished, and the secret that resumption will use -----------------------------
const upToFinished = sha256(CLIENT_HELLO, SERVER_HELLO, flight);
const main = hmac(deriveSecret(handshake, 'derived', sha256(EMPTY)), zeros);
const clientHandshake = deriveSecret(handshake, 'c hs traffic', sha256(CLIENT_HELLO, SERVER_HELLO));
const clientVerify = hmac(expandLabel(clientHandshake, 'finished', EMPTY, 32), upToFinished);
const clientFinished = Buffer.concat([Buffer.from([20, 0, 0, 32]), clientVerify]);
// learn-lint-allow inclusive: "res master" and "exp master" are label strings that RFC 9846 keeps on the wire
const secrets = { 'client application traffic': deriveSecret(main, 'c ap traffic', upToFinished), 'server application traffic': deriveSecret(main, 's ap traffic', upToFinished), exporter: deriveSecret(main, 'exp master', upToFinished) };
// learn-lint-allow inclusive: "res master" is a label string that RFC 9846 keeps on the wire
const resumption = deriveSecret(main, 'res master', sha256(CLIENT_HELLO, SERVER_HELLO, flight, clientFinished));
console.log(`Transcript-Hash up to the server Finished ${short(upToFinished)}`);
for (const [name, secret] of Object.entries(secrets)) console.log(` ${name.padEnd(28)}${short(secret)}`);
console.log(`client Finished verify_data ${short(clientVerify)}`);
const ticketNonce = Buffer.from([0, 0]);
console.log(`resumption pre-shared key ${short(expandLabel(resumption, 'resumption', ticketNonce, 32))}`); Output
server handshake key 3fce5160…6ee403bc, iv 5d313eb2…13000b30 record: 679 bytes, authenticated; inner content type 0x16, 657 bytes of handshake messages EncryptedExtensions 40 bytes Certificate 445 bytes CertificateVerify 136 bytes Finished 36 bytes with the keys from a ServerHello changed by one bit, the record opens: false CertificateVerify: scheme 0x0804, 128-byte signature verifies with the certificate's key: true Transcript-Hash up to CertificateVerify edb7725f…a06110ed Finished verify_data 9b9b141d…b78f0718 matches the MAC we computed: true Transcript-Hash up to the server Finished 9608102a…ff83df13 client application traffic 9e40646c…c17504a5 server application traffic a11af9f0…cb631643 exporter fe22f881…09d3cf50 client Finished verify_data a8ec436d…edd5ce61 resumption pre-shared key 4ecd0eb6…92e1c4f3
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node flight.mjs
The record opens, so its tag shows that the sender holds the handshake key, and the keys from a hello changed by one bit do not open it. Inside are four messages of 40, 445, 136 and 36 bytes. Three different things are checked, each over more of the handshake than the one before:
| Check | What it shows | What it covers |
|---|---|---|
| The record’s AEAD tag | the sender holds the handshake key | the record and its header |
| CertificateVerify | the sender holds the private key of the certificate it sent | the transcript hash up to the Certificate |
| Finished | the sender holds the traffic secret and saw the same handshake | the transcript hash up to CertificateVerify |
The signature of the trace uses scheme 0x0804, RSA-PSS with SHA-256, over 64 spaces, the context string TLS 1.3, server CertificateVerify, a zero byte and the transcript hash, and it verifies with the key in the certificate that the server sent. The Finished message: The last message of each side's handshake in TLS 1.3, an HMAC over the transcript under a key from that side's handshake traffic secret. It shows that the sender holds the keys and saw the same messages. is an HMAC under a finished key that Expand-Label derives from the server’s handshake traffic secret, over the transcript hash up to CertificateVerify, edb7725f…. It equals the 32 bytes the server sent, and the program compares them in constant time, as the HMAC lesson did. Then the client hashes everything up to the server Finished, 9608102a…, derives the application traffic secrets from it, builds its own Finished and ends with the resumption key 4ecd0eb6…, which the next section uses.
What none of the checks says is whether the certificate belongs to the host the client meant to reach. CertificateVerify shows possession of a key, and the next lesson checks the chain of trust and the name.
Key idea
CertificateVerify shows that the sender holds the certificate’s private key and Finished that both sides saw the same handshake: a client needs both, and neither decides whether the certificate is one to trust.
Checkpoint
Check: Keys, CertificateVerify and Finished
Early data trades forward secrecy and replay safety for one round trip
A client that has connected before can put a request into its first flight. RFC 8448 section 4 shows one: a 512-byte ClientHello that offers the pre-shared key from the previous connection, followed by a 28-byte record of 0-RTT early data: Application data that a TLS 1.3 client sends in its first flight, encrypted under a key from a pre-shared key. It has no forward secrecy and can be replayed, so only safe-to-repeat requests belong in it.. Its key comes from the first rung of the ladder, Derive-Secret(Early Secret, "c e traffic", ClientHello), and the inputs are the pre-shared key and the ClientHello. Nothing comes from the server, which has not answered yet.
Two weaker properties follow, and RFC 9846 section 2.3 states both. The protocol gives the early data no forward-secrecy guarantee, since whoever later gets the pre-shared key can derive its key, so a client should assume that it has none. And nothing ties it to one connection, so a copy of the flight sent again meets the same key, the same nonce and the same ClientHello. Step through one request and a copy of it:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- First flight. The client sends a ClientHello that offers a pre-shared key, and in the same flight a 28-byte record of early data, encrypted under a key made from the pre-shared key and that ClientHello.
- The request runs once. The server derives the same key from the same two inputs, the record's tag is valid and the request runs. Nothing the server has not sent yet was needed for this.
- Someone keeps a copy. Anyone on the path can keep the exact bytes of that flight. The copy needs no key for this: it holds the encrypted bytes and nothing else.
- The copy arrives later. The copy sends the flight again, on a new connection. Its ClientHello is identical, so the server derives the identical key, and the record's sequence number is 0 again.
- Server that checks the tag. The tag is valid, so this server runs the request again, and a third copy would run it a third time. This is the server of the first run in the program.
- Server that records hellos. The second server has stored the ClientHello of the first flight. It sees the same one, refuses the early data and lets the client resend the request after the handshake, so the request has run once.
- The client's part. A client cannot tell which kind of server it is talking to, so it must put only requests that are safe to repeat into early data, such as a read, and send anything that changes state after the handshake.
The program derives the early key from the pre-shared key that the previous program ended with, opens the RFC’s early-data record and gives the same recorded flight three times to two toy servers:
// 0-RTT early data in TLS 1.3, on the bytes of RFC 8448 section 4. The key for early data comes from the pre-shared key
// and the ClientHello alone, so a recorded copy of the first flight opens again each time it is replayed. Two toy servers
// handle the same recorded flight three times: one that only checks the AEAD tag, and one that also records each
// ClientHello it has accepted, the defence of RFC 9846 section 8.2.
import { createDecipheriv, createHmac } from 'node:crypto';
const bytes = (text) => Buffer.from(text.replace(/\s+/g, ''), 'hex');
const short = (b) => `${b.toString('hex').slice(0, 8)}…${b.toString('hex').slice(-8)}`;
// The pre-shared key is the one the previous program ended with. RFC 8448 prints the SHA-256 of the 512-byte ClientHello
// that carries it, and the one encrypted record of early data that follows that ClientHello on the wire.
const PSK = bytes('4ecd0eb6ec3b4d87f5d6028f922ca4c5851a277fd41311c9e62d2c9492e1c4f3');
const CLIENT_HELLO_HASH = bytes('08ad0fa05d7c7233b1775ba2ff9f4c5b8b59276b7f227f13a976245f5d960913');
const EARLY_RECORD = bytes('1703030017ab1df420e75c457a7cc5d2844f76d5aee4b4edbf049be0');
const RFC = {
early: '9b2188e9b2fc6d64d71dc329900e20bb41915000f678aa839cbb797cb7d8332c',
clientEarlyTraffic: '3fbbe6a60deb66c30a32795aba0eff7eaa10105586e7be5c09678d63b6caab62',
};
const hmac = (key, data) => createHmac('sha256', key).update(data).digest();
const expand = (prk, info, length) => {
let block = Buffer.alloc(0);
let out = Buffer.alloc(0);
for (let counter = 1; out.length < length; counter++) {
block = hmac(prk, Buffer.concat([block, info, Buffer.from([counter])]));
out = Buffer.concat([out, block]);
}
return out.subarray(0, length);
};
const expandLabel = (secret, label, context, length) => {
const full = Buffer.from(`tls13 ${label}`);
return expand(secret, Buffer.concat([Buffer.from([length >> 8, length & 255, full.length]), full, Buffer.from([context.length]), context]), length);
};
// ---- the early key: nothing in it comes from the server ----------------------------------------------------------------
const early = hmac(Buffer.alloc(32), PSK); // HKDF-Extract with a salt of 32 zero bytes and the PSK
const clientEarly = expandLabel(early, 'c e traffic', CLIENT_HELLO_HASH, 32);
const key = expandLabel(clientEarly, 'key', Buffer.alloc(0), 16);
const iv = expandLabel(clientEarly, 'iv', Buffer.alloc(0), 12);
console.log(`Early Secret ${short(early)} equals RFC 8448: ${early.toString('hex') === RFC.early}`);
console.log(`client early traffic secret ${short(clientEarly)} equals RFC 8448: ${clientEarly.toString('hex') === RFC.clientEarlyTraffic}`);
console.log(`early key ${short(key)}, iv ${short(iv)}: a function of the PSK and the ClientHello only`);
// ---- a toy server: open the record, and act on it ---------------------------------------------------------------------
// Every connection starts its record sequence at 0, so a replayed record meets the same key and the same nonce.
const open = (record) => {
const decipher = createDecipheriv('aes-128-gcm', key, iv).setAAD(record.subarray(0, 5)).setAuthTag(record.subarray(-16));
const inner = Buffer.concat([decipher.update(record.subarray(5, -16)), decipher.final()]);
return inner.subarray(0, -1).toString('latin1'); // the last byte is the content type
};
const weakServer = () => {
let actions = 0;
return {
handle: (record) => {
open(record); // the tag is valid, so the request is acted on
actions += 1;
return 'processed';
},
count: () => actions,
};
};
const recordingServer = () => {
const accepted = new Set();
let actions = 0;
return {
handle: (record, clientHelloHash) => {
const id = clientHelloHash.toString('hex');
if (accepted.has(id)) return 'refused: this ClientHello was accepted before';
accepted.add(id);
open(record);
actions += 1;
return 'processed';
},
count: () => actions,
};
};
console.log(`\nThe recorded flight opens to "${open(EARLY_RECORD)}"; anyone on the path who copies it can send the same ${EARLY_RECORD.length} bytes again`);
for (const [name, server] of [['checks only the AEAD tag', weakServer()], ['records each ClientHello', recordingServer()]]) {
const answers = [1, 2, 3].map(() => server.handle(EARLY_RECORD, CLIENT_HELLO_HASH));
console.log(` server that ${name}: ${answers.map((a) => a.split(':')[0]).join(', ')}; the request ran ${server.count()} time${server.count() === 1 ? '' : 's'}`);
} Output
Early Secret 9b2188e9…b7d8332c equals RFC 8448: true client early traffic secret 3fbbe6a6…b6caab62 equals RFC 8448: true early key 920205a5…42834f54, iv 6d475f09…610db2b9: a function of the PSK and the ClientHello only The recorded flight opens to "ABCDEF"; anyone on the path who copies it can send the same 28 bytes again server that checks only the AEAD tag: processed, processed, processed; the request ran 3 times server that records each ClientHello: processed, refused, refused; the request ran 1 time
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node early_data.mjs
The server that only checks the tag ran the request three times. The one that records each ClientHello ran it once and refused the copies. RFC 9846 section 8 asks for the second behaviour: a server must accept a given 0-RTT handshake at most once per server instance, by recording ClientHellos or by single-use tickets. A client cannot know which a server does, so it must send only early data that is safe to replay. The per-instance rule has a cost. With 12 servers behind a balancer and no shared record, one request can run up to 12 times, one for each instance. HTTP adds a second layer: RFC 8470 lets a server answer 425 Too Early to a request that arrived in early data and cannot safely be repeated. Absent other information, it forbids a client to send unsafe methods in early data, and it warns that a safe method is only a guide, since some resources change state on a GET.
A purchase in early data
Wrong: send POST /orders in early data to save the 80 ms of a round trip, since a copy of the flight can place the order again. Right: send a read such as GET /catalog in early data, and send the order after the handshake finishes, when the server has contributed a fresh random value.
Checkpoint
Check: Early data and replay
Practise: build Expand-Label and check a Finished message
Reading a derivation and writing it are different skills, and the only way to know a key schedule is right is to match published values. Two Python steps follow, each with sample tests that run in the page. In the first you write the labelled expansion and Derive-Secret and hold them to the values of RFC 8448. In the second you compute a Finished MAC and test a received one, with no exception for bad input and a constant-time comparison, on the server’s and the client’s messages of the trace:
Code lab · 2 steps
Build the TLS 1.3 labelled expansion and a Finished check
Write HKDF-Expand-Label and Derive-Secret from HMAC, then use them to compute and check a Finished message.
Exercise · Medium · Python
Build HKDF-Expand-Label and Derive-Secret
labels.py already has hkdf_expand, HKDF-Expand of RFC 5869 with SHA-256. Write the three functions that TLS 1.3 builds on it (RFC 9846 section 7.1):
hkdf_label(label, context, length)returns the info bytes:lengthas two big-endian bytes, then one byte for the length ofb"tls13" + label(with a space aftertls13) followed by those bytes, then one byte for the length ofcontextfollowed bycontext.expand_label(secret, label, context, length)ishkdf_expand(secret, hkdf_label(...), length).derive_secret(secret, label, messages)isexpand_labelwith the contextSHA-256(messages)and a length of 32.messagesis the concatenation of the handshake messages so far, and it may be empty.
Refuse what the format cannot hold with ValueError: a label longer than 249 bytes (the whole label field is at most 255), a context longer than 255 bytes, and a length outside 1 to 8,160 (255 blocks of 32 bytes).
The sample tests hold your code to the values that RFC 8448 prints for its first handshake: the 49 bytes of the "derived" info, the Handshake Secret's traffic secrets, a write key and an IV. The starter code leaves out the tls13 prefix, writes the length in one byte and hashes nothing.
Starter code · labels.py
import hashlib
import hmac
def hkdf_expand(prk: bytes, info: bytes, length: int) -> bytes:
"""HKDF-Expand (RFC 5869) with SHA-256."""
out, block, counter = b"", b"", 1
while len(out) < length:
block = hmac.digest(prk, block + info + bytes([counter]), "sha256")
out += block
counter += 1
return out[:length]
def hkdf_label(label: bytes, context: bytes, length: int) -> bytes:
"""The info that Expand-Label hands to HKDF-Expand."""
return bytes([length]) + bytes([len(label)]) + label + bytes([len(context)]) + context
def expand_label(secret: bytes, label: bytes, context: bytes, length: int) -> bytes:
return hkdf_expand(secret, hkdf_label(label, context, length), length)
def derive_secret(secret: bytes, label: bytes, messages: bytes) -> bytes:
"""Expand-Label with the SHA-256 of the handshake messages so far as its context and 32 bytes of output."""
return expand_label(secret, label, messages, 32) The sample tests · test_labels.py
from labels import derive_secret, expand_label, hkdf_label
# RFC 8448 section 3: the two hellos, and the secrets of the handshake.
CLIENT_HELLO = bytes.fromhex(
"010000c00303cb34ecb1e78163ba1c38c6dacb196a6dffa21a8d9912ec18a2ef6283024dece700000613011303130201"
"0000910000000b0009000006736572766572ff01000100000a00140012001d0017001800190100010101020103010400"
"230000003300260024001d002099381de560e4bd43d23d8e435a7dbafeb3c06e51c13cae4d5413691e529aaf2c002b00"
"03020304000d0020001e040305030603020308040805080604010501060102010402050206020202002d00020101001c"
"00024001"
)
SERVER_HELLO = bytes.fromhex(
"020000560303a6af06a4121860dc5e6e60249cd34c95930c8ac5cb1434dac155772ed3e2692800130100002e00330024"
"001d0020c9828876112095fe66762bdbf7c672e156d6cc253b833df1dd69b1b04e751f0f002b00020304"
)
EARLY_SECRET = bytes.fromhex("33ad0a1c607ec03b09e6cd9893680ce210adf300aa1f2660e1b22e10f170f92a")
HANDSHAKE_SECRET = bytes.fromhex("1dc826e93606aa6fdc0aadc12f741b01046aa6b99f691ed221a9f0ca043fbeac")
SERVER_HANDSHAKE = bytes.fromhex("b67b7d690cc16c4e75e54213cb2d37b4e9c912bcded9105d42befd59d391ad38")
def raises_value_error(call):
try:
call()
except ValueError:
return True
return False
def test_the_info_of_derived_is_49_bytes():
"""RFC 8448 prints the info for the "derived" label: 49 bytes ending in the hash of the empty string"""
empty_hash = bytes.fromhex("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
info = hkdf_label(b"derived", empty_hash, 32)
assert len(info) == 49
assert info.hex() == "00200d746c7331332064657269766564" + "20" + empty_hash.hex()
def test_derive_secret_with_no_messages():
"""Derive-Secret(Early Secret, "derived", "") is 6f2615a1..., because nothing hashed is still 32 bytes"""
assert derive_secret(EARLY_SECRET, b"derived", b"").hex() == "6f2615a108c702c5678f54fc9dbab69716c076189c48250cebeac3576c3611ba"
def test_handshake_traffic_secrets_from_the_hellos():
"""the hash of both hellos gives the client and server handshake traffic secrets of RFC 8448"""
hellos = CLIENT_HELLO + SERVER_HELLO
assert len(hellos) == 286
assert derive_secret(HANDSHAKE_SECRET, b"c hs traffic", hellos).hex() == "b3eddb126e067f35a780b3abf45e2d8f3b1a950738f52e9600746a0e27a55a21"
assert derive_secret(HANDSHAKE_SECRET, b"s hs traffic", hellos).hex() == "b67b7d690cc16c4e75e54213cb2d37b4e9c912bcded9105d42befd59d391ad38"
def test_write_key_and_iv_from_a_traffic_secret():
"""a 16-byte key and a 12-byte IV come from the same secret with the labels key and iv and an empty context"""
assert expand_label(SERVER_HANDSHAKE, b"key", b"", 16).hex() == "3fce516009c21727d0f2e4e86ee403bc"
assert expand_label(SERVER_HANDSHAKE, b"iv", b"", 12).hex() == "5d313eb2671276ee13000b30"
def test_the_format_limits_are_refused():
"""a label over 249 bytes, a context over 255 bytes and a length of 0 or over 8,160 are ValueError"""
assert raises_value_error(lambda: expand_label(SERVER_HANDSHAKE, b"x" * 250, b"", 16))
assert raises_value_error(lambda: expand_label(SERVER_HANDSHAKE, b"key", b"c" * 256, 16))
assert raises_value_error(lambda: expand_label(SERVER_HANDSHAKE, b"key", b"", 0))
assert raises_value_error(lambda: expand_label(SERVER_HANDSHAKE, b"key", b"", 8161))
assert len(expand_label(SERVER_HANDSHAKE, b"key", b"", 8160)) == 8160
assert len(expand_label(SERVER_HANDSHAKE, b"x" * 249, b"c" * 255, 32)) == 32 A hint
Build the label first: full = b"tls13 " + label. The info is length.to_bytes(2, "big"), then bytes([len(full)]) + full, then bytes([len(context)]) + context. For derive_secret, hash with hashlib.sha256(messages).digest(), and remember that hashing nothing still gives 32 bytes, so the context of the "derived" step is never empty.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Exercise · Medium · Python
Compute and check a Finished message
finished.py has expand_label from the previous step. Write the two functions that check a TLS 1.3 Finished message (RFC 9846 section 4.5.3):
verify_data(base_key, transcript_hash)returns the 32 bytes of the MAC. The MAC key is not the traffic secret itself: it isexpand_label(base_key, b"finished", b"", 32), and the MAC is HMAC-SHA-256 under that key overtranscript_hash.check_finished(base_key, transcript_hash, received)returnsTruewhenreceivedequals the MAC andFalseotherwise. It never raises: a value that is notbytesof exactly 32 bytes isFalse, and the comparison useshmac.compare_digest.
The sample tests use the values of RFC 8448: the server's Finished over the transcript up to its CertificateVerify, and the client's Finished over the transcript up to the server Finished. The starter code keys the MAC with the traffic secret itself and compares with ==.
Starter code · finished.py
import hmac
def hkdf_expand(prk: bytes, info: bytes, length: int) -> bytes:
"""HKDF-Expand (RFC 5869) with SHA-256."""
out, block, counter = b"", b"", 1
while len(out) < length:
block = hmac.digest(prk, block + info + bytes([counter]), "sha256")
out += block
counter += 1
return out[:length]
def expand_label(secret: bytes, label: bytes, context: bytes, length: int) -> bytes:
"""HKDF-Expand-Label of TLS 1.3, as the previous step builds it."""
full = b"tls13 " + label
info = length.to_bytes(2, "big") + bytes([len(full)]) + full + bytes([len(context)]) + context
return hkdf_expand(secret, info, length)
def verify_data(base_key: bytes, transcript_hash: bytes) -> bytes:
"""The MAC of a Finished message."""
return hmac.digest(base_key, transcript_hash, "sha256")
def check_finished(base_key: bytes, transcript_hash: bytes, received: bytes) -> bool:
"""True when `received` is the MAC; False for anything else, without raising."""
return verify_data(base_key, transcript_hash) == received The sample tests · test_finished.py
import hmac
from finished import check_finished, verify_data
# RFC 8448 section 3.
SERVER_HANDSHAKE = bytes.fromhex("b67b7d690cc16c4e75e54213cb2d37b4e9c912bcded9105d42befd59d391ad38")
CLIENT_HANDSHAKE = bytes.fromhex("b3eddb126e067f35a780b3abf45e2d8f3b1a950738f52e9600746a0e27a55a21")
UP_TO_CERTIFICATE_VERIFY = bytes.fromhex("edb7725fa7a3473b031ec8ef65a2485493900138a2b91291407d7951a06110ed")
UP_TO_SERVER_FINISHED = bytes.fromhex("9608102a0f1ccc6db6250b7b7e417b1a000eaada3daae4777a7686c9ff83df13")
SERVER_FINISHED = bytes.fromhex("9b9b141d906337fbd2cbdce71df4deda4ab42c309572cb7fffee5454b78f0718")
CLIENT_FINISHED = bytes.fromhex("a8ec436d677634ae525ac1fcebe11a039ec17694fac6e98527b642f2edd5ce61")
def test_server_finished_of_rfc_8448():
"""the server's verify_data over the transcript up to its CertificateVerify"""
assert verify_data(SERVER_HANDSHAKE, UP_TO_CERTIFICATE_VERIFY) == SERVER_FINISHED
def test_client_finished_of_rfc_8448():
"""the client's verify_data over the transcript up to the server Finished"""
assert verify_data(CLIENT_HANDSHAKE, UP_TO_SERVER_FINISHED) == CLIENT_FINISHED
def test_right_values_check_true():
"""both Finished messages of the trace are accepted"""
assert check_finished(SERVER_HANDSHAKE, UP_TO_CERTIFICATE_VERIFY, SERVER_FINISHED) is True
assert check_finished(CLIENT_HANDSHAKE, UP_TO_SERVER_FINISHED, CLIENT_FINISHED) is True
def test_one_changed_bit_anywhere_is_refused():
"""a changed transcript hash, a changed MAC or the other side's key fails the check"""
flipped_hash = bytes([UP_TO_CERTIFICATE_VERIFY[0] ^ 1]) + UP_TO_CERTIFICATE_VERIFY[1:]
flipped_mac = SERVER_FINISHED[:-1] + bytes([SERVER_FINISHED[-1] ^ 1])
assert check_finished(SERVER_HANDSHAKE, flipped_hash, SERVER_FINISHED) is False
assert check_finished(SERVER_HANDSHAKE, UP_TO_CERTIFICATE_VERIFY, flipped_mac) is False
assert check_finished(CLIENT_HANDSHAKE, UP_TO_CERTIFICATE_VERIFY, SERVER_FINISHED) is False
def test_malformed_values_are_false_not_errors():
"""a MAC that is short, long, empty, text or missing is False and never raises"""
for bad in [SERVER_FINISHED[:31], SERVER_FINISHED + b"\x00", b"", SERVER_FINISHED.hex(), None, bytearray(SERVER_FINISHED)]:
assert check_finished(SERVER_HANDSHAKE, UP_TO_CERTIFICATE_VERIFY, bad) is False
def test_the_comparison_is_constant_time():
"""check_finished compares through hmac.compare_digest"""
calls = []
real = hmac.compare_digest
hmac.compare_digest = lambda a, b: (calls.append(1), real(a, b))[1]
try:
assert check_finished(SERVER_HANDSHAKE, UP_TO_CERTIFICATE_VERIFY, SERVER_FINISHED) is True
finally:
hmac.compare_digest = real
assert calls A hint
Two steps for verify_data: finished_key = expand_label(base_key, b"finished", b"", 32), then hmac.digest(finished_key, transcript_hash, "sha256"). For check_finished, test the type and the length of received before you compare anything, then return hmac.compare_digest(expected, received).
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
The practice session asks about all five ideas of the lesson, begins with the one you know least and explains each answer when you give it:
Practice that adapts to you
Practise this lesson
17 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
-
Fresher to mid · Warm-up
What does the transcript hash do in the TLS 1.3 key schedule, and what happens when the two sides hash different messages?
The transcript hash is the hash of the handshake messages exchanged so far, each with its type and length bytes and without record headers. TLS 1.3 uses it as the context of Derive-Secret, so every traffic secret depends on the messages as well as on the chain of secrets above it. CertificateVerify signs it and Finished MACs it, which ties each proof to this handshake. When the two sides hashed different messages, because something changed one of them on the path, they derive different traffic secrets. The first encrypted record then fails its AEAD tag and the handshake ends. A key schedule that left the transcript out would give both sides the same keys whatever each of them saw, and the change would go unnoticed by the keys.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
Your service uses a TLS 1.3 library written by another team. Against a reference peer both sides compute the same X25519 secret, yet the first encrypted handshake record never opens. How do you find the cause, and how do you stop it coming back?
-
Senior to staff
A team wants 0-RTT on every endpoint of a payment API that runs on 12 edge servers behind a balancer, with no state shared between them, because round trips cost 80 ms. Evaluate the plan and say what you would ship.
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What are the three secrets of the TLS 1.3 key schedule, and what does each add?
Early Secret: HKDF-Extract of the pre-shared key, or 32 zero bytes when there is none (a missing secret does not skip a round), giving the early-data key. Handshake Secret: Extract of the key exchange result under a salt derived from the Early Secret, giving the handshake traffic secrets. Main Secret: Extract of 32 zero bytes under a salt derived from the Handshake Secret, giving the application traffic, exporter and resumption secrets. A lower secret cannot be computed without all those above it.
-
What do HKDF-Expand-Label and Derive-Secret compute in TLS 1.3?
HKDF-Expand-Label(Secret, Label, Context, Length) is HKDF-Expand with an info made of the output length (2 bytes), the label behind the six characters tls13 with a length byte, and the context with a length byte. Derive-Secret(Secret, Label, Messages) is Expand-Label with the transcript hash of the messages as context and a hash-sized output. Keys and IVs use the labels key and iv with an empty context.
-
What is the context of Derive-Secret(secret, "derived", ""), and what happens if you get it wrong?
Not an empty string: the 32-byte SHA-256 of nothing, e3b0c442…, which is what hashing no messages gives. The info is then 49 bytes (2 for the length, 1 and 13 for the label, 1 and 32 for the context). The wrong version runs and returns 32 plausible bytes with no error, so only a test against RFC 8448's values catches it.
-
Why does the transcript hash go into every TLS 1.3 traffic secret?
So that a message changed on the way gives the two sides different keys. With one bit of the ServerHello flipped, TLS 1.3 derived b67b7d69… and ad0201ec…, no shared key, while a schedule that left the hash out derived identical keys and said nothing about the change. The hash covers the handshake messages in order, each with its 4-byte header and none of the 5-byte record headers.
-
How does TLS 1.3 make a record's key and nonce?
Each direction takes a key and an IV from its traffic secret with the labels key and iv and an empty context (16-byte key and 12-byte IV for TLS_AES_128_GCM_SHA256). The nonce of each record is the IV XORed with the record's 64-bit sequence number, left-padded with zeros, so the first record uses the IV itself. A counter nonce cannot repeat under one key: when a sequence number would wrap, the connection rekeys or ends.
-
What do the record's AEAD tag, CertificateVerify and Finished each show?
The AEAD tag shows that the sender holds the handshake key. CertificateVerify, a signature over 64 spaces, the context string TLS 1.3, server CertificateVerify, a zero byte and the transcript hash up to the Certificate, shows that the sender controls the private key of the certificate it sent. Finished, an HMAC under a finished key over the transcript up to CertificateVerify, shows that the sender holds the traffic secret and saw the same handshake. None says whether the certificate belongs to the host you meant.
-
What are the risks of TLS 1.3 early data (0-RTT)?
It is keyed from the pre-shared key and the ClientHello alone, so assume no forward secrecy, and nothing ties it to one connection, so a recorded flight can be replayed: it ran three times against a server that checked only the tag and once against one that recorded ClientHellos. RFC 9846 says each server instance must accept a given 0-RTT handshake only once, so 12 servers with no shared record may run a request up to 12 times. Send only requests safe to repeat; a server may answer 425 Too Early (RFC 8470).
-
Which RFCs now define TLS 1.3 and the status of TLS 1.2?
RFC 9846 replaces RFC 8446 as the specification of TLS 1.3. It renames the last secret of the ladder the main secret but keeps RFC 8446's wire labels, so no derived value changes. RFC 9852 says new protocols using TLS must require TLS 1.3, and RFC 9851 puts TLS 1.2 in feature freeze.
Key takeaways
- The TLS 1.3 key schedule chains the Early, Handshake and Main Secrets, each an HKDF-Extract of one new input under a salt derived from the rung above; nine values recomputed from the two hellos and the X25519 result equalled RFC 8448’s.
- Expand-Label feeds HKDF-Expand 49 bytes for the “derived” step, a length, the label behind
tls13and a context, and the context is the 32-byte hash of no messages, not an empty one. - Every traffic secret hashes the messages so far: one flipped bit of the ServerHello gave the two sides unrelated keys under TLS 1.3 and identical keys under a schedule that left the transcript out.
- The server’s first flight opens only under the right key, CertificateVerify shows possession of the certificate’s key, and Finished shows that both sides saw the same handshake; none of them decides whether to trust the certificate.
- Early data is keyed from the pre-shared key and the ClientHello alone, so a recorded flight ran three times against a server that checked only the tag and once against one that recorded ClientHellos; only requests that are safe to repeat belong in it.
Terms in this lesson
- 0-RTT early data
- Application data that a TLS 1.3 client sends in its first flight, encrypted under a key from a pre-shared key. It has no forward secrecy and can be replayed, so only safe-to-repeat requests belong in it.
- Derive-Secret
- HKDF-Expand-Label with the transcript hash of the messages so far as its context and a hash-sized output, so a secret taken with it depends on exactly the messages exchanged up to that point.
- Finished message
- The last message of each side's handshake in TLS 1.3, an HMAC over the transcript under a key from that side's handshake traffic secret. It shows that the sender holds the keys and saw the same messages.
- Handshake Secret
- The second secret of the TLS 1.3 schedule. It is HKDF-Extract of the key exchange result under a salt derived from the Early Secret, and the handshake traffic secrets come from it.
- HKDF-Expand-Label
- TLS 1.3's wrapper around HKDF-Expand. It builds the info from the output length, the label behind "tls13 " and a context, each framed by its length, so every use of a secret gets its own key.
- Main Secret
- The third secret of the TLS 1.3 schedule, from which the application traffic secrets, the exporter secret and the resumption secret are taken. RFC 9846 gave it this name; earlier documents used another.
- pre-shared key
- A secret that both sides hold before a handshake, either provisioned outside TLS or derived from an earlier connection for resumption. TLS 1.3 mixes it into the Early Secret.
- TLS key schedule
- The chain of HKDF-Extract and HKDF-Expand-Label steps by which TLS 1.3 turns a pre-shared key, the key exchange result and the handshake messages into every key of a connection.
- traffic secret
- A 32-byte secret of the TLS 1.3 schedule for one direction and one phase, handshake or application. The record key and the IV of that direction are derived from it.
- transcript hash
- The hash of the handshake messages sent and received so far, with their type and length fields but without record headers. TLS 1.3 mixes it into key derivation and covers it in its authentication messages.
References
- RFC 9846: The Transport Layer Security (TLS) Protocol Version 1.3 (sections 2.3, 4.1, 4.5, 5.3, 7.1, 7.3 and 8) (Internet Engineering Task Force (IETF))
- RFC 8448: Example Handshake Traces for TLS 1.3 (sections 3 and 4) (Internet Engineering Task Force (IETF))
- RFC 5869: HMAC-based Extract-and-Expand Key Derivation Function (HKDF) (Internet Engineering Task Force (IETF))
- RFC 8470: Using Early Data in HTTP (Internet Engineering Task Force (IETF))
- RFC 9852: New Protocols Using TLS Must Require TLS 1.3 (Internet Engineering Task Force (IETF))
- RFC 9851: TLS 1.2 is in Feature Freeze (Internet Engineering Task Force (IETF))
- Node.js 24 documentation: Crypto (createHmac, createDecipheriv, verify and X509Certificate) (OpenJS Foundation (Node.js))
- Python 3.14 documentation: hmac, keyed-hashing for message authentication (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress