diff --git a/REVIEW.md b/REVIEW.md index 5673fe1078d..55ca4bd542c 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -22,6 +22,7 @@ Assume a better approach exists until you have tried to find one: - **Layer**: does the logic live where it belongs (library vs runtime adapter, application vs relay, moq-lite model vs IETF layer, our crate vs upstream)? Does a package boundary leak? - **Public API and wire**: would we want to support this shape for years, in every language? Look for foot-guns, names tied to today's implementation, a new surface that could merge into an existing one, and exports nothing needs yet. Is every API or wire change listed under the body's Impact, with breaking ones marked `!`? - **Completeness**: does it fix every instance of the problem and the gaps it exposes, or land a partial version plus follow-ups? +- **Bounds**: can a peer grow a queue or map here? It should hold the latest state per key, or be bounded by credit the peer already spent, never one entry per event. - **Evidence**: is a performance claim measured, a bug reproduced, and is each new test worth running forever? An approach finding names the alternative and why it holds up better long-term. diff --git a/quest/m0/README.md b/quest/m0/README.md index 0231c3a98d6..1a9574e7971 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -28,8 +28,8 @@ edge, which embeds moq-relay; it moved there on 2026-09-28. - [Nightly passes on main](/quest/m0/nightly/README.md) - the scheduled Nightly passes end to end, so red means a new break - [Interop and Video pass on main](/quest/m0/interop-green/README.md) - the scheduled Interop and Video workflows pass, so red means a new break -- [JS qmux keeps credit and final sizes consistent with Rust](/quest/m0/qmux-js-credit.md) - a JS WebSocket session no longer stalls on cancelled streams or gets closed by a Rust peer with FINAL_SIZE_ERROR, and its control queue stays bounded -- [Peer and config values never panic or wrap moq-net arithmetic](/quest/m0/peer-arithmetic.md) - a peer's retry interval, object id, drop end or sequence, and huge configured durations no longer panic a task or wrap +- [JS qmux streams follow QUIC's per-stream state machine](/quest/m0/qmux-js-credit.md) - credit changes only in stream state transitions, so a JS WebSocket session cannot stall on cancelled streams or send a short final size that a Rust peer refuses +- [kio's Instant has no panicking arithmetic, and peer values never wrap](/quest/m0/peer-arithmetic.md) - every deadline states what overflow means, so a peer's retry interval or a huge configured duration cannot panic a task - [Publish the release line's drain() wrappers](/quest/m0/ffi-shutdown-backport.md) - Dart `moq` 0.1.1 reaches pub.dev from `release` - [Release's audio encoder rolls groups before the cap](/quest/m0/release-audio-group-cap.md) - a long `groupDuration` with short frames never throws `GroupTooLarge` on `release` - [CI cost and latency](/quest/m0/ci/README.md) - cloud CI runs only in GitHub's merge queue, one tested entry at a time, expensive checks run nightly and gate `release`, caches move to R2, and runners are chosen last diff --git a/quest/m0/peer-arithmetic.md b/quest/m0/peer-arithmetic.md index 0bcf0f34f4f..71fa115dbd9 100644 --- a/quest/m0/peer-arithmetic.md +++ b/quest/m0/peer-arithmetic.md @@ -1,16 +1,15 @@ -# [S] Peer and config values never panic or wrap moq-net arithmetic +# [M] kio's Instant has no panicking arithmetic, and peer values never wrap ## Goal -No value a peer sends or an application configures panics a moq-net task or silently wraps: +No value a peer sends or an application configures can panic a moq task or silently wrap: -- `rs/moq-net/src/ietf/publisher.rs` `refusal`: a REQUEST_ERROR Retry Interval near `u64::MAX` panics through kio's `Instant + Duration`. Any peer we send PUBLISH_NAMESPACE to can trigger it. -- `rs/moq-net/src/ietf/publisher.rs` `save_join`: `largest.object + 1` on an upstream's LARGEST_OBJECT of `u64::MAX`. -- `rs/moq-net/src/reconnect.rs`: `now + duration` and `mul_f64` on `Goaway.handover`, `Config::timeout` and `Backoff` panic for huge configured values once a GOAWAY arrives; `Server::deadline` already treats overflow as no deadline. -- `rs/moq-net/src/lite/subscriber.rs` and `subscriber/fills.rs`: SUBSCRIBE_DROP's inclusive end `u64::MAX` becomes `..u64::MAX` through `saturating_add(1)`, so the drop misses group `u64::MAX`. -- `rs/moq-net/src/lite/publisher.rs` `DatagramSend::send`: a sequence of 2^62 or more relayed from lite-07 to a lite-05/06 subscriber fails to encode and is dropped silently, and the subscription ends in a reset instead of a finish. -- `rs/moq-net/src/route/mod.rs` `requests`: `fd[&id].0 + 1` on an advertised seqno `u64::MAX` (test-only today; becomes reachable when lite-07 drives the router). +- `kio::clock::Instant` drops `Add`, `AddAssign`, `Sub` and `SubAssign` with `Duration` (`rs/kio/src/clock/instant.rs`), which panic past the clock's range. Every call site picks `checked_add`/`checked_sub` with an explicit meaning for overflow (no deadline, refused) or a saturating form. This removes the class behind, among others, the IETF publisher's REQUEST_ERROR retry interval (`rs/moq-net/src/ietf/publisher.rs` `refusal`, which any peer can panic) and `rs/moq-net/src/reconnect.rs` deadlines from huge configured durations. +- Peer-supplied counters never wrap with `+ 1`: the ietf `save_join` `largest.object + 1`, the lite SUBSCRIBE_DROP end `u64::MAX` (`saturating_add(1)` that misses that group in `lite/subscriber.rs` and `subscriber/fills.rs`), and the router's advertised seqno in `route/mod.rs`. +- A datagram sequence of 2^62 or more relayed to a lite-05/06 subscriber is handled explicitly in `lite/publisher.rs` `DatagramSend::send` instead of dropped silently. ## Plan -Found by the 2026-11-07 invariant audit (ietf-1, ietf-2, lite-3, lite-4, lite-9, model-3). Each site gets `checked_add` with an explicit meaning for overflow (no deadline, unsupported, refused) and a test at the extreme value. Prefer refusing the impossible value at decode or at `Reconnect::new` where that is simpler than handling it downstream. +Found by the 2026-11-07 invariant audit (ietf-1, ietf-2, lite-3, lite-4, lite-9, model-3). + +Decided 2026-11-07 (maintainer, invariant-audit root causes): remove the operators rather than make them saturate, so each of the roughly 600 sites states what overflow means; most edits are mechanical. Breaking for the published `kio` crate; it stays in m0 because a remote peer can panic the publisher today. Removing `Sub` alongside `Add` is the same class (underflow before the clock's start). Each peer-reachable site gets a test at the extreme value. diff --git a/quest/m0/qmux-js-credit.md b/quest/m0/qmux-js-credit.md index be987bcc1e1..499c10e159d 100644 --- a/quest/m0/qmux-js-credit.md +++ b/quest/m0/qmux-js-credit.md @@ -1,19 +1,21 @@ -# [S] JS qmux keeps credit and final sizes consistent with Rust +# [M] JS qmux streams follow QUIC's per-stream state machine ## Goal -A JS qmux session (the WebSocket fallback) never stalls or gets closed by a Rust peer because of its own flow-control bookkeeping, and a peer cannot grow its memory without bound: +`js/qmux` tracks each stream's send and receive halves through the states of [RFC 9000 ยง3](https://www.rfc-editor.org/rfc/rfc9000#section-3), as `rs/moq-quic/src/connection/streams/state.rs` does, instead of credit bookkeeping spread across `session.ts` handlers. A JS qmux session (the WebSocket fallback) then cannot leak or double-count credit by construction: -- Bytes that arrive on a stream we already stopped (late STREAM frames, and the final-size gap of a later RESET_STREAM) count toward connection receive credit. Today `js/qmux/src/session.ts` (`#onStream`, `#onResetStream`) returns early once the receive half is deleted on cancel, so each cancelled stream permanently shrinks the window until MAX_DATA is never sent again. -- A RESET_STREAM's final size is at least every byte already handed to the socket. Today `sendOffset` advances only after `scheduler.enqueueStream` resolves, so a STOP_SENDING handled while a frame is mid-`sink.write` replies with a short final size, which Rust's `rs/moq-quic/src/connection/streams/recv.rs` refuses with FINAL_SIZE_ERROR. -- MAX_DATA, MAX_STREAMS_{BIDI,UNI} and MAX_STREAM_DATA (per stream) replace any queued copy, as Rust does with its pending flags, instead of each taking a new entry on the uncapped control lane. -- An empty binary WebSocket message is a no-op on every qmux version, matching `rs/qmux/src/transport/websocket.rs`; qmux-00 currently throws. -- WebSocket messages larger than the record size are refused before they are buffered where the platform allows it: Rust `rs/qmux/src/ws.rs` `Upgraded` validates or sets the tungstenite `max_message_size` itself instead of trusting the caller, and JS documents the `ws` `maxPayload` it needs. +- A stream's flow record lives until both halves are terminal on the wire (all data or a reset received and consumed; FIN or RESET sent), not until the app releases its handle. Today `#maybeDeleteStreamFlow` drops it on cancel, so late STREAM bytes and a later RESET's final-size gap are never counted and the connection window shrinks until MAX_DATA stops (an honest Rust peer stalls the session). +- Connection and stream credit change only inside state transitions, so no handler can return early past the accounting (today twelve call sites do it by hand). +- The send half's offset counts bytes when the scheduler takes them, so a RESET_STREAM's final size covers a frame mid-`sink.write`. Today it does not, and Rust closes the connection with FINAL_SIZE_ERROR. +- Window updates (MAX_DATA, MAX_STREAMS_{BIDI,UNI}, MAX_STREAM_DATA) are pending flags or per-stream latest values drained by the scheduler, as Rust's `pending` flags are, not entries on the uncapped control lane. +- An empty binary WebSocket message is a no-op on every version, and messages over the record size are refused before buffering where the platform allows it (Rust `rs/qmux/src/ws.rs` `Upgraded` validates its own `max_message_size`; JS documents `ws` `maxPayload`). ## Plan -Found by the 2026-11-07 invariant audit (qmux-1 to qmux-5). qmux-1 and qmux-2 are the urgent ones: an honest Rust relay triggers both whenever a JS app cancels group streams over WebSocket. +Found by the 2026-11-07 invariant audit (qmux-1 to qmux-5); qmux-1 and qmux-2 are reproduced as tests before the redesign. -- Keep each stopped peer stream's receive offset (and final size once known) until FIN or RESET so late bytes are accounted, and port Rust's `credit_returns_for_unread_and_stopped_streams` (`rs/moq-quic/src/connection/mux/tests.rs`). -- Count bytes as sent when the scheduler takes the frame from its queue, or track the in-flight frame per stream and include it in the RESET final size. Test with a sink whose `write` is held open while STOP_SENDING arrives. -- Send the MAX_* frames through `replaceControl` keyed by type (and stream id for MAX_STREAM_DATA); fix the "bounded" claim in `scheduler.ts`. +Decided 2026-11-07 (maintainer, invariant-audit root causes): fix the cause, the hand-rolled flow accounting, rather than patching each early return. Mirror Rust's state shapes and port its credit tests (`credit_returns_for_unread_and_stopped_streams` in `rs/moq-quic/src/connection/mux/tests.rs` among them) so both implementations pin the same invariants. A single implementation for both languages is the longer-term route, planned in [Evaluate WASM-to-JS for @moq/net](/quest/m5/rs2ts-wasm2js.md); this does not wait for it. + +## Related + +- [js/qmux close](/quest/m1/explicit-state/qmux-close.md) - the session close phase and `recv.ts` stream-end sweep; land the per-stream state machine so both share one stream state diff --git a/quest/m1/README.md b/quest/m1/README.md index 5d3dbb94b19..897602cde36 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -34,11 +34,11 @@ moves what remains of them once their claims merge. - [JS cache pool](/quest/m1/js-cache-pool.md) - `js/net` bills cached frames and drop-set runs to a per-broadcast byte pool ported from Rust - [Drop set](/quest/m1/drop-set.md) - the drop set's track and cursor runs are billed to the cache pool, so fragmenting drops costs what the pool holds; eviction folds final runs or ends the cursor with `TooFarBehind` - [Only the relay's own stats publisher uses the stats prefix](/quest/m1/stats-prefix.md) - a broad grant can no longer publish unmetered media or spoof node stats under `.stats` -- [js/net enforces the input checks Rust does](/quest/m1/js-net-input-checks.md) - GOAWAY redirects to loopback spellings, credential logging, ANNOUNCE_INIT size, sequence range, and version pinning match Rust -- [Rust and JS accept exactly the same tokens and key sets](/quest/m1/token-parse-strict.md) - a null `exp` no longer means never-expiring in Rust, duplicate kids and bad scope roots are refused, and every relay lease is validated +- [js/net and moq-net decode the same inputs the same way](/quest/m1/js-net-input-checks.md) - a shared input corpus pins both decoders, and a typed endpoint stops loopback spellings from slipping past GOAWAY checks +- [Rust and JS run one token and key-set corpus](/quest/m1/token-parse-strict.md) - a shared corpus pins both parsers, so a null `exp` no longer means never-expiring in Rust - [Resetting a kio sleep to its deadline does not wake it](/quest/m1/kio-reset-same-deadline.md) - a caller that resets on every poll no longer spins the simulator - [A refused lite SUBSCRIBE_UPDATE ends the subscription](/quest/m1/lite-update-refused.md) - the publisher no longer serves a removed range or reports the wrong start after the model refuses an update -- [Cluster peers have one identity each and fresh inputs](/quest/m1/cluster-peer-identity.md) - a duplicate cluster id, a self-dial, or a rotated cluster token fails loud instead of partitioning or looping the mesh +- [A cluster peer's identity is a type, not a string](/quest/m1/cluster-peer-identity.md) - a canonical `PeerKey` and a refused duplicate cluster id stop self-dials, double links, and silent mesh partitions - [Media fuzz findings](/quest/m1/fuzz-findings/README.md) - the nightly media fuzz targets run clean: mp4-atom, MKV block, TS step, SPS overflow, and catalog encoder bugs they found are fixed - [A smaller, safer API than release](/quest/m1/api-audit/README.md) - every surface added since release has a consumer or is gone, overlapping operations are collapsed, names mirror across Rust, JS, and the bindings, and every break says how to migrate - [Routing guide](/quest/m1/routing-guide.md) - concepts and practical outbound federation setup @@ -61,7 +61,7 @@ moves what remains of them once their claims merge. - [Lifecycles are explicit state](/quest/m1/explicit-state/README.md) - every lifecycle the 2026-11-07 audit found implicit is one enum or union, fixing the bugs in the 11 lifecycles it redesigns, with review and agent rules against new ones - [Copies of one group identity must agree beyond their bytes](/quest/m1/copy-identity.md) - copies that differ in timestamp, object fields, or frame count fail as a protocol violation - [Session auth grants and replies stay consistent and bounded](/quest/m1/auth-stream-bounds.md) - an AUTH_ERROR revokes its grant, queued grants and retiring tokens are bounded, and dropping queued requests cannot deadlock -- [Relay settings and admin input that cannot work are refused](/quest/m1/relay-config-checks.md) - clustered stats without a node name, pin without workers, repeated filter keys, and unknown transports fail loud +- [Relay settings parse once into a validated configuration](/quest/m1/relay-config-checks.md) - settings that cannot work together are refused at load instead of accepted and ignored - [Track drop runs are forgotten below the floor](/quest/m1/drop-runs-prune.md) - a track's dropped-group record stays proportional to its window in Rust and JS ## Related diff --git a/quest/m1/cluster-peer-identity.md b/quest/m1/cluster-peer-identity.md index e8c96fb58fc..2d01f8bddc5 100644 --- a/quest/m1/cluster-peer-identity.md +++ b/quest/m1/cluster-peer-identity.md @@ -1,15 +1,16 @@ -# [S] Cluster peers have one identity each and fresh inputs +# [S] A cluster peer's identity is a type, not a string ## Goal -A relay mesh fails loud on identity mistakes instead of silently partitioning or looping, and its peer inputs stay current and bounded (`rs/moq-relay/src/cluster.rs`, `relay.rs`): +Relay mesh identity mistakes are unrepresentable or fail loud (`rs/moq-relay/src/cluster.rs`, `session.rs`): -- A peer session whose declared hop (`Request::peer_hop`) equals our `cluster.id` is refused with an error naming the duplicate, instead of every route from it being dropped as reflected. -- One relay has one dial key: `DialTarget` keys on `canonicalize_peer_key` (which also strips URL userinfo, so credentials stop reaching logs and the `remote` span), and the self filter applies to static `cluster.connect` entries too. A test covers the self filter. -- `--cluster-lan` on a relay without a QUIC listener is refused at startup instead of advertising SRV port 0. +- A `PeerKey` type, constructible only through `canonicalize_peer_key`, keys the dial map, the self filter, LAN discovery and `/nodes`. Today `DialTarget` keys on the raw URL with only the query cleared, so one relay can get two links, a node can dial itself, and URL userinfo reaches logs and the `remote` span. The self filter applies to static `cluster.connect` entries too, and a test covers it. +- A peer session whose declared hop (`Request::peer_hop`) equals our `cluster.id` is refused with an error naming the duplicate, instead of every route from it being dropped as reflected and the mesh silently partitioning. - The `cluster.token` file is re-read on redial (or watched like the API file), so a rotated or expiring token does not break every redial until restart. -- The `cluster.api.url` body and file are read with a size cap; an oversized answer keeps the last good peer list. +- A `session::Registry` registration removes only its own entry, so a reused `Request` id cannot unlist another live session. ## Plan -Found by the 2026-11-07 invariant audit (relay-2, relay-4, relay-5, relay-7, relay-9, relay-10). +Found by the 2026-11-07 invariant audit (relay-2, relay-4, relay-5, relay-9, relay-13). + +Decided 2026-11-07 (maintainer, invariant-audit root causes): make the canonical key a type so no code path can key on a raw URL again. The audit's suggestion of a byte cap on the `cluster.api.url` body is dropped: that endpoint is operator-trusted, and a constant cap is not a design bound. diff --git a/quest/m1/explicit-state/qmux-close.md b/quest/m1/explicit-state/qmux-close.md index ff7158236cc..344adc8c121 100644 --- a/quest/m1/explicit-state/qmux-close.md +++ b/quest/m1/explicit-state/qmux-close.md @@ -39,3 +39,7 @@ Also sweep `js/qmux/src/recv.ts` and `js/qmux/src/credit.ts`: [moq-dev/moq#8136](https://github.com/moq-dev/moq/pull/8136) and [moq-dev/moq#8088](https://github.com/moq-dev/moq/pull/8088) also edit `session.ts`. Expect merge conflicts. + +## Related + +- [JS qmux streams follow QUIC's per-stream state machine](/quest/m0/qmux-js-credit.md) - its stream states subsume the `recv.ts` end flags; sweep `recv.ts` on top of it diff --git a/quest/m1/js-net-input-checks.md b/quest/m1/js-net-input-checks.md index 92d4b051709..b2ef492063b 100644 --- a/quest/m1/js-net-input-checks.md +++ b/quest/m1/js-net-input-checks.md @@ -1,16 +1,15 @@ -# [S] js/net enforces the input checks Rust does +# [M] js/net and moq-net decode the same inputs the same way ## Goal -`@moq/net` rejects the inputs `rs/moq-net` rejects, so JS is not the weaker side of a session: +Rust and JS cannot drift apart on what they accept, because both run one shared corpus, and URLs are parsed once into a typed endpoint: -- A GOAWAY redirect is classified on the host actually dialed. `isLocal` in `js/net/src/connection/goaway.ts` parses the opaque `moql:`/`moqt:` host, so `127.1`, `0x7f000001` and `2130706433` pass as non-local while `dialable()` rewrites them to `https:`, which resolves to 127.0.0.1. With `redirect: "follow"` a relay can steer the client and its tokens to loopback. -- `redact` in `js/net/src/util/log.ts` logs scheme, host and port only. For `moql:` URLs `url.origin` is `"null"` and the path, which can carry a token, is kept. -- The lite-01/02 ANNOUNCE_INIT is capped like Rust's (`frame::SEGMENT - 16`) and its count is checked against the announce budget before decoding (`js/net/src/lite/announce.ts`). -- `checkSequence` (`js/net/src/util/sequence.ts`) refuses a group sequence at or above 2^64, as datagrams already do, so `appendGroup` after group 2^64-1 fails instead of minting a group past `UNBOUNDED`. -- `accept.ts` requires a pinned `AcceptProps.version` to be one the client offered, as `rs/moq-net/src/server.rs` does. -- `open.ts` `patience` treats an RTT of 0 as unknown, so a transport that reports 0 before its first sample does not make every stalled open reset a lower-priority stream. +- A shared corpus of moq-lite inputs, each with its expected outcome (decoded value, or refused), is replayed by `rs/moq-net` and `js/net` tests. It starts from the fuzz regressions in `rs/moq-net/fuzz/regressions` plus hand cases for every difference this audit found: the lite-01/02 ANNOUNCE_INIT size and count budget (JS allows 64 MiB and checks the announce budget only after building the array), group sequences at 2^64 (`checkSequence` accepts them), and a pinned server version the client did not offer (`accept.ts`). +- JS parses a dial target once into a typed endpoint (scheme, host as an IP or a name, port), the way `rs/moq-net/src/reconnect/redirect.rs` does, and GOAWAY classification, redirect checks, dialing and log redaction all use it. Today `isLocal` (`js/net/src/connection/goaway.ts`) reads the opaque `moql:` host, so `127.1`, `0x7f000001` and `2130706433` pass as non-local while `dialable()` turns them into 127.0.0.1, which lets a relay steer a `redirect: "follow"` client and its tokens to loopback. `redact` logs `null/` for `moql:` URLs, path tokens included. +- `open.ts` `patience` treats an RTT of 0 as unknown. ## Plan -Found by the 2026-11-07 invariant audit (js-2, js-5 to js-9). Tests for each, including the three loopback spellings. js-1 (announce queue growth) is already [JS announced coalescing](/quest/m3/js-announced-coalesce.md). +Found by the 2026-11-07 invariant audit (js-2, js-5 to js-9). + +Decided 2026-11-07 (maintainer, invariant-audit root causes): the cause is two hand-written decoders with no shared inputs; the corpus makes any divergence a failing test on both sides. Pick the corpus location and format to suit both test runners (a directory of JSON or hex cases is the obvious start). The single-implementation route stays [Evaluate WASM-to-JS for @moq/net](/quest/m5/rs2ts-wasm2js.md). js-1 (announce queue growth) is [JS announced coalescing](/quest/m3/js-announced-coalesce.md). diff --git a/quest/m1/relay-config-checks.md b/quest/m1/relay-config-checks.md index 605cb5b2e87..18200179b47 100644 --- a/quest/m1/relay-config-checks.md +++ b/quest/m1/relay-config-checks.md @@ -1,16 +1,23 @@ -# [S] Relay settings and admin input that cannot work are refused +# [S] Relay settings parse once into a validated configuration ## Goal -moq-relay refuses at load or request time what it currently accepts and then ignores or mishandles: +moq-relay turns its CLI, environment and TOML settings into one validated configuration at load, and the rest of the relay takes that type, so a setting cannot be accepted and then ignored. Cross-field rules live in that one step instead of across `Config::resolve`, `Cluster::new`, `Cluster::start` and `Relay::load`. The cases this audit found are refused there: -- Stats enabled on a clustered relay without `stats.node` is refused, or `stats.node` defaults to `cluster.node`, so nodes stop overwriting each other's `/node` broadcast. -- `runtime.pin` without `runtime.workers` is refused, matching the file's "refused rather than ignored" rule. -- A repeated key on `/sessions` or `/sessions/revalidate` (`session.rs` `Filter::from_query`) is an error instead of the last value winning. -- Refusal counters (`refusals.rs`) stay bounded with stats disabled, where nothing calls the pruning `Registry::report`. -- `auth.rs` maps every `moq_tokio::Transport` without `unreachable!` (the enum is `#[non_exhaustive]`), and the qmux-01-only ALPN choice in `websocket.rs` is derived from the version, not a hand-kept string list. -- `session::Registry` registration drops only their own entry, so an embedder registering a reused `Request` id cannot unlist another live session. +- Stats enabled on a clustered relay without `stats.node`; or `stats.node` defaults to `cluster.node`. Without either, nodes overwrite each other's `/node` broadcast. +- `--cluster-lan` without a QUIC listener; today it advertises SRV port 0. +- `runtime.pin` without `runtime.workers`. + +The same parse-once rule applies to the admin endpoints: a repeated key on `/sessions` or `/sessions/revalidate` (`Filter::from_query`) is an error, not last-wins. + +Smaller gaps fixed in the same PR: + +- Refusal counters (`refusals.rs`) stay bounded with stats disabled, where nothing prunes them. +- `auth.rs` maps every `moq_tokio::Transport` without `unreachable!`. +- `websocket.rs` derives its qmux-01-only ALPN choice from the version instead of a hand-kept list. ## Plan -Found by the 2026-11-07 invariant audit (relay-3, relay-6, relay-8, relay-11 to relay-14). +Found by the 2026-11-07 invariant audit (relay-3, relay-6, relay-7, relay-8, relay-11, relay-12, relay-14). + +Decided 2026-11-07 (maintainer, invariant-audit root causes): parse, don't validate. The existing settings drift test stays and covers the new type. diff --git a/quest/m1/token-parse-strict.md b/quest/m1/token-parse-strict.md index aa825ef3828..8aeea378630 100644 --- a/quest/m1/token-parse-strict.md +++ b/quest/m1/token-parse-strict.md @@ -1,15 +1,20 @@ -# [S] Rust and JS accept exactly the same tokens and key sets +# [S] Rust and JS run one token and key-set corpus ## Goal -Token and key parsing is strict and identical in `rs/moq-auth` and `js/auth`, and every grant the relay holds has been validated: +`rs/moq-auth` and `js/auth` load one shared corpus of tokens, key sets and JWKs, each marked accepted (with the resulting grant) or refused, so a parser difference fails a test on both sides. The cases this audit found pass: -- `exp`, `iat` and `nbf` refuse `null` and values outside the range both languages represent. Today Rust reads `"exp": null` as no expiry (a token that never expires) while JS refuses it; the Grant wire `expires` has the same gap. -- A KeySet with a duplicate `kid` is refused at parse in both languages instead of the first match winning. +- `exp`, `iat` and `nbf` refuse `null` and values outside the range both languages represent. Rust reads `"exp": null` as no expiry, a token that never expires, while JS refuses it; the Grant wire `expires` has the same gap. +- A key set with a duplicate `kid` is refused in both languages instead of the first match winning. - A JWK's scope root passes `path::validate` on import in both languages, as the `with_scope` doc promises. -- The relay detects a token the way the server parses one (`presents_token` vs form-decoded `jwt`), so `?%6Awt=` cannot fall through to the public grant; key sources are cached instead of re-read and re-parsed per token. -- `Lease::new` (`rs/moq-relay/src/auth.rs`) runs `Grant::validate`, so an embedder's grant cannot skip the tier charset and upstream-implies-peer checks. + +Two relay-side gaps found beside them are fixed in the same PR: + +- The relay detects a token the way the server parses one (`presents_token` vs the form-decoded `jwt`), so `?%6Awt=` cannot fall through to the public grant, and key sources are cached rather than re-read per token. +- `Lease::new` (`rs/moq-relay/src/auth.rs`) runs `Grant::validate`. ## Plan -Found by the 2026-11-07 invariant audit (auth-2, auth-4, auth-9, auth-11, auth-12). Each refusal gets a vector in the Rust/JS interop test (`js/auth/src/interop.test.ts`), including a null-`exp` token. +Found by the 2026-11-07 invariant audit (auth-2, auth-4, auth-9, auth-11, auth-12). + +Decided 2026-11-07 (maintainer, invariant-audit root causes): the cause is two hand-written parsers checked only by JS loading a few Rust-signed fixtures (`js/auth/src/interop.test.ts`). The corpus replaces those fixtures and runs in both directions. diff --git a/quest/m3/mesh/README.md b/quest/m3/mesh/README.md index 42301df4952..a5ef1c0f716 100644 --- a/quest/m3/mesh/README.md +++ b/quest/m3/mesh/README.md @@ -102,7 +102,7 @@ the third, and survives a relay restart. - [Byte streams](/quest/m3/stream/README.md) - lossless TCP-level streams over MoQ - [Endpoint API](/quest/m3/mesh/endpoint.md) - an iroh-shaped `Endpoint` that connects to an endpoint ID and service and accepts byte streams - [Mesh relay node](/quest/m3/mesh/node.md) - a relay node enrolls into a mesh, dials its relays, and admits only enrolled LAN peers -- [Mesh enrollment is bounded and revocation takes effect](/quest/m3/mesh/device-bounds.md) - a reusable key enrolls a bounded number of devices and a revoked device loses access within a bounded time +- [Mesh enrollment is bounded and revocation takes effect](/quest/m3/mesh/device-bounds.md) - a reusable key enrolls at most its own use budget and a revoked device loses access within a bounded time - [Certificate credentials](/quest/m3/mesh/cert-credentials.md) - a certificate chain and a proof signature stand in for a JWT, checked against a trust store the application swaps live - [Address hints](/quest/m3/mesh/addr.md) - nodes publish their addresses on mesh tracks and dial each other directly where mDNS cannot find them - [Sparse overlay](/quest/m3/mesh/overlay.md) - each node keeps about log N deterministic direct links, forwards for the rest, and falls back to relays across a partition diff --git a/quest/m3/mesh/device-bounds.md b/quest/m3/mesh/device-bounds.md index 6dffe8c0ccc..2a9976d684f 100644 --- a/quest/m3/mesh/device-bounds.md +++ b/quest/m3/mesh/device-bounds.md @@ -4,9 +4,9 @@ The mesh coordinator (`rs/moq-auth/src/mesh/coordinator.rs`) stops assuming its device store stays small and its revocations wait for token expiry: -- A reusable enrollment key enrolls a bounded number of devices, and `/enroll` is rate limited; today each enroll clones the whole store, rewrites the state file and returns every member under one mutex, so a leaked key can mint thousands of endpoints. +- A reusable enrollment key carries a use budget set when it is created, so the coordinator holds at most what its keys allow. Today a reusable key enrolls any number of endpoints, and each enroll clones the whole store, rewrites the state file and returns every member under one mutex, so a leaked key can mint thousands. - A revoked device loses access within a bounded time, not only at its token's `exp` (1h by default): either `decide` checks revocation for mesh tokens, or mesh tokens are short-lived with revalidation on. ## Plan -Found by the 2026-11-07 invariant audit (auth-7, auth-8). +Found by the 2026-11-07 invariant audit (auth-7, auth-8). The budget belongs to the key's owner, not a server-wide constant or rate limit, per REVIEW.md's rule against constant caps.