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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user