diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt index 8def4a514..cc07833a2 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt @@ -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 diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClient.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClient.kt index d5bdac7b6..9a33c9336 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClient.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClient.kt @@ -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. */ diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClientHello.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClientHello.kt index df77a3361..35d7e7e30 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClientHello.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClientHello.kt @@ -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