Commit Graph

13324 Commits

Author SHA1 Message Date
Claude c7a49d1679 fix(nestsclient): RESET_STREAM with typed code after SubscribeDrop body — moq-lite Lite-03
moq-lite Lite-03 conveys application-level errors on any stream via
`RESET_STREAM(application_error_code u32)` (audit M3). Pre-fix, our
publisher's two SubscribeDrop reply paths
(`BROADCAST_DOES_NOT_EXIST`, `TRACK_DOES_NOT_EXIST`) wrote the Drop
body and then `bidi.finish()` — a graceful FIN — which overlapped
with "publisher gracefully shut down." A subscriber watching only
the QUIC layer (no body decode) couldn't tell rejection from
shutdown.

Replace the trailing `bidi.finish()` with `bidi.reset(errorCode)`
in both Drop paths. The errorCode matches the body's `errorCode`
field so a peer that decodes the Drop sees the same number as one
that only sees the RESET_STREAM frame. The plumbing landed in the
prior commit (`feat(quic,nestsclient): plumb RESET_STREAM +
STOP_SENDING through WebTransport`).

Tests: existing `publisher_replies_subscribeDrop_when_*` regressions
gain an additional `lastPeerResetCode` assertion via
`FakeBidiStream`, locking the typed-error contract.

Audit doc: nestsClient/plans/2026-05-09-moq-lite-rfc-compliance.md (M3).

https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq
2026-05-09 14:34:39 +00:00
Claude 1c7b158e11 feat(quic,nestsclient): plumb RESET_STREAM + STOP_SENDING through WebTransport
Extends the WebTransport interface and its three implementations
(QUIC-backed, in-memory fake, peer-stripped demux) with typed
error-code cancel paths so moq-lite Lite-03's M2/M3 audit items can
land.

QUIC layer (`:quic`):
  - `StrippedWtStream` gains optional `reset` and `stopSending`
    closures, wired by `WtPeerStreamDemux.emitStripped` through
    `QuicStream.resetStream(errorCode)` /
    `QuicStream.stopSending(errorCode)` + `driver.wakeup()` so the
    frames actually leave the connection. `reset` is null on
    peer-initiated uni streams (no send half on our side);
    `stopSending` is unconditional (every surfaced stream has a
    read side).

WebTransport interface (`:nestsClient`):
  - `WebTransportReadStream.stopSending(errorCode: Long)` —
    suspending; first-call-wins.
  - `WebTransportWriteStream.reset(errorCode: Long)` —
    suspending; first-call-wins. Distinct from the existing
    graceful `finish()` / FIN.

Adapters:
  - `QuicBidiStreamAdapter`, `QuicReadStreamAdapter`,
    `QuicUniWriteStreamAdapter` route directly to the underlying
    `QuicStream` API.
  - `StrippedWtReadStreamAdapter` /
    `StrippedWtBidiStreamAdapter` route through the demux's
    closures; defensive `?:` fallbacks defend against a future
    demux bug that forgets to wire a closure.
  - `FakeBidiStream`, `FakeReadStream`, `ChannelWriteStream` (the
    in-memory test transport) record reset / stopSending codes
    in shared `AtomicLong` cells so tests can assert "the peer
    reset with code X" / "the listener stopSending with code Y"
    via new `lastResetCode` / `lastStopSendingCode` /
    `lastPeerResetCode` / `lastPeerStopSendingCode` properties.
    Sentinel `NO_CODE = Long.MIN_VALUE` distinguishes "not called"
    from a legitimate code 0.

Pure plumbing — no moq-lite call sites use the new methods yet.
Subsequent commits wire M3 (publisher Drop reply uses
reset(errorCode)) and M2 (listener group-cancel uses
stopSending(errorCode)).

Audit doc: nestsClient/plans/2026-05-09-moq-lite-rfc-compliance.md (M2 + M3).

https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq
2026-05-09 14:31:48 +00:00
Claude 20a3e9006d Merge remote-tracking branch 'origin/main' into claude/audit-moq-lite-compliance-NSuPk 2026-05-09 14:23:49 +00:00
Claude 25c52e6b65 docs(nestsclient): record M1/L5 fixes + M6 closure in moq-lite Lite-03 audit
Adds Fix #3 (M1 — trackPriority + sequence bit-pack matching kixelated
PriorityHandle), Fix #4 (L5 — fresh publisher list at dispatch
time), and the M6 closure (verified via WebFetch that Goaway has no
body schema in moq-lite Lite-03 — `rs/moq-lite/src/lite/{stream,
client}.rs`; our recognise/log/FIN handler is canonical) to the
compliance audit document.

Also marks L4 closed (subsumed by M1 — the saturating cast guard
collapsed into the per-track high byte logic).

Updates the audio-rooms completion-plan pointer with the new shipped
count: four publisher-side spec tightenings + one closure (M6) +
five 🟡 / 🟦 items explicitly deferred with rationale.

Remaining deferrals: M2 + M3 (need WebTransport/`:quic` interface
extension, locked per audit prompt), L1 (Lite-04 codec rewrite, no
relay forces it), L2 + L3 (no consumer for the API).

https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq
2026-05-09 14:21:08 +00:00
Claude b3f3ef59f5 fix(nestsclient): refresh publisher list per-dispatch on inbound bidi — moq-lite Lite-03
`handleInboundBidi` previously snapshotted the publisher list at the
TOP of the function (= bidi-arrival time) and used the snapshot for
the rest of the bidi's lifetime. A publisher registered between
bidi-open and the first inbound chunk would miss the dispatcher's
view, even though the publisher was already live in `activePublishers`
by the time we needed to dispatch.

Practical impact today: zero — both nests publishers (`audio/data`
and `catalog.json`) register from `MoqLiteNestsSpeaker.startBroadcasting`
before the relay's SUBSCRIBE bidi for either track lands. The
~few-ms gap is below the network round-trip floor.

But the contract is narrower if we read the list at first-byte time:
we then see every publisher that was registered up to the moment we
needed to make a routing decision. Closes the L5 row of the audit doc.

