Merge remote-tracking branch 'origin/main' into claude/audit-moq-lite-compliance-NSuPk
This commit is contained in:
@@ -1,16 +1,24 @@
|
||||
# QUIC + WebTransport stack — current state (2026-04-26)
|
||||
# QUIC + WebTransport stack — current state
|
||||
|
||||
This document is the post-implementation snapshot of `:quic`. It supersedes
|
||||
the original [pure-kotlin QUIC + WebTransport plan](../../docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md)
|
||||
which was written before any code shipped and is now historical.
|
||||
Living status doc for `:quic`. First written 2026-04-26 as the post-implementation
|
||||
snapshot of the [pure-kotlin QUIC + WebTransport
|
||||
plan](../../docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md);
|
||||
last full rewrite **2026-05-09** after a four-RFC compliance audit (RFC 9000,
|
||||
9001, 9002, 9221 + 9114 + 9204) found the doc had drifted ~2 weeks behind
|
||||
shipping work. The original plan is now historical.
|
||||
|
||||
## TL;DR
|
||||
|
||||
`:quic` is a self-contained Kotlin-Multiplatform module that speaks QUIC v1
|
||||
+ HTTP/3 + WebTransport against real-world servers (aioquic + picoquic
|
||||
verified; nestsClient/MoQ on top). It uses Quartz crypto primitives only —
|
||||
no BouncyCastle, no JNI. ~8.5k lines of production code, ~5k lines of
|
||||
tests, 39 test files, five rounds of parallel audit + fix passes.
|
||||
+ HTTP/3 + WebTransport against real-world servers (aioquic + picoquic +
|
||||
quic-go interop verified, including 0-RTT). Pure Kotlin: Quartz crypto
|
||||
primitives + JCA AEAD only — no BouncyCastle, no JNI. ~17k lines of
|
||||
production code, 81 test files, ten-plus rounds of audit + fix passes
|
||||
since 2026-04-26.
|
||||
|
||||
The 2026-05-09 audit confirmed the core handshake / packet protection /
|
||||
loss recovery / frame routing surface is RFC-grade. Real gaps are listed
|
||||
in [§ RFC compliance gaps](#rfc-compliance-gaps-2026-05-09).
|
||||
|
||||
## What shipped vs the original plan
|
||||
|
||||
@@ -20,18 +28,18 @@ tests, 39 test files, five rounds of parallel audit + fix passes.
|
||||
| B. TLS 1.3 | 3 wk | done | RFC 8446 client state machine, X25519 ECDHE, RFC 8448 §3 vectors pass bit-for-bit |
|
||||
| C. Initial + Handshake packets | 2 wk | done | RFC 9001 §A.2/§A.3 vectors pass; ChaCha20 per §A.5 |
|
||||
| D. 1-RTT + STREAM | 1 wk | done | Stream offset reassembly, FIN, fuzzed |
|
||||
| E. ACK + flow control | 1 wk | done | MAX_DATA / MAX_STREAM_DATA / MAX_STREAMS routing + writer enforcement |
|
||||
| E. ACK + flow control | 1 wk | done | MAX_DATA / MAX_STREAM_DATA / MAX_STREAMS routing + writer enforcement + per-stream receive enforcement |
|
||||
| F. Loss recovery + congestion control | 1 wk | done (no CC) | RFC 9002 §5/§6 RTT estimator + packet/time-threshold loss detection + PTO + per-frame retransmit shipped 2026-05-05; congestion control still TBD |
|
||||
| G. Datagram extension | ½ wk | done | RFC 9221 frames + bounded incoming queue |
|
||||
| H. Connection lifecycle | 1 wk | done | CONNECTION_CLOSE, idle timeout, draining/closing, idempotent driver close |
|
||||
| H. Connection lifecycle | 1 wk | done | CONNECTION_CLOSE, draining/closing, idempotent driver close (idle-timeout enforcement still missing — see gaps) |
|
||||
| I. HTTP/3 | 2 wk | done | Control stream + SETTINGS + GOAWAY (with id-regression check) + duplicate-id rejection |
|
||||
| J. QPACK | 2 wk | done | Static-table + Huffman + integer codec (RFC 7541 §B + RFC 9204 §B.1 vectors) |
|
||||
| K. Extended CONNECT + WT | 1 wk | done | Stream-type prefixes, HTTP Datagram + WT_CLOSE_SESSION capsule |
|
||||
| L. Interop + hardening | 2 wk | done + much more | Live interop against aioquic Docker; **5 rounds of audit + fix** beyond the plan |
|
||||
| L. Interop + hardening | 2 wk | done + much more | Live interop against aioquic + picoquic + quic-go; **10+ rounds of audit + fix**, multiplexing, 0-RTT, key update, ECN, path migration |
|
||||
|
||||
The original plan estimated 17–19 weeks. We ran the full sequence plus five
|
||||
unscheduled audit rounds. Every audit found real bugs; the suite is what
|
||||
caught them on regression.
|
||||
The original plan estimated 17–19 weeks. We shipped the full sequence plus
|
||||
a long tail of audit-driven hardening. Every audit found real bugs; the
|
||||
test suite is what catches them on regression.
|
||||
|
||||
## What's actually in the module
|
||||
|
||||
@@ -43,51 +51,61 @@ quic/
|
||||
│ ├── Buffer.kt ← QuicReader / QuicWriter
|
||||
│ ├── Varint.kt ← RFC 9000 §16
|
||||
│ ├── connection/ ← QuicConnection orchestrator + Driver
|
||||
│ │ ├── QuicConnection.kt (≈ 600 lines, the hub)
|
||||
│ │ ├── QuicConnection.kt (≈ 2800 lines, the hub)
|
||||
│ │ ├── QuicConnectionDriver.kt (read/send loops + close)
|
||||
│ │ ├── QuicConnectionParser.kt (feedDatagram + dispatchFrames)
|
||||
│ │ ├── QuicConnectionWriter.kt (drainOutbound + flow-control updates)
|
||||
│ │ ├── PathValidator.kt ← RFC 9000 §5.1.2 + §8.2 + §9
|
||||
│ │ ├── PacketProtection.kt + builder
|
||||
│ │ ├── PacketNumberSpace.kt
|
||||
│ │ ├── ConnectionId.kt + TransportParameters.kt
|
||||
│ │ └── EncryptionLevel.kt + LevelState.kt
|
||||
│ ├── crypto/ ← Aead, header protection, HKDF helpers, AesEcbHeaderProtection,
|
||||
│ │ ChaCha20HeaderProtection, ChaCha20Poly1305Aead, InitialSecrets,
|
||||
│ │ ├── EncryptionLevel.kt + LevelState.kt
|
||||
│ │ └── recovery/ ← RecoveryToken, SentPacket,
|
||||
│ │ AckedPackets, QuicLossDetection
|
||||
│ ├── crypto/ ← Aead, header protection, HKDF helpers,
|
||||
│ │ AesEcbHeaderProtection, ChaCha20HeaderProtection,
|
||||
│ │ ChaCha20Poly1305Aead, InitialSecrets,
|
||||
│ │ PlatformAesOneBlock, PlatformChaCha20Block (expect),
|
||||
│ │ bestAes128GcmAead (expect)
|
||||
│ ├── frame/ ← Frame.kt sealed hierarchy + FrameFuzzerTest target
|
||||
│ │ includes RESET_STREAM / STOP_SENDING / NEW_TOKEN
|
||||
│ │ includes RESET_STREAM / STOP_SENDING / NEW_TOKEN /
|
||||
│ │ PATH_CHALLENGE / PATH_RESPONSE / NEW_CONNECTION_ID /
|
||||
│ │ RETIRE_CONNECTION_ID / ACK_ECN
|
||||
│ ├── http3/ ← Http3FrameReader + Http3Settings + frame types
|
||||
│ ├── packet/ ← LongHeaderPacket, ShortHeaderPacket, RetryPacket, peekHeader
|
||||
│ ├── packet/ ← LongHeaderPacket, ShortHeaderPacket (with
|
||||
│ │ reserved-bit + key-phase peek), RetryPacket, peekHeader
|
||||
│ ├── qpack/ ← QpackDecoder, QpackEncoder, QpackHuffman, QpackInteger,
|
||||
│ │ QpackStaticTable
|
||||
│ ├── connection/recovery/ ← RecoveryToken + SentPacket + QuicLossDetection
|
||||
│ │ (RFC 9002 §5/§6 RTT estimator + loss detection +
|
||||
│ │ PTO; per-frame retransmit dispatch)
|
||||
│ ├── recovery/ ← AckTracker (with ack-eliciting gating)
|
||||
│ ├── stream/ ← QuicStream, ReceiveBuffer (with FIN-fully-read), SendBuffer, StreamId
|
||||
│ ├── tls/ ← TlsClient state machine + ClientHello/ServerHello/EE/Cert/CV/Finished
|
||||
│ │ codecs, TlsKeySchedule, TlsTranscriptHash (incremental), TlsConstants,
|
||||
│ ├── stream/ ← QuicStream, ReceiveBuffer (with FIN-fully-read +
|
||||
│ │ per-stream receive-limit), SendBuffer, StreamId
|
||||
│ ├── tls/ ← TlsClient state machine + ClientHello (incl. PSK +
|
||||
│ │ early_data resumption variant) / ServerHello / EE /
|
||||
│ │ Cert / CV / Finished / NewSessionTicket codecs,
|
||||
│ │ TlsKeySchedule (incl. 0-RTT + resumption_master_secret),
|
||||
│ │ TlsTranscriptHash (incremental), TlsConstants,
|
||||
│ │ PermissiveCertificateValidator, TlsRunningSha256 (expect)
|
||||
│ ├── transport/ ← UdpSocket (expect)
|
||||
│ └── webtransport/ ← QuicWebTransportFactory + QuicWebTransportSessionState +
|
||||
│ WtPeerStreamDemux + WtCapsule + WtDatagram + ExtendedConnect
|
||||
└── jvmAndroid/kotlin/com/vitorpamplona/quic/
|
||||
├── crypto/JcaAesGcmAead.kt ← cached JCA Cipher per direction with IV-reuse fallback
|
||||
├── crypto/JcaChaCha20Poly1305Aead.kt ← JCA ChaCha20-Poly1305 (with Quartz fallback)
|
||||
├── crypto/PlatformCrypto.kt ← actuals
|
||||
├── tls/JdkCertificateValidator.kt ← system-trust-store chain validation + RSA-PSS / ECDSA / Ed25519
|
||||
├── tls/JdkCertificateValidator.kt ← system-trust-store chain validation + PSL filter +
|
||||
│ RSA-PSS / ECDSA / Ed25519
|
||||
├── tls/TlsRunningSha256.kt ← MessageDigest.clone()-based incremental hash
|
||||
└── transport/UdpSocket.kt ← DatagramChannel + suspend wrapper
|
||||
```
|
||||
|
||||
## Crypto surface
|
||||
|
||||
Quartz primitives only:
|
||||
Quartz primitives + JCA only:
|
||||
|
||||
| Primitive | Source |
|
||||
|---|---|
|
||||
| AES-128-GCM | `JcaAesGcmAead` (jvmAndroid) — cached `Cipher` per direction; `Aes128Gcm` singleton (commonMain) for non-hot paths |
|
||||
| ChaCha20-Poly1305 | Quartz `ChaCha20Poly1305` (commonMain pure-Kotlin) wrapped in `ChaCha20Poly1305Aead` |
|
||||
| ChaCha20-Poly1305 | `JcaChaCha20Poly1305Aead` (jvmAndroid, JCA fast path) with Quartz pure-Kotlin fallback in `ChaCha20Poly1305Aead` |
|
||||
| HKDF-Extract / Expand-Label | Quartz `Hkdf` + `MacInstance`; thin RFC 8446 §7.1 helper in `crypto/HkdfHelpers.kt` |
|
||||
| SHA-256 (one-shot) | Quartz `sha256(...)` |
|
||||
| SHA-256 (incremental, for transcript) | `TlsRunningSha256` (expect/actual; jvmAndroid wraps `MessageDigest.clone()`) |
|
||||
@@ -101,32 +119,58 @@ X.509 chain validation, hostname verification, signature verification all
|
||||
delegate to JDK `TrustManagerFactory` / `Signature.getInstance(...)`.
|
||||
`CertificateValidator` is a non-null typed parameter — tests pass an
|
||||
explicit `PermissiveCertificateValidator`; production passes
|
||||
`JdkCertificateValidator`.
|
||||
`JdkCertificateValidator` (with PSL-subset wildcard rules).
|
||||
|
||||
## What we deliberately don't do
|
||||
|
||||
- **QUIC server role.** Client-only.
|
||||
- **0-RTT / session resumption.** No PSK extension offered; an arriving
|
||||
ServerFinished without prior Certificate is hard-failed.
|
||||
- **Connection migration / preferred address / multiple paths.** `NEW_CONNECTION_ID`
|
||||
and `PATH_*` frames decode (so peers don't break us) but aren't acted on.
|
||||
- **Path MTU discovery.** Fixed 1200-byte ceiling per RFC 9000 §14.
|
||||
- **Anti-amplification limits (server side).** We're a client; the server
|
||||
applies its own.
|
||||
- **HTTP/3 server push.**
|
||||
- **QPACK dynamic-table inserts on the encoder.** We send literal-only;
|
||||
decoder accepts dynamic-table indexed lines.
|
||||
- **ECN / anti-amplification limits.** We're a client.
|
||||
- **TLS Key-Update / NewSessionTicket.** Detected and refused (KeyUpdate
|
||||
fails the connection rather than silently desynchronising).
|
||||
- **Congestion control (NewReno / CUBIC / BBR).** Loss detection is in
|
||||
place (RFC 9002 §5/§6) but no rate-limiting feedback loop reacts to
|
||||
losses; we send as fast as the application provides bytes. Independent
|
||||
follow-up.
|
||||
- **QPACK dynamic-table inserts on the encoder.** We send literal +
|
||||
static-indexed only; decoder accepts dynamic-table indexed lines.
|
||||
- **Congestion control (NewReno / CUBIC / BBR).** Loss detection is
|
||||
in place (RFC 9002 §5/§6) but no rate-limiting feedback loop reacts
|
||||
to losses; we send as fast as the application provides bytes. See
|
||||
[`2026-05-05-congestion-control.md`](2026-05-05-congestion-control.md)
|
||||
(parked indefinitely; acceptable for moq-lite audio).
|
||||
|
||||
## What's now shipped that the original "deliberately don't do" list disclaimed
|
||||
|
||||
The plan's pre-rewrite version listed the following as out-of-scope. They
|
||||
shipped between 2026-04-26 and 2026-05-09 and now WORK:
|
||||
|
||||
- **0-RTT / session resumption.** TLS 1.3 PSK + `early_data` extension on
|
||||
both ends; client caches `resumption_master_secret`, parses
|
||||
`NewSessionTicket`, and replays 0-RTT data with prior-connection ALPN
|
||||
+ transport params. Verified against picoquic and quic-go.
|
||||
- **TLS Key-Update / NewSessionTicket.** RFC 9001 §6 1-RTT key update —
|
||||
client-initiated AND peer-initiated. `KEY_PHASE`-bit peek before AEAD,
|
||||
in-flight gate, PN-regression check on the rotation packet, proper
|
||||
HKDF roll of `application_traffic_secret_N+1`.
|
||||
- **NEW_CONNECTION_ID / RETIRE_CONNECTION_ID handling, including
|
||||
`retire_prior_to`** per RFC 9000 §5.1.2 + §19.15 (`PathValidator.recordPeerNewConnectionId`
|
||||
with full state machine: stale-offer retire, watermark monotonicity,
|
||||
pool-full bound, duplicate-with-mismatch rejection).
|
||||
- **Path validation (client- and server-initiated).** RFC 9000 §8.2 +
|
||||
§9 — full PATH_CHALLENGE / PATH_RESPONSE round-trip, 3*PTO timeout
|
||||
per §8.2.4, abrupt migration to a new DCID on success, forced
|
||||
rotation when `retire_prior_to` exceeds the active CID.
|
||||
- **ECN (1-RTT only).** ECT(0) on outbound, `ACK_ECN` (0x03) frames
|
||||
with truthful CE / ECT(0) / ECT(1) counters. Initial / Handshake
|
||||
ACKs intentionally skip ECN counts (interop conservatism).
|
||||
|
||||
The remaining pre-rewrite "don't do" items (server role, PMTU, HTTP/3
|
||||
push, QPACK encoder dynamic table, CC) are still out-of-scope.
|
||||
|
||||
## Verified interop
|
||||
|
||||
- **aioquic** (Python): `quic-interop-runner`-style Docker setup; full
|
||||
handshake + Extended CONNECT + h3 datagram round-trip.
|
||||
- **picoquic** (C): Docker image, lightweight HTTP/3 GET.
|
||||
handshake + multiplexing + Extended CONNECT + h3 datagram round-trip.
|
||||
- **picoquic** (C): Docker image, h3, 0-RTT, key-update, longRTT, ECN
|
||||
test cases pass.
|
||||
- **quic-go** (Go): 0-RTT pass.
|
||||
- **In-memory pipe** (`InMemoryQuicPipe`, modeled on Cloudflare quiche's
|
||||
`Pipe`) drives both sides of the handshake in one JVM for fast tests
|
||||
without sockets.
|
||||
@@ -137,13 +181,19 @@ gates on the audio-rooms completion plan
|
||||
|
||||
## Audit summary
|
||||
|
||||
| Round | Focus | Findings | Status |
|
||||
|---|---|---|---|
|
||||
| 1 | Initial review (pre-interop) | 6 critical correctness/security bugs | all fixed |
|
||||
| 2 | TLS hardening + lifecycle | hangs + TLS edge cases | all fixed |
|
||||
| 3 | Performance + concurrency | cipher caching, polling, transcript O(n²) | all fixed |
|
||||
| 4 | Core + TLS + perf + coverage gaps (4 parallel agents) | ~30 items including 4 CRITICAL interop blockers | all fixed; comprehensive regression tests added |
|
||||
| 5 | Regression check + concurrency-specific (2 parallel agents) | 1 CRITICAL ackEliciting regression I'd just introduced + WT scope leak + others | all fixed |
|
||||
| Round | Date | Focus | Findings | Status |
|
||||
|---|---|---|---|---|
|
||||
| 1 | 2026-04-22 | Initial review (pre-interop) | 6 critical correctness/security bugs | all fixed |
|
||||
| 2 | 2026-04-23 | TLS hardening + lifecycle | hangs + TLS edge cases | all fixed |
|
||||
| 3 | 2026-04-24 | Performance + concurrency | cipher caching, polling, transcript O(n²) | all fixed |
|
||||
| 4 | 2026-04-25 | Core + TLS + perf + coverage gaps (4 parallel agents) | ~30 items including 4 CRITICAL interop blockers | all fixed; comprehensive regression tests added |
|
||||
| 5 | 2026-04-26 | Regression check + concurrency-specific (2 parallel agents) | 1 CRITICAL ackEliciting regression I'd just introduced + WT scope leak + others | all fixed |
|
||||
| 6 | 2026-05-04 | Per-frame retransmit + SendBuffer rewrite | retain-until-ACK + control-frame retx + STREAM/CRYPTO recovery | all fixed; see [`2026-05-04-control-frame-retransmit.md`](2026-05-04-control-frame-retransmit.md) |
|
||||
| 7 | 2026-05-05 | Loss-detection timer + PSK rejection signal | RFC 9002 §6.1.2 timer; PSK rejection recover-in-place | all fixed |
|
||||
| 8 | 2026-05-06 | Path validation pass 1 + 2 | DCID rotation, retire-prior-to monotonicity, abrupt migration | all fixed |
|
||||
| 9 | 2026-05-07 | DoS hardening + reserved-bit checks + TLS bounds + SendBuffer shrink | bound peer-controlled buffers and channels | all fixed |
|
||||
| 10 | 2026-05-08 | Lock-split design + close-under-load + key-update PN gate | streamsLock + lifecycleLock split | all fixed; see [`2026-05-08-lock-split-design.md`](2026-05-08-lock-split-design.md) |
|
||||
| 11 | 2026-05-09 | Four-RFC compliance audit (4 parallel agents: 9000, 9001, 9002, 9221+9114+9204) | ~15 items, mostly receive-side validation gaps | catalogued in [§ RFC compliance gaps](#rfc-compliance-gaps-2026-05-09) below; **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.1–A.5 (Initial
|
||||
encrypt/decrypt, Retry, ChaCha20, server-side HP), RFC 9204 §B.1 (QPACK),
|
||||
RFC 7541 (Huffman).
|
||||
- **End-to-end pipe tests:** `InMemoryQuicPipeTest`, `CoalescedPacketSkipTest`,
|
||||
`ReceiveLimitEnforcementTest`, `PeerStreamLimitTest`, `FrameRoutingTest`,
|
||||
`AckElicitingFramesTest`.
|
||||
`AckElicitingFramesTest`, `MultiplexingRoundTripTest`,
|
||||
`MultiplexingThroughputTest`, `MultiplexingCoalescingTest`,
|
||||
`MultiStreamFinDeliveryTest`, `MultiplexingAioquicTpsTest`.
|
||||
- **Adversarial:** `FrameFuzzerTest`, `HostilePacketInputTest`,
|
||||
`TlsSecurityPropertiesTest`, `HelloRetryRequestTest`.
|
||||
`TlsSecurityPropertiesTest`, `HelloRetryRequestTest`,
|
||||
`CloseDatagramRfcComplianceTest`, `CloseUnderLoadTest`.
|
||||
- **Crypto:** `JcaAesGcmAeadTest`, `ChaCha20Poly1305AeadTest`,
|
||||
`TlsTranscriptHashTest`.
|
||||
- **WT / HTTP/3:** `CapsuleReaderTest`, `WtPeerStreamDemuxTest`,
|
||||
`WtFramingTest`.
|
||||
- **Recovery:** `AckTrackerCoalescedTest`, `AckTrackerGatingTest`,
|
||||
`RecoveryTokenTest`, `SentPacketTest`, `ReceiverFlowControlTest` (9
|
||||
cases mirrored from neqo `fc.rs`), `QuicLossDetectionTest`,
|
||||
`PtoTest`, `QuicConnectionRetransmitTest`,
|
||||
`SendBufferRetainUntilAckTest` (14 cases for the rewrite),
|
||||
`StreamRetransmitTest`, `CryptoRetransmitTest`,
|
||||
`ResetStopSendingEmitTest` (7 cases).
|
||||
- **Interop:** `InteropRunner` (jvmTest, drives a real socket against a
|
||||
Dockerised aioquic; opt-in, not in CI).
|
||||
`AckTrackerPurgeOnAckOfAckTest`, `RecoveryTokenTest`, `SentPacketTest`,
|
||||
`ReceiverFlowControlTest` (9 cases mirrored from neqo `fc.rs`),
|
||||
`QuicLossDetectionTest`, `PtoTest`, `PtoCryptoRetransmitTest`,
|
||||
`QuicConnectionRetransmitTest`, `RetransmitIntegrationTest`,
|
||||
`SendBufferRetainUntilAckTest` (14 cases), `StreamRetransmitTest`,
|
||||
`CryptoRetransmitTest`, `ResetStopSendingEmitTest` (7 cases),
|
||||
`OnTokensLostTest`, `MoqLiteLossHarnessTest`.
|
||||
- **Path validation / migration:** `PathValidatorTest`,
|
||||
`PathValidationTest`, `ClientPathMigrationTest`.
|
||||
- **Key lifecycle:** `KeyDiscardTest`, `KeyUpdatePeerInitiatedTest`.
|
||||
- **Concurrency / soak:** `BatchedOpenLockContractTest`,
|
||||
`StreamRetirementSoakTest`, `QuicHeapSoakTest`,
|
||||
`QuicConnectionDriverLifecycleTest`.
|
||||
- **Misc:** `VersionNegotiationTest`, `RetryHandlingTest`,
|
||||
`PacketNumberSpaceTest`, `ConnectionIdTest`,
|
||||
`FlowControlSnapshotTest`, `PendingFlowControlEmitTest`,
|
||||
`PeerStreamCreditExtensionTest`, `PeerUniStreamDrainTest`.
|
||||
- **Interop (jvmTest, opt-in):** `InteropRunner` drives a real socket
|
||||
against a Dockerised aioquic / picoquic / quic-go. Not in CI.
|
||||
|
||||
## Known limitations / deferred work
|
||||
|
||||
These are the items future audit rounds keep flagging that we've
|
||||
consciously not tackled — all confined to the steady-state path that audio
|
||||
rooms don't exercise heavily:
|
||||
These are items future audit rounds keep flagging that we've consciously
|
||||
not tackled:
|
||||
|
||||
1. ~~**No STREAM retransmit on loss** (audit-4 #10).~~ **Resolved 2026-05-05** —
|
||||
`SendBuffer` rewritten for retain-until-ACK with three-state range
|
||||
@@ -188,27 +251,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-06–08** —
|
||||
full client-initiated DCID rotation + server-initiated path
|
||||
validation per RFC 9000 §8.2 + §9. See `PathValidator.kt` and the
|
||||
three test files (`PathValidatorTest`, `PathValidationTest`,
|
||||
`ClientPathMigrationTest`).
|
||||
5. ~~**Stateless reset detection.**~~ **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 ~1–2 wk project.
|
||||
See [`2026-05-05-congestion-control.md`](2026-05-05-congestion-control.md).
|
||||
|
||||
## RFC compliance gaps (2026-05-09)
|
||||
|
||||
Catalogued from a four-agent compliance audit (RFC 9000 / 9001 / 9002 /
|
||||
9221+9114+9204). Each item lists the spec section, the gap, and
|
||||
file:line evidence. Severity:
|
||||
|
||||
- 🔴 **High** — exploitable by a hostile peer (resource exhaustion,
|
||||
silent desync, RTT poisoning).
|
||||
- 🟡 **Medium** — interop or correctness risk; cooperative peers
|
||||
unaffected.
|
||||
- 🟦 **Low / cosmetic** — diagnostic loss, doesn't break the wire.
|
||||
|
||||
### Receive-side validation gaps
|
||||
|
||||
| RFC § | Sev | Gap | Evidence |
|
||||
|---|---|---|---|
|
||||
| RFC 9000 §17.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
|
||||
|
||||
+45
-7
@@ -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)
|
||||
|
||||
+188
-4
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+68
-3
@@ -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 0x100–0x1ff for TLS alerts mapped
|
||||
* via `0x100 + alert_description`; those aren't enumerated here — the
|
||||
* encoder builds them ad-hoc via [TlsAlertException.quicErrorCode].
|
||||
*/
|
||||
object QuicTransportError {
|
||||
const val NO_ERROR: Long = 0x00
|
||||
const val INTERNAL_ERROR: Long = 0x01
|
||||
const val CONNECTION_REFUSED: Long = 0x02
|
||||
const val FLOW_CONTROL_ERROR: Long = 0x03
|
||||
const val STREAM_LIMIT_ERROR: Long = 0x04
|
||||
const val STREAM_STATE_ERROR: Long = 0x05
|
||||
const val FINAL_SIZE_ERROR: Long = 0x06
|
||||
const val FRAME_ENCODING_ERROR: Long = 0x07
|
||||
const val TRANSPORT_PARAMETER_ERROR: Long = 0x08
|
||||
const val CONNECTION_ID_LIMIT_ERROR: Long = 0x09
|
||||
const val PROTOCOL_VIOLATION: Long = 0x0a
|
||||
const val INVALID_TOKEN: Long = 0x0b
|
||||
const val APPLICATION_ERROR: Long = 0x0c
|
||||
|
||||
/**
|
||||
* RFC 9000 §22: an endpoint received more data in CRYPTO frames
|
||||
* than it can buffer. We enforce a per-level cap so a misbehaving
|
||||
* peer can't pin unbounded memory before the handshake completes.
|
||||
*/
|
||||
const val CRYPTO_BUFFER_EXCEEDED: Long = 0x0d
|
||||
|
||||
/**
|
||||
* RFC 9000 §22 / RFC 9001 §6: an endpoint detected errors in
|
||||
* performing a key update — e.g. peer used the wrong key phase
|
||||
* with a regressing PN, or AEAD failed under both the live and
|
||||
* next-phase keys during a rotation attempt.
|
||||
*/
|
||||
const val KEY_UPDATE_ERROR: Long = 0x0e
|
||||
|
||||
/**
|
||||
* RFC 9001 §6.6: encryption / integrity limit on the active AEAD
|
||||
* was reached. Used by the per-key invocation tracker.
|
||||
*/
|
||||
const val AEAD_LIMIT_REACHED: Long = 0x0f
|
||||
|
||||
const val NO_VIABLE_PATH: Long = 0x10
|
||||
}
|
||||
|
||||
sealed class Frame {
|
||||
abstract fun encode(out: QuicWriter)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
+124
-4
@@ -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
|
||||
|
||||
+172
@@ -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")
|
||||
}
|
||||
}
|
||||
+137
@@ -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)
|
||||
+230
@@ -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)
|
||||
}
|
||||
}
|
||||
+188
@@ -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")
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quic.webtransport
|
||||
|
||||
import com.vitorpamplona.quic.QuicWriter
|
||||
import com.vitorpamplona.quic.http3.Http3ErrorCode
|
||||
import com.vitorpamplona.quic.http3.Http3FrameType
|
||||
import com.vitorpamplona.quic.http3.Http3Settings
|
||||
import com.vitorpamplona.quic.http3.Http3SettingsId
|
||||
import com.vitorpamplona.quic.http3.Http3StreamType
|
||||
import com.vitorpamplona.quic.stream.QuicStream
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
|
||||
/**
|
||||
* RFC 9114 §6.2.1 + RFC 9204 §4.2 critical-stream closure.
|
||||
*
|
||||
* "If either control stream is closed at any point, this MUST be
|
||||
* treated as a connection error of type H3_CLOSED_CRITICAL_STREAM."
|
||||
*
|
||||
* RFC 9204 §4.2 extends this to the QPACK encoder + decoder streams.
|
||||
*
|
||||
* Pre-fix the demux recorded a `peerH3ProtocolError` flag on
|
||||
* frame-validation throws but did NOT auto-close the connection on
|
||||
* peer-side stream FIN at all — the application was expected to poll
|
||||
* the flag and call close(). For WiFi↔cellular handoff and other
|
||||
* lossy paths where the relay can drop the control stream
|
||||
* mid-session, the audio user saw stalled output with no error event.
|
||||
*
|
||||
* The fix: the demux now drives `connection.close(errorCode, reason)`
|
||||
* itself when any critical stream closes (clean FIN OR protocol
|
||||
* violation). The closure intent is latched on
|
||||
* `criticalStreamClosureCode` / `criticalStreamClosureReason` so tests
|
||||
* can verify the path runs without wiring a real driver.
|
||||
*/
|
||||
class CriticalStreamClosureTest {
|
||||
private fun controlStreamPrefix(): ByteArray = QuicWriter().also { it.writeVarint(Http3StreamType.CONTROL) }.toByteArray()
|
||||
|
||||
private fun qpackEncoderPrefix(): ByteArray = QuicWriter().also { it.writeVarint(Http3StreamType.QPACK_ENCODER) }.toByteArray()
|
||||
|
||||
private fun validSettingsFrame(): ByteArray =
|
||||
Http3Settings(
|
||||
mapOf(
|
||||
Http3SettingsId.ENABLE_CONNECT_PROTOCOL to 1L,
|
||||
Http3SettingsId.ENABLE_WEBTRANSPORT to 1L,
|
||||
),
|
||||
).encodeFrame()
|
||||
|
||||
@Test
|
||||
fun control_stream_FIN_triggers_h3_closed_critical_stream() {
|
||||
runBlocking {
|
||||
val stream = QuicStream(3L, QuicStream.Direction.UNIDIRECTIONAL_REMOTE_TO_LOCAL)
|
||||
val demuxScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
val demux = WtPeerStreamDemux(expectedConnectStreamId = 0L, scope = demuxScope)
|
||||
demux.process(stream)
|
||||
|
||||
// Push a valid SETTINGS so the demux is happy with the
|
||||
// stream contents, then FIN it cleanly.
|
||||
stream.deliverIncoming(controlStreamPrefix())
|
||||
stream.deliverIncoming(validSettingsFrame())
|
||||
stream.closeIncoming()
|
||||
|
||||
val ok =
|
||||
withTimeoutOrNull(2_000L) {
|
||||
while (demux.criticalStreamClosureCode == null) delay(5)
|
||||
true
|
||||
}
|
||||
assertEquals(true, ok, "demux should latch H3_CLOSED_CRITICAL_STREAM within timeout")
|
||||
assertEquals(Http3ErrorCode.CLOSED_CRITICAL_STREAM, demux.criticalStreamClosureCode)
|
||||
val reason = demux.criticalStreamClosureReason
|
||||
assertNotNull(reason)
|
||||
assertEquals(true, reason.contains("CONTROL"), "reason should mention CONTROL stream: $reason")
|
||||
|
||||
demuxScope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun qpack_encoder_FIN_triggers_h3_closed_critical_stream() {
|
||||
runBlocking {
|
||||
val stream = QuicStream(3L, QuicStream.Direction.UNIDIRECTIONAL_REMOTE_TO_LOCAL)
|
||||
val demuxScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
val demux = WtPeerStreamDemux(expectedConnectStreamId = 0L, scope = demuxScope)
|
||||
demux.process(stream)
|
||||
|
||||
// QPACK encoder stream — we drain its bytes (we don't run a
|
||||
// dynamic table) but its FIN is still a critical-stream
|
||||
// closure per RFC 9204 §4.2.
|
||||
stream.deliverIncoming(qpackEncoderPrefix())
|
||||
stream.deliverIncoming(byteArrayOf(0x00, 0x00, 0x00)) // junk QPACK we ignore
|
||||
stream.closeIncoming()
|
||||
|
||||
val ok =
|
||||
withTimeoutOrNull(2_000L) {
|
||||
while (demux.criticalStreamClosureCode == null) delay(5)
|
||||
true
|
||||
}
|
||||
assertEquals(true, ok, "demux should latch H3_CLOSED_CRITICAL_STREAM for QPACK encoder")
|
||||
assertEquals(Http3ErrorCode.CLOSED_CRITICAL_STREAM, demux.criticalStreamClosureCode)
|
||||
val reason = demux.criticalStreamClosureReason
|
||||
assertNotNull(reason)
|
||||
assertEquals(true, reason.contains("QPACK encoder"), "reason should mention QPACK encoder: $reason")
|
||||
|
||||
demuxScope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun control_stream_settings_violation_triggers_close_with_specific_code() {
|
||||
// Control stream's first frame is REQUIRED to be SETTINGS per
|
||||
// RFC 9114 §7.2.4.1. A peer that opens with a different frame
|
||||
// is H3_MISSING_SETTINGS — the demux's frame reader throws,
|
||||
// and our auto-close should propagate the specific code (not
|
||||
// just the generic H3_GENERAL_PROTOCOL_ERROR).
|
||||
runBlocking {
|
||||
val stream = QuicStream(3L, QuicStream.Direction.UNIDIRECTIONAL_REMOTE_TO_LOCAL)
|
||||
val demuxScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
val demux = WtPeerStreamDemux(expectedConnectStreamId = 0L, scope = demuxScope)
|
||||
demux.process(stream)
|
||||
|
||||
// First (and only) frame on the control stream is GOAWAY
|
||||
// — illegal: SETTINGS must be first. Reader should throw
|
||||
// H3_MISSING_SETTINGS.
|
||||
val goawayBody = QuicWriter().also { it.writeVarint(0L) }.toByteArray()
|
||||
val goaway =
|
||||
QuicWriter()
|
||||
.apply {
|
||||
writeVarint(Http3FrameType.GOAWAY)
|
||||
writeVarint(goawayBody.size.toLong())
|
||||
writeBytes(goawayBody)
|
||||
}.toByteArray()
|
||||
stream.deliverIncoming(controlStreamPrefix())
|
||||
stream.deliverIncoming(goaway)
|
||||
stream.closeIncoming()
|
||||
|
||||
val ok =
|
||||
withTimeoutOrNull(2_000L) {
|
||||
while (demux.criticalStreamClosureCode == null) delay(5)
|
||||
true
|
||||
}
|
||||
assertEquals(true, ok)
|
||||
assertEquals(Http3ErrorCode.MISSING_SETTINGS, demux.criticalStreamClosureCode)
|
||||
assertNotNull(demux.peerH3ProtocolError)
|
||||
|
||||
demuxScope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun control_stream_unexpected_data_frame_maps_to_frame_unexpected() {
|
||||
runBlocking {
|
||||
val stream = QuicStream(3L, QuicStream.Direction.UNIDIRECTIONAL_REMOTE_TO_LOCAL)
|
||||
val demuxScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
val demux = WtPeerStreamDemux(expectedConnectStreamId = 0L, scope = demuxScope)
|
||||
demux.process(stream)
|
||||
|
||||
// Valid SETTINGS first, then an illegal DATA frame. RFC
|
||||
// 9114 §7.2.1: DATA on the control stream is
|
||||
// H3_FRAME_UNEXPECTED.
|
||||
val data =
|
||||
QuicWriter()
|
||||
.apply {
|
||||
writeVarint(Http3FrameType.DATA)
|
||||
writeVarint(4L)
|
||||
writeBytes(byteArrayOf(1, 2, 3, 4))
|
||||
}.toByteArray()
|
||||
stream.deliverIncoming(controlStreamPrefix())
|
||||
stream.deliverIncoming(validSettingsFrame())
|
||||
stream.deliverIncoming(data)
|
||||
stream.closeIncoming()
|
||||
|
||||
val ok =
|
||||
withTimeoutOrNull(2_000L) {
|
||||
while (demux.criticalStreamClosureCode == null) delay(5)
|
||||
true
|
||||
}
|
||||
assertEquals(true, ok)
|
||||
assertEquals(Http3ErrorCode.FRAME_UNEXPECTED, demux.criticalStreamClosureCode)
|
||||
|
||||
demuxScope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun closure_code_is_idempotent_first_call_wins() {
|
||||
runBlocking {
|
||||
val stream = QuicStream(3L, QuicStream.Direction.UNIDIRECTIONAL_REMOTE_TO_LOCAL)
|
||||
val demuxScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
val demux = WtPeerStreamDemux(expectedConnectStreamId = 0L, scope = demuxScope)
|
||||
demux.process(stream)
|
||||
|
||||
// Inject a protocol violation (sets the code) then FIN
|
||||
// (would set CLOSED_CRITICAL_STREAM if we didn't latch).
|
||||
// The latch keeps the violation code, not the FIN code.
|
||||
val data =
|
||||
QuicWriter()
|
||||
.apply {
|
||||
writeVarint(Http3FrameType.DATA)
|
||||
writeVarint(0L)
|
||||
}.toByteArray()
|
||||
stream.deliverIncoming(controlStreamPrefix())
|
||||
stream.deliverIncoming(validSettingsFrame())
|
||||
stream.deliverIncoming(data)
|
||||
stream.closeIncoming()
|
||||
|
||||
val ok =
|
||||
withTimeoutOrNull(2_000L) {
|
||||
while (demux.criticalStreamClosureCode == null) delay(5)
|
||||
true
|
||||
}
|
||||
assertEquals(true, ok)
|
||||
// Either code is acceptable depending on timing; both
|
||||
// routes go through closeConnection(). The point is
|
||||
// [criticalStreamClosureCode] doesn't FLIP back and forth
|
||||
// — first writer wins.
|
||||
val firstCode = demux.criticalStreamClosureCode
|
||||
delay(50) // give the FIN-path coroutine time to also try
|
||||
assertEquals(firstCode, demux.criticalStreamClosureCode, "closure code should not flip after first observation")
|
||||
|
||||
demuxScope.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user