fix(quic-interop): zerortt — match wire format to cached ALPN; requeue on TLS-rejection

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 /<path>\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) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-05-07 19:34:17 -04:00
parent 6425dd53d2
commit 9bbfe718f9
3 changed files with 97 additions and 36 deletions
@@ -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
@@ -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
}