diff --git a/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/QuicWebTransportFactory.kt b/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/QuicWebTransportFactory.kt index 783ac3117..3a9af28d1 100644 --- a/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/QuicWebTransportFactory.kt +++ b/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/QuicWebTransportFactory.kt @@ -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( diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt index 23507a514..2a42e1e11 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt @@ -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()) { diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/packet/RetryPacket.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/packet/RetryPacket.kt index acdb145fb..b3e78a76e 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/packet/RetryPacket.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/packet/RetryPacket.kt @@ -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 diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/stream/QuicStream.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/stream/QuicStream.kt index 1918c87bb..c99868d8f 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/stream/QuicStream.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/stream/QuicStream.kt @@ -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(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(capacity = 64) val incoming: Flow get() = incomingChannel.consumeAsFlow() /** Per-stream send credit (peer's MAX_STREAM_DATA value). */ diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClient.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClient.kt index e6aef63d7..dccedefad 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClient.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClient.kt @@ -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(), + ) diff --git a/quic/src/jvmTest/kotlin/com/vitorpamplona/quic/interop/InteropRunner.kt b/quic/src/jvmTest/kotlin/com/vitorpamplona/quic/interop/InteropRunner.kt index d33c7fc32..514df4d5d 100644 --- a/quic/src/jvmTest/kotlin/com/vitorpamplona/quic/interop/InteropRunner.kt +++ b/quic/src/jvmTest/kotlin/com/vitorpamplona/quic/interop/InteropRunner.kt @@ -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) { 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 -> {