Move the publishers fetch from the function-top to inside the
`if (!dispatched)` block, after the control-type byte has been read.
On empty publisher list (the case we previously short-circuited at
function-top), we now FIN cleanly so the peer's wait resolves
instead of hanging on an idle bidi.

Audit doc: nestsClient/plans/2026-05-09-moq-lite-rfc-compliance.md (L5).

https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq
2026-05-09 14:17:52 +00:00
Claude 38df70504a fix(nestsclient): pack trackPriority + sequence into stream priority — moq-lite Lite-03 priority.rs
Per moq-lite Lite-03 (kixelated/moq `rs/moq-lite/src/lite/priority.rs`),
`Publisher::serve_group` calls `priority.insert(track.priority,
sequence)` and feeds the resulting position into `stream.set_priority`.
Priority sorts first by `track.priority u8` (higher track = drains
ahead under congestion), then by group `sequence` within a track
(newer = drains ahead).

Pre-fix we passed raw `sequence.toInt()` directly to setPriority,
ignoring the per-track byte. For our single-Opus-track production case
this was unobservable — newer-first ordering held by sequence
monotonicity — but a future multi-track broadcast (audio + companion
catalog / status track) would have starved the lower-rate track the
moment audio's outbound queue got congested.

Fix: add `trackPriority: Int = DEFAULT_TRACK_PRIORITY` parameter to
`MoqLiteSession.publish()`, store on PublisherStateImpl, and bit-pack
in `openGroupStream`:
  bits 31..24  trackPriority u8 (0..255)
  bits 23..0   sequence low 24 bits

The 24-bit sequence window is ample (≈ 6 days at 1 group/sec, beyond
which all newer groups within a single track tie — but they still
beat older groups of any LOWER-priority track via the top byte).
Production sessions cycle on JWT refresh every 9 min, so the wrap is
defensive only.

`DEFAULT_TRACK_PRIORITY = 0x80` matches the existing subscriber-side
DEFAULT_PRIORITY midpoint, so all existing call sites keep their
prior behavior.

Test seam: `FakeWebTransport.openUniStream` now records the most-
recent `setPriority` value via a shared `AtomicInteger` cell that the
peer-side `FakeReadStream` exposes as `lastSetPriority`. Lets the new
regression test verify the bit-pack formula on the actual peer-side
read stream rather than peeking into private state.

Regression test:
`MoqLiteSessionTest.publisher_packs_trackPriority_and_sequence_into_setPriority_value`.

Audit doc: nestsClient/plans/2026-05-09-moq-lite-rfc-compliance.md (M1).

https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq
2026-05-09 14:16:36 +00:00
Vitor Pamplona 6f32975c5c Merge pull request #2815 from vitorpamplona/claude/review-quic-rfc-compliance-YHkyV
RFC 9000/9001 compliance audit: connection lifecycle, flow control, AEAD limits
2026-05-09 10:06:36 -04:00
Claude 626521a6f2 docs(nestsclient): moq-lite Lite-03 compliance audit (2026-05-09)
Records the cross-spec compliance audit of the moq-lite Lite-03
implementation against kixelated's reference Rust impl
(github.com/kixelated/moq, rs/moq-lite/). Methodology mirrors the
recent QUIC RFC review: walked every Kotlin source under
`moq/lite/`, traced data flow speaker → relay → listener, and
cross-referenced each on-wire message + control byte against the
Rust reference.

Outcome: no 🔴 wire-incompatibilities found. Every codec primitive —
control type discriminators, AnnouncePlease, Announce, Subscribe
(incl. the `Option<u64>` off-by-one and `Duration` millis-as-varint
conventions), SubscribeOk/Drop with the type-outside-size-prefix
framing peculiar to Lite-03, GroupHeader, Probe — matches byte-for-
byte.

Six 🟡 (spec-loose / future-fragile) and four 🟦 (diagnostic /
observability) gaps documented:
  - M1 stream priority parity vs kixelated's PriorityHandle
    (single-track Opus impact: invisible)
  - M2 STOP_SENDING for single-group cancel not exposed
  - M3 RESET_STREAM with Error::to_code not used
  - M4 AnnouncePlease prefix-mismatch falsely emits Active — fixed
    in 6c2d7efc
  - M5 inbound Subscribe doesn't validate broadcast field — fixed
    in 3dcee2a4
  - M6 Goaway body / migration handler

Also references the new audit from `2026-04-26-audio-rooms-completion.md`
so future readers find it via the completion-plan pointer.

https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq
2026-05-09 13:53:06 +00:00
Claude 3dcee2a4e3 fix(nestsclient): reject inbound Subscribe whose broadcast doesn't match — moq-lite Lite-03 subscribe.rs
The publisher-side dispatcher in handleInboundBidi previously matched
inbound Subscribe bidis on `track` only, never checking whether the
requested `broadcast` field matched the suffix our publisher claimed
on this session. A peer (or buggy relay) could open a Subscribe under
broadcast="otherPubkey" track="audio/data" and we would route OUR
audio frames to them — the dispatcher only filtered on track.

Production never bit because moq-rs's relay routes Subscribe messages
to the specific publisher whose broadcast matches the requested path
upstream of us, so an off-target broadcast never landed on our
inbound bidi pump. But a peer-to-peer connection without a relay
in between would have been able to siphon our audio under any
broadcast string they invented.

The fix: validate `sub.broadcast == publisher.suffix` (both already
path-normalised by the codec) before track matching. On mismatch,
reply `SubscribeDrop(errorCode=BROADCAST_DOES_NOT_EXIST,
reasonPhrase="<requested> not published on this session
(we publish <ours>)")` and FIN. New error code
`MoqLiteSubscribeDropCode.BROADCAST_DOES_NOT_EXIST = 0x05L` mirrors
the IETF MoQ-transport `TRACK_NAMESPACE_DOES_NOT_EXIST` semantic so
a watcher can tell apart "wrong room" from "wrong rendition".

