Commit Graph

12 Commits

Author SHA1 Message Date
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
Claude cdb9d87a2e fix(quic): RFC 9114 §6.2.1 critical-stream auto-close + WiFi-handoff stateless-reset tokens
Two related improvements for the audio-rooms moq-lite path on mobile.

** RFC 9114 §6.2.1 + RFC 9204 §4.2 critical-stream closure **

When the peer closes a critical unidirectional stream — control (0x00),
QPACK encoder (0x02), or QPACK decoder (0x03) — the spec MANDATES we
treat it as a connection error of type H3_CLOSED_CRITICAL_STREAM.
Pre-fix the demux only set `peerH3ProtocolError` flag on protocol
violations, with no autonomous close on clean FIN; the application
was expected to poll. For audio rooms running on lossy mobile paths,
relay-side control-stream drops left users staring at silent audio.

  * `Http3ErrorCode` constants for the §8.1 error-code table.
  * `WtPeerStreamDemux.drainControlStream` calls `connection.close(...)`
    on either clean FIN (H3_CLOSED_CRITICAL_STREAM) or QuicCodecException
    raised by the frame reader (specific code via
    [http3ErrorCodeForMessage] map: H3_MISSING_SETTINGS,
    H3_FRAME_UNEXPECTED, H3_SETTINGS_ERROR, H3_ID_ERROR, etc.).
  * New `drainCriticalStream` helper fires the same close on QPACK
    encoder / decoder FIN (RFC 9204 §4.2).
  * Closure intent latched on `criticalStreamClosureCode` /
    `criticalStreamClosureReason` so tests can verify the path runs
    without wiring a real driver.

** RFC 9000 §10.3 stateless-reset tokens for migrated CIDs **

The 2026-05-09 stateless-reset detection covered tokens in the
unused-CID pool but lost them the moment a CID was rotated to active
via `tryStartValidation` (WiFi handoff) or `forceRotateToHigherSequence`
(peer-forced retire). On the new path the migrated token wouldn't
match — relay crash mid-handoff would silently hang until idle timeout.

  * `PathValidator.knownStatelessResetTokens` — append-only lifetime
    store, populated in `recordPeerNewConnectionId`. Survives rotation.
  * `QuicConnection.isStatelessReset` walks the lifetime store instead
    of the unused pool.
  * §10.3.1 explicitly permits keeping tokens after retirement; cost
    is ~16 bytes per peer-issued CID.

Tests:
  * `CriticalStreamClosureTest` (5 cases) — control FIN, QPACK FIN,
    MISSING_SETTINGS, FRAME_UNEXPECTED, idempotency.
  * `StatelessResetDetectionTest` extended with 2 cases —
    `token_persists_after_path_migration_for_wifi_handoff` and
    `token_persists_through_force_rotation_for_acid_reissue`.

Plan updated: 🟦 H3_CLOSED_CRITICAL_STREAM resolved; the
"limitation: tokens for actively-used DCIDs we migrated to" caveat is
removed from the §10.3 entry and from known-limitations item #5.

https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik
2026-05-09 13:12:00 +00:00
Claude d4ffe0474f fix(quic): RFC 9000 §10.2.2 DRAINING state for peer CONNECTION_CLOSE
Peer's CONNECTION_CLOSE now transitions the connection through DRAINING
for 3 * PTO before flipping to CLOSED, instead of going straight to
CLOSED. While DRAINING:
  - the writer emits no packets (drainOutbound returns null);
  - the parser drops late inbound silently at the top of feedDatagramInner;
  - close()-awaiters unblock immediately via closingDrainSignal so the
    transition isn't gated on the 3*PTO grace.

After 3 * PTO the driver's send-loop timer fires
markClosedExternally("draining period elapsed") which transitions to
CLOSED. The §10.2.2 grace period gives the peer's last retransmits a
chance to converge before we discard state.

Implementation:
  * `Status.DRAINING` enum value with kdoc tying it to §10.2.2.
  * `QuicConnection.drainingDeadlineMs` + `enterDraining(reason, nowMs)`
    (sets status, computes `now + max(3*pto, MIN_DRAINING_PERIOD_MS)`,
    completes closingDrainSignal, fires qlog) + `isDrainingExpired`.
  * Parser's CONNECTION_CLOSE handler routes through `enterDraining`
    instead of `markClosedExternally`.
  * Driver folds `drainingDeadlineMs` into its send-loop sleep
    `withTimeoutOrNull` and transitions to CLOSED on expiry.
  * Writer short-circuits drainOutbound for DRAINING (returns null —
    spec MUST NOT send).
  * Parser drops late inbound at the top of feedDatagramInner.

Updated `FrameRoutingTest.connection_close_from_peer_short_circuits_remaining_frames`
to expect DRAINING (was CLOSED). Other status=CLOSED assertions in the
test suite cover OUR-side closes (markClosedExternally on protocol
violations / TRANSPORT_PARAMETER_ERROR / FLOW_CONTROL_ERROR) which
still go directly to CLOSED.

Test: `DrainingStateTest` (7 cases) covers the peer-close → DRAINING
transition, late-inbound drop, deadline math, the
MIN_DRAINING_PERIOD_MS floor, retransmit no-op semantics, and the
fresh-connection invariant.

Plan updated to mark all 🟡 Medium items resolved. The 2026-05-09
audit's complete spec-compliance close-out: 2 of 2 High + 6 of 6
Medium fixed in seven commits.

https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik
2026-05-09 05:09:39 +00:00
Claude a80f4ea19d fix(quic): RFC 9000 §17.2/§17.3 fixed-bit + §10.1 idle-timeout enforcement
The two High-severity gaps from the 2026-05-09 four-RFC compliance audit.

Fixed-bit (RFC 9000 §17.2 long-header, §17.3 short-header): the spec
says fixed-bit (0x40) MUST be 1 in every v1 packet; receivers MUST
discard packets where it's 0. Pre-fix the parsers checked only the
form-bit (0x80) and accepted any fixed-bit value — a peer or off-path
attacker could route packets through AEAD that aren't valid v1 packets.
Both `parseAndDecrypt` paths and the `peekKeyPhase` shortcut now
silently drop fixed-bit=0. The bit isn't header-protected (HP only
XORs the low 5 bits), so the check runs on the raw wire byte before
HP unmask + AEAD. Test: `FixedBitValidationTest` (3 cases).

Idle timeout (RFC 9000 §10.1): `max_idle_timeout` was decoded into
config and advertised via the ClientHello transport-params extension,
but never enforced — a black-holed connection lived indefinitely.
This commit:

  * adds `QuicConnection.lastActivityMs`, bumped on (a) successful
    inbound packet decrypt and (b) outbound ack-eliciting send per
    §10.1.1
  * adds `effectiveIdleTimeoutMs()` returning the min of local and
    peer advertisements (skipping any side that advertised 0), with
    the §10.1 `3 * PTO` floor applied
  * folds the idle deadline into the driver's send-loop
    `withTimeoutOrNull` so an idle connection wakes exactly at expiry
  * silently transitions to CLOSED via `markClosedExternally` per
    §10.2.1 ("the connection enters the closing state silently —
    discarding the connection state without sending a CONNECTION_CLOSE")

Test: `IdleTimeoutTest` (8 cases) covers the min-of-two computation,
0-means-disabled handling, the 3*PTO floor, deadline math, and
postpone-on-activity. Plan updated to mark both items resolved.

https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik
2026-05-09 04:29:32 +00:00
Claude 9f7f6a9ef9 docs(quic): refresh stack-status plan to match shipped 2026-05-09 reality
Plan had drifted ~2 weeks behind shipping work. Rewrote to reflect the
features that landed since 2026-04-26 (path validation, 0-RTT, key
update, ECN, session resumption, lock-split refactor, DoS hardening)
and added a fresh RFC compliance gaps section catalogued from a
four-agent audit (RFC 9000, 9001, 9002, 9221+9114+9204).

https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik
2026-05-09 04:13:46 +00:00
Claude ef4bb99988 refactor(quic): split conn.lock into streamsLock + per-level lock + lifecycleLock
The single connection-wide `QuicConnection.lock` mutex serialised every
critical path: the read loop's `feedDatagram`, the send loop's
`drainOutbound`, and every public mutator (`openBidiStream`,
`streamById`, `flowControlSnapshot`, ...). The multiplexing testcase
opens hundreds of bidi streams in parallel and was capped at ~25
streams/sec by lock contention against the I/O loops.

Phase 1 of the lock split (see
`quic/plans/2026-05-08-lock-split-design.md`) introduces three
domain-specific mutexes:

  - `streamsLock` — streams registry, datagram queues, stream-id
    counters, connection-level flow-control bookkeeping, pending-
    retransmit maps for control frames
  - `LevelState.levelLock` (one per encryption level) — per-level
    pnSpace / sentPackets / ackTracker / CRYPTO buffers
  - `lifecycleLock` — status transitions, close reason/error code

Acquisition order: `lifecycleLock < streamsLock < levelLock`.
Per-stream `synchronized(this)` blocks inside SendBuffer/ReceiveBuffer
remain at the leaf — never acquire any QuicConnection mutex while
holding a per-stream lock.

The legacy `lock: Mutex` field is preserved as a deprecated alias of
`lifecycleLock` for source-compatibility with external test harnesses;
new code MUST use the appropriate domain lock.

Highlights:

  - `feedDatagram` / `drainOutbound` now require the caller to hold
    `streamsLock`; the driver wraps each call. Phase 1 keeps the whole
    feed/drain inside `streamsLock` for safety; phase 2 (deferred) will
    split frame-collection from encrypt + sentPackets-record so app
    coroutines can intersperse during the encrypt window.
  - `pendingPing`, `peerTransportParameters`, `status`,
    `handshakeComplete` are now @Volatile so observers read them
    without a lock.
  - `markClosedExternally` no longer needs any lock (status is
    @Volatile, signals are channel-thread-safe).
  - Driver's PTO bookkeeping uses the volatile fields directly — no
    lock needed.
  - Tests that manually acquired `conn.lock` to call
    `getOrCreatePeerStreamLocked` / `onTokensAcked` / `onTokensLost`
    now acquire `streamsLock` (the domain those routines mutate).
  - New `MultiplexingThroughputTest` locks in the contract: 1000
    parallel `openBidiStream` calls must complete in <2 s.

Test plan:

  - `:quic:jvmTest` — 294 tests pass (293 prior + 1 new throughput).
  - `MultiplexingThroughputTest`: 1000 bidi streams in 52 ms
    (~19,000 streams/sec on the in-memory pipe), well above the
    250+/sec target.
  - `:nestsClient:compileKotlinJvm` — clean, no API breaks.
  - `./gradlew :quic:spotlessApply` — clean.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
2026-05-07 02:24:51 +00:00
Claude fba0a5c952 feat(quic): bestEffort streams + park CC plan indefinitely
After drafting the congestion-control plan we concluded the audio-rooms
workload doesn't actually need CC — speakers push ~8 KB/sec, which
never fills any modern link's capacity. The one real concern that
surfaced — STREAM retransmit wasting bandwidth on stale Opus frames
on lossy uplinks — is much cheaper to fix directly than to bound via
a 14-test CC subsystem.

SendBuffer gains a `bestEffort: Boolean = false` constructor flag.
When true, markLost drops the lost ranges instead of moving them to
the retransmit queue and lets the underlying byte storage compact as
if the bytes had been ACK'd. The FIN flag (if covered) also stays
sent — best-effort skips FIN re-emission too. The peer may end up
with a truncated stream; moq-lite's per-stream timeouts handle that.

Plumbed through QuicStream → QuicConnection.openUniStream(bestEffort)
→ QuicWebTransportSessionState.openUniStream(bestEffort) →
WebTransportSession.openUniStream(bestEffort). Default is false
everywhere, so reliable streams (HTTP/3 control, moq-lite SUBSCRIBE
bidi, etc.) keep RFC 9000 §3.5 semantics.

MoqLiteSession.openGroupStream now passes `bestEffort = true` —
group streams carry a single Opus packet, are real-time, and don't
benefit from retransmit.

Internal cleanup: `removeOverlap`'s `ackedNotLost: Boolean` parameter
became `OverlapAction { ACK, RETRANSMIT, DROP }` so the third best-
effort disposition has a name. Same code paths, same tests, just
clearer at the call site.

CC plan (quic/plans/2026-05-05-congestion-control.md) is updated to
"parked indefinitely" with a note that this commit is the lighter-
weight alternative that addresses the only practical concern. The
plan is preserved as a reference if a future workload justifies CC.

New tests: SendBufferBestEffortTest (6 cases — reliable baseline,
best-effort drops, FIN drop in best-effort mode, partial overlap,
idempotent stale loss, ACK path still works).

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-05 02:06:08 +00:00
Claude 08bb3e14e1 docs(quic): plan congestion control (NewReno per RFC 9002 §7)
Open plan, not started. Conservative scope:
- NewReno reference algorithm (RFC 9002 §7).
- Bytes-in-flight tracking + send-side gating.
- Persistent-congestion handling.
- ~14 unit + 2 integration tests, ~3-5 days work.

Out of scope (deferred follow-ups): pacing, CUBIC, BBR, ECN.

The Why section is honest that this is "good citizen" work, not
"fix a bug" work — the audio cliff was a stream-id / forwarding
issue, not a rate-control issue, and the retransmit subsystem we
just shipped is what production actually needed. Plan stays open as
the natural next item but should not be prioritised over field
validation of the retransmit work.

Reference: neqo's cc/classic_cc.rs.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-05 01:46:00 +00:00
Claude 2053f50f35 fix(quic): discard Initial/Handshake keys per RFC 9001 §4.9
Pre-fix `:quic` held Initial AND Handshake encryption-level state
indefinitely once derived. AEAD cipher state, per-level CRYPTO
buffers, and the per-level sent-packet map all stayed alive for the
lifetime of the connection — a real memory leak for long sessions
(audio rooms run for hours).

LevelState.discardKeys() (idempotent):
- Nulls sendProtection / receiveProtection (frees AEAD state).
- Replaces cryptoSend / cryptoReceive with empty instances.
- Replaces ackTracker with an empty instance.
- Clears sentPackets and resets largestAckedPn /
  largestAckedSentTimeMs.
- Latches keysDiscarded = true.

Hook locations:
- Initial discard (RFC 9001 §4.9.1, client side): in
  QuicConnectionWriter.drainOutbound, after a Handshake-level packet
  is built into the outbound datagram. The next drainOutbound MUST
  NOT touch the Initial level; any retransmitted Initial from the
  peer is silently dropped (receiveProtection == null), which is
  correct per the same RFC since the server has also moved up
  encryption levels by then.
- Handshake discard (RFC 9001 §4.9.2 + §4.1.2, client side): in
  QuicConnectionParser, on receipt of a HANDSHAKE_DONE frame.

Once a level's protection is null, parser-side decrypt at that level
returns null silently (existing receiveProtection == null check) and
writer-side build skips it (existing sendProtection == null check),
so no further code paths needed updating.

New test: KeyDiscardTest (4 cases — Initial keys discarded after
first Handshake packet, Handshake keys still live until
HANDSHAKE_DONE, Handshake keys discarded on HANDSHAKE_DONE,
discardKeys is idempotent).

Listed in the audit-summary deferred-work as item 3
(`No Initial / Handshake key discard`).

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-05 01:41:28 +00:00
Claude 70af1953dc docs(quic): record retransmit subsystem implementation status
Update the two affected plan docs now that RFC 9002 retransmit is
shipped on this branch.

quic/plans/2026-05-04-control-frame-retransmit.md:
- Mark plan as shipped 2026-05-05; list the 15 commits that landed it
  (plan + 9 steps + 5 follow-ups + perf + audit).
- Document what changed vs the original scope: the deferred follow-ups
  (STREAM data, CRYPTO, RESET_STREAM/STOP_SENDING/NEW_CONNECTION_ID)
  all shipped on top of the receive-flow-control core.
- Note the binary-search SendBuffer perf optimisation and the
  audit-driven first-call-wins fix.
- Update the cap-workaround status: initialMaxStreamsUni is back to
  10_000 (not the 1_000_000 mentioned in the original Why).

quic/plans/2026-04-26-quic-stack-status.md:
- Phase F downgraded from "partial" to "done (no CC)" — loss
  detection, RTT estimator, PTO, and per-frame retransmit shipped.
- Removed "no STREAM retransmit" / "SendBuffer doesn't retain bytes
  until ACK" from the deliberately-don't-do list (now do).
- Added congestion-control as the new deliberately-don't-do entry.
- Crossed out the corresponding deferred-work items; added congestion
  control as deferred item #8.
- Listed the new recovery test files in the test inventory.
- Linked to the retransmit plan + implementation log.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-05 01:15:29 +00:00
Claude c24630513f docs(quic): plan control-frame retransmit subsystem mirroring neqo
The shipping fix raises initialMaxStreamsUni to 1M to dodge the
moq-rs cliff that fired when our :quic emitted its first
MAX_STREAMS_UNI extension. That sidesteps the symptom but leaves
every ack-eliciting control frame one wire-loss away from a silent
stall (MAX_DATA, MAX_STREAM_DATA, RESET_STREAM, etc.). Browser
QUIC stacks (Firefox neqo and Chrome quiche, both verified by
reading source) implement RFC 9002 §6 loss detection + per-frame
retransmit; :quic does not.

Plan documents:
  - the architecture, mirroring neqo's typed-token shape (chosen
    over quiche's monotonic-control-frame-id deque on code-fit
    grounds — better match for :quic's existing sealed-class
    Frame / MoqLiteControl idioms)
  - file-by-file implementation order in 9 steps that each compile
    and test independently
  - inventory of ~50 tests to port from neqo (9 from fc.rs, ~20
    from recovery/mod.rs, ~10 connection-level, plus codec round
    trips). Each row links to the upstream neqo test by file:line
    and the equivalent Kotlin test name we'll add
  - explicit out-of-scope list (STREAM data retransmit, CRYPTO
    retransmit, congestion control, 0-RTT, multipath) — separate
    follow-ups
  - effort estimate (4–7 days) and acceptance criteria

References upstream code at /tmp/quic-refs/neqo (mozilla/neqo) and
/tmp/quic-refs/quiche (google/quiche), sparse-cloned for source
verification.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-04 22:25:34 +00:00
Claude 4338e5e6c4 docs(quic+nestsClient): post-implementation status + audio-rooms completion plan
Two new module-local plan docs (per CLAUDE.md's "plans live in the owning
module" rule) and a sweep of stale inline phase references.

quic/plans/2026-04-26-quic-stack-status.md:
  Post-mortem of the original docs/plans/2026-04-22 plan. Documents
  what shipped vs what was estimated, the actual package layout (~8.5k
  LoC, 39 test files, 5 audit rounds), the crypto delegation surface
  (Quartz only — no BouncyCastle, no JNI), interop verification status
  (aioquic + picoquic; nests not yet), and known deferred items
  (STREAM retransmit, Initial-key discard, etc.).

nestsClient/plans/2026-04-26-audio-rooms-completion.md:
  Punch list to ship audio rooms end-to-end:
    M1 Listener wire-up in Amethyst UI
    M2 Multi-speaker audience UX
    M3 Foreground service for backgrounded playback
    M4 Manual interop pass against nostrnests.com
    M5 MoQ publisher path (ANNOUNCE / TrackPublisher)
    M6 Capture → encode → publish pipeline
    M7 NestsSpeaker API
    M8 App polish (reconnect, leave cleanup)
    M9 Foreground service for speakers
  ~6 weeks for full audio rooms; ~2 weeks for listener-only MVP.

Inline doc cleanup:
  * Removed "Phase 3a/3c-1/3c-2/3c-3" / "Phase B/C/D-K/L" references
    from active code; replaced with "today" or pointers to the
    completion plan
  * Removed "Kwik-based stub" references; QuicWebTransportFactory and
    surrounding docs now describe :quic as the production path
  * TlsClient header reflects non-null certificateValidator + the
    JdkCertificateValidator / PermissiveCertificateValidator split
  * SendBuffer header documents the best-effort no-retransmit mode
    explicitly (was hidden behind a "Phase L will fix this" note)
  * MoqMessage / MoqObject / MoqSession reflect listener-side as
    shipped + publisher-side as Phase M5

CLAUDE.md:
  * Module list now includes :quic and :nestsClient (was 5 modules,
    now 7)
  * Architecture diagram + sharing philosophy explain what each new
    module owns

No production behaviour changes; doc + comment-only edits. Tests green.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
2026-04-26 01:38:48 +00:00