◐ Off-By-One · answer catalog

tls13-psk-binder-truncated-transcript-verification

1 answer(s)pythonpython3

The task is to reproduce the RFC 8446 § PSK binder check from raw HKDF primitives. Almost every bug in this area comes from one of seven places, not from the HMAC itself:

📦 Source in repository (JSON)

Answer

TLS 1.3 PSK Binder Verification — Diagnosis & Fix

Root-cause analysis

The task is to reproduce the RFC 8446 § PSK binder check from raw HKDF primitives. Almost every bug in this area comes from one of seven places, not from the HMAC itself:

  1. Hashing the wrong ClientHello. The transcript is not the full ClientHello on the wire.
  2. Mis-reading "Truncate()". RFC 8446 says: "a partial ClientHello up to and including the PreSharedKeyExtension.identities field … The length fields for the message (including the overall length, the length of the extensions block, and the length of the pre_shared_key extension) are all set as if binders of the correct lengths were present."
  3. The transcript is the raw prefix through the identities vector.
  4. All enclosing length fields keep their on-wire values (they still count the binders).
  5. The 2-byte binder-vector length and the binder entries are not hashed at all.
  6. A frequent wrong implementation appends a zeroed 2-byte binder length (or rewrites the outer lengths). RFC 8448 disproves that: the hashed prefix is 477 bytes, and SHA-256(prefix) = 63224b2e…, while the full ClientHello is 512 bytes. A 479-byte prefix‖0000 gives a different hash.
  7. Wrong key-schedule label. Resumption PSKs use "res binder", external PSKs use "ext binder"; using the other one silently fails.
  8. Using the PSK of a different ticket. The PSK is the HKDF IKM — if the server validates the binder against a PSK from another ticket, the Early Secret (and every derived key) is wrong.
  9. Confusing the early-data transcript. client_early_traffic_secret = Derive-Secret(Early Secret, "c e traffic", ClientHello) uses the full ClientHello (binders included) and must not include EndOfEarlyData or any later message. The binder, by contrast, uses Truncate(ClientHello).
  10. Forgetting downgrade protection. RFC 8446 §4.1.3: the last 8 bytes of ServerHello.random must not equal "DOWNGRD\x01" (TLS 1.2) or "DOWNGRD\x00" (≤TLS 1.1); seeing one means the connection was downgraded and must abort.
  11. Non-constant-time comparison / missing HelloRetryRequest handling. For HRR the binder covers Transcript-Hash(CH1, HRR, Truncate(CH2)).

The exact chain is:

Early Secret  = HKDF-Extract(salt=0, IKM=PSK)
binder_key    = Derive-Secret(Early Secret, "ext binder"|"res binder", "")
finished_key  = HKDF-Expand-Label(binder_key, "finished", "", Hash.length)
binder        = HMAC(finished_key, Transcript-Hash(Truncate(ClientHello)))

Derive-Secret(S, L, M) = HKDF-Expand-Label(S, L, Hash(M), Hash.length)
HKDF-Expand-Label(S,L,C,Ln) = HKDF-Expand(S,
    HkdfLabel{ u16 Ln; "tls13 "‖L; C }, Ln)

Exact fix

The complete, stdlib-only implementation is below (tls13_psk.py). It is directly runnable and self-tests against the RFC 8448 resumed-0-RTT vector.

#!/usr/bin/env python3
"""
RFC 8446 TLS 1.3 PSK binder verification from raw HKDF primitives.
Stdlib only (hashlib/hmac); no TLS library. See RFC 8446 <ip-address>, 4.4.4, 7.1.
Verified against the RFC 8448 resumed 0-RTT trace.
"""
import hashlib, hmac, struct

DOWNGRADE_SENTINELS = (bytes.fromhex("444f574e47524401"),
                       bytes.fromhex("444f574e47524400"))

# ----------------------------- HKDF / key schedule -------------------------
def _ds(h): return hashlib.new(h).digest_size

def hkdf_extract(h, salt, ikm):
    if salt is None: salt = b"\x00" * _ds(h)
    return hmac.new(salt, ikm, h).digest()

def hkdf_expand(h, prk, info, n):
    if n > 255 * _ds(h): raise ValueError("HKDF length")
    out, t, i = b"", b"", 1
    while len(out) < n:
        t = hmac.new(prk, t + info + bytes([i]), h).digest(); out += t; i += 1
    return out[:n]

