From ff8398c693b507230da84ac6865f6951bb4ec00a Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 7 May 2026 18:20:59 -0400 Subject: [PATCH] prep(quic): TLS 0-RTT key derivation + early_data extension encoder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../vitorpamplona/quic/tls/TlsExtension.kt | 14 +++++++++++ .../vitorpamplona/quic/tls/TlsKeySchedule.kt | 25 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsExtension.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsExtension.kt index 1f66ef0a4..2926bccbe 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsExtension.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsExtension.kt @@ -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 diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsKeySchedule.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsKeySchedule.kt index b69504a96..a64d0cb34 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsKeySchedule.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsKeySchedule.kt @@ -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")