skip to content

Turnstile readers send 0-RTT early data to whichever server answers. Which RFC 8446 anti-replay defence survives a replay landing on a different server?

level: seniorimportance: should knowfreq 54%

answer

  1. the whole first flight is copyable
  2. the binder replays intact with it
  3. three defences, two of them stateful
  4. state must span everyone who can open it
  5. freshness bounds the window, nothing more

basics

~20 s

None survives on its own. Single-use tickets and ClientHello recording both need state shared by every server that can open the ticket; a freshness check only bounds how long a copied flight stays usable. Partitioning ticket-opening per server is the alternative.

solid answer

~50 s

A first flight carrying early data is self-contained: the copied `ClientHello`, its `PskBinderEntry` and the early records all verify again, because the binder is a MAC over those very bytes. RFC 8446 offers three defences and a server that accepts 0-RTT must implement one. Single-use tickets invalidate the ticket on first use, which needs a used-ticket record visible to every server that can open it. `ClientHello` recording keeps a unique value from each accepted hello and rejects duplicates - same requirement, same scope. Freshness checks compare the age recovered from `obfuscated_ticket_age` against elapsed time and reject outside a window; alone they bound the replay window rather than closing it, and they are what keeps the recording finite. Across independent servers, either share the state as widely as the ability to open a ticket, or scope ticket-opening so a replay elsewhere simply falls back to a full handshake.

code

pseudocode · 15 lines
pseudocode
on ClientHello offering early_data:
    state = open ticket(identity.ticket)
    age   = (identity.obfuscated_ticket_age - state.ticket_age_add) mod 2^32

    if absolute(age - elapsed_since_issue) > allowed_window:
        reject early data                 // continue the 1-RTT handshake

    else if replay_store holds unique_value(ClientHello):
        reject early data                 // continue the 1-RTT handshake

    else:
        replay_store.add(unique_value(ClientHello), expires after allowed_window)
        accept early data, at most max_early_data_size bytes

// rejecting is never fatal: the client resends after Finished

go deeper

for a junior

Know that data sent before the handshake finishes can be captured and sent again, so it must never be the only copy of an action that has to happen once.

for a middle

Explain why the copied flight still verifies, and name the three defences: invalidating a used ticket, recording the hello, and checking the claimed age.

for a senior

Reason about where the state for those defences has to live relative to which servers can open a ticket, and about designing so a rejected or duplicated flight is harmless.

for a principal

Weigh a hot-path consistent read across the estate against narrowing which servers can open a ticket, and decide whether one round trip is worth either.

## Why the first flight is copyable at all When a ticket was issued with an `early_data` extension carrying a `max_early_data_size`, a resuming client may put `early_data(42)` in its `ClientHello` and immediately send application records protected by keys derived from the pre-shared key alone. It closes that stream with `EndOfEarlyData` once the server's flight arrives. Everything in that flight is a pure function of the ticket and the client's state. Nothing in it depends on anything the server said in *this* exchange - there has been no server message yet. So an attacker who records the bytes can send exactly those bytes again, later, to any server able to open the ticket. The `PskBinderEntry` does not help: it is a MAC over that hello, and a verbatim copy carries a verbatim-valid binder. It proves possession; it never proved freshness. The attacker does not even need to finish the handshake. If the server hands early data to the application on arrival, the effect has already happened. ## The three defences RFC 8446 names 1. **Single-use tickets.** The server records that a ticket has been used for 0-RTT and refuses it a second time. It is the cleanest rule and the one with the strongest state requirement: the record has to be consulted by every server that can open that ticket, before the early data is acted on. 2. **ClientHello recording.** The server stores a unique value taken from each accepted hello and rejects a duplicate within the window in which that hello could still be valid. This permits a ticket to be used from more than one client attempt while still killing exact replays - and needs the same shared visibility. 3. **Freshness checks.** The server subtracts the `ticket_age_add` it issued from the offered `obfuscated_ticket_age`, obtaining the client's claimed age, and compares it with the time actually elapsed since it issued that ticket. Outside a tolerance for network delay and clock skew, the early data is refused. ## What each one does and does not do | Defence | Stops an exact replay? | State it needs | Where it breaks | |---|---|---|---| | Single-use tickets | yes, after the first use | used-ticket record, fleet-wide | a second server with no view of the record | | ClientHello recording | yes, within the window | recorded values, fleet-wide | the same, plus memory if the window is long | | Freshness check | no - it shortens the window | the issuing server's own clock | a replay arriving inside the window | The third row is the one candidates get wrong. A freshness check is not a replay defence on its own; it is what makes the other two affordable, by bounding how long a recorded value must be kept. ## The fleet problem, stated plainly Sealed, key-wrapped tickets are what let any front end resume - and that is exactly what lets a replay succeed at a front end that never saw the original. The capability to accept is the capability to be replayed at, and the two can only be separated in two ways: - **Share the state as widely as the capability.** A used-ticket or hello record that every accepting server consults before acting on early data. That is a consistent read in the hot path, which is a real cost at turnstile volumes. - **Narrow the capability.** Scope ticket-opening so that only one server, or one small group, can open a given ticket. A replay sent anywhere else simply fails to resume and falls back to a full handshake - and the client, which has to tolerate rejection anyway, loses only the 0-RTT saving. ## What the application still owes Even with a defence in place, two things remain true and both belong in the design, not in the TLS layer: - early data may be **rejected**, in which case the server omits `early_data` from its `EncryptedExtensions` and the client resends the data after the handshake - normal, and it must be harmless; - early data may be **duplicated** before any defence catches it, so anything with a side effect that must happen once has no business in a first flight. A payment authorisation at a turnstile is the clearest case: a replayed flight is a second authorisation. ## The secrecy half of the bargain Those first records are protected by keys derived from the pre-shared key alone - no fresh exchange has happened yet, and cannot have. If the resumption offered a `key_share(51)` with `psk_dhe_ke`, that share protects the traffic *after* the handshake completes; it does nothing for the early records. So 0-RTT trades away both replay protection and the freshness of the key protecting that flight, and it buys one round trip.

  • Why does the binder in a replayed ClientHello fail to detect the replay?
    Because it is a MAC over that hello, keyed from the pre-shared key. A verbatim copy therefore carries a verbatim-valid binder. The binder was only ever a proof of possession and of binding to those bytes; nothing in it is unique to a point in time or to one attempt.
  • What happens when a server declines the offered early data?
    It omits early_data from its EncryptedExtensions, skips the early records it cannot or will not process, and completes the 1-RTT handshake; the client sends that data again afterwards. Rejection is an ordinary outcome, so a client that cannot resend safely should not have offered 0-RTT.
  • Does early data get the same protection as the rest of the resumed connection?
    No. Those records are keyed from the pre-shared key alone, because no server message has arrived yet. If the resumption offered a fresh key_share(51), that share protects only the traffic after the handshake completes, not the early flight.

saying these in an interview costs you the question

  • Says the binder makes a replayed first flight detectable
  • Claims 0-RTT is safe as long as requests are idempotent
  • Thinks single-use tickets stop replay in any deployment
  • Treats a freshness check alone as a replay defence
  • Believes early data has the same key freshness as later traffic
  • Assumes rejecting early data breaks the connection