def hkdf_expand_label(h, secret, label, ctx, n):
    """HKDF-Expand-Label: HkdfLabel{length, "tls13 "+label, context}."""
    if isinstance(label, str): label = label.encode()
    lab = b"tls13 " + label
    assert 7 <= len(lab) <= 255 and len(ctx) <= 255
    info = n.to_bytes(2, "big") + bytes([len(lab)]) + lab + bytes([len(ctx)]) + ctx
    return hkdf_expand(h, secret, info, n)

def derive_secret(h, secret, label, transcript):
    """Derive-Secret(Secret, Label, Messages) = Expand-Label(.,Label,Hash(Messages))."""
    th = hashlib.new(h, transcript).digest()
    return hkdf_expand_label(h, secret, label, th, _ds(h))

def early_secret(h, psk):                       return hkdf_extract(h, None, psk or b"")

def binder_key(h, psk, external=False):
    lab = b"ext binder" if external else b"res binder"
    return derive_secret(h, early_secret(h, psk), lab, b"")

def client_early_traffic_secret(h, psk, full_client_hello):
    # transcript is the FULL ClientHello (binders included); never EndOfEarlyData
    return derive_secret(h, early_secret(h, psk), b"c e traffic", full_client_hello)

def early_exporter_master_secret(h, psk, full_client_hello):
    return derive_secret(h, early_secret(h, psk), b"e exp master", full_client_hello)

def resumption_ticket_psk(h, resumption_master_secret, ticket_nonce):
    return hkdf_expand_label(h, resumption_master_secret, b"resumption",
                             ticket_nonce, _ds(h))

def compute_binder(h, bk, truncated_client_hello):
    fk = hkdf_expand_label(h, bk, b"finished", b"", _ds(h))
    th = hashlib.new(h, truncated_client_hello).digest()
    return hmac.new(fk, th, h).digest()

# ----------------------------- ClientHello parsing -------------------------
def parse_client_hello(data):
    """Return dict: fields + parsed PSK identities/binders + truncation offset."""
    assert data[0] == 1, "not a ClientHello"
    o = 4
    rv = {"raw": data, "legacy_version": data[o:o+2]}; o += 2
    rv["random"] = data[o:o+32]; o += 32
    n = data[o]; o += 1; rv["legacy_session_id"] = data[o:o+n]; o += n
    n = int.from_bytes(data[o:o+2], "big"); o += 2
    rv["cipher_suites"] = data[o:o+n]; o += n
    n = data[o]; o += 1; o += n                          # compression methods
    n = int.from_bytes(data[o:o+2], "big"); o += 2       # extensions length
    ext_end = o + n
    rv.update(extensions=[], identities=[], binders=[], binder_offset=None)
    while o < ext_end:
        et = int.from_bytes(data[o:o+2], "big"); o += 2
        el = int.from_bytes(data[o:o+2], "big"); o += 2
        v0, v1 = o, o + el
        rv["extensions"].append((et, data[v0:v1]))
        if et == 0x0029:                                  # pre_shared_key
            p = v0
            ids = int.from_bytes(data[p:p+2], "big"); p += 2
            end = p + ids
            while p < end:
                il = int.from_bytes(data[p:p+2], "big"); p += 2
                ident = data[p:p+il]; p += il
                age = struct.unpack_from(">I", data, p)[0]; p += 4
                rv["identities"].append((ident, age))
            rv["binder_offset"] = p                       # right after identities
            bl = int.from_bytes(data[p:p+2], "big"); p += 2
            bend = p + bl
            while p < bend:
                l = data[p]; p += 1
                rv["binders"].append(data[p:p+l]); p += l
        o = v1
    return rv

def truncate_client_hello(data):
    """Truncate(CH): bytes through PreSharedKeyExtension.identities; enclosing
    length fields keep their on-wire (binders-present) values; no binders and
    no binder-vector length are hashed. RFC 8446 <ip-address> / RFC 8448."""
    return data[:parse_client_hello(data)["binder_offset"]]

