From 9bbfe718f907fed875bdc9f2b6bb4e9f9f25e5dd Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 7 May 2026 19:34:17 -0400 Subject: [PATCH] =?UTF-8?q?fix(quic-interop):=20zerortt=20=E2=80=94=20matc?= =?UTF-8?q?h=20wire=20format=20to=20cached=20ALPN;=20requeue=20on=20TLS-re?= =?UTF-8?q?jection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two coupled gaps surfaced when running the runner's zerortt testcase against aioquic (picoquic + quic-go already passed because they fault-tolerate harder). 1) Wire format. The 0-RTT pre-handshake batch was sending raw "GET /\r\n" on bidi streams regardless of ALPN. aioquic's h3 server accepts 0-RTT at the TLS layer (early_data extension echoed in EE) but its h3 layer silently drops bidi streams whose payload isn't a valid HEADERS frame — server log shows N "Stream X created by peer" lines and zero responses. Switch the pre-handshake builder to fork on the cached ALPN: h3 → Http3GetClient (three uni control streams + HEADERS-framed bidi requests via prepareRequests); else → HqInteropGetClient (raw text). The post-handshake side then reuses the pre-handshake client and collects responses via awaitResponse(handle), so 1-RTT replay (after rejection) lands on the right parser. 2) TLS-layer rejection. When the server skips the early_data extension in EncryptedExtensions, the client must replay all in-flight 0-RTT app data through the 1-RTT keys (RFC 9001 §4.6.2). TlsClient now exposes earlyDataAccepted, set in the WAITING_ENCRYPTED_EXTENSIONS branch. QuicConnection's onApplicationKeysReady checks it: if 0-RTT was offered but EE didn't carry early_data, we requeueAllInflightStreamData() + cryptoSend.requeueAllInflight() + sentPackets.clear() BEFORE installing 1-RTT keys, so the next writer drain ships the identical stream/CRYPTO bytes under 1-RTT protection. Same stream handles, same response collection — invisible to the request layer. Result, ./quic/interop/run-matrix.sh -t zerortt: aioquic ✓(Z) picoquic ✓(Z) quic-go ✓(Z) Resumption sweep regression-clean across all three. 334 :quic unit tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../quic/interop/runner/InteropClient.kt | 83 +++++++++++-------- .../quic/connection/QuicConnection.kt | 27 ++++++ .../com/vitorpamplona/quic/tls/TlsClient.kt | 23 +++++ 3 files changed, 97 insertions(+), 36 deletions(-) diff --git a/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/InteropClient.kt b/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/InteropClient.kt index 75ac6ef0e..a2469c68d 100644 --- a/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/InteropClient.kt +++ b/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/InteropClient.kt @@ -34,7 +34,6 @@ import kotlinx.coroutines.async import kotlinx.coroutines.cancel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.toList import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeoutOrNull import java.io.File @@ -876,23 +875,35 @@ private suspend fun runOneResumptionConnection( // get away without the rebuild — the server processes the // requests and replies in 1-RTT after handshake. val zeroRttPlanned = resumption != null && resumption.maxEarlyDataSize > 0 && fetchUrls.isNotEmpty() - val pre0RttHandles = + // ALPN isn't yet renegotiated for THIS connection (we're + // pre-handshake), so use the cached ALPN from the prior + // connection. RFC 9001 §4.6 requires the same ALPN when sending + // 0-RTT data; the wire format MUST match it. aioquic's h3 server + // accepts 0-RTT at the TLS layer but silently drops bidi streams + // whose payload isn't a valid HEADERS frame, so we must + // pre-instantiate the right GetClient (h3 → Http3GetClient with + // its three uni control streams + HEADERS-framed bidi requests; + // hq-interop → raw "GET /\r\n"). + val cachedAlpn = resumption?.negotiatedAlpn?.decodeToString().orEmpty() + val pre0RttClient: GetClient? = if (zeroRttPlanned) { - // ALPN isn't yet renegotiated for THIS connection (we're - // pre-handshake), so use the cached ALPN from the prior - // connection — RFC 9001 §4.6 requires the same ALPN when - // sending 0-RTT data. - val cachedAlpn = resumption!!.negotiatedAlpn?.decodeToString().orEmpty() + when (cachedAlpn) { + "h3" -> Http3GetClient(conn, driver).also { it.init(scope) } + else -> HqInteropGetClient(conn, driver) + } + } else { + null + } + val pre0RttHandles = + if (pre0RttClient != null) { // Pre-handshake stream open works because QuicConnection.init // pre-loaded peerMaxStreamsBidi from the resumption state. - val handles = - conn.openBidiStreamsBatch(fetchUrls.map { "GET ${it.path}\r\n".encodeToByteArray() }) { stream, request -> - stream.send.enqueue(request) - stream.send.finish() - stream - } + // prepareRequests opens N bidi streams under a single + // streamsLock hold so the writer's first 0-RTT drain + // coalesces them into one (or few) packets. + val handles = pre0RttClient.prepareRequests(authority, fetchUrls.map { it.path }) driver.wakeup() - cachedAlpn to handles + handles } else { null } @@ -912,8 +923,13 @@ private suspend fun runOneResumptionConnection( conn.tls.negotiatedAlpn ?.decodeToString() .orEmpty() + // Reuse the pre-handshake client when 0-RTT was attempted — + // its uni control streams (h3 SETTINGS + qpack streams) are + // already opened and either survived 0-RTT or got requeued + // by QuicConnection.onApplicationKeysReady's rejection path + // when the server skipped the early_data extension. val client: GetClient = - when (negotiated) { + pre0RttClient ?: when (negotiated) { "h3" -> { Http3GetClient(conn, driver).also { it.init(scope) } } @@ -930,34 +946,29 @@ private suspend fun runOneResumptionConnection( var anyFailed = false if (pre0RttHandles != null) { - // 0-RTT path: streams already opened and GETs already enqueued - // pre-handshake. Just collect the responses on each stream. - // Server may have accepted the 0-RTT data (responses come back) - // or rejected it; rejection means data was dropped server-side - // and we'd have to resend in 1-RTT, which is real work and not - // wired here. For the runner's zerortt testcase against picoquic - // (which accepts), this path is sufficient. - val (_, handles) = pre0RttHandles - for ((url, stream) in fetchUrls.zip(handles)) { - val body = + // 0-RTT path: streams already opened and requests already + // enqueued pre-handshake. Just collect the responses via the + // ALPN-matching client. If the server rejected 0-RTT at the + // TLS layer, QuicConnection.onApplicationKeysReady has already + // requeued the stream data through the 1-RTT keys — same + // handles, same response collection. + for ((url, handle) in fetchUrls.zip(pre0RttHandles)) { + val resp = withTimeoutOrNull(TRANSFER_TIMEOUT_SEC * 1_000L) { - val chunks = stream.incoming.toList() - val total = chunks.sumOf { it.size } - val buf = ByteArray(total) - var off = 0 - for (c in chunks) { - c.copyInto(buf, off) - off += c.size - } - buf + client.awaitResponse(handle) } - if (body == null || body.isEmpty()) { + if (resp == null || resp.body.isEmpty()) { anyFailed = true System.err.println("[resumption:$iterIdx] 0RTT GET ${url.path} → empty/timeout") continue } + if (resp.status != 200 && resp.status != 0) { + System.err.println("[resumption:$iterIdx] 0RTT GET ${url.path} → status ${resp.status}") + anyFailed = true + continue + } val name = url.path.substringAfterLast('/').ifBlank { "index" } - File(downloadsDir, name).writeBytes(body) + File(downloadsDir, name).writeBytes(resp.body) } } else { for (url in fetchUrls) { 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 0969730de..0228ae728 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt @@ -540,6 +540,33 @@ class QuicConnection( clientSecret: ByteArray, serverSecret: ByteArray, ) { + // RFC 9001 §4.10 — 0-RTT rejection fallback. If we + // offered 0-RTT but the server's EncryptedExtensions + // didn't echo the early_data extension, any application + // data we already shipped under early-data keys was + // silently dropped server-side. Re-queue it so the + // writer replays it under the about-to-be-installed + // 1-RTT keys. Must run BEFORE we install the 1-RTT + // sendProtection — once the writer sees 1-RTT keys + // available it'll start drainOutbound under short + // headers; we want any pending retransmits to flow + // through that path with the original byte content. + // + // requeueAllInflightStreamData walks streamsList under + // the assumption the caller holds streamsLock — which + // we do here because the parser path that fired this + // listener (handleServerFinished → onApplicationKeysReady) + // runs inside streamsLock.withLock { feedDatagram(...) } + // in the read loop. + val rejected0Rtt = + resumption != null && + resumption.maxEarlyDataSize > 0 && + !tls.earlyDataAccepted + if (rejected0Rtt) { + requeueAllInflightStreamData() + application.cryptoSend.requeueAllInflight() + application.sentPackets.clear() + } application.sendProtection = packetProtectionFromSecret(cipherSuite, clientSecret) application.receiveProtection = packetProtectionFromSecret(cipherSuite, serverSecret) // Drop 0-RTT keys — the writer must use 1-RTT short 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 9a33c9336..05175001a 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClient.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClient.kt @@ -166,6 +166,23 @@ class TlsClient( */ private var pskAccepted: Boolean = false + /** + * RFC 8446 §4.2.10 — true after EncryptedExtensions echoes the empty + * `early_data` extension we sent in the resumption ClientHello. + * False (the default) means the server rejected 0-RTT — any + * application data we already sent under early-data keys was + * silently dropped server-side and the QUIC layer must re-queue it + * for retransmission once 1-RTT keys are available. + * + * Read by [com.vitorpamplona.quic.connection.QuicConnection]'s + * onApplicationKeysReady callback to decide whether to invoke + * [com.vitorpamplona.quic.connection.QuicConnection.requeueAllInflightStreamData]. + * Only meaningful on resumption + 0-RTT connections; non-0-RTT + * connections leave it at false and never check it. + */ + var earlyDataAccepted: Boolean = false + private set + /** The 32-byte ClientHello random, available after [start]. Exposed so * observers (e.g. SSLKEYLOGFILE writer) can correlate secrets with * this connection. */ @@ -396,6 +413,12 @@ class TlsClient( } negotiatedAlpn = alpn peerTransportParameters = ee.quicTransportParameters + // RFC 8446 §4.2.10 — server's `early_data` extension in + // EE confirms 0-RTT acceptance. Absence means the server + // ignored / dropped any app data we already sent under + // early-data keys, and the QUIC layer must re-queue it + // for 1-RTT replay. + earlyDataAccepted = ee.extensions.any { it.type == TlsConstants.EXT_EARLY_DATA } transcript.append(msg) state = State.WAITING_CERTIFICATE_OR_FINISHED }