Cryptography · Protocol Construction

Pre-Shared Key Binders and the Cost of Early Data

How a binder commits a client to a session ticket before the server has contributed anything, why 0-RTT cannot be replay-protected by the protocol, and what the truncation rules prevent.

A resumed TLS 1.3 connection can be established in one round trip instead of two, and can carry application data in that first flight. Both properties come from the same source: the client holds a pre-shared key from an earlier session and offers it in its very first message. The server, if it accepts, derives traffic keys from that key and the client’s fresh key share, and the client can encrypt application data before it has heard anything from the server.

The price is that the client’s first flight must prove possession of the PSK using a value that cannot depend on the server’s reply. Whatever mechanism does that is necessarily computed over a message the client wrote before it knew anything about this connection. The binder is that mechanism, and its structure is entirely determined by this constraint.

What a binder is computed over

The ClientHello in a PSK resumption is a large message: legacy version and random, cipher suites, extensions including key shares and the pre_shared_key extension, and inside that extension a list of identities with associated binders. A binder is an HMAC over a hash of a truncated ClientHello: everything up to and including the identities list, with the binders list itself omitted.

The truncation is not a convenience. A binder cannot cover itself, so the bytes it authenticates must be a prefix of the message that contains it, and the protocol has to specify exactly where the cut falls. RFC 8446 defines the truncated ClientHello as the ClientHello with the binders list removed from the pre_shared_key extension; the extension’s length field and the handshake message’s length field are adjusted accordingly, and the result is hashed and fed to the binder HMAC. An implementation that computes the binder over the ClientHello with the binders field zeroed, or over the ClientHello as sent with the binders stripped from the end, produces a different value and will be rejected by a conforming peer.

The binder key is derived by a short chain from the PSK:

early_secret      = HKDF-Extract(0, PSK)
binder_key        = Derive-Secret(early_secret, "res binder", "")
finished_key      = HKDF-Expand-Label(binder_key, "finished", "", Hash.length)
binder            = HMAC(finished_key, Transcript-Hash(Truncate(ClientHello1)))

The label res binder distinguishes resumption binders from the ext binder used for externally provisioned PSKs, which is a domain separation decision of the kind described in domain separation and labelled key derivation. The empty context in the Derive-Secret call is forced: at the time the binder is computed there is no transcript to bind to, only the message being written.

The binder therefore commits the client to the identities list, the cipher suites, the key shares, and every other extension in that ClientHello. It does not commit to anything the server sends, because nothing has been sent yet. That asymmetry is the whole security story of early data.

Why replay protection is out of reach

In a full handshake, the server contributes a fresh random and a fresh key share, and the client’s Finished covers both. A recorded handshake cannot be replayed because the server’s random will differ. In a resumption with early data, the client’s first flight is a complete, self-contained message that the server may act on immediately. An attacker who records that flight can send it again, to the same server or a colluding one, and the server has no way to tell the two apart from the bytes alone.

The protocol cannot fix this, and RFC 8446 says so explicitly: 0-RTT data has no replay protection, and applications must either tolerate replay or restrict what can be sent. What the protocol does provide is that the handshake is not compromised. The server’s Finished covers the ClientHello including the binder, so an attacker replaying an early flight gets a session whose later messages it cannot forge. Only the early application data is exposed to replay, and only for the window before the server’s Finished arrives.

Server-side mitigations exist and are all partial. A server can refuse early data entirely, which costs the round trip but restores full protection. It can keep a single-use ticket database, which restores replay protection at the cost of shared state and a lookup per connection, defeating much of the point of stateless resumption. It can accept early data only for idempotent requests, which pushes the decision to the application. Or it can use a mechanism outside the protocol, such as a client-supplied nonce that the server checks, which requires the client to have state and the server to trust it.

The single-use ticket approach has a failure mode worth noting: tickets are issued with a lifetime, and a server that honours a ticket past its lifetime has extended the replay window. The ticket_age_add field exists to let the client report how long it has held the ticket in a way the server can check without storing per-ticket state, which detects a client using a stale ticket but does not prevent a replay of a fresh one.