# ----------------------------- Binder verification -------------------------
def verify_psk_binder(h, client_hello, psk, identity_index=0,
                      external_psk=False, transcript_prefix=b""):
    """Constant-time check. transcript_prefix supports CH1||HRR (HelloRetryRequest)."""
    ch = parse_client_hello(client_hello)
    assert ch["binder_offset"] is not None, "no pre_shared_key extension"
    truncated = transcript_prefix + truncate_client_hello(client_hello)
    bk = binder_key(h, psk, external_psk)
    expected = compute_binder(h, bk, truncated)
    got = ch["binders"][identity_index]
    return {
        "accept": hmac.compare_digest(expected, got),
        "expected": expected.hex(), "received": got.hex(),
        "early_secret": early_secret(h, psk).hex(), "binder_key": bk.hex(),
        "client_early_traffic_secret":
            client_early_traffic_secret(h, psk, client_hello).hex(),
    }

# ----------------------------- Downgrade protection ------------------------
def has_downgrade_sentinel(server_hello_random):
    return len(server_hello_random) >= 8 and \
        server_hello_random[-8:] in DOWNGRADE_SENTINELS

def parse_server_hello(data):
    assert data[0] == 2
    o = 4
    rv = {"legacy_version": data[o:o+2]}; o += 2
    rv["random"] = data[o:o+32]; o += 32
    n = data[o]; o += 1; rv["legacy_session_id"] = data[o:o+n]; o += n
    rv["cipher_suite"] = data[o:o+2]; o += 2
    o += 1                                                # compression
    n = int.from_bytes(data[o:o+2], "big"); o += 2
    end = o + n; rv["extensions"] = []
    while o < end:
        et = int.from_bytes(data[o:o+2], "big"); o += 2
        el = int.from_bytes(data[o:o+2], "big"); o += 2
        rv["extensions"].append((et, data[o:o+el])); o += el
    return rv

# ===========================================================================
# Verification against RFC 8448 (resumed 0-RTT handshake)
# ===========================================================================
CH_RFC8448 = bytes.fromhex(
    "010001fc03031bc3ceb6bbe39cff938355b5a50adb6db21b7a6af649d7b4bc41"
    "9d7876487d95000006130113031302010001cd0000000b000900000673657276"
    "6572ff01000100000a00140012001d0017001800190100010101020103010400"
    "3300260024001d0020e4ffb68ac05f8d96c99da26698346c6be16482badddafe"
    "051a66b4f18d668f0b002a0000002b0003020304000d0020001e040305030603"
    "020308040805080604010501060102010402050206020202002d00020101001c"
    "0002400100150057000000000000000000000000000000000000000000000000"
    "0000000000000000000000000000000000000000000000000000000000000000"
    "0000000000000000000000000000000000000000000000000000000000000000"
    "2900dd00b800b22c035d829359ee5ff7af4ec900000000262a6494dc486d2c8a"
    "34cb33fa90bf1b0070ad3c498883c9367c09a2be785abc55cd226097a3a98211"
    "7283f82a03a143efd3ff5dd36d64e861be7fd61d2827db279cce145077d454a3"
    "664d4e6da4d29ee03725a6a4dafcd0fc67d2aea70529513e3da2677fa5906c5b"
    "3f7d8f92f228bda40dda721470f9fbf297b5aea617646fac5c03272e970727c6"
    "21a79141ef5f7de6505e5bfbc388e93343694093934ae4d357fad6aacb002120"
    "3add4fb2d8fdf822a0ca3cf7678ef5e88dae990141c5924d57bb6fa31b9e5f9d")
PSK_RFC8448 = bytes.fromhex(
    "4ecd0eb6ec3b4d87f5d6028f922ca4c5851a277fd41311c9e62d2c9492e1c4f3")
SH_RFC8448 = bytes.fromhex(
    "0200005c03033ccfd2dec890222763472ae8136777c9d7358777bb66e91ea5122495"
    "f559ea2d00130100003400290002000000330024001d0020121761ee42c333e1b9e7"
    "7b60dd57c2053cd94512ab47f115e86eff50942cea31002b00020304")

