Internet Protocols · QUIC and HTTP/3

The QUIC Handshake: TLS 1.3 Without a Record Layer

How CRYPTO frames carry the TLS handshake inside the transport, why encryption levels change mid-handshake, and how Retry forces address validation before a certificate is sent.

QUIC uses TLS 1.3 for authentication and key agreement, but not for framing. There is no TLS record layer on the wire: handshake messages are carried in CRYPTO frames, which are ordinary transport frames with a stream-like offset, and the encryption is applied by the transport’s own packet protection. The handshake still runs in the order TLS 1.3 defines, and the key schedule is the standard one, but the transport controls when keys become available and which packets may use them.

One handshake, four encryption levels

The packet protection keys are derived not from the TLS exporter alone but from a hierarchy. An initial key is derived from a fixed salt combined with the client’s destination connection ID, which is the random value the client chose for its first flight. That key is not secret in any meaningful sense: anyone who observed the connection ID can derive it. It exists so that the first flight has a consistent format and so that a passive observer cannot trivially read the handshake, not because it provides security.

After the TLS handshake begins, keys are derived in sequence: 0-RTT keys, available to the client before the handshake starts if it has a cached session; Handshake keys, available once the TLS handshake messages establishing them have been exchanged; and 1-RTT keys, available once the handshake completes. Each level has its own packet type, its own key, and its own rules about what may be carried.

The levels are what make the handshake incremental. A packet can be sent at the Handshake level while earlier Initial packets are still being retransmitted, and a receiver that has the Handshake keys can process it without waiting for the Initial data to be complete. Because each encryption level is a separate sequence space with its own packet numbers and its own acknowledgement ranges, loss recovery works per level, and a lost Initial does not hold up a Handshake packet that arrived intact.

The Initial key derivation uses a fixed salt of 20 bytes and the client’s destination connection ID as the input keying material, run through HKDF-Extract and then HKDF-Expand to produce the client and server keys and IVs. Because the salt is fixed and public, a middlebox can derive the keys and read the Initial packets, which is deliberate: it allows network operators to observe the handshake and to generate the transport parameters used for things like connection-ID routing, without giving them the ability to read anything else.

CRYPTO frames are not streams

A CRYPTO frame carries an offset, a length, and bytes. The offset space is per encryption level, and the bytes at a given level form a single ordered stream of handshake messages. Unlike STREAM frames, CRYPTO frames are not subject to flow control, because the handshake has to complete before flow control can be meaningfully negotiated, and they are not associated with a stream ID.

Retransmission works differently too. A CRYPTO frame is retransmitted when it is lost, but the transport does not have to send exactly the same bytes: the offset identifies the position in the handshake message sequence, and a sender may split or coalesce as convenient. The receiver reassembles by offset and passes complete handshake messages to TLS, which processes them in order.

Because CRYPTO frames are not flow controlled, out-of-order handshake data has no advertised bound, and the transport instead sets a floor: an implementation must be able to buffer at least 4,096 bytes received out of order across CRYPTO frames. An endpoint that cannot buffer that much during the handshake must close the connection with CRYPTO_BUFFER_EXCEEDED. The buffer holds reordering, not the whole exchange, but it is the transport’s only defence against unbounded memory use before flow control exists.

The exchange, step by step

The client sends an Initial packet containing a TLS ClientHello in a CRYPTO frame, padded so the datagram is at least 1,200 bytes. The padding is required for two reasons: it gives the server an anti-amplification budget large enough for its certificate, and it verifies that the path carries a full-size datagram.

The server responds with an Initial packet carrying ServerHello, and possibly with Handshake packets carrying EncryptedExtensions, Certificate, CertificateVerify, and Finished. It may also send a Retry before any of that. The client then sends its own Handshake-level Finished, and once the server has verified it, the handshake is complete at the server, which sends HANDSHAKE_DONE. The client discards its Initial keys as soon as it sends its first Handshake packet, and both endpoints discard the Handshake keys once the handshake is confirmed, which leaves 1-RTT as the only level in use.

HANDSHAKE_DONE is not part of TLS. It is a transport frame that confirms the handshake at the client, and the client is required to discard its Handshake keys once confirmation arrives. The server treats its own completion of the TLS handshake as confirmation and must send the frame as soon as that happens. The frame is sent at the 1-RTT level, so receiving it also proves that the server holds the 1-RTT keys, and a client may alternatively treat the acknowledgement of any 1-RTT packet it sent as confirmation.

