From 9f7f6a9ef992f4b406a5517f2dabb7d1319e381b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 9 May 2026 04:13:46 +0000 Subject: [PATCH 01/10] docs(quic): refresh stack-status plan to match shipped 2026-05-09 reality Plan had drifted ~2 weeks behind shipping work. Rewrote to reflect the features that landed since 2026-04-26 (path validation, 0-RTT, key update, ECN, session resumption, lock-split refactor, DoS hardening) and added a fresh RFC compliance gaps section catalogued from a four-agent audit (RFC 9000, 9001, 9002, 9221+9114+9204). https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik --- quic/plans/2026-04-26-quic-stack-status.md | 287 +++++++++++++++------ 1 file changed, 205 insertions(+), 82 deletions(-) diff --git a/quic/plans/2026-04-26-quic-stack-status.md b/quic/plans/2026-04-26-quic-stack-status.md index 07f878f819..4008faa4b9 100644 --- a/quic/plans/2026-04-26-quic-stack-status.md +++ b/quic/plans/2026-04-26-quic-stack-status.md @@ -1,16 +1,24 @@ -# QUIC + WebTransport stack — current state (2026-04-26) +# QUIC + WebTransport stack — current state -This document is the post-implementation snapshot of `:quic`. It supersedes -the original [pure-kotlin QUIC + WebTransport plan](../../docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md) -which was written before any code shipped and is now historical. +Living status doc for `:quic`. First written 2026-04-26 as the post-implementation +snapshot of the [pure-kotlin QUIC + WebTransport +plan](../../docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md); +last full rewrite **2026-05-09** after a four-RFC compliance audit (RFC 9000, +9001, 9002, 9221 + 9114 + 9204) found the doc had drifted ~2 weeks behind +shipping work. The original plan is now historical. ## TL;DR `:quic` is a self-contained Kotlin-Multiplatform module that speaks QUIC v1 -+ HTTP/3 + WebTransport against real-world servers (aioquic + picoquic -verified; nestsClient/MoQ on top). It uses Quartz crypto primitives only — -no BouncyCastle, no JNI. ~8.5k lines of production code, ~5k lines of -tests, 39 test files, five rounds of parallel audit + fix passes. ++ HTTP/3 + WebTransport against real-world servers (aioquic + picoquic + +quic-go interop verified, including 0-RTT). Pure Kotlin: Quartz crypto +primitives + JCA AEAD only — no BouncyCastle, no JNI. ~17k lines of +production code, 81 test files, ten-plus rounds of audit + fix passes +since 2026-04-26. + +The 2026-05-09 audit confirmed the core handshake / packet protection / +loss recovery / frame routing surface is RFC-grade. Real gaps are listed +in [§ RFC compliance gaps](#rfc-compliance-gaps-2026-05-09). ## What shipped vs the original plan @@ -20,18 +28,18 @@ tests, 39 test files, five rounds of parallel audit + fix passes. | B. TLS 1.3 | 3 wk | done | RFC 8446 client state machine, X25519 ECDHE, RFC 8448 §3 vectors pass bit-for-bit | | C. Initial + Handshake packets | 2 wk | done | RFC 9001 §A.2/§A.3 vectors pass; ChaCha20 per §A.5 | | D. 1-RTT + STREAM | 1 wk | done | Stream offset reassembly, FIN, fuzzed | -| E. ACK + flow control | 1 wk | done | MAX_DATA / MAX_STREAM_DATA / MAX_STREAMS routing + writer enforcement | +| E. ACK + flow control | 1 wk | done | MAX_DATA / MAX_STREAM_DATA / MAX_STREAMS routing + writer enforcement + per-stream receive enforcement | | F. Loss recovery + congestion control | 1 wk | done (no CC) | RFC 9002 §5/§6 RTT estimator + packet/time-threshold loss detection + PTO + per-frame retransmit shipped 2026-05-05; congestion control still TBD | | G. Datagram extension | ½ wk | done | RFC 9221 frames + bounded incoming queue | -| H. Connection lifecycle | 1 wk | done | CONNECTION_CLOSE, idle timeout, draining/closing, idempotent driver close | +| H. Connection lifecycle | 1 wk | done | CONNECTION_CLOSE, draining/closing, idempotent driver close (idle-timeout enforcement still missing — see gaps) | | I. HTTP/3 | 2 wk | done | Control stream + SETTINGS + GOAWAY (with id-regression check) + duplicate-id rejection | | J. QPACK | 2 wk | done | Static-table + Huffman + integer codec (RFC 7541 §B + RFC 9204 §B.1 vectors) | | K. Extended CONNECT + WT | 1 wk | done | Stream-type prefixes, HTTP Datagram + WT_CLOSE_SESSION capsule | -| L. Interop + hardening | 2 wk | done + much more | Live interop against aioquic Docker; **5 rounds of audit + fix** beyond the plan | +| L. Interop + hardening | 2 wk | done + much more | Live interop against aioquic + picoquic + quic-go; **10+ rounds of audit + fix**, multiplexing, 0-RTT, key update, ECN, path migration | -The original plan estimated 17–19 weeks. We ran the full sequence plus five -unscheduled audit rounds. Every audit found real bugs; the suite is what -caught them on regression. +The original plan estimated 17–19 weeks. We shipped the full sequence plus +a long tail of audit-driven hardening. Every audit found real bugs; the +test suite is what catches them on regression. ## What's actually in the module @@ -43,51 +51,61 @@ quic/ │ ├── Buffer.kt ← QuicReader / QuicWriter │ ├── Varint.kt ← RFC 9000 §16 │ ├── connection/ ← QuicConnection orchestrator + Driver - │ │ ├── QuicConnection.kt (≈ 600 lines, the hub) + │ │ ├── QuicConnection.kt (≈ 2800 lines, the hub) │ │ ├── QuicConnectionDriver.kt (read/send loops + close) │ │ ├── QuicConnectionParser.kt (feedDatagram + dispatchFrames) │ │ ├── QuicConnectionWriter.kt (drainOutbound + flow-control updates) + │ │ ├── PathValidator.kt ← RFC 9000 §5.1.2 + §8.2 + §9 │ │ ├── PacketProtection.kt + builder │ │ ├── PacketNumberSpace.kt │ │ ├── ConnectionId.kt + TransportParameters.kt - │ │ └── EncryptionLevel.kt + LevelState.kt - │ ├── crypto/ ← Aead, header protection, HKDF helpers, AesEcbHeaderProtection, - │ │ ChaCha20HeaderProtection, ChaCha20Poly1305Aead, InitialSecrets, + │ │ ├── EncryptionLevel.kt + LevelState.kt + │ │ └── recovery/ ← RecoveryToken, SentPacket, + │ │ AckedPackets, QuicLossDetection + │ ├── crypto/ ← Aead, header protection, HKDF helpers, + │ │ AesEcbHeaderProtection, ChaCha20HeaderProtection, + │ │ ChaCha20Poly1305Aead, InitialSecrets, │ │ PlatformAesOneBlock, PlatformChaCha20Block (expect), │ │ bestAes128GcmAead (expect) │ ├── frame/ ← Frame.kt sealed hierarchy + FrameFuzzerTest target - │ │ includes RESET_STREAM / STOP_SENDING / NEW_TOKEN + │ │ includes RESET_STREAM / STOP_SENDING / NEW_TOKEN / + │ │ PATH_CHALLENGE / PATH_RESPONSE / NEW_CONNECTION_ID / + │ │ RETIRE_CONNECTION_ID / ACK_ECN │ ├── http3/ ← Http3FrameReader + Http3Settings + frame types - │ ├── packet/ ← LongHeaderPacket, ShortHeaderPacket, RetryPacket, peekHeader + │ ├── packet/ ← LongHeaderPacket, ShortHeaderPacket (with + │ │ reserved-bit + key-phase peek), RetryPacket, peekHeader │ ├── qpack/ ← QpackDecoder, QpackEncoder, QpackHuffman, QpackInteger, │ │ QpackStaticTable - │ ├── connection/recovery/ ← RecoveryToken + SentPacket + QuicLossDetection - │ │ (RFC 9002 §5/§6 RTT estimator + loss detection + - │ │ PTO; per-frame retransmit dispatch) │ ├── recovery/ ← AckTracker (with ack-eliciting gating) - │ ├── stream/ ← QuicStream, ReceiveBuffer (with FIN-fully-read), SendBuffer, StreamId - │ ├── tls/ ← TlsClient state machine + ClientHello/ServerHello/EE/Cert/CV/Finished - │ │ codecs, TlsKeySchedule, TlsTranscriptHash (incremental), TlsConstants, + │ ├── stream/ ← QuicStream, ReceiveBuffer (with FIN-fully-read + + │ │ per-stream receive-limit), SendBuffer, StreamId + │ ├── tls/ ← TlsClient state machine + ClientHello (incl. PSK + + │ │ early_data resumption variant) / ServerHello / EE / + │ │ Cert / CV / Finished / NewSessionTicket codecs, + │ │ TlsKeySchedule (incl. 0-RTT + resumption_master_secret), + │ │ TlsTranscriptHash (incremental), TlsConstants, │ │ PermissiveCertificateValidator, TlsRunningSha256 (expect) │ ├── transport/ ← UdpSocket (expect) │ └── webtransport/ ← QuicWebTransportFactory + QuicWebTransportSessionState + │ WtPeerStreamDemux + WtCapsule + WtDatagram + ExtendedConnect └── jvmAndroid/kotlin/com/vitorpamplona/quic/ ├── crypto/JcaAesGcmAead.kt ← cached JCA Cipher per direction with IV-reuse fallback + ├── crypto/JcaChaCha20Poly1305Aead.kt ← JCA ChaCha20-Poly1305 (with Quartz fallback) ├── crypto/PlatformCrypto.kt ← actuals - ├── tls/JdkCertificateValidator.kt ← system-trust-store chain validation + RSA-PSS / ECDSA / Ed25519 + ├── tls/JdkCertificateValidator.kt ← system-trust-store chain validation + PSL filter + + │ RSA-PSS / ECDSA / Ed25519 ├── tls/TlsRunningSha256.kt ← MessageDigest.clone()-based incremental hash └── transport/UdpSocket.kt ← DatagramChannel + suspend wrapper ``` ## Crypto surface -Quartz primitives only: +Quartz primitives + JCA only: | Primitive | Source | |---|---| | AES-128-GCM | `JcaAesGcmAead` (jvmAndroid) — cached `Cipher` per direction; `Aes128Gcm` singleton (commonMain) for non-hot paths | -| ChaCha20-Poly1305 | Quartz `ChaCha20Poly1305` (commonMain pure-Kotlin) wrapped in `ChaCha20Poly1305Aead` | +| ChaCha20-Poly1305 | `JcaChaCha20Poly1305Aead` (jvmAndroid, JCA fast path) with Quartz pure-Kotlin fallback in `ChaCha20Poly1305Aead` | | HKDF-Extract / Expand-Label | Quartz `Hkdf` + `MacInstance`; thin RFC 8446 §7.1 helper in `crypto/HkdfHelpers.kt` | | SHA-256 (one-shot) | Quartz `sha256(...)` | | SHA-256 (incremental, for transcript) | `TlsRunningSha256` (expect/actual; jvmAndroid wraps `MessageDigest.clone()`) | @@ -101,32 +119,58 @@ X.509 chain validation, hostname verification, signature verification all delegate to JDK `TrustManagerFactory` / `Signature.getInstance(...)`. `CertificateValidator` is a non-null typed parameter — tests pass an explicit `PermissiveCertificateValidator`; production passes -`JdkCertificateValidator`. +`JdkCertificateValidator` (with PSL-subset wildcard rules). ## What we deliberately don't do - **QUIC server role.** Client-only. -- **0-RTT / session resumption.** No PSK extension offered; an arriving - ServerFinished without prior Certificate is hard-failed. -- **Connection migration / preferred address / multiple paths.** `NEW_CONNECTION_ID` - and `PATH_*` frames decode (so peers don't break us) but aren't acted on. - **Path MTU discovery.** Fixed 1200-byte ceiling per RFC 9000 §14. +- **Anti-amplification limits (server side).** We're a client; the server + applies its own. - **HTTP/3 server push.** -- **QPACK dynamic-table inserts on the encoder.** We send literal-only; - decoder accepts dynamic-table indexed lines. -- **ECN / anti-amplification limits.** We're a client. -- **TLS Key-Update / NewSessionTicket.** Detected and refused (KeyUpdate - fails the connection rather than silently desynchronising). -- **Congestion control (NewReno / CUBIC / BBR).** Loss detection is in - place (RFC 9002 §5/§6) but no rate-limiting feedback loop reacts to - losses; we send as fast as the application provides bytes. Independent - follow-up. +- **QPACK dynamic-table inserts on the encoder.** We send literal + + static-indexed only; decoder accepts dynamic-table indexed lines. +- **Congestion control (NewReno / CUBIC / BBR).** Loss detection is + in place (RFC 9002 §5/§6) but no rate-limiting feedback loop reacts + to losses; we send as fast as the application provides bytes. See + [`2026-05-05-congestion-control.md`](2026-05-05-congestion-control.md) + (parked indefinitely; acceptable for moq-lite audio). + +## What's now shipped that the original "deliberately don't do" list disclaimed + +The plan's pre-rewrite version listed the following as out-of-scope. They +shipped between 2026-04-26 and 2026-05-09 and now WORK: + +- **0-RTT / session resumption.** TLS 1.3 PSK + `early_data` extension on + both ends; client caches `resumption_master_secret`, parses + `NewSessionTicket`, and replays 0-RTT data with prior-connection ALPN + + transport params. Verified against picoquic and quic-go. +- **TLS Key-Update / NewSessionTicket.** RFC 9001 §6 1-RTT key update — + client-initiated AND peer-initiated. `KEY_PHASE`-bit peek before AEAD, + in-flight gate, PN-regression check on the rotation packet, proper + HKDF roll of `application_traffic_secret_N+1`. +- **NEW_CONNECTION_ID / RETIRE_CONNECTION_ID handling, including + `retire_prior_to`** per RFC 9000 §5.1.2 + §19.15 (`PathValidator.recordPeerNewConnectionId` + with full state machine: stale-offer retire, watermark monotonicity, + pool-full bound, duplicate-with-mismatch rejection). +- **Path validation (client- and server-initiated).** RFC 9000 §8.2 + + §9 — full PATH_CHALLENGE / PATH_RESPONSE round-trip, 3*PTO timeout + per §8.2.4, abrupt migration to a new DCID on success, forced + rotation when `retire_prior_to` exceeds the active CID. +- **ECN (1-RTT only).** ECT(0) on outbound, `ACK_ECN` (0x03) frames + with truthful CE / ECT(0) / ECT(1) counters. Initial / Handshake + ACKs intentionally skip ECN counts (interop conservatism). + +The remaining pre-rewrite "don't do" items (server role, PMTU, HTTP/3 +push, QPACK encoder dynamic table, CC) are still out-of-scope. ## Verified interop - **aioquic** (Python): `quic-interop-runner`-style Docker setup; full - handshake + Extended CONNECT + h3 datagram round-trip. -- **picoquic** (C): Docker image, lightweight HTTP/3 GET. + handshake + multiplexing + Extended CONNECT + h3 datagram round-trip. +- **picoquic** (C): Docker image, h3, 0-RTT, key-update, longRTT, ECN + test cases pass. +- **quic-go** (Go): 0-RTT pass. - **In-memory pipe** (`InMemoryQuicPipe`, modeled on Cloudflare quiche's `Pipe`) drives both sides of the handshake in one JVM for fast tests without sockets. @@ -137,13 +181,19 @@ gates on the audio-rooms completion plan ## Audit summary -| Round | Focus | Findings | Status | -|---|---|---|---| -| 1 | Initial review (pre-interop) | 6 critical correctness/security bugs | all fixed | -| 2 | TLS hardening + lifecycle | hangs + TLS edge cases | all fixed | -| 3 | Performance + concurrency | cipher caching, polling, transcript O(n²) | all fixed | -| 4 | Core + TLS + perf + coverage gaps (4 parallel agents) | ~30 items including 4 CRITICAL interop blockers | all fixed; comprehensive regression tests added | -| 5 | Regression check + concurrency-specific (2 parallel agents) | 1 CRITICAL ackEliciting regression I'd just introduced + WT scope leak + others | all fixed | +| Round | Date | Focus | Findings | Status | +|---|---|---|---|---| +| 1 | 2026-04-22 | Initial review (pre-interop) | 6 critical correctness/security bugs | all fixed | +| 2 | 2026-04-23 | TLS hardening + lifecycle | hangs + TLS edge cases | all fixed | +| 3 | 2026-04-24 | Performance + concurrency | cipher caching, polling, transcript O(n²) | all fixed | +| 4 | 2026-04-25 | Core + TLS + perf + coverage gaps (4 parallel agents) | ~30 items including 4 CRITICAL interop blockers | all fixed; comprehensive regression tests added | +| 5 | 2026-04-26 | Regression check + concurrency-specific (2 parallel agents) | 1 CRITICAL ackEliciting regression I'd just introduced + WT scope leak + others | all fixed | +| 6 | 2026-05-04 | Per-frame retransmit + SendBuffer rewrite | retain-until-ACK + control-frame retx + STREAM/CRYPTO recovery | all fixed; see [`2026-05-04-control-frame-retransmit.md`](2026-05-04-control-frame-retransmit.md) | +| 7 | 2026-05-05 | Loss-detection timer + PSK rejection signal | RFC 9002 §6.1.2 timer; PSK rejection recover-in-place | all fixed | +| 8 | 2026-05-06 | Path validation pass 1 + 2 | DCID rotation, retire-prior-to monotonicity, abrupt migration | all fixed | +| 9 | 2026-05-07 | DoS hardening + reserved-bit checks + TLS bounds + SendBuffer shrink | bound peer-controlled buffers and channels | all fixed | +| 10 | 2026-05-08 | Lock-split design + close-under-load + key-update PN gate | streamsLock + lifecycleLock split | all fixed; see [`2026-05-08-lock-split-design.md`](2026-05-08-lock-split-design.md) | +| 11 | 2026-05-09 | Four-RFC compliance audit (4 parallel agents: 9000, 9001, 9002, 9221+9114+9204) | ~15 items, mostly receive-side validation gaps | catalogued in [§ RFC compliance gaps](#rfc-compliance-gaps-2026-05-09) below | Every fix carries an inline `audit-N #M` reference comment so the regression test → fix → comment chain is auditable. The whole audit corpus is in the @@ -151,35 +201,48 @@ git log (commits whose subject starts with `fix(quic):` or `perf(quic):`). ## Test inventory -Roughly grouped: +81 `*Test.kt` files. Roughly grouped: - **RFC vectors:** RFC 8448 §3 (TLS handshake), RFC 9001 §A.1–A.5 (Initial encrypt/decrypt, Retry, ChaCha20, server-side HP), RFC 9204 §B.1 (QPACK), RFC 7541 (Huffman). - **End-to-end pipe tests:** `InMemoryQuicPipeTest`, `CoalescedPacketSkipTest`, `ReceiveLimitEnforcementTest`, `PeerStreamLimitTest`, `FrameRoutingTest`, - `AckElicitingFramesTest`. + `AckElicitingFramesTest`, `MultiplexingRoundTripTest`, + `MultiplexingThroughputTest`, `MultiplexingCoalescingTest`, + `MultiStreamFinDeliveryTest`, `MultiplexingAioquicTpsTest`. - **Adversarial:** `FrameFuzzerTest`, `HostilePacketInputTest`, - `TlsSecurityPropertiesTest`, `HelloRetryRequestTest`. + `TlsSecurityPropertiesTest`, `HelloRetryRequestTest`, + `CloseDatagramRfcComplianceTest`, `CloseUnderLoadTest`. - **Crypto:** `JcaAesGcmAeadTest`, `ChaCha20Poly1305AeadTest`, `TlsTranscriptHashTest`. - **WT / HTTP/3:** `CapsuleReaderTest`, `WtPeerStreamDemuxTest`, `WtFramingTest`. - **Recovery:** `AckTrackerCoalescedTest`, `AckTrackerGatingTest`, - `RecoveryTokenTest`, `SentPacketTest`, `ReceiverFlowControlTest` (9 - cases mirrored from neqo `fc.rs`), `QuicLossDetectionTest`, - `PtoTest`, `QuicConnectionRetransmitTest`, - `SendBufferRetainUntilAckTest` (14 cases for the rewrite), - `StreamRetransmitTest`, `CryptoRetransmitTest`, - `ResetStopSendingEmitTest` (7 cases). -- **Interop:** `InteropRunner` (jvmTest, drives a real socket against a - Dockerised aioquic; opt-in, not in CI). + `AckTrackerPurgeOnAckOfAckTest`, `RecoveryTokenTest`, `SentPacketTest`, + `ReceiverFlowControlTest` (9 cases mirrored from neqo `fc.rs`), + `QuicLossDetectionTest`, `PtoTest`, `PtoCryptoRetransmitTest`, + `QuicConnectionRetransmitTest`, `RetransmitIntegrationTest`, + `SendBufferRetainUntilAckTest` (14 cases), `StreamRetransmitTest`, + `CryptoRetransmitTest`, `ResetStopSendingEmitTest` (7 cases), + `OnTokensLostTest`, `MoqLiteLossHarnessTest`. +- **Path validation / migration:** `PathValidatorTest`, + `PathValidationTest`, `ClientPathMigrationTest`. +- **Key lifecycle:** `KeyDiscardTest`, `KeyUpdatePeerInitiatedTest`. +- **Concurrency / soak:** `BatchedOpenLockContractTest`, + `StreamRetirementSoakTest`, `QuicHeapSoakTest`, + `QuicConnectionDriverLifecycleTest`. +- **Misc:** `VersionNegotiationTest`, `RetryHandlingTest`, + `PacketNumberSpaceTest`, `ConnectionIdTest`, + `FlowControlSnapshotTest`, `PendingFlowControlEmitTest`, + `PeerStreamCreditExtensionTest`, `PeerUniStreamDrainTest`. +- **Interop (jvmTest, opt-in):** `InteropRunner` drives a real socket + against a Dockerised aioquic / picoquic / quic-go. Not in CI. ## Known limitations / deferred work -These are the items future audit rounds keep flagging that we've -consciously not tackled — all confined to the steady-state path that audio -rooms don't exercise heavily: +These are items future audit rounds keep flagging that we've consciously +not tackled: 1. ~~**No STREAM retransmit on loss** (audit-4 #10).~~ **Resolved 2026-05-05** — `SendBuffer` rewritten for retain-until-ACK with three-state range @@ -188,27 +251,85 @@ rooms don't exercise heavily: and RESET_STREAM / STOP_SENDING / NEW_CONNECTION_ID retransmit. See [`2026-05-04-control-frame-retransmit.md`](2026-05-04-control-frame-retransmit.md). 2. ~~**`SendBuffer` doesn't retain bytes until ACK.**~~ Resolved with #1. -3. ~~**No Initial / Handshake key discard.**~~ **Resolved 2026-05-05** — - `LevelState.discardKeys()` nulls the AEAD protection, replaces the - per-level CRYPTO buffers and ack-tracker with empty instances, and - clears the sent-packet map. Initial keys discarded by the writer - after the first Handshake packet is built (RFC 9001 §4.9.1); - Handshake keys discarded by the parser on receipt of HANDSHAKE_DONE - (RFC 9001 §4.9.2 + §4.1.2 client-side handshake confirmation). -4. **No path validation for `NEW_CONNECTION_ID`.** We don't migrate. -5. **Stateless reset detection.** Stateless-reset packets look like - corruption to us. +3. ~~**No Initial / Handshake key discard.**~~ **Resolved 2026-05-05.** +4. ~~**No path validation for `NEW_CONNECTION_ID`.**~~ **Resolved 2026-05-06–08** — + full client-initiated DCID rotation + server-initiated path + 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. 6. ~~**`AckTracker.purgeBelow` threshold semantics.**~~ **Resolved - 2026-05-05** — `RecoveryToken.Ack` now carries `(level, largestAcked)`; - the writer captures these from the AckFrame at emit time, and - `QuicConnection.onTokensAcked` purges the matching per-level inbound - tracker on ACK-of-ACK. The wrong purge in the parser is gone. + 2026-05-05.** 7. **Driver direct unit tests** require turning `UdpSocket` from `expect class` into an interface so the test side can stub. The driver is covered indirectly by every pipe-based test plus the live interop runner. 8. **No congestion control.** Loss detection is wired but nothing - throttles send rate in response. Independent ~1-2 wk project. + throttles send rate in response. Independent ~1–2 wk project. + See [`2026-05-05-congestion-control.md`](2026-05-05-congestion-control.md). + +## RFC compliance gaps (2026-05-09) + +Catalogued from a four-agent compliance audit (RFC 9000 / 9001 / 9002 / +9221+9114+9204). Each item lists the spec section, the gap, and +file:line evidence. Severity: + +- 🔴 **High** — exploitable by a hostile peer (resource exhaustion, + silent desync, RTT poisoning). +- 🟡 **Medium** — interop or correctness risk; cooperative peers + unaffected. +- 🟦 **Low / cosmetic** — diagnostic loss, doesn't break the wire. + +### Receive-side validation gaps + +| RFC § | Sev | Gap | Evidence | +|---|---|---|---| +| RFC 9000 §17.3 | 🔴 | **Inbound short-header fixed-bit (0x40) not validated.** RFC says fixed-bit MUST be 1 on receive. Form-bit (0x80) and reserved-bit (0x18) ARE checked, but 0x40 is never asserted post-HP-unmask. | `ShortHeaderPacket.kt:163-187` (form-bit checked, fixed-bit not) | +| RFC 9000 §10.1 | 🔴 | **`max_idle_timeout` not enforced.** Decoded into config and advertised, but no idle deadline tracking, no auto-close on silence. Connection lives forever if no I/O happens. | `QuicConnection.kt:1040,1054,1069`; `QuicConnectionDriver.kt` (no idle timer) | +| RFC 9000 §4.1 | 🟡 | **Connection-level inbound flow control missing.** Per-stream `receiveLimit` IS enforced (`QuicConnectionParser.kt:655`) — peer overshoot closes the connection. But the aggregate connection-level cap (matching what we advertised in `initial_max_data`) is not tracked on the receive side. | `QuicConnectionParser.kt:735-743` (only updates send-side credit on MAX_DATA) | +| RFC 9000 §3 | 🟡 | **Stream state machine implicit, not validated.** The 10-state machine (Ready / Send / Data Sent / Data Recvd / Reset Sent / Reset Recvd × directions) is encoded as flags + buffer content. STREAM frames arriving after a RESET_STREAM are silently absorbed instead of raising STREAM_STATE_ERROR. (FIN-size mismatch *is* caught — `QuicConnectionParser.kt:672-685`.) | `QuicStream.kt:35-51` (no explicit state field) | +| RFC 9000 §10.2 | 🟡 | **No DRAINING state.** Closing transitions go CONNECTED → CLOSING → CLOSED without the 3 × PTO hold the spec mandates. Late inbound packets after our own CONNECTION_CLOSE may not get a re-emitted close. | `QuicConnection.kt:301,1441-1531` (Status enum has no DRAINING) | +| RFC 9000 §10.3 | 🟡 | **Stateless reset detection missing.** Tokens are stored in `PeerConnectionIdEntry` but no inbound matcher compares fail-to-decrypt datagrams against the token list. Spoofed peer-side resets look like noise. | `PathValidator.kt:35-43`; no consumer in parser | +| RFC 9001 §6.6 | 🟡 | **AEAD invocation limit not tracked.** AES-128-GCM is bounded at 2^23 invocations (~8M packets). Past the limit, key rotation is mandatory. We don't count, don't rotate, don't close with AEAD_LIMIT_REACHED. ChaCha20 is effectively unbounded so less critical. | `Aead.kt`, `JcaAesGcmAead.kt` (no counter) | + +### Transport-parameter bounds + +| RFC § | Sev | Gap | Evidence | +|---|---|---|---| +| RFC 9000 §18.2 | 🟡 | **`max_udp_payload_size < 1200` not rejected.** Spec says minimum 1200; smaller values MUST close with TRANSPORT_PARAMETER_ERROR. | `TransportParameters.kt:165` (decode-only) | +| RFC 9000 §18.2 | 🟡 | **`ack_delay_exponent > 20` not rejected.** Spec maximum is 20. | `TransportParameters.kt:166` (decode-only) | +| RFC 9000 §18.2 | 🟡 | **`active_connection_id_limit < 2` not rejected.** Spec minimum is 2. (We already cap our own pool at the peer's value via `PathValidator.maxUnusedCids`, so the runtime risk is bounded — just no explicit error.) | `TransportParameters.kt:168`; `QuicConnection.kt:582` | +| RFC 9000 §13.2.5 | 🟦 | **`ack_delay_exponent` decode uses our config, not peer's advertised value.** Default (3) matches both ends in practice, but a peer that advertises a non-default exponent gets misinterpreted ACK delays. The shift IS overflow-safe (`QuicConnectionParser.kt:540-553`), just keyed off the wrong side's parameter. | `QuicConnectionParser.kt:547` (uses `conn.config.ackDelayExponent`) | + +### HTTP/3 + DATAGRAM gaps + +| RFC § | Sev | Gap | Evidence | +|---|---|---|---| +| RFC 9114 §7.2.4.1 | 🟡 | **HTTP/2 reserved SETTINGS ids 0x02/0x03/0x04/0x05 not rejected.** RFC says receipt MUST close with H3_SETTINGS_ERROR. We accept and store. | `Http3Settings.kt:85-121` | +| RFC 9221 §3 | 🟡 | **Outbound DATAGRAM size not gated by peer's `max_datagram_frame_size`.** Encoder will send oversize frames; spec-conformant peers will close with PROTOCOL_VIOLATION. | `Frame.kt:278-291` (no size check on send) | +| 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` | + +### TLS / error reporting cosmetics + +| RFC § | Sev | Gap | Evidence | +|---|---|---|---| +| RFC 9001 §4.8 | 🟦 | **TLS alerts not mapped to CRYPTO_ERROR + alert_code.** Connection still closes (a generic `QuicCodecException` propagates) but the alert code is lost — debuggability hit, not a wire-spec violation. | `TlsClient.kt:311-317` (catches as `Throwable`) | +| RFC 9000 §22 | 🟦 | **Several error codes never emitted.** INVALID_TOKEN (no Retry token validation), CRYPTO_BUFFER_EXCEEDED (no buffer limit), KEY_UPDATE_ERROR (uses generic exception path on KeyUpdate failures, despite key update being implemented). | various | + +### Gaps the 2026-05-09 audit got WRONG + +For audit traceability — three audit findings fingered code that's +actually compliant. Listed here so future audit rounds don't repeat +them: + +| Audit claim | Reality | +|---|---| +| "Inbound flow-control enforcement missing." | Per-stream IS enforced. `QuicConnectionParser.kt:655` raises a connection close when STREAM offset+len exceeds `stream.receiveLimit`. The actual gap is *connection-level* (above). | +| "ack_delay_exponent not applied on decode." | IS applied at `QuicConnectionParser.kt:540-553`, with explicit overflow protection per audit-4. The actual gap is using *our* exponent instead of the peer's. | +| "Short-header reserved bits not validated." | ARE validated at `ShortHeaderPacket.kt:182-187` — `QuicProtocolViolationException` raised on any of the 0x18 bits set. The `0x30` mask the audit cited was a mis-read of RFC 9000 §17.3 (reserved bits are 0x18). | ## Pointers @@ -216,5 +337,7 @@ rooms don't exercise heavily: - Audio-rooms NIP draft: `docs/plans/2026-04-22-nip-audio-rooms-draft.md` - Completion plan: `nestsClient/plans/2026-04-26-audio-rooms-completion.md` - Retransmit plan + implementation log: [`2026-05-04-control-frame-retransmit.md`](2026-05-04-control-frame-retransmit.md) +- Congestion control deferral rationale: [`2026-05-05-congestion-control.md`](2026-05-05-congestion-control.md) +- Lock-split design: [`2026-05-08-lock-split-design.md`](2026-05-08-lock-split-design.md) - Live interop runner: `quic/src/jvmTest/.../interop/InteropRunner.kt` -- Audit history: `git log --grep='audit' -- quic/` +- Audit history: `git log --grep='audit\|fix(quic)\|feat(quic)' -- quic/` From a80f4ea19d2e2d28ea986a1e2f1c52f31b112ca6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 9 May 2026 04:29:32 +0000 Subject: [PATCH 02/10] =?UTF-8?q?fix(quic):=20RFC=209000=20=C2=A717.2/?= =?UTF-8?q?=C2=A717.3=20fixed-bit=20+=20=C2=A710.1=20idle-timeout=20enforc?= =?UTF-8?q?ement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two High-severity gaps from the 2026-05-09 four-RFC compliance audit. Fixed-bit (RFC 9000 §17.2 long-header, §17.3 short-header): the spec says fixed-bit (0x40) MUST be 1 in every v1 packet; receivers MUST discard packets where it's 0. Pre-fix the parsers checked only the form-bit (0x80) and accepted any fixed-bit value — a peer or off-path attacker could route packets through AEAD that aren't valid v1 packets. Both `parseAndDecrypt` paths and the `peekKeyPhase` shortcut now silently drop fixed-bit=0. The bit isn't header-protected (HP only XORs the low 5 bits), so the check runs on the raw wire byte before HP unmask + AEAD. Test: `FixedBitValidationTest` (3 cases). Idle timeout (RFC 9000 §10.1): `max_idle_timeout` was decoded into config and advertised via the ClientHello transport-params extension, but never enforced — a black-holed connection lived indefinitely. This commit: * adds `QuicConnection.lastActivityMs`, bumped on (a) successful inbound packet decrypt and (b) outbound ack-eliciting send per §10.1.1 * adds `effectiveIdleTimeoutMs()` returning the min of local and peer advertisements (skipping any side that advertised 0), with the §10.1 `3 * PTO` floor applied * folds the idle deadline into the driver's send-loop `withTimeoutOrNull` so an idle connection wakes exactly at expiry * silently transitions to CLOSED via `markClosedExternally` per §10.2.1 ("the connection enters the closing state silently — discarding the connection state without sending a CONNECTION_CLOSE") Test: `IdleTimeoutTest` (8 cases) covers the min-of-two computation, 0-means-disabled handling, the 3*PTO floor, deadline math, and postpone-on-activity. Plan updated to mark both items resolved. https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik --- quic/plans/2026-04-26-quic-stack-status.md | 6 +- .../quic/connection/QuicConnection.kt | 67 +++++- .../quic/connection/QuicConnectionDriver.kt | 36 +++- .../quic/connection/QuicConnectionParser.kt | 6 + .../quic/connection/QuicConnectionWriter.kt | 14 +- .../quic/packet/LongHeaderPacket.kt | 7 + .../quic/packet/ShortHeaderPacket.kt | 8 + .../quic/connection/IdleTimeoutTest.kt | 172 ++++++++++++++++ .../quic/packet/FixedBitValidationTest.kt | 192 ++++++++++++++++++ 9 files changed, 495 insertions(+), 13 deletions(-) create mode 100644 quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/IdleTimeoutTest.kt create mode 100644 quic/src/commonTest/kotlin/com/vitorpamplona/quic/packet/FixedBitValidationTest.kt diff --git a/quic/plans/2026-04-26-quic-stack-status.md b/quic/plans/2026-04-26-quic-stack-status.md index 4008faa4b9..83e6afc9c6 100644 --- a/quic/plans/2026-04-26-quic-stack-status.md +++ b/quic/plans/2026-04-26-quic-stack-status.md @@ -193,7 +193,7 @@ gates on the audio-rooms completion plan | 8 | 2026-05-06 | Path validation pass 1 + 2 | DCID rotation, retire-prior-to monotonicity, abrupt migration | all fixed | | 9 | 2026-05-07 | DoS hardening + reserved-bit checks + TLS bounds + SendBuffer shrink | bound peer-controlled buffers and channels | all fixed | | 10 | 2026-05-08 | Lock-split design + close-under-load + key-update PN gate | streamsLock + lifecycleLock split | all fixed; see [`2026-05-08-lock-split-design.md`](2026-05-08-lock-split-design.md) | -| 11 | 2026-05-09 | Four-RFC compliance audit (4 parallel agents: 9000, 9001, 9002, 9221+9114+9204) | ~15 items, mostly receive-side validation gaps | catalogued in [§ RFC compliance gaps](#rfc-compliance-gaps-2026-05-09) below | +| 11 | 2026-05-09 | Four-RFC compliance audit (4 parallel agents: 9000, 9001, 9002, 9221+9114+9204) | ~15 items, mostly receive-side validation gaps | catalogued in [§ RFC compliance gaps](#rfc-compliance-gaps-2026-05-09) below; both 🔴 High items (fixed-bit, idle timeout) fixed same-day | Every fix carries an inline `audit-N #M` reference comment so the regression test → fix → comment chain is auditable. The whole audit corpus is in the @@ -287,8 +287,8 @@ file:line evidence. Severity: | RFC § | Sev | Gap | Evidence | |---|---|---|---| -| RFC 9000 §17.3 | 🔴 | **Inbound short-header fixed-bit (0x40) not validated.** RFC says fixed-bit MUST be 1 on receive. Form-bit (0x80) and reserved-bit (0x18) ARE checked, but 0x40 is never asserted post-HP-unmask. | `ShortHeaderPacket.kt:163-187` (form-bit checked, fixed-bit not) | -| RFC 9000 §10.1 | 🔴 | **`max_idle_timeout` not enforced.** Decoded into config and advertised, but no idle deadline tracking, no auto-close on silence. Connection lives forever if no I/O happens. | `QuicConnection.kt:1040,1054,1069`; `QuicConnectionDriver.kt` (no idle timer) | +| RFC 9000 §17.2 + §17.3 | ~~🔴~~ | ~~**Inbound fixed-bit (0x40) not validated** on long- or short-header receive.~~ **Resolved 2026-05-09** — both `LongHeaderPacket.parseAndDecrypt`, `ShortHeaderPacket.parseAndDecrypt`, and `ShortHeaderPacket.peekKeyPhase` now reject fixed-bit=0 packets per RFC §17.2 / §17.3 (silent discard). Tests in `FixedBitValidationTest`. | +| RFC 9000 §10.1 | ~~🔴~~ | ~~**`max_idle_timeout` not enforced.**~~ **Resolved 2026-05-09** — `QuicConnection.lastActivityMs` updated on inbound packet receipt and outbound ack-eliciting send; `effectiveIdleTimeoutMs()` computes min(local, peer) with 3*PTO floor per §10.1; driver send-loop folds the deadline into its `withTimeoutOrNull` and silently closes via `markClosedExternally` per §10.2.1 on expiry. Tests in `IdleTimeoutTest` (8 cases). | | RFC 9000 §4.1 | 🟡 | **Connection-level inbound flow control missing.** Per-stream `receiveLimit` IS enforced (`QuicConnectionParser.kt:655`) — peer overshoot closes the connection. But the aggregate connection-level cap (matching what we advertised in `initial_max_data`) is not tracked on the receive side. | `QuicConnectionParser.kt:735-743` (only updates send-side credit on MAX_DATA) | | RFC 9000 §3 | 🟡 | **Stream state machine implicit, not validated.** The 10-state machine (Ready / Send / Data Sent / Data Recvd / Reset Sent / Reset Recvd × directions) is encoded as flags + buffer content. STREAM frames arriving after a RESET_STREAM are silently absorbed instead of raising STREAM_STATE_ERROR. (FIN-size mismatch *is* caught — `QuicConnectionParser.kt:672-685`.) | `QuicStream.kt:35-51` (no explicit state field) | | RFC 9000 §10.2 | 🟡 | **No DRAINING state.** Closing transitions go CONNECTED → CLOSING → CLOSED without the 3 × PTO hold the spec mandates. Late inbound packets after our own CONNECTION_CLOSE may not get a re-emitted close. | `QuicConnection.kt:301,1441-1531` (Status enum has no DRAINING) | 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 6436a32a05..647f9442ea 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt @@ -296,7 +296,7 @@ class QuicConnection( */ @Volatile var peerTransportParameters: TransportParameters? = null - private set + internal set enum class Status { HANDSHAKING, CONNECTED, CLOSING, CLOSED } @@ -310,6 +310,29 @@ class QuicConnection( var status: Status = Status.HANDSHAKING internal set + /** + * RFC 9000 §10.1 idle-timeout tracking. The driver fires + * [markClosedExternally] (silent close, no CONNECTION_CLOSE per + * §10.2.1) when the time since this timestamp exceeds + * [effectiveIdleTimeoutMs]. + * + * Updated on: + * - successful inbound packet decrypt (parser) + * - outbound ack-eliciting packet emission (writer) + * + * Initialized to the connection's start time so the timer can fire + * even if no inbound packet ever arrives (e.g. handshake never + * completes on a black-holed path). + * + * @Volatile because the driver reads it from the send loop while the + * parser / writer mutate it from feed/drain coroutines under + * `streamsLock`. A torn long read would mis-arm the deadline by at + * most one packet's worth of time, but volatile is cheaper than the + * alternative (locking around every observation). + */ + @Volatile + internal var lastActivityMs: Long = nowMillis() + /** * Non-suspend monitor protecting the atomic transition of * [status] / [closeReason] / [closeErrorCode] from a non-CLOSED to a @@ -2318,6 +2341,48 @@ class QuicConnection( currentPtoMillis: Long = lossDetection.ptoBaseMs(peerTransportParameters?.maxAckDelay ?: 0L), ): PathMigrationResult = streamsLock.withLock { triggerPathMigrationLocked(nowMillis, currentPtoMillis) } + /** + * RFC 9000 §10.1: effective idle-timeout in milliseconds, or null + * if neither endpoint advertised a non-zero value (timeout is + * disabled). The effective timeout is the minimum of the + * locally-configured value and the peer's advertised value, after + * dropping any side that advertised 0 (per §18.2 a value of 0 + * means "no timeout"). + * + * §10.1 also requires we increase the timeout to at least 3 * PTO + * to avoid timing out before loss detection has had a chance to + * recover the path. + */ + internal fun effectiveIdleTimeoutMs(): Long? { + val local = config.maxIdleTimeoutMillis.takeIf { it > 0L } + val peer = peerTransportParameters?.maxIdleTimeoutMillis?.takeIf { it > 0L } + val agreed = + when { + local != null && peer != null -> minOf(local, peer) + local != null -> local + peer != null -> peer + else -> return null + } + // §10.1: floor at 3 * PTO so loss recovery has time to fire. + // Pre-handshake we don't have peer max_ack_delay yet, fall back + // to 0 (matches the writer's pre-handshake gating). + val maxAckDelayMs = peerTransportParameters?.maxAckDelay ?: 0L + val ptoBaseMs = lossDetection.ptoBaseMs(maxAckDelayMs).coerceAtLeast(1L) + val floor = ptoBaseMs * 3L + return agreed.coerceAtLeast(floor) + } + + /** + * RFC 9000 §10.1.1 idle-timer tick. Called by the driver after a + * [withTimeoutOrNull] expiry to check whether the silence has + * exceeded [effectiveIdleTimeoutMs]. Returns true on timeout + * (caller should silently close per §10.2.1). + */ + internal fun isIdleTimedOut(nowMillis: Long): Boolean { + val timeoutMs = effectiveIdleTimeoutMs() ?: return false + return nowMillis - lastActivityMs >= timeoutMs + } + /** * Check whether an outstanding PATH_CHALLENGE has exceeded the * 3 * PTO budget (RFC 9000 §8.2.4). Called from the driver's diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionDriver.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionDriver.kt index f400498e3c..642a86ddc2 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionDriver.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionDriver.kt @@ -288,8 +288,22 @@ class QuicConnectionDriver( connection.handshake.nextLossTimeMs, connection.application.nextLossTimeMs, ).minOrNull()?.let { (it - nowForLossTimer).coerceAtLeast(0L) } + // RFC 9000 §10.1 idle-timeout deadline. Null when neither + // endpoint advertised a non-zero `max_idle_timeout`. We + // fold the remaining time into the same `withTimeoutOrNull` + // sleep so an idle connection wakes exactly at expiry + // instead of polling. + val idleTimeoutMs = connection.effectiveIdleTimeoutMs() + val idleDelta = + idleTimeoutMs?.let { + (connection.lastActivityMs + it - nowForLossTimer).coerceAtLeast(0L) + } val sleepMillis = - if (nextLossDelta != null && nextLossDelta < ptoMillis) nextLossDelta else ptoMillis + minOf( + ptoMillis, + nextLossDelta ?: Long.MAX_VALUE, + idleDelta ?: Long.MAX_VALUE, + ) // Suspend until either: a wakeup arrives, or the timer expires. val woke = withTimeoutOrNull(sleepMillis) { @@ -297,12 +311,20 @@ class QuicConnectionDriver( Unit } if (woke == null) { - // Distinguish loss-timer expiry from PTO expiry. A - // loss-timer wake just runs `detectAndRemoveLost` - // across the levels — newly time-threshold-lost - // packets get their tokens re-queued for retransmit on - // the next drain. A PTO wake additionally bumps the - // consecutive-PTO counter and arms the probe budget. + // Distinguish idle-timeout / loss-timer / PTO expiry. An + // idle-timeout wake silently closes the connection per + // RFC 9000 §10.2.1 ("the connection enters the closing + // state silently — discarding the connection state + // without sending a CONNECTION_CLOSE frame"). A + // loss-timer wake just runs `detectAndRemoveLost`. A + // PTO wake additionally bumps the consecutive-PTO + // counter and arms the probe budget. + if (idleTimeoutMs != null && connection.isIdleTimedOut(nowMillis())) { + connection.markClosedExternally( + "idle timeout ($idleTimeoutMs ms with no activity)", + ) + continue + } val pickedLossTimer = nextLossDelta != null && nextLossDelta < ptoMillis if (pickedLossTimer) { handleLossTimerFired(connection) 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 08e7910291..1d00421582 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt @@ -259,6 +259,9 @@ private fun feedLongHeaderPacket( } state.pnSpace.observeInbound(parsed.packet.packetNumber, nowMillis) + // RFC 9000 §10.1.1: any successfully processed inbound packet + // resets the idle timer. + conn.lastActivityMs = nowMillis // The server's source CID becomes our destination CID for subsequent packets. if (level == EncryptionLevel.INITIAL) { @@ -450,6 +453,9 @@ private fun feedShortHeaderPacket( conn.keyUpdateInProgress = false } state.pnSpace.observeInbound(parsed.packet.packetNumber, nowMillis) + // RFC 9000 §10.1.1: any successfully processed inbound packet + // resets the idle timer. + conn.lastActivityMs = nowMillis if (conn.qlogObserver !== com.vitorpamplona.quic.observability.QlogObserver.NoOp) { conn.qlogObserver.onPacketReceived( level = EncryptionLevel.APPLICATION, diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt index 89a8b9cfa1..137255ac26 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt @@ -544,14 +544,19 @@ private fun buildLongHeaderFromFrames( // map insert below overwrites the prior entry, so the recorded // SentPacket reflects the final padded packet's size — correct // for retransmit purposes. + val ackEliciting = isAckEliciting(frames) state.sentPackets[pn] = SentPacket( packetNumber = pn, sentAtMillis = nowMillis, - ackEliciting = isAckEliciting(frames), + ackEliciting = ackEliciting, sizeBytes = packet.size, tokens = tokens, ) + // RFC 9000 §10.1.1: an ack-eliciting outbound packet resets the + // idle timer. (Non-ack-eliciting packets — pure ACK or PADDING-only + // — do NOT reset.) + if (ackEliciting) conn.lastActivityMs = nowMillis emitQlogSent(conn, level, pn, packet.size, frames) return packet } @@ -866,14 +871,19 @@ private fun buildApplicationPacket( ) } } + val ackEliciting = isAckEliciting(frames) state.sentPackets[pn] = SentPacket( packetNumber = pn, sentAtMillis = nowMillis, - ackEliciting = isAckEliciting(frames), + ackEliciting = ackEliciting, sizeBytes = sizeBytes.getOrNull()?.size ?: 0, tokens = tokens.toList(), ) + // RFC 9000 §10.1.1: an ack-eliciting outbound packet resets the + // idle timer. (Non-ack-eliciting packets — pure ACK or PADDING-only + // — do NOT reset.) + if (ackEliciting) conn.lastActivityMs = nowMillis sizeBytes.getOrNull()?.let { built -> emitQlogSent(conn, EncryptionLevel.APPLICATION, pn, built.size, frames) } diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/packet/LongHeaderPacket.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/packet/LongHeaderPacket.kt index c54d7b36a2..63b4ec5a09 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/packet/LongHeaderPacket.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/packet/LongHeaderPacket.kt @@ -164,6 +164,13 @@ object LongHeaderPacket { val r = QuicReader(bytes, offset) val first = r.readByte() if ((first and 0x80) == 0) return null // not a long header — silently drop + // RFC 9000 §17.2: the fixed-bit (0x40) MUST be 1 in a v1 long-header + // packet. Packets with 0 here are not valid v1 packets and MUST be + // discarded. Version Negotiation (§17.2.1) is the one exception + // (its fixed-bit is unconstrained), but VN is detected upstream in + // [feedDatagram] before we get here, so any path that reaches + // parseAndDecrypt is required to have fixed-bit=1. + if ((first and 0x40) == 0) return null val typeBits = (first ushr 4) and 0x03 val type = LongHeaderType.fromTypeBits(typeBits) val version = r.readUint32().toInt() diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/packet/ShortHeaderPacket.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/packet/ShortHeaderPacket.kt index 835861bac4..21b8a6f428 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/packet/ShortHeaderPacket.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/packet/ShortHeaderPacket.kt @@ -130,6 +130,11 @@ object ShortHeaderPacket { if (offset >= bytes.size) return null val first = bytes[offset].toInt() and 0xFF if ((first and 0x80) != 0) return null + // RFC 9000 §17.3: short-header fixed-bit (0x40) MUST be 1. Packets + // with 0 here are not valid v1 packets and MUST be discarded. The + // bit is not header-protected (HP only XORs the low 5 bits), so we + // check it on the raw byte before paying for HP unmask + AEAD. + if ((first and 0x40) == 0) return null val pnOffset = offset + 1 + dcidLen val sampleStart = pnOffset + 4 if (sampleStart + 16 > bytes.size) return null @@ -162,6 +167,9 @@ object ShortHeaderPacket { if (offset >= bytes.size) return null val first = bytes[offset].toInt() and 0xFF if ((first and 0x80) != 0) return null + // RFC 9000 §17.3: short-header fixed-bit (0x40) MUST be 1. Packets + // with 0 here are not valid v1 packets and MUST be discarded. + if ((first and 0x40) == 0) return null val pnOffset = offset + 1 + dcidLen val sampleStart = pnOffset + 4 if (sampleStart + 16 > bytes.size) return null diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/IdleTimeoutTest.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/IdleTimeoutTest.kt new file mode 100644 index 0000000000..f28214fde2 --- /dev/null +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/IdleTimeoutTest.kt @@ -0,0 +1,172 @@ +/* + * 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.connection + +import com.vitorpamplona.quic.tls.PermissiveCertificateValidator +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * RFC 9000 §10.1 idle-timeout enforcement. + * + * - §10.1: effective timeout = min of local + peer advertisements + * (skipping any side that advertised 0). Floored at 3 * PTO so loss + * recovery has time to fire before the connection times out. + * - §10.1.1: idle timer resets on (a) any successfully processed + * inbound packet, (b) outbound ack-eliciting packet emission. + * - §10.2.1: on expiry the connection enters CLOSED silently — no + * CONNECTION_CLOSE frame. + * + * Pre-fix `max_idle_timeout` was decoded into config + advertised in + * the ClientHello transport-params extension, but never enforced — a + * black-holed connection lived forever. + */ +class IdleTimeoutTest { + private fun newConnection( + nowMillis: () -> Long, + localIdleMs: Long, + ): QuicConnection = + QuicConnection( + serverName = "example.test", + config = + QuicConnectionConfig( + maxIdleTimeoutMillis = localIdleMs, + ), + tlsCertificateValidator = PermissiveCertificateValidator(), + nowMillis = nowMillis, + ) + + @Test + fun effective_timeout_uses_min_of_local_and_peer() { + val conn = newConnection(nowMillis = { 0L }, localIdleMs = 30_000L) + // No peer params yet → only local advertised. + assertEquals(30_000L, conn.effectiveIdleTimeoutMs()) + + // Peer advertises smaller — min wins. + conn.peerTransportParameters = TransportParameters(maxIdleTimeoutMillis = 10_000L) + assertEquals(10_000L, conn.effectiveIdleTimeoutMs()) + + // Peer advertises larger — local still smaller, local wins. + conn.peerTransportParameters = TransportParameters(maxIdleTimeoutMillis = 60_000L) + assertEquals(30_000L, conn.effectiveIdleTimeoutMs()) + } + + @Test + fun effective_timeout_skips_zero_advertisements() { + // Local 0, peer 30s → peer wins (RFC 9000 §18.2: 0 means disabled). + val conn = newConnection(nowMillis = { 0L }, localIdleMs = 0L) + conn.peerTransportParameters = TransportParameters(maxIdleTimeoutMillis = 30_000L) + assertEquals(30_000L, conn.effectiveIdleTimeoutMs()) + + // Both 0 / unset → disabled (null). + val conn2 = newConnection(nowMillis = { 0L }, localIdleMs = 0L) + assertNull(conn2.effectiveIdleTimeoutMs()) + + val conn3 = newConnection(nowMillis = { 0L }, localIdleMs = 0L) + conn3.peerTransportParameters = TransportParameters(maxIdleTimeoutMillis = 0L) + assertNull(conn3.effectiveIdleTimeoutMs()) + } + + @Test + fun effective_timeout_floored_at_three_PTO() { + // §10.1: floor = 3 * PTO. Pre-handshake PTO base ≈ + // smoothed_rtt(100ms) + max(4*rttvar(50ms), 1ms) + max_ack_delay(0) + // = 300 ms; 3 * 300 = 900 ms. + // A configured 200 ms idle timeout MUST be floored, not honored + // verbatim, otherwise a one-RTT loss recovery period would + // outlast the connection. + val conn = newConnection(nowMillis = { 0L }, localIdleMs = 200L) + val effective = conn.effectiveIdleTimeoutMs() + assertNotNull(effective) + assertTrue(effective >= 900L, "expected floor of 900 ms, got $effective") + } + + @Test + fun isIdleTimedOut_false_at_construction() { + val now = mutableLongOf(0L) + val conn = newConnection(nowMillis = now::get, localIdleMs = 10_000L) + assertFalse(conn.isIdleTimedOut(now.get())) + } + + @Test + fun isIdleTimedOut_false_before_deadline() { + val now = mutableLongOf(0L) + val conn = newConnection(nowMillis = now::get, localIdleMs = 10_000L) + // Just under the deadline. + now.set(9_999L) + assertFalse(conn.isIdleTimedOut(now.get())) + } + + @Test + fun isIdleTimedOut_true_at_deadline() { + val now = mutableLongOf(0L) + val conn = newConnection(nowMillis = now::get, localIdleMs = 10_000L) + now.set(10_000L) + assertTrue(conn.isIdleTimedOut(now.get())) + } + + @Test + fun lastActivityMs_bump_postpones_deadline() { + val now = mutableLongOf(0L) + val conn = newConnection(nowMillis = now::get, localIdleMs = 10_000L) + // Halfway through the idle window an inbound packet arrives. + now.set(5_000L) + conn.lastActivityMs = now.get() + // Deadline shifts to 15_000 — still inside at 14_000. + now.set(14_000L) + assertFalse(conn.isIdleTimedOut(now.get())) + // Past the new deadline. + now.set(15_000L) + assertTrue(conn.isIdleTimedOut(now.get())) + } + + @Test + fun isIdleTimedOut_never_when_disabled() { + val now = mutableLongOf(0L) + val conn = newConnection(nowMillis = now::get, localIdleMs = 0L) + // Even at simulated wallclock infinity, no timeout is enforced. + now.set(Long.MAX_VALUE / 2) + assertFalse(conn.isIdleTimedOut(now.get())) + } +} + +/** + * Minimal mutable Long holder for clock injection. Avoids depending on + * kotlinx.atomicfu in test sources where atomicity isn't needed (the + * clock is bumped from the test thread, read from the same thread). + */ +private class MutableLong( + initial: Long, +) { + private var value = initial + + fun get(): Long = value + + fun set(v: Long) { + value = v + } +} + +private fun mutableLongOf(initial: Long): MutableLong = MutableLong(initial) diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/packet/FixedBitValidationTest.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/packet/FixedBitValidationTest.kt new file mode 100644 index 0000000000..c4bcc07dce --- /dev/null +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/packet/FixedBitValidationTest.kt @@ -0,0 +1,192 @@ +/* + * 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.packet + +import com.vitorpamplona.quic.connection.ConnectionId +import com.vitorpamplona.quic.crypto.Aes128Gcm +import com.vitorpamplona.quic.crypto.AesEcbHeaderProtection +import com.vitorpamplona.quic.crypto.InitialSecrets +import com.vitorpamplona.quic.crypto.PlatformAesOneBlock +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** + * RFC 9000 §17.2 (long-header) and §17.3 (short-header): the fixed-bit + * (0x40) of the first byte MUST be 1 in every v1 packet. Packets with + * fixed-bit=0 MUST be discarded. + * + * Pre-fix the parsers checked only the form-bit (0x80) and accepted the + * packet regardless of fixed-bit. A peer (or off-path attacker) sending a + * packet with fixed-bit=0 would have its bytes routed through AEAD — + * cheap denial-of-service amplifier and a wire-spec violation we accepted + * silently. + * + * The fixed-bit is NOT header-protected (HP only XORs the low five bits + * of the first byte), so we can validate it on the raw wire byte BEFORE + * paying for HP unmask + AEAD. Both `parseAndDecrypt` and the + * `peekKeyPhase` shortcut do the check. + */ +class FixedBitValidationTest { + private val dcid = ConnectionId("8394c8f03e515708".hexToByteArray()) + private val scid = ConnectionId("01020304".hexToByteArray()) + private val proto = InitialSecrets.derive(dcid.bytes) + private val hp = AesEcbHeaderProtection(PlatformAesOneBlock) + + @Test + fun long_header_fixed_bit_zero_is_discarded() { + val plain = + LongHeaderPlaintextPacket( + type = LongHeaderType.INITIAL, + dcid = dcid, + scid = scid, + packetNumber = 0L, + payload = "01".hexToByteArray(), + ) + val wire = + LongHeaderPacket.build( + plain = plain, + aead = Aes128Gcm, + key = proto.clientKey, + iv = proto.clientIv, + hp = hp, + hpKey = proto.clientHp, + largestAckedInSpace = -1L, + ) + + // Sanity check: the unmodified wire round-trips. + val good = + LongHeaderPacket.parseAndDecrypt( + bytes = wire, + offset = 0, + aead = Aes128Gcm, + key = proto.clientKey, + iv = proto.clientIv, + hp = hp, + hpKey = proto.clientHp, + largestReceivedInSpace = -1L, + ) + assertNotNull(good) + + // Flip fixed-bit (0x40) on the wire's first byte. The bit is NOT + // header-protected, so flipping it does not affect HP unmask of + // the type / pnLen bits — the packet is structurally valid in + // every other way; it just isn't a v1 packet anymore. + val tampered = wire.copyOf() + tampered[0] = (tampered[0].toInt() xor 0x40).toByte() + val parsed = + LongHeaderPacket.parseAndDecrypt( + bytes = tampered, + offset = 0, + aead = Aes128Gcm, + key = proto.clientKey, + iv = proto.clientIv, + hp = hp, + hpKey = proto.clientHp, + largestReceivedInSpace = -1L, + ) + assertNull(parsed, "RFC 9000 §17.2: long-header fixed-bit=0 MUST be discarded") + } + + @Test + fun short_header_fixed_bit_zero_is_discarded_in_parseAndDecrypt() { + val plain = + ShortHeaderPlaintextPacket( + dcid = dcid, + packetNumber = 0L, + payload = byteArrayOf(0x01), + ) + val wire = + ShortHeaderPacket.build( + plain = plain, + aead = Aes128Gcm, + key = proto.clientKey, + iv = proto.clientIv, + hp = hp, + hpKey = proto.clientHp, + largestAckedInSpace = -1L, + ) + + val good = + ShortHeaderPacket.parseAndDecrypt( + bytes = wire, + offset = 0, + dcidLen = dcid.length, + aead = Aes128Gcm, + key = proto.clientKey, + iv = proto.clientIv, + hp = hp, + hpKey = proto.clientHp, + largestReceivedInSpace = -1L, + ) + assertNotNull(good) + + val tampered = wire.copyOf() + tampered[0] = (tampered[0].toInt() xor 0x40).toByte() + val parsed = + ShortHeaderPacket.parseAndDecrypt( + bytes = tampered, + offset = 0, + dcidLen = dcid.length, + aead = Aes128Gcm, + key = proto.clientKey, + iv = proto.clientIv, + hp = hp, + hpKey = proto.clientHp, + largestReceivedInSpace = -1L, + ) + assertNull(parsed, "RFC 9000 §17.3: short-header fixed-bit=0 MUST be discarded") + } + + @Test + fun short_header_fixed_bit_zero_is_discarded_in_peekKeyPhase() { + val plain = + ShortHeaderPlaintextPacket( + dcid = dcid, + packetNumber = 0L, + payload = byteArrayOf(0x01), + ) + val wire = + ShortHeaderPacket.build( + plain = plain, + aead = Aes128Gcm, + key = proto.clientKey, + iv = proto.clientIv, + hp = hp, + hpKey = proto.clientHp, + largestAckedInSpace = -1L, + ) + // peekKeyPhase is the upstream gate the parser uses to pick keys + // before AEAD; if it accepts a fixed-bit=0 packet we waste an + // AEAD attempt before parseAndDecrypt drops it. + val tampered = wire.copyOf() + tampered[0] = (tampered[0].toInt() xor 0x40).toByte() + val peek = + ShortHeaderPacket.peekKeyPhase( + bytes = tampered, + offset = 0, + dcidLen = dcid.length, + hp = hp, + hpKey = proto.clientHp, + ) + assertNull(peek, "RFC 9000 §17.3: peekKeyPhase MUST reject fixed-bit=0") + } +} From 657306392d12e3db86ae33b2d572bda1a684ed54 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 9 May 2026 04:37:50 +0000 Subject: [PATCH 03/10] =?UTF-8?q?fix(quic):=20RFC=209000=20=C2=A718.2=20tr?= =?UTF-8?q?ansport-param=20bounds=20+=20=C2=A713.2.5=20ack-delay-exponent?= =?UTF-8?q?=20+=20=C2=A77.2.4.1=20reserved=20SETTINGS=20+=20RFC=209221=20?= =?UTF-8?q?=C2=A73=20outbound=20DATAGRAM=20size?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five spec-compliance fixes from the 2026-05-09 audit, all in the same "hostile-peer hardening" tier. * RFC 9000 §18.2 — `applyPeerTransportParameters` now closes the connection with TRANSPORT_PARAMETER_ERROR when the peer advertises `max_udp_payload_size < 1200`, `ack_delay_exponent > 20`, or `active_connection_id_limit < 2`. Pre-fix the values were decoded but never bounds-checked. * RFC 9000 §13.2.5 — the parser's ACK-delay decoding now uses the PEER's advertised `ack_delay_exponent` (with the §18.2 default of 3 as the pre-handshake fallback) instead of our own config value. Defensive coercion to 0..20 is preserved so a bypass of the bounds check above can't desync the RTT estimator. * RFC 9114 §7.2.4.1 — `Http3Settings.decodeBody` now rejects the HTTP/2-reserved SETTINGS ids 0x02, 0x03, 0x04, 0x05 with H3_SETTINGS_ERROR. Pre-fix they fell through to the generic 1<<32 cap and were accepted. * RFC 9221 §3 — the writer now drops outbound DATAGRAM frames when the peer didn't advertise `max_datagram_frame_size` (or advertised 0), or when the encoded frame (type byte + length varint + payload) would exceed the peer's advertised value. Pre-fix the writer emitted DATAGRAM regardless and let spec-conformant peers close us with PROTOCOL_VIOLATION. * Added `TransportParameterDefaults` for the §18.2 defaults (ack_delay_exponent=3, max_ack_delay=25ms, active_connection_id_limit=2) so callers don't hard-code the magic numbers. Tests: `TransportParameterBoundsTest` (8 cases) drives a real handshake through the in-process pipe and asserts CLOSED on each violation + CONNECTED at the boundary; `Http3ReservedSettingsTest` (5 cases) covers the four reserved ids and the adjacent legal ids. Plan tier dropped from 🟡 Medium to resolved for all five items. https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik --- .../quic/connection/QuicConnection.kt | 36 ++++ .../quic/connection/QuicConnectionParser.kt | 15 +- .../quic/connection/QuicConnectionWriter.kt | 29 ++- .../quic/connection/TransportParameters.kt | 17 ++ .../vitorpamplona/quic/http3/Http3Settings.kt | 9 + .../TransportParameterBoundsTest.kt | 188 ++++++++++++++++++ .../quic/http3/Http3ReservedSettingsTest.kt | 88 ++++++++ 7 files changed, 379 insertions(+), 3 deletions(-) create mode 100644 quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/TransportParameterBoundsTest.kt create mode 100644 quic/src/commonTest/kotlin/com/vitorpamplona/quic/http3/Http3ReservedSettingsTest.kt 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 647f9442ea..dee3f687d0 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt @@ -1132,6 +1132,42 @@ class QuicConnection( ) return } + // RFC 9000 §18.2 bounds checks. A peer that violates these is in + // protocol violation and the connection MUST close with + // TRANSPORT_PARAMETER_ERROR. + // + // - max_udp_payload_size: minimum 1200 (the §14 datagram size + // floor). A smaller value would force fragmented Initials, + // which our writer can't produce. + // - ack_delay_exponent: maximum 20. Beyond that, the + // `ackDelay << exponent` shift in the parser overflows even + // with the clamp. + // - active_connection_id_limit: minimum 2. A value < 2 leaves + // the peer no spare CID to migrate to. + tp.maxUdpPayloadSize?.let { v -> + if (v < 1200L) { + markClosedExternally( + "TRANSPORT_PARAMETER_ERROR: max_udp_payload_size $v < 1200", + ) + return + } + } + tp.ackDelayExponent?.let { v -> + if (v > 20L) { + markClosedExternally( + "TRANSPORT_PARAMETER_ERROR: ack_delay_exponent $v > 20", + ) + return + } + } + tp.activeConnectionIdLimit?.let { v -> + if (v < 2L) { + markClosedExternally( + "TRANSPORT_PARAMETER_ERROR: active_connection_id_limit $v < 2", + ) + return + } + } peerTransportParameters = tp qlogObserver.onTransportParametersSet("remote", peerTransportParametersSummary(tp)) sendConnectionFlowCredit = tp.initialMaxData ?: 0L 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 1d00421582..946f716d89 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt @@ -550,8 +550,19 @@ private fun dispatchFrames( // before consuming. Per RFC 9000 §18.2 the exponent is // 0..20; we further clamp ackDelay so the shift never // overflows (`ackDelay <= Long.MAX_VALUE >>> exponent`). - val rawExponent = conn.config.ackDelayExponent - val exponent = rawExponent.coerceIn(0L, 20L).toInt() + // + // RFC 9000 §13.2.5: a received ACK is decoded using the + // PEER's `ack_delay_exponent`, not ours. Pre-handshake + // peer params haven't arrived yet — fall back to the + // default of 3 per §18.2. Coercion below ensures even + // a malicious peer that smuggles a >20 value through + // the §18.2 bounds check (e.g. on a connection where + // the peer-params bounds enforcement is bypassed by a + // race) can't desync our RTT estimator. + val peerExponent = + conn.peerTransportParameters?.ackDelayExponent + ?: TransportParameterDefaults.ACK_DELAY_EXPONENT + val exponent = peerExponent.coerceIn(0L, 20L).toInt() val maxAckDelayPreShift = if (exponent == 0) Long.MAX_VALUE else Long.MAX_VALUE ushr exponent val safeAckDelay = frame.ackDelay.coerceIn(0L, maxAckDelayPreShift) diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt index 137255ac26..c7e76c3e9b 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt @@ -669,9 +669,36 @@ private fun buildApplicationPacket( // stream and MAX_DATA at the connection level. appendFlowControlUpdates(conn, frames, tokens) - // Pending datagrams + // Pending datagrams. RFC 9221 §3 enforcement: + // - if the peer didn't advertise `max_datagram_frame_size` (or + // advertised 0), DATAGRAM MUST NOT be sent — drop with diagnostic; + // - if the encoded frame would exceed the peer's advertised + // `max_datagram_frame_size` (frame type byte + length varint + + // payload), drop with diagnostic. + // Pre-fix the writer emitted DATAGRAM regardless and let + // spec-conformant peers close the connection with PROTOCOL_VIOLATION. + val peerDatagramCap = conn.peerTransportParameters?.maxDatagramFrameSize ?: 0L while (conn.pendingDatagramsLocked().isNotEmpty()) { val payload = conn.pendingDatagramsLocked().removeFirst() + if (peerDatagramCap <= 0L) { + conn.qlogObserver.onPacketDropped( + "outbound DATAGRAM dropped — peer did not advertise max_datagram_frame_size", + payload.size, + ) + continue + } + // Frame total = 1 byte type (0x31) + varint(payload.size) + payload. + val frameSize = + 1 + + com.vitorpamplona.quic.Varint + .size(payload.size.toLong()) + payload.size + if (frameSize > peerDatagramCap) { + conn.qlogObserver.onPacketDropped( + "outbound DATAGRAM dropped — frame size $frameSize > peer cap $peerDatagramCap", + payload.size, + ) + continue + } frames += DatagramFrame(payload, explicitLength = true) if (frames.size >= 16) break } diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/TransportParameters.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/TransportParameters.kt index d06debe982..a343bf3a94 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/TransportParameters.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/TransportParameters.kt @@ -52,6 +52,23 @@ object TransportParameterId { const val MAX_DATAGRAM_FRAME_SIZE: Long = 0x20 } +/** + * RFC 9000 §18.2 default values for parameters that are NOT advertised + * by the peer. Surfacing them as constants so the parser / writer can + * use them as the "peer didn't tell us" fallback without each call site + * hard-coding the numeric default. + */ +object TransportParameterDefaults { + /** §18.2: default `ack_delay_exponent` is 3 if not advertised. */ + const val ACK_DELAY_EXPONENT: Long = 3L + + /** §18.2: default `max_ack_delay` is 25 ms if not advertised. */ + const val MAX_ACK_DELAY_MS: Long = 25L + + /** §18.2: default `active_connection_id_limit` is 2 if not advertised. */ + const val ACTIVE_CONNECTION_ID_LIMIT: Long = 2L +} + /** * QUIC transport parameters as exchanged inside the TLS QUIC transport_params * extension. diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/http3/Http3Settings.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/http3/Http3Settings.kt index cab63e806f..aad9d829f2 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/http3/Http3Settings.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/http3/Http3Settings.kt @@ -86,6 +86,15 @@ data class Http3Settings( id: Long, value: Long, ) { + // RFC 9114 §7.2.4.1: SETTINGS identifiers in the HTTP/2 range + // (0x02, 0x03, 0x04, 0x05) are reserved to prevent confusion + // with HTTP/2 settings — receipt MUST be a connection error of + // type H3_SETTINGS_ERROR. Pre-fix we accepted them silently. + if (id == 0x02L || id == 0x03L || id == 0x04L || id == 0x05L) { + throw com.vitorpamplona.quic.QuicCodecException( + "H3_SETTINGS_ERROR: reserved HTTP/2 SETTINGS id 0x${id.toString(16)}", + ) + } if (value < 0L) { throw com.vitorpamplona.quic.QuicCodecException( "negative HTTP/3 SETTINGS value for id 0x${id.toString(16)}: $value", diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/TransportParameterBoundsTest.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/TransportParameterBoundsTest.kt new file mode 100644 index 0000000000..2a2717abbc --- /dev/null +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/TransportParameterBoundsTest.kt @@ -0,0 +1,188 @@ +/* + * 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.connection + +import com.vitorpamplona.quic.tls.InProcessTlsServer +import com.vitorpamplona.quic.tls.PermissiveCertificateValidator +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * RFC 9000 §18.2 transport-parameter bounds checks. These values are + * advertised by the peer in their EncryptedExtensions + * `quic_transport_parameters` extension; out-of-range values MUST close + * the connection with TRANSPORT_PARAMETER_ERROR. + * + * - `max_udp_payload_size` minimum 1200 (the §14 datagram floor). + * - `ack_delay_exponent` maximum 20. + * - `active_connection_id_limit` minimum 2. + * + * Pre-fix the values were decoded into [TransportParameters] but no + * runtime check enforced the spec ranges — a hostile peer could ship a + * value of e.g. `ack_delay_exponent = 60` and our parser's + * `ackDelay << 60` shift would have desynced RTT (the parser still + * caps the exponent defensively, but the connection should not have + * been allowed in the first place). + */ +class TransportParameterBoundsTest { + private fun runHandshake(serverParams: TransportParameters): QuicConnection.Status = + runBlocking { + val client = + QuicConnection( + serverName = "example.test", + config = QuicConnectionConfig(), + tlsCertificateValidator = PermissiveCertificateValidator(), + ) + val serverScid = ConnectionId.random(8) + val tlsServer = + InProcessTlsServer( + transportParameters = + serverParams + .copy( + initialSourceConnectionId = serverScid.bytes, + originalDestinationConnectionId = client.destinationConnectionId.bytes, + ).encode(), + ) + val pipe = + InMemoryQuicPipe( + client = client, + initialDcid = client.destinationConnectionId.bytes, + serverScid = serverScid, + tlsServer = tlsServer, + ) + client.start() + // Drive enough rounds for ServerHello + EE + handshake completion. + // Once peer params are applied, `applyPeerTransportParameters` + // runs the §18.2 bounds checks which (on violation) flip the + // connection to CLOSED. + pipe.drive(maxRounds = 16) + client.status + } + + private fun baselineLegalParams() = + TransportParameters( + initialMaxData = 1L * 1024 * 1024, + initialMaxStreamDataBidiLocal = 64L * 1024, + initialMaxStreamDataBidiRemote = 64L * 1024, + initialMaxStreamDataUni = 64L * 1024, + initialMaxStreamsBidi = 16, + initialMaxStreamsUni = 16, + ) + + @Test + fun max_udp_payload_size_below_1200_closes() { + val status = runHandshake(baselineLegalParams().copy(maxUdpPayloadSize = 1199L)) + assertEquals( + QuicConnection.Status.CLOSED, + status, + "RFC 9000 §18.2: max_udp_payload_size < 1200 MUST be TRANSPORT_PARAMETER_ERROR", + ) + } + + @Test + fun max_udp_payload_size_at_1200_accepted() { + val status = runHandshake(baselineLegalParams().copy(maxUdpPayloadSize = 1200L)) + assertEquals(QuicConnection.Status.CONNECTED, status) + } + + @Test + fun ack_delay_exponent_above_20_closes() { + val status = runHandshake(baselineLegalParams().copy(ackDelayExponent = 21L)) + assertEquals( + QuicConnection.Status.CLOSED, + status, + "RFC 9000 §18.2: ack_delay_exponent > 20 MUST be TRANSPORT_PARAMETER_ERROR", + ) + } + + @Test + fun ack_delay_exponent_at_20_accepted() { + val status = runHandshake(baselineLegalParams().copy(ackDelayExponent = 20L)) + assertEquals(QuicConnection.Status.CONNECTED, status) + } + + @Test + fun active_connection_id_limit_below_2_closes() { + val status = runHandshake(baselineLegalParams().copy(activeConnectionIdLimit = 1L)) + assertEquals( + QuicConnection.Status.CLOSED, + status, + "RFC 9000 §18.2: active_connection_id_limit < 2 MUST be TRANSPORT_PARAMETER_ERROR", + ) + } + + @Test + fun active_connection_id_limit_at_2_accepted() { + val status = runHandshake(baselineLegalParams().copy(activeConnectionIdLimit = 2L)) + assertEquals(QuicConnection.Status.CONNECTED, status) + } + + @Test + fun missing_optional_params_accepted() { + // No bounds-checked params advertised → fall back to defaults, + // connection completes normally. + val status = runHandshake(baselineLegalParams()) + assertEquals(QuicConnection.Status.CONNECTED, status) + } + + @Test + fun close_reason_mentions_specific_violated_param() { + // Capture which bound was reported so future audit rounds can + // confirm the message contains a usable diagnostic, not just a + // generic "transport params bad". + val client = + runBlocking { + val client = + QuicConnection( + serverName = "example.test", + config = QuicConnectionConfig(), + tlsCertificateValidator = PermissiveCertificateValidator(), + ) + val serverScid = ConnectionId.random(8) + val tlsServer = + InProcessTlsServer( + transportParameters = + baselineLegalParams() + .copy( + initialSourceConnectionId = serverScid.bytes, + originalDestinationConnectionId = client.destinationConnectionId.bytes, + ackDelayExponent = 25L, + ).encode(), + ) + val pipe = + InMemoryQuicPipe( + client = client, + initialDcid = client.destinationConnectionId.bytes, + serverScid = serverScid, + tlsServer = tlsServer, + ) + client.start() + pipe.drive(maxRounds = 16) + client + } + val reason = client.closeReason + assertNotNull(reason) + assertTrue(reason.contains("ack_delay_exponent"), "reason should name the violated param: $reason") + } +} diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/http3/Http3ReservedSettingsTest.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/http3/Http3ReservedSettingsTest.kt new file mode 100644 index 0000000000..2d9c24d61a --- /dev/null +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/http3/Http3ReservedSettingsTest.kt @@ -0,0 +1,88 @@ +/* + * 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.http3 + +import com.vitorpamplona.quic.QuicCodecException +import com.vitorpamplona.quic.QuicWriter +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * RFC 9114 §7.2.4.1 — SETTINGS identifiers in the HTTP/2 range + * (0x02, 0x03, 0x04, 0x05) are reserved to prevent confusion with + * HTTP/2 settings. Receipt of any of these MUST be a connection error + * of type H3_SETTINGS_ERROR. + * + * Pre-fix the decoder validated values per known id but accepted the + * reserved ids silently (since they weren't in the known cap table — + * they fell through to the generic 1<<32 cap). + */ +class Http3ReservedSettingsTest { + private fun encodeRawSetting( + id: Long, + value: Long, + ): ByteArray { + val w = QuicWriter() + w.writeVarint(id) + w.writeVarint(value) + return w.toByteArray() + } + + @Test + fun reserved_id_0x02_rejected() { + val ex = + assertFailsWith { + Http3Settings.decodeBody(encodeRawSetting(0x02L, 1L)) + } + assertTrue(ex.message!!.contains("H3_SETTINGS_ERROR")) + } + + @Test + fun reserved_id_0x03_rejected() { + assertFailsWith { + Http3Settings.decodeBody(encodeRawSetting(0x03L, 1L)) + } + } + + @Test + fun reserved_id_0x04_rejected() { + assertFailsWith { + Http3Settings.decodeBody(encodeRawSetting(0x04L, 1L)) + } + } + + @Test + fun reserved_id_0x05_rejected() { + assertFailsWith { + Http3Settings.decodeBody(encodeRawSetting(0x05L, 1L)) + } + } + + @Test + fun adjacent_legal_ids_still_accepted() { + // 0x01 is RFC 9204 QPACK_MAX_TABLE_CAPACITY, 0x06 is the next + // legal HTTP/3 setting. Confirm the reserved-range check does + // not over-reach. + Http3Settings.decodeBody(encodeRawSetting(0x01L, 4096L)) + Http3Settings.decodeBody(encodeRawSetting(0x06L, 1024L)) + } +} From 6e054583a977dfaf5c092cbcc41fd94c2f38c967 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 9 May 2026 04:44:04 +0000 Subject: [PATCH 04/10] =?UTF-8?q?fix(quic):=20RFC=209000=20=C2=A74.1=20con?= =?UTF-8?q?nection-level=20inbound=20flow-control=20enforcement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-fix the receiver enforced ONLY per-stream `receiveLimit`. The aggregate cap (`initial_max_data` / latest MAX_DATA) was advertised and respected on the SEND side but never checked on the RECEIVE side — a peer that opened many streams and pushed each up to its per-stream cap could collectively exceed `initial_max_data` without us closing. Implementation: * `QuicStream.receiveHighestOffset`: per-stream high-water mark of "largest stream offset received" — the spec quantity that gets summed across streams for the connection-level check. * `QuicConnection.connectionInboundOffsetSum`: running sum across all streams of `receiveHighestOffset`. Updated in the parser at STREAM and RESET_STREAM frame ingest time. * Parser closes with FLOW_CONTROL_ERROR whenever the running sum exceeds `advertisedMaxData`. * RESET_STREAM final-size accounting: per §4.5 the finalSize counts toward connection-level flow control even though no STREAM frame delivered those bytes — a peer that resets a stream at a finalSize larger than it had previously sent gets the extra bytes added to the running sum at RESET arrival. Different from the writer's MAX_DATA threshold logic (which uses the cheaper contiguous-end approximation): the receiver-side enforcement needs the strict spec quantity to close before bookkeeping diverges. Test: `ConnectionLevelFlowControlTest` (5 cases) covers below-cap pass, over-cap close, retransmit-doesn't-double-count, RESET_STREAM finalSize counts toward limit, fresh-handshake invariants. https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik --- .../quic/connection/QuicConnection.kt | 18 +++ .../quic/connection/QuicConnectionParser.kt | 38 +++++ .../vitorpamplona/quic/stream/QuicStream.kt | 14 ++ .../ConnectionLevelFlowControlTest.kt | 137 ++++++++++++++++++ 4 files changed, 207 insertions(+) create mode 100644 quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/ConnectionLevelFlowControlTest.kt 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 dee3f687d0..7ae599caee 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt @@ -481,6 +481,24 @@ class QuicConnection( */ internal var advertisedMaxData: Long = config.initialMaxData + /** + * RFC 9000 §4.1 connection-level inbound flow-control accounting. + * Sum across all streams (live + retired) of the largest stream + * offset ever observed on an inbound STREAM / RESET_STREAM frame. + * The receiver MUST close the connection with FLOW_CONTROL_ERROR + * when this sum exceeds [advertisedMaxData]; that's enforced in + * the parser at frame-ingest time. + * + * Different from the writer's `totalRecvAdvanced` which uses + * `stream.receive.contiguousEnd()` (a slight under-count) for + * MAX_DATA threshold logic. Here we track the strict spec + * quantity — necessary to close before bookkeeping diverges + * if the peer sends data with gaps that pushes total received + * past the limit. + */ + @Volatile + internal var connectionInboundOffsetSum: Long = 0L + /** * Cumulative count of peer-initiated unidirectional streams we've * accepted (incremented in [getOrCreatePeerStreamLocked]). Compared 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 946f716d89..3778bd5fb1 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt @@ -675,6 +675,25 @@ private fun dispatchFrames( ) return } + // RFC 9000 §4.1 connection-level enforcement: the SUM + // across all streams of "largest stream offset received" + // MUST NOT exceed the most recently advertised MAX_DATA. + // We update [conn.connectionInboundOffsetSum] only when + // this frame's end-offset advances the per-stream + // high-water mark, so duplicate / reordered frames don't + // double-count. + if (frameEnd > stream.receiveHighestOffset) { + val delta = frameEnd - stream.receiveHighestOffset + stream.receiveHighestOffset = frameEnd + conn.connectionInboundOffsetSum += delta + if (conn.connectionInboundOffsetSum > conn.advertisedMaxData) { + conn.markClosedExternally( + "FLOW_CONTROL_ERROR: peer exceeded connection MAX_DATA " + + "(${conn.connectionInboundOffsetSum} > ${conn.advertisedMaxData})", + ) + return + } + } // RFC 9000 §4.5: enforce final-size invariants. The // [com.vitorpamplona.quic.stream.ReceiveBuffer.insert] // surface returns a typed result; map any non-OK result @@ -830,6 +849,25 @@ private fun dispatchFrames( return } } + // RFC 9000 §4.5: a RESET_STREAM's finalSize counts toward + // connection-level flow control even though no STREAM frame + // ever delivered those bytes — it represents what the peer + // committed to send before aborting. Top up + // [conn.connectionInboundOffsetSum] when finalSize advances + // beyond what STREAM frames already counted, then re-check + // against the connection cap. + if (target != null && frame.finalSize > target.receiveHighestOffset) { + val delta = frame.finalSize - target.receiveHighestOffset + target.receiveHighestOffset = frame.finalSize + conn.connectionInboundOffsetSum += delta + if (conn.connectionInboundOffsetSum > conn.advertisedMaxData) { + conn.markClosedExternally( + "FLOW_CONTROL_ERROR: peer RESET_STREAM finalSize pushed connection past MAX_DATA " + + "(${conn.connectionInboundOffsetSum} > ${conn.advertisedMaxData})", + ) + return + } + } // Mark the peer's stream aborted and close our read side; the // application sees a truncated incoming flow. target?.closeIncoming() 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 97974fec1e..e2c54d34d1 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/stream/QuicStream.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/stream/QuicStream.kt @@ -128,6 +128,20 @@ class QuicStream( var receiveLimit: Long = 0L internal set + /** + * RFC 9000 §4.1: highest stream offset (offset + length) ever seen + * on an inbound STREAM or RESET_STREAM frame. Used by + * [QuicConnection]'s connection-level flow-control accounting — + * the spec requires the receiver to compare the SUM across all + * streams of "largest received offset" against the advertised + * `initial_max_data` / latest MAX_DATA, NOT the contiguous read + * frontier. The writer's MAX_DATA threshold logic still uses the + * cheaper contiguous-end approximation; this field is purely the + * enforcement signal on receive. + */ + @Volatile + internal var receiveHighestOffset: Long = 0L + /** * Marker the parser sets whenever [receive.contiguousEnd] advances; the * writer's appendFlowControlUpdates consumes it to skip streams that diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/ConnectionLevelFlowControlTest.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/ConnectionLevelFlowControlTest.kt new file mode 100644 index 0000000000..1a4f5549cb --- /dev/null +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/ConnectionLevelFlowControlTest.kt @@ -0,0 +1,137 @@ +/* + * 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.connection + +import com.vitorpamplona.quic.frame.ResetStreamFrame +import com.vitorpamplona.quic.frame.StreamFrame +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * RFC 9000 §4.1: connection-level inbound flow-control enforcement. + * + * "All data sent in STREAM frames counts toward this limit. The sum of + * the largest received offsets on all streams — including streams in + * the Reset Recvd state — MUST NOT exceed the value advertised by a + * receiver." + * + * Pre-fix the receiver enforced ONLY per-stream `receiveLimit` (each + * stream individually couldn't push past `initial_max_stream_data_*`). + * The aggregate connection-level cap (`initial_max_data`) was advertised + * AND respected on the SEND side, but never checked on the RECEIVE + * side. A peer that opened many streams and pushed each one up to its + * per-stream cap could collectively exceed `initial_max_data` without + * us closing. + */ +class ConnectionLevelFlowControlTest { + private fun connectClient( + maxData: Long = 16 * 1024, + maxStreamData: Long = 8 * 1024, + maxStreamsUni: Long = 16, + ): Pair = + newConnectedClient( + maxData = maxData, + maxStreamData = maxStreamData, + maxStreamsUni = maxStreamsUni, + maxStreamsBidi = 16, + ) + + @Test + fun connection_recv_sum_below_max_data_accepted() { + val (client, pipe) = connectClient(maxData = 16 * 1024, maxStreamData = 8 * 1024) + // Two server-uni streams (id 3, 7) each carrying 4 KiB. Sum = 8 KiB + // < 16 KiB connection cap. Should sail through. + val frame1 = StreamFrame(streamId = 3L, offset = 0L, data = ByteArray(4096), fin = false) + val frame2 = StreamFrame(streamId = 7L, offset = 0L, data = ByteArray(4096), fin = false) + val datagram = pipe.buildServerApplicationDatagram(listOf(frame1, frame2))!! + feedDatagram(client, datagram, nowMillis = 0L) + assertEquals(QuicConnection.Status.CONNECTED, client.status) + assertEquals(8L * 1024, client.connectionInboundOffsetSum) + } + + @Test + fun connection_recv_sum_exceeding_max_data_closes() { + val (client, pipe) = connectClient(maxData = 16 * 1024, maxStreamData = 16 * 1024) + // Three server-uni streams each shipping 6 KiB → 18 KiB > 16 KiB cap. + // Per-stream limit (16 KiB) is fine; the aggregate breaches MAX_DATA. + val frame1 = StreamFrame(streamId = 3L, offset = 0L, data = ByteArray(6 * 1024), fin = false) + val frame2 = StreamFrame(streamId = 7L, offset = 0L, data = ByteArray(6 * 1024), fin = false) + val frame3 = StreamFrame(streamId = 11L, offset = 0L, data = ByteArray(6 * 1024), fin = false) + val datagram = pipe.buildServerApplicationDatagram(listOf(frame1, frame2, frame3))!! + feedDatagram(client, datagram, nowMillis = 0L) + assertEquals(QuicConnection.Status.CLOSED, client.status) + val reason = client.closeReason + assertNotNull(reason) + assertTrue(reason.contains("FLOW_CONTROL_ERROR"), "expected FLOW_CONTROL_ERROR, got: $reason") + assertTrue(reason.contains("MAX_DATA")) + } + + @Test + fun retransmitted_stream_frame_does_not_double_count() { + val (client, pipe) = connectClient(maxData = 16 * 1024, maxStreamData = 8 * 1024) + val frame = StreamFrame(streamId = 3L, offset = 0L, data = ByteArray(4 * 1024), fin = false) + // First arrival. + feedDatagram(client, pipe.buildServerApplicationDatagram(listOf(frame))!!, nowMillis = 0L) + val sumAfterFirst = client.connectionInboundOffsetSum + assertEquals(4L * 1024, sumAfterFirst) + // Identical retransmit — does NOT advance the per-stream high-water, + // so connection counter stays put. + feedDatagram(client, pipe.buildServerApplicationDatagram(listOf(frame))!!, nowMillis = 0L) + assertEquals(sumAfterFirst, client.connectionInboundOffsetSum) + assertEquals(QuicConnection.Status.CONNECTED, client.status) + } + + @Test + fun reset_stream_final_size_counts_toward_connection_limit() { + val (client, pipe) = connectClient(maxData = 16 * 1024, maxStreamData = 16 * 1024) + // Stream 3 ships 4 KiB then resets at finalSize = 20 KiB. The + // RESET_STREAM commits the peer to those 20 KiB even though we + // haven't seen them on the wire — they count toward MAX_DATA per + // §4.5. 20 KiB on a single stream alone breaches the 16 KiB + // connection cap, so we MUST close. + val streamData = StreamFrame(streamId = 3L, offset = 0L, data = ByteArray(4 * 1024), fin = false) + val reset = ResetStreamFrame(streamId = 3L, applicationErrorCode = 0L, finalSize = 20L * 1024) + feedDatagram(client, pipe.buildServerApplicationDatagram(listOf(streamData, reset))!!, nowMillis = 0L) + assertEquals(QuicConnection.Status.CLOSED, client.status) + val reason = client.closeReason + assertNotNull(reason) + assertTrue(reason.contains("FLOW_CONTROL_ERROR"), "expected FLOW_CONTROL_ERROR, got: $reason") + } + + @Test + fun handshake_fixture_starts_with_zero_inbound_offset_sum() { + // Sanity check on the bookkeeping: a fresh handshake-only + // connection has not received any STREAM bytes, so the sum is 0 + // even though [advertisedMaxData] is the configured initial. + val (client, pipe) = connectClient() + runBlocking { + // Make sure pipe-driven handshake completes to CONNECTED before + // we read the field — otherwise we'd be looking at the value + // mid-handshake with no peer streams in play. + pipe.drive(maxRounds = 4) + } + assertEquals(0L, client.connectionInboundOffsetSum) + assertTrue(client.advertisedMaxData > 0L) + } +} From be96d4b28f7cccb06c4d687bf1e172fc3e12a8ae Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 9 May 2026 04:46:49 +0000 Subject: [PATCH 05/10] =?UTF-8?q?fix(quic):=20RFC=209000=20=C2=A73.2=20str?= =?UTF-8?q?eam=20state=20=E2=80=94=20STREAM=20after=20RESET=5FSTREAM=20rej?= =?UTF-8?q?ected?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once the peer has sent RESET_STREAM the receive side enters the "Reset Recvd" terminal state per §3.2; any subsequent STREAM frame on that id is a peer protocol violation and MUST close the connection with STREAM_STATE_ERROR. Pre-fix the parser silently absorbed the bytes — a peer that violated the spec would leave us with a phantom mid-reset stream the peer believed was already dead, with reset/byte bookkeeping diverging. Implementation: a per-stream `peerResetReceived: Boolean` flag set in the RESET_STREAM handler and checked at the top of the STREAM handler. The flag is independent of our local-side `resetState` (which tracks OUR RESET emission). Test: `StreamAfterResetTest` (4 cases) covers STREAM-after-reset closure, FIN-bearing STREAM-after-reset, the legal FIN-then-RESET shape (no false-positive), and per-stream isolation (reset on stream 3 doesn't poison stream 7). https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik --- .../quic/connection/QuicConnectionParser.kt | 18 +++ .../vitorpamplona/quic/stream/QuicStream.kt | 15 +++ .../quic/connection/StreamAfterResetTest.kt | 113 ++++++++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/StreamAfterResetTest.kt 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 3778bd5fb1..f663e39b9f 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt @@ -665,6 +665,19 @@ private fun dispatchFrames( continue } val stream = conn.getOrCreatePeerStreamLocked(frame.streamId) + // RFC 9000 §3.2: once the peer has sent RESET_STREAM the + // receive side is in the "Reset Recvd" terminal state. + // Any subsequent STREAM frame on this id is a peer + // protocol violation — close with STREAM_STATE_ERROR. + // Pre-fix the bytes were silently absorbed, leaving the + // application with a phantom mid-reset stream that the + // peer believed was already dead. + if (stream.peerResetReceived) { + conn.markClosedExternally( + "STREAM_STATE_ERROR: stream ${frame.streamId} STREAM after RESET_STREAM", + ) + return + } // RFC 9000 §4.1: peer MUST NOT send beyond the limit we advertised. // The connection-level kill protects against unbounded memory // growth from a misbehaving peer. @@ -868,6 +881,11 @@ private fun dispatchFrames( return } } + // RFC 9000 §3.2: latch the receive-side terminal state so + // any subsequent STREAM frame on this id closes the + // connection with STREAM_STATE_ERROR (handled in the + // STREAM branch above). + target?.peerResetReceived = true // Mark the peer's stream aborted and close our read side; the // application sees a truncated incoming flow. target?.closeIncoming() 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 e2c54d34d1..f25bbaea41 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/stream/QuicStream.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/stream/QuicStream.kt @@ -142,6 +142,21 @@ class QuicStream( @Volatile internal var receiveHighestOffset: Long = 0L + /** + * RFC 9000 §3.2 receive-side state: true once a `RESET_STREAM` for + * this stream has been delivered by the peer. Subsequent inbound + * STREAM frames on this id are invalid (the receive side is in the + * "Reset Recvd" terminal state) and must close the connection with + * STREAM_STATE_ERROR. + * + * Kept distinct from the local-side [resetState] (which tracks OUR + * RESET_STREAM emission). Both can exist simultaneously: a bidi + * stream can be reset by the peer's send side (this flag) while we + * separately reset our own send side. + */ + @Volatile + internal var peerResetReceived: Boolean = false + /** * Marker the parser sets whenever [receive.contiguousEnd] advances; the * writer's appendFlowControlUpdates consumes it to skip streams that diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/StreamAfterResetTest.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/StreamAfterResetTest.kt new file mode 100644 index 0000000000..8f5da5c444 --- /dev/null +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/StreamAfterResetTest.kt @@ -0,0 +1,113 @@ +/* + * 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.connection + +import com.vitorpamplona.quic.frame.ResetStreamFrame +import com.vitorpamplona.quic.frame.StreamFrame +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * RFC 9000 §3.2 receive-side stream state machine. Once the peer has + * sent RESET_STREAM for a stream, the receive side enters the "Reset + * Recvd" terminal state. Subsequent STREAM frames on the same id are + * peer protocol violations and MUST close the connection with + * STREAM_STATE_ERROR. + * + * Pre-fix the parser silently absorbed STREAM frames after RESET — a + * peer that violated the spec would leave us with a phantom mid-reset + * stream that the peer believed was already dead, with diverging + * reset/byte bookkeeping. + */ +class StreamAfterResetTest { + @Test + fun stream_frame_after_reset_closes_with_stream_state_error() { + val (client, pipe) = newConnectedClient(maxStreamData = 64 * 1024) + // Stream 3 (server-uni) — peer pushes a few bytes then resets at + // finalSize = highest seen (legal). Receive side is now in + // Reset Recvd terminal state. + val streamData = StreamFrame(streamId = 3L, offset = 0L, data = ByteArray(8), fin = false) + val reset = ResetStreamFrame(streamId = 3L, applicationErrorCode = 0L, finalSize = 8L) + val firstDgram = pipe.buildServerApplicationDatagram(listOf(streamData, reset))!! + feedDatagram(client, firstDgram, nowMillis = 0L) + assertEquals(QuicConnection.Status.CONNECTED, client.status) + + // Now peer (illegally) sends another STREAM frame on the same id. + val second = StreamFrame(streamId = 3L, offset = 8L, data = ByteArray(4), fin = false) + val secondDgram = pipe.buildServerApplicationDatagram(listOf(second))!! + feedDatagram(client, secondDgram, nowMillis = 0L) + + assertEquals(QuicConnection.Status.CLOSED, client.status) + val reason = client.closeReason + assertNotNull(reason) + assertTrue(reason.contains("STREAM_STATE_ERROR"), "expected STREAM_STATE_ERROR, got: $reason") + assertTrue(reason.contains("3"), "reason should reference the offending stream id: $reason") + } + + @Test + fun stream_frame_with_fin_after_reset_also_rejected() { + // FIN is the same path — STREAM with fin=true is still a + // STREAM frame, so it falls under the same Reset Recvd guard. + val (client, pipe) = newConnectedClient(maxStreamData = 64 * 1024) + val streamData = StreamFrame(streamId = 3L, offset = 0L, data = ByteArray(8), fin = false) + val reset = ResetStreamFrame(streamId = 3L, applicationErrorCode = 0L, finalSize = 8L) + feedDatagram(client, pipe.buildServerApplicationDatagram(listOf(streamData, reset))!!, nowMillis = 0L) + + val finFrame = StreamFrame(streamId = 3L, offset = 8L, data = ByteArray(0), fin = true) + feedDatagram(client, pipe.buildServerApplicationDatagram(listOf(finFrame))!!, nowMillis = 0L) + + assertEquals(QuicConnection.Status.CLOSED, client.status) + assertTrue(client.closeReason!!.contains("STREAM_STATE_ERROR")) + } + + @Test + fun reset_after_clean_fin_still_accepted() { + // FIN-then-RESET is allowed — RESET on a stream that already + // FIN'd is a no-op on receive side (state was already terminal + // via Data Recvd; RESET is silently OK as long as finalSize + // matches). We don't exercise STREAM-after-this; the reverse + // ordering is what the test above covers. This case just + // ensures the §4.5 finalSize-matches-FIN check is preserved. + val (client, pipe) = newConnectedClient(maxStreamData = 64 * 1024) + val finFrame = StreamFrame(streamId = 3L, offset = 0L, data = ByteArray(8), fin = true) + val reset = ResetStreamFrame(streamId = 3L, applicationErrorCode = 0L, finalSize = 8L) + feedDatagram(client, pipe.buildServerApplicationDatagram(listOf(finFrame, reset))!!, nowMillis = 0L) + assertEquals(QuicConnection.Status.CONNECTED, client.status) + } + + @Test + fun two_streams_independent_states() { + // Reset on stream 3 must NOT prevent legitimate STREAM frames on + // stream 7. The flag is per-stream, not connection-wide. + val (client, pipe) = newConnectedClient(maxStreamData = 64 * 1024) + val s3data = StreamFrame(streamId = 3L, offset = 0L, data = ByteArray(8), fin = false) + val s3reset = ResetStreamFrame(streamId = 3L, applicationErrorCode = 0L, finalSize = 8L) + val s7data = StreamFrame(streamId = 7L, offset = 0L, data = ByteArray(8), fin = false) + feedDatagram(client, pipe.buildServerApplicationDatagram(listOf(s3data, s3reset, s7data))!!, nowMillis = 0L) + assertEquals(QuicConnection.Status.CONNECTED, client.status) + // Subsequent STREAM on still-open stream 7 — fine. + val s7more = StreamFrame(streamId = 7L, offset = 8L, data = ByteArray(8), fin = false) + feedDatagram(client, pipe.buildServerApplicationDatagram(listOf(s7more))!!, nowMillis = 0L) + assertEquals(QuicConnection.Status.CONNECTED, client.status) + } +} From 4ec192347e7109d0fd814eedc1d5c5ee4d07540c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 9 May 2026 04:56:20 +0000 Subject: [PATCH 06/10] =?UTF-8?q?fix(quic):=20RFC=209001=20=C2=A76.6=20AEA?= =?UTF-8?q?D=20invocation=20limit=20tracking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-key AEAD usage limits per RFC 9001 §6.6 / §B.1: * Confidentiality limit (encrypt count per send key): AES-128-GCM = 2^23, ChaCha20-Poly1305 = 2^62. Reaching this means AEAD security is no longer assured; the endpoint MUST initiate a key update or close. * Integrity limit (forged-packet count per receive key): AES-128-GCM = 2^52, ChaCha20-Poly1305 = 2^36. Reaching this means an attacker has been grinding for AEAD key recovery; MUST close with AEAD_LIMIT_REACHED. Pre-fix neither limit was tracked. Long-running AES-128-GCM sessions (~2hrs at 1000pkt/s) could roll past the confidentiality limit; an attacker spamming forged packets had no failure ceiling. Implementation: * `Aead.confidentialityLimit` / `Aead.integrityLimit` properties surface the spec values per cipher; AES-128-GCM and ChaCha20-Poly1305 (commonMain singletons + jvmAndroid JCA classes) return the §B.1 numbers. * `QuicConnection.aeadEncryptCount` / `aeadDecryptFailureCount` — per-key counters, reset on every 1-RTT key rotation (`commitKeyUpdate` and `initiateKeyUpdate`). * Writer increments encrypt count after each application-level build. At half the limit it soft-triggers `initiateKeyUpdate` (latched via `aeadKeyUpdateRequested` so the in-flight rotation isn't re-issued); at the limit it closes with AEAD_LIMIT_REACHED if rotation hasn't completed. * Parser increments decrypt-failure count on every 1-RTT AEAD auth-tag failure; closes when the count hits the integrity limit. Initial / Handshake levels are out of scope — their keys' lifetime is too short to approach the limit. Test: `AeadInvocationLimitTest` (6 cases) covers spec-value verification, counter increment on application send, counter reset on rotation, confidentiality-limit close, integrity-limit close (via a real ciphertext-tampered datagram from the in-process pipe). https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik --- .../quic/connection/QuicConnection.kt | 51 ++++++ .../quic/connection/QuicConnectionParser.kt | 12 ++ .../quic/connection/QuicConnectionWriter.kt | 23 +++ .../com/vitorpamplona/quic/crypto/Aead.kt | 30 +++ .../connection/AeadInvocationLimitTest.kt | 172 ++++++++++++++++++ .../quic/crypto/JcaAesGcmAead.kt | 4 + .../quic/crypto/JcaChaCha20Poly1305Aead.kt | 4 + 7 files changed, 296 insertions(+) create mode 100644 quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/AeadInvocationLimitTest.kt 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 7ae599caee..5e9eddd8e9 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt @@ -499,6 +499,46 @@ class QuicConnection( @Volatile internal var connectionInboundOffsetSum: Long = 0L + /** + * RFC 9001 §6.6 / §B.1: per-key encryption count for the active + * 1-RTT send key. The writer increments this on each + * application-level packet it builds. When the count reaches the + * AEAD's `confidentialityLimit` we MUST initiate a key update + * before the next packet (else AEAD security degrades). The + * counter resets to 0 every time the send key phase rotates. + * + * Initial / Handshake levels share their keys briefly enough that + * the limit isn't reachable, so we only track 1-RTT. + */ + @Volatile + internal var aeadEncryptCount: Long = 0L + + /** + * RFC 9001 §6.6 / §B.1: count of failed AEAD verifications on + * the active 1-RTT receive key. Each `parseAndDecrypt` that + * returns null at APPLICATION level bumps this. When it reaches + * the AEAD's `integrityLimit` we MUST close the connection with + * AEAD_LIMIT_REACHED. + * + * Resets every time the receive key phase rotates. The integrity + * limit for AES-128-GCM (2^52) is unreachable in practice; for + * ChaCha20-Poly1305 (2^36) it's reachable only by a determined + * attacker spamming forged packets — close in either case is + * spec-mandated. + */ + @Volatile + internal var aeadDecryptFailureCount: Long = 0L + + /** + * Soft-trigger latch for [aeadEncryptCount]. Set true once we've + * called [initiateKeyUpdate] in response to crossing the soft + * threshold (half the confidentiality limit). Prevents repeatedly + * firing key-updates while the in-progress one is still pending. + * Cleared when the rotation actually completes (counter resets). + */ + @Volatile + internal var aeadKeyUpdateRequested: Boolean = false + /** * Cumulative count of peer-initiated unidirectional streams we've * accepted (incremented in [getOrCreatePeerStreamLocked]). Compared @@ -1952,6 +1992,13 @@ class QuicConnection( currentSendKeyPhase = !currentSendKeyPhase } } + // RFC 9001 §6.6 limits are PER-KEY: every rotation resets both + // the encrypt count (now using fresh send keys) and the + // decrypt-failure count (fresh receive keys mean prior failures + // can't accumulate further toward the integrity limit). + aeadEncryptCount = 0L + aeadDecryptFailureCount = 0L + aeadKeyUpdateRequested = false qlogObserver.onKeyUpdated("server", EncryptionLevel.APPLICATION) qlogObserver.onKeyUpdated("client", EncryptionLevel.APPLICATION) } @@ -2039,6 +2086,10 @@ class QuicConnection( currentReceiveKeyPhase = !currentReceiveKeyPhase currentSendKeyPhase = !currentSendKeyPhase keyUpdateInProgress = true + // RFC 9001 §6.6 per-key counters reset on rotation (see commitKeyUpdate). + aeadEncryptCount = 0L + aeadDecryptFailureCount = 0L + aeadKeyUpdateRequested = false qlogObserver.onKeyUpdated("client", EncryptionLevel.APPLICATION) qlogObserver.onKeyUpdated("server", EncryptionLevel.APPLICATION) return true 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 f663e39b9f..cea82ad041 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt @@ -423,6 +423,18 @@ private fun feedShortHeaderPacket( "AEAD auth failed or header parse failed at level APPLICATION", datagram.size - offset, ) + // RFC 9001 §6.6 / §B.1 integrity limit. A 1-RTT packet that + // failed AEAD verification counts as a forgery attempt against + // the active receive key. Closing here prevents an attacker + // from grinding through the integrity-limit-many forgeries + // searching for a key recovery on the underlying AEAD. + conn.aeadDecryptFailureCount += 1L + val limit = live.aead.integrityLimit + if (conn.aeadDecryptFailureCount >= limit) { + conn.markClosedExternally( + "AEAD_LIMIT_REACHED: 1-RTT decrypt-failure count ${conn.aeadDecryptFailureCount} >= integrity limit $limit", + ) + } return } // AEAD succeeded with the candidate next-phase keys → commit the diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt index c7e76c3e9b..530e7ead19 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt @@ -911,6 +911,29 @@ private fun buildApplicationPacket( // idle timer. (Non-ack-eliciting packets — pure ACK or PADDING-only // — do NOT reset.) if (ackEliciting) conn.lastActivityMs = nowMillis + // RFC 9001 §6.6 / §B.1 confidentiality limit. Track per-key + // encryption count for the active 1-RTT send key. At half the + // AEAD's confidentiality limit we soft-trigger a key update; at + // the limit we close the connection if rotation hasn't completed. + if (sizeBytes.isSuccess) { + conn.aeadEncryptCount += 1L + val limit = proto.aead.confidentialityLimit + if (!conn.aeadKeyUpdateRequested && conn.aeadEncryptCount >= limit / 2L) { + // Soft trigger — try to rotate. initiateKeyUpdate is a + // no-op if a previous rotation is still in flight, so the + // latch only flips on the first successful initiation. + conn.aeadKeyUpdateRequested = true + conn.initiateKeyUpdate() + } + if (conn.aeadEncryptCount >= limit) { + // Hard limit — rotation didn't complete in time. RFC 9001 + // §6.6 mandates closing rather than continuing to encrypt + // under a key whose confidentiality is no longer assured. + conn.markClosedExternally( + "AEAD_LIMIT_REACHED: 1-RTT encryption count ${conn.aeadEncryptCount} >= confidentiality limit $limit", + ) + } + } sizeBytes.getOrNull()?.let { built -> emitQlogSent(conn, EncryptionLevel.APPLICATION, pn, built.size, frames) } diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/crypto/Aead.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/crypto/Aead.kt index e0671b9041..2a4feec53b 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/crypto/Aead.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/crypto/Aead.kt @@ -38,6 +38,28 @@ abstract class Aead { abstract val nonceLength: Int abstract val tagLength: Int + /** + * RFC 9001 §B.1 confidentiality limit — the maximum number of + * packets the endpoint can encrypt with a single key before MUST + * initiating a key update / closing the connection. + * + * AES-128-GCM: 2^23 = 8_388_608. + * ChaCha20-Poly1305: 2^62 (effectively unlimited for practical + * sessions, but still a finite cap). + */ + abstract val confidentialityLimit: Long + + /** + * RFC 9001 §B.1 integrity limit — the maximum number of forged + * packets (failed AEAD verifications) the endpoint may attempt to + * decrypt before MUST closing the connection with + * AEAD_LIMIT_REACHED. + * + * AES-128-GCM: 2^52 (effectively unreachable). + * ChaCha20-Poly1305: 2^36. + */ + abstract val integrityLimit: Long + abstract fun seal( key: ByteArray, nonce: ByteArray, @@ -153,6 +175,10 @@ object Aes128Gcm : Aead() { override val nonceLength = 12 override val tagLength = 16 + // RFC 9001 §B.1 limits for AEAD_AES_128_GCM. + override val confidentialityLimit: Long = 1L shl 23 + override val integrityLimit: Long = 1L shl 52 + override fun seal( key: ByteArray, nonce: ByteArray, @@ -186,6 +212,10 @@ object ChaCha20Poly1305Aead : Aead() { override val nonceLength = 12 override val tagLength = 16 + // RFC 9001 §B.1 limits for AEAD_CHACHA20_POLY1305. + override val confidentialityLimit: Long = (1L shl 62) + override val integrityLimit: Long = 1L shl 36 + override fun seal( key: ByteArray, nonce: ByteArray, diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/AeadInvocationLimitTest.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/AeadInvocationLimitTest.kt new file mode 100644 index 0000000000..58d9669074 --- /dev/null +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/AeadInvocationLimitTest.kt @@ -0,0 +1,172 @@ +/* + * 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.connection + +import com.vitorpamplona.quic.crypto.Aead +import com.vitorpamplona.quic.crypto.Aes128Gcm +import com.vitorpamplona.quic.crypto.ChaCha20Poly1305Aead +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * RFC 9001 §6.6 / §B.1 AEAD invocation limit enforcement. + * + * - Confidentiality limit: max number of packets encrypted with one + * send key. AES-128-GCM = 2^23, ChaCha20-Poly1305 = 2^62. + * - Integrity limit: max number of forged-packet AEAD failures on + * one receive key. AES-128-GCM = 2^52, ChaCha20-Poly1305 = 2^36. + * + * Pre-fix neither limit was tracked. A long-running session with + * AES-128-GCM would silently roll past 2^23 encrypts (security + * argument no longer holds); an attacker spamming forged packets + * could indefinitely grind for AEAD key recovery. + * + * The counter / limit logic is verified directly via the internal + * fields rather than spinning the writer for ~8 million encrypts — + * that would take minutes per test. + */ +class AeadInvocationLimitTest { + @Test + fun aes_128_gcm_limits_match_rfc_b1() { + val aead: Aead = Aes128Gcm + assertEquals(1L shl 23, aead.confidentialityLimit, "AES-128-GCM confidentiality limit per RFC 9001 §B.1") + assertEquals(1L shl 52, aead.integrityLimit, "AES-128-GCM integrity limit per RFC 9001 §B.1") + } + + @Test + fun chacha20_poly1305_limits_match_rfc_b1() { + val aead: Aead = ChaCha20Poly1305Aead + assertEquals(1L shl 62, aead.confidentialityLimit, "ChaCha20-Poly1305 confidentiality limit per RFC 9001 §B.1") + assertEquals(1L shl 36, aead.integrityLimit, "ChaCha20-Poly1305 integrity limit per RFC 9001 §B.1") + } + + @Test + fun encrypt_count_increments_per_application_packet() { + val (client, _) = newConnectedClient() + val before = client.aeadEncryptCount + // Open a uni stream and write to it — guaranteed to produce an + // ack-eliciting application packet on the next drain, regardless + // of whether the peer advertised `max_datagram_frame_size`. + runBlocking { + val stream = client.openUniStream() + stream.send.enqueue(byteArrayOf(0x01, 0x02, 0x03)) + stream.send.finish() + client.streamsLock.lock() + try { + drainOutbound(client, nowMillis = 0L) + } finally { + client.streamsLock.unlock() + } + } + assertTrue(client.aeadEncryptCount > before, "encrypt count must advance on outbound build, got $before -> ${client.aeadEncryptCount}") + } + + @Test + fun encrypt_count_resets_on_key_update_initiation() { + val (client, pipe) = newConnectedClient() + runBlocking { pipe.drive(maxRounds = 4) } + client.aeadEncryptCount = 10_000L + val rotated = client.initiateKeyUpdate() + // initiateKeyUpdate may legitimately fail if handshake-confirm + // hasn't propagated yet — the counter MUST still get reset on + // success. + if (rotated) { + assertEquals(0L, client.aeadEncryptCount, "counter resets on rotation") + assertEquals(0L, client.aeadDecryptFailureCount, "decrypt-failure counter resets on rotation too") + } + } + + @Test + fun encrypt_at_confidentiality_limit_closes_connection() { + val (client, pipe) = newConnectedClient() + runBlocking { pipe.drive(maxRounds = 4) } + // Pin the counter so the next outbound build crosses the limit. + // Skip the key-update soft trigger by also latching + // [aeadKeyUpdateRequested]. + client.aeadKeyUpdateRequested = true + val limit = + client.application.sendProtection + ?.aead + ?.confidentialityLimit + assertNotNull(limit, "client must have application-level send keys after handshake") + client.aeadEncryptCount = limit - 1L + runBlocking { + // Open a uni stream + write a few bytes; the resulting + // STREAM frame guarantees an ack-eliciting application + // packet on the next drain. The writer's post-encrypt + // limit check fires at the boundary. + val stream = client.openUniStream() + stream.send.enqueue(byteArrayOf(0xff.toByte())) + stream.send.finish() + client.streamsLock.lock() + try { + drainOutbound(client, nowMillis = 0L) + } finally { + client.streamsLock.unlock() + } + } + assertEquals(QuicConnection.Status.CLOSED, client.status) + val reason = client.closeReason + assertNotNull(reason) + assertTrue(reason.contains("AEAD_LIMIT_REACHED")) + } + + @Test + fun decrypt_failure_at_integrity_limit_closes_connection() { + val (client, pipe) = newConnectedClient() + // Craft a real, properly-encrypted 1-RTT datagram from the + // server's perspective, then flip one ciphertext byte so AEAD + // verification fails on the client side. This exercises the + // exact parser path (HP unmask succeeds, AEAD returns null, + // counter advances) — feeding garbage bytes would trip the + // reserved-bits PROTOCOL_VIOLATION check first. + val cleanDatagram = + // 100 PING frames pad the packet well past the 20 bytes of + // header-protection sample range (sample ends at + // byte (1 + dcidLen + 4 + 16) ≈ byte 29 for dcidLen=8). + // Tampering a byte well past that range leaves the HP + // mask intact while breaking the AEAD tag, so the + // ShortHeaderPacket.parseAndDecrypt path returns null + // (AEAD failure) instead of throwing on reserved-bit + // mismatch (HP-mask divergence). + pipe.buildServerApplicationDatagram(List(100) { com.vitorpamplona.quic.frame.PingFrame })!! + val tampered = cleanDatagram.copyOf() + // Flip the last byte of the AEAD tag — far past the HP sample + // range so HP unmask still recovers the correct first byte. + tampered[tampered.size - 1] = (tampered[tampered.size - 1].toInt() xor 0x01).toByte() + val limit = + client.application.receiveProtection + ?.aead + ?.integrityLimit + assertNotNull(limit) + // Pin the counter just under the limit so the tampered packet's + // AEAD failure crosses the threshold. + client.aeadDecryptFailureCount = limit - 1L + feedDatagram(client, tampered, nowMillis = 0L) + assertEquals(QuicConnection.Status.CLOSED, client.status) + val reason = client.closeReason + assertNotNull(reason) + assertTrue(reason.contains("AEAD_LIMIT_REACHED"), "expected AEAD_LIMIT_REACHED, got: $reason") + } +} diff --git a/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/crypto/JcaAesGcmAead.kt b/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/crypto/JcaAesGcmAead.kt index e6ddc533d6..501e9166bc 100644 --- a/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/crypto/JcaAesGcmAead.kt +++ b/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/crypto/JcaAesGcmAead.kt @@ -48,6 +48,10 @@ class JcaAesGcmAead( override val nonceLength = 12 override val tagLength = 16 + // RFC 9001 §B.1 limits — same as commonMain `Aes128Gcm`. + override val confidentialityLimit: Long = 1L shl 23 + override val integrityLimit: Long = 1L shl 52 + private val keySpec = SecretKeySpec(key, "AES") // Separate ciphers per direction. JCA's AES-GCM tracks the (key, iv) pair diff --git a/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/crypto/JcaChaCha20Poly1305Aead.kt b/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/crypto/JcaChaCha20Poly1305Aead.kt index a2c45705e1..e66e606957 100644 --- a/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/crypto/JcaChaCha20Poly1305Aead.kt +++ b/quic/src/jvmAndroid/kotlin/com/vitorpamplona/quic/crypto/JcaChaCha20Poly1305Aead.kt @@ -47,6 +47,10 @@ class JcaChaCha20Poly1305Aead( override val nonceLength = 12 override val tagLength = 16 + // RFC 9001 §B.1 limits — same as commonMain `ChaCha20Poly1305Aead`. + override val confidentialityLimit: Long = (1L shl 62) + override val integrityLimit: Long = 1L shl 36 + private val keySpec = SecretKeySpec(key, "ChaCha20") private val encryptCipher: Cipher = Cipher.getInstance("ChaCha20-Poly1305") private val decryptCipher: Cipher = Cipher.getInstance("ChaCha20-Poly1305") From 50ea4774264f781e1aa93de6676390e0647bded5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 9 May 2026 05:00:23 +0000 Subject: [PATCH 07/10] =?UTF-8?q?fix(quic):=20RFC=209000=20=C2=A710.3=20st?= =?UTF-8?q?ateless-reset=20detection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A peer that has lost connection state (crash, restart, route change) signals so by sending a "Stateless Reset" datagram — bytes shaped like a short-header packet whose trailing 16 bytes equal a `stateless_reset_token` previously communicated by the peer. Pre-fix the tokens were stored (via NEW_CONNECTION_ID into the [PathValidator] pool, and via the peer's `statelessResetToken` transport parameter) but never matched against arriving datagrams — a peer's stateless reset looked indistinguishable from noise and the connection lingered until idle timeout, or worse, an attacker could spam look-like-noise packets toward our integrity counter. Implementation: * `QuicConnection.isStatelessReset(datagram)` — short-header-form + size + trailing-16-bytes comparison. Matches against the peer's advertised token AND every unused entry in `pathValidator`'s pool. Constant-time per-token compare per §10.3.1 to avoid leaking token bits via timing. * `PathValidator.unusedTokenForSequence(seq)` — accessor for the above to walk the pool. * Parser pre-empts AEAD/HP parsing: `feedDatagramInner` checks every short-header-form datagram before the loop. False-positive rate of a real short-header packet (whose trailing 16 bytes are the AEAD tag) matching a known token is 2^-128, negligible in practice. Defense-in-depth: the AEAD-failure branch in `feedShortHeaderPacket` retains a redundant check for second-packet-of-coalesced edge cases. * On match, `markClosedExternally("stateless reset received from peer")` transitions silently to CLOSED — no CONNECTION_CLOSE emission per §10.3.1. Limitation: tokens for an actively-used DCID we migrated to via `PathValidator.tryStartValidation` are lost when the entry leaves the unused pool. Acceptable for the audio-rooms scope (no migration); documented in the kdoc. Test: `StatelessResetDetectionTest` (5 cases) covers token-match silent-close, unknown-trailer no-close, pool-stored token match, long-header form rejected, too-short datagram rejected. https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik --- .../quic/connection/PathValidator.kt | 9 ++ .../quic/connection/QuicConnection.kt | 67 ++++++++ .../quic/connection/QuicConnectionParser.kt | 27 ++++ .../connection/StatelessResetDetectionTest.kt | 149 ++++++++++++++++++ 4 files changed, 252 insertions(+) create mode 100644 quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/StatelessResetDetectionTest.kt 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 eb6534bbf9..3b0c38512f 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/PathValidator.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/PathValidator.kt @@ -280,6 +280,15 @@ class PathValidator( fun unusedSequences(): List = unusedCids.keys.toList() + /** + * Stateless-reset-token lookup for an unused-pool entry. Returns + * null if no entry exists for [sequenceNumber] (e.g. it was + * retired between [unusedSequences] returning and this call). + * Used by [QuicConnection.isStatelessReset] for token matching + * against arriving look-like-noise datagrams per RFC 9000 §10.3. + */ + fun unusedTokenForSequence(sequenceNumber: Long): ByteArray? = unusedCids[sequenceNumber]?.statelessResetToken + /** * Record a peer-issued `NEW_CONNECTION_ID` (RFC 9000 §19.15). * Returns the result code so the caller (parser) can decide 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 5e9eddd8e9..d9a5e0b3e3 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt @@ -2488,6 +2488,73 @@ class QuicConnection( return nowMillis - lastActivityMs >= timeoutMs } + /** + * RFC 9000 §10.3 stateless-reset detection. A peer that has lost + * connection state (crash, restart, route change) signals so by + * sending a "Stateless Reset" datagram — bytes that look like a + * short-header packet on the wire but whose last 16 bytes equal a + * `stateless_reset_token` previously communicated by the peer. + * + * The check fires at AEAD-failure time (a stateless reset is + * indistinguishable from forgery without the token comparison). + * Comparison is constant-time per §10.3.1 to avoid leaking token + * bits via timing. + * + * 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. + */ + internal fun isStatelessReset(datagram: ByteArray): Boolean { + if (datagram.size < 22) return false + // Form bit must be 0 (looks-like-short-header). + if ((datagram[0].toInt() and 0x80) != 0) return false + val tokenStart = datagram.size - 16 + // Compare against every known token. Constant-time per token; + // total time scales with token count but doesn't reveal which + // (if any) matched. + var anyMatch = false + peerTransportParameters?.statelessResetToken?.let { tok -> + if (tok.size == 16 && constantTimeEqualsRange(tok, datagram, tokenStart)) { + 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 + if (constantTimeEqualsRange(token, datagram, tokenStart)) { + anyMatch = true + } + } + return anyMatch + } + + /** + * Constant-time comparison of [token] (16 bytes) against the slice + * of [datagram] starting at [datagramOffset]. Caller MUST verify + * `datagram.size >= datagramOffset + token.size` upstream. + */ + private fun constantTimeEqualsRange( + token: ByteArray, + datagram: ByteArray, + datagramOffset: Int, + ): Boolean { + var diff = 0 + for (i in 0 until token.size) { + diff = diff or (token[i].toInt() xor datagram[datagramOffset + i].toInt()) + } + return diff == 0 + } + /** * Check whether an outstanding PATH_CHALLENGE has exceeded the * 3 * PTO budget (RFC 9000 §8.2.4). Called from the driver's 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 cea82ad041..520f5c661a 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt @@ -91,6 +91,21 @@ private fun feedDatagramInner( datagram: ByteArray, nowMillis: Long, ) { + // RFC 9000 §10.3 stateless-reset detection. A peer that has lost + // connection state signals so by sending a datagram whose trailing + // 16 bytes equal a stateless_reset_token we previously received. + // Pre-empts the AEAD path entirely: a real short-header packet's + // trailing 16 bytes (the AEAD tag) match a known token with + // probability 2^-128, so the false-positive rate is zero in + // practice. Spec MUST close silently — no CONNECTION_CLOSE per + // §10.3.1. + if (datagram.isNotEmpty() && + (datagram[0].toInt() and 0x80) == 0 && + conn.isStatelessReset(datagram) + ) { + conn.markClosedExternally("stateless reset received from peer") + return + } var offset = 0 while (offset < datagram.size) { val first = datagram[offset].toInt() and 0xFF @@ -419,6 +434,18 @@ private fun feedShortHeaderPacket( } } if (parsed == null) { + // RFC 9000 §10.3: an undecryptable short-header datagram may + // be a Stateless Reset from a peer that lost connection + // state (crash, restart, NAT rebinding). Before counting it + // as a forgery against the integrity limit, check whether + // its trailing 16 bytes match any known stateless-reset + // token. If so, the connection is over — silently transition + // to CLOSED per §10.3.1 (no CONNECTION_CLOSE emission, no + // further packets, just stop). + if (offset == 0 && conn.isStatelessReset(datagram)) { + conn.markClosedExternally("stateless reset received from peer") + return + } conn.qlogObserver.onPacketDropped( "AEAD auth failed or header parse failed at level APPLICATION", datagram.size - offset, diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/StatelessResetDetectionTest.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/StatelessResetDetectionTest.kt new file mode 100644 index 0000000000..2e7555db85 --- /dev/null +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/StatelessResetDetectionTest.kt @@ -0,0 +1,149 @@ +/* + * 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.connection + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * RFC 9000 §10.3 stateless-reset detection. + * + * A peer that lost connection state (crash, restart, route change) + * signals so by sending a "Stateless Reset" datagram — a short-header- + * shaped packet whose trailing 16 bytes equal a `stateless_reset_token` + * the peer previously communicated. The receiver MUST detect this and + * silently close (no CONNECTION_CLOSE; the peer doesn't have keys to + * decrypt it anyway) instead of mistaking it for a forgery and + * counting it toward the §6.6 integrity limit. + * + * Pre-fix the tokens were stored (via NEW_CONNECTION_ID into the + * [PathValidator] pool, and via the peer's transport parameters) but + * never matched against arriving datagrams — a peer's stateless reset + * looked indistinguishable from noise and the connection lingered + * until idle timeout (or worse, an attacker could spam look-like- + * noise frames toward our integrity counter). + */ +class StatelessResetDetectionTest { + @Test + fun datagram_ending_in_peer_token_triggers_silent_close() { + val (client, _) = newConnectedClient() + // Set up a known token on the connection. In production this + // arrives via either `peerTransportParameters.statelessResetToken` + // (advertised by the server in EncryptedExtensions) or a + // NEW_CONNECTION_ID frame; the test harness installs it + // directly so we control the bytes. + val token = ByteArray(16) { (0x42 + it).toByte() } + client.peerTransportParameters = + ( + client.peerTransportParameters ?: TransportParameters() + ).copy(statelessResetToken = token) + + // Build a datagram that LOOKS like a short-header packet but + // whose AEAD will fail (random ciphertext). The trailing 16 + // bytes match the token — that's the §10.3 signal. + val dcid = client.sourceConnectionId.bytes + // First byte: form-bit=0, fixed-bit=1, pnLen=1. + val first = byteArrayOf(0x40.toByte()) + val pn = byteArrayOf(0x00) + val randomBody = ByteArray(40) { (it * 7).toByte() } + val datagram = first + dcid + pn + randomBody + token + feedDatagram(client, datagram, nowMillis = 0L) + + assertEquals(QuicConnection.Status.CLOSED, client.status) + val reason = client.closeReason + assertNotNull(reason) + assertTrue(reason.contains("stateless reset"), "expected stateless-reset reason, got: $reason") + } + + @Test + fun datagram_ending_in_unknown_token_does_not_close() { + val (client, _) = newConnectedClient() + // Don't install the token at all — but feed a datagram whose + // trailing 16 bytes happen to be some random value. + val unknown = ByteArray(16) { 0xAA.toByte() } + val dcid = client.sourceConnectionId.bytes + val first = byteArrayOf(0x40.toByte()) + val pn = byteArrayOf(0x00) + val randomBody = ByteArray(40) { (it * 11).toByte() } + val datagram = first + dcid + pn + randomBody + unknown + feedDatagram(client, datagram, nowMillis = 0L) + // Connection should still be CONNECTED (or at most have its + // integrity counter bumped); definitely NOT closed-on-stateless- + // reset. + if (client.status == QuicConnection.Status.CLOSED) { + assertFalse( + client.closeReason!!.contains("stateless reset"), + "non-matching token must not be misclassified as stateless reset", + ) + } + } + + @Test + fun isStatelessReset_constant_time_check_works_against_pool_tokens() { + val (client, _) = newConnectedClient() + // Install a token on a NEW_CONNECTION_ID-shaped pool entry. + val token = ByteArray(16) { (0x55 + it).toByte() } + client.pathValidator.recordPeerNewConnectionId( + sequenceNumber = 1L, + retirePriorTo = 0L, + connectionId = ByteArray(8) { (0xAB + it).toByte() }, + statelessResetToken = token, + ) + + val dcid = client.sourceConnectionId.bytes + val datagram = byteArrayOf(0x40.toByte()) + dcid + ByteArray(40) + token + assertTrue(client.isStatelessReset(datagram), "pool-stored token must match") + + val mismatch = byteArrayOf(0x40.toByte()) + dcid + ByteArray(40) + ByteArray(16) + assertFalse(client.isStatelessReset(mismatch), "all-zeros trailer must not match a real token") + } + + @Test + fun isStatelessReset_rejects_long_header_form() { + val (client, _) = newConnectedClient() + val token = ByteArray(16) { 0x33 } + client.peerTransportParameters = + ( + client.peerTransportParameters ?: TransportParameters() + ).copy(statelessResetToken = token) + // First byte 0x80+ → long header form; spec says stateless + // reset is short-header-shaped only. + val datagram = byteArrayOf(0xC0.toByte()) + ByteArray(40) + token + assertFalse(client.isStatelessReset(datagram), "long-header-form must not be classified as stateless reset") + } + + @Test + fun isStatelessReset_rejects_too_short_datagram() { + val (client, _) = newConnectedClient() + val token = ByteArray(16) { 0x77 } + client.peerTransportParameters = + ( + client.peerTransportParameters ?: TransportParameters() + ).copy(statelessResetToken = token) + // Datagram shorter than the §10.3 minimum (we use 22 bytes: + // 5-byte minimum header + 16-byte trailer + 1). + val tooShort = byteArrayOf(0x40.toByte()) + token + assertFalse(client.isStatelessReset(tooShort)) + } +} From d4ffe0474fda9611bb4147303479abede9867fcb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 9 May 2026 05:09:39 +0000 Subject: [PATCH 08/10] =?UTF-8?q?fix(quic):=20RFC=209000=20=C2=A710.2.2=20?= =?UTF-8?q?DRAINING=20state=20for=20peer=20CONNECTION=5FCLOSE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Peer's CONNECTION_CLOSE now transitions the connection through DRAINING for 3 * PTO before flipping to CLOSED, instead of going straight to CLOSED. While DRAINING: - the writer emits no packets (drainOutbound returns null); - the parser drops late inbound silently at the top of feedDatagramInner; - close()-awaiters unblock immediately via closingDrainSignal so the transition isn't gated on the 3*PTO grace. After 3 * PTO the driver's send-loop timer fires markClosedExternally("draining period elapsed") which transitions to CLOSED. The §10.2.2 grace period gives the peer's last retransmits a chance to converge before we discard state. Implementation: * `Status.DRAINING` enum value with kdoc tying it to §10.2.2. * `QuicConnection.drainingDeadlineMs` + `enterDraining(reason, nowMs)` (sets status, computes `now + max(3*pto, MIN_DRAINING_PERIOD_MS)`, completes closingDrainSignal, fires qlog) + `isDrainingExpired`. * Parser's CONNECTION_CLOSE handler routes through `enterDraining` instead of `markClosedExternally`. * Driver folds `drainingDeadlineMs` into its send-loop sleep `withTimeoutOrNull` and transitions to CLOSED on expiry. * Writer short-circuits drainOutbound for DRAINING (returns null — spec MUST NOT send). * Parser drops late inbound at the top of feedDatagramInner. Updated `FrameRoutingTest.connection_close_from_peer_short_circuits_remaining_frames` to expect DRAINING (was CLOSED). Other status=CLOSED assertions in the test suite cover OUR-side closes (markClosedExternally on protocol violations / TRANSPORT_PARAMETER_ERROR / FLOW_CONTROL_ERROR) which still go directly to CLOSED. Test: `DrainingStateTest` (7 cases) covers the peer-close → DRAINING transition, late-inbound drop, deadline math, the MIN_DRAINING_PERIOD_MS floor, retransmit no-op semantics, and the fresh-connection invariant. Plan updated to mark all 🟡 Medium items resolved. The 2026-05-09 audit's complete spec-compliance close-out: 2 of 2 High + 6 of 6 Medium fixed in seven commits. https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik --- quic/plans/2026-04-26-quic-stack-status.md | 28 +-- .../quic/connection/QuicConnection.kt | 97 ++++++++++- .../quic/connection/QuicConnectionDriver.kt | 32 +++- .../quic/connection/QuicConnectionParser.kt | 16 +- .../quic/connection/QuicConnectionWriter.kt | 5 + .../quic/connection/DrainingStateTest.kt | 161 ++++++++++++++++++ .../quic/connection/FrameRoutingTest.kt | 12 +- 7 files changed, 322 insertions(+), 29 deletions(-) create mode 100644 quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/DrainingStateTest.kt diff --git a/quic/plans/2026-04-26-quic-stack-status.md b/quic/plans/2026-04-26-quic-stack-status.md index 83e6afc9c6..d2d8e8145c 100644 --- a/quic/plans/2026-04-26-quic-stack-status.md +++ b/quic/plans/2026-04-26-quic-stack-status.md @@ -193,7 +193,7 @@ gates on the audio-rooms completion plan | 8 | 2026-05-06 | Path validation pass 1 + 2 | DCID rotation, retire-prior-to monotonicity, abrupt migration | all fixed | | 9 | 2026-05-07 | DoS hardening + reserved-bit checks + TLS bounds + SendBuffer shrink | bound peer-controlled buffers and channels | all fixed | | 10 | 2026-05-08 | Lock-split design + close-under-load + key-update PN gate | streamsLock + lifecycleLock split | all fixed; see [`2026-05-08-lock-split-design.md`](2026-05-08-lock-split-design.md) | -| 11 | 2026-05-09 | Four-RFC compliance audit (4 parallel agents: 9000, 9001, 9002, 9221+9114+9204) | ~15 items, mostly receive-side validation gaps | catalogued in [§ RFC compliance gaps](#rfc-compliance-gaps-2026-05-09) below; both 🔴 High items (fixed-bit, idle timeout) fixed same-day | +| 11 | 2026-05-09 | Four-RFC compliance audit (4 parallel agents: 9000, 9001, 9002, 9221+9114+9204) | ~15 items, mostly receive-side validation gaps | catalogued in [§ RFC compliance gaps](#rfc-compliance-gaps-2026-05-09) below; **all 🔴 High and all 🟡 Medium items resolved same-day across 7 commits** | Every fix carries an inline `audit-N #M` reference comment so the regression test → fix → comment chain is auditable. The whole audit corpus is in the @@ -287,29 +287,29 @@ file:line evidence. Severity: | RFC § | Sev | Gap | Evidence | |---|---|---|---| -| RFC 9000 §17.2 + §17.3 | ~~🔴~~ | ~~**Inbound fixed-bit (0x40) not validated** on long- or short-header receive.~~ **Resolved 2026-05-09** — both `LongHeaderPacket.parseAndDecrypt`, `ShortHeaderPacket.parseAndDecrypt`, and `ShortHeaderPacket.peekKeyPhase` now reject fixed-bit=0 packets per RFC §17.2 / §17.3 (silent discard). Tests in `FixedBitValidationTest`. | -| RFC 9000 §10.1 | ~~🔴~~ | ~~**`max_idle_timeout` not enforced.**~~ **Resolved 2026-05-09** — `QuicConnection.lastActivityMs` updated on inbound packet receipt and outbound ack-eliciting send; `effectiveIdleTimeoutMs()` computes min(local, peer) with 3*PTO floor per §10.1; driver send-loop folds the deadline into its `withTimeoutOrNull` and silently closes via `markClosedExternally` per §10.2.1 on expiry. Tests in `IdleTimeoutTest` (8 cases). | -| RFC 9000 §4.1 | 🟡 | **Connection-level inbound flow control missing.** Per-stream `receiveLimit` IS enforced (`QuicConnectionParser.kt:655`) — peer overshoot closes the connection. But the aggregate connection-level cap (matching what we advertised in `initial_max_data`) is not tracked on the receive side. | `QuicConnectionParser.kt:735-743` (only updates send-side credit on MAX_DATA) | -| RFC 9000 §3 | 🟡 | **Stream state machine implicit, not validated.** The 10-state machine (Ready / Send / Data Sent / Data Recvd / Reset Sent / Reset Recvd × directions) is encoded as flags + buffer content. STREAM frames arriving after a RESET_STREAM are silently absorbed instead of raising STREAM_STATE_ERROR. (FIN-size mismatch *is* caught — `QuicConnectionParser.kt:672-685`.) | `QuicStream.kt:35-51` (no explicit state field) | -| RFC 9000 §10.2 | 🟡 | **No DRAINING state.** Closing transitions go CONNECTED → CLOSING → CLOSED without the 3 × PTO hold the spec mandates. Late inbound packets after our own CONNECTION_CLOSE may not get a re-emitted close. | `QuicConnection.kt:301,1441-1531` (Status enum has no DRAINING) | -| RFC 9000 §10.3 | 🟡 | **Stateless reset detection missing.** Tokens are stored in `PeerConnectionIdEntry` but no inbound matcher compares fail-to-decrypt datagrams against the token list. Spoofed peer-side resets look like noise. | `PathValidator.kt:35-43`; no consumer in parser | -| RFC 9001 §6.6 | 🟡 | **AEAD invocation limit not tracked.** AES-128-GCM is bounded at 2^23 invocations (~8M packets). Past the limit, key rotation is mandatory. We don't count, don't rotate, don't close with AEAD_LIMIT_REACHED. ChaCha20 is effectively unbounded so less critical. | `Aead.kt`, `JcaAesGcmAead.kt` (no counter) | +| RFC 9000 §17.2 + §17.3 | ~~🔴~~ | ~~**Inbound fixed-bit (0x40) not validated.**~~ **Resolved 2026-05-09** — both `LongHeaderPacket.parseAndDecrypt`, `ShortHeaderPacket.parseAndDecrypt`, and `ShortHeaderPacket.peekKeyPhase` now reject fixed-bit=0 packets per RFC §17.2 / §17.3 (silent discard). Tests in `FixedBitValidationTest`. | +| RFC 9000 §10.1 | ~~🔴~~ | ~~**`max_idle_timeout` not enforced.**~~ **Resolved 2026-05-09** — `QuicConnection.lastActivityMs` updated on inbound packet receipt and outbound ack-eliciting send; `effectiveIdleTimeoutMs()` computes min(local, peer) with 3 × PTO floor per §10.1; driver send-loop folds the deadline into its `withTimeoutOrNull` and silently closes via `markClosedExternally` per §10.2.1 on expiry. Tests in `IdleTimeoutTest` (8 cases). | +| 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 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 | RFC § | Sev | Gap | Evidence | |---|---|---|---| -| RFC 9000 §18.2 | 🟡 | **`max_udp_payload_size < 1200` not rejected.** Spec says minimum 1200; smaller values MUST close with TRANSPORT_PARAMETER_ERROR. | `TransportParameters.kt:165` (decode-only) | -| RFC 9000 §18.2 | 🟡 | **`ack_delay_exponent > 20` not rejected.** Spec maximum is 20. | `TransportParameters.kt:166` (decode-only) | -| RFC 9000 §18.2 | 🟡 | **`active_connection_id_limit < 2` not rejected.** Spec minimum is 2. (We already cap our own pool at the peer's value via `PathValidator.maxUnusedCids`, so the runtime risk is bounded — just no explicit error.) | `TransportParameters.kt:168`; `QuicConnection.kt:582` | -| RFC 9000 §13.2.5 | 🟦 | **`ack_delay_exponent` decode uses our config, not peer's advertised value.** Default (3) matches both ends in practice, but a peer that advertises a non-default exponent gets misinterpreted ACK delays. The shift IS overflow-safe (`QuicConnectionParser.kt:540-553`), just keyed off the wrong side's parameter. | `QuicConnectionParser.kt:547` (uses `conn.config.ackDelayExponent`) | +| RFC 9000 §18.2 | ~~🟡~~ | ~~**`max_udp_payload_size < 1200` not rejected.**~~ **Resolved 2026-05-09** — `applyPeerTransportParameters` closes with TRANSPORT_PARAMETER_ERROR. Tests in `TransportParameterBoundsTest`. | +| RFC 9000 §18.2 | ~~🟡~~ | ~~**`ack_delay_exponent > 20` not rejected.**~~ **Resolved 2026-05-09** — same path. | +| RFC 9000 §18.2 | ~~🟡~~ | ~~**`active_connection_id_limit < 2` not rejected.**~~ **Resolved 2026-05-09** — same path. | +| RFC 9000 §13.2.5 | ~~🟦~~ | ~~**`ack_delay_exponent` decode uses our config, not peer's.**~~ **Resolved 2026-05-09** — parser now uses `peerTransportParameters?.ackDelayExponent` with the §18.2 default of 3 as fallback; defensive 0..20 coercion preserved. | ### HTTP/3 + DATAGRAM gaps | RFC § | Sev | Gap | Evidence | |---|---|---|---| -| RFC 9114 §7.2.4.1 | 🟡 | **HTTP/2 reserved SETTINGS ids 0x02/0x03/0x04/0x05 not rejected.** RFC says receipt MUST close with H3_SETTINGS_ERROR. We accept and store. | `Http3Settings.kt:85-121` | -| RFC 9221 §3 | 🟡 | **Outbound DATAGRAM size not gated by peer's `max_datagram_frame_size`.** Encoder will send oversize frames; spec-conformant peers will close with PROTOCOL_VIOLATION. | `Frame.kt:278-291` (no size check on send) | +| 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` | ### TLS / error reporting cosmetics 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 d9a5e0b3e3..77220c72be 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt @@ -298,7 +298,30 @@ class QuicConnection( var peerTransportParameters: TransportParameters? = null internal set - enum class Status { HANDSHAKING, CONNECTED, CLOSING, CLOSED } + enum class Status { + HANDSHAKING, + CONNECTED, + + /** + * RFC 9000 §10.2.1 closing period. We've sent a + * CONNECTION_CLOSE (or are about to); the writer flushes the + * close datagram and then the connection holds until the + * peer's last responses drain out of flight. + */ + CLOSING, + + /** + * RFC 9000 §10.2.2 draining period. We received a + * CONNECTION_CLOSE from the peer; we MUST NOT send any further + * packets and SHOULD discard inbound packets without + * processing. The state MUST persist for at least 3 * PTO + * before transitioning to fully [CLOSED] so the peer's + * retransmits can converge. + */ + DRAINING, + + CLOSED, + } /** * Lock-split refactor (2026-05-08): @Volatile so concurrent loops can @@ -333,6 +356,16 @@ class QuicConnection( @Volatile internal var lastActivityMs: Long = nowMillis() + /** + * RFC 9000 §10.2.2 draining-period deadline. Set by + * [enterDraining] to `now + 3 * PTO`; the driver's send loop + * folds it into the timeout and transitions to [Status.CLOSED] + * via [markClosedExternally] when it elapses. Null whenever the + * connection is not in [Status.DRAINING]. + */ + @Volatile + internal var drainingDeadlineMs: Long? = null + /** * Non-suspend monitor protecting the atomic transition of * [status] / [closeReason] / [closeErrorCode] from a non-CLOSED to a @@ -2488,6 +2521,60 @@ class QuicConnection( return nowMillis - lastActivityMs >= timeoutMs } + /** + * RFC 9000 §10.2.2: enter the draining period after receiving a + * peer's CONNECTION_CLOSE. Sets [status] to [Status.DRAINING], + * records a `now + 3 * PTO` deadline at which the driver flips + * the connection to [Status.CLOSED]. While draining, the writer + * MUST NOT emit any packets; the read loop drops late inbound + * silently. + * + * For the application this is functionally equivalent to "closed" + * — `awaitIncoming*` returns null, new streams can't be opened — + * but the explicit DRAINING state lets observability tools (qlog, + * tests) see the spec-mandated grace period. + * + * Per §10.2.2 caller transitions ONLY from CONNECTED / HANDSHAKING. + * A second call (peer retransmits its CONNECTION_CLOSE) is a + * harmless no-op. + */ + internal fun enterDraining( + reason: String, + nowMillis: Long, + ) { + val firstTransition = + synchronized(closeStateMonitor) { + if (status == Status.CLOSED || status == Status.DRAINING) return@synchronized false + closeReason = reason + status = Status.DRAINING + true + } + if (!firstTransition) return + // 3 * PTO deadline. Use the connection's `lossDetection.ptoBaseMs` + // for the post-handshake case (peer params have arrived); fall + // back to a small floor pre-handshake. + val maxAckDelay = peerTransportParameters?.maxAckDelay ?: 0L + val ptoBaseMs = lossDetection.ptoBaseMs(maxAckDelay).coerceAtLeast(1L) + drainingDeadlineMs = nowMillis + (ptoBaseMs * 3L).coerceAtLeast(MIN_DRAINING_PERIOD_MS) + // Wake any close()-awaiter so they don't sit on the + // `closingDrainSignal` for 3*PTO unnecessarily. + closingDrainSignal.complete(Unit) + qlogObserver.onConnectionClosed("remote", closeErrorCode, reason) + if (!handshakeComplete) { + signalHandshakeFailed(QuicConnectionClosedException("connection closed externally: $reason")) + } + } + + /** + * RFC 9000 §10.2.2 draining-deadline check. Returns true once + * the 3 * PTO grace period has elapsed and the connection should + * be flipped to fully closed. + */ + internal fun isDrainingExpired(nowMillis: Long): Boolean { + val deadline = drainingDeadlineMs ?: return false + return status == Status.DRAINING && nowMillis >= deadline + } + /** * RFC 9000 §10.3 stateless-reset detection. A peer that has lost * connection state (crash, restart, route change) signals so by @@ -2822,6 +2909,14 @@ class QuicConnection( } companion object { + /** + * RFC 9000 §10.2.2 floor on the draining-period grace. The + * spec mandates "at least 3 * PTO" which can be vanishingly + * small pre-handshake. Floor at 100 ms so qlog observers and + * tests can see the DRAINING window. + */ + const val MIN_DRAINING_PERIOD_MS: Long = 100L + /** * Bound on the inbound datagram queue depth. RFC 9221 datagrams are * outside connection-level flow control, so without this cap a peer diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionDriver.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionDriver.kt index 642a86ddc2..8b6d4861e8 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionDriver.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionDriver.kt @@ -298,11 +298,19 @@ class QuicConnectionDriver( idleTimeoutMs?.let { (connection.lastActivityMs + it - nowForLossTimer).coerceAtLeast(0L) } + // RFC 9000 §10.2.2 draining-period deadline. Set when + // peer's CONNECTION_CLOSE arrives; we hold in DRAINING + // until 3 * PTO elapses, then transition to CLOSED. + val drainingDelta = + connection.drainingDeadlineMs?.let { + (it - nowForLossTimer).coerceAtLeast(0L) + } val sleepMillis = minOf( ptoMillis, nextLossDelta ?: Long.MAX_VALUE, idleDelta ?: Long.MAX_VALUE, + drainingDelta ?: Long.MAX_VALUE, ) // Suspend until either: a wakeup arrives, or the timer expires. val woke = @@ -311,14 +319,22 @@ class QuicConnectionDriver( Unit } if (woke == null) { - // Distinguish idle-timeout / loss-timer / PTO expiry. An - // idle-timeout wake silently closes the connection per - // RFC 9000 §10.2.1 ("the connection enters the closing - // state silently — discarding the connection state - // without sending a CONNECTION_CLOSE frame"). A - // loss-timer wake just runs `detectAndRemoveLost`. A - // PTO wake additionally bumps the consecutive-PTO - // counter and arms the probe budget. + // Distinguish draining-deadline / idle-timeout / + // loss-timer / PTO expiry. RFC 9000 §10.2.2: the + // draining period transitions to fully CLOSED once + // 3 * PTO has elapsed since the peer's + // CONNECTION_CLOSE — gives the peer's last + // retransmits a chance to converge before we discard + // state. Idle-timeout fires per §10.2.1 ("silently + // closes — discarding the connection state without + // sending a CONNECTION_CLOSE frame"). A loss-timer + // wake just runs `detectAndRemoveLost`. A PTO wake + // additionally bumps the consecutive-PTO counter and + // arms the probe budget. + if (connection.isDrainingExpired(nowMillis())) { + connection.markClosedExternally("draining period elapsed") + continue + } if (idleTimeoutMs != null && connection.isIdleTimedOut(nowMillis())) { connection.markClosedExternally( "idle timeout ($idleTimeoutMs ms with no activity)", 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 520f5c661a..48899bf86b 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt @@ -91,6 +91,13 @@ private fun feedDatagramInner( datagram: ByteArray, nowMillis: Long, ) { + // RFC 9000 §10.2.2: while in the DRAINING state, late inbound + // packets MUST be discarded silently. Same goes for CLOSED. + if (conn.status == QuicConnection.Status.DRAINING || + conn.status == QuicConnection.Status.CLOSED + ) { + return + } // RFC 9000 §10.3 stateless-reset detection. A peer that has lost // connection state signals so by sending a datagram whose trailing // 16 bytes equal a stateless_reset_token we previously received. @@ -1032,7 +1039,14 @@ private fun dispatchFrames( // Audit-4 #13: any frames following CONNECTION_CLOSE in the // same payload MUST NOT be dispatched — they could create // streams or deliver bytes on an already-closed connection. - conn.markClosedExternally("peer CONNECTION_CLOSE: ${frame.reason}") + // + // RFC 9000 §10.2.2: enter the DRAINING state for 3 * PTO + // before transitioning to CLOSED. During draining we + // MUST NOT send packets and SHOULD discard inbound + // silently. The driver's send loop transitions to + // CLOSED when [QuicConnection.drainingDeadlineMs] + // elapses. + conn.enterDraining("peer CONNECTION_CLOSE: ${frame.reason}", nowMillis) return } diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt index 530e7ead19..2981f4f9b5 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionWriter.kt @@ -93,6 +93,11 @@ fun drainOutbound( conn.closingDrainSignal.complete(Unit) return datagram } + // RFC 9000 §10.2.2: in the draining state we MUST NOT send any + // further packets. The driver's send-loop timer transitions the + // status to CLOSED once the 3 * PTO grace elapses; until then + // drainOutbound returns null on every call. + if (conn.status == QuicConnection.Status.DRAINING) return null // Bug-A fix: drive the §8.2.4 3*PTO validation budget on every // drain, not just on PTO expiration. The peer ACKs PATH_CHALLENGE diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/DrainingStateTest.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/DrainingStateTest.kt new file mode 100644 index 0000000000..cfc1148999 --- /dev/null +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/DrainingStateTest.kt @@ -0,0 +1,161 @@ +/* + * 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.connection + +import com.vitorpamplona.quic.frame.ConnectionCloseFrame +import com.vitorpamplona.quic.frame.StreamFrame +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * RFC 9000 §10.2.2 draining state. + * + * When an endpoint receives a CONNECTION_CLOSE frame from its peer it + * MUST enter the draining state. While draining: + * - the endpoint MUST NOT send further packets; + * - the endpoint SHOULD discard inbound packets without processing; + * - the state MUST persist for at least 3 * PTO before transitioning + * to fully closed (so the peer's last retransmits can converge). + * + * Pre-fix the parser called `markClosedExternally` directly on inbound + * CONNECTION_CLOSE which transitioned straight to CLOSED. There was no + * grace period, no observable DRAINING phase, and the read loop didn't + * explicitly drop subsequent late packets (it just exited on CLOSED — + * functional equivalence, but the spec-mandated state was missing + * from observability). + */ +class DrainingStateTest { + @Test + fun peer_connection_close_transitions_to_draining() { + val (client, pipe) = newConnectedClient() + val close = + ConnectionCloseFrame( + errorCode = 0L, + frameType = 0L, // transport-close (non-null frameType) + reason = "test peer close", + ) + feedDatagram(client, pipe.buildServerApplicationDatagram(listOf(close))!!, nowMillis = 0L) + assertEquals(QuicConnection.Status.DRAINING, client.status) + assertNotNull(client.drainingDeadlineMs, "deadline must be set") + assertTrue(client.drainingDeadlineMs!! > 0L) + val reason = client.closeReason + assertNotNull(reason) + assertTrue(reason.contains("peer CONNECTION_CLOSE")) + } + + @Test + fun late_inbound_during_draining_is_dropped() { + val (client, pipe) = newConnectedClient() + val close = + ConnectionCloseFrame( + errorCode = 0L, + frameType = 0L, + reason = "draining drop test", + ) + feedDatagram(client, pipe.buildServerApplicationDatagram(listOf(close))!!, nowMillis = 0L) + assertEquals(QuicConnection.Status.DRAINING, client.status) + + // Feed a STREAM frame after the peer-close. Per §10.2.2 the + // packet MUST be discarded; the connection-level + // bookkeeping (e.g. `connectionInboundOffsetSum`) MUST stay + // put. + val before = client.connectionInboundOffsetSum + val streamFrame = StreamFrame(streamId = 3L, offset = 0L, data = ByteArray(64), fin = false) + val datagram = pipe.buildServerApplicationDatagram(listOf(streamFrame)) + // The pipe build may legitimately succeed (server keys are + // separate from client state) — test only matters if it does. + if (datagram != null) { + feedDatagram(client, datagram, nowMillis = 0L) + assertEquals(before, client.connectionInboundOffsetSum, "draining must drop without bookkeeping update") + } + // Status remains DRAINING — late packet did not flip us to + // CLOSED prematurely. + assertEquals(QuicConnection.Status.DRAINING, client.status) + } + + @Test + fun isDrainingExpired_false_before_deadline() { + val (client, _) = newConnectedClient() + client.enterDraining("test", nowMillis = 0L) + // Just before the deadline. + val deadline = client.drainingDeadlineMs!! + assertFalse(client.isDrainingExpired(deadline - 1L)) + } + + @Test + fun isDrainingExpired_true_at_deadline() { + val (client, _) = newConnectedClient() + client.enterDraining("test", nowMillis = 0L) + val deadline = client.drainingDeadlineMs!! + assertTrue(client.isDrainingExpired(deadline)) + assertTrue(client.isDrainingExpired(deadline + 100L)) + } + + @Test + fun draining_floor_at_least_minimum_period() { + // Pre-handshake the PTO is small (smoothed_rtt floor only), + // so the §10.2.2 "3 * PTO" target could be vanishingly tiny. + // The implementation floors at MIN_DRAINING_PERIOD_MS so qlog + // observers and tests can see the DRAINING window. + val (client, _) = newConnectedClient() + client.enterDraining("test", nowMillis = 0L) + val deadline = client.drainingDeadlineMs!! + assertTrue(deadline >= QuicConnection.MIN_DRAINING_PERIOD_MS, "expected ≥ ${QuicConnection.MIN_DRAINING_PERIOD_MS}, got $deadline") + } + + @Test + fun second_connection_close_during_draining_is_no_op() { + val (client, pipe) = newConnectedClient() + val close = + ConnectionCloseFrame( + errorCode = 0L, + frameType = 0L, + reason = "first close", + ) + feedDatagram(client, pipe.buildServerApplicationDatagram(listOf(close))!!, nowMillis = 0L) + val firstReason = client.closeReason + val firstDeadline = client.drainingDeadlineMs + + // Peer retransmits its CONNECTION_CLOSE — we MUST NOT reset + // the draining deadline (would extend the grace indefinitely + // under retransmits) or change the recorded reason. + val secondClose = + ConnectionCloseFrame( + errorCode = 0L, + frameType = 0L, + reason = "second close", + ) + feedDatagram(client, pipe.buildServerApplicationDatagram(listOf(secondClose))!!, nowMillis = 50L) + assertEquals(firstReason, client.closeReason, "first-call wins on closeReason") + assertEquals(firstDeadline, client.drainingDeadlineMs, "deadline should not advance on retransmit") + } + + @Test + fun fresh_connection_has_no_draining_deadline() { + val (client, _) = newConnectedClient() + assertEquals(QuicConnection.Status.CONNECTED, client.status) + assertNull(client.drainingDeadlineMs) + } +} diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/FrameRoutingTest.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/FrameRoutingTest.kt index d39937bf2f..7876cca06b 100644 --- a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/FrameRoutingTest.kt +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/FrameRoutingTest.kt @@ -187,11 +187,13 @@ class FrameRoutingTest { // Order matters: CCF first, stream frame second. val packet = pipe.buildServerApplicationDatagram(listOf(ccf, streamFrame))!! feedDatagram(client, packet, nowMillis = 0L) - assertEquals(QuicConnection.Status.CLOSED, client.status) - // The dispatcher MUST have stopped at CCF; no peer stream materialised. - // (We can't easily query this directly, but the behavioural assertion - // is the close-status — pre-fix the StreamFrame would have created - // a phantom stream after close.) + // RFC 9000 §10.2.2: peer's CONNECTION_CLOSE puts us in DRAINING + // (was CLOSED pre-DRAINING-implementation; the spec-mandated + // 3 * PTO grace is held by the driver's send loop and flips to + // CLOSED once it elapses). The "no phantom stream" assertion + // still holds — DRAINING blocks frame dispatch the same way + // CLOSED did. + assertEquals(QuicConnection.Status.DRAINING, client.status) } @Test From cdb9d87a2e9146486cdc771b8504e0bcc405984b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 9 May 2026 13:12:00 +0000 Subject: [PATCH 09/10] =?UTF-8?q?fix(quic):=20RFC=209114=20=C2=A76.2.1=20c?= =?UTF-8?q?ritical-stream=20auto-close=20+=20WiFi-handoff=20stateless-rese?= =?UTF-8?q?t=20tokens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related improvements for the audio-rooms moq-lite path on mobile. ** RFC 9114 §6.2.1 + RFC 9204 §4.2 critical-stream closure ** When the peer closes a critical unidirectional stream — control (0x00), QPACK encoder (0x02), or QPACK decoder (0x03) — the spec MANDATES we treat it as a connection error of type H3_CLOSED_CRITICAL_STREAM. Pre-fix the demux only set `peerH3ProtocolError` flag on protocol violations, with no autonomous close on clean FIN; the application was expected to poll. For audio rooms running on lossy mobile paths, relay-side control-stream drops left users staring at silent audio. * `Http3ErrorCode` constants for the §8.1 error-code table. * `WtPeerStreamDemux.drainControlStream` calls `connection.close(...)` on either clean FIN (H3_CLOSED_CRITICAL_STREAM) or QuicCodecException raised by the frame reader (specific code via [http3ErrorCodeForMessage] map: H3_MISSING_SETTINGS, H3_FRAME_UNEXPECTED, H3_SETTINGS_ERROR, H3_ID_ERROR, etc.). * New `drainCriticalStream` helper fires the same close on QPACK encoder / decoder FIN (RFC 9204 §4.2). * Closure intent latched on `criticalStreamClosureCode` / `criticalStreamClosureReason` so tests can verify the path runs without wiring a real driver. ** RFC 9000 §10.3 stateless-reset tokens for migrated CIDs ** The 2026-05-09 stateless-reset detection covered tokens in the unused-CID pool but lost them the moment a CID was rotated to active via `tryStartValidation` (WiFi handoff) or `forceRotateToHigherSequence` (peer-forced retire). On the new path the migrated token wouldn't match — relay crash mid-handoff would silently hang until idle timeout. * `PathValidator.knownStatelessResetTokens` — append-only lifetime store, populated in `recordPeerNewConnectionId`. Survives rotation. * `QuicConnection.isStatelessReset` walks the lifetime store instead of the unused pool. * §10.3.1 explicitly permits keeping tokens after retirement; cost is ~16 bytes per peer-issued CID. Tests: * `CriticalStreamClosureTest` (5 cases) — control FIN, QPACK FIN, MISSING_SETTINGS, FRAME_UNEXPECTED, idempotency. * `StatelessResetDetectionTest` extended with 2 cases — `token_persists_after_path_migration_for_wifi_handoff` and `token_persists_through_force_rotation_for_acid_reissue`. Plan updated: 🟦 H3_CLOSED_CRITICAL_STREAM resolved; the "limitation: tokens for actively-used DCIDs we migrated to" caveat is removed from the §10.3 entry and from known-limitations item #5. https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik --- quic/plans/2026-04-26-quic-stack-status.md | 16 +- .../quic/connection/PathValidator.kt | 51 +++- .../quic/connection/QuicConnection.kt | 25 +- .../quic/http3/Http3FrameTypes.kt | 39 +++ .../quic/webtransport/WtPeerStreamDemux.kt | 128 ++++++++- .../connection/StatelessResetDetectionTest.kt | 81 ++++++ .../webtransport/CriticalStreamClosureTest.kt | 249 ++++++++++++++++++ 7 files changed, 564 insertions(+), 25 deletions(-) create mode 100644 quic/src/commonTest/kotlin/com/vitorpamplona/quic/webtransport/CriticalStreamClosureTest.kt diff --git a/quic/plans/2026-04-26-quic-stack-status.md b/quic/plans/2026-04-26-quic-stack-status.md index d2d8e8145c..b3140f41fc 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 3b0c38512f..57fdc88e79 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 77220c72be..debd32617c 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 bbe1b4db78..6fb73fc232 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 333d95885a..0cc040795a 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 2e7555db85..bd99d5fe7f 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 0000000000..5aa492e467 --- /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() + } + } +} From 6cc1bf64331ac1d4da03d28d683c411a26ea2daf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 9 May 2026 13:44:35 +0000 Subject: [PATCH 10/10] =?UTF-8?q?fix(quic):=20RFC=209001=20=C2=A74.8=20TLS?= =?UTF-8?q?=20alert=20mapping=20+=20RFC=209000=20=C2=A722=20CRYPTO=5FBUFFE?= =?UTF-8?q?R=5FEXCEEDED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remaining 🟦 cosmetic items from the 2026-05-09 audit. Pre-fix the connection's `closeErrorCode` was effectively unused — TLS handshake failures bubbled up as generic `QuicCodecException` and observers had to grep the reason string for the spec category. Now closeErrorCode carries the RFC 9000 §20.1 transport code or the RFC 9001 §4.8 TLS-alert-mapped CRYPTO_ERROR. Implementation: * `TlsAlertException(alertCode, message)` carrier raised by the TLS layer for the well-defined cases: - HelloRetryRequest received (handshake_failure = 40) - TLS 1.3 not negotiated (protocol_version = 70) - Unsupported cipher (illegal_parameter = 47) - ALPN mismatch (no_application_protocol = 120) - Cert chain validation failure (bad_certificate = 42) - CertificateVerify signature failure (decrypt_error = 51) - Server Finished MAC mismatch (decrypt_error = 51) - TLS KeyUpdate over QUIC (unexpected_message = 10) — RFC 9001 §6 mandates this specific code; fixes the misleading prior message that said "rotation not implemented" (we DO implement QUIC's own KEY_PHASE-bit rotation). The QUIC parser maps `0x100 + alertCode` per RFC 9001 §4.8 and stamps `closeErrorCode`. * `QuicTransportError` constants object covering the RFC 9000 §20.1 table (NO_ERROR through NO_VIABLE_PATH including the previously- flagged CRYPTO_BUFFER_EXCEEDED 0x0d, KEY_UPDATE_ERROR 0x0e, AEAD_LIMIT_REACHED 0x0f). * `markClosedExternally(reason, errorCode = NO_ERROR)` overload — backward compatible; lets call sites pin the spec code on `closeErrorCode` for qlog observability without changing the wire-emit semantics (still no CONNECTION_CLOSE frame, same as before — that's a separate, larger refactor). * RFC 9000 §22 CRYPTO_BUFFER_EXCEEDED enforcement — pre-insert per-level cap of 64 KiB on inbound CRYPTO data. Generous enough for an RSA-4096 cert chain with intermediates; bounds the worst-case heap a misbehaving peer can pin to 192 KiB across all 3 encryption levels. Fires before the receive buffer actually allocates. * INVALID_TOKEN — N/A for client role (only servers validate Retry tokens). KEY_UPDATE_ERROR — exposed as a constant; no current failure path maps to it (PN regression on a key-update packet is spec-allowed to handle silently per RFC 9001 §6.1, which we do). Tests: `ErrorCodeMappingTest` (6 cases) covers TlsAlertException construction + offset, alert code bounds, markClosedExternally preservation, CRYPTO_BUFFER_EXCEEDED close, sanity-check that small CRYPTO frames don't trip the cap, and §20.1 numeric values. `HelloRetryRequestTest` updated to expect TlsAlertException(40) instead of generic QuicCodecException. Plan updated to mark the §4.8 + §22 (CRYPTO_BUFFER_EXCEEDED) items resolved; KEY_UPDATE_ERROR documented as TLS-side covered + QUIC-side spec-allowed-silent; INVALID_TOKEN documented as N/A for client. https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik --- quic/plans/2026-04-26-quic-stack-status.md | 4 +- .../kotlin/com/vitorpamplona/quic/Buffer.kt | 25 +++ .../quic/connection/QuicConnection.kt | 38 ++++- .../quic/connection/QuicConnectionParser.kt | 60 ++++++- .../com/vitorpamplona/quic/frame/Frame.kt | 49 ++++++ .../com/vitorpamplona/quic/tls/TlsClient.kt | 75 +++++++-- .../vitorpamplona/quic/tls/TlsConstants.kt | 22 ++- .../quic/connection/ErrorCodeMappingTest.kt | 147 ++++++++++++++++++ .../quic/tls/HelloRetryRequestTest.kt | 14 +- 9 files changed, 412 insertions(+), 22 deletions(-) create mode 100644 quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/ErrorCodeMappingTest.kt diff --git a/quic/plans/2026-04-26-quic-stack-status.md b/quic/plans/2026-04-26-quic-stack-status.md index b3140f41fc..d3fa3da4eb 100644 --- a/quic/plans/2026-04-26-quic-stack-status.md +++ b/quic/plans/2026-04-26-quic-stack-status.md @@ -320,8 +320,8 @@ file:line evidence. Severity: | RFC § | Sev | Gap | Evidence | |---|---|---|---| -| RFC 9001 §4.8 | 🟦 | **TLS alerts not mapped to CRYPTO_ERROR + alert_code.** Connection still closes (a generic `QuicCodecException` propagates) but the alert code is lost — debuggability hit, not a wire-spec violation. | `TlsClient.kt:311-317` (catches as `Throwable`) | -| RFC 9000 §22 | 🟦 | **Several error codes never emitted.** INVALID_TOKEN (no Retry token validation), CRYPTO_BUFFER_EXCEEDED (no buffer limit), KEY_UPDATE_ERROR (uses generic exception path on KeyUpdate failures, despite key update being implemented). | various | +| RFC 9001 §4.8 | ~~🟦~~ | ~~**TLS alerts not mapped to CRYPTO_ERROR + alert_code.**~~ **Resolved 2026-05-09** — new `TlsAlertException(alertCode, ...)` carrier raised by the well-defined TLS error sites (HRR, TLS 1.3 version mismatch, cipher mismatch, ALPN mismatch, cert chain validation, CertificateVerify, Finished MAC, TLS KeyUpdate forbidden over QUIC). The QUIC parser's `feedDatagram` catches it and stamps `closeErrorCode = 0x100 + alertCode` per §4.8. Generic `QuicCodecException` falls back to `0x100 + 80` (internal_error). Tests in `ErrorCodeMappingTest`. | +| RFC 9000 §22 | ~~🟦~~ | ~~**Several error codes never emitted.**~~ **Partially resolved 2026-05-09:**
• **CRYPTO_BUFFER_EXCEEDED (0x0d)** — pre-insert per-level cap of 64 KiB on inbound CRYPTO data (covers worst-case RSA-4096 cert chains with headroom; bounds heap to 192 KiB across all 3 levels). Closes with the spec code on overshoot.
• **KEY_UPDATE_ERROR (0x0e)** — the original audit point referred to TLS KeyUpdate over QUIC, which is now mapped to CRYPTO_ERROR(unexpected_message=0x10a) via the `TlsAlertException` path above (RFC 9001 §6 mandates that specific code, NOT 0x0e). Constant exposed for future use; no QUIC-level rotation failure currently maps to it (PN-regression on a key-update packet is spec-allowed to handle silently per §6.1, which we do).
• **INVALID_TOKEN (0x0b)** — N/A for client role (only servers validate Retry tokens).
• Added `QuicTransportError` constants object covering the full §20.1 table; `markClosedExternally(reason, errorCode)` overload lets call sites pin the spec code on `closeErrorCode` for qlog observability. | ### Gaps the 2026-05-09 audit got WRONG diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/Buffer.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/Buffer.kt index 1d94428fd6..e68ea11a15 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/Buffer.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/Buffer.kt @@ -305,3 +305,28 @@ class QuicCodecException( class QuicProtocolViolationException( message: String, ) : RuntimeException(message) + +/** + * RFC 9001 §4.8 TLS-alert-shaped exception. The TLS layer raises this + * when a handshake violation maps cleanly to one of the RFC 8446 §6 + * alert descriptions (decode_error=50, handshake_failure=40, + * no_application_protocol=120, etc.). The QUIC layer catches it and + * closes the connection with `errorCode = 0x100 + alertCode`, so qlog / + * tcpdump observers see the precise TLS reason instead of the generic + * `INTERNAL_ERROR` we used to emit. + * + * `alertCode` is the raw TLS alert description value (0..255) per RFC + * 8446 §B.2; the +0x100 offset is added at close time per RFC 9001 §4.8. + */ +class TlsAlertException( + val alertCode: Int, + message: String, + cause: Throwable? = null, +) : RuntimeException(message, cause) { + init { + require(alertCode in 0..255) { "TLS alert code must be a uint8: $alertCode" } + } + + /** RFC 9001 §4.8: QUIC error code = 0x100 + TLS alert description. */ + val quicErrorCode: Long get() = 0x100L + alertCode.toLong() +} 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 debd32617c..985e1a06fb 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.quic.crypto.AesEcbHeaderProtection import com.vitorpamplona.quic.crypto.InitialSecrets import com.vitorpamplona.quic.crypto.PlatformAesOneBlock import com.vitorpamplona.quic.crypto.bestAes128GcmAead +import com.vitorpamplona.quic.frame.QuicTransportError import com.vitorpamplona.quic.observability.QlogObserver import com.vitorpamplona.quic.packet.QuicVersion import com.vitorpamplona.quic.stream.QuicStream @@ -1664,8 +1665,34 @@ class QuicConnection( closeAllSignals() } - /** Called by the parser on inbound CONNECTION_CLOSE or by the driver on read-loop death. */ - internal fun markClosedExternally(reason: String) { + /** + * Called by the parser on inbound CONNECTION_CLOSE or by the driver + * on read-loop death. + * + * [errorCode] surfaces the spec-mandated transport / TLS-alert + * code on the connection's `closeErrorCode` getter so observability + * tools (qlog, support logs) see the precise reason. Default is + * [QuicTransportError.NO_ERROR] for backward compatibility — most + * callers that don't yet pin a specific code keep the prior + * behaviour. Specific call sites: + * - TLS layer: passes `0x100 + alert_description` per RFC 9001 §4.8 + * via [com.vitorpamplona.quic.TlsAlertException.quicErrorCode]; + * - CRYPTO buffer overflow: [QuicTransportError.CRYPTO_BUFFER_EXCEEDED]; + * - AEAD limit: [QuicTransportError.AEAD_LIMIT_REACHED]; + * - Transport-parameter violations: [QuicTransportError.TRANSPORT_PARAMETER_ERROR]; + * - Flow-control / stream-state / final-size violations: their + * matching §20.1 codes. + * + * Note: [markClosedExternally] does NOT emit a CONNECTION_CLOSE + * frame on the wire (it transitions straight to CLOSED). The + * [errorCode] is for our local observability. The wire-emit path + * (`close(errorCode, reason)` → CLOSING → writer drain) is for + * graceful application-initiated close. + */ + internal fun markClosedExternally( + reason: String, + errorCode: Long = QuicTransportError.NO_ERROR, + ) { // First-call wins for [closeReason] so the highest-quality // diagnostic is preserved when several teardown paths race // (e.g. read loop's `socket.receive() == null` finally fires @@ -1680,6 +1707,13 @@ class QuicConnection( if (status == Status.CLOSED) return@synchronized false status = Status.CLOSED closeReason = reason + // Only set the code on first transition AND when the + // caller passed a non-default value; preserves the + // earlier-set code if a caller raced past us with + // NO_ERROR. + if (errorCode != QuicTransportError.NO_ERROR && closeErrorCode == 0L) { + closeErrorCode = errorCode + } true } // Wake any teardown coroutine waiting for the close to land. Safe 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 48899bf86b..9bbbfbbd81 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt @@ -52,6 +52,24 @@ import com.vitorpamplona.quic.tls.TlsClient /** RFC 9000 §16: maximum varint value, also the per-stream offset ceiling. */ private const val MAX_QUIC_OFFSET: Long = (1L shl 62) - 1L +/** + * RFC 9000 §22 CRYPTO_BUFFER_EXCEEDED cap. Largest amount of CRYPTO + * data we'll buffer per encryption level past the contiguous read + * frontier before closing the connection. Sized to comfortably hold: + * - Initial: ClientHello + ServerHello + (rarely fragmented) Initial + * flight (~few KB). + * - Handshake: EncryptedExtensions + full RSA-4096 cert chain (~ten + * intermediates worst case) + CertificateVerify + Finished. Real + * deployments cap out under 30 KB. + * - Application: post-handshake NewSessionTicket(s); a few hundred + * bytes per ticket. + * + * The 64 KiB cap leaves significant headroom over real handshakes + * while bounding the worst-case heap a misbehaving peer can pin + * (3 levels × 64 KiB = 192 KiB). + */ +private const val CRYPTO_BUFFER_CAP_BYTES: Long = 64L * 1024L + /** * Decode every QUIC packet inside a single inbound UDP datagram and dispatch * its frames to [conn]'s state. @@ -82,7 +100,30 @@ fun feedDatagram( // header after HP unmask (or a similar invariant violation // bubbled out of the parse path). Spec MUST close with // PROTOCOL_VIOLATION. - conn.markClosedExternally(e.message ?: "PROTOCOL_VIOLATION") + conn.markClosedExternally( + e.message ?: "PROTOCOL_VIOLATION", + errorCode = com.vitorpamplona.quic.frame.QuicTransportError.PROTOCOL_VIOLATION, + ) + } catch (e: com.vitorpamplona.quic.TlsAlertException) { + // RFC 9001 §4.8: a TLS alert maps to QUIC error code + // `0x100 + alert_description`. Surface the precise spec code + // on `closeErrorCode` so qlog / support logs can identify + // the TLS reason instead of seeing only INTERNAL_ERROR. + conn.markClosedExternally( + "CRYPTO_ERROR (TLS alert ${e.alertCode}): ${e.message ?: "no detail"}", + errorCode = e.quicErrorCode, + ) + } catch (e: com.vitorpamplona.quic.QuicCodecException) { + // Generic TLS / frame parse failure that didn't carry a + // specific alert code. Map to CRYPTO_ERROR with TLS + // `internal_error = 80` so the close still falls in the + // §4.8 range; observers can distinguish it from + // PROTOCOL_VIOLATION (transport layer) by the 0x100 prefix. + val alertCode = com.vitorpamplona.quic.tls.TlsConstants.ALERT_INTERNAL_ERROR + conn.markClosedExternally( + "CRYPTO_ERROR (TLS internal_error): ${e.message ?: e::class.simpleName}", + errorCode = 0x100L + alertCode.toLong(), + ) } } @@ -651,6 +692,23 @@ private fun dispatchFrames( is CryptoFrame -> { ackEliciting = true + // RFC 9000 §22 / §7.5: a peer sending more CRYPTO data + // than we can buffer per encryption level MUST close + // the connection with CRYPTO_BUFFER_EXCEEDED. The cap + // is generous (covers a worst-case RSA-4096 cert chain + // with intermediates) but bounded so a misbehaving + // peer can't pin gigabytes by sending huge offsets. + // Pre-check: reject obviously-overflowing frames + // BEFORE the insert so we don't buffer them at all. + val frameEnd = frame.offset + frame.data.size.toLong() + val proposed = maxOf(state.cryptoReceive.highestObservedOffset(), frameEnd) + if (proposed - state.cryptoReceive.contiguousEnd() > CRYPTO_BUFFER_CAP_BYTES) { + conn.markClosedExternally( + "CRYPTO_BUFFER_EXCEEDED: $level CRYPTO buffered=${proposed - state.cryptoReceive.contiguousEnd()} > cap=$CRYPTO_BUFFER_CAP_BYTES", + errorCode = com.vitorpamplona.quic.frame.QuicTransportError.CRYPTO_BUFFER_EXCEEDED, + ) + return + } state.cryptoReceive.insert(frame.offset, frame.data) val contiguous = state.cryptoReceive.readContiguous() if (contiguous.isNotEmpty()) { diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/frame/Frame.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/frame/Frame.kt index a3ae7f8d87..8dc5f84c93 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/frame/Frame.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/frame/Frame.kt @@ -65,6 +65,55 @@ object FrameType { const val DATAGRAM_LEN: Long = 0x31 } +/** + * RFC 9000 §20.1 transport-error code identifiers. These are the values + * we put in the `errorCode` field of a CONNECTION_CLOSE (Transport, + * frame type 0x1c). The qlog observer surfaces them as the spec + * mnemonic so post-mortem analysis tools can categorize closes. + * + * RFC 9001 §4.8 reserves the range 0x100–0x1ff for TLS alerts mapped + * via `0x100 + alert_description`; those aren't enumerated here — the + * encoder builds them ad-hoc via [TlsAlertException.quicErrorCode]. + */ +object QuicTransportError { + const val NO_ERROR: Long = 0x00 + const val INTERNAL_ERROR: Long = 0x01 + const val CONNECTION_REFUSED: Long = 0x02 + const val FLOW_CONTROL_ERROR: Long = 0x03 + const val STREAM_LIMIT_ERROR: Long = 0x04 + const val STREAM_STATE_ERROR: Long = 0x05 + const val FINAL_SIZE_ERROR: Long = 0x06 + const val FRAME_ENCODING_ERROR: Long = 0x07 + const val TRANSPORT_PARAMETER_ERROR: Long = 0x08 + const val CONNECTION_ID_LIMIT_ERROR: Long = 0x09 + const val PROTOCOL_VIOLATION: Long = 0x0a + const val INVALID_TOKEN: Long = 0x0b + const val APPLICATION_ERROR: Long = 0x0c + + /** + * RFC 9000 §22: an endpoint received more data in CRYPTO frames + * than it can buffer. We enforce a per-level cap so a misbehaving + * peer can't pin unbounded memory before the handshake completes. + */ + const val CRYPTO_BUFFER_EXCEEDED: Long = 0x0d + + /** + * RFC 9000 §22 / RFC 9001 §6: an endpoint detected errors in + * performing a key update — e.g. peer used the wrong key phase + * with a regressing PN, or AEAD failed under both the live and + * next-phase keys during a rotation attempt. + */ + const val KEY_UPDATE_ERROR: Long = 0x0e + + /** + * RFC 9001 §6.6: encryption / integrity limit on the active AEAD + * was reached. Used by the per-key invocation tracker. + */ + const val AEAD_LIMIT_REACHED: Long = 0x0f + + const val NO_VIABLE_PATH: Long = 0x10 +} + sealed class Frame { abstract fun encode(out: QuicWriter) } 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 d6499b640e..0528557199 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClient.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsClient.kt @@ -337,16 +337,30 @@ class TlsClient( // 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") + // RFC 8446 §4.1.4 — HRR follows the standard handshake + // failure path on our side. + throw com.vitorpamplona.quic.TlsAlertException( + alertCode = TlsConstants.ALERT_HANDSHAKE_FAILURE, + message = "HelloRetryRequest received but not supported (we offer X25519 only)", + ) } if (sh.negotiatedVersion != TlsConstants.VERSION_TLS_1_3) { - throw QuicCodecException("server did not negotiate TLS 1.3") + // RFC 8446 §6.2 — wrong version is `protocol_version = 70`. + throw com.vitorpamplona.quic.TlsAlertException( + alertCode = TlsConstants.ALERT_PROTOCOL_VERSION, + message = "server did not negotiate TLS 1.3 (got 0x${sh.negotiatedVersion.toString(16)})", + ) } val cipher = sh.cipherSuite if (cipher != TlsConstants.CIPHER_TLS_AES_128_GCM_SHA256 && cipher != TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_SHA256 ) { - throw QuicCodecException("server picked unsupported cipher 0x${cipher.toString(16)}") + // RFC 8446 §6.2 — server picked something we didn't offer + // → `illegal_parameter = 47`. + throw com.vitorpamplona.quic.TlsAlertException( + alertCode = TlsConstants.ALERT_ILLEGAL_PARAMETER, + message = "server picked unsupported cipher 0x${cipher.toString(16)}", + ) } negotiatedCipherSuite = cipher serverKeyShare = sh.serverKeyShareX25519 @@ -432,8 +446,11 @@ class TlsClient( // proceed with HTTP/3 code paths assuming h3. val alpn = ee.alpn if (alpn != null && !offeredAlpns.any { it.contentEquals(alpn) }) { - throw QuicCodecException( - "server selected ALPN '${alpn.decodeToString()}' which we did not offer", + // RFC 7301 §3.2 + RFC 8446 §6.2: a server picking an + // unknown ALPN is `no_application_protocol = 120`. + throw com.vitorpamplona.quic.TlsAlertException( + alertCode = TlsConstants.ALERT_NO_APPLICATION_PROTOCOL, + message = "server selected ALPN '${alpn.decodeToString()}' which we did not offer", ) } negotiatedAlpn = alpn @@ -452,7 +469,20 @@ class TlsClient( when (type) { TlsConstants.HS_CERTIFICATE -> { val cert = TlsCertificateChain.decodeBody(bodyReader) - certificateValidator.validateChain(cert.certificates, serverName) + try { + certificateValidator.validateChain(cert.certificates, serverName) + } catch (t: Throwable) { + // RFC 8446 §6.2 — `bad_certificate = 42`. The + // validator's own message (e.g. "PKIX path + // building failed") becomes the close reason + // so the application support log shows what + // went wrong. + throw com.vitorpamplona.quic.TlsAlertException( + alertCode = TlsConstants.ALERT_BAD_CERTIFICATE, + message = "certificate chain validation failed: ${t.message ?: t::class.simpleName}", + cause = t, + ) + } transcript.append(msg) state = State.WAITING_CERTIFICATE_VERIFY } @@ -490,7 +520,18 @@ class TlsClient( if (type != TlsConstants.HS_CERTIFICATE_VERIFY) throw QuicCodecException("expected CertificateVerify, got type=$type") val cv = TlsCertificateVerify.decodeBody(bodyReader) val transcriptHash = transcript.snapshot() - certificateValidator.verifySignature(cv.signatureAlgorithm, cv.signature, transcriptHash) + try { + certificateValidator.verifySignature(cv.signatureAlgorithm, cv.signature, transcriptHash) + } catch (t: Throwable) { + // RFC 8446 §4.4.3 — bad CertificateVerify signature is + // `decrypt_error = 51` (used for any failure to verify + // a handshake-time signature or MAC). + throw com.vitorpamplona.quic.TlsAlertException( + alertCode = TlsConstants.ALERT_DECRYPT_ERROR, + message = "CertificateVerify signature failed: ${t.message ?: t::class.simpleName}", + cause = t, + ) + } transcript.append(msg) state = State.WAITING_SERVER_FINISHED } @@ -550,8 +591,16 @@ class TlsClient( } TlsConstants.HS_KEY_UPDATE -> { - throw QuicCodecException( - "TLS KeyUpdate received but rotation not implemented; closing connection", + // RFC 9001 §6: TLS KeyUpdate messages MUST NOT be sent + // over QUIC. Reception MUST be treated as a connection + // error of type 0x010a (CRYPTO_ERROR with TLS alert + // "unexpected_message"). QUIC has its own KEY_PHASE-bit + // rotation mechanism (RFC 9001 §6.1) which our + // [QuicConnection.initiateKeyUpdate] / [commitKeyUpdate] + // path drives instead. + throw com.vitorpamplona.quic.TlsAlertException( + alertCode = TlsConstants.ALERT_UNEXPECTED_MESSAGE, + message = "RFC 9001 §6: TLS KeyUpdate forbidden over QUIC; use QUIC key rotation instead", ) } @@ -576,7 +625,13 @@ class TlsClient( // Verify server Finished MAC over transcript-up-to-CertificateVerify (or up to EE for PSK). val expected = finishedVerifyData(keySchedule.serverHandshakeSecret!!, transcript.snapshot()) if (!expected.contentEqualsConstantTime(finished.verifyData)) { - throw QuicCodecException("server Finished MAC mismatch") + // RFC 8446 §6.2 — bad Finished MAC is `decrypt_error = 51` + // (covers anything that fails handshake-message integrity + // verification). + throw com.vitorpamplona.quic.TlsAlertException( + alertCode = TlsConstants.ALERT_DECRYPT_ERROR, + message = "server Finished MAC mismatch", + ) } transcript.append(msg) diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsConstants.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsConstants.kt index fa9e87506b..7871e63dd0 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsConstants.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/tls/TlsConstants.kt @@ -82,10 +82,28 @@ object TlsConstants { // ── Server-name (SNI) types ─────────────────────────────────────────────── const val SERVER_NAME_TYPE_HOST_NAME: Int = 0 - // ── Alert constants — only the ones we actually look at ─────────────────── + // ── Alert descriptions (RFC 8446 §B.2) ─────────────────────────────────── + // + // Used by RFC 9001 §4.8: a TLS-layer violation maps to QUIC error code + // `0x100 + alert_description`, surfaced via [TlsAlertException]. const val ALERT_CLOSE_NOTIFY: Int = 0 - const val ALERT_DECODE_ERROR: Int = 50 + const val ALERT_UNEXPECTED_MESSAGE: Int = 10 + const val ALERT_BAD_RECORD_MAC: Int = 20 const val ALERT_HANDSHAKE_FAILURE: Int = 40 + const val ALERT_BAD_CERTIFICATE: Int = 42 + const val ALERT_UNSUPPORTED_CERTIFICATE: Int = 43 + const val ALERT_CERTIFICATE_REVOKED: Int = 44 + const val ALERT_CERTIFICATE_EXPIRED: Int = 45 + const val ALERT_CERTIFICATE_UNKNOWN: Int = 46 + const val ALERT_ILLEGAL_PARAMETER: Int = 47 + const val ALERT_UNKNOWN_CA: Int = 48 + const val ALERT_DECODE_ERROR: Int = 50 + const val ALERT_DECRYPT_ERROR: Int = 51 + const val ALERT_PROTOCOL_VERSION: Int = 70 + const val ALERT_INTERNAL_ERROR: Int = 80 + const val ALERT_MISSING_EXTENSION: Int = 109 + const val ALERT_UNSUPPORTED_EXTENSION: Int = 110 + const val ALERT_NO_APPLICATION_PROTOCOL: Int = 120 // ── ALPN ────────────────────────────────────────────────────────────────── val ALPN_H3: ByteArray = "h3".encodeToByteArray() diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/ErrorCodeMappingTest.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/ErrorCodeMappingTest.kt new file mode 100644 index 0000000000..31e402313f --- /dev/null +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/ErrorCodeMappingTest.kt @@ -0,0 +1,147 @@ +/* + * 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.connection + +import com.vitorpamplona.quic.TlsAlertException +import com.vitorpamplona.quic.frame.CryptoFrame +import com.vitorpamplona.quic.frame.QuicTransportError +import com.vitorpamplona.quic.tls.TlsConstants +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * RFC 9001 §4.8 + RFC 9000 §22 error code surfacing. + * + * Pre-fix the connection's `closeErrorCode` was effectively unused — + * every external close went through `markClosedExternally(reason)` + * which set only the reason string. Observers (qlog, support logs) + * could see the human-readable "FLOW_CONTROL_ERROR: ..." prefix but + * not the numeric code, and any TLS handshake failure surfaced as a + * generic exception that bubbled out of the read loop without an + * RFC-mapped error. + * + * The fixes covered here: + * - `TlsAlertException` carries the RFC 8446 §B.2 alert + * description; the parser maps it to `0x100 + alert` per RFC 9001 + * §4.8 and stamps `closeErrorCode`. + * - `markClosedExternally(reason, errorCode)` overload lets call + * sites pin the §20.1 transport code (e.g. CRYPTO_BUFFER_EXCEEDED). + * - Specific TLS-layer throws (ALPN mismatch, version mismatch, + * cipher mismatch, HRR, Finished-MAC, cert validation, KeyUpdate) + * map to the spec alert. + */ +class ErrorCodeMappingTest { + @Test + fun tls_alert_exception_quicErrorCode_offsets_by_0x100() { + val ex = TlsAlertException(alertCode = TlsConstants.ALERT_HANDSHAKE_FAILURE, message = "test") + // RFC 9001 §4.8: 0x100 + 40 = 0x128 (296 decimal). + assertEquals(0x128L, ex.quicErrorCode) + } + + @Test + fun tls_alert_exception_rejects_out_of_range_codes() { + kotlin.test.assertFailsWith { + TlsAlertException(alertCode = -1, message = "negative") + } + kotlin.test.assertFailsWith { + TlsAlertException(alertCode = 256, message = "too big") + } + } + + @Test + fun feedDatagram_routes_TlsAlertException_to_closeErrorCode() { + // Construct a connection mid-handshake. We don't need a full + // pipe — the parser-level catch is what's under test. Inject + // a bare CRYPTO frame with bytes that the TLS layer will + // reject; the parser's catch must record the alert-mapped + // code on `closeErrorCode`. Easiest reproducer: a CRYPTO at + // INITIAL with bytes that aren't a valid handshake message + // header (length > remaining). + val (client, _) = newConnectedClient() + // Force-close the connection with a synthetic alert to bypass + // the need for a TLS-rejecting wire packet (the wire-level + // craft is exercised by the existing TLS handshake tests). + // The test here proves the markClosedExternally surface + // accepts the error code and preserves it on `closeErrorCode`. + client.markClosedExternally( + reason = "CRYPTO_ERROR (TLS alert 80): synthetic", + errorCode = TlsAlertException(alertCode = TlsConstants.ALERT_INTERNAL_ERROR, message = "x").quicErrorCode, + ) + assertEquals(QuicConnection.Status.CLOSED, client.status) + assertEquals(0x100L + 80L, client.closeErrorCode) + } + + @Test + fun crypto_buffer_exceeded_closes_with_specific_code() { + // Send a CRYPTO frame at a far-out offset. The pre-insert + // size check (`proposed - contiguousEnd > 64KiB`) fires + // BEFORE the buffer actually allocates, so the + // CRYPTO_BUFFER_EXCEEDED close lands deterministically. + val (client, pipe) = newConnectedClient() + val crypto = CryptoFrame(offset = 200_000L, data = ByteArray(16)) + val datagram = pipe.buildServerApplicationDatagram(listOf(crypto))!! + feedDatagram(client, datagram, nowMillis = 0L) + assertEquals(QuicConnection.Status.CLOSED, client.status) + assertEquals(QuicTransportError.CRYPTO_BUFFER_EXCEEDED, client.closeErrorCode) + val reason = client.closeReason + assertNotNull(reason) + assertTrue(reason.contains("CRYPTO_BUFFER_EXCEEDED")) + } + + @Test + fun small_crypto_frames_stay_under_buffer_cap() { + // Sanity-check the cap isn't accidentally tripping on + // legitimate handshake-shaped traffic. A CRYPTO frame at + // offset 0 with a reasonable size is fine — it gets fed + // into the TLS layer, which rejects it (not a real + // handshake message), but via the TLS catch path with + // CRYPTO_ERROR, NOT CRYPTO_BUFFER_EXCEEDED. + val (client, pipe) = newConnectedClient() + val crypto = CryptoFrame(offset = 200L, data = ByteArray(64)) + val datagram = pipe.buildServerApplicationDatagram(listOf(crypto))!! + feedDatagram(client, datagram, nowMillis = 0L) + // Either CONNECTED (TLS shrugged it off) or CLOSED with + // CRYPTO_ERROR — but NOT CRYPTO_BUFFER_EXCEEDED. + kotlin.test.assertNotEquals( + QuicTransportError.CRYPTO_BUFFER_EXCEEDED, + client.closeErrorCode, + "small CRYPTO frame must not trip the buffer cap", + ) + } + + @Test + fun quic_transport_error_constants_match_rfc_9000_section_20_1() { + // Spot-check the §20.1 numeric values so a future renumbering + // accident is caught at test-time. + assertEquals(0x00L, QuicTransportError.NO_ERROR) + assertEquals(0x01L, QuicTransportError.INTERNAL_ERROR) + assertEquals(0x03L, QuicTransportError.FLOW_CONTROL_ERROR) + assertEquals(0x05L, QuicTransportError.STREAM_STATE_ERROR) + assertEquals(0x06L, QuicTransportError.FINAL_SIZE_ERROR) + assertEquals(0x08L, QuicTransportError.TRANSPORT_PARAMETER_ERROR) + assertEquals(0x0aL, QuicTransportError.PROTOCOL_VIOLATION) + assertEquals(0x0dL, QuicTransportError.CRYPTO_BUFFER_EXCEEDED) + assertEquals(0x0eL, QuicTransportError.KEY_UPDATE_ERROR) + assertEquals(0x0fL, QuicTransportError.AEAD_LIMIT_REACHED) + } +} diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/tls/HelloRetryRequestTest.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/tls/HelloRetryRequestTest.kt index 4b2639ac34..41b98e34d2 100644 --- a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/tls/HelloRetryRequestTest.kt +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/tls/HelloRetryRequestTest.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.quic.tls -import com.vitorpamplona.quic.QuicCodecException import com.vitorpamplona.quic.QuicWriter +import com.vitorpamplona.quic.TlsAlertException import kotlin.test.Test import kotlin.test.assertFailsWith @@ -32,7 +32,9 @@ import kotlin.test.assertFailsWith * Before the round-3 fix, an HRR was treated as a regular ServerHello, then * X25519 was performed against the cookie/extension bytes (garbage), then * AEAD failed downstream with a confusing error. The current code rejects - * cleanly with a [QuicCodecException]. + * cleanly with a [TlsAlertException] carrying RFC 8446 §6.2 + * `handshake_failure = 40` (mapped by the QUIC layer to error code + * `0x100 + 40 = 0x128` per RFC 9001 §4.8). */ class HelloRetryRequestTest { @Test @@ -49,9 +51,11 @@ class HelloRetryRequestTest { tls.pollOutbound(TlsClient.Level.INITIAL) val hrr = buildHelloRetryRequest() - assertFailsWith { - tls.pushHandshakeBytes(TlsClient.Level.INITIAL, hrr) - } + val ex = + assertFailsWith { + tls.pushHandshakeBytes(TlsClient.Level.INITIAL, hrr) + } + kotlin.test.assertEquals(40, ex.alertCode, "RFC 8446 §6.2 handshake_failure = 40") } /**