diff --git a/quic/plans/2026-04-26-quic-stack-status.md b/quic/plans/2026-04-26-quic-stack-status.md index d2d8e8145..b3140f41f 100644 --- a/quic/plans/2026-04-26-quic-stack-status.md +++ b/quic/plans/2026-04-26-quic-stack-status.md @@ -257,10 +257,14 @@ not tackled: validation per RFC 9000 §8.2 + §9. See `PathValidator.kt` and the three test files (`PathValidatorTest`, `PathValidationTest`, `ClientPathMigrationTest`). -5. **Stateless reset detection.** Stateless-reset tokens are now - *recorded* (per `PeerConnectionIdEntry`) but no inbound check - compares "looks-like-noise" datagrams against the token list. RFC - 9000 §10.3. +5. ~~**Stateless reset detection.**~~ **Resolved 2026-05-09** — + `QuicConnection.isStatelessReset` matches every short-header-form + datagram's trailing 16 bytes against the peer's transport-param + token AND every NEW_CONNECTION_ID token retained in + `PathValidator.allKnownStatelessResetTokens` (lifetime store — + covers the WiFi-handoff case where the migrated CID's token would + otherwise be lost the moment migration started). Constant-time per + §10.3.1; silent CLOSED transition on match. 6. ~~**`AckTracker.purgeBelow` threshold semantics.**~~ **Resolved 2026-05-05.** 7. **Driver direct unit tests** require turning `UdpSocket` from `expect @@ -292,7 +296,7 @@ file:line evidence. Severity: | RFC 9000 §4.1 | ~~🟡~~ | ~~**Connection-level inbound flow control missing.**~~ **Resolved 2026-05-09** — `QuicStream.receiveHighestOffset` + `QuicConnection.connectionInboundOffsetSum` track the §4.1 spec quantity (sum of largest-received-offset across streams). Parser closes with FLOW_CONTROL_ERROR when the sum exceeds `advertisedMaxData`. RESET_STREAM finalSize counted toward the limit per §4.5. Tests in `ConnectionLevelFlowControlTest`. | | RFC 9000 §3 | ~~🟡~~ | ~~**Stream state machine implicit, not validated.**~~ **Resolved 2026-05-09** — `QuicStream.peerResetReceived` latches when a RESET_STREAM arrives; the parser closes with STREAM_STATE_ERROR on any subsequent STREAM frame for the same id. (Other state transitions — FIN-size mismatch, illegal peer-initiated opens — were already caught.) Tests in `StreamAfterResetTest`. | | RFC 9000 §10.2 | ~~🟡~~ | ~~**No DRAINING state.**~~ **Resolved 2026-05-09** — `Status.DRAINING` added; parser routes peer's CONNECTION_CLOSE through `enterDraining` (sets status + 3 × PTO deadline) instead of immediate CLOSED. Driver folds the deadline into its send-loop sleep and flips to CLOSED on expiry. Late inbound during DRAINING is silently dropped at the top of `feedDatagramInner`. Tests in `DrainingStateTest`. | -| RFC 9000 §10.3 | ~~🟡~~ | ~~**Stateless reset detection missing.**~~ **Resolved 2026-05-09** — `QuicConnection.isStatelessReset` checks the trailing 16 bytes of every short-header-form datagram against the peer's `statelessResetToken` AND every unused entry in `pathValidator`'s pool, in constant time per §10.3.1. On match, silently transitions to CLOSED. Tests in `StatelessResetDetectionTest`. | +| RFC 9000 §10.3 | ~~🟡~~ | ~~**Stateless reset detection missing.**~~ **Resolved 2026-05-09** — `QuicConnection.isStatelessReset` checks the trailing 16 bytes of every short-header-form datagram against the peer's `statelessResetToken` AND every NEW_CONNECTION_ID token in `PathValidator.allKnownStatelessResetTokens` (lifetime store — extends past CID rotation / retirement so WiFi↔cellular handoffs still detect reset on the new path), in constant time per §10.3.1. On match, silently transitions to CLOSED. Tests in `StatelessResetDetectionTest` (7 cases including handoff + force-rotation). | | RFC 9001 §6.6 | ~~🟡~~ | ~~**AEAD invocation limit not tracked.**~~ **Resolved 2026-05-09** — `Aead.confidentialityLimit` / `integrityLimit` properties surface the §B.1 values; `QuicConnection.aeadEncryptCount` / `aeadDecryptFailureCount` track per-key usage and reset on rotation. Writer triggers a key update at half the confidentiality limit and closes with AEAD_LIMIT_REACHED if rotation can't complete; parser closes if decrypt failures hit the integrity limit. Tests in `AeadInvocationLimitTest`. | ### Transport-parameter bounds @@ -310,7 +314,7 @@ file:line evidence. Severity: |---|---|---|---| | RFC 9114 §7.2.4.1 | ~~🟡~~ | ~~**HTTP/2 reserved SETTINGS ids 0x02/0x03/0x04/0x05 not rejected.**~~ **Resolved 2026-05-09** — `Http3Settings.decodeBody` raises H3_SETTINGS_ERROR for ids 0x02..0x05. Tests in `Http3ReservedSettingsTest`. | | RFC 9221 §3 | ~~🟡~~ | ~~**Outbound DATAGRAM size not gated.**~~ **Resolved 2026-05-09** — writer drops outbound DATAGRAM frames when peer didn't advertise `max_datagram_frame_size` or when the encoded frame would exceed the advertised value. Diagnostic via `qlogObserver.onPacketDropped`. | -| RFC 9114 §6.2.1 | 🟦 | **Closing the control stream surfaces a flag rather than auto-closing.** Spec says closing the control stream is H3_CLOSED_CRITICAL_STREAM. Caller must poll `peerH3ProtocolError` and close. | `Http3FrameReader.kt`; `WtPeerStreamDemux.kt` | +| RFC 9114 §6.2.1 + RFC 9204 §4.2 | ~~🟦~~ | ~~**Closing the control stream surfaces a flag rather than auto-closing.**~~ **Resolved 2026-05-09** — `WtPeerStreamDemux` now drives `connection.close(errorCode, reason)` itself when ANY critical unidirectional stream closes (control + QPACK encoder + QPACK decoder), whether by clean FIN (H3_CLOSED_CRITICAL_STREAM) or HTTP/3 protocol violation (mapped to the specific §8.1 code where possible). New `Http3ErrorCode` constants. Tests in `CriticalStreamClosureTest` (5 cases) cover FIN-on-control, FIN-on-QPACK, MISSING_SETTINGS, FRAME_UNEXPECTED, and idempotency. Audio rooms now get an immediate disconnect event when the relay drops the control stream mid-session. | ### TLS / error reporting cosmetics diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/PathValidator.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/PathValidator.kt index 3b0c38512..57fdc88e7 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/PathValidator.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/PathValidator.kt @@ -289,6 +289,48 @@ class PathValidator( */ fun unusedTokenForSequence(sequenceNumber: Long): ByteArray? = unusedCids[sequenceNumber]?.statelessResetToken + /** + * RFC 9000 §10.3 stateless-reset support, audio-rooms WiFi-handoff + * extension. Every stateless-reset token the peer has ever issued + * us via NEW_CONNECTION_ID — whether the corresponding CID is + * still in [unusedCids], currently active (rotated to via + * [tryStartValidation] / [forceRotateToHigherSequence]), or + * RETIRE_CONNECTION_ID'd. + * + * Why retain after rotation/retire: when the user roams between + * WiFi and cellular, we migrate the connection to a fresh DCID + * via [tryStartValidation]. If the peer (relay) loses connection + * state during the handoff (crash, restart, NAT rebinding), it + * sends a stateless reset using whichever of OUR-issued source + * CIDs it last had cached — which corresponds to whichever + * stateless-reset token it remembers issuing for that CID. We + * MUST be able to match the reset against the right token. If we + * only kept tokens for CIDs in the unused pool (the pre-fix + * shape), the rotated-to active CID's token would be lost the + * moment migration started — a stateless reset on the new path + * would look like noise and we'd hang until idle timeout. + * + * §10.3.1 explicitly allows endpoints to keep tokens after + * retirement: "An endpoint MAY drop a Stateless Reset Token + * after retiring the corresponding connection ID; this saves + * storage at the cost of being more vulnerable to spoofing if + * the same token is reused by an attacker." For audio-rooms the + * extra ~16 bytes per peer-issued CID is trivial. + * + * Returned in insertion order (oldest first); caller treats it + * as an unordered set for matching. + */ + fun allKnownStatelessResetTokens(): List = knownStatelessResetTokens + + /** + * Lifetime store of every stateless-reset token the peer has + * ever advertised via NEW_CONNECTION_ID. Append-only — entries + * are NOT removed when the corresponding CID is rotated out of + * [unusedCids] or RETIRE_CONNECTION_ID'd. See + * [allKnownStatelessResetTokens] for the rationale. + */ + private val knownStatelessResetTokens: ArrayList = ArrayList() + /** * Record a peer-issued `NEW_CONNECTION_ID` (RFC 9000 §19.15). * Returns the result code so the caller (parser) can decide @@ -375,12 +417,19 @@ class PathValidator( if (unusedCids.size >= maxUnusedCids) return RecordResult.PoolFull + val tokenCopy = statelessResetToken.copyOf() unusedCids[sequenceNumber] = PeerConnectionIdEntry( sequenceNumber = sequenceNumber, connectionId = connectionId.copyOf(), - statelessResetToken = statelessResetToken.copyOf(), + statelessResetToken = tokenCopy, ) + // Append to the lifetime store so the token is still + // matchable after a future [tryStartValidation] / + // [forceRotateToHigherSequence] removes the entry from + // [unusedCids]. See [allKnownStatelessResetTokens] for + // rationale (WiFi-handoff stateless-reset detection). + knownStatelessResetTokens.add(tokenCopy) return RecordResult.Stored } diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt index 77220c72b..debd32617 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt @@ -2589,16 +2589,15 @@ class QuicConnection( * * Token sources covered: * - The peer's `stateless_reset_token` transport parameter (the - * sequence-0 / initial CID's token), iff this is a - * server-issued connection. - * - Each unused entry in [pathValidator]'s pool (peer issued - * via NEW_CONNECTION_ID). - * - * Not covered (limitation of the current path-migration plumbing): - * tokens for an actively-used DCID that we migrated to via - * [PathValidator.tryStartValidation] — we lose the token when the - * entry leaves the pool. The vast majority of clients never - * migrate; this gap is acceptable for the audio-rooms scope. + * sequence-0 / initial CID's token). + * - Every NEW_CONNECTION_ID token the peer has ever sent + * ([PathValidator.allKnownStatelessResetTokens]) — including + * tokens for CIDs that have since been rotated-to-active or + * RETIRE_CONNECTION_ID'd. The lifetime retention covers the + * WiFi↔cellular handoff path: when we migrate to a new DCID + * and the peer crashes mid-handoff, the stateless reset on + * the new path uses the migrated CID's token, which is no + * longer in the unused pool but IS in the lifetime store. */ internal fun isStatelessReset(datagram: ByteArray): Boolean { if (datagram.size < 22) return false @@ -2614,10 +2613,8 @@ class QuicConnection( anyMatch = true } } - for (entry in pathValidator.unusedSequences()) { - // Hot-path access lives on the pool — fetch the token via - // the same code path the rest of [PathValidator] uses. - val token = pathValidator.unusedTokenForSequence(entry) ?: continue + for (token in pathValidator.allKnownStatelessResetTokens()) { + if (token.size != 16) continue if (constantTimeEqualsRange(token, datagram, tokenStart)) { anyMatch = true } diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/http3/Http3FrameTypes.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/http3/Http3FrameTypes.kt index bbe1b4db7..6fb73fc23 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/http3/Http3FrameTypes.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/http3/Http3FrameTypes.kt @@ -60,3 +60,42 @@ object Http3SettingsId { /** WebTransport over HTTP/3 (draft / RFC 9220). */ const val ENABLE_WEBTRANSPORT: Long = 0xc671706a } + +/** + * HTTP/3 error code identifiers per RFC 9114 §8.1. These are the values + * emitted in CONNECTION_CLOSE frames at the application-error level + * (frame type 0x1d) when an HTTP/3 invariant is violated. Quoted values + * are the spec defaults; we don't define the full table — only the + * codes the demux / frame reader actually need. + */ +object Http3ErrorCode { + /** §8.1: graceful close, no error. */ + const val NO_ERROR: Long = 0x100 + + /** §8.1: peer violated a generic HTTP/3 invariant we can't classify more precisely. */ + const val GENERAL_PROTOCOL_ERROR: Long = 0x101 + + /** §8.1: an internal-to-our-implementation error. */ + const val INTERNAL_ERROR: Long = 0x102 + + /** + * RFC 9114 §6.2.1 / RFC 9204 §4.2: a critical unidirectional stream + * (control, QPACK encoder, QPACK decoder) was closed by the peer. + */ + const val CLOSED_CRITICAL_STREAM: Long = 0x104 + + /** §8.1: a frame appeared in a context that does not permit it. */ + const val FRAME_UNEXPECTED: Long = 0x105 + + /** §8.1: frame layout / length / payload was invalid. */ + const val FRAME_ERROR: Long = 0x106 + + /** §5.2: a peer's stream id violated its own GOAWAY commitment. */ + const val ID_ERROR: Long = 0x108 + + /** §7.2.4.1: a SETTINGS frame contained an error. */ + const val SETTINGS_ERROR: Long = 0x109 + + /** §7.2.4.1: the first frame on the control stream was not SETTINGS. */ + const val MISSING_SETTINGS: Long = 0x10a +} diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/webtransport/WtPeerStreamDemux.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/webtransport/WtPeerStreamDemux.kt index 333d95885..0cc040795 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/webtransport/WtPeerStreamDemux.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/webtransport/WtPeerStreamDemux.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quic.webtransport import com.vitorpamplona.quic.Varint +import com.vitorpamplona.quic.http3.Http3ErrorCode import com.vitorpamplona.quic.http3.Http3Frame import com.vitorpamplona.quic.http3.Http3FrameReader import com.vitorpamplona.quic.http3.Http3Settings @@ -126,6 +127,27 @@ class WtPeerStreamDemux( var peerGoawayProtocolError: String? = null private set + /** + * RFC 9114 §6.2.1 + RFC 9204 §4.2 critical-stream closure record. + * Latches the (errorCode, reason) pair the demux passed to its + * driver-side `connection.close` when ANY critical unidirectional + * stream — control, QPACK encoder, QPACK decoder — was closed by + * the peer (clean FIN) or violated an HTTP/3 invariant. + * + * Production callers don't need to observe this; the + * `connection.close` already drives the QUIC layer to CLOSED. + * Tests use it to assert the closure path fired without wiring a + * real driver: the flag is set inside [closeConnection] before + * (and independently of) the suspending `connection.close` call. + */ + @Volatile + var criticalStreamClosureCode: Long? = null + private set + + @Volatile + var criticalStreamClosureReason: String? = null + private set + /** * Surface RFC 9114 §7.2 frame-validation violations the * [Http3FrameReader] catches (H3_FRAME_UNEXPECTED, H3_MISSING_SETTINGS, @@ -214,7 +236,19 @@ class WtPeerStreamDemux( } Http3StreamType.QPACK_ENCODER, Http3StreamType.QPACK_DECODER -> { - drainBlackHole(chunkChannel) + // RFC 9204 §4.2: QPACK encoder + decoder + // streams are critical. Closure of either + // MUST be treated as H3_CLOSED_CRITICAL_STREAM + // — same wire-spec class as the control + // stream. We don't speak the QPACK + // dynamic-table instructions either way + // (encoder is RIC=0, decoder is best-effort + // ACKs we ignore), so the drain is still + // black-hole; only the FIN handling differs. + drainCriticalStream( + chunkChannel, + "QPACK ${if (streamType == Http3StreamType.QPACK_ENCODER) "encoder" else "decoder"} stream", + ) } Http3StreamType.WEBTRANSPORT_UNI_STREAM -> { @@ -271,19 +305,105 @@ class WtPeerStreamDemux( reader.push(chunk) consumeFrames(reader) } + // Channel iteration exited cleanly → peer FIN'd the + // control stream. RFC 9114 §6.2.1: closure of any + // critical stream MUST be treated as a connection error + // of type H3_CLOSED_CRITICAL_STREAM. + closeConnection( + Http3ErrorCode.CLOSED_CRITICAL_STREAM, + "H3_CLOSED_CRITICAL_STREAM: peer closed CONTROL stream", + ) } catch (e: com.vitorpamplona.quic.QuicCodecException) { // RFC 9114 §7.2 frame-validation throws (H3_FRAME_UNEXPECTED / // H3_MISSING_SETTINGS / reserved type) land here. Record the - // diagnostic so the QUIC layer / application can close the - // connection deliberately instead of having the route() catch - // silently swallow the message. Idempotent on duplicate hits. + // diagnostic so observability tools (qlog, tests) see a + // structured message; ALSO drive the connection closed via + // [closeConnection] so the application doesn't need to poll + // [peerH3ProtocolError] separately. Idempotent on duplicate + // hits. if (peerH3ProtocolError == null) { peerH3ProtocolError = e.message ?: "HTTP/3 protocol violation on CONTROL stream" } + closeConnection( + http3ErrorCodeForMessage(e.message), + e.message ?: "HTTP/3 protocol violation on CONTROL stream", + ) throw e } } + /** + * Drain a critical unidirectional stream (QPACK encoder / decoder) + * whose payload we don't otherwise process. The QPACK control + * messages would only matter if we ran a dynamic table — since we + * don't, the bytes themselves are dropped, but the §6.2.1 + * "critical stream closed = connection error" obligation still + * applies. On peer FIN we call [closeConnection] with + * H3_CLOSED_CRITICAL_STREAM. + */ + private suspend fun drainCriticalStream( + chunkChannel: Channel, + label: String, + ) { + @Suppress("UNUSED_VARIABLE") + for (discarded in chunkChannel) { + // intentionally discarded — QPACK dynamic-table is off. + } + closeConnection( + Http3ErrorCode.CLOSED_CRITICAL_STREAM, + "H3_CLOSED_CRITICAL_STREAM: peer closed $label", + ) + } + + /** + * Close the underlying QUIC connection with an HTTP/3 + * application-level error code. No-op when the demux is running + * without a [driver] (test path) — the test is then expected to + * read [peerH3ProtocolError] / observe the FIN explicitly. + * + * Launched on the demux's [scope] so the suspending close call + * doesn't block the per-stream collector that's exiting. + */ + private fun closeConnection( + errorCode: Long, + reason: String, + ) { + // Latch the closure intent FIRST so tests (and qlog observers) + // can see what we tried to do, regardless of whether a + // driver/connection is wired to follow through. Idempotent: a + // duplicate fire (e.g. control stream errors then closes) + // doesn't overwrite the first reason. + if (criticalStreamClosureCode == null) { + criticalStreamClosureCode = errorCode + criticalStreamClosureReason = reason + } + val conn = driver?.connection ?: return + scope.launch { + conn.close(errorCode, reason) + driver.wakeup() + } + } + + /** + * Best-effort mapping from a [QuicCodecException] message raised by + * the HTTP/3 frame reader / SETTINGS validator to the matching + * RFC 9114 §8.1 error code. We attach the code to the + * application-level CONNECTION_CLOSE so downstream observers + * (qlog, peer) get the precise spec category instead of a generic + * GENERAL_PROTOCOL_ERROR. + */ + private fun http3ErrorCodeForMessage(message: String?): Long { + if (message == null) return Http3ErrorCode.GENERAL_PROTOCOL_ERROR + return when { + message.contains("H3_FRAME_UNEXPECTED") -> Http3ErrorCode.FRAME_UNEXPECTED + message.contains("H3_MISSING_SETTINGS") -> Http3ErrorCode.MISSING_SETTINGS + message.contains("H3_SETTINGS_ERROR") -> Http3ErrorCode.SETTINGS_ERROR + message.contains("H3_ID_ERROR") -> Http3ErrorCode.ID_ERROR + message.contains("H3_FRAME_ERROR") -> Http3ErrorCode.FRAME_ERROR + else -> Http3ErrorCode.GENERAL_PROTOCOL_ERROR + } + } + private fun consumeFrames(reader: Http3FrameReader) { while (true) { val frame = reader.next() ?: return diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/StatelessResetDetectionTest.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/StatelessResetDetectionTest.kt index 2e7555db8..bd99d5fe7 100644 --- a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/StatelessResetDetectionTest.kt +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/StatelessResetDetectionTest.kt @@ -133,6 +133,87 @@ class StatelessResetDetectionTest { assertFalse(client.isStatelessReset(datagram), "long-header-form must not be classified as stateless reset") } + @Test + fun token_persists_after_path_migration_for_wifi_handoff() { + // RFC 9000 §10.3 + audio-rooms WiFi-handoff path. The peer + // issues us a fresh CID via NEW_CONNECTION_ID. We migrate to + // it via tryStartValidation (mimics the WiFi→cellular + // handoff: client moves to a new path, requests a fresh DCID + // for unlinkability). The peer (relay) then loses connection + // state mid-handoff and emits a stateless reset using the + // token it issued for the migrated CID. We MUST detect the + // reset even though the CID has left the unused pool. + val (client, _) = newConnectedClient() + val handoffToken = ByteArray(16) { (0x88 + it).toByte() } + val newCidBytes = ByteArray(8) { (0xCC + it).toByte() } + // Step 1: peer offers a NEW_CONNECTION_ID. Token lands in + // the unused pool AND in the lifetime store. + val recordResult = + client.pathValidator.recordPeerNewConnectionId( + sequenceNumber = 1L, + retirePriorTo = 0L, + connectionId = newCidBytes, + statelessResetToken = handoffToken, + ) + assertEquals(PathValidator.RecordResult.Stored, recordResult) + assertEquals(1, client.pathValidator.unusedCount()) + + // Step 2: client triggers a path migration (the WiFi + // handoff). The CID leaves the unused pool and becomes + // active. Pre-fix this would have lost the token. + val migration = client.pathValidator.tryStartValidation(nowMillis = 0L, currentPtoMillis = 100L) + assertEquals(PathMigrationResult.Started, migration) + assertEquals(0, client.pathValidator.unusedCount(), "unused pool drained by migration") + + // Step 3: a stateless-reset datagram arrives carrying the + // handoff token in its trailing 16 bytes. The lifetime store + // still has it, so the match succeeds. + val dcid = client.sourceConnectionId.bytes + val datagram = byteArrayOf(0x40.toByte()) + dcid + ByteArray(40) + handoffToken + assertTrue( + client.isStatelessReset(datagram), + "post-migration token MUST still match for WiFi-handoff stateless-reset detection", + ) + } + + @Test + fun token_persists_through_force_rotation_for_acid_reissue() { + // RFC 9000 §5.1.2: when the peer raises retire_prior_to past + // our active CID, we force-rotate to a fresh entry. The + // displaced active CID's token must still be matchable in + // case the peer (or an adversary spoofing the peer) + // stateless-resets us on the prior path during the brief + // window before our retirement settles. + val (client, _) = newConnectedClient() + // Issue two CIDs (seq 1 and 2). retire_prior_to=2 will + // force a rotation to seq 2 and queue retirement for seq 1. + val tokenA = ByteArray(16) { (0x11 + it).toByte() } + val tokenB = ByteArray(16) { (0x22 + it).toByte() } + client.pathValidator.recordPeerNewConnectionId( + sequenceNumber = 1L, + retirePriorTo = 0L, + connectionId = ByteArray(8) { 0xAA.toByte() }, + statelessResetToken = tokenA, + ) + client.pathValidator.recordPeerNewConnectionId( + sequenceNumber = 2L, + retirePriorTo = 2L, // peer demands we retire CIDs < 2 (i.e. seq 0 + 1) + connectionId = ByteArray(8) { 0xBB.toByte() }, + statelessResetToken = tokenB, + ) + // Force-rotate (peer's watermark = 2 > activeCidSequence = 0). + val rotation = client.pathValidator.forceRotateToHigherSequence() + assertNotNull(rotation) + assertEquals(0, client.pathValidator.unusedCount(), "force-rotation drains the pool") + + // Both tokens must still match — neither is in the unused + // pool any more, but both live on in the lifetime store. + val dcid = client.sourceConnectionId.bytes + val payload = ByteArray(40) + assertTrue(client.isStatelessReset(byteArrayOf(0x40.toByte()) + dcid + payload + tokenA)) + assertTrue(client.isStatelessReset(byteArrayOf(0x40.toByte()) + dcid + payload + tokenB)) + } + @Test fun isStatelessReset_rejects_too_short_datagram() { val (client, _) = newConnectedClient() diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/webtransport/CriticalStreamClosureTest.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/webtransport/CriticalStreamClosureTest.kt new file mode 100644 index 000000000..5aa492e46 --- /dev/null +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/webtransport/CriticalStreamClosureTest.kt @@ -0,0 +1,249 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quic.webtransport + +import com.vitorpamplona.quic.QuicWriter +import com.vitorpamplona.quic.http3.Http3ErrorCode +import com.vitorpamplona.quic.http3.Http3FrameType +import com.vitorpamplona.quic.http3.Http3Settings +import com.vitorpamplona.quic.http3.Http3SettingsId +import com.vitorpamplona.quic.http3.Http3StreamType +import com.vitorpamplona.quic.stream.QuicStream +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +/** + * RFC 9114 §6.2.1 + RFC 9204 §4.2 critical-stream closure. + * + * "If either control stream is closed at any point, this MUST be + * treated as a connection error of type H3_CLOSED_CRITICAL_STREAM." + * + * RFC 9204 §4.2 extends this to the QPACK encoder + decoder streams. + * + * Pre-fix the demux recorded a `peerH3ProtocolError` flag on + * frame-validation throws but did NOT auto-close the connection on + * peer-side stream FIN at all — the application was expected to poll + * the flag and call close(). For WiFi↔cellular handoff and other + * lossy paths where the relay can drop the control stream + * mid-session, the audio user saw stalled output with no error event. + * + * The fix: the demux now drives `connection.close(errorCode, reason)` + * itself when any critical stream closes (clean FIN OR protocol + * violation). The closure intent is latched on + * `criticalStreamClosureCode` / `criticalStreamClosureReason` so tests + * can verify the path runs without wiring a real driver. + */ +class CriticalStreamClosureTest { + private fun controlStreamPrefix(): ByteArray = QuicWriter().also { it.writeVarint(Http3StreamType.CONTROL) }.toByteArray() + + private fun qpackEncoderPrefix(): ByteArray = QuicWriter().also { it.writeVarint(Http3StreamType.QPACK_ENCODER) }.toByteArray() + + private fun validSettingsFrame(): ByteArray = + Http3Settings( + mapOf( + Http3SettingsId.ENABLE_CONNECT_PROTOCOL to 1L, + Http3SettingsId.ENABLE_WEBTRANSPORT to 1L, + ), + ).encodeFrame() + + @Test + fun control_stream_FIN_triggers_h3_closed_critical_stream() { + runBlocking { + val stream = QuicStream(3L, QuicStream.Direction.UNIDIRECTIONAL_REMOTE_TO_LOCAL) + val demuxScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val demux = WtPeerStreamDemux(expectedConnectStreamId = 0L, scope = demuxScope) + demux.process(stream) + + // Push a valid SETTINGS so the demux is happy with the + // stream contents, then FIN it cleanly. + stream.deliverIncoming(controlStreamPrefix()) + stream.deliverIncoming(validSettingsFrame()) + stream.closeIncoming() + + val ok = + withTimeoutOrNull(2_000L) { + while (demux.criticalStreamClosureCode == null) delay(5) + true + } + assertEquals(true, ok, "demux should latch H3_CLOSED_CRITICAL_STREAM within timeout") + assertEquals(Http3ErrorCode.CLOSED_CRITICAL_STREAM, demux.criticalStreamClosureCode) + val reason = demux.criticalStreamClosureReason + assertNotNull(reason) + assertEquals(true, reason.contains("CONTROL"), "reason should mention CONTROL stream: $reason") + + demuxScope.cancel() + } + } + + @Test + fun qpack_encoder_FIN_triggers_h3_closed_critical_stream() { + runBlocking { + val stream = QuicStream(3L, QuicStream.Direction.UNIDIRECTIONAL_REMOTE_TO_LOCAL) + val demuxScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val demux = WtPeerStreamDemux(expectedConnectStreamId = 0L, scope = demuxScope) + demux.process(stream) + + // QPACK encoder stream — we drain its bytes (we don't run a + // dynamic table) but its FIN is still a critical-stream + // closure per RFC 9204 §4.2. + stream.deliverIncoming(qpackEncoderPrefix()) + stream.deliverIncoming(byteArrayOf(0x00, 0x00, 0x00)) // junk QPACK we ignore + stream.closeIncoming() + + val ok = + withTimeoutOrNull(2_000L) { + while (demux.criticalStreamClosureCode == null) delay(5) + true + } + assertEquals(true, ok, "demux should latch H3_CLOSED_CRITICAL_STREAM for QPACK encoder") + assertEquals(Http3ErrorCode.CLOSED_CRITICAL_STREAM, demux.criticalStreamClosureCode) + val reason = demux.criticalStreamClosureReason + assertNotNull(reason) + assertEquals(true, reason.contains("QPACK encoder"), "reason should mention QPACK encoder: $reason") + + demuxScope.cancel() + } + } + + @Test + fun control_stream_settings_violation_triggers_close_with_specific_code() { + // Control stream's first frame is REQUIRED to be SETTINGS per + // RFC 9114 §7.2.4.1. A peer that opens with a different frame + // is H3_MISSING_SETTINGS — the demux's frame reader throws, + // and our auto-close should propagate the specific code (not + // just the generic H3_GENERAL_PROTOCOL_ERROR). + runBlocking { + val stream = QuicStream(3L, QuicStream.Direction.UNIDIRECTIONAL_REMOTE_TO_LOCAL) + val demuxScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val demux = WtPeerStreamDemux(expectedConnectStreamId = 0L, scope = demuxScope) + demux.process(stream) + + // First (and only) frame on the control stream is GOAWAY + // — illegal: SETTINGS must be first. Reader should throw + // H3_MISSING_SETTINGS. + val goawayBody = QuicWriter().also { it.writeVarint(0L) }.toByteArray() + val goaway = + QuicWriter() + .apply { + writeVarint(Http3FrameType.GOAWAY) + writeVarint(goawayBody.size.toLong()) + writeBytes(goawayBody) + }.toByteArray() + stream.deliverIncoming(controlStreamPrefix()) + stream.deliverIncoming(goaway) + stream.closeIncoming() + + val ok = + withTimeoutOrNull(2_000L) { + while (demux.criticalStreamClosureCode == null) delay(5) + true + } + assertEquals(true, ok) + assertEquals(Http3ErrorCode.MISSING_SETTINGS, demux.criticalStreamClosureCode) + assertNotNull(demux.peerH3ProtocolError) + + demuxScope.cancel() + } + } + + @Test + fun control_stream_unexpected_data_frame_maps_to_frame_unexpected() { + runBlocking { + val stream = QuicStream(3L, QuicStream.Direction.UNIDIRECTIONAL_REMOTE_TO_LOCAL) + val demuxScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val demux = WtPeerStreamDemux(expectedConnectStreamId = 0L, scope = demuxScope) + demux.process(stream) + + // Valid SETTINGS first, then an illegal DATA frame. RFC + // 9114 §7.2.1: DATA on the control stream is + // H3_FRAME_UNEXPECTED. + val data = + QuicWriter() + .apply { + writeVarint(Http3FrameType.DATA) + writeVarint(4L) + writeBytes(byteArrayOf(1, 2, 3, 4)) + }.toByteArray() + stream.deliverIncoming(controlStreamPrefix()) + stream.deliverIncoming(validSettingsFrame()) + stream.deliverIncoming(data) + stream.closeIncoming() + + val ok = + withTimeoutOrNull(2_000L) { + while (demux.criticalStreamClosureCode == null) delay(5) + true + } + assertEquals(true, ok) + assertEquals(Http3ErrorCode.FRAME_UNEXPECTED, demux.criticalStreamClosureCode) + + demuxScope.cancel() + } + } + + @Test + fun closure_code_is_idempotent_first_call_wins() { + runBlocking { + val stream = QuicStream(3L, QuicStream.Direction.UNIDIRECTIONAL_REMOTE_TO_LOCAL) + val demuxScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val demux = WtPeerStreamDemux(expectedConnectStreamId = 0L, scope = demuxScope) + demux.process(stream) + + // Inject a protocol violation (sets the code) then FIN + // (would set CLOSED_CRITICAL_STREAM if we didn't latch). + // The latch keeps the violation code, not the FIN code. + val data = + QuicWriter() + .apply { + writeVarint(Http3FrameType.DATA) + writeVarint(0L) + }.toByteArray() + stream.deliverIncoming(controlStreamPrefix()) + stream.deliverIncoming(validSettingsFrame()) + stream.deliverIncoming(data) + stream.closeIncoming() + + val ok = + withTimeoutOrNull(2_000L) { + while (demux.criticalStreamClosureCode == null) delay(5) + true + } + assertEquals(true, ok) + // Either code is acceptable depending on timing; both + // routes go through closeConnection(). The point is + // [criticalStreamClosureCode] doesn't FLIP back and forth + // — first writer wins. + val firstCode = demux.criticalStreamClosureCode + delay(50) // give the FIN-path coroutine time to also try + assertEquals(firstCode, demux.criticalStreamClosureCode, "closure code should not flip after first observation") + + demuxScope.cancel() + } + } +}