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:
+15
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user