The server’s certificate is the largest item in the exchange, and it arrives before the client has proved it can receive anything. That is the reason for the three-times amplification limit, and the reason Retry exists.

Retry, tokens, and the cost of validating twice

A server that receives an Initial from an address it has not validated has two options. It can proceed with the handshake and accept that it may be sending a certificate to a spoofed address, subject to the amplification limit. Or it can send a Retry packet, which contains a token and a sixteen-byte integrity tag but no encrypted payload, and wait for the client to repeat its Initial with the token included.

The token is opaque to the client. The server typically makes it a authenticated encryption of the client’s address and a timestamp, so that when the client returns with it, the server can verify that the address is the one it originally saw without keeping per-attempt state. A token that has been issued but never used costs the server nothing.

Retry is not free, and its cost lands on the connection IDs the handshake establishes, which connection IDs and path migration describes as the basis for routing after the handshake. The client must discard its first Initial and send a new one, which costs a round trip, and the client must recompute its Initial keys because the destination connection ID in the new Initial may differ. A server that sends Retry for every connection adds a full round trip to every handshake, which is why it is normally reserved for cases where address validation matters more than latency, such as a server under amplification attack.

The integrity tag in the Retry packet is computed over a pseudo-packet formed from the client’s original destination connection ID, the server’s source connection ID, and the token, using a key derived from the original connection ID. This lets the client verify that the Retry came from something that saw its original Initial, which prevents an off-path attacker from injecting a Retry that would send the client to a different server.

A NEW_TOKEN frame serves a related purpose without the round trip. The server can issue a token in a 1-RTT packet, and the client can present it in the Initials of a later connection, allowing the server to skip validation for that new connection. A token obtained from Retry must not be used this way; it applies only to the attempt that triggered it. A NEW_TOKEN token can be used after an arbitrary delay, so the specification asks that it carry an expiry, and it must not embed anything, such as the original connection ID, that would let an observer link the two connections.

What 0-RTT can and cannot do

If the client has a session ticket from a previous connection, it can send 0-RTT data immediately, encrypted with keys derived from the ticket. This is the only way to get a request onto the wire before the handshake completes, and it costs a full handshake’s worth of latency saved.

The constraint is that 0-RTT data is not forward secret and is replayable: an attacker who captures the 0-RTT flight can send it again, and the server will process it as if it were new. The specification therefore restricts what 0-RTT may carry to requests that are safe to repeat, and the server indicates in its transport parameters and in the TLS extension which it will accept. A server that does not want to handle replay at the application layer simply refuses 0-RTT, and the client falls back to sending after the handshake.

The client must also be careful about which streams it puts in 0-RTT. The stream IDs used in 0-RTT are the same ones the connection will use later, so a stream opened in 0-RTT and then reset after the handshake must be handled consistently. This is one reason the stream ID space is partitioned by initiator rather than allocated dynamically: a client can open streams before it knows anything about the server’s state, because the numbering scheme does not depend on it.

The transport parameters ride along

The TLS handshake carries QUIC’s transport parameters as a TLS extension. These include the initial flow-control limits, the maximum number of streams, the active connection ID limit, the maximum UDP payload size the endpoint is willing to receive, and whether 0-RTT is enabled. They are authenticated by the handshake, which is what makes them trustworthy: an attacker cannot raise a peer’s limits by modifying a packet.

The parameters are also the mechanism by which a connection’s initial conditions are set, and they are why a QUIC connection has no separate negotiation phase. Everything that would be a socket option in TCP is a signed value in the handshake, and a mismatch between what an endpoint advertises and what it enforces is a protocol error rather than a performance surprise. The credit that these parameters establish is consumed and replenished by the MAX_* frames described in connection and stream flow control.

One asymmetry is worth naming. The client’s transport parameters are sent in its Initial, before it knows the server’s, so a client that wants a large stream limit cannot condition it on the server’s willingness. The server can, however, send a smaller limit and the client must respect it, because the limits are directional: each endpoint states what it will accept, and the other endpoint’s sends are bounded by that statement.

References

  1. RFC 9001: Using TLS to Secure QUIC — IETF
  2. RFC 8446: The Transport Layer Security (TLS) Protocol Version 1.3 — IETF
  3. RFC 9000: QUIC: A UDP-Based Multiplexed and Secure Transport — IETF