Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Advanced
  • 30 minutes
  • Examples run with Node.js 24.21.0, Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free lesson, Pro answers: 30 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

Interactive · step by step The three secrets of a TLS 1.3 handshake, one rung at a time Step through the ladder on RFC 8448's trace. The left column is the chain of secrets, the right column the traffic secrets taken from it. Each box shows the start of its value.
The three secrets of a TLS 1.3 handshake, one rung at a time, step 7 of 7. Showing Early Secret (33ad0a1c607ec03b…), Handshake Secret (1dc826e93606aa6f…), Main Secret (18df06843d13a08b…), Handshake traffic (b3eddb12… and b67b7d69…), Application traffic (9e40646c… and a11af9f0…).derivedderivedc and s hs trafficc and s ap trafficEarly Secret33ad0a1c607ec03b…Handshake Secret1dc826e93606aa6f…Main Secret18df06843d13a08b…Handshake trafficb3eddb12… and b67b7d69…Application traffic9e40646c… and a11af9f0…

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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 ladder of RFC 8448, computed from the two hellos JavaScript · ladder/ladder.mjs
// 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

  1. Question 1 of 2 A handshake uses no pre-shared key. What does the key schedule extract to make the Early Secret?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 32 zero bytes as the input, under a salt of 32 zero bytes

    Every handshake runs all three extractions. With no pre-shared key the first one takes zeros for both its salt and its input, which gives 33ad0a1c… in RFC 8448 and in every other SHA-256 handshake of that kind.

  2. Question 2 of 2 What is the salt of the extraction that makes the Main Secret?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Derive-Secret(Handshake Secret, "derived", "")

    The Main Secret is HKDF-Extract with the derived salt 43de77e0… and 32 zero bytes, in RFC 8448. Nothing new is mixed in at this rung; the dependence on the key exchange runs through the salt.

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:

Interactive · stage by stage The 49 bytes that HKDF-Expand-Label feeds to HKDF-Expand Step through the six parts of the info for the label "derived". Each box shows the bytes it adds and the length so far.
The 49 bytes that HKDF-Expand-Label feeds to HKDF-Expand, step 6 of 6. Showing Output length (2 bytes so far), Label length (3 bytes so far), The label (16 bytes so far), Context length (17 bytes so far), The context (49 bytes in all), HKDF-Expand (6f2615a1…). In focus: HKDF-Expand.Output length2 bytes so farLabel length3 bytes so farThe label16 bytes so farContext length17 bytes so farThe context49 bytes in allHKDF-Expand6f2615a1…

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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:

The info of HKDF-Expand-Label, and three ways to get it wrong Python · labels/expand_label.py
"""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

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

  1. Question 1 of 2 How many bytes is the info of HKDF-Expand-Label for the label "derived" with a 32-byte context?

    Type a number.

    A hint

    Add the output length, the label length byte, the label behind "tls13 ", the context length byte and the context.

    Show the answer: Question 1 of 2

    Answer: 49 bytes

    2 bytes of output length, 1 byte of label length, 13 bytes for "tls13 derived", 1 byte of context length and 32 bytes of context make 49, the size RFC 8448 prints for this info.

  2. Question 2 of 2 A developer computes the first "derived" salt as expand_label(early, b"derived", b"", 32). What goes wrong?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It returns an unrelated 32 bytes without raising, because the context must be the SHA-256 of the empty string

    The RFC writes Derive-Secret(Early Secret, "derived", "") and means Expand-Label with Transcript-Hash of no messages, which is e3b0c442… as context. The version with an empty context returns a value that equals nothing in the trace.

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:

A ServerHello changed by one bit, under two key schedules Python · tamper/tamper.py
"""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

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

  1. Question 1 of 2 One bit of the ServerHello changes between the server and the client. What do the two sides derive under TLS 1.3?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Different handshake traffic secrets, so the client cannot open the server's encrypted flight

    The program derived b67b7d69… on the server and ad0201ec… on the client from the two versions of the ServerHello. With no common key the first record's tag fails.

  2. Question 2 of 2 A protocol derives its traffic key as Expand-Label(handshake_secret, "s hs traffic", "", 32). Which problem does a test "a changed hello must change the keys" reveal?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A hello changed on the way gives both sides the same key, so the change is not noticed by the keys

    Both sides compute the same secret whatever each of them saw, which is what the weak design in the program showed. Putting the transcript hash into the context makes the key a function of the messages as well as of the secret.

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:

Interactive · message by message Who can read what during a TLS 1.3 handshake Step through the messages of RFC 8448's handshake. The line under each actor says which keys it holds after each step.
Who can read what during a TLS 1.3 handshake, step 8 of 8. Messages so far: Client to Server: ClientHello 196 bytes; Server to Client: ServerHello 90 bytes; Server to Client: 679-byte record; Client to Server: Finished; Client to Server: application data.Clientapplication keysServerapplication keysClientHello 196 bytesServerHello 90 bytes679-byte recordFinishedapplication data

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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…
  8. 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:

Opening the server's first flight, and checking its signature and its Finished JavaScript · flight/flight.mjs
// 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

  1. Question 1 of 2 What does the server's Finished MAC cover in the 1-RTT handshake of the trace?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The transcript from the ClientHello up to CertificateVerify

    For the server the Handshake Context runs from the ClientHello up to the later of EncryptedExtensions and CertificateRequest, and the MAC covers it together with the Certificate and CertificateVerify, which was edb7725f… in the trace.

  2. Question 2 of 2 How many bytes is the verify_data of a Finished message in a handshake that uses SHA-256?

    Type a number.

    A hint

    It is the size of the HMAC output for the handshake hash.

    Show the answer: Question 2 of 2

    Answer: 32 bytes

    In TLS 1.3 the verify_data is as long as the HMAC output of the handshake hash, 32 bytes for SHA-256. Earlier versions of TLS always used 12.

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:

Interactive · message by message A copy of an early-data flight, sent a second time Step through one early-data request and a copy of it. The line under the server says how many times the request has run.
A copy of an early-data flight, sent a second time, step 7 of 7. Messages so far: Client to Server: ClientHello + early data; Client to Copy on the path: same bytes; Copy on the path to Server: ClientHello + early data.Clientsends the requestServerrequest ran 1 timeCopy on the pathholds the same bytesClientHello + early datasame bytesClientHello + early data

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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:

A recorded early-data flight, replayed against two servers JavaScript · early/early_data.mjs
// 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

  1. Question 1 of 2 Which values does the client's early traffic secret depend on? Choose all that apply.

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • The pre-shared key
    • The ClientHello

    Only the pre-shared key and the ClientHello are known to both sides at that moment, so only they can be in the key. That is why a copy of the flight derives the same key again.

  2. Question 2 of 2 A shop offers 0-RTT. Which request is a candidate for early data?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: GET /catalog?page=2, which only reads

    Early data must be safe to run more than once. A read qualifies. A purchase does not, and neither does a link that changes state, even though its method is GET.

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.

  1. Build HKDF-Expand-Label and Derive-Secret To do
  2. Compute and check a Finished message To do

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: length as two big-endian bytes, then one byte for the length of b"tls13" + label (with a space after tls13) followed by those bytes, then one byte for the length of context followed by context.
  • expand_label(secret, label, context, length) is hkdf_expand(secret, hkdf_label(...), length).
  • derive_secret(secret, label, messages) is expand_label with the context SHA-256(messages) and a length of 32. messages is 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.

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 is expand_label(base_key, b"finished", b"", 32), and the MAC is HMAC-SHA-256 under that key over transcript_hash.
  • check_finished(base_key, transcript_hash, received) returns True when received equals the MAC and False otherwise. It never raises: a value that is not bytes of exactly 32 bytes is False, and the comparison uses hmac.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).

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.