The age obfuscation, and why it is not a nonce

Each NewSessionTicket carries a random 32-bit ticket_age_add value. The client reports an obfuscated age equal to the true age in milliseconds plus this value, modulo $2^{32}$. The server, which knows the value it issued, subtracts and obtains the true age, and can reject a ticket older than the ticket lifetime or a reported age that is implausibly small for the elapsed time.

TLS 1.3 keeps the NewSessionTicket message introduced for stateless resumption in RFC 5077, but the ticket no longer carries sealed session state that the server unseals; it identifies a pre-shared key that both sides already hold, and the client offers it back by identity in a later ClientHello. The lifetime the earlier specification associates with the ticket is what ticket_age_add lets the server check without per-ticket state.

The obfuscation exists because the age travels in the clear in the ClientHello and would otherwise be a stable per-client fingerprint. It is not a replay defence: an attacker replaying a captured ClientHello replays the same obfuscated age, and the server sees exactly the age the original client reported, which was valid then and remains consistent with the ticket. The field detects a client that has held a ticket too long, and a server with a clock problem, and nothing else.

The truncation rules and their edge cases

Three details of the binder construction are easy to implement incorrectly and produce interoperability failures that are hard to diagnose, because both sides compute a plausible-looking value.

The first is which ClientHello is hashed when a HelloRetryRequest occurs. If the client’s second ClientHello carries a PSK, the binder must cover the synthetic message_hash message followed by the second ClientHello, not the second ClientHello alone. The synthetic message is the same one used for the transcript, described in transcript hashing and handshake binding, so the binder and the transcript are computed from the same accumulated state. A client that recomputes the binder over only its new ClientHello will be rejected.

The second is the placement of the pre_shared_key extension. RFC 8446 requires it to be the last extension in the ClientHello, because its binders cover all the preceding extensions and the truncation rule is defined in terms of the extension’s position. An implementation that appends another extension after it changes the bytes the binder covers in a way the truncation rule does not describe, and a conforming server will either reject the message or compute a different digest.

The third is the relationship between the early data extension and the binder. The early_data extension appears in the ClientHello and, if the client is sending 0-RTT, in the EncryptedExtensions. The binder covers the ClientHello’s copy. The server’s acceptance is signalled in EncryptedExtensions, which arrives after the client has already sent its early records, so the client’s decision to send early data is unilateral and can be wrong. A client that sends early data to a server that then rejects it must be prepared to retransmit the application data after the handshake, and must not treat the early records as delivered.

Sizing the risk

The decision to enable early data is a decision about what an attacker can do with a replayed request. Three properties bound the exposure, and all three are application-level.

The data is encrypted under a key the attacker does not hold, so replay is the only attack. An attacker cannot read or modify the early records; it can only cause the server to process the same plaintext twice.

The key is derived from the PSK and the client’s key share, so the early records of one connection are not decryptable in another connection even if the attacker replays them there. Replay means “the same bytes to the same server”, not “these bytes anywhere”.

The exposure ends when the server’s Finished is processed. After that point the client must not send more early records, and a server that continues to accept them has extended a channel whose key is not the confirmed one.

For requests that create state, the practical rule is that early data must be treated as at-most-once only when the server can deduplicate, and as at-least-once otherwise. A payment or an account creation is not suitable; a cache lookup or a static asset fetch is. Protocols that layer their own anti-replay tokens on top, such as a single-use challenge in the request body, restore protection but reintroduce the server state that resumption was meant to avoid.

References

  1. RFC 8446: The Transport Layer Security (TLS) Protocol Version 1.3 — IETF
  2. RFC 5077: Transport Layer Security (TLS) Session Resumption without Server-Side State — IETF
  3. A Cryptographic Analysis of the TLS 1.3 Handshake Protocol — Dowling, Fischlin, Günther, Stebila, Journal of Cryptology 2021