def _selftest():
    r = verify_psk_binder("sha256", CH_RFC8448, PSK_RFC8448)
    assert r["accept"], r
    assert r["expected"] == r["received"] == \
        "3add4fb2d8fdf822a0ca3cf7678ef5e88dae990141c5924d57bb6fa31b9e5f9d"
    assert r["binder_key"] == \
        "69fe131a3bbad5d63c64eebcc30e395b9d8107726a13d074e389dbc8a4e47256"
    assert r["client_early_traffic_secret"] == \
        "3fbbe6a60deb66c30a32795aba0eff7eaa10105586e7be5c09678d63b6caab62"
    assert len(truncate_client_hello(CH_RFC8448)) == 477
    print("RFC 8448 binder OK:", r["received"])

    # wrong transcript hash (tamper with the ClientHello prefix)
    bad = bytearray(CH_RFC8448); bad[20] ^= 1
    assert not verify_psk_binder("sha256", bytes(bad), PSK_RFC8448)["accept"]
    # PSK from a different ticket
    assert not verify_psk_binder("sha256", CH_RFC8448, b"\x00"*32)["accept"]
    # external vs resumption label
    assert not verify_psk_binder("sha256", CH_RFC8448, PSK_RFC8448,
                                 external_psk=True)["accept"]
    # EndOfEarlyData must NOT enter the early-data transcript
    a = client_early_traffic_secret("sha256", PSK_RFC8448, CH_RFC8448)
    b = client_early_traffic_secret("sha256", PSK_RFC8448,
                                    CH_RFC8448 + b"\x05\x00\x00\x00")
    assert a != b
    # forged downgrade sentinel in ServerHello.random
    sh = bytearray(SH_RFC8448); sh[4+2+32-8:4+2+32] = DOWNGRADE_SENTINELS[0]
    assert has_downgrade_sentinel(bytes(sh[4+2:4+2+32]))
    assert not has_downgrade_sentinel(SH_RFC8448[4+2:4+2+32])
    print("adversarial cases OK")
    print("ALL TESTS PASSED")

if __name__ == "__main__":
    _selftest()

Run it:

python3 tls13_psk.py

Verification

The implementation is validated against the RFC 8448 §4 resumed 0-RTT handshake (the authoritative reference for this exact computation). Observed output:

RFC 8448 binder OK: 3add4fb2d8fdf822a0ca3cf7678ef5e88dae990141c5924d57bb6fa31b9e5f9d
adversarial cases OK
ALL TESTS PASSED

Exact values reproduced from the RFC:

quantity value
Truncate(ClientHello) length 477 bytes (full CH is 512)
SHA-256(Truncate(CH)) 63224b2e4573f2d3454ca84b9d009a04f6be9e05711a8396473aefa01e924a14
early_secret 9b2188e9b2fc6d64d71dc329900e20bb41915000f678aa839cbb797cb7d8332c
binder_key (res binder) 69fe131a3bbad5d63c64eebcc30e395b9d8107726a13d074e389dbc8a4e47256
finished_key 5588673e72cb59c87d220caffe94f2dea9a3b1609f7d50e90a48227db9ed7eaa
binder 3add4fb2d8fdf822a0ca3cf7678ef5e88dae990141c5924d57bb6fa31b9e5f9d
client_early_traffic_secret 3fbbe6a60deb66c30a32795aba0eff7eaa10105586e7be5c09678d63b6caab62
resumption ticket PSK (7df235f2…, nonce 0000) 4ecd0eb6ec3b4d87f5d6028f922ca4c5851a277fd41311c9e62d2c9492e1c4f3

Negative cases all return reject (accept == False):

For HelloRetryRequest, pass transcript_prefix = ClientHello1‖HelloRetryRequest; verify_psk_binder then hashes prefix || Truncate(ClientHello2) exactly as RFC 8446 requires.

Key correctness facts encoded here:

Evidence & signatures

# Evidence
- Problem class: tls13-psk-binder-truncated-transcript-verification
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-21T04:46:04.957Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Build the RFC 8446 TLS 1.3 pre-shared-key binder check in Python from the raw HKDF-Extract / HKDF-Expand-Label primitives, with no TLS library allowed: derive the resumption and early-data secrets, compute the binder over the partially-truncated ClientHello (everything up to the binder list, with the binders field length zeroed as the RFC specifies), and decide accept/reject on supplied byte transcripts. The grader includes transcripts where the binder covers the wrong transcript hash, where the PSK is imported from a different ticket, where the early-data transcript must exclude the EndOfEarlyData message, and where the downgrade sentinel is forged, and requires exact accept/reject plus the derived keys reprinted as hex.", "environment": "python3", "language": "python", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "tls13-psk-binder-truncated-transcript-verification", "provider": "openrouter", "solved_at": "2026-09-21T04:46:04.958Z", "version": "3.11"}
Generated from the verified corpus · MIT licensedBack to the catalog