HMAC Generator Choose hex for the key and the message, paste 64 zeros in both, and read the Early Secret 33ad0a1c… that opens the ladder.

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.

  1. Question 1 of 17 In which order are the three secrets of the TLS 1.3 key schedule made?

    Choose one answer.

    Show the answer: Question 1 of 17

    Answer: Early Secret, Handshake Secret, Main Secret

    The schedule is a chain: Early Secret, then the Handshake Secret salted with a value derived from it, then the Main Secret salted with one derived from the Handshake Secret.

  2. Question 2 of 17 What are the salt and the input of the extraction that makes the Handshake Secret?

    Choose one answer.

    Show the answer: Question 2 of 17

    Answer: The salt is Derive-Secret(Early Secret, "derived", "") and the input is the key exchange result

    Handshake Secret = HKDF-Extract(Derive-Secret(Early Secret, "derived", ""), key exchange result), which is 1dc826e9… in RFC 8448.

  3. Question 3 of 17 The Main Secret is an extraction of only zeros. Why is it still different for every handshake?

    Choose one answer.

    Show the answer: Question 3 of 17

    Answer: Its salt is derived from the Handshake Secret, which depends on the key exchange

    The chain is the point of the "derived" steps: a rung with a constant input still depends on the key exchange through its salt.

  4. Question 4 of 17 Which text does every label of HKDF-Expand-Label begin with?

    Choose one answer.

    Show the answer: Question 4 of 17

    Answer: "tls13 ", with a space after tls13

    HkdfLabel carries the label as "tls13 " plus the label, so RFC 8448's info for "derived" contains the bytes 746c733133 20.

  5. Question 5 of 17 How many bytes is the info for the label "c hs traffic" with a 32-byte context?

    Type a number.

    A hint

    The label "tls13 c hs traffic" is 18 bytes. Add the length fields and the context.

    Show the answer: Question 5 of 17

    Answer: 54 bytes

    2 bytes of output length, 1 of label length, 18 of "tls13 c hs traffic", 1 of context length and 32 of context make 54, the size RFC 8448 prints for this step.

  6. Question 6 of 17 This builds the info for the "derived" step. Which rule of HKDF-Expand-Label does it break?

    Read the code, then choose one answer.

    import hashlib
    
    empty_hash = hashlib.sha256(b"").digest()
    info = (32).to_bytes(2, "big") + bytes([len(b"derived")]) + b"derived" + bytes([32]) + empty_hash
    Show the answer: Question 6 of 17

    Answer: The label lacks the "tls13 " prefix, so the info is 43 bytes and the output equals nothing in the RFC

    With the label "derived" instead of "tls13 derived" the info has 2 + 1 + 7 + 1 + 32 = 43 bytes. HKDF accepts it and returns 32 bytes that equal nothing in the trace.

  7. Question 7 of 17 What does the TLS 1.3 transcript hash cover for each handshake message?

    Choose one answer.

    Show the answer: Question 7 of 17

    Answer: The message with its 4-byte type and length header, without the record header

    In RFC 9846 section 4.1 the transcript hash is the hash of the concatenated handshake messages, each with its type and length and without record headers, 286 bytes for the two hellos of the trace.

  8. Question 8 of 17 After a HelloRetryRequest, what stands in the transcript hash for the first ClientHello?

    Choose one answer.

    Show the answer: Question 8 of 17

    Answer: A synthetic message of type message_hash that holds the hash of that ClientHello

    RFC 9846 section 4.1 replaces ClientHello1 with a message of type message_hash (254) that contains Hash(ClientHello1), so the transcript can be rebuilt from a stored hash.

  9. Question 9 of 17 A designer says Finished is not needed because every key already depends on the transcript. What does Finished add?

    Choose one answer.

    Show the answer: Question 9 of 17

    Answer: Proof that the peer holds the keys and saw the same messages, which matching keys alone do not show

    RFC 9846 calls Finished essential for the authentication of the handshake and of the computed keys. Each side proves that it derived the same keys and saw the same handshake.

  10. Question 10 of 17 How many bytes is the write key for TLS_AES_128_GCM_SHA256?

    Type a number.

    A hint

    The 128 in the suite's name is the key size in bits.

    Show the answer: Question 10 of 17

    Answer: 16 bytes

    AES-128 uses a 16-byte key, taken with the label key and an empty context, next to a 12-byte IV. The key of the trace starts 3fce5160….

  11. Question 11 of 17 What is the nonce of the first record that a side sends under a traffic key?

    Choose one answer.

    Show the answer: Question 11 of 17

    Answer: The IV itself, because the sequence number 0 XORed into it changes nothing

    Both sides count records from 0 under each key, so the first record's nonce is the IV, and the nonce changes with every record. This is a counter nonce of the kind the symmetric module described.

  12. Question 12 of 17 Which key does the HMAC of a Finished message use?

    Choose one answer.

    Show the answer: Question 12 of 17

    Answer: A finished key made with Expand-Label(traffic secret, "finished", "", 32)

    The key separation rule from the HKDF lesson applies: the finished key, the record key and the IV each come from their own label.

  13. Question 13 of 17 A client verifies the CertificateVerify signature with the key in the server's certificate. Does that show the server is the host the client meant to reach?

    Choose one answer.

    Show the answer: Question 13 of 17

    Answer: No. It shows possession of that certificate's key, and the chain and the name still need checking

    CertificateVerify proves possession of a private key, and the next lesson covers how a client decides that the certificate itself is acceptable.

  14. Question 14 of 17 What does 0-RTT early data lack compared with the rest of a TLS 1.3 connection?

    Choose one answer.

    Show the answer: Question 14 of 17

    Answer: Forward secrecy, and protection against replay between connections

    The data is encrypted and authenticated, but a copy of the flight is accepted again, and a leak of the pre-shared key later opens it.

  15. Question 15 of 17 Eight servers each accept a given 0-RTT handshake at most once and share no record. At most how many times can one recorded request run?

    Type a number.

    A hint

    The guarantee is per server instance.

    Show the answer: Question 15 of 17

    Answer: 8 times

    RFC 9846 section 8 limits replays to the number of server instances. Shared state or single-use tickets across the instances lower it to one.

  16. Question 16 of 17 A server receives an early-data request that it cannot safely run twice. What does RFC 8470 let it answer?

    Choose one answer.

    Show the answer: Question 16 of 17

    Answer: 425 Too Early, so the client repeats the request after the handshake

    RFC 8470 defines the 425 (Too Early) status code for this, and the Early-Data header field for intermediaries that pass such requests on.

  17. Question 17 of 17 An API offers GET /status, POST /payments and GET /export?mark=1, which marks the export as downloaded. Which requests may go in early data?

    Choose one answer.

    Show the answer: Question 17 of 17

    Answer: Only GET /status

    Judge the effect of running a request twice, not its method. A read with no side effects is safe, and anything that changes state belongs after the handshake.

Interview questions

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

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

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

    Model answer, Pro. First step and follow-ups

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

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

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

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

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

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

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

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

  8. 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 tls13 and 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.