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
|
Living status doc for `:quic`. First written 2026-04-26 as the post-implementation
|
||||||
the original [pure-kotlin QUIC + WebTransport plan](../../docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md)
|
snapshot of the [pure-kotlin QUIC + WebTransport
|
||||||
which was written before any code shipped and is now historical.
|
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
|
## TL;DR
|
||||||
|
|
||||||
`:quic` is a self-contained Kotlin-Multiplatform module that speaks QUIC v1
|
`:quic` is a self-contained Kotlin-Multiplatform module that speaks QUIC v1
|
||||||
+ HTTP/3 + WebTransport against real-world servers (aioquic + picoquic
|
+ HTTP/3 + WebTransport against real-world servers (aioquic + picoquic +
|
||||||
verified; nestsClient/MoQ on top). It uses Quartz crypto primitives only —
|
quic-go interop verified, including 0-RTT). Pure Kotlin: Quartz crypto
|
||||||
no BouncyCastle, no JNI. ~8.5k lines of production code, ~5k lines of
|
primitives + JCA AEAD only — no BouncyCastle, no JNI. ~17k lines of
|
||||||
tests, 39 test files, five rounds of parallel audit + fix passes.
|
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
|
## 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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) |
|
| 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 |
|
| 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
|
The original plan estimated 17–19 weeks. We shipped the full sequence plus
|
||||||
unscheduled audit rounds. Every audit found real bugs; the suite is what
|
a long tail of audit-driven hardening. Every audit found real bugs; the
|
||||||
caught them on regression.
|
test suite is what catches them on regression.
|
||||||
|
|
||||||
## What's actually in the module
|
## What's actually in the module
|
||||||
|
|
||||||
@@ -43,51 +51,61 @@ quic/
|
|||||||
│ ├── Buffer.kt ← QuicReader / QuicWriter
|
│ ├── Buffer.kt ← QuicReader / QuicWriter
|
||||||
│ ├── Varint.kt ← RFC 9000 §16
|
│ ├── Varint.kt ← RFC 9000 §16
|
||||||
│ ├── connection/ ← QuicConnection orchestrator + Driver
|
│ ├── connection/ ← QuicConnection orchestrator + Driver
|
||||||
│ │ ├── QuicConnection.kt (≈ 600 lines, the hub)
|
│ │ ├── QuicConnection.kt (≈ 2800 lines, the hub)
|
||||||
│ │ ├── QuicConnectionDriver.kt (read/send loops + close)
|
│ │ ├── QuicConnectionDriver.kt (read/send loops + close)
|
||||||
│ │ ├── QuicConnectionParser.kt (feedDatagram + dispatchFrames)
|
│ │ ├── QuicConnectionParser.kt (feedDatagram + dispatchFrames)
|
||||||
│ │ ├── QuicConnectionWriter.kt (drainOutbound + flow-control updates)
|
│ │ ├── QuicConnectionWriter.kt (drainOutbound + flow-control updates)
|
||||||
|
│ │ ├── PathValidator.kt ← RFC 9000 §5.1.2 + §8.2 + §9
|
||||||
│ │ ├── PacketProtection.kt + builder
|
│ │ ├── PacketProtection.kt + builder
|
||||||
│ │ ├── PacketNumberSpace.kt
|
│ │ ├── PacketNumberSpace.kt
|
||||||
│ │ ├── ConnectionId.kt + TransportParameters.kt
|
│ │ ├── ConnectionId.kt + TransportParameters.kt
|
||||||
│ │ └── EncryptionLevel.kt + LevelState.kt
|
│ │ ├── EncryptionLevel.kt + LevelState.kt
|
||||||
│ ├── crypto/ ← Aead, header protection, HKDF helpers, AesEcbHeaderProtection,
|
│ │ └── recovery/ ← RecoveryToken, SentPacket,
|
||||||
│ │ ChaCha20HeaderProtection, ChaCha20Poly1305Aead, InitialSecrets,
|
│ │ AckedPackets, QuicLossDetection
|
||||||
|
│ ├── crypto/ ← Aead, header protection, HKDF helpers,
|
||||||
|
│ │ AesEcbHeaderProtection, ChaCha20HeaderProtection,
|
||||||
|
│ │ ChaCha20Poly1305Aead, InitialSecrets,
|
||||||
│ │ PlatformAesOneBlock, PlatformChaCha20Block (expect),
|
│ │ PlatformAesOneBlock, PlatformChaCha20Block (expect),
|
||||||
│ │ bestAes128GcmAead (expect)
|
│ │ bestAes128GcmAead (expect)
|
||||||
│ ├── frame/ ← Frame.kt sealed hierarchy + FrameFuzzerTest target
|
│ ├── 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
|
│ ├── 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,
|
│ ├── qpack/ ← QpackDecoder, QpackEncoder, QpackHuffman, QpackInteger,
|
||||||
│ │ QpackStaticTable
|
│ │ 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)
|
│ ├── recovery/ ← AckTracker (with ack-eliciting gating)
|
||||||
│ ├── stream/ ← QuicStream, ReceiveBuffer (with FIN-fully-read), SendBuffer, StreamId
|
│ ├── stream/ ← QuicStream, ReceiveBuffer (with FIN-fully-read +
|
||||||
│ ├── tls/ ← TlsClient state machine + ClientHello/ServerHello/EE/Cert/CV/Finished
|
│ │ per-stream receive-limit), SendBuffer, StreamId
|
||||||
│ │ codecs, TlsKeySchedule, TlsTranscriptHash (incremental), TlsConstants,
|
│ ├── 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)
|
│ │ PermissiveCertificateValidator, TlsRunningSha256 (expect)
|
||||||
│ ├── transport/ ← UdpSocket (expect)
|
│ ├── transport/ ← UdpSocket (expect)
|
||||||
│ └── webtransport/ ← QuicWebTransportFactory + QuicWebTransportSessionState +
|
│ └── webtransport/ ← QuicWebTransportFactory + QuicWebTransportSessionState +
|
||||||
│ WtPeerStreamDemux + WtCapsule + WtDatagram + ExtendedConnect
|
│ WtPeerStreamDemux + WtCapsule + WtDatagram + ExtendedConnect
|
||||||
└── jvmAndroid/kotlin/com/vitorpamplona/quic/
|
└── jvmAndroid/kotlin/com/vitorpamplona/quic/
|
||||||
├── crypto/JcaAesGcmAead.kt ← cached JCA Cipher per direction with IV-reuse fallback
|
├── 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
|
├── 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
|
├── tls/TlsRunningSha256.kt ← MessageDigest.clone()-based incremental hash
|
||||||
└── transport/UdpSocket.kt ← DatagramChannel + suspend wrapper
|
└── transport/UdpSocket.kt ← DatagramChannel + suspend wrapper
|
||||||
```
|
```
|
||||||
|
|
||||||
## Crypto surface
|
## Crypto surface
|
||||||
|
|
||||||
Quartz primitives only:
|
Quartz primitives + JCA only:
|
||||||
|
|
||||||
| Primitive | Source |
|
| Primitive | Source |
|
||||||
|---|---|
|
|---|---|
|
||||||
| AES-128-GCM | `JcaAesGcmAead` (jvmAndroid) — cached `Cipher` per direction; `Aes128Gcm` singleton (commonMain) for non-hot paths |
|
| 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` |
|
| 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 (one-shot) | Quartz `sha256(...)` |
|
||||||
| SHA-256 (incremental, for transcript) | `TlsRunningSha256` (expect/actual; jvmAndroid wraps `MessageDigest.clone()`) |
|
| 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(...)`.
|
delegate to JDK `TrustManagerFactory` / `Signature.getInstance(...)`.
|
||||||
`CertificateValidator` is a non-null typed parameter — tests pass an
|
`CertificateValidator` is a non-null typed parameter — tests pass an
|
||||||
explicit `PermissiveCertificateValidator`; production passes
|
explicit `PermissiveCertificateValidator`; production passes
|
||||||
`JdkCertificateValidator`.
|
`JdkCertificateValidator` (with PSL-subset wildcard rules).
|
||||||
|
|
||||||
## What we deliberately don't do
|
## What we deliberately don't do
|
||||||
|
|
||||||
- **QUIC server role.** Client-only.
|
- **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.
|
- **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.**
|
- **HTTP/3 server push.**
|
||||||
- **QPACK dynamic-table inserts on the encoder.** We send literal-only;
|
- **QPACK dynamic-table inserts on the encoder.** We send literal +
|
||||||
decoder accepts dynamic-table indexed lines.
|
static-indexed only; decoder accepts dynamic-table indexed lines.
|
||||||
- **ECN / anti-amplification limits.** We're a client.
|
- **Congestion control (NewReno / CUBIC / BBR).** Loss detection is
|
||||||
- **TLS Key-Update / NewSessionTicket.** Detected and refused (KeyUpdate
|
in place (RFC 9002 §5/§6) but no rate-limiting feedback loop reacts
|
||||||
fails the connection rather than silently desynchronising).
|
to losses; we send as fast as the application provides bytes. See
|
||||||
- **Congestion control (NewReno / CUBIC / BBR).** Loss detection is in
|
[`2026-05-05-congestion-control.md`](2026-05-05-congestion-control.md)
|
||||||
place (RFC 9002 §5/§6) but no rate-limiting feedback loop reacts to
|
(parked indefinitely; acceptable for moq-lite audio).
|
||||||
losses; we send as fast as the application provides bytes. Independent
|
|
||||||
follow-up.
|
## 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
|
## Verified interop
|
||||||
|
|
||||||
- **aioquic** (Python): `quic-interop-runner`-style Docker setup; full
|
- **aioquic** (Python): `quic-interop-runner`-style Docker setup; full
|
||||||
handshake + Extended CONNECT + h3 datagram round-trip.
|
handshake + multiplexing + Extended CONNECT + h3 datagram round-trip.
|
||||||
- **picoquic** (C): Docker image, lightweight HTTP/3 GET.
|
- **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
|
- **In-memory pipe** (`InMemoryQuicPipe`, modeled on Cloudflare quiche's
|
||||||
`Pipe`) drives both sides of the handshake in one JVM for fast tests
|
`Pipe`) drives both sides of the handshake in one JVM for fast tests
|
||||||
without sockets.
|
without sockets.
|
||||||
@@ -137,13 +181,19 @@ gates on the audio-rooms completion plan
|
|||||||
|
|
||||||
## Audit summary
|
## Audit summary
|
||||||
|
|
||||||
| Round | Focus | Findings | Status |
|
| Round | Date | Focus | Findings | Status |
|
||||||
|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| 1 | Initial review (pre-interop) | 6 critical correctness/security bugs | all fixed |
|
| 1 | 2026-04-22 | Initial review (pre-interop) | 6 critical correctness/security bugs | all fixed |
|
||||||
| 2 | TLS hardening + lifecycle | hangs + TLS edge cases | all fixed |
|
| 2 | 2026-04-23 | TLS hardening + lifecycle | hangs + TLS edge cases | all fixed |
|
||||||
| 3 | Performance + concurrency | cipher caching, polling, transcript O(n²) | all fixed |
|
| 3 | 2026-04-24 | 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 |
|
| 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 | Regression check + concurrency-specific (2 parallel agents) | 1 CRITICAL ackEliciting regression I'd just introduced + WT scope leak + others | all fixed |
|
| 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
|
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
|
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
|
## Test inventory
|
||||||
|
|
||||||
Roughly grouped:
|
81 `*Test.kt` files. Roughly grouped:
|
||||||
|
|
||||||
- **RFC vectors:** RFC 8448 §3 (TLS handshake), RFC 9001 §A.1–A.5 (Initial
|
- **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),
|
encrypt/decrypt, Retry, ChaCha20, server-side HP), RFC 9204 §B.1 (QPACK),
|
||||||
RFC 7541 (Huffman).
|
RFC 7541 (Huffman).
|
||||||
- **End-to-end pipe tests:** `InMemoryQuicPipeTest`, `CoalescedPacketSkipTest`,
|
- **End-to-end pipe tests:** `InMemoryQuicPipeTest`, `CoalescedPacketSkipTest`,
|
||||||
`ReceiveLimitEnforcementTest`, `PeerStreamLimitTest`, `FrameRoutingTest`,
|
`ReceiveLimitEnforcementTest`, `PeerStreamLimitTest`, `FrameRoutingTest`,
|
||||||
`AckElicitingFramesTest`.
|
`AckElicitingFramesTest`, `MultiplexingRoundTripTest`,
|
||||||
|
`MultiplexingThroughputTest`, `MultiplexingCoalescingTest`,
|
||||||
|
`MultiStreamFinDeliveryTest`, `MultiplexingAioquicTpsTest`.
|
||||||
- **Adversarial:** `FrameFuzzerTest`, `HostilePacketInputTest`,
|
- **Adversarial:** `FrameFuzzerTest`, `HostilePacketInputTest`,
|
||||||
`TlsSecurityPropertiesTest`, `HelloRetryRequestTest`.
|
`TlsSecurityPropertiesTest`, `HelloRetryRequestTest`,
|
||||||
|
`CloseDatagramRfcComplianceTest`, `CloseUnderLoadTest`.
|
||||||
- **Crypto:** `JcaAesGcmAeadTest`, `ChaCha20Poly1305AeadTest`,
|
- **Crypto:** `JcaAesGcmAeadTest`, `ChaCha20Poly1305AeadTest`,
|
||||||
`TlsTranscriptHashTest`.
|
`TlsTranscriptHashTest`.
|
||||||
- **WT / HTTP/3:** `CapsuleReaderTest`, `WtPeerStreamDemuxTest`,
|
- **WT / HTTP/3:** `CapsuleReaderTest`, `WtPeerStreamDemuxTest`,
|
||||||
`WtFramingTest`.
|
`WtFramingTest`.
|
||||||
- **Recovery:** `AckTrackerCoalescedTest`, `AckTrackerGatingTest`,
|
- **Recovery:** `AckTrackerCoalescedTest`, `AckTrackerGatingTest`,
|
||||||
`RecoveryTokenTest`, `SentPacketTest`, `ReceiverFlowControlTest` (9
|
`AckTrackerPurgeOnAckOfAckTest`, `RecoveryTokenTest`, `SentPacketTest`,
|
||||||
cases mirrored from neqo `fc.rs`), `QuicLossDetectionTest`,
|
`ReceiverFlowControlTest` (9 cases mirrored from neqo `fc.rs`),
|
||||||
`PtoTest`, `QuicConnectionRetransmitTest`,
|
`QuicLossDetectionTest`, `PtoTest`, `PtoCryptoRetransmitTest`,
|
||||||
`SendBufferRetainUntilAckTest` (14 cases for the rewrite),
|
`QuicConnectionRetransmitTest`, `RetransmitIntegrationTest`,
|
||||||
`StreamRetransmitTest`, `CryptoRetransmitTest`,
|
`SendBufferRetainUntilAckTest` (14 cases), `StreamRetransmitTest`,
|
||||||
`ResetStopSendingEmitTest` (7 cases).
|
`CryptoRetransmitTest`, `ResetStopSendingEmitTest` (7 cases),
|
||||||
- **Interop:** `InteropRunner` (jvmTest, drives a real socket against a
|
`OnTokensLostTest`, `MoqLiteLossHarnessTest`.
|
||||||
Dockerised aioquic; opt-in, not in CI).
|
- **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
|
## Known limitations / deferred work
|
||||||
|
|
||||||
These are the items future audit rounds keep flagging that we've
|
These are items future audit rounds keep flagging that we've consciously
|
||||||
consciously not tackled — all confined to the steady-state path that audio
|
not tackled:
|
||||||
rooms don't exercise heavily:
|
|
||||||
|
|
||||||
1. ~~**No STREAM retransmit on loss** (audit-4 #10).~~ **Resolved 2026-05-05** —
|
1. ~~**No STREAM retransmit on loss** (audit-4 #10).~~ **Resolved 2026-05-05** —
|
||||||
`SendBuffer` rewritten for retain-until-ACK with three-state range
|
`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.
|
and RESET_STREAM / STOP_SENDING / NEW_CONNECTION_ID retransmit.
|
||||||
See [`2026-05-04-control-frame-retransmit.md`](2026-05-04-control-frame-retransmit.md).
|
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.
|
2. ~~**`SendBuffer` doesn't retain bytes until ACK.**~~ Resolved with #1.
|
||||||
3. ~~**No Initial / Handshake key discard.**~~ **Resolved 2026-05-05** —
|
3. ~~**No Initial / Handshake key discard.**~~ **Resolved 2026-05-05.**
|
||||||
`LevelState.discardKeys()` nulls the AEAD protection, replaces the
|
4. ~~**No path validation for `NEW_CONNECTION_ID`.**~~ **Resolved 2026-05-06–08** —
|
||||||
per-level CRYPTO buffers and ack-tracker with empty instances, and
|
full client-initiated DCID rotation + server-initiated path
|
||||||
clears the sent-packet map. Initial keys discarded by the writer
|
validation per RFC 9000 §8.2 + §9. See `PathValidator.kt` and the
|
||||||
after the first Handshake packet is built (RFC 9001 §4.9.1);
|
three test files (`PathValidatorTest`, `PathValidationTest`,
|
||||||
Handshake keys discarded by the parser on receipt of HANDSHAKE_DONE
|
`ClientPathMigrationTest`).
|
||||||
(RFC 9001 §4.9.2 + §4.1.2 client-side handshake confirmation).
|
5. ~~**Stateless reset detection.**~~ **Resolved 2026-05-09** —
|
||||||
4. **No path validation for `NEW_CONNECTION_ID`.** We don't migrate.
|
`QuicConnection.isStatelessReset` matches every short-header-form
|
||||||
5. **Stateless reset detection.** Stateless-reset packets look like
|
datagram's trailing 16 bytes against the peer's transport-param
|
||||||
corruption to us.
|
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
|
6. ~~**`AckTracker.purgeBelow` threshold semantics.**~~ **Resolved
|
||||||
2026-05-05** — `RecoveryToken.Ack` now carries `(level, largestAcked)`;
|
2026-05-05.**
|
||||||
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.
|
|
||||||
7. **Driver direct unit tests** require turning `UdpSocket` from `expect
|
7. **Driver direct unit tests** require turning `UdpSocket` from `expect
|
||||||
class` into an interface so the test side can stub. The driver is
|
class` into an interface so the test side can stub. The driver is
|
||||||
covered indirectly by every pipe-based test plus the live interop
|
covered indirectly by every pipe-based test plus the live interop
|
||||||
runner.
|
runner.
|
||||||
8. **No congestion control.** Loss detection is wired but nothing
|
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
|
## Pointers
|
||||||
|
|
||||||
@@ -216,5 +341,7 @@ rooms don't exercise heavily:
|
|||||||
- Audio-rooms NIP draft: `docs/plans/2026-04-22-nip-audio-rooms-draft.md`
|
- 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`
|
- 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)
|
- 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`
|
- 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(
|
class QuicProtocolViolationException(
|
||||||
message: String,
|
message: String,
|
||||||
) : RuntimeException(message)
|
) : 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()
|
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).
|
* Record a peer-issued `NEW_CONNECTION_ID` (RFC 9000 §19.15).
|
||||||
* Returns the result code so the caller (parser) can decide
|
* Returns the result code so the caller (parser) can decide
|
||||||
@@ -366,12 +417,19 @@ class PathValidator(
|
|||||||
|
|
||||||
if (unusedCids.size >= maxUnusedCids) return RecordResult.PoolFull
|
if (unusedCids.size >= maxUnusedCids) return RecordResult.PoolFull
|
||||||
|
|
||||||
|
val tokenCopy = statelessResetToken.copyOf()
|
||||||
unusedCids[sequenceNumber] =
|
unusedCids[sequenceNumber] =
|
||||||
PeerConnectionIdEntry(
|
PeerConnectionIdEntry(
|
||||||
sequenceNumber = sequenceNumber,
|
sequenceNumber = sequenceNumber,
|
||||||
connectionId = connectionId.copyOf(),
|
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
|
return RecordResult.Stored
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import com.vitorpamplona.quic.crypto.AesEcbHeaderProtection
|
|||||||
import com.vitorpamplona.quic.crypto.InitialSecrets
|
import com.vitorpamplona.quic.crypto.InitialSecrets
|
||||||
import com.vitorpamplona.quic.crypto.PlatformAesOneBlock
|
import com.vitorpamplona.quic.crypto.PlatformAesOneBlock
|
||||||
import com.vitorpamplona.quic.crypto.bestAes128GcmAead
|
import com.vitorpamplona.quic.crypto.bestAes128GcmAead
|
||||||
|
import com.vitorpamplona.quic.frame.QuicTransportError
|
||||||
import com.vitorpamplona.quic.observability.QlogObserver
|
import com.vitorpamplona.quic.observability.QlogObserver
|
||||||
import com.vitorpamplona.quic.packet.QuicVersion
|
import com.vitorpamplona.quic.packet.QuicVersion
|
||||||
import com.vitorpamplona.quic.stream.QuicStream
|
import com.vitorpamplona.quic.stream.QuicStream
|
||||||
@@ -296,9 +297,32 @@ class QuicConnection(
|
|||||||
*/
|
*/
|
||||||
@Volatile
|
@Volatile
|
||||||
var peerTransportParameters: TransportParameters? = null
|
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
|
* Lock-split refactor (2026-05-08): @Volatile so concurrent loops can
|
||||||
@@ -310,6 +334,39 @@ class QuicConnection(
|
|||||||
var status: Status = Status.HANDSHAKING
|
var status: Status = Status.HANDSHAKING
|
||||||
internal set
|
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
|
* Non-suspend monitor protecting the atomic transition of
|
||||||
* [status] / [closeReason] / [closeErrorCode] from a non-CLOSED to a
|
* [status] / [closeReason] / [closeErrorCode] from a non-CLOSED to a
|
||||||
@@ -458,6 +515,64 @@ class QuicConnection(
|
|||||||
*/
|
*/
|
||||||
internal var advertisedMaxData: Long = config.initialMaxData
|
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
|
* Cumulative count of peer-initiated unidirectional streams we've
|
||||||
* accepted (incremented in [getOrCreatePeerStreamLocked]). Compared
|
* accepted (incremented in [getOrCreatePeerStreamLocked]). Compared
|
||||||
@@ -1109,6 +1224,42 @@ class QuicConnection(
|
|||||||
)
|
)
|
||||||
return
|
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
|
peerTransportParameters = tp
|
||||||
qlogObserver.onTransportParametersSet("remote", peerTransportParametersSummary(tp))
|
qlogObserver.onTransportParametersSet("remote", peerTransportParametersSummary(tp))
|
||||||
sendConnectionFlowCredit = tp.initialMaxData ?: 0L
|
sendConnectionFlowCredit = tp.initialMaxData ?: 0L
|
||||||
@@ -1514,8 +1665,34 @@ class QuicConnection(
|
|||||||
closeAllSignals()
|
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
|
// First-call wins for [closeReason] so the highest-quality
|
||||||
// diagnostic is preserved when several teardown paths race
|
// diagnostic is preserved when several teardown paths race
|
||||||
// (e.g. read loop's `socket.receive() == null` finally fires
|
// (e.g. read loop's `socket.receive() == null` finally fires
|
||||||
@@ -1530,6 +1707,13 @@ class QuicConnection(
|
|||||||
if (status == Status.CLOSED) return@synchronized false
|
if (status == Status.CLOSED) return@synchronized false
|
||||||
status = Status.CLOSED
|
status = Status.CLOSED
|
||||||
closeReason = reason
|
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
|
true
|
||||||
}
|
}
|
||||||
// Wake any teardown coroutine waiting for the close to land. Safe
|
// Wake any teardown coroutine waiting for the close to land. Safe
|
||||||
@@ -1875,6 +2059,13 @@ class QuicConnection(
|
|||||||
currentSendKeyPhase = !currentSendKeyPhase
|
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("server", EncryptionLevel.APPLICATION)
|
||||||
qlogObserver.onKeyUpdated("client", EncryptionLevel.APPLICATION)
|
qlogObserver.onKeyUpdated("client", EncryptionLevel.APPLICATION)
|
||||||
}
|
}
|
||||||
@@ -1962,6 +2153,10 @@ class QuicConnection(
|
|||||||
currentReceiveKeyPhase = !currentReceiveKeyPhase
|
currentReceiveKeyPhase = !currentReceiveKeyPhase
|
||||||
currentSendKeyPhase = !currentSendKeyPhase
|
currentSendKeyPhase = !currentSendKeyPhase
|
||||||
keyUpdateInProgress = true
|
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("client", EncryptionLevel.APPLICATION)
|
||||||
qlogObserver.onKeyUpdated("server", EncryptionLevel.APPLICATION)
|
qlogObserver.onKeyUpdated("server", EncryptionLevel.APPLICATION)
|
||||||
return true
|
return true
|
||||||
@@ -2318,6 +2513,166 @@ class QuicConnection(
|
|||||||
currentPtoMillis: Long = lossDetection.ptoBaseMs(peerTransportParameters?.maxAckDelay ?: 0L),
|
currentPtoMillis: Long = lossDetection.ptoBaseMs(peerTransportParameters?.maxAckDelay ?: 0L),
|
||||||
): PathMigrationResult = streamsLock.withLock { triggerPathMigrationLocked(nowMillis, currentPtoMillis) }
|
): 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
|
* Check whether an outstanding PATH_CHALLENGE has exceeded the
|
||||||
* 3 * PTO budget (RFC 9000 §8.2.4). Called from the driver's
|
* 3 * PTO budget (RFC 9000 §8.2.4). Called from the driver's
|
||||||
@@ -2585,6 +2940,14 @@ class QuicConnection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
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
|
* Bound on the inbound datagram queue depth. RFC 9221 datagrams are
|
||||||
* outside connection-level flow control, so without this cap a peer
|
* outside connection-level flow control, so without this cap a peer
|
||||||
|
|||||||
+45
-7
@@ -288,8 +288,30 @@ class QuicConnectionDriver(
|
|||||||
connection.handshake.nextLossTimeMs,
|
connection.handshake.nextLossTimeMs,
|
||||||
connection.application.nextLossTimeMs,
|
connection.application.nextLossTimeMs,
|
||||||
).minOrNull()?.let { (it - nowForLossTimer).coerceAtLeast(0L) }
|
).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 =
|
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.
|
// Suspend until either: a wakeup arrives, or the timer expires.
|
||||||
val woke =
|
val woke =
|
||||||
withTimeoutOrNull(sleepMillis) {
|
withTimeoutOrNull(sleepMillis) {
|
||||||
@@ -297,12 +319,28 @@ class QuicConnectionDriver(
|
|||||||
Unit
|
Unit
|
||||||
}
|
}
|
||||||
if (woke == null) {
|
if (woke == null) {
|
||||||
// Distinguish loss-timer expiry from PTO expiry. A
|
// Distinguish draining-deadline / idle-timeout /
|
||||||
// loss-timer wake just runs `detectAndRemoveLost`
|
// loss-timer / PTO expiry. RFC 9000 §10.2.2: the
|
||||||
// across the levels — newly time-threshold-lost
|
// draining period transitions to fully CLOSED once
|
||||||
// packets get their tokens re-queued for retransmit on
|
// 3 * PTO has elapsed since the peer's
|
||||||
// the next drain. A PTO wake additionally bumps the
|
// CONNECTION_CLOSE — gives the peer's last
|
||||||
// consecutive-PTO counter and arms the probe budget.
|
// 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
|
val pickedLossTimer = nextLossDelta != null && nextLossDelta < ptoMillis
|
||||||
if (pickedLossTimer) {
|
if (pickedLossTimer) {
|
||||||
handleLossTimerFired(connection)
|
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. */
|
/** RFC 9000 §16: maximum varint value, also the per-stream offset ceiling. */
|
||||||
private const val MAX_QUIC_OFFSET: Long = (1L shl 62) - 1L
|
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
|
* Decode every QUIC packet inside a single inbound UDP datagram and dispatch
|
||||||
* its frames to [conn]'s state.
|
* its frames to [conn]'s state.
|
||||||
@@ -82,7 +100,30 @@ fun feedDatagram(
|
|||||||
// header after HP unmask (or a similar invariant violation
|
// header after HP unmask (or a similar invariant violation
|
||||||
// bubbled out of the parse path). Spec MUST close with
|
// bubbled out of the parse path). Spec MUST close with
|
||||||
// PROTOCOL_VIOLATION.
|
// 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,
|
datagram: ByteArray,
|
||||||
nowMillis: Long,
|
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
|
var offset = 0
|
||||||
while (offset < datagram.size) {
|
while (offset < datagram.size) {
|
||||||
val first = datagram[offset].toInt() and 0xFF
|
val first = datagram[offset].toInt() and 0xFF
|
||||||
@@ -259,6 +322,9 @@ private fun feedLongHeaderPacket(
|
|||||||
}
|
}
|
||||||
|
|
||||||
state.pnSpace.observeInbound(parsed.packet.packetNumber, nowMillis)
|
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.
|
// The server's source CID becomes our destination CID for subsequent packets.
|
||||||
if (level == EncryptionLevel.INITIAL) {
|
if (level == EncryptionLevel.INITIAL) {
|
||||||
@@ -416,10 +482,34 @@ private fun feedShortHeaderPacket(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (parsed == null) {
|
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(
|
conn.qlogObserver.onPacketDropped(
|
||||||
"AEAD auth failed or header parse failed at level APPLICATION",
|
"AEAD auth failed or header parse failed at level APPLICATION",
|
||||||
datagram.size - offset,
|
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
|
return
|
||||||
}
|
}
|
||||||
// AEAD succeeded with the candidate next-phase keys → commit the
|
// AEAD succeeded with the candidate next-phase keys → commit the
|
||||||
@@ -450,6 +540,9 @@ private fun feedShortHeaderPacket(
|
|||||||
conn.keyUpdateInProgress = false
|
conn.keyUpdateInProgress = false
|
||||||
}
|
}
|
||||||
state.pnSpace.observeInbound(parsed.packet.packetNumber, nowMillis)
|
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) {
|
if (conn.qlogObserver !== com.vitorpamplona.quic.observability.QlogObserver.NoOp) {
|
||||||
conn.qlogObserver.onPacketReceived(
|
conn.qlogObserver.onPacketReceived(
|
||||||
level = EncryptionLevel.APPLICATION,
|
level = EncryptionLevel.APPLICATION,
|
||||||
@@ -544,8 +637,19 @@ private fun dispatchFrames(
|
|||||||
// before consuming. Per RFC 9000 §18.2 the exponent is
|
// before consuming. Per RFC 9000 §18.2 the exponent is
|
||||||
// 0..20; we further clamp ackDelay so the shift never
|
// 0..20; we further clamp ackDelay so the shift never
|
||||||
// overflows (`ackDelay <= Long.MAX_VALUE >>> exponent`).
|
// 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 =
|
val maxAckDelayPreShift =
|
||||||
if (exponent == 0) Long.MAX_VALUE else Long.MAX_VALUE ushr exponent
|
if (exponent == 0) Long.MAX_VALUE else Long.MAX_VALUE ushr exponent
|
||||||
val safeAckDelay = frame.ackDelay.coerceIn(0L, maxAckDelayPreShift)
|
val safeAckDelay = frame.ackDelay.coerceIn(0L, maxAckDelayPreShift)
|
||||||
@@ -588,6 +692,23 @@ private fun dispatchFrames(
|
|||||||
|
|
||||||
is CryptoFrame -> {
|
is CryptoFrame -> {
|
||||||
ackEliciting = true
|
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)
|
state.cryptoReceive.insert(frame.offset, frame.data)
|
||||||
val contiguous = state.cryptoReceive.readContiguous()
|
val contiguous = state.cryptoReceive.readContiguous()
|
||||||
if (contiguous.isNotEmpty()) {
|
if (contiguous.isNotEmpty()) {
|
||||||
@@ -648,6 +769,19 @@ private fun dispatchFrames(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
val stream = conn.getOrCreatePeerStreamLocked(frame.streamId)
|
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.
|
// RFC 9000 §4.1: peer MUST NOT send beyond the limit we advertised.
|
||||||
// The connection-level kill protects against unbounded memory
|
// The connection-level kill protects against unbounded memory
|
||||||
// growth from a misbehaving peer.
|
// growth from a misbehaving peer.
|
||||||
@@ -658,6 +792,25 @@ private fun dispatchFrames(
|
|||||||
)
|
)
|
||||||
return
|
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
|
// RFC 9000 §4.5: enforce final-size invariants. The
|
||||||
// [com.vitorpamplona.quic.stream.ReceiveBuffer.insert]
|
// [com.vitorpamplona.quic.stream.ReceiveBuffer.insert]
|
||||||
// surface returns a typed result; map any non-OK result
|
// surface returns a typed result; map any non-OK result
|
||||||
@@ -813,6 +966,30 @@ private fun dispatchFrames(
|
|||||||
return
|
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
|
// Mark the peer's stream aborted and close our read side; the
|
||||||
// application sees a truncated incoming flow.
|
// application sees a truncated incoming flow.
|
||||||
target?.closeIncoming()
|
target?.closeIncoming()
|
||||||
@@ -920,7 +1097,14 @@ private fun dispatchFrames(
|
|||||||
// Audit-4 #13: any frames following CONNECTION_CLOSE in the
|
// Audit-4 #13: any frames following CONNECTION_CLOSE in the
|
||||||
// same payload MUST NOT be dispatched — they could create
|
// same payload MUST NOT be dispatched — they could create
|
||||||
// streams or deliver bytes on an already-closed connection.
|
// 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+68
-3
@@ -93,6 +93,11 @@ fun drainOutbound(
|
|||||||
conn.closingDrainSignal.complete(Unit)
|
conn.closingDrainSignal.complete(Unit)
|
||||||
return datagram
|
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
|
// 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
|
// 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
|
// map insert below overwrites the prior entry, so the recorded
|
||||||
// SentPacket reflects the final padded packet's size — correct
|
// SentPacket reflects the final padded packet's size — correct
|
||||||
// for retransmit purposes.
|
// for retransmit purposes.
|
||||||
|
val ackEliciting = isAckEliciting(frames)
|
||||||
state.sentPackets[pn] =
|
state.sentPackets[pn] =
|
||||||
SentPacket(
|
SentPacket(
|
||||||
packetNumber = pn,
|
packetNumber = pn,
|
||||||
sentAtMillis = nowMillis,
|
sentAtMillis = nowMillis,
|
||||||
ackEliciting = isAckEliciting(frames),
|
ackEliciting = ackEliciting,
|
||||||
sizeBytes = packet.size,
|
sizeBytes = packet.size,
|
||||||
tokens = tokens,
|
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)
|
emitQlogSent(conn, level, pn, packet.size, frames)
|
||||||
return packet
|
return packet
|
||||||
}
|
}
|
||||||
@@ -664,9 +674,36 @@ private fun buildApplicationPacket(
|
|||||||
// stream and MAX_DATA at the connection level.
|
// stream and MAX_DATA at the connection level.
|
||||||
appendFlowControlUpdates(conn, frames, tokens)
|
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()) {
|
while (conn.pendingDatagramsLocked().isNotEmpty()) {
|
||||||
val payload = conn.pendingDatagramsLocked().removeFirst()
|
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)
|
frames += DatagramFrame(payload, explicitLength = true)
|
||||||
if (frames.size >= 16) break
|
if (frames.size >= 16) break
|
||||||
}
|
}
|
||||||
@@ -866,14 +903,42 @@ private fun buildApplicationPacket(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val ackEliciting = isAckEliciting(frames)
|
||||||
state.sentPackets[pn] =
|
state.sentPackets[pn] =
|
||||||
SentPacket(
|
SentPacket(
|
||||||
packetNumber = pn,
|
packetNumber = pn,
|
||||||
sentAtMillis = nowMillis,
|
sentAtMillis = nowMillis,
|
||||||
ackEliciting = isAckEliciting(frames),
|
ackEliciting = ackEliciting,
|
||||||
sizeBytes = sizeBytes.getOrNull()?.size ?: 0,
|
sizeBytes = sizeBytes.getOrNull()?.size ?: 0,
|
||||||
tokens = tokens.toList(),
|
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 ->
|
sizeBytes.getOrNull()?.let { built ->
|
||||||
emitQlogSent(conn, EncryptionLevel.APPLICATION, pn, built.size, frames)
|
emitQlogSent(conn, EncryptionLevel.APPLICATION, pn, built.size, frames)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,23 @@ object TransportParameterId {
|
|||||||
const val MAX_DATAGRAM_FRAME_SIZE: Long = 0x20
|
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
|
* QUIC transport parameters as exchanged inside the TLS QUIC transport_params
|
||||||
* extension.
|
* extension.
|
||||||
|
|||||||
@@ -38,6 +38,28 @@ abstract class Aead {
|
|||||||
abstract val nonceLength: Int
|
abstract val nonceLength: Int
|
||||||
abstract val tagLength: 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(
|
abstract fun seal(
|
||||||
key: ByteArray,
|
key: ByteArray,
|
||||||
nonce: ByteArray,
|
nonce: ByteArray,
|
||||||
@@ -153,6 +175,10 @@ object Aes128Gcm : Aead() {
|
|||||||
override val nonceLength = 12
|
override val nonceLength = 12
|
||||||
override val tagLength = 16
|
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(
|
override fun seal(
|
||||||
key: ByteArray,
|
key: ByteArray,
|
||||||
nonce: ByteArray,
|
nonce: ByteArray,
|
||||||
@@ -186,6 +212,10 @@ object ChaCha20Poly1305Aead : Aead() {
|
|||||||
override val nonceLength = 12
|
override val nonceLength = 12
|
||||||
override val tagLength = 16
|
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(
|
override fun seal(
|
||||||
key: ByteArray,
|
key: ByteArray,
|
||||||
nonce: ByteArray,
|
nonce: ByteArray,
|
||||||
|
|||||||
@@ -65,6 +65,55 @@ object FrameType {
|
|||||||
const val DATAGRAM_LEN: Long = 0x31
|
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 {
|
sealed class Frame {
|
||||||
abstract fun encode(out: QuicWriter)
|
abstract fun encode(out: QuicWriter)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,3 +60,42 @@ object Http3SettingsId {
|
|||||||
/** WebTransport over HTTP/3 (draft / RFC 9220). */
|
/** WebTransport over HTTP/3 (draft / RFC 9220). */
|
||||||
const val ENABLE_WEBTRANSPORT: Long = 0xc671706a
|
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,
|
id: Long,
|
||||||
value: 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) {
|
if (value < 0L) {
|
||||||
throw com.vitorpamplona.quic.QuicCodecException(
|
throw com.vitorpamplona.quic.QuicCodecException(
|
||||||
"negative HTTP/3 SETTINGS value for id 0x${id.toString(16)}: $value",
|
"negative HTTP/3 SETTINGS value for id 0x${id.toString(16)}: $value",
|
||||||
|
|||||||
@@ -164,6 +164,13 @@ object LongHeaderPacket {
|
|||||||
val r = QuicReader(bytes, offset)
|
val r = QuicReader(bytes, offset)
|
||||||
val first = r.readByte()
|
val first = r.readByte()
|
||||||
if ((first and 0x80) == 0) return null // not a long header — silently drop
|
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 typeBits = (first ushr 4) and 0x03
|
||||||
val type = LongHeaderType.fromTypeBits(typeBits)
|
val type = LongHeaderType.fromTypeBits(typeBits)
|
||||||
val version = r.readUint32().toInt()
|
val version = r.readUint32().toInt()
|
||||||
|
|||||||
@@ -130,6 +130,11 @@ object ShortHeaderPacket {
|
|||||||
if (offset >= bytes.size) return null
|
if (offset >= bytes.size) return null
|
||||||
val first = bytes[offset].toInt() and 0xFF
|
val first = bytes[offset].toInt() and 0xFF
|
||||||
if ((first and 0x80) != 0) return null
|
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 pnOffset = offset + 1 + dcidLen
|
||||||
val sampleStart = pnOffset + 4
|
val sampleStart = pnOffset + 4
|
||||||
if (sampleStart + 16 > bytes.size) return null
|
if (sampleStart + 16 > bytes.size) return null
|
||||||
@@ -162,6 +167,9 @@ object ShortHeaderPacket {
|
|||||||
if (offset >= bytes.size) return null
|
if (offset >= bytes.size) return null
|
||||||
val first = bytes[offset].toInt() and 0xFF
|
val first = bytes[offset].toInt() and 0xFF
|
||||||
if ((first and 0x80) != 0) return null
|
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 pnOffset = offset + 1 + dcidLen
|
||||||
val sampleStart = pnOffset + 4
|
val sampleStart = pnOffset + 4
|
||||||
if (sampleStart + 16 > bytes.size) return null
|
if (sampleStart + 16 > bytes.size) return null
|
||||||
|
|||||||
@@ -128,6 +128,35 @@ class QuicStream(
|
|||||||
var receiveLimit: Long = 0L
|
var receiveLimit: Long = 0L
|
||||||
internal set
|
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
|
* Marker the parser sets whenever [receive.contiguousEnd] advances; the
|
||||||
* writer's appendFlowControlUpdates consumes it to skip streams that
|
* 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
|
// offer X25519, the only group nests + most servers accept), so
|
||||||
// any HRR is a hard failure.
|
// any HRR is a hard failure.
|
||||||
if (sh.random.contentEquals(HELLO_RETRY_REQUEST_RANDOM)) {
|
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) {
|
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
|
val cipher = sh.cipherSuite
|
||||||
if (cipher != TlsConstants.CIPHER_TLS_AES_128_GCM_SHA256 &&
|
if (cipher != TlsConstants.CIPHER_TLS_AES_128_GCM_SHA256 &&
|
||||||
cipher != TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_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
|
negotiatedCipherSuite = cipher
|
||||||
serverKeyShare = sh.serverKeyShareX25519
|
serverKeyShare = sh.serverKeyShareX25519
|
||||||
@@ -432,8 +446,11 @@ class TlsClient(
|
|||||||
// proceed with HTTP/3 code paths assuming h3.
|
// proceed with HTTP/3 code paths assuming h3.
|
||||||
val alpn = ee.alpn
|
val alpn = ee.alpn
|
||||||
if (alpn != null && !offeredAlpns.any { it.contentEquals(alpn) }) {
|
if (alpn != null && !offeredAlpns.any { it.contentEquals(alpn) }) {
|
||||||
throw QuicCodecException(
|
// RFC 7301 §3.2 + RFC 8446 §6.2: a server picking an
|
||||||
"server selected ALPN '${alpn.decodeToString()}' which we did not offer",
|
// 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
|
negotiatedAlpn = alpn
|
||||||
@@ -452,7 +469,20 @@ class TlsClient(
|
|||||||
when (type) {
|
when (type) {
|
||||||
TlsConstants.HS_CERTIFICATE -> {
|
TlsConstants.HS_CERTIFICATE -> {
|
||||||
val cert = TlsCertificateChain.decodeBody(bodyReader)
|
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)
|
transcript.append(msg)
|
||||||
state = State.WAITING_CERTIFICATE_VERIFY
|
state = State.WAITING_CERTIFICATE_VERIFY
|
||||||
}
|
}
|
||||||
@@ -490,7 +520,18 @@ class TlsClient(
|
|||||||
if (type != TlsConstants.HS_CERTIFICATE_VERIFY) throw QuicCodecException("expected CertificateVerify, got type=$type")
|
if (type != TlsConstants.HS_CERTIFICATE_VERIFY) throw QuicCodecException("expected CertificateVerify, got type=$type")
|
||||||
val cv = TlsCertificateVerify.decodeBody(bodyReader)
|
val cv = TlsCertificateVerify.decodeBody(bodyReader)
|
||||||
val transcriptHash = transcript.snapshot()
|
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)
|
transcript.append(msg)
|
||||||
state = State.WAITING_SERVER_FINISHED
|
state = State.WAITING_SERVER_FINISHED
|
||||||
}
|
}
|
||||||
@@ -550,8 +591,16 @@ class TlsClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
TlsConstants.HS_KEY_UPDATE -> {
|
TlsConstants.HS_KEY_UPDATE -> {
|
||||||
throw QuicCodecException(
|
// RFC 9001 §6: TLS KeyUpdate messages MUST NOT be sent
|
||||||
"TLS KeyUpdate received but rotation not implemented; closing connection",
|
// 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).
|
// Verify server Finished MAC over transcript-up-to-CertificateVerify (or up to EE for PSK).
|
||||||
val expected = finishedVerifyData(keySchedule.serverHandshakeSecret!!, transcript.snapshot())
|
val expected = finishedVerifyData(keySchedule.serverHandshakeSecret!!, transcript.snapshot())
|
||||||
if (!expected.contentEqualsConstantTime(finished.verifyData)) {
|
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)
|
transcript.append(msg)
|
||||||
|
|
||||||
|
|||||||
@@ -82,10 +82,28 @@ object TlsConstants {
|
|||||||
// ── Server-name (SNI) types ───────────────────────────────────────────────
|
// ── Server-name (SNI) types ───────────────────────────────────────────────
|
||||||
const val SERVER_NAME_TYPE_HOST_NAME: Int = 0
|
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_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_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 ──────────────────────────────────────────────────────────────────
|
// ── ALPN ──────────────────────────────────────────────────────────────────
|
||||||
val ALPN_H3: ByteArray = "h3".encodeToByteArray()
|
val ALPN_H3: ByteArray = "h3".encodeToByteArray()
|
||||||
|
|||||||
+124
-4
@@ -21,6 +21,7 @@
|
|||||||
package com.vitorpamplona.quic.webtransport
|
package com.vitorpamplona.quic.webtransport
|
||||||
|
|
||||||
import com.vitorpamplona.quic.Varint
|
import com.vitorpamplona.quic.Varint
|
||||||
|
import com.vitorpamplona.quic.http3.Http3ErrorCode
|
||||||
import com.vitorpamplona.quic.http3.Http3Frame
|
import com.vitorpamplona.quic.http3.Http3Frame
|
||||||
import com.vitorpamplona.quic.http3.Http3FrameReader
|
import com.vitorpamplona.quic.http3.Http3FrameReader
|
||||||
import com.vitorpamplona.quic.http3.Http3Settings
|
import com.vitorpamplona.quic.http3.Http3Settings
|
||||||
@@ -126,6 +127,27 @@ class WtPeerStreamDemux(
|
|||||||
var peerGoawayProtocolError: String? = null
|
var peerGoawayProtocolError: String? = null
|
||||||
private set
|
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
|
* Surface RFC 9114 §7.2 frame-validation violations the
|
||||||
* [Http3FrameReader] catches (H3_FRAME_UNEXPECTED, H3_MISSING_SETTINGS,
|
* [Http3FrameReader] catches (H3_FRAME_UNEXPECTED, H3_MISSING_SETTINGS,
|
||||||
@@ -214,7 +236,19 @@ class WtPeerStreamDemux(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Http3StreamType.QPACK_ENCODER, Http3StreamType.QPACK_DECODER -> {
|
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 -> {
|
Http3StreamType.WEBTRANSPORT_UNI_STREAM -> {
|
||||||
@@ -271,19 +305,105 @@ class WtPeerStreamDemux(
|
|||||||
reader.push(chunk)
|
reader.push(chunk)
|
||||||
consumeFrames(reader)
|
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) {
|
} catch (e: com.vitorpamplona.quic.QuicCodecException) {
|
||||||
// RFC 9114 §7.2 frame-validation throws (H3_FRAME_UNEXPECTED /
|
// RFC 9114 §7.2 frame-validation throws (H3_FRAME_UNEXPECTED /
|
||||||
// H3_MISSING_SETTINGS / reserved type) land here. Record the
|
// H3_MISSING_SETTINGS / reserved type) land here. Record the
|
||||||
// diagnostic so the QUIC layer / application can close the
|
// diagnostic so observability tools (qlog, tests) see a
|
||||||
// connection deliberately instead of having the route() catch
|
// structured message; ALSO drive the connection closed via
|
||||||
// silently swallow the message. Idempotent on duplicate hits.
|
// [closeConnection] so the application doesn't need to poll
|
||||||
|
// [peerH3ProtocolError] separately. Idempotent on duplicate
|
||||||
|
// hits.
|
||||||
if (peerH3ProtocolError == null) {
|
if (peerH3ProtocolError == null) {
|
||||||
peerH3ProtocolError = e.message ?: "HTTP/3 protocol violation on CONTROL stream"
|
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
|
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) {
|
private fun consumeFrames(reader: Http3FrameReader) {
|
||||||
while (true) {
|
while (true) {
|
||||||
val frame = reader.next() ?: return
|
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.
|
// Order matters: CCF first, stream frame second.
|
||||||
val packet = pipe.buildServerApplicationDatagram(listOf(ccf, streamFrame))!!
|
val packet = pipe.buildServerApplicationDatagram(listOf(ccf, streamFrame))!!
|
||||||
feedDatagram(client, packet, nowMillis = 0L)
|
feedDatagram(client, packet, nowMillis = 0L)
|
||||||
assertEquals(QuicConnection.Status.CLOSED, client.status)
|
// RFC 9000 §10.2.2: peer's CONNECTION_CLOSE puts us in DRAINING
|
||||||
// The dispatcher MUST have stopped at CCF; no peer stream materialised.
|
// (was CLOSED pre-DRAINING-implementation; the spec-mandated
|
||||||
// (We can't easily query this directly, but the behavioural assertion
|
// 3 * PTO grace is held by the driver's send loop and flips to
|
||||||
// is the close-status — pre-fix the StreamFrame would have created
|
// CLOSED once it elapses). The "no phantom stream" assertion
|
||||||
// a phantom stream after close.)
|
// still holds — DRAINING blocks frame dispatch the same way
|
||||||
|
// CLOSED did.
|
||||||
|
assertEquals(QuicConnection.Status.DRAINING, client.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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
|
package com.vitorpamplona.quic.tls
|
||||||
|
|
||||||
import com.vitorpamplona.quic.QuicCodecException
|
|
||||||
import com.vitorpamplona.quic.QuicWriter
|
import com.vitorpamplona.quic.QuicWriter
|
||||||
|
import com.vitorpamplona.quic.TlsAlertException
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertFailsWith
|
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
|
* Before the round-3 fix, an HRR was treated as a regular ServerHello, then
|
||||||
* X25519 was performed against the cookie/extension bytes (garbage), then
|
* X25519 was performed against the cookie/extension bytes (garbage), then
|
||||||
* AEAD failed downstream with a confusing error. The current code rejects
|
* 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 {
|
class HelloRetryRequestTest {
|
||||||
@Test
|
@Test
|
||||||
@@ -49,9 +51,11 @@ class HelloRetryRequestTest {
|
|||||||
tls.pollOutbound(TlsClient.Level.INITIAL)
|
tls.pollOutbound(TlsClient.Level.INITIAL)
|
||||||
|
|
||||||
val hrr = buildHelloRetryRequest()
|
val hrr = buildHelloRetryRequest()
|
||||||
assertFailsWith<QuicCodecException> {
|
val ex =
|
||||||
tls.pushHandshakeBytes(TlsClient.Level.INITIAL, hrr)
|
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 nonceLength = 12
|
||||||
override val tagLength = 16
|
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")
|
private val keySpec = SecretKeySpec(key, "AES")
|
||||||
|
|
||||||
// Separate ciphers per direction. JCA's AES-GCM tracks the (key, iv) pair
|
// 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 nonceLength = 12
|
||||||
override val tagLength = 16
|
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 keySpec = SecretKeySpec(key, "ChaCha20")
|
||||||
private val encryptCipher: Cipher = Cipher.getInstance("ChaCha20-Poly1305")
|
private val encryptCipher: Cipher = Cipher.getInstance("ChaCha20-Poly1305")
|
||||||
private val decryptCipher: Cipher = Cipher.getInstance("ChaCha20-Poly1305")
|
private val decryptCipher: Cipher = Cipher.getInstance("ChaCha20-Poly1305")
|
||||||
|
|||||||
Reference in New Issue
Block a user