Merge remote-tracking branch 'origin/main' into claude/audit-moq-lite-compliance-NSuPk

This commit is contained in:
Claude
2026-05-09 14:23:49 +00:00
33 changed files with 3231 additions and 127 deletions
+209 -82
View File
@@ -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 1719 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 1719 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; **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
@@ -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.1A.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,89 @@ 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-0608**
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.**~~ **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**`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 ~12 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.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 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
| RFC § | Sev | Gap | Evidence |
|---|---|---|---|
| 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.**~~ **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 + 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
| RFC § | Sev | Gap | Evidence |
|---|---|---|---|
| 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:**<br>• **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.<br>• **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).<br>• **INVALID_TOKEN (0x0b)** — N/A for client role (only servers validate Retry tokens).<br>• 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
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 +341,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/`
@@ -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()
}
@@ -280,6 +280,57 @@ class PathValidator(
fun unusedSequences(): List<Long> = 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
/**
* 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<ByteArray> = 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<ByteArray> = ArrayList()
/**
* Record a peer-issued `NEW_CONNECTION_ID` (RFC 9000 §19.15).
* Returns the result code so the caller (parser) can decide
@@ -366,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
}
@@ -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
@@ -296,9 +297,32 @@ class QuicConnection(
*/
@Volatile
var peerTransportParameters: TransportParameters? = null
private set
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
@@ -310,6 +334,39 @@ 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()
/**
* 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
@@ -458,6 +515,64 @@ 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
/**
* 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
@@ -1109,6 +1224,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
@@ -1514,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
@@ -1530,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
@@ -1875,6 +2059,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)
}
@@ -1962,6 +2153,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
@@ -2318,6 +2513,166 @@ 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
}
/**
* 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
* 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).
* - 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
// 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 (token in pathValidator.allKnownStatelessResetTokens()) {
if (token.size != 16) 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
@@ -2585,6 +2940,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
@@ -288,8 +288,30 @@ 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)
}
// 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 =
if (nextLossDelta != null && nextLossDelta < ptoMillis) nextLossDelta else ptoMillis
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 =
withTimeoutOrNull(sleepMillis) {
@@ -297,12 +319,28 @@ 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 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)",
)
continue
}
val pickedLossTimer = nextLossDelta != null && nextLossDelta < ptoMillis
if (pickedLossTimer) {
handleLossTimerFired(connection)
@@ -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(),
)
}
}
@@ -91,6 +132,28 @@ 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.
// 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
@@ -259,6 +322,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) {
@@ -416,10 +482,34 @@ 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,
)
// 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
@@ -450,6 +540,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,
@@ -544,8 +637,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)
@@ -588,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()) {
@@ -648,6 +769,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.
@@ -658,6 +792,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
@@ -813,6 +966,30 @@ 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
}
}
// 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()
@@ -920,7 +1097,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
}
@@ -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
@@ -544,14 +549,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
}
@@ -664,9 +674,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
}
@@ -866,14 +903,42 @@ 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
// 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)
}
@@ -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.
@@ -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,
@@ -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 0x1000x1ff 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)
}
@@ -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
}
@@ -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",
@@ -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()
@@ -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
@@ -128,6 +128,35 @@ 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
/**
* 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
@@ -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)
@@ -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()
@@ -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<ByteArray>,
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
@@ -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")
}
}
@@ -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<QuicConnection, InMemoryQuicPipe> =
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)
}
}
@@ -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)
}
}
@@ -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<IllegalArgumentException> {
TlsAlertException(alertCode = -1, message = "negative")
}
kotlin.test.assertFailsWith<IllegalArgumentException> {
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)
}
}
@@ -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
@@ -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)
@@ -0,0 +1,230 @@
/*
* 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 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()
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))
}
}
@@ -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)
}
}
@@ -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")
}
}
@@ -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<QuicCodecException> {
Http3Settings.decodeBody(encodeRawSetting(0x02L, 1L))
}
assertTrue(ex.message!!.contains("H3_SETTINGS_ERROR"))
}
@Test
fun reserved_id_0x03_rejected() {
assertFailsWith<QuicCodecException> {
Http3Settings.decodeBody(encodeRawSetting(0x03L, 1L))
}
}
@Test
fun reserved_id_0x04_rejected() {
assertFailsWith<QuicCodecException> {
Http3Settings.decodeBody(encodeRawSetting(0x04L, 1L))
}
}
@Test
fun reserved_id_0x05_rejected() {
assertFailsWith<QuicCodecException> {
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))
}
}
@@ -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")
}
}
@@ -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<QuicCodecException> {
tls.pushHandshakeBytes(TlsClient.Level.INITIAL, hrr)
}
val ex =
assertFailsWith<TlsAlertException> {
tls.pushHandshakeBytes(TlsClient.Level.INITIAL, hrr)
}
kotlin.test.assertEquals(40, ex.alertCode, "RFC 8446 §6.2 handshake_failure = 40")
}
/**
@@ -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 WiFicellular 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()
}
}
}
@@ -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
@@ -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")