Regression test:
`MoqLiteSessionTest.publisher_replies_subscribeDrop_when_broadcast_does_not_match`.

Audit doc: nestsClient/plans/2026-05-09-moq-lite-rfc-compliance.md (M5).

https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq
2026-05-09 13:52:47 +00:00
Claude 6c2d7efccb fix(nestsclient): skip Active announce when AnnouncePlease prefix doesn't match — moq-lite Lite-03 announce.rs
Per moq-lite Lite-03 (kixelated/moq `rs/moq-lite/src/lite/announce.rs`),
a publisher MUST only emit `Announce(Active)` for broadcasts whose path
starts with the requested AnnouncePlease prefix. Pre-fix, when
`MoqLitePath.stripPrefix(please.prefix, ourSuffix)` returned `null`, we
fell through to `?: announcePublisher.suffix` and emitted Active under
our full suffix anyway, falsely advertising the broadcast under a
prefix the subscriber didn't request.

Production never bit because the relay always opens its announce bidi
to us with `prefix=""` (which always matches), but a future peer-to-
peer or namespace-scoped subscriber would have observed a ghost
broadcast under whatever prefix they asked about.

The fix: if `stripPrefix` returns `null`, FIN the bidi cleanly without
writing any Announce body. The subscriber sees an empty announce
stream and moves on.

Regression test:
`MoqLiteSessionTest.publisher_skips_announce_when_announce_please_prefix_does_not_match`.

Audit doc: nestsClient/plans/2026-05-09-moq-lite-rfc-compliance.md (M4).

https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq
2026-05-09 13:51:24 +00:00
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
David Kaspar 3a346968db Merge pull request #2813 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-09 14:55:08 +02:00
Crowdin Bot 17645fd66a New Crowdin translations by GitHub Action 2026-05-09 12:54:52 +00:00
Vitor Pamplona 9dd6ef078f Merge pull request #2810 from davotoula/feat/bottom-bar-favorite-algo-feeds
Add Favorite Feed Algorithms to bottom-bar catalogue
2026-05-09 08:54:11 -04:00
Vitor Pamplona 5fe0a05f29 Merge pull request #2812 from davotoula/fix/redact-debug-surfaces
fix: redact secrets and sensitive payloads from debug logs (Quartz/Amethyst)
2026-05-09 08:53:25 -04:00
David Kaspar 3a52f74fc6 Merge pull request #2811 from davotoula/feat/note-copy-raw-json
feat(note): copy raw JSON from note dropdown
2026-05-09 14:33:39 +02:00
davotoula a947834f31 amethyst:
- drop decrypted NIP-78 body from sync parse-failure log
- stop logging raw pref values on parse failure
2026-05-09 14:06:50 +02:00
davotoula 9a15a99b73 Quartz:
- Drop full event JSON from signature-verify failure logs
- Redact privKey in KeyPair.toString
2026-05-09 14:05:05 +02:00
davotoula b9a1165551 feat(note): copy raw JSON action in dropdown menu 2026-05-09 13:56:19 +02:00
David Kaspar 2479fef181 Merge pull request #2809 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-09 10:15:38 +02:00
Crowdin Bot 7e799f7deb New Crowdin translations by GitHub Action 2026-05-09 08:14:26 +00:00
davotoula 5dec9e665a refactor(amethyst): address Sonar code-smell warnings
- Merge nested if statements in marmot stores' delete() and in
  ZoomableContentDialog's MediaUrl* gating block (S1066).
- Replace `if (cond) false else x` with `!cond && x` in
  ImageVideoDescription's effectiveStripMetadata (S1125).
- Remove unused `nip95description` local in FileServerSelectionRow.

All changes are body-internal with identical behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:09:52 +02:00
davotoula b260c31995 Add Favorite Feed Algorithms to bottom-bar catalogue 2026-05-09 10:03:53 +02: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 50ea477426 fix(quic): 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 shaped like
a short-header packet whose trailing 16 bytes equal a
`stateless_reset_token` previously communicated by the peer.

Pre-fix the tokens were stored (via NEW_CONNECTION_ID into the
[PathValidator] pool, and via the peer's `statelessResetToken`
transport parameter) 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 packets toward our integrity counter.

Implementation:

  * `QuicConnection.isStatelessReset(datagram)` — short-header-form
    + size + trailing-16-bytes comparison. Matches against the peer's
    advertised token AND every unused entry in `pathValidator`'s
    pool. Constant-time per-token compare per §10.3.1 to avoid
    leaking token bits via timing.
  * `PathValidator.unusedTokenForSequence(seq)` — accessor for the
    above to walk the pool.
  * Parser pre-empts AEAD/HP parsing: `feedDatagramInner` checks every
    short-header-form datagram before the loop. False-positive rate
    of a real short-header packet (whose trailing 16 bytes are the
    AEAD tag) matching a known token is 2^-128, negligible in
    practice. Defense-in-depth: the AEAD-failure branch in
    `feedShortHeaderPacket` retains a redundant check for
    second-packet-of-coalesced edge cases.
  * On match, `markClosedExternally("stateless reset received from
    peer")` transitions silently to CLOSED — no CONNECTION_CLOSE
    emission per §10.3.1.

Limitation: tokens for an actively-used DCID we migrated to via
`PathValidator.tryStartValidation` are lost when the entry leaves
the unused pool. Acceptable for the audio-rooms scope (no migration);
documented in the kdoc.

Test: `StatelessResetDetectionTest` (5 cases) covers token-match
silent-close, unknown-trailer no-close, pool-stored token match,
long-header form rejected, too-short datagram rejected.

https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik
2026-05-09 05:00:23 +00:00
Claude 4ec192347e fix(quic): RFC 9001 §6.6 AEAD invocation limit tracking
Per-key AEAD usage limits per RFC 9001 §6.6 / §B.1:

  * Confidentiality limit (encrypt count per send key):
    AES-128-GCM = 2^23, ChaCha20-Poly1305 = 2^62. Reaching this means
    AEAD security is no longer assured; the endpoint MUST initiate a
    key update or close.
  * Integrity limit (forged-packet count per receive key):
    AES-128-GCM = 2^52, ChaCha20-Poly1305 = 2^36. Reaching this means
    an attacker has been grinding for AEAD key recovery; MUST close
    with AEAD_LIMIT_REACHED.

