prep(quic): wire 0-RTT TLS callback + state slot, pre-writer-refactor

Lays groundwork for full 0-RTT without yet diverging the writer's
application-packet build. Three additive pieces:

- TlsResumptionState carries maxEarlyDataSize (parsed from
  NewSessionTicket's early_data extension) + peerTransportParameters
  + negotiatedAlpn from the prior connection. RFC 9001 §7.4.1
  requires a 0-RTT-sending client to use the REMEMBERED transport
  params (flow-control windows, stream caps) when sending 0-RTT
  data, since the new connection's ServerHello hasn't arrived yet.

- TlsClient.start, on resumption with maxEarlyDataSize > 0:
  derive client_early_traffic_secret via the new
  TlsKeySchedule.deriveEarlyTraffic + post-CH transcript snapshot,
  surface via secretsListener.onEarlyDataKeysReady. Resumption
  ClientHello now also includes the empty `early_data` extension to
  opt into 0-RTT.

- QuicConnection has zeroRttSendProtection slot installed in
  onEarlyDataKeysReady and cleared in onApplicationKeysReady (RFC
  9001 §4.10 — 0-RTT keys MUST NOT be used after 1-RTT installed).

Remaining: writer's buildApplicationPacket needs a dual 0-RTT
long-header (type=0x01) / 1-RTT short-header path; remembered
transport params have to land before any pre-handshake stream
creation so credit is available; EE accept/reject signal must
trigger re-send when the server declines. None of those are wired
yet — this commit is just the TLS-side foundation. 334 unit tests
pass, no behaviour change for non-resumption / non-0-RTT
connections.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-05-07 18:29:42 -04:00
parent ff8398c693
commit b736a953ef
3 changed files with 130 additions and 14 deletions
@@ -245,6 +245,26 @@ class QuicConnection(
@Volatile
internal var previousReceiveProtection: PacketProtection? = null
/**
* RFC 9001 §4.10 — 0-RTT send-side packet protection. Installed when
* the TLS layer derives `client_early_traffic_secret` (right after
* a resumption ClientHello with the early_data extension goes out)
* and cleared when 1-RTT keys arrive (the protocol forbids using
* 0-RTT keys after that point — the next outbound packet must use
* 1-RTT and a short header).
*
* When non-null AND [application]'s 1-RTT [LevelState.sendProtection]
* is null, the writer builds outbound application data as long-
* header 0-RTT packets (type 0x01) using these keys; the packet
* number space is shared with 1-RTT (RFC 9000 §17.2.3).
*
* Receive side is symmetric on the server only — the server never
* sends 0-RTT packets to the client, so we never need a 0-RTT
* receive protection slot.
*/
@Volatile
internal var zeroRttSendProtection: PacketProtection? = null
@Volatile
var handshakeComplete: Boolean = false
private set
@@ -501,6 +521,20 @@ class QuicConnection(
qlogObserver.onKeyUpdated("server", EncryptionLevel.HANDSHAKE)
}
override fun onEarlyDataKeysReady(
cipherSuite: Int,
clientEarlySecret: ByteArray,
) {
// Resumption + 0-RTT path: install 0-RTT packet
// protection so the writer can encrypt outbound
// application data with early-data keys until 1-RTT
// keys arrive. Cleared in onApplicationKeysReady (RFC
// 9001 §4.10 forbids using 0-RTT keys once 1-RTT is
// available).
zeroRttSendProtection = packetProtectionFromSecret(cipherSuite, clientEarlySecret)
qlogObserver.onKeyUpdated("client", EncryptionLevel.APPLICATION)
}
override fun onApplicationKeysReady(
cipherSuite: Int,
clientSecret: ByteArray,
@@ -508,6 +542,9 @@ class QuicConnection(
) {
application.sendProtection = packetProtectionFromSecret(cipherSuite, clientSecret)
application.receiveProtection = packetProtectionFromSecret(cipherSuite, serverSecret)
// Drop 0-RTT keys — the writer must use 1-RTT short
// headers from here on (RFC 9001 §4.10).
zeroRttSendProtection = null
// Stash the live secrets + cipher suite so we can derive
// next-phase keys via HKDF-Expand-Label("quic ku") on demand
// when the peer initiates a key update (RFC 9001 §6). Only
@@ -212,6 +212,7 @@ class TlsClient(
ticket = resumption.ticket,
obfuscatedTicketAge = obfuscatedAge,
binderFinishedKey = binderFinishedKey,
includeEarlyData = resumption.maxEarlyDataSize > 0,
transcriptHashOfPartialCh = { partial ->
// Hash a one-shot copy of the running transcript
// would-be-state: an empty TlsRunningSha256 fed
@@ -245,6 +246,20 @@ class TlsClient(
transcript.append(chBytes)
outboundQueues[Level.INITIAL]!!.addLast(chBytes)
// Resumption + 0-RTT: derive client_early_traffic_secret from
// the early-secret-from-PSK + post-ClientHello transcript and
// hand the secret off so the QUIC layer can install 0-RTT
// packet protection. The server applies the same derivation
// on its side when it processes our PSK-bound ClientHello.
if (resumption != null && resumption.maxEarlyDataSize > 0) {
keySchedule.deriveEarlyTraffic(transcript.snapshot())
secretsListener.onEarlyDataKeysReady(
cipherSuite = resumption.cipherSuite,
clientEarlySecret = keySchedule.clientEarlyTrafficSecret!!,
)
}
state = State.WAITING_SERVER_HELLO
}
@@ -458,6 +473,18 @@ class TlsClient(
if (rms != null) {
val ticket = parseNewSessionTicketBody(bodyReader)
val psk = resumptionPsk(rms, ticket.nonce)
// RFC 8446 §4.2.10 — NewSessionTicket-side
// early_data extension carries uint32
// max_early_data_size. Presence (with size>0)
// signals the server permits 0-RTT for this
// ticket.
val edExt = ticket.extensions.firstOrNull { it.type == TlsConstants.EXT_EARLY_DATA }
val maxEarly =
if (edExt != null && edExt.data.size >= 4) {
QuicReader(edExt.data).readUint32().toLong() and 0xFFFFFFFFL
} else {
0L
}
secretsListener.onNewSessionTicket(
TlsResumptionState(
ticket = ticket.ticket,
@@ -466,6 +493,9 @@ class TlsClient(
ticketAgeAdd = ticket.ticketAgeAdd,
ticketLifetimeSec = ticket.ticketLifetimeSec,
issuedAtMillis = nowMillisSource(),
maxEarlyDataSize = maxEarly,
peerTransportParameters = peerTransportParameters,
negotiatedAlpn = negotiatedAlpn,
),
)
}
@@ -551,6 +581,23 @@ interface TlsSecretsListener {
fun onHandshakeComplete()
/**
* Resumption + 0-RTT path: TLS has derived
* `client_early_traffic_secret` (RFC 8446 §7.1) right after the
* ClientHello transcript snapshot. The QUIC layer can install
* 0-RTT send-side packet protection at this point so subsequent
* outbound application data goes out as 0-RTT (long header
* packet type 0x01) until 1-RTT keys arrive and supersede.
*
* Default no-op so existing callers don't have to know about
* 0-RTT. Fires AT MOST ONCE per connection non-resumption
* connections never derive an early-data secret.
*/
fun onEarlyDataKeysReady(
cipherSuite: Int,
clientEarlySecret: ByteArray,
) = Unit
/**
* Server issued a NewSessionTicket. The TLS layer hands off a
* ready-to-use [TlsResumptionState] capturing everything the next
@@ -599,6 +646,28 @@ data class TlsResumptionState(
val ticketLifetimeSec: Long,
/** Wall-clock millis when the ticket was issued (server time, but we use ours — the obfuscation makes the absolute clock irrelevant). */
val issuedAtMillis: Long,
/**
* RFC 9001 §4.6.1 + RFC 8446 §4.2.10. Non-zero when the issuing
* server signaled in NewSessionTicket's `early_data` extension that
* this ticket may carry up to N bytes of 0-RTT application data on
* the next connection. Zero (the default) means the server didn't
* advertise 0-RTT for this ticket; the client MUST NOT include the
* `early_data` extension on the resumption ClientHello in that case.
*/
val maxEarlyDataSize: Long = 0L,
/**
* Peer's transport parameters from the connection that issued this
* ticket opaque encoded blob from the prior connection's
* EncryptedExtensions. RFC 9001 §7.4.1: a 0-RTT-sending client MUST
* use the REMEMBERED transport parameters (specifically flow-control
* windows and stream caps) when sending 0-RTT data, since the new
* connection's ServerHello hasn't arrived yet so the new params
* aren't known. The QUIC layer applies these to the connection
* before writing 0-RTT packets.
*/
val peerTransportParameters: ByteArray? = null,
/** Negotiated ALPN from the prior connection. 0-RTT must use the same protocol. */
val negotiatedAlpn: ByteArray? = null,
)
/** Pluggable certificate validator. Decoupled so we can stub it in tests. */
@@ -157,23 +157,33 @@ fun buildResumptionClientHelloBytes(
binderFinishedKey: ByteArray,
transcriptHashOfPartialCh: (ByteArray) -> ByteArray,
binderHmac: (key: ByteArray, data: ByteArray) -> ByteArray,
/** When true, include the empty `early_data` extension to opt into 0-RTT. */
includeEarlyData: Boolean = false,
): ByteArray {
val exts =
listOf(
TlsExtension(TlsConstants.EXT_SERVER_NAME, encodeServerNameExtension(serverName)),
TlsExtension(TlsConstants.EXT_SUPPORTED_VERSIONS, encodeSupportedVersionsExtensionClient()),
TlsExtension(TlsConstants.EXT_SUPPORTED_GROUPS, encodeSupportedGroupsX25519()),
TlsExtension(TlsConstants.EXT_SIGNATURE_ALGORITHMS, encodeSignatureAlgorithms()),
TlsExtension(TlsConstants.EXT_KEY_SHARE, encodeKeyShareClientX25519(x25519PublicKey)),
TlsExtension(TlsConstants.EXT_PSK_KEY_EXCHANGE_MODES, encodePskKeyExchangeModesDhe()),
TlsExtension(TlsConstants.EXT_ALPN, encodeAlpn(alpns)),
TlsExtension(TlsConstants.EXT_QUIC_TRANSPORT_PARAMETERS, quicTransportParams),
buildList {
add(TlsExtension(TlsConstants.EXT_SERVER_NAME, encodeServerNameExtension(serverName)))
add(TlsExtension(TlsConstants.EXT_SUPPORTED_VERSIONS, encodeSupportedVersionsExtensionClient()))
add(TlsExtension(TlsConstants.EXT_SUPPORTED_GROUPS, encodeSupportedGroupsX25519()))
add(TlsExtension(TlsConstants.EXT_SIGNATURE_ALGORITHMS, encodeSignatureAlgorithms()))
add(TlsExtension(TlsConstants.EXT_KEY_SHARE, encodeKeyShareClientX25519(x25519PublicKey)))
add(TlsExtension(TlsConstants.EXT_PSK_KEY_EXCHANGE_MODES, encodePskKeyExchangeModesDhe()))
add(TlsExtension(TlsConstants.EXT_ALPN, encodeAlpn(alpns)))
add(TlsExtension(TlsConstants.EXT_QUIC_TRANSPORT_PARAMETERS, quicTransportParams))
if (includeEarlyData) {
// RFC 8446 §4.2.10 — empty body in ClientHello signals
// "I'm about to send 0-RTT data". Goes BEFORE
// pre_shared_key (which must be last per §4.2.11).
add(TlsExtension(TlsConstants.EXT_EARLY_DATA, encodeEarlyDataEmpty()))
}
// pre_shared_key MUST be last (RFC 8446 §4.2.11).
TlsExtension(
TlsConstants.EXT_PRE_SHARED_KEY,
encodePreSharedKeyPlaceholder(ticket, obfuscatedTicketAge),
),
)
add(
TlsExtension(
TlsConstants.EXT_PRE_SHARED_KEY,
encodePreSharedKeyPlaceholder(ticket, obfuscatedTicketAge),
),
)
}
val ch = TlsClientHello(random = random, cipherSuites = cipherSuites, extensions = exts)
val withPlaceholder = ch.encode()
// PartialClientHello = encoded bytes minus the trailing binders block