Files
amethyst/quic/plans/2026-04-26-quic-stack-status.md
T
Claude 6cc1bf6433 fix(quic): RFC 9001 §4.8 TLS alert mapping + RFC 9000 §22 CRYPTO_BUFFER_EXCEEDED
The remaining 🟦 cosmetic items from the 2026-05-09 audit. Pre-fix the
connection's `closeErrorCode` was effectively unused — TLS handshake
failures bubbled up as generic `QuicCodecException` and observers had
to grep the reason string for the spec category. Now closeErrorCode
carries the RFC 9000 §20.1 transport code or the RFC 9001 §4.8
TLS-alert-mapped CRYPTO_ERROR.

Implementation:

  * `TlsAlertException(alertCode, message)` carrier raised by the
    TLS layer for the well-defined cases:
      - HelloRetryRequest received (handshake_failure = 40)
      - TLS 1.3 not negotiated (protocol_version = 70)
      - Unsupported cipher (illegal_parameter = 47)
      - ALPN mismatch (no_application_protocol = 120)
      - Cert chain validation failure (bad_certificate = 42)
      - CertificateVerify signature failure (decrypt_error = 51)
      - Server Finished MAC mismatch (decrypt_error = 51)
      - TLS KeyUpdate over QUIC (unexpected_message = 10) — RFC 9001
        §6 mandates this specific code; fixes the misleading prior
        message that said "rotation not implemented" (we DO implement
        QUIC's own KEY_PHASE-bit rotation).
    The QUIC parser maps `0x100 + alertCode` per RFC 9001 §4.8 and
    stamps `closeErrorCode`.

  * `QuicTransportError` constants object covering the RFC 9000 §20.1
    table (NO_ERROR through NO_VIABLE_PATH including the previously-
    flagged CRYPTO_BUFFER_EXCEEDED 0x0d, KEY_UPDATE_ERROR 0x0e,
    AEAD_LIMIT_REACHED 0x0f).

  * `markClosedExternally(reason, errorCode = NO_ERROR)` overload —
    backward compatible; lets call sites pin the spec code on
    `closeErrorCode` for qlog observability without changing the
    wire-emit semantics (still no CONNECTION_CLOSE frame, same as
    before — that's a separate, larger refactor).

  * RFC 9000 §22 CRYPTO_BUFFER_EXCEEDED enforcement — pre-insert
    per-level cap of 64 KiB on inbound CRYPTO data. Generous enough
    for an RSA-4096 cert chain with intermediates; bounds the
    worst-case heap a misbehaving peer can pin to 192 KiB across
    all 3 encryption levels. Fires before the receive buffer
    actually allocates.

  * INVALID_TOKEN — N/A for client role (only servers validate Retry
    tokens). KEY_UPDATE_ERROR — exposed as a constant; no current
    failure path maps to it (PN regression on a key-update packet
    is spec-allowed to handle silently per RFC 9001 §6.1, which we
    do).

Tests: `ErrorCodeMappingTest` (6 cases) covers TlsAlertException
construction + offset, alert code bounds, markClosedExternally
preservation, CRYPTO_BUFFER_EXCEEDED close, sanity-check that small
CRYPTO frames don't trip the cap, and §20.1 numeric values.
`HelloRetryRequestTest` updated to expect TlsAlertException(40)
instead of generic QuicCodecException.

Plan updated to mark the §4.8 + §22 (CRYPTO_BUFFER_EXCEEDED) items
resolved; KEY_UPDATE_ERROR documented as TLS-side covered + QUIC-side
spec-allowed-silent; INVALID_TOKEN documented as N/A for client.

https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik
2026-05-09 13:44:35 +00:00

348 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# QUIC + WebTransport stack — current state
Living status doc for `:quic`. First written 2026-04-26 as the post-implementation
snapshot of the [pure-kotlin QUIC + WebTransport
plan](../../docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md);
last full rewrite **2026-05-09** after a four-RFC compliance audit (RFC 9000,
9001, 9002, 9221 + 9114 + 9204) found the doc had drifted ~2 weeks behind
shipping work. The original plan is now historical.
## TL;DR
`:quic` is a self-contained Kotlin-Multiplatform module that speaks QUIC v1
+ HTTP/3 + WebTransport against real-world servers (aioquic + picoquic +
quic-go interop verified, including 0-RTT). Pure Kotlin: Quartz crypto
primitives + JCA AEAD only — no BouncyCastle, no JNI. ~17k lines of
production code, 81 test files, ten-plus rounds of audit + fix passes
since 2026-04-26.
The 2026-05-09 audit confirmed the core handshake / packet protection /
loss recovery / frame routing surface is RFC-grade. Real gaps are listed
in [§ RFC compliance gaps](#rfc-compliance-gaps-2026-05-09).
## What shipped vs the original plan
| Phase | Original estimate | Actual | Notes |
|---|---|---|---|
| A. Foundations | 1 wk | done | KMP module, UdpSocket on `jvmAndroid`, Varint migrated from nestsClient |
| B. TLS 1.3 | 3 wk | done | RFC 8446 client state machine, X25519 ECDHE, RFC 8448 §3 vectors pass bit-for-bit |
| C. Initial + Handshake packets | 2 wk | done | RFC 9001 §A.2/§A.3 vectors pass; ChaCha20 per §A.5 |
| D. 1-RTT + STREAM | 1 wk | done | Stream offset reassembly, FIN, fuzzed |
| E. ACK + flow control | 1 wk | done | MAX_DATA / MAX_STREAM_DATA / MAX_STREAMS routing + writer enforcement + per-stream receive enforcement |
| F. Loss recovery + congestion control | 1 wk | done (no CC) | RFC 9002 §5/§6 RTT estimator + packet/time-threshold loss detection + PTO + per-frame retransmit shipped 2026-05-05; congestion control still TBD |
| G. Datagram extension | ½ wk | done | RFC 9221 frames + bounded incoming queue |
| H. Connection lifecycle | 1 wk | done | CONNECTION_CLOSE, draining/closing, idempotent driver close (idle-timeout enforcement still missing — see gaps) |
| I. HTTP/3 | 2 wk | done | Control stream + SETTINGS + GOAWAY (with id-regression check) + duplicate-id rejection |
| J. QPACK | 2 wk | done | Static-table + Huffman + integer codec (RFC 7541 §B + RFC 9204 §B.1 vectors) |
| K. Extended CONNECT + WT | 1 wk | done | Stream-type prefixes, HTTP Datagram + WT_CLOSE_SESSION capsule |
| L. Interop + hardening | 2 wk | done + much more | Live interop against aioquic + picoquic + quic-go; **10+ rounds of audit + fix**, multiplexing, 0-RTT, key update, ECN, path migration |
The original plan estimated 1719 weeks. We shipped the full sequence plus
a long tail of audit-driven hardening. Every audit found real bugs; the
test suite is what catches them on regression.
## What's actually in the module
```
quic/
├── plans/ ← module-local design docs
└── src/
├── commonMain/kotlin/com/vitorpamplona/quic/
│ ├── Buffer.kt ← QuicReader / QuicWriter
│ ├── Varint.kt ← RFC 9000 §16
│ ├── connection/ ← QuicConnection orchestrator + Driver
│ │ ├── QuicConnection.kt (≈ 2800 lines, the hub)
│ │ ├── QuicConnectionDriver.kt (read/send loops + close)
│ │ ├── QuicConnectionParser.kt (feedDatagram + dispatchFrames)
│ │ ├── QuicConnectionWriter.kt (drainOutbound + flow-control updates)
│ │ ├── PathValidator.kt ← RFC 9000 §5.1.2 + §8.2 + §9
│ │ ├── PacketProtection.kt + builder
│ │ ├── PacketNumberSpace.kt
│ │ ├── ConnectionId.kt + TransportParameters.kt
│ │ ├── EncryptionLevel.kt + LevelState.kt
│ │ └── recovery/ ← RecoveryToken, SentPacket,
│ │ AckedPackets, QuicLossDetection
│ ├── crypto/ ← Aead, header protection, HKDF helpers,
│ │ AesEcbHeaderProtection, ChaCha20HeaderProtection,
│ │ ChaCha20Poly1305Aead, InitialSecrets,
│ │ PlatformAesOneBlock, PlatformChaCha20Block (expect),
│ │ bestAes128GcmAead (expect)
│ ├── frame/ ← Frame.kt sealed hierarchy + FrameFuzzerTest target
│ │ includes RESET_STREAM / STOP_SENDING / NEW_TOKEN /
│ │ PATH_CHALLENGE / PATH_RESPONSE / NEW_CONNECTION_ID /
│ │ RETIRE_CONNECTION_ID / ACK_ECN
│ ├── http3/ ← Http3FrameReader + Http3Settings + frame types
│ ├── packet/ ← LongHeaderPacket, ShortHeaderPacket (with
│ │ reserved-bit + key-phase peek), RetryPacket, peekHeader
│ ├── qpack/ ← QpackDecoder, QpackEncoder, QpackHuffman, QpackInteger,
│ │ QpackStaticTable
│ ├── recovery/ ← AckTracker (with ack-eliciting gating)
│ ├── stream/ ← QuicStream, ReceiveBuffer (with FIN-fully-read +
│ │ per-stream receive-limit), SendBuffer, StreamId
│ ├── tls/ ← TlsClient state machine + ClientHello (incl. PSK +
│ │ early_data resumption variant) / ServerHello / EE /
│ │ Cert / CV / Finished / NewSessionTicket codecs,
│ │ TlsKeySchedule (incl. 0-RTT + resumption_master_secret),
│ │ TlsTranscriptHash (incremental), TlsConstants,
│ │ PermissiveCertificateValidator, TlsRunningSha256 (expect)
│ ├── transport/ ← UdpSocket (expect)
│ └── webtransport/ ← QuicWebTransportFactory + QuicWebTransportSessionState +
│ WtPeerStreamDemux + WtCapsule + WtDatagram + ExtendedConnect
└── jvmAndroid/kotlin/com/vitorpamplona/quic/
├── crypto/JcaAesGcmAead.kt ← cached JCA Cipher per direction with IV-reuse fallback
├── crypto/JcaChaCha20Poly1305Aead.kt ← JCA ChaCha20-Poly1305 (with Quartz fallback)
├── crypto/PlatformCrypto.kt ← actuals
├── tls/JdkCertificateValidator.kt ← system-trust-store chain validation + PSL filter +
│ RSA-PSS / ECDSA / Ed25519
├── tls/TlsRunningSha256.kt ← MessageDigest.clone()-based incremental hash
└── transport/UdpSocket.kt ← DatagramChannel + suspend wrapper
```
## Crypto surface
Quartz primitives + JCA only:
| Primitive | Source |
|---|---|
| AES-128-GCM | `JcaAesGcmAead` (jvmAndroid) — cached `Cipher` per direction; `Aes128Gcm` singleton (commonMain) for non-hot paths |
| ChaCha20-Poly1305 | `JcaChaCha20Poly1305Aead` (jvmAndroid, JCA fast path) with Quartz pure-Kotlin fallback in `ChaCha20Poly1305Aead` |
| HKDF-Extract / Expand-Label | Quartz `Hkdf` + `MacInstance`; thin RFC 8446 §7.1 helper in `crypto/HkdfHelpers.kt` |
| SHA-256 (one-shot) | Quartz `sha256(...)` |
| SHA-256 (incremental, for transcript) | `TlsRunningSha256` (expect/actual; jvmAndroid wraps `MessageDigest.clone()`) |
| X25519 ECDHE | Quartz `X25519` |
| Ed25519 | Quartz `Ed25519` (only inside the JVM cert validator path) |
| AES-ECB (one block, for header protection) | `Cipher.getInstance("AES/ECB/NoPadding")` (jvmAndroid only) |
| ChaCha20 keystream (header protection) | Quartz `ChaCha20Core.chaCha20Xor` |
| SecureRandom | Quartz `RandomInstance` |
X.509 chain validation, hostname verification, signature verification all
delegate to JDK `TrustManagerFactory` / `Signature.getInstance(...)`.
`CertificateValidator` is a non-null typed parameter — tests pass an
explicit `PermissiveCertificateValidator`; production passes
`JdkCertificateValidator` (with PSL-subset wildcard rules).
## What we deliberately don't do
- **QUIC server role.** Client-only.
- **Path MTU discovery.** Fixed 1200-byte ceiling per RFC 9000 §14.
- **Anti-amplification limits (server side).** We're a client; the server
applies its own.
- **HTTP/3 server push.**
- **QPACK dynamic-table inserts on the encoder.** We send literal +
static-indexed only; decoder accepts dynamic-table indexed lines.
- **Congestion control (NewReno / CUBIC / BBR).** Loss detection is
in place (RFC 9002 §5/§6) but no rate-limiting feedback loop reacts
to losses; we send as fast as the application provides bytes. See
[`2026-05-05-congestion-control.md`](2026-05-05-congestion-control.md)
(parked indefinitely; acceptable for moq-lite audio).
## What's now shipped that the original "deliberately don't do" list disclaimed
The plan's pre-rewrite version listed the following as out-of-scope. They
shipped between 2026-04-26 and 2026-05-09 and now WORK:
- **0-RTT / session resumption.** TLS 1.3 PSK + `early_data` extension on
both ends; client caches `resumption_master_secret`, parses
`NewSessionTicket`, and replays 0-RTT data with prior-connection ALPN
+ transport params. Verified against picoquic and quic-go.
- **TLS Key-Update / NewSessionTicket.** RFC 9001 §6 1-RTT key update —
client-initiated AND peer-initiated. `KEY_PHASE`-bit peek before AEAD,
in-flight gate, PN-regression check on the rotation packet, proper
HKDF roll of `application_traffic_secret_N+1`.
- **NEW_CONNECTION_ID / RETIRE_CONNECTION_ID handling, including
`retire_prior_to`** per RFC 9000 §5.1.2 + §19.15 (`PathValidator.recordPeerNewConnectionId`
with full state machine: stale-offer retire, watermark monotonicity,
pool-full bound, duplicate-with-mismatch rejection).
- **Path validation (client- and server-initiated).** RFC 9000 §8.2 +
§9 — full PATH_CHALLENGE / PATH_RESPONSE round-trip, 3*PTO timeout
per §8.2.4, abrupt migration to a new DCID on success, forced
rotation when `retire_prior_to` exceeds the active CID.
- **ECN (1-RTT only).** ECT(0) on outbound, `ACK_ECN` (0x03) frames
with truthful CE / ECT(0) / ECT(1) counters. Initial / Handshake
ACKs intentionally skip ECN counts (interop conservatism).
The remaining pre-rewrite "don't do" items (server role, PMTU, HTTP/3
push, QPACK encoder dynamic table, CC) are still out-of-scope.
## Verified interop
- **aioquic** (Python): `quic-interop-runner`-style Docker setup; full
handshake + multiplexing + Extended CONNECT + h3 datagram round-trip.
- **picoquic** (C): Docker image, h3, 0-RTT, key-update, longRTT, ECN
test cases pass.
- **quic-go** (Go): 0-RTT pass.
- **In-memory pipe** (`InMemoryQuicPipe`, modeled on Cloudflare quiche's
`Pipe`) drives both sides of the handshake in one JVM for fast tests
without sockets.
What's NOT verified: a live nests/MoQ audio-room exchange end to end. That
gates on the audio-rooms completion plan
([nestsClient/plans/2026-04-26-audio-rooms-completion.md](../../nestsClient/plans/2026-04-26-audio-rooms-completion.md)).
## Audit summary
| Round | Date | Focus | Findings | Status |
|---|---|---|---|---|
| 1 | 2026-04-22 | Initial review (pre-interop) | 6 critical correctness/security bugs | all fixed |
| 2 | 2026-04-23 | TLS hardening + lifecycle | hangs + TLS edge cases | all fixed |
| 3 | 2026-04-24 | Performance + concurrency | cipher caching, polling, transcript O(n²) | all fixed |
| 4 | 2026-04-25 | Core + TLS + perf + coverage gaps (4 parallel agents) | ~30 items including 4 CRITICAL interop blockers | all fixed; comprehensive regression tests added |
| 5 | 2026-04-26 | Regression check + concurrency-specific (2 parallel agents) | 1 CRITICAL ackEliciting regression I'd just introduced + WT scope leak + others | all fixed |
| 6 | 2026-05-04 | Per-frame retransmit + SendBuffer rewrite | retain-until-ACK + control-frame retx + STREAM/CRYPTO recovery | all fixed; see [`2026-05-04-control-frame-retransmit.md`](2026-05-04-control-frame-retransmit.md) |
| 7 | 2026-05-05 | Loss-detection timer + PSK rejection signal | RFC 9002 §6.1.2 timer; PSK rejection recover-in-place | all fixed |
| 8 | 2026-05-06 | Path validation pass 1 + 2 | DCID rotation, retire-prior-to monotonicity, abrupt migration | all fixed |
| 9 | 2026-05-07 | DoS hardening + reserved-bit checks + TLS bounds + SendBuffer shrink | bound peer-controlled buffers and channels | all fixed |
| 10 | 2026-05-08 | Lock-split design + close-under-load + key-update PN gate | streamsLock + lifecycleLock split | all fixed; see [`2026-05-08-lock-split-design.md`](2026-05-08-lock-split-design.md) |
| 11 | 2026-05-09 | Four-RFC compliance audit (4 parallel agents: 9000, 9001, 9002, 9221+9114+9204) | ~15 items, mostly receive-side validation gaps | catalogued in [§ RFC compliance gaps](#rfc-compliance-gaps-2026-05-09) below; **all 🔴 High and all 🟡 Medium items resolved same-day across 7 commits** |
Every fix carries an inline `audit-N #M` reference comment so the regression
test → fix → comment chain is auditable. The whole audit corpus is in the
git log (commits whose subject starts with `fix(quic):` or `perf(quic):`).
## Test inventory
81 `*Test.kt` files. Roughly grouped:
- **RFC vectors:** RFC 8448 §3 (TLS handshake), RFC 9001 §A.1A.5 (Initial
encrypt/decrypt, Retry, ChaCha20, server-side HP), RFC 9204 §B.1 (QPACK),
RFC 7541 (Huffman).
- **End-to-end pipe tests:** `InMemoryQuicPipeTest`, `CoalescedPacketSkipTest`,
`ReceiveLimitEnforcementTest`, `PeerStreamLimitTest`, `FrameRoutingTest`,
`AckElicitingFramesTest`, `MultiplexingRoundTripTest`,
`MultiplexingThroughputTest`, `MultiplexingCoalescingTest`,
`MultiStreamFinDeliveryTest`, `MultiplexingAioquicTpsTest`.
- **Adversarial:** `FrameFuzzerTest`, `HostilePacketInputTest`,
`TlsSecurityPropertiesTest`, `HelloRetryRequestTest`,
`CloseDatagramRfcComplianceTest`, `CloseUnderLoadTest`.
- **Crypto:** `JcaAesGcmAeadTest`, `ChaCha20Poly1305AeadTest`,
`TlsTranscriptHashTest`.
- **WT / HTTP/3:** `CapsuleReaderTest`, `WtPeerStreamDemuxTest`,
`WtFramingTest`.
- **Recovery:** `AckTrackerCoalescedTest`, `AckTrackerGatingTest`,
`AckTrackerPurgeOnAckOfAckTest`, `RecoveryTokenTest`, `SentPacketTest`,
`ReceiverFlowControlTest` (9 cases mirrored from neqo `fc.rs`),
`QuicLossDetectionTest`, `PtoTest`, `PtoCryptoRetransmitTest`,
`QuicConnectionRetransmitTest`, `RetransmitIntegrationTest`,
`SendBufferRetainUntilAckTest` (14 cases), `StreamRetransmitTest`,
`CryptoRetransmitTest`, `ResetStopSendingEmitTest` (7 cases),
`OnTokensLostTest`, `MoqLiteLossHarnessTest`.
- **Path validation / migration:** `PathValidatorTest`,
`PathValidationTest`, `ClientPathMigrationTest`.
- **Key lifecycle:** `KeyDiscardTest`, `KeyUpdatePeerInitiatedTest`.
- **Concurrency / soak:** `BatchedOpenLockContractTest`,
`StreamRetirementSoakTest`, `QuicHeapSoakTest`,
`QuicConnectionDriverLifecycleTest`.
- **Misc:** `VersionNegotiationTest`, `RetryHandlingTest`,
`PacketNumberSpaceTest`, `ConnectionIdTest`,
`FlowControlSnapshotTest`, `PendingFlowControlEmitTest`,
`PeerStreamCreditExtensionTest`, `PeerUniStreamDrainTest`.
- **Interop (jvmTest, opt-in):** `InteropRunner` drives a real socket
against a Dockerised aioquic / picoquic / quic-go. Not in CI.
## Known limitations / deferred work
These are items future audit rounds keep flagging that we've consciously
not tackled:
1. ~~**No STREAM retransmit on loss** (audit-4 #10).~~ **Resolved 2026-05-05**
`SendBuffer` rewritten for retain-until-ACK with three-state range
tracking; ACK / loss dispatchers re-queue lost ranges to the
retransmit FIFO. Also covers CRYPTO retransmit per encryption level
and RESET_STREAM / STOP_SENDING / NEW_CONNECTION_ID retransmit.
See [`2026-05-04-control-frame-retransmit.md`](2026-05-04-control-frame-retransmit.md).
2. ~~**`SendBuffer` doesn't retain bytes until ACK.**~~ Resolved with #1.
3. ~~**No Initial / Handshake key discard.**~~ **Resolved 2026-05-05.**
4. ~~**No path validation for `NEW_CONNECTION_ID`.**~~ **Resolved 2026-05-0608**
full client-initiated DCID rotation + server-initiated path
validation per RFC 9000 §8.2 + §9. See `PathValidator.kt` and the
three test files (`PathValidatorTest`, `PathValidationTest`,
`ClientPathMigrationTest`).
5. ~~**Stateless reset detection.**~~ **Resolved 2026-05-09**
`QuicConnection.isStatelessReset` matches every short-header-form
datagram's trailing 16 bytes against the peer's transport-param
token AND every NEW_CONNECTION_ID token retained in
`PathValidator.allKnownStatelessResetTokens` (lifetime store —
covers the WiFi-handoff case where the migrated CID's token would
otherwise be lost the moment migration started). Constant-time per
§10.3.1; silent CLOSED transition on match.
6. ~~**`AckTracker.purgeBelow` threshold semantics.**~~ **Resolved
2026-05-05.**
7. **Driver direct unit tests** require turning `UdpSocket` from `expect
class` into an interface so the test side can stub. The driver is
covered indirectly by every pipe-based test plus the live interop
runner.
8. **No congestion control.** Loss detection is wired but nothing
throttles send rate in response. Independent ~12 wk project.
See [`2026-05-05-congestion-control.md`](2026-05-05-congestion-control.md).
## RFC compliance gaps (2026-05-09)
Catalogued from a four-agent compliance audit (RFC 9000 / 9001 / 9002 /
9221+9114+9204). Each item lists the spec section, the gap, and
file:line evidence. Severity:
- 🔴 **High** — exploitable by a hostile peer (resource exhaustion,
silent desync, RTT poisoning).
- 🟡 **Medium** — interop or correctness risk; cooperative peers
unaffected.
- 🟦 **Low / cosmetic** — diagnostic loss, doesn't break the wire.
### Receive-side validation gaps
| RFC § | Sev | Gap | Evidence |
|---|---|---|---|
| RFC 9000 §17.2 + §17.3 | ~~🔴~~ | ~~**Inbound fixed-bit (0x40) not validated.**~~ **Resolved 2026-05-09** — both `LongHeaderPacket.parseAndDecrypt`, `ShortHeaderPacket.parseAndDecrypt`, and `ShortHeaderPacket.peekKeyPhase` now reject fixed-bit=0 packets per RFC §17.2 / §17.3 (silent discard). Tests in `FixedBitValidationTest`. |
| RFC 9000 §10.1 | ~~🔴~~ | ~~**`max_idle_timeout` not enforced.**~~ **Resolved 2026-05-09** — `QuicConnection.lastActivityMs` updated on inbound packet receipt and outbound ack-eliciting send; `effectiveIdleTimeoutMs()` computes min(local, peer) with 3 × PTO floor per §10.1; driver send-loop folds the deadline into its `withTimeoutOrNull` and silently closes via `markClosedExternally` per §10.2.1 on expiry. Tests in `IdleTimeoutTest` (8 cases). |
| RFC 9000 §4.1 | ~~🟡~~ | ~~**Connection-level inbound flow control missing.**~~ **Resolved 2026-05-09** — `QuicStream.receiveHighestOffset` + `QuicConnection.connectionInboundOffsetSum` track the §4.1 spec quantity (sum of largest-received-offset across streams). Parser closes with FLOW_CONTROL_ERROR when the sum exceeds `advertisedMaxData`. RESET_STREAM finalSize counted toward the limit per §4.5. Tests in `ConnectionLevelFlowControlTest`. |
| RFC 9000 §3 | ~~🟡~~ | ~~**Stream state machine implicit, not validated.**~~ **Resolved 2026-05-09** — `QuicStream.peerResetReceived` latches when a RESET_STREAM arrives; the parser closes with STREAM_STATE_ERROR on any subsequent STREAM frame for the same id. (Other state transitions — FIN-size mismatch, illegal peer-initiated opens — were already caught.) Tests in `StreamAfterResetTest`. |
| RFC 9000 §10.2 | ~~🟡~~ | ~~**No DRAINING state.**~~ **Resolved 2026-05-09** — `Status.DRAINING` added; parser routes peer's CONNECTION_CLOSE through `enterDraining` (sets status + 3 × PTO deadline) instead of immediate CLOSED. Driver folds the deadline into its send-loop sleep and flips to CLOSED on expiry. Late inbound during DRAINING is silently dropped at the top of `feedDatagramInner`. Tests in `DrainingStateTest`. |
| RFC 9000 §10.3 | ~~🟡~~ | ~~**Stateless reset detection missing.**~~ **Resolved 2026-05-09** — `QuicConnection.isStatelessReset` checks the trailing 16 bytes of every short-header-form datagram against the peer's `statelessResetToken` AND every NEW_CONNECTION_ID token in `PathValidator.allKnownStatelessResetTokens` (lifetime store — extends past CID rotation / retirement so WiFi↔cellular handoffs still detect reset on the new path), in constant time per §10.3.1. On match, silently transitions to CLOSED. Tests in `StatelessResetDetectionTest` (7 cases including handoff + force-rotation). |
| RFC 9001 §6.6 | ~~🟡~~ | ~~**AEAD invocation limit not tracked.**~~ **Resolved 2026-05-09** — `Aead.confidentialityLimit` / `integrityLimit` properties surface the §B.1 values; `QuicConnection.aeadEncryptCount` / `aeadDecryptFailureCount` track per-key usage and reset on rotation. Writer triggers a key update at half the confidentiality limit and closes with AEAD_LIMIT_REACHED if rotation can't complete; parser closes if decrypt failures hit the integrity limit. Tests in `AeadInvocationLimitTest`. |
### Transport-parameter bounds
| RFC § | Sev | Gap | Evidence |
|---|---|---|---|
| RFC 9000 §18.2 | ~~🟡~~ | ~~**`max_udp_payload_size < 1200` not rejected.**~~ **Resolved 2026-05-09** — `applyPeerTransportParameters` closes with TRANSPORT_PARAMETER_ERROR. Tests in `TransportParameterBoundsTest`. |
| RFC 9000 §18.2 | ~~🟡~~ | ~~**`ack_delay_exponent > 20` not rejected.**~~ **Resolved 2026-05-09** — same path. |
| RFC 9000 §18.2 | ~~🟡~~ | ~~**`active_connection_id_limit < 2` not rejected.**~~ **Resolved 2026-05-09** — same path. |
| RFC 9000 §13.2.5 | ~~🟦~~ | ~~**`ack_delay_exponent` decode uses our config, not peer's.**~~ **Resolved 2026-05-09** — parser now uses `peerTransportParameters?.ackDelayExponent` with the §18.2 default of 3 as fallback; defensive 0..20 coercion preserved. |
### HTTP/3 + DATAGRAM gaps
| RFC § | Sev | Gap | Evidence |
|---|---|---|---|
| RFC 9114 §7.2.4.1 | ~~🟡~~ | ~~**HTTP/2 reserved SETTINGS ids 0x02/0x03/0x04/0x05 not rejected.**~~ **Resolved 2026-05-09** — `Http3Settings.decodeBody` raises H3_SETTINGS_ERROR for ids 0x02..0x05. Tests in `Http3ReservedSettingsTest`. |
| RFC 9221 §3 | ~~🟡~~ | ~~**Outbound DATAGRAM size not gated.**~~ **Resolved 2026-05-09** — writer drops outbound DATAGRAM frames when peer didn't advertise `max_datagram_frame_size` or when the encoded frame would exceed the advertised value. Diagnostic via `qlogObserver.onPacketDropped`. |
| RFC 9114 §6.2.1 + RFC 9204 §4.2 | ~~🟦~~ | ~~**Closing the control stream surfaces a flag rather than auto-closing.**~~ **Resolved 2026-05-09** — `WtPeerStreamDemux` now drives `connection.close(errorCode, reason)` itself when ANY critical unidirectional stream closes (control + QPACK encoder + QPACK decoder), whether by clean FIN (H3_CLOSED_CRITICAL_STREAM) or HTTP/3 protocol violation (mapped to the specific §8.1 code where possible). New `Http3ErrorCode` constants. Tests in `CriticalStreamClosureTest` (5 cases) cover FIN-on-control, FIN-on-QPACK, MISSING_SETTINGS, FRAME_UNEXPECTED, and idempotency. Audio rooms now get an immediate disconnect event when the relay drops the control stream mid-session. |
### TLS / error reporting cosmetics
| RFC § | Sev | Gap | Evidence |
|---|---|---|---|
| RFC 9001 §4.8 | ~~🟦~~ | ~~**TLS alerts not mapped to CRYPTO_ERROR + alert_code.**~~ **Resolved 2026-05-09** — new `TlsAlertException(alertCode, ...)` carrier raised by the well-defined TLS error sites (HRR, TLS 1.3 version mismatch, cipher mismatch, ALPN mismatch, cert chain validation, CertificateVerify, Finished MAC, TLS KeyUpdate forbidden over QUIC). The QUIC parser's `feedDatagram` catches it and stamps `closeErrorCode = 0x100 + alertCode` per §4.8. Generic `QuicCodecException` falls back to `0x100 + 80` (internal_error). Tests in `ErrorCodeMappingTest`. |
| RFC 9000 §22 | ~~🟦~~ | ~~**Several error codes never emitted.**~~ **Partially resolved 2026-05-09:**<br>• **CRYPTO_BUFFER_EXCEEDED (0x0d)** — pre-insert per-level cap of 64 KiB on inbound CRYPTO data (covers worst-case RSA-4096 cert chains with headroom; bounds heap to 192 KiB across all 3 levels). Closes with the spec code on overshoot.<br>• **KEY_UPDATE_ERROR (0x0e)** — the original audit point referred to TLS KeyUpdate over QUIC, which is now mapped to CRYPTO_ERROR(unexpected_message=0x10a) via the `TlsAlertException` path above (RFC 9001 §6 mandates that specific code, NOT 0x0e). Constant exposed for future use; no QUIC-level rotation failure currently maps to it (PN-regression on a key-update packet is spec-allowed to handle silently per §6.1, which we do).<br>• **INVALID_TOKEN (0x0b)** — N/A for client role (only servers validate Retry tokens).<br>• Added `QuicTransportError` constants object covering the full §20.1 table; `markClosedExternally(reason, errorCode)` overload lets call sites pin the spec code on `closeErrorCode` for qlog observability. |
### Gaps the 2026-05-09 audit got WRONG
For audit traceability — three audit findings fingered code that's
actually compliant. Listed here so future audit rounds don't repeat
them:
| Audit claim | Reality |
|---|---|
| "Inbound flow-control enforcement missing." | Per-stream IS enforced. `QuicConnectionParser.kt:655` raises a connection close when STREAM offset+len exceeds `stream.receiveLimit`. The actual gap is *connection-level* (above). |
| "ack_delay_exponent not applied on decode." | IS applied at `QuicConnectionParser.kt:540-553`, with explicit overflow protection per audit-4. The actual gap is using *our* exponent instead of the peer's. |
| "Short-header reserved bits not validated." | ARE validated at `ShortHeaderPacket.kt:182-187` — `QuicProtocolViolationException` raised on any of the 0x18 bits set. The `0x30` mask the audit cited was a mis-read of RFC 9000 §17.3 (reserved bits are 0x18). |
## Pointers
- Original (frozen) plan: `docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.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`
- Retransmit plan + implementation log: [`2026-05-04-control-frame-retransmit.md`](2026-05-04-control-frame-retransmit.md)
- Congestion control deferral rationale: [`2026-05-05-congestion-control.md`](2026-05-05-congestion-control.md)
- Lock-split design: [`2026-05-08-lock-split-design.md`](2026-05-08-lock-split-design.md)
- Live interop runner: `quic/src/jvmTest/.../interop/InteropRunner.kt`
- Audit history: `git log --grep='audit\|fix(quic)\|feat(quic)' -- quic/`