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
@@ -56,8 +56,14 @@ fun feedDatagram(
val first = datagram[offset].toInt() and 0xFF
val isLong = (first and 0x80) != 0
if (isLong) {
val consumed = feedLongHeaderPacket(conn, datagram, offset, nowMillis) ?: break
offset += consumed
// Per RFC 9001 §5.5, drop ONLY the failing packet, not subsequent
// coalesced ones. Use peekHeader to advance over a packet whose
// payload we couldn't decrypt; only break the loop on a header
// that's totally unparseable (then we don't know where the next
// packet starts).
val peeked = LongHeaderPacket.peekHeader(datagram, offset) ?: break
val consumed = feedLongHeaderPacket(conn, datagram, offset, nowMillis)
offset += consumed ?: peeked.totalLength
} else {
// Short-header — consumes the rest of the datagram.
feedShortHeaderPacket(conn, datagram, offset, nowMillis)
@@ -170,6 +176,17 @@ private fun dispatchFrames(
is StreamFrame -> {
ackEliciting = true
val stream = conn.getOrCreatePeerStreamLocked(frame.streamId)
// RFC 9000 §4.1: peer MUST NOT send beyond the limit we advertised.
// We don't enforce per-stream limit here yet (it'd require closing
// with FLOW_CONTROL_ERROR), but we DO enforce connection-level
// bound to prevent unbounded memory growth from a misbehaving peer.
val frameEnd = frame.offset + frame.data.size
if (frameEnd > stream.receiveLimit) {
conn.markClosedExternally(
"peer exceeded stream ${frame.streamId} receive limit ($frameEnd > ${stream.receiveLimit})",
)
return
}
stream.receive.insert(frame.offset, frame.data, frame.fin)
val data = stream.receive.readContiguous()
if (data.isNotEmpty()) {
@@ -61,8 +61,10 @@ data class RetryPacket(
val r = QuicReader(bytes, 1)
val version = r.readUint32().toInt()
val dcidLen = r.readByte()
if (dcidLen !in 0..20) return null // RFC 9000 §17.2 caps CID at 20
val dcid = ConnectionId(r.readBytes(dcidLen))
val scidLen = r.readByte()
if (scidLen !in 0..20) return null
val scid = ConnectionId(r.readBytes(scidLen))
// Retry token consumes everything up to the last 16 bytes (tag).
val tokenLen = bytes.size - r.position - 16
@@ -39,8 +39,16 @@ class QuicStream(
val send = SendBuffer()
val receive = ReceiveBuffer()
/** Bytes received and confirmed contiguous, exposed as a flow to the consumer. */
private val incomingChannel = Channel<ByteArray>(capacity = Channel.UNLIMITED)
/**
* Bytes received and confirmed contiguous, exposed as a flow to the consumer.
*
* Bounded buffer (64 chunks) — combined with the per-stream receive-limit
* enforced in the parser, this caps unbounded memory growth from a slow
* consumer. Producer (the parser) uses [trySend], dropping bytes if the
* channel is full; the receive-limit enforcement makes "channel full" a
* connection-level error long before it becomes a memory problem.
*/
private val incomingChannel = Channel<ByteArray>(capacity = 64)
val incoming: Flow<ByteArray> get() = incomingChannel.consumeAsFlow()
/** Per-stream send credit (peer's MAX_STREAM_DATA value). */
@@ -168,6 +168,13 @@ class TlsClient(
if (type != TlsConstants.HS_SERVER_HELLO) throw QuicCodecException("expected ServerHello, got type=$type")
if (level != Level.INITIAL) throw QuicCodecException("ServerHello must arrive at Initial level")
val sh = TlsServerHello.decodeBody(bodyReader)
// RFC 8446 §4.1.4: HelloRetryRequest is encoded as a ServerHello
// with a fixed magic random. We don't implement HRR (we only
// offer X25519, the only group nests + most servers accept), so
// any HRR is a hard failure.
if (sh.random.contentEquals(HELLO_RETRY_REQUEST_RANDOM)) {
throw QuicCodecException("HelloRetryRequest received but not supported")
}
if (sh.negotiatedVersion != TlsConstants.VERSION_TLS_1_3) {
throw QuicCodecException("server did not negotiate TLS 1.3")
}
@@ -383,3 +390,40 @@ internal class ByteArrayBuilder {
return msg
}
}
/** RFC 8446 §4.1.4 — HelloRetryRequest is a ServerHello whose Random equals SHA-256("HelloRetryRequest"). */
private val HELLO_RETRY_REQUEST_RANDOM: ByteArray =
byteArrayOf(
0xCF.toByte(),
0x21.toByte(),
0xAD.toByte(),
0x74.toByte(),
0xE5.toByte(),
0x9A.toByte(),
0x61.toByte(),
0x11.toByte(),
0xBE.toByte(),
0x1D.toByte(),
0x8C.toByte(),
0x02.toByte(),
0x1E.toByte(),
0x65.toByte(),
0xB8.toByte(),
0x91.toByte(),
0xC2.toByte(),
0xA2.toByte(),
0x11.toByte(),
0x16.toByte(),
0x7A.toByte(),
0xBB.toByte(),
0x8C.toByte(),
0x5E.toByte(),
0x07.toByte(),
0x9E.toByte(),
0x09.toByte(),
0xE2.toByte(),
0xC8.toByte(),
0xA8.toByte(),
0x33.toByte(),
0x9C.toByte(),
)
@@ -28,6 +28,7 @@ import com.vitorpamplona.quic.transport.UdpSocket
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
@@ -100,8 +101,15 @@ fun main(args: Array<String>) {
driver.close()
} catch (_: Throwable) {
}
// Give the close-launched teardown a moment to actually run, so we
// don't return from runBlocking with the socket still bound.
kotlinx.coroutines.delay(50)
outcome
}
// Cancel the parent scope so any orphaned coroutines (e.g. driver teardown)
// are torn down before main() exits. Without this the JVM hangs on stray
// IO-dispatcher threads.
scope.cancel()
when (outcome) {
is InteropOutcome.Connected -> {