prep(quic): TLS 0-RTT key derivation + early_data extension encoder

Foundation for the 0-RTT path that follows. Two additive pieces:

- TlsKeySchedule.clientEarlyTrafficSecret + deriveEarlyTraffic
  (transcriptAfterClientHello). RFC 8446 §7.1:
  client_early_traffic_secret = Derive-Secret(early_secret,
  "c e traffic", H(ClientHello)). Driven by the QUIC layer right after
  the resumption ClientHello is appended to the transcript so the
  early-data keys are available for the writer to install before
  ServerHello arrives.

- encodeEarlyDataEmpty for the ClientHello-side early_data extension
  body (empty per RFC 8446 §4.2.10 — its mere presence signals "I'm
  about to send 0-RTT"). NewSessionTicket carries a uint32
  max_early_data_size variant which is parsed but not yet acted on;
  the resumption path doesn't require it.

Wire build, packet protection, and pre-handshake stream creation
follow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-05-07 18:20:59 -04:00
parent fec917d27b
commit ff8398c693
2 changed files with 39 additions and 0 deletions
@@ -178,6 +178,20 @@ fun encodePreSharedKeyPlaceholder(
return w.toByteArray()
}
/**
* Encode the `early_data` extension body. In a ClientHello the body is
* empty (the extension's mere presence signals "I'm sending 0-RTT
* data"). In a NewSessionTicket the body is `uint32 max_early_data_size`.
* In an EncryptedExtensions message the body is empty (server's
* acceptance signal). We only emit the empty form (ClientHello side).
*
* RFC 8446 §4.2.10. Trailing position requirement: it goes WITH the
* pre_shared_key extension in the ClientHello extensions list — we put
* it just before pre_shared_key for symmetry with what aioquic / picoquic
* emit.
*/
fun encodeEarlyDataEmpty(): ByteArray = ByteArray(0)
/** RFC 8446 §4.2.11.2 — SHA-256 binder size for our cipher suites. */
const val BINDER_BYTES: Int = 32
@@ -59,6 +59,15 @@ class TlsKeySchedule(
var serverApplicationSecret: ByteArray? = null
private set
/**
* RFC 8446 §7.1 — `client_early_traffic_secret`, derived from the
* early secret + transcript-up-to-and-including-ClientHello. Used to
* encrypt 0-RTT packets the client sends before ServerHello arrives.
* Only non-null on resumption-with-early-data connections.
*/
var clientEarlyTrafficSecret: ByteArray? = null
private set
/**
* RFC 8446 §7.1 resumption master secret. Derived AFTER client Finished
* is sent (transcript = CH..client.Finished). Used as the input keying
@@ -99,6 +108,22 @@ class TlsKeySchedule(
earlySecret = HKDF.extract(psk, zeros)
}
/**
* Derive the client early-data traffic secret. RFC 8446 §7.1:
*
* client_early_traffic_secret = Derive-Secret(Early Secret,
* "c e traffic", H(ClientHello))
*
* Caller passes the post-ClientHello transcript hash explicitly so
* the schedule doesn't have to track which transcript snapshot is
* needed (this is the binder-substituted ClientHello, exactly the
* bytes the server will hash on its side).
*/
fun deriveEarlyTraffic(transcriptAfterClientHello: ByteArray) {
val es = earlySecret ?: error("call deriveEarlyFromPsk first")
clientEarlyTrafficSecret = deriveSecret(es, "c e traffic", transcriptAfterClientHello)
}
/** Step 2: derive Handshake Secret using ECDHE shared secret. */
fun deriveHandshake(ecdheSharedSecret: ByteArray) {
val early = earlySecret ?: error("call deriveEarly first")