fix(quic): live interop against aioquic + round-3 audit fixes

🎉 First successful live-interop handshake against a real reference impl.
Drove our pure-Kotlin QUIC client at the aucslab/aioquic-http3-server Docker
container; HANDSHAKE COMPLETE with negotiated ALPN=h3 and full transport
parameter exchange:

  max_data=1048576, max_streams_bidi=128, max_streams_uni=128,
  idle_timeout=60000ms, max_datagram=65536

Every layer worked end-to-end over real UDP: packet codec, header
protection, AES-128-GCM AEAD, TLS 1.3 with X25519, CertificateVerify,
transport parameters, ALPN negotiation. The PermissiveCertificateValidator
+ InteropRunner main + Gradle :quic:interop task make this reproducible
in one command.

Round-3 audit fixes (8 critical bugs caught by 4 parallel reviewers):

1. feedDatagram coalesced-packet skip (RFC 9001 §5.5):
   `?: break` discarded all subsequent coalesced packets if any one of
   them failed to decrypt. Now uses peekHeader to advance over a failed
   packet, only breaking on a totally-unparseable header.

2. Receive-side flow-control enforcement:
   Parser was inserting STREAM frames without checking against the limit
   we advertised. A misbehaving peer could blow past initialMaxStreamDataX
   with no error; now triggers markClosedExternally with FLOW_CONTROL
   diagnostic.

3. Bounded incomingChannel (audit-2 finding finally addressed):
   QuicStream.incomingChannel was Channel.UNLIMITED — slow consumer +
   fast peer = unbounded heap growth. Now Channel(64), bounded by the
   per-stream receive limit. Combined with #2, memory growth is capped.

4. RetryPacket CID length validation:
   parse() didn't bounds-check dcidLen/scidLen against the RFC 9000 §17.2
   1..20 range. Hostile Retry with cidLen=255 → readBytes throws
   QuicCodecException to the caller (instead of returning null for silent
   drop). Added explicit `!in 0..20 → return null`.

5. HelloRetryRequest detection:
   No HRR check — we treated it as a regular ServerHello, derived an
   ECDHE on garbage, then failed AEAD downstream with a confusing error.
   Now checks ServerHello.random against the SHA-256("HelloRetryRequest")
   magic value and throws cleanly.

6. CancellationException handling in WT factory:
   Catch-all wrapped CancellationException as HandshakeFailed, breaking
   structured concurrency. Now rethrows ce after closing the driver.

7. InteropRunner scope leak on timeout:
   parentScope was never cancelled — orphaned coroutines kept the IO
   dispatcher alive after main exited. Now scope.cancel() runs
   unconditionally; small delay gives the driver-launched teardown a
   moment to flush before exit.

8. RFC 9220 §3.1 SETTINGS-before-CONNECT documented as known limitation:
   The strict requirement to wait for SETTINGS_ENABLE_WEBTRANSPORT=1
   before sending CONNECT requires more refactoring (the demux capturing
   peerSettings lives inside QuicWebTransportSessionState, which we don't
   build until after the request bidi opens). Tolerant servers (aioquic,
   quic-go) accept early CONNECT; documented for future strict-RFC fix.

All :quic:jvmTest + :nestsClient:jvmTest pass. Live aioquic interop verified
post-fix: handshake completes, transport parameters round-trip cleanly.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
This commit is contained in:
Claude
2026-04-25 23:21:37 +00:00
parent d2a10c9e4a
commit 2f9a0a0e03
6 changed files with 98 additions and 4 deletions
@@ -121,6 +121,16 @@ class QuicWebTransportFactory(
val controlBytes =
byteArrayOf(Http3StreamType.CONTROL.toByte()) + buildClientWebTransportSettings().encodeFrame()
controlStream.send.enqueue(controlBytes)
driver.wakeup()
// RFC 9220 §3.1 strictly requires waiting for the server's SETTINGS
// frame confirming SETTINGS_ENABLE_WEBTRANSPORT=1 before sending
// CONNECT. Wiring peerSettings to gate the CONNECT requires
// restructuring (the demux lives inside QuicWebTransportSessionState
// which we don't build until after the request bidi opens).
// Tolerant servers (aioquic, quic-go's interop) accept early CONNECT;
// strict servers (Chromium) may close the stream. Tracked as a
// known limitation of the v1 stack.
// Open the Extended CONNECT request stream.
val requestStream = conn.openBidiStream()
@@ -154,6 +164,11 @@ class QuicWebTransportFactory(
return QuicWebTransportSession(state)
} catch (we: WebTransportException) {
throw we
} catch (ce: kotlinx.coroutines.CancellationException) {
// Preserve cancellation semantics — wrapping it in HandshakeFailed
// would break structured concurrency. But still close the driver.
driver.close()
throw ce
} catch (t: Throwable) {
driver.close()
throw WebTransportException(