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 =
|
val controlBytes =
|
||||||
byteArrayOf(Http3StreamType.CONTROL.toByte()) + buildClientWebTransportSettings().encodeFrame()
|
byteArrayOf(Http3StreamType.CONTROL.toByte()) + buildClientWebTransportSettings().encodeFrame()
|
||||||
controlStream.send.enqueue(controlBytes)
|
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.
|
// Open the Extended CONNECT request stream.
|
||||||
val requestStream = conn.openBidiStream()
|
val requestStream = conn.openBidiStream()
|
||||||
@@ -154,6 +164,11 @@ class QuicWebTransportFactory(
|
|||||||
return QuicWebTransportSession(state)
|
return QuicWebTransportSession(state)
|
||||||
} catch (we: WebTransportException) {
|
} catch (we: WebTransportException) {
|
||||||
throw we
|
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) {
|
} catch (t: Throwable) {
|
||||||
driver.close()
|
driver.close()
|
||||||
throw WebTransportException(
|
throw WebTransportException(
|
||||||
|
|||||||
+19
-2
@@ -56,8 +56,14 @@ fun feedDatagram(
|
|||||||
val first = datagram[offset].toInt() and 0xFF
|
val first = datagram[offset].toInt() and 0xFF
|
||||||
val isLong = (first and 0x80) != 0
|
val isLong = (first and 0x80) != 0
|
||||||
if (isLong) {
|
if (isLong) {
|
||||||
val consumed = feedLongHeaderPacket(conn, datagram, offset, nowMillis) ?: break
|
// Per RFC 9001 §5.5, drop ONLY the failing packet, not subsequent
|
||||||
offset += consumed
|
// 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 {
|
} else {
|
||||||
// Short-header — consumes the rest of the datagram.
|
// Short-header — consumes the rest of the datagram.
|
||||||
feedShortHeaderPacket(conn, datagram, offset, nowMillis)
|
feedShortHeaderPacket(conn, datagram, offset, nowMillis)
|
||||||
@@ -170,6 +176,17 @@ private fun dispatchFrames(
|
|||||||
is StreamFrame -> {
|
is StreamFrame -> {
|
||||||
ackEliciting = true
|
ackEliciting = true
|
||||||
val stream = conn.getOrCreatePeerStreamLocked(frame.streamId)
|
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)
|
stream.receive.insert(frame.offset, frame.data, frame.fin)
|
||||||
val data = stream.receive.readContiguous()
|
val data = stream.receive.readContiguous()
|
||||||
if (data.isNotEmpty()) {
|
if (data.isNotEmpty()) {
|
||||||
|
|||||||
@@ -61,8 +61,10 @@ data class RetryPacket(
|
|||||||
val r = QuicReader(bytes, 1)
|
val r = QuicReader(bytes, 1)
|
||||||
val version = r.readUint32().toInt()
|
val version = r.readUint32().toInt()
|
||||||
val dcidLen = r.readByte()
|
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 dcid = ConnectionId(r.readBytes(dcidLen))
|
||||||
val scidLen = r.readByte()
|
val scidLen = r.readByte()
|
||||||
|
if (scidLen !in 0..20) return null
|
||||||
val scid = ConnectionId(r.readBytes(scidLen))
|
val scid = ConnectionId(r.readBytes(scidLen))
|
||||||
// Retry token consumes everything up to the last 16 bytes (tag).
|
// Retry token consumes everything up to the last 16 bytes (tag).
|
||||||
val tokenLen = bytes.size - r.position - 16
|
val tokenLen = bytes.size - r.position - 16
|
||||||
|
|||||||
@@ -39,8 +39,16 @@ class QuicStream(
|
|||||||
val send = SendBuffer()
|
val send = SendBuffer()
|
||||||
val receive = ReceiveBuffer()
|
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()
|
val incoming: Flow<ByteArray> get() = incomingChannel.consumeAsFlow()
|
||||||
|
|
||||||
/** Per-stream send credit (peer's MAX_STREAM_DATA value). */
|
/** 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 (type != TlsConstants.HS_SERVER_HELLO) throw QuicCodecException("expected ServerHello, got type=$type")
|
||||||
if (level != Level.INITIAL) throw QuicCodecException("ServerHello must arrive at Initial level")
|
if (level != Level.INITIAL) throw QuicCodecException("ServerHello must arrive at Initial level")
|
||||||
val sh = TlsServerHello.decodeBody(bodyReader)
|
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) {
|
if (sh.negotiatedVersion != TlsConstants.VERSION_TLS_1_3) {
|
||||||
throw QuicCodecException("server did not negotiate TLS 1.3")
|
throw QuicCodecException("server did not negotiate TLS 1.3")
|
||||||
}
|
}
|
||||||
@@ -383,3 +390,40 @@ internal class ByteArrayBuilder {
|
|||||||
return msg
|
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.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
|
||||||
@@ -100,8 +101,15 @@ fun main(args: Array<String>) {
|
|||||||
driver.close()
|
driver.close()
|
||||||
} catch (_: Throwable) {
|
} 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
|
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) {
|
when (outcome) {
|
||||||
is InteropOutcome.Connected -> {
|
is InteropOutcome.Connected -> {
|
||||||
|
|||||||
Reference in New Issue
Block a user