Pre-fix neither limit was tracked. Long-running AES-128-GCM sessions
(~2hrs at 1000pkt/s) could roll past the confidentiality limit; an
attacker spamming forged packets had no failure ceiling.

Implementation:

  * `Aead.confidentialityLimit` / `Aead.integrityLimit` properties
    surface the spec values per cipher; AES-128-GCM and
    ChaCha20-Poly1305 (commonMain singletons + jvmAndroid JCA classes)
    return the §B.1 numbers.
  * `QuicConnection.aeadEncryptCount` / `aeadDecryptFailureCount` —
    per-key counters, reset on every 1-RTT key rotation
    (`commitKeyUpdate` and `initiateKeyUpdate`).
  * Writer increments encrypt count after each application-level
    build. At half the limit it soft-triggers `initiateKeyUpdate`
    (latched via `aeadKeyUpdateRequested` so the in-flight rotation
    isn't re-issued); at the limit it closes with AEAD_LIMIT_REACHED
    if rotation hasn't completed.
  * Parser increments decrypt-failure count on every 1-RTT AEAD
    auth-tag failure; closes when the count hits the integrity limit.

Initial / Handshake levels are out of scope — their keys' lifetime is
too short to approach the limit.

Test: `AeadInvocationLimitTest` (6 cases) covers spec-value
verification, counter increment on application send, counter reset on
rotation, confidentiality-limit close, integrity-limit close (via a
real ciphertext-tampered datagram from the in-process pipe).

https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik
2026-05-09 04:56:20 +00:00
Claude be96d4b28f fix(quic): RFC 9000 §3.2 stream state — STREAM after RESET_STREAM rejected
Once the peer has sent RESET_STREAM the receive side enters the "Reset
Recvd" terminal state per §3.2; any subsequent STREAM frame on that id
is a peer protocol violation and MUST close the connection with
STREAM_STATE_ERROR.

Pre-fix the parser silently absorbed the bytes — a peer that violated
the spec would leave us with a phantom mid-reset stream the peer
believed was already dead, with reset/byte bookkeeping diverging.

Implementation: a per-stream `peerResetReceived: Boolean` flag set in
the RESET_STREAM handler and checked at the top of the STREAM handler.
The flag is independent of our local-side `resetState` (which tracks
OUR RESET emission).

Test: `StreamAfterResetTest` (4 cases) covers STREAM-after-reset
closure, FIN-bearing STREAM-after-reset, the legal FIN-then-RESET
shape (no false-positive), and per-stream isolation (reset on stream 3
doesn't poison stream 7).

https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik
2026-05-09 04:46:49 +00:00
Claude 6e054583a9 fix(quic): RFC 9000 §4.1 connection-level inbound flow-control enforcement
Pre-fix the receiver enforced ONLY per-stream `receiveLimit`. The
aggregate cap (`initial_max_data` / latest 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 up to its per-stream cap
could collectively exceed `initial_max_data` without us closing.

Implementation:

  * `QuicStream.receiveHighestOffset`: per-stream high-water mark of
    "largest stream offset received" — the spec quantity that gets
    summed across streams for the connection-level check.
  * `QuicConnection.connectionInboundOffsetSum`: running sum across
    all streams of `receiveHighestOffset`. Updated in the parser at
    STREAM and RESET_STREAM frame ingest time.
  * Parser closes with FLOW_CONTROL_ERROR whenever the running sum
    exceeds `advertisedMaxData`.
  * RESET_STREAM final-size accounting: per §4.5 the finalSize counts
    toward connection-level flow control even though no STREAM frame
    delivered those bytes — a peer that resets a stream at a finalSize
    larger than it had previously sent gets the extra bytes added to
    the running sum at RESET arrival.

Different from the writer's MAX_DATA threshold logic (which uses the
cheaper contiguous-end approximation): the receiver-side enforcement
needs the strict spec quantity to close before bookkeeping diverges.

Test: `ConnectionLevelFlowControlTest` (5 cases) covers below-cap pass,
over-cap close, retransmit-doesn't-double-count, RESET_STREAM
finalSize counts toward limit, fresh-handshake invariants.

https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik
2026-05-09 04:44:04 +00:00
Claude 657306392d fix(quic): RFC 9000 §18.2 transport-param bounds + §13.2.5 ack-delay-exponent + §7.2.4.1 reserved SETTINGS + RFC 9221 §3 outbound DATAGRAM size
Five spec-compliance fixes from the 2026-05-09 audit, all in the same
"hostile-peer hardening" tier.

  * RFC 9000 §18.2 — `applyPeerTransportParameters` now closes the
    connection with TRANSPORT_PARAMETER_ERROR when the peer advertises
    `max_udp_payload_size < 1200`, `ack_delay_exponent > 20`, or
    `active_connection_id_limit < 2`. Pre-fix the values were decoded
    but never bounds-checked.

  * RFC 9000 §13.2.5 — the parser's ACK-delay decoding now uses the
    PEER's advertised `ack_delay_exponent` (with the §18.2 default of 3
    as the pre-handshake fallback) instead of our own config value.
    Defensive coercion to 0..20 is preserved so a bypass of the bounds
    check above can't desync the RTT estimator.

  * RFC 9114 §7.2.4.1 — `Http3Settings.decodeBody` now rejects the
    HTTP/2-reserved SETTINGS ids 0x02, 0x03, 0x04, 0x05 with
    H3_SETTINGS_ERROR. Pre-fix they fell through to the generic
    1<<32 cap and were accepted.

  * RFC 9221 §3 — the writer now drops outbound DATAGRAM frames when
    the peer didn't advertise `max_datagram_frame_size` (or advertised
    0), or when the encoded frame (type byte + length varint + payload)
    would exceed the peer's advertised value. Pre-fix the writer
    emitted DATAGRAM regardless and let spec-conformant peers close us
    with PROTOCOL_VIOLATION.

  * Added `TransportParameterDefaults` for the §18.2 defaults
    (ack_delay_exponent=3, max_ack_delay=25ms, active_connection_id_limit=2)
    so callers don't hard-code the magic numbers.

Tests: `TransportParameterBoundsTest` (8 cases) drives a real handshake
through the in-process pipe and asserts CLOSED on each violation +
CONNECTED at the boundary; `Http3ReservedSettingsTest` (5 cases) covers
the four reserved ids and the adjacent legal ids. Plan tier dropped
from 🟡 Medium to resolved for all five items.

https://claude.ai/code/session_01XGmmaVuy2wsSZQx2AqN2ik
2026-05-09 04:37:50 +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
Vitor Pamplona 2a9b5bc17d Merge pull request #2808 from vitorpamplona/claude/review-quic-blocking-code-autOK
perf(quic): lock-free hot paths — ThreadLocal Cipher, AtomicReference close, @Volatile getters
2026-05-08 23:19:37 -04:00
Claude 2bb55ff9ec perf(quic): drop synchronized from QuicStream + replace close() polling with CompletableDeferred
Round 2 of the blocking-code audit follow-ups. Both changes remove
commonMain synchronized blocks and replace polling with event-driven
suspension; :quic:jvmTest stays green on JVM and :quic:compileAndroidMain
passes for Android.

QuicStream.resetStream / stopSending
  - Replace synchronized(this) double-checked init with
    AtomicReference<ResetState?>.compareAndSet(null, …) (and same for
    stopSendingState). The pattern is "first call wins"; CAS expresses
    that directly without a lock.
  - Backing fields converted from `internal var … = null` to
    `internal val … = AtomicReference(null)`. The two readers in
    QuicConnectionWriter switch to .load(); QuicConnectionWriter gains a
    file-level @OptIn(ExperimentalAtomicApi::class).
  - The @Volatile resetEmitPending / stopSendingEmitPending writes happen
    only on the winning CAS path, so the writer reading the flag still
    sees the populated state via volatile happens-before.

QuicConnectionDriver.close polling loop
  - Replace `while (status == CLOSING) delay(1)` polling with a
    CompletableDeferred<Unit> on QuicConnection (closingDrainSignal).
    The deferred is completed at both transitions to CLOSED:
    (1) drainOutbound after building the CONNECTION_CLOSE datagram, and
    (2) markClosedExternally on its synchronized first-call-wins path.
  - close() now awaits the deferred under the existing
    CLOSE_FLUSH_TIMEOUT_MILLIS bound — same upper-bound semantics, no
    1 ms wakeups during teardown. complete(Unit) is idempotent, so the
    two transition sites racing each other is safe.

https://claude.ai/code/session_01CXTjnuHKCNXDmpfyKxgG3V
2026-05-09 03:13:57 +00:00
Claude 0e4b12654d perf(quic): lock-free hot paths — ThreadLocal Cipher, AtomicReference close, @Volatile getters
Audit of blocking and synchronized code in the QUIC module surfaced four
hot-path wins, all verified against :quic:jvmTest.

- PlatformCrypto: header-protection AES-ECB now uses a per-thread cached
  Cipher. Previously every inbound and outbound packet paid for
  Cipher.getInstance("AES/ECB/NoPadding") provider lookup. ThreadLocal is
  safe because the call is stateless — every invocation re-init's with the
  caller-supplied key.
- JdkCertificateValidator: gate the SAN-side InetAddress.getByName behind
  looksLikeIpLiteral so a malformed cert with a hostname in a type 7 SAN
  cannot trigger DNS resolution on the TLS validation path.
- QuicConnectionDriver.close: replace synchronized(this) double-checked
  init with AtomicReference<Job?> + compareAndSet on a CoroutineStart.LAZY
  job. Lock-free, removes a synchronized from commonMain, preserves the
  original "first caller wins, second awaits same Job" contract.
- SendBuffer: mark _nextOffset, nextSendOffset, _finPending, _finSent,
  _finAcked @Volatile and drop synchronized from their single-field
  getters (nextOffset, sentOffset, finPending, finSent, finAcked). The
  compound-formula readableBytes getter still synchronizes.

https://claude.ai/code/session_01CXTjnuHKCNXDmpfyKxgG3V
2026-05-09 03:13:57 +00:00
Vitor Pamplona 9ec4f04bd7 Merge pull request #2804 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-08 21:43:09 -04:00
Vitor Pamplona ba6eaa1385 Merge pull request #2807 from vitorpamplona/claude/fix-kdoc-lint-errors-c6Jd2
Clean up documentation and organize constant definitions
2026-05-08 21:43:01 -04:00
Claude 2be76c898c fix(quic): resolve dangling KDoc lint errors
Move the feedDatagram KDoc below the MAX_QUIC_OFFSET const so it
attaches to the function declaration, and merge the duplicate KDoc
blocks above TlsResumptionState into a single block.
2026-05-09 01:40:48 +00:00
Vitor Pamplona 5c50e54569 Merge pull request #2806 from vitorpamplona/claude/quic-followups-round10-13
QUIC: audit closeout — TLS PSK fallback, AEAD allocation, PSL subset, ECN
2026-05-08 21:36:33 -04:00
Claude df6103ffdd fix(quic): PSL subset, JCA ChaCha20-Poly1305, truthful ECN reporting
Round 13 — three architectural follow-ups + a closeout note.

* JdkCertificateValidator: ship a hand-picked Public Suffix List
  subset covering the high-volume multi-label effective-TLDs
  (multi-tenant ccTLDs and major hosting platforms). Pre-fix the
  dot-count heuristic accepted `*.co.uk`, `*.s3.amazonaws.com`,
  `*.github.io`, etc. — wildcards spanning these would impersonate
  every co-tenant. The new MULTI_LABEL_PUBLIC_SUFFIXES set adds a
  layer above the dot-count check; combined with the WebPKI / CT
  ecosystem already requiring CAs to consult the full PSL when
  issuing, this closes the practical attack surfaces. Full
  ~9000-entry PSL data shipping is still deferred (data-shipping
  ask, doc'd); a domain not in the subset that's also a multi-label
  ETLD remains a gap.

* JcaChaCha20Poly1305Aead: new JCA-backed implementation mirroring
  JcaAesGcmAead's shape (cached Cipher + SecretKeySpec, range
  overloads via Cipher.doFinal(input, off, len, output, outOff),
  recent-nonce history for legitimate IV reuse on the
  Initial-padding rebuild path). bestChaCha20Poly1305Aead(key)
  expect/actual factory tries the JCA path first (Java 11+ /
  Android API 28+) and falls back to the pure-Kotlin
  ChaCha20Poly1305Aead singleton if the algorithm isn't available
  (older Android, headless GraalVM native-image without the
  standard providers). PacketProtectionBuilder routes the
  ChaCha20-Poly1305 cipher suite through the factory instead of
  the singleton. On supporting platforms this gives the same
  outbound-allocation savings as round 8's AES-GCM range overload.

* QuicConnectionWriter: stop emitting fake ECN counts on ACK
  frames. Pre-fix every 1-RTT ACK carried `AckEcnCounts(0, 0, 0)`
  — claiming to track ECN while actually never reading inbound TOS
  bits. RFC 9000 §13.4.2: "An endpoint that uses ECN MUST report
  accurate ECN counts." Hardcoded zeros could be flagged as a
  PROTOCOL_VIOLATION by strict peers cross-validating against
  outbound packet counts; aioquic / picoquic / quic-go tolerate
  it but other stacks may not. With ecnCounts = null we honestly
  advertise "this endpoint isn't reporting ECN", peer skips its
  own ECN-driven congestion logic for our direction. We still
  mark outbound ECT(0) (other peers' tracking benefits from the
  path-quality signal); RFC 9000 §13.4 allows the asymmetry.

* MutableSharedFlow migration for QuicStream.incoming declined as
  obviated. The audit's suggestion was a workaround for the
  cancel-coupling specifically (collector cancel → channel cancel
  → INTERNAL_ERROR) — round 11's `flow { for (c in
  incomingChannel) emit(c) }` wrapper solved that. Switching to
  MutableSharedFlow would change the semantics from "each byte to
  exactly one consumer" (correct for stream bytes) to fan-out
  (every emission to all collectors), which is wrong for QUIC
  stream data.

All 269 :quic:jvmTest tests pass. BUILD SUCCESSFUL in 40s.

https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM
2026-05-09 01:06:32 +00:00
Claude 953869714b fix(quic): visibility, scratch caching, retransmit coalescing, secret hygiene
Round 12 — six smaller follow-ups across visibility, perf, and secret-handling.

* QuicStream.receiveDirtyForFlowControl gains @Volatile. The parser's
  read-loop writes the flag and the writer's send-loop reads it
  WITHOUT holding the same lock; without volatile the writer could
  miss the parser's update for an unbounded time on JVM (the field is
  hot in a drain loop where the JIT might cache it), suppressing
  MAX_STREAM_DATA emissions until something else triggered a fresh
  load.

* SendBuffer.readableBytes runs in O(1) instead of O(R) by maintaining
  a cached `retransmitTotalBytes` counter. Updated in lockstep with
  every retransmit deque mutation: addLast in [requeueAllInflight] +
  the two paths in [removeOverlap] (RETRANSMIT zero-length + main
  range), and add/removeFirst in [takeChunk]. Pre-flight "anything to
  send?" check on the writer's hot path was previously walking the
  deque per-stream per-drain.

* SendBuffer.requeueAllInflight coalesces adjacent ranges on insert.
  Pre-fix the PTO probe path appended each in-flight range as a
  separate retransmit entry, so on the next drain takeChunk emitted
  one tiny STREAM frame per original-packet boundary. With
  coalescing, contiguous bytes get replayed as one chunk + one AEAD
  seal. FIN-bearing ranges stay separate (merging across a FIN
  changes the implicit final-size invariant).

* TlsResumptionState dropped `data class`. The auto-generated
  equals/hashCode used reference equality on its ByteArray fields
  (PSK / ticket / peerTransportParameters), so two byte-identical
  states compared unequal — almost never useful and a footgun for
  caller-side caches. The auto-toString would dump PSK contents into
  any log. Replaced with hand-written equals/hashCode using
  contentEquals on the byte fields and a redacted toString that
  reveals only sizes.

* QuicConnectionParser RESET_STREAM handler bounds finalSize at
  [0, 2^62-1] per RFC 9000 §16 (the QUIC offset ceiling). Pre-fix
  we accepted any varint, including values that could overflow
  downstream Long math.

* PathChallenge/PathResponse IAE leak: re-traced and verified
  non-issue. The decoder calls `r.readBytes(8)`, which either throws
  QuicCodecException (short read) or returns exactly 8 bytes — the
  constructor's `require(data.size == 8)` is unreachable from the
  decode path. The audit was over-cautious; no code change.

All 269 :quic:jvmTest tests pass. BUILD SUCCESSFUL in 39s.

https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM
2026-05-09 00:59:28 +00:00
Claude e7b7d99582 perf(quic): outbound AEAD allocation, scratch reuse, sort skip + key-update + flow
Round 11 — six follow-ups across perf, correctness, and API hygiene.

* QuicStream.incoming: replace `consumeAsFlow()` with `flow { for (c in
  incomingChannel) emit(c) }`. Pre-fix the consume-style flow
  cancelled the underlying Channel when the collector terminated,
  which coupled "application stopped reading" with "parser
  INTERNAL_ERRORs the connection on next delivery". The new emit-only
  flow leaves the channel open across collector cancellation, so
  applications can stop reading temporarily and resume later
  (sequential collects, not concurrent — that's still a race on the
  channel iterator).

* RFC 9001 §6.1 client-initiated key update: the existing
  [QuicConnection.initiateKeyUpdate] no longer requires the caller
  to enforce spec invariants. Returns false if the handshake isn't
  yet complete (§6.5: MUST NOT initiate before HANDSHAKE_DONE) or if
  a previous rotation is still in flight (§6.4: MUST NOT initiate a
  subsequent update until the previous one is confirmed). The parser
  clears the [keyUpdateInProgress] flag on the first inbound packet
  that AEAD-decrypts under the post-rotation live keys — the
  confirmation signal that the peer has rolled forward.

* Aead.sealInto: new range + in-place seal that writes ciphertext+tag
  DIRECTLY into a caller-supplied output buffer at a given offset.
  Default impl falls back to sealRange + copy; JcaAesGcmAead overrides
  to use Cipher.doFinal(input, inOff, inLen, output, outOff).
  LongHeaderPacket.build / ShortHeaderPacket.build now pre-allocate
  the final packet buffer in a single shot and have the AEAD write
  ciphertext+tag in-place. Pre-fix every outbound packet allocated
  4 ByteArrays (headerBytes, paddedPlaintext, ciphertext, concat
  buffer); now ~2 (the final packet + the AEAD provider's internal
  scratch).

* QuicConnectionWriter.drainOutbound: skip the
  `active.sortedByDescending { priority }` allocation when EVERY
  active stream shares the same priority — not just the
  default-zero case. A homogeneous priority-7 workload now keeps
  insertion order at no cost.

* QuicConnection.scratchAppFrames / scratchAppTokens: per-connection
  reusable lists for buildApplicationPacket. Cleared at function
  entry, re-used across drains under streamsLock's single-writer
  guarantee. The tokens list is `.toList()`-snapshotted into the
  SentPacket record before reuse, so retransmit dispatch is
  unaffected. NOT applied to collectHandshakeLevelFrames because
  its returned [HandshakeLevelContents] is held across two
  buildLongHeaderFromFrames calls (natural-size + padded rebuild)
  in drainOutbound.

* Removed dead `parts = mutableListOf<ByteArray>()` declaration at
  the top of drainOutbound — never referenced; the actual datagram
  assembly uses inline `listOfNotNull(...)` instead.

All 269 :quic:jvmTest tests pass. BUILD SUCCESSFUL in 43s.

https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM
2026-05-09 00:50:36 +00:00
Claude b097580fdd fix(quic): TLS PSK rejection — recover in-place instead of failing
Round 10 — supersedes the typed-exception approach from round 9.

Round 9 introduced [PskRejectedException] as a "punt to the application
layer" hack, with a comment claiming the in-place fallback was too
subtle to land safely. After tracing the actual derivation path the
fix turns out to be one line.

The key observation: the on-wire ClientHello (with `pre_shared_key`
extension and binder bytes) goes into BOTH client and server
transcript hashes regardless of accept / reject (RFC 8446 §4.2.11).
The transcript hash itself doesn't need any rebuild. The ONLY thing
that differs between accept and reject is how [earlySecret] is
derived:

  * accepted: HKDF-Extract(0, PSK)
  * rejected: HKDF-Extract(0, 0)  ← same as no-resumption path

So when the server returns ServerHello without `pre_shared_key`,
we simply call `keySchedule.deriveEarly()` to overwrite the
PSK-derived [earlySecret] with the zero-keyed value. [deriveHandshake]
runs immediately after with the new earlySecret + ECDHE shared
secret, and the handshake proceeds along the regular non-resumption
path (which is well-tested by every non-resumption test in the
suite).

* `pskAccepted = false` so the
  WAITING_CERTIFICATE_OR_FINISHED state correctly demands
  Certificate + CertificateVerify (a Finished without those would
  still be rejected as unauthenticated).
* Any 0-RTT packets the application emitted under the
  PSK-derived [clientEarlyTrafficSecret] are lost — server can't
  decrypt them and EncryptedExtensions arrives without the
  early_data extension, so [earlyDataAccepted] = false. The
  application layer is responsible for replaying any 0-RTT-bound
  payload over 1-RTT. The handshake itself proceeds cleanly.
* [PskRejectedException] (added in round 9) deleted as dead code.
  [QuicCodecException] reverts to a `final` class.

All 269 :quic:jvmTest tests pass. The fallback re-uses the
deriveEarly() codepath that every non-resumption test exercises,
so test coverage is implicit in the existing suite.

https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM
2026-05-09 00:33:09 +00:00
Vitor Pamplona c7ae683a5c Merge pull request #2805 from vitorpamplona/claude/quic-followups-round7-9
QUIC: audit follow-ups — RFC 9002 loss timer, AEAD allocation, SETTINGS validation
2026-05-08 20:27:33 -04:00
Claude 28c1a355da fix(quic): RFC 9002 §6.1.2 loss-detection timer + PSK rejection signal
Round 9 — the two largest items remaining from the audit.

* RFC 9002 §6.1.2 timer-driven loss detection. [detectAndRemoveLost]
  now returns a [Result] data class carrying both the list of lost
  packets and the absolute monotonic deadline at which the next
  earliest sub-largest in-flight packet will cross the time threshold.
  The parser stores that deadline on each [LevelState.nextLossTimeMs]
  per encryption level, and the driver's send loop now sleeps for
  `min(ptoDeadline, minNextLossTimeAcrossLevels) - now`. On expiry,
  the driver distinguishes:
   * Loss-timer wake → run [detectAndRemoveLost] across all levels;
     declare time-threshold-lost packets and dispatch their tokens.
     No probe budget, no exponential backoff.
   * PTO wake → existing [handlePtoFired] path (probe + backoff).
  Pre-fix tail loss waited for the full PTO (often 5x the time
  threshold) before retransmitting because we had no event between
  ACK arrivals to fire loss detection. Now `9/8 * max_rtt` is the
  ceiling.

* TLS PSK rejection: instead of the prior generic [QuicCodecException]
  ("server rejected PSK; full-handshake fallback not implemented"),
  raise a typed [PskRejectedException] subclass. Application reconnect
  logic can selectively catch this and retry the handshake without
  cached resumption state — a path that's correct by construction
  (fresh ClientHello, no PSK history, no transcript-rebuild
  complexity). [QuicCodecException] is now `open` so the subclass
  can extend it.

  In-place fallback (clear early secret, rebuild transcript without
  PSK extension, replay derivation) remains deferred — the subtle
  transcript-hash discrepancies it could introduce would be much
  harder to debug than a hard failure that the application
  intentionally turns into a retry.

All 269 :quic:jvmTest tests pass. BUILD SUCCESSFUL in 47s.

https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM
2026-05-09 00:23:37 +00:00
Claude 90f59a3351 perf(quic): AEAD range overload + doc notes for known fragile couplings
Round 8 — AEAD allocation reduction on the receive hot path, plus the
documentation of two known-fragile-but-not-broken couplings.

* Aead gains [openRange] / [sealRange] taking (array, offset, length)
  triples for AAD and plaintext/ciphertext. Default impls slice and
  delegate to the existing whole-array methods so non-JCA AEADs
  (Aes128Gcm singleton, ChaCha20Poly1305Aead) still work. The
  JCA-backed [JcaAesGcmAead] overrides both, passing offsets straight
  to `Cipher.updateAAD(byte[], offset, len)` and
  `Cipher.doFinal(byte[], inputOffset, inputLen)` — JCA accepts ranges
  natively and does no internal copies. Saves ~2 KB ByteArray
  allocations per inbound packet (one for the header `aad`, one for
  the `ciphertext`) — at audio-room receive rates (~100 packets/sec)
  that's ~12 MB/min of GC churn eliminated. Same overload on the
  outbound path is wired but currently exercised less because the
  build path constructs `headerBytes` and `paddedPlaintext` as
  separate fresh allocations.

  parseAndDecrypt in both ShortHeaderPacket and LongHeaderPacket now
  call openRange instead of slicing into intermediates.

* JdkCertificateValidator.dnsMatches: explicit doc note on the
  public-suffix-list gap. The dot-count heuristic accepts wildcards
  like `*.co.uk` / `*.github.io` / `*.s3.amazonaws.com` whose effective
  TLD spans multiple labels. WebPKI / CT logging mitigates this in
  practice (CAs validate against the actual PSL), but our local
  validation alone wouldn't catch a rogue cert. Production callers
  for sensitive endpoints should rely on OS / NetworkSecurityConfig
  pinning rather than QUIC's hostname check alone. Full PSL data
  shipping deferred until justified.

* QuicStream.incoming: explicit doc note on the single-collector
  contract. [consumeAsFlow] cancels the underlying [Channel] when
  its collector terminates, and once cancelled the parser's next
  trySend fails, sets [overflowed] = true, and the parser tears down
  the connection with INTERNAL_ERROR. Production callers already
  follow single-collector + collect-until-FIN, but the doc lays out
  the rule so a future refactor doesn't loosen it accidentally.
  Long-form discussion of the `MutableSharedFlow` alternative and
  why we declined it.

All 269 :quic:jvmTest tests pass. BUILD SUCCESSFUL in 40s.

https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM
2026-05-09 00:17:19 +00:00
Claude f987b3dfe2 fix(quic): SETTINGS validation, sensitive headers, peer-stream count, drain alloc
Round 7 — closing out the audit's spec/correctness/perf quick wins.

* WtPeerStreamDemux: validate peer SETTINGS includes ENABLE_WEBTRANSPORT=1
  AND ENABLE_CONNECT_PROTOCOL=1 (draft-ietf-webtrans-http3 §3 +
  RFC 8441). Pre-fix we accepted any SETTINGS and proceeded to
  Extended CONNECT, which the server then rejects with no
  diagnostic for the application. Now surfaces as a typed protocol
  error on peerH3ProtocolError so the QUIC layer closes deliberately.

* QpackEncoder: set N=1 (never-indexed) on literal field lines for
  authorization / cookie / set-cookie / proxy-authorization per
  RFC 9204 §4.5.4. Pre-fix sensitive credentials could be cached
  by intermediate QPACK encoder caches.

* QuicConnection.getOrCreatePeerStreamLocked: track peerInitiated*Count
  via max(current, peerIndex+1) instead of += 1. The counter now
  derives from the stream id's index field (RFC 9000 §2.1) and is
  idempotent against retransmits-after-eviction. Pre-fix a peer
  retransmit on a stream id that aged out of the retired-IDs FIFO
  bumped the counter again, eventually triggering spurious
  MAX_STREAMS_* emissions.

* applyPeerRetireConnectionIdLocked: reclassify the close-on-seq=0
  case as PROTOCOL_VIOLATION (peer fault) instead of INTERNAL_ERROR
  (our fault). The diagnostic was misleading — the peer IS
  misbehaving (asking us to retire our only SCID with no
  replacement available), not us.

* QuicConnectionWriter.drainOutbound: skip the
  `streamsView.filter { !it.isClosed }` allocation when no streams
  are closed. Quick `any` scan first; only allocate the filtered
  list when at least one stream is actually closed. Saves an
  N-sized ArrayList per drain in the common case (~50 drains/sec
  × N up to ~2000 streams under multiplex load).

All 269 :quic:jvmTest tests pass. BUILD SUCCESSFUL in 40s.

https://claude.ai/code/session_01AhGvbMV8uPRse3TmAGaddM
2026-05-09 00:14:07 +00:00
Crowdin Bot acfcb550af New Crowdin translations by GitHub Action 2026-05-09 00:13:22 +00:00
Vitor Pamplona 004e39f9cb Merge pull request #2803 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-08 20:12:17 -04:00
Crowdin Bot 58fe3f892e New Crowdin translations by GitHub Action 2026-05-09 00:12:03 +00:00