From f54c423e2248576948463ce12128b544ed536967 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 24 Sep 2026 10:36:13 -0700 Subject: [PATCH 01/53] quest: open the auth line Co-Authored-By: Claude Opus 5.5 From 288ea4f0aa314b488cb2729825d0f8ccb28dbc23 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 24 Sep 2026 20:52:15 -0700 Subject: [PATCH 02/53] feat(net): exchange grants over lite-06 AUTH streams (#4062) Co-authored-by: Claude Opus 5.5 --- doc/.vitepress/drafts.test.ts | 8 +- doc/concept/moq-lite.md | 18 + doc/lib/js/net.md | 1 + doc/lib/rs/moq-net.md | 29 ++ drafts/draft-lcurley-moq-lite.md | 83 +++ js/net/src/auth.ts | 122 +++++ js/net/src/connection/accept.ts | 3 + js/net/src/connection/connect.ts | 3 + js/net/src/connection/established.ts | 8 + js/net/src/error.ts | 18 + js/net/src/ietf/connection.ts | 4 + js/net/src/index.ts | 2 + js/net/src/lite/auth.test.ts | 241 +++++++++ js/net/src/lite/auth.ts | 539 ++++++++++++++++++++ js/net/src/lite/connection.ts | 56 ++- js/net/src/lite/publisher.ts | 158 +++++- js/net/src/lite/stream.ts | 1 + js/net/src/lite/subscriber.ts | 56 ++- js/net/src/lite/version.ts | 18 + quest/m1/auth/README.md | 3 - quest/m1/auth/bindings.md | 1 - quest/m1/auth/lite.md | 151 ------ quest/m1/auth/moq-transport.md | 9 +- quest/m1/auth/peer-grant.md | 1 - quest/m1/auth/relay-refresh.md | 8 +- quest/m1/path-patterns.md | 4 - rs/moq-net/src/auth.rs | 722 +++++++++++++++++++++++++++ rs/moq-net/src/client.rs | 19 +- rs/moq-net/src/lib.rs | 1 + rs/moq-net/src/lite/auth.rs | 304 +++++++++++ rs/moq-net/src/lite/mod.rs | 2 + rs/moq-net/src/lite/publisher.rs | 428 +++++++++++++++- rs/moq-net/src/lite/session.rs | 220 ++++++++ rs/moq-net/src/lite/stream.rs | 1 + rs/moq-net/src/lite/subscriber.rs | 54 +- rs/moq-net/src/lite/version.rs | 12 + rs/moq-net/src/server.rs | 68 ++- rs/moq-net/src/session.rs | 14 +- rs/moq-net/tests/auth.rs | 622 +++++++++++++++++++++++ rs/moq-net/tests/support/mock.rs | 19 +- 40 files changed, 3800 insertions(+), 231 deletions(-) create mode 100644 js/net/src/auth.ts create mode 100644 js/net/src/lite/auth.test.ts create mode 100644 js/net/src/lite/auth.ts delete mode 100644 quest/m1/auth/lite.md create mode 100644 rs/moq-net/src/auth.rs create mode 100644 rs/moq-net/src/lite/auth.rs create mode 100644 rs/moq-net/tests/auth.rs diff --git a/doc/.vitepress/drafts.test.ts b/doc/.vitepress/drafts.test.ts index f340921693..df79be4f23 100644 --- a/doc/.vitepress/drafts.test.ts +++ b/doc/.vitepress/drafts.test.ts @@ -128,20 +128,20 @@ describe("tables render as tables", () => { const rendered = html(page as DraftPage); - // The Bidirectional Streams table: one header, six body rows, each + // The Bidirectional Streams table: one header, seven body rows, each // separated by a kramdown rule in the source. Cells carry the source's // alignment, so match around the style attribute. const header = /]*>Stream<\/th>/.exec(rendered); expect(header).not.toBeNull(); - for (const stream of ["Announce", "Subscribe", "Fetch", "Probe", "Goaway", "Track"]) { + for (const stream of ["Announce", "Subscribe", "Fetch", "Probe", "Goaway", "Track", "Auth"]) { expect(rendered).toMatch(new RegExp(`]*>${stream}`)); } - // One header row plus six body rows, in a single table. + // One header row plus seven body rows, in a single table. const start = rendered.lastIndexOf("")[0]; - expect(rows.match(//g) ?? []).toHaveLength(7); + expect(rows.match(//g) ?? []).toHaveLength(8); }); }); diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 6c1fd65b96..54ed43bc85 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -32,6 +32,24 @@ and newer, each side also sends a `SETUP` message with its capabilities. Rust and TypeScript speak moq-lite 01 through 06 and moq-transport drafts 14 through 22. Clients offer `moq-lite-06` first by default. +## Authorization + +On moq-lite 06 each side presents a token on its own Auth stream and learns +what it may publish and subscribe to. Right after setup both sides present the +credential the connection already carried (the URL token, a client +certificate, or nothing), so a publisher learns before anyone subscribes +whether its broadcasts can reach the peer. More tokens can be presented later +without reconnecting; the session's scope is the union of every open token's +grant, and withdrawing, revoking, or narrowing one withdraws only what it alone +covered. + +A client that publishes a broadcast outside its grant closes the session with +`UNAUTHORIZED` and names the path in the close reason, rather than waiting +forever for a subscriber the relay will never let through. A grant is advice +for the side that holds it; the side that issued it still enforces its own +scope. Older versions and moq-transport have no grant; the token in the URL +keeps working everywhere. + ## Discovery A session can ask for announcements matching a path prefix. The peer replies diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index 0802896244..39936cbac2 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -53,6 +53,7 @@ for (;;) { - **Discovery** by any pattern scope (`origin.announced(scope)`, such as `room/*/chat`; default everything). Each event's `path` is the covered prefix relative to the origin, `captures` reports what the scope's wildcards matched when the prefix pins them, and `kind` says whether it was announced, updated, or retracted. The consumer is an async iterable. `origin.broadcasts(scope)` is a live `Getter>` of the same covered prefixes for UIs that need the current set. A borrowed `Connection.origin` also exposes `dynamic(prefix, route)` for serving paths on demand. - **Subscriptions** carry a priority, a `Time.Milli` max age, and optional `groups` bounds. Groups arrive out of order and are read frame by frame, with `Error.TooFarBehind` when a reader asks for a frame the group never held and `Error.GroupTooLarge` when a write exceeds the cache budget and aborts the group. - **Datagrams** on moq-lite 05+ and fetch-by-sequence for history. +- **Authorization** on moq-lite 06: an established session's `auth.grant` is a `Getter` with what the relay lets this side publish and subscribe to, learned right after setup. `auth.add(token)` presents another token without reconnecting and resolves with an `Auth.Token` to `close()` later; it rejects with `Auth.Unsupported` when the peer takes no tokens in band. A connection that publishes a broadcast outside its grant closes with `SessionCode.Unauthorized`, naming the path in the reason. - **Errors** live under one namespace: a stream reset throws `Error.Stream` with a `StreamCode`, while a session close gives `Error.Session` with a `SessionCode`. The registries are disjoint, so the same number means different things in each, and 64+ is yours. Named conditions such as `Error.TooFarBehind`, `Error.FrameTooLarge`, and `Error.GroupTooLarge` subclass `Error.Stream`, so one `code` check handles a condition raised here or reported by the peer. IETF streams use their own mapping: cancellation sends CANCELLED, other local failures send INTERNAL\_ERROR, and received codes remain opaque. - **Paths** with `Path.relative` for the cross-broadcast catalog references hang uses. Path patterns (`Path.Pattern`, `Path.Patterns`) are re-exported from [`@moq/pattern`](https://www.npmjs.com/package/@moq/pattern). Literal `Path` stays a coordinate. diff --git a/doc/lib/rs/moq-net.md b/doc/lib/rs/moq-net.md index 33f67336aa..0a708df11e 100644 --- a/doc/lib/rs/moq-net.md +++ b/doc/lib/rs/moq-net.md @@ -70,6 +70,35 @@ Cache activity is dated lazily: reads and writes mark a group active without reading a clock, and the next `gc` pass stamps it with the supplied instant. Expiry is therefore approximate; a late `gc` extends retention. +## Authorization + +`session.auth()` is the session's `auth::Handle`. On moq-lite 06 each side +presents its connection's credential right after setup, and `grant()` watches +the union of every grant this side holds: `None` until the peer answers, +forever on other versions. `add(token)` presents another token and resolves +once the peer answers; drop the returned `auth::Token` to withdraw it. + +```rust +let mut grant = session.auth().grant(); +while grant.peek().is_none() { + grant.changed().await?; +} +let refreshed = session.auth().add(jwt).await?; +``` + +By default a session answers the peer's connection credential with what its own +origin handles allow and refuses any other token as unsupported. To verify +tokens yourself, take `handshake.auth().requests()` on the `server::Handshake` +before `ok()` (or `session.auth().requests()` before first polling the driver) +and answer every `auth::Request` with `accept(grant)`, which returns an +`auth::Issued` you can `update` or `revoke`, or `reject`. The wire carries +prefix grants for now, so a grant that is not a union of subtrees is refused. + +A client whose origin publishes a broadcast outside its grant closes the +session with `Unauthorized`, naming the path in the close reason. A grant that +shrinks withdraws the announcements and cancels the subscriptions it no longer +covers, and leaves the session up. + ## Patterns `Pattern` describes a set of paths; `Patterns` is a union reduced by diff --git a/drafts/draft-lcurley-moq-lite.md b/drafts/draft-lcurley-moq-lite.md index c86b6bcc46..7f9846681a 100644 --- a/drafts/draft-lcurley-moq-lite.md +++ b/drafts/draft-lcurley-moq-lite.md @@ -362,6 +362,8 @@ There's a 1-byte STREAM_TYPE at the beginning of each stream. | ------- | ------------- | ----------- | | 0x6 | Track | Subscriber | | ------- | ------------- | ----------- | +| 0x7 | Auth | Either | +| ------- | ------------- | ----------- | ### Announce A subscriber can open an Announce Stream to discover routes matching a prefix. @@ -495,6 +497,24 @@ In either case the publisher periodically replies with PROBE messages on the sam If the publisher advertised no Probe capability (e.g., the congestion controller is not exposed), it MUST reset the stream. +### Auth {#auth-stream} +Either endpoint can open an Auth Stream (0x7) to present a token and learn what it grants. + +The opener sends a single AUTH message and keeps its side open for as long as the token applies. +Each endpoint SHOULD open one Auth Stream with an empty token right after the session starts, so both learn what the credential the connection already presented (the request URI, a client certificate, or nothing) allows. +The acceptor replies with an AUTH_OK carrying the grant, or an AUTH_ERROR refusing the token. +It MAY send further AUTH_OK messages to replace the grant, such as with a lowered expiry, and MAY send an AUTH_ERROR after an AUTH_OK to revoke it; the latest AUTH_OK is the token's grant. +After an AUTH_ERROR the acceptor closes its side of the stream. +The opener withdraws a token by closing or resetting its side of the stream, and the acceptor then closes its own. + +A grant names the paths the opener may publish to the acceptor and subscribe to from it, relative to the session. +An endpoint's scope is the union of the grants of its open Auth Streams, and a stream that ends removes its grant from the union. +When the union shrinks, an endpoint SHOULD withdraw its announcements and cancel its subscriptions that the union no longer covers, keeping the session and everything still covered. +An endpoint that announces a broadcast outside the union once its own setup tokens are answered SHOULD close the session with UNAUTHORIZED, rather than wait for a subscription that will never come. + +An acceptor that does not verify a token in band, or cannot express its grant in AUTH_OK, resets the stream, the same as a peer that does not support the Auth Stream (see [STREAM_TYPE](#stream_type)). +An opener that sees the stream reset before any reply treats the token as unsupported rather than refused. + ### Goaway Either endpoint can open a Goaway Stream (0x5) to initiate a graceful session shutdown. @@ -1265,6 +1285,64 @@ A peer that reconnects to a provided URI SHOULD keep using that URI for subseque A relay that receives a GOAWAY SHOULD treat the announcements that arrived on that session as the most expensive routes available, so a subscription it can serve from another session moves at the next Group boundary rather than when the draining session finally closes. The routes stay usable: a broadcast reachable only over the draining session MUST keep being served until the session ends, which is what makes the sender's deadline a handover window rather than a cutoff. +## AUTH {#auth-message} +AUTH is the first message on an Auth Stream, presenting a token. + +~~~ +AUTH Message { + Message Length (i) + Token (b) +} +~~~ + +**Token**: +The credential to verify, opaque to moq-lite. +An empty token presents the credential the connection already carried, or nothing. + +## AUTH_OK {#auth-ok} +AUTH_OK grants a token, and replaces any grant sent before it on the same stream. + +~~~ +AUTH_OK Message { + Type (i) = 0x0 + Message Length (i) + Publish Count (i) + Publish Prefix (s) ... + Subscribe Count (i) + Subscribe Prefix (s) ... + Expires (i) +} +~~~ + +**Publish Prefix**: +A path prefix the opener may announce and serve, using the encoding and matching rules of the ANNOUNCE_REQUEST [prefix](#announce-request). +An empty prefix grants every path, and a count of zero grants none. + +**Subscribe Prefix**: +A path prefix the opener may subscribe to, with the same rules. + +**Expires**: +The number of milliseconds until the grant lapses, or 0 for never. +The acceptor revokes a lapsed grant with AUTH_ERROR; the opener uses Expires to present a replacement token in time. + +## AUTH_ERROR {#auth-error} +AUTH_ERROR refuses a token, or revokes it after an AUTH_OK. + +~~~ +AUTH_ERROR Message { + Type (i) = 0x1 + Message Length (i) + Error Code (i) + Reason Phrase (s) +} +~~~ + +**Error Code**: +A code from the [session error registry](#session-error-codes), such as UNAUTHORIZED. + +**Reason Phrase**: +A human-readable reason, at most 8,192 bytes. + ## GROUP The GROUP message contains information about a Group, as well as a reference to the subscription being served. @@ -1324,6 +1402,7 @@ The `Message Length` describes the payload size on the wire. ## moq-lite-06 - Assigned `moq-lite-06` as this draft's protocol identifier. +- Added the Auth Stream (0x7) with AUTH, AUTH_OK, and AUTH_ERROR: either endpoint presents a token on its own stream and learns the paths it may publish and subscribe to. The union of a session's open grants is its scope. A shrink withdraws what it no longer covers, and an announcement outside the scope closes the session with UNAUTHORIZED. A peer without the stream resets it. - Require error-code translation when bridging protocols and draft versions. - Made a repeated non-zero Hop ID in one announcement's Hop ID list a PROTOCOL_VIOLATION, matching draft-lcurley-moq-cluster. Repeated 0 entries stay legal. - Moved the Qmux-over-WebSocket binding details to draft-lcurley-qmux-websocket; the binding itself is unchanged. @@ -1487,6 +1566,10 @@ moq-lite inherits the transport security of the underlying connection: QUIC and ## Bandwidth Probing The `Increase` Probe level (see [Probe Parameter](#probe-parameter)) lets a subscriber ask the publisher to pad the connection up to a target bitrate. A publisher MUST NOT treat the target as authorization to send beyond what congestion control allows: padding is bounded by the congestion window, so probing cannot be used to amplify traffic toward the subscriber or a spoofed address. A publisher that only advertised `Report` MUST NOT pad above its current sending rate. Because all data flows on an established, congestion-controlled session to the connecting peer, moq-lite offers no off-path amplification vector. +## Authorization +An AUTH_OK tells the opener what the acceptor will allow; it does not replace the acceptor's own enforcement, which MUST still refuse what the grant does not cover. +A token presented on an Auth Stream is as sensitive as one in the request URI, and relies on the same transport confidentiality. + ## Session Redirection GOAWAY carries an optional New Session URI that asks the peer to reconnect elsewhere. A malicious or compromised peer could use this to redirect a client to an attacker-controlled server. A recipient MUST validate the URI against local policy (scheme, authority, and port) before reconnecting, and MUST NOT reconnect if validation fails (see [GOAWAY](#goaway)). Migrated subscriptions carry no implicit trust from the prior session; the new session is authenticated independently. diff --git a/js/net/src/auth.ts b/js/net/src/auth.ts new file mode 100644 index 0000000000..6efbc3bd6f --- /dev/null +++ b/js/net/src/auth.ts @@ -0,0 +1,122 @@ +/** + * In-band authorization: present tokens to the peer and learn what they grant. + * + * Each side of a moq-lite-06 session presents the credential its connection already + * carried (the URL, or nothing) right after setup, and learns the {@link Grant} it earned. + * {@link Auth.grant} is the union of every token this side presented, and {@link Auth.add} + * presents another without reconnecting. Mirrors the Rust `moq_net::auth`. + * + * @module + */ + +import { type Getter, Signal } from "@moq/signals"; +import type { SessionCode } from "./error.ts"; +import type * as Path from "./path.ts"; + +/** + * What a peer lets this side do, in this side's own paths (relative to the session). + * + * An empty {@link Path.Patterns} grants nothing; `**` grants everything. + */ +export interface Grant { + /** The broadcasts this side may publish to the peer. */ + publish: Path.Patterns; + /** The broadcasts this side may subscribe to from the peer. */ + subscribe: Path.Patterns; + /** When the grant lapses, in `Date.now()` milliseconds, or undefined for never. */ + expires?: number; +} + +/** Whether two grants allow the same paths and lapse together. */ +export function grantsEqual(a: Grant | undefined, b: Grant | undefined): boolean { + if (a === undefined || b === undefined) return a === b; + return a.publish.equals(b.publish) && a.subscribe.equals(b.subscribe) && a.expires === b.expires; +} + +/** The session or peer carries no AUTH exchange, or takes no tokens in band. */ +export class Unsupported extends Error { + constructor(message = "auth unsupported") { + super(message); + this.name = "Unsupported"; + } +} + +/** A token this side presented. {@link Token.close} withdraws it. */ +export interface Token { + /** This token's own grant: undefined until the peer answers, and again once it ends. */ + readonly grant: Getter; + /** Settles when the token ends: the peer's revocation, or its withdrawal. */ + readonly closed: PromiseLike; + /** Withdraw the token, removing its grant from the union. */ + close(): void; +} + +/** A token the peer presented, waiting for an answer. Answer every one. */ +export interface Request { + /** The token the peer presented. Empty means the credential its connection carried. */ + readonly token: Uint8Array; + /** + * Grant the token until the returned {@link Issued} is revoked or closed. + * + * The session only tells the peer; the origins this side serves and accepts are + * what enforce the grant, and revoking before `expires` is the acceptor's job. + */ + accept(grant: Grant): Issued; + /** Refuse the token with a session code and a reason for the peer. */ + reject(code: SessionCode, reason: string): void; +} + +/** A grant issued to one of the peer's tokens. */ +export interface Issued { + /** Replace the grant, such as with a lowered expiry. */ + update(grant: Grant): void; + /** Revoke the grant with a session code and a reason for the peer. */ + revoke(code: SessionCode, reason: string): void; + /** End the grant without a reason. */ + close(): void; + /** Settles once the peer withdraws the token or its stream ends. */ + readonly closed: PromiseLike; +} + +/** The tokens the peer presents, for an application that answers them itself. */ +export interface Requests { + /** The next token the peer presents, or undefined once the session ends. */ + next(): Promise; + /** Refuse every later token. */ + close(): void; +} + +/** A session's tokens and grants. See the module docs. */ +export interface Auth { + /** The union of every grant this side holds: undefined until the peer first answers. */ + readonly grant: Getter; + + /** + * Present another token, resolving once the peer answers it. Rejects with + * {@link Unsupported} when the session or the peer takes no tokens in band, or with the + * peer's refusal as an `Error.Session`. + */ + add(token: string | Uint8Array): Promise; + + /** + * Answer every token the peer presents, instead of the default: granting what this side + * publishes and consumes for the connection's own credential and refusing any other. + * + * Call it as soon as connect or accept resolves, in the same task: whoever answers is + * decided once, right after. Throws once that has happened. + */ + requests(): Requests; +} + +/** The {@link Auth} of a session without AUTH: no grant, and no tokens. */ +export class None implements Auth { + readonly grant: Getter = new Signal(undefined); + + add(_token: string | Uint8Array): Promise { + return Promise.reject(new Unsupported()); + } + + requests(): Requests { + return { next: async () => undefined, close: () => {} }; + } +} diff --git a/js/net/src/connection/accept.ts b/js/net/src/connection/accept.ts index c048ec5768..4e04985094 100644 --- a/js/net/src/connection/accept.ts +++ b/js/net/src/connection/accept.ts @@ -39,6 +39,8 @@ export interface AcceptProps { type SessionProps = { discovery: boolean; publish?: OriginConsumer; + /** Whether this side dialed; only the dialing side aborts on a publication its grant does not cover. */ + client: boolean; }; /** @@ -66,6 +68,7 @@ async function acceptInner( const wiring: SessionProps = { discovery: props.discovery ?? true, publish: props.publish, + client: false, }; if (protocol === Ietf.ALPN.DRAFT_22) { diff --git a/js/net/src/connection/connect.ts b/js/net/src/connection/connect.ts index 185cd4fecc..6b384a5559 100644 --- a/js/net/src/connection/connect.ts +++ b/js/net/src/connection/connect.ts @@ -122,6 +122,8 @@ export interface ConnectProps { type SessionProps = { discovery: boolean; publish?: OriginConsumer; + /** Whether this side dialed; only the dialing side aborts on a publication its grant does not cover. */ + client: boolean; }; // Save if WebSocket won the last race, so we won't give QUIC a head start next time. @@ -167,6 +169,7 @@ async function connectInner(url: URL, props: Omit, abort: P const wiring: SessionProps = { discovery: props.discovery ?? true, publish: props.publish, + client: true, }; if (props.transport) { diff --git a/js/net/src/connection/established.ts b/js/net/src/connection/established.ts index c8d7278217..547db1b0b4 100644 --- a/js/net/src/connection/established.ts +++ b/js/net/src/connection/established.ts @@ -1,5 +1,6 @@ import type { Getter } from "@moq/signals"; import type * as announce from "../announced.ts"; +import type * as Auth from "../auth.ts"; import type * as Path from "../path.ts"; import type { Probe, Stats } from "./stats.ts"; import type { Transport } from "./transport.ts"; @@ -26,6 +27,13 @@ export interface Established { */ readonly probe: Getter; + /** + * The tokens this side presented and the grant they earned, plus the tokens the peer + * presents. On moq-lite-06 each side presents its connection's credential right after + * setup; elsewhere the grant stays undefined. + */ + readonly auth: Auth.Auth; + /** * Whether the relay supports broadcast discovery: announcing which broadcasts exist under a * prefix. When false, {@link announced} never yields, so a consumer must subscribe blind diff --git a/js/net/src/error.ts b/js/net/src/error.ts index 912fae64cd..9a65d22933 100644 --- a/js/net/src/error.ts +++ b/js/net/src/error.ts @@ -424,6 +424,24 @@ export function fromClose(info: WebTransportCloseInfo): Session | null { return new Session(code, { reason: info.reason }); } +// WebTransport rejects a close reason over 1024 bytes of UTF-8 by throwing, so a reason +// built from peer-supplied data has to be bounded before it gets there. A broadcast path +// is peer-supplied and long enough to reach this on its own. +const MAX_CLOSE_REASON = 1024; + +/** + * The longest prefix of `text` that fits a session close reason. `encodeInto` stops on a + * whole code point, so `read` never lands mid-character the way slicing bytes would. + * + * @internal + */ +export function closeReason(text: string): string { + const encoder = new TextEncoder(); + const buf = new Uint8Array(MAX_CLOSE_REASON); + const { read } = encoder.encodeInto(text, buf); + return text.slice(0, read); +} + /** * Coerce an unknown thrown value into an `Error`. * diff --git a/js/net/src/ietf/connection.ts b/js/net/src/ietf/connection.ts index d4d24eb416..e46e7554ed 100644 --- a/js/net/src/ietf/connection.ts +++ b/js/net/src/ietf/connection.ts @@ -1,5 +1,6 @@ import { type Getter, Signal } from "@moq/signals"; import type * as announce from "../announced.ts"; +import * as Auth from "../auth.ts"; import type { Established } from "../connection/established.ts"; import { type Probe, type Stats, transportStats } from "../connection/stats.ts"; import { type Transport, transportOf } from "../connection/transport.ts"; @@ -42,6 +43,9 @@ export class Connection implements Established { /** moq-transport has no PROBE, so this stays empty; see {@link Established.probe}. */ readonly probe: Getter = new Signal({}); + /** moq-transport carries no AUTH exchange yet; see {@link Established.auth}. */ + readonly auth: Auth.Auth = new Auth.None(); + // The established WebTransport session. #quic: WebTransport; diff --git a/js/net/src/index.ts b/js/net/src/index.ts index 932f7adc23..bc5a166b43 100644 --- a/js/net/src/index.ts +++ b/js/net/src/index.ts @@ -9,6 +9,8 @@ export * as Signals from "@moq/signals"; /** Broadcast announcement streams. */ export * as Announce from "./announce.ts"; +/** In-band authorization: tokens presented to the peer and the grants they earn. */ +export * as Auth from "./auth.ts"; /** Send-side bandwidth estimates split among the tracks sharing a connection. */ export * as Bandwidth from "./bandwidth_api.ts"; /** Broadcast role handles. */ diff --git a/js/net/src/lite/auth.test.ts b/js/net/src/lite/auth.test.ts new file mode 100644 index 0000000000..f1fb805ab1 --- /dev/null +++ b/js/net/src/lite/auth.test.ts @@ -0,0 +1,241 @@ +import { expect, test } from "bun:test"; +import type { Getter } from "@moq/signals"; +import { type Grant, type Issued, Unsupported } from "../auth.ts"; +import { accept as acceptSession, connect as connectSession, type Established } from "../connection/index.ts"; +import { SessionCode, SessionError } from "../error.ts"; +import { createMockTransportPair, type MockTransport } from "../mock.ts"; +import { Producer as OriginProducer } from "../origin.ts"; +import * as Path from "../path.ts"; +import { Reader, Writer } from "../stream.ts"; +import { AuthError, AuthMessage, AuthOk, decodeAuthReplyMaybe, encodeAuthReply } from "./auth.ts"; +import * as Lite from "./index.ts"; + +const url = new URL("https://localhost:4443/test"); + +function patterns(...prefixes: string[]): Path.Patterns { + return new Path.Patterns(prefixes.map((prefix) => Path.Pattern.subtree(prefix))); +} + +function grant(publish: string[], subscribe: string[]): Grant { + return { publish: patterns(...publish), subscribe: patterns(...subscribe) }; +} + +/** Round-trip bytes through a writer and back out of a reader. */ +async function roundTrip(write: (w: Writer) => Promise): Promise { + const chunks: Uint8Array[] = []; + const writer = new Writer( + new WritableStream({ + write(chunk) { + chunks.push(new Uint8Array(chunk)); + }, + }), + ); + await write(writer); + writer.close(); + await writer.closed; + const size = chunks.reduce((n, c) => n + c.byteLength, 0); + const buf = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + buf.set(chunk, offset); + offset += chunk.byteLength; + } + return new Reader(undefined, buf); +} + +test("AUTH round-trips its token", async () => { + const r = await roundTrip((w) => new AuthMessage(new TextEncoder().encode("jwt")).encode(w, Lite.Version.DRAFT_06)); + const msg = await AuthMessage.decode(r, Lite.Version.DRAFT_06); + expect(new TextDecoder().decode(msg.token)).toBe("jwt"); +}); + +test("AUTH_OK and AUTH_ERROR round-trip behind their type", async () => { + const ok = new AuthOk(patterns(""), patterns(), 60_000); + const err = new AuthError(SessionCode.Unauthorized, "expired"); + const r = await roundTrip(async (w) => { + await encodeAuthReply(w, ok, Lite.Version.DRAFT_06); + await encodeAuthReply(w, err, Lite.Version.DRAFT_06); + }); + const first = await decodeAuthReplyMaybe(r, Lite.Version.DRAFT_06); + expect(first).toBeInstanceOf(AuthOk); + if (!(first instanceof AuthOk)) throw new Error("unreachable"); + // The empty prefix grants everything; the empty list grants nothing. + expect(first.publish.equals(new Path.Patterns([Path.Pattern.all()]))).toBe(true); + expect(first.subscribe.size).toBe(0); + expect(first.expires).toBe(60_000); + + const second = await decodeAuthReplyMaybe(r, Lite.Version.DRAFT_06); + expect(second).toEqual(err); + expect(await decodeAuthReplyMaybe(r, Lite.Version.DRAFT_06)).toBeUndefined(); +}); + +test("a grant the prefix wire cannot express is refused, never widened", async () => { + const narrow = new AuthOk(new Path.Patterns([Path.Pattern.literal("room/alice")]), patterns()); + await expect(roundTrip((w) => narrow.encode(w, Lite.Version.DRAFT_06))).rejects.toBeInstanceOf(Unsupported); +}); + +test("lite-05 carries no AUTH", async () => { + await expect( + roundTrip((w) => new AuthMessage(new Uint8Array()).encode(w, Lite.Version.DRAFT_05)), + ).rejects.toThrow(); +}); + +async function waitFor(getter: Getter, ready: (value: T) => boolean): Promise { + let value = getter.peek(); + while (!ready(value)) value = await getter.changed(); + return value; +} + +interface Pair { + client: Established; + server: Established; + transport: MockTransport; +} + +async function connect(opts: { publish?: OriginProducer; serverPublish?: OriginProducer; protocol?: string }) { + const pair = createMockTransportPair(opts.protocol ?? Lite.ALPN_06); + const [client, server] = await Promise.all([ + connectSession({ url, transport: pair.client, publish: opts.publish?.consume() }), + acceptSession({ transport: pair.server, url, publish: opts.serverPublish?.consume() }), + ]); + return { client, server, transport: pair.client } satisfies Pair; +} + +test("both sides learn their default grant", async () => { + const { client, server } = await connect({ publish: new OriginProducer() }); + + // The server consumes anything and publishes nothing. + const clientGrant = await waitFor(client.auth.grant, (g) => g !== undefined); + expect(clientGrant?.publish.equals(patterns(""))).toBe(true); + expect(clientGrant?.subscribe.size).toBe(0); + + // The client publishes, so the server may subscribe to anything. + const serverGrant = await waitFor(server.auth.grant, (g) => g !== undefined); + expect(serverGrant?.publish.equals(patterns(""))).toBe(true); + expect(serverGrant?.subscribe.equals(patterns(""))).toBe(true); + + client.close(); + server.close(); +}); + +test("a token without an acceptor reports unsupported", async () => { + const { client, server } = await connect({ publish: new OriginProducer() }); + await waitFor(client.auth.grant, (g) => g !== undefined); + await expect(client.auth.add("token")).rejects.toBeInstanceOf(Unsupported); + client.close(); + server.close(); +}); + +test("an out-of-scope broadcast closes the session and names the path", async () => { + const origin = new OriginProducer(); + const { client, server, transport } = await connect({ publish: origin }); + const requests = server.auth.requests(); + const issued: Issued[] = []; + void (async () => { + for (;;) { + const request = await requests.next(); + if (!request) break; + issued.push(request.accept(grant(["baz"], []))); + } + })(); + + await waitFor(client.auth.grant, (g) => g !== undefined); + origin.createBroadcast(Path.from("baz/ok")).announce(); + origin.createBroadcast(Path.from("foo/bar")).announce(); + + const info = await transport.closed; + expect(info.closeCode).toBe(SessionCode.Unauthorized); + expect(info.reason).toBe("unauthorized: foo/bar"); + server.close(); +}); + +test("a revoked grant withdraws its broadcasts without closing the session", async () => { + const origin = new OriginProducer(); + const { client, server, transport } = await connect({ publish: origin }); + const requests = server.auth.requests(); + const issued: Issued[] = []; + void (async () => { + for (;;) { + const request = await requests.next(); + if (!request) break; + issued.push(request.accept(grant(["a"], []))); + } + })(); + + await waitFor(client.auth.grant, (g) => g !== undefined); + origin.createBroadcast(Path.from("a/x")).announce(); + + const announced = server.announced(); + const first = await announced.next(); + expect(first?.prefix).toBe(Path.from("a/x")); + expect(first?.kind).toBe("announced"); + + issued[0]?.revoke(SessionCode.Unauthorized, "expired"); + const second = await announced.next(); + expect(second?.prefix).toBe(Path.from("a/x")); + expect(second?.kind).toBe("retracted"); + + // The union is empty but still a grant, and a new token restores it. + const empty = await waitFor(client.auth.grant, (g) => g !== undefined && g.publish.size === 0); + expect(empty?.subscribe.size).toBe(0); + const token = await client.auth.add("again"); + expect(token.grant.peek()?.publish.equals(patterns("a"))).toBe(true); + const third = await announced.next(); + expect(third?.kind).toBe("announced"); + + let closed = false; + void transport.closed.then(() => { + closed = true; + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(closed).toBe(false); + + announced.close(); + client.close(); + server.close(); +}); + +test("a refused token surfaces the acceptor's code and reason", async () => { + const { client, server } = await connect({ publish: new OriginProducer() }); + const requests = server.auth.requests(); + void (async () => { + for (;;) { + const request = await requests.next(); + if (!request) break; + if (request.token.byteLength === 0) request.accept(grant([], [])); + else request.reject(SessionCode.Unauthorized, "bad signature"); + } + })(); + + const err = await client.auth.add("forged").catch((e: unknown) => e); + expect(err).toBeInstanceOf(SessionError); + expect((err as SessionError).code).toBe(SessionCode.Unauthorized); + client.close(); + server.close(); +}); + +test("a refused setup token grants nothing rather than everything", async () => { + const { client, server } = await connect({ publish: new OriginProducer() }); + const requests = server.auth.requests(); + void (async () => { + for (;;) { + const request = await requests.next(); + if (!request) break; + request.reject(SessionCode.Unauthorized, "bad credential"); + } + })(); + + const empty = await waitFor(client.auth.grant, (g) => g !== undefined); + expect(empty?.publish.size).toBe(0); + expect(empty?.subscribe.size).toBe(0); + client.close(); + server.close(); +}); + +test("older versions have no grant", async () => { + const { client, server } = await connect({ publish: new OriginProducer(), protocol: Lite.ALPN_05 }); + expect(client.auth.grant.peek()).toBeUndefined(); + await expect(client.auth.add("token")).rejects.toBeInstanceOf(Unsupported); + client.close(); + server.close(); +}); diff --git a/js/net/src/lite/auth.ts b/js/net/src/lite/auth.ts new file mode 100644 index 0000000000..747c9c3530 --- /dev/null +++ b/js/net/src/lite/auth.ts @@ -0,0 +1,539 @@ +import { type Getter, Signal } from "@moq/signals"; +import { + type Auth as AuthApi, + type Grant, + grantsEqual, + type Issued, + type Request, + type Requests, + type Token, + Unsupported, +} from "../auth.ts"; +import { error, SessionCode, SessionError, StreamCode, StreamError } from "../error.ts"; +import * as Path from "../path.ts"; +import { type Reader, Stream, type Writer } from "../stream.ts"; +import * as Message from "./message.ts"; +import { StreamId } from "./stream.ts"; +import { hasAuth, type Version } from "./version.ts"; + +function guardAuth(version: Version) { + if (!hasAuth(version)) throw new Error("auth not supported for this version"); +} + +/** Longest AUTH_ERROR reason, in bytes, matching the Rust decoder. */ +const MAX_REASON = 8192; + +/** The first message on an Auth Stream: the token the opener presents. Lite06+. */ +export class AuthMessage { + token: Uint8Array; + + constructor(token: Uint8Array) { + this.token = token; + } + + async #encode(w: Writer) { + await w.u53(this.token.byteLength); + if (this.token.byteLength > 0) await w.write(this.token); + } + + static async #decode(r: Reader): Promise { + const size = await r.u53(); + return new AuthMessage(await r.read(size)); + } + + async encode(w: Writer, version: Version): Promise { + guardAuth(version); + return Message.encode(w, this.#encode.bind(this)); + } + + static async decode(r: Reader, version: Version): Promise { + guardAuth(version); + return Message.decode(r, AuthMessage.#decode); + } +} + +// The wire carries prefixes, the ANNOUNCE_REQUEST encoding, so only a union of subtrees is +// representable. Anything else is refused rather than widened to its head. +async function encodePrefixes(w: Writer, patterns: Path.Patterns) { + const prefixes = patterns.toArray().map((pattern) => { + const prefix = pattern.asPrefix(); + if (prefix === undefined) throw new Unsupported(`grant not representable as prefixes: ${pattern}`); + return prefix; + }); + await w.u53(prefixes.length); + for (const prefix of prefixes) await w.string(prefix); +} + +async function decodePrefixes(r: Reader): Promise { + const count = await r.u53(); + const patterns = new Path.Patterns(); + for (let i = 0; i < count; i++) { + patterns.insert(Path.Pattern.subtree(await r.string())); + } + return patterns; +} + +/** The grant a token earns, as the acceptor writes it. */ +export class AuthOk { + publish: Path.Patterns; + subscribe: Path.Patterns; + /** Milliseconds until the grant lapses, or undefined for never. */ + expires?: number; + + constructor(publish: Path.Patterns, subscribe: Path.Patterns, expires?: number) { + this.publish = publish; + this.subscribe = subscribe; + this.expires = expires; + } + + async #encode(w: Writer) { + await encodePrefixes(w, this.publish); + await encodePrefixes(w, this.subscribe); + // 0 means never, so a lapsed grant rounds up to the smallest real expiry. + const expires = + this.expires === undefined ? 0 : Math.min(Math.max(Math.ceil(this.expires), 1), Number.MAX_SAFE_INTEGER); + await w.u53(expires); + } + + static async #decode(r: Reader): Promise { + const publish = await decodePrefixes(r); + const subscribe = await decodePrefixes(r); + const expires = await r.u53(); + return new AuthOk(publish, subscribe, expires === 0 ? undefined : expires); + } + + async encode(w: Writer, version: Version): Promise { + guardAuth(version); + return Message.encode(w, this.#encode.bind(this)); + } + + static async decode(r: Reader, version: Version): Promise { + guardAuth(version); + return Message.decode(r, AuthOk.#decode); + } +} + +/** The acceptor refusing a token, or revoking it after an AUTH_OK. */ +export class AuthError { + /** A code from the session error registry. */ + code: number; + reason: string; + + constructor(code: number, reason: string) { + this.code = code; + this.reason = reason; + } + + async #encode(w: Writer) { + if (new TextEncoder().encode(this.reason).byteLength > MAX_REASON) { + throw new Error("AUTH_ERROR reason exceeds 8,192 bytes"); + } + await w.u53(this.code); + await w.string(this.reason); + } + + static async #decode(r: Reader): Promise { + const code = await r.u53(); + const reason = await r.string(); + if (new TextEncoder().encode(reason).byteLength > MAX_REASON) { + throw new Error("AUTH_ERROR reason exceeds 8,192 bytes"); + } + return new AuthError(code, reason); + } + + async encode(w: Writer, version: Version): Promise { + guardAuth(version); + return Message.encode(w, this.#encode.bind(this)); + } + + static async decode(r: Reader, version: Version): Promise { + guardAuth(version); + return Message.decode(r, AuthError.#decode); + } +} + +/** A message the acceptor writes on the Auth Stream. */ +export type AuthReply = AuthOk | AuthError; + +const AUTH_OK = 0; +const AUTH_ERROR = 1; + +/** Encode an AUTH_OK or AUTH_ERROR behind its type. */ +export async function encodeAuthReply(w: Writer, reply: AuthReply, version: Version): Promise { + await w.u53(reply instanceof AuthOk ? AUTH_OK : AUTH_ERROR); + await reply.encode(w, version); +} + +/** Decode the next AUTH_OK or AUTH_ERROR, or undefined once the stream ends. */ +export async function decodeAuthReplyMaybe(r: Reader, version: Version): Promise { + guardAuth(version); + if (await r.done()) return undefined; + const typ = await r.u53(); + switch (typ) { + case AUTH_OK: + return AuthOk.decode(r, version); + case AUTH_ERROR: + return AuthError.decode(r, version); + default: + throw new Error(`unknown auth reply type: ${typ}`); + } +} + +function union(grants: Iterable): Grant { + const publish = new Path.Patterns(); + const subscribe = new Path.Patterns(); + let expires: number | undefined; + for (const grant of grants) { + for (const pattern of grant.publish) publish.insert(pattern); + for (const pattern of grant.subscribe) subscribe.insert(pattern); + // The earliest expiry is when the union next shrinks. + if (grant.expires !== undefined) expires = Math.min(expires ?? grant.expires, grant.expires); + } + return { publish, subscribe, expires }; +} + +function cancel(): StreamError { + return new StreamError(StreamCode.Cancel, { message: "cancel" }); +} + +/** One token this side presented. */ +class Presented implements Token { + readonly grant = new Signal(undefined); + readonly closed: Promise; + readonly answered: Promise; + readonly setup: boolean; + readonly token: Uint8Array; + + stream?: Stream; + withdrawn = false; + isAnswered = false; + ended = false; + + #close!: (err: Error | null) => void; + #answer!: () => void; + #refuse!: (err: Error) => void; + + constructor(token: Uint8Array, setup: boolean) { + this.token = token; + this.setup = setup; + this.closed = new Promise((resolve) => { + this.#close = resolve; + }); + this.answered = new Promise((resolve, reject) => { + this.#answer = resolve; + this.#refuse = reject; + }); + // A caller that never awaits the answer must not see an unhandled rejection. + this.answered.catch(() => void 0); + } + + answer() { + if (this.isAnswered) return; + this.isAnswered = true; + this.#answer(); + } + + end(err: Error | null) { + if (this.ended) return; + this.ended = true; + this.grant.set(undefined); + if (!this.isAnswered) { + this.isAnswered = true; + this.#refuse(err ?? new Error("withdrawn")); + } + this.#close(err); + } + + close() { + if (this.withdrawn) return; + this.withdrawn = true; + // The stream's loop notices and ends the token; one still opening checks on arrival. + this.stream?.abort(cancel()); + } +} + +/** The peer's token, answered by the application. */ +class PeerRequest implements Request { + readonly token: Uint8Array; + #issued: IssuedGrant; + #answered = false; + + constructor(token: Uint8Array, issued: IssuedGrant) { + this.token = token; + this.#issued = issued; + } + + accept(grant: Grant): Issued { + if (this.#answered) throw new Error("already answered"); + this.#answered = true; + this.#issued.update(grant); + return this.#issued; + } + + reject(code: SessionCode, reason: string): void { + if (this.#answered) throw new Error("already answered"); + this.#answered = true; + this.#issued.revoke(code, reason); + } +} + +/** Our side of one of the peer's tokens: the grant we issued and its stream. */ +class IssuedGrant implements Issued { + readonly closed: Promise; + #stream: Stream; + #version: Version; + #writes = Promise.resolve(); + #done = false; + + constructor(stream: Stream, version: Version) { + this.#stream = stream; + this.#version = version; + // The presenter withdraws by closing or cancelling its side. + this.closed = stream.reader.closed.then( + () => null, + (err: unknown) => (err instanceof StreamError && err.code === StreamCode.Cancel ? null : error(err)), + ); + } + + #write(reply: AuthReply) { + this.#writes = this.#writes + .then(() => encodeAuthReply(this.#stream.writer, reply, this.#version)) + .catch((err: unknown) => { + // The peer already closed the stream: nothing left to tell it. + if (err instanceof StreamError) return; + // This wire carries prefixes only, so a pattern grant cannot be told, only + // withheld: reset the stream, which the presenter reads as unsupported rather + // than refused. Never widen it. Any other reply that fails to encode resets + // the same way. + if (!(err instanceof Unsupported)) console.warn("auth reply not sent", err); + this.#done = true; + this.#stream.writer.reset(err); + }); + } + + update(grant: Grant): void { + if (this.#done) return; + const expires = grant.expires === undefined ? undefined : grant.expires - Date.now(); + this.#write(new AuthOk(grant.publish, grant.subscribe, expires)); + } + + revoke(code: SessionCode, reason: string): void { + if (this.#done) return; + this.#write(new AuthError(code, reason)); + this.close(); + } + + close(): void { + if (this.#done) return; + this.#done = true; + this.#writes = this.#writes.then(() => this.#stream.writer.close()); + } +} + +/** The peer's tokens, queued for the application. */ +class RequestQueue implements Requests { + #queue: PeerRequest[] = []; + #waiters: ((request: PeerRequest | undefined) => void)[] = []; + #closed = false; + + push(request: PeerRequest): boolean { + if (this.#closed) return false; + const waiter = this.#waiters.shift(); + if (waiter) waiter(request); + else this.#queue.push(request); + return true; + } + + next(): Promise { + const next = this.#queue.shift(); + if (next || this.#closed) return Promise.resolve(next); + return new Promise((resolve) => this.#waiters.push(resolve)); + } + + close(): void { + this.#closed = true; + for (const request of this.#queue.splice(0)) { + request.reject(SessionCode.Unauthorized, "not accepting tokens"); + } + for (const waiter of this.#waiters.splice(0)) waiter(undefined); + } +} + +/** Constructor options for {@link AuthSession}. @internal */ +export interface AuthSessionProps { + quic: WebTransport; + version: Version; + /** What the default acceptor grants the peer's connection credential. */ + peerGrant: Grant; +} + +/** + * A lite session's tokens and grants: presents ours, one AUTH stream each, and answers + * the peer's. + * + * @internal + */ +export class AuthSession implements AuthApi { + #quic: WebTransport; + #version: Version; + #peerGrant: Grant; + + #union = new Signal(undefined); + // The peer replied to some token, so the union is known even when empty. + #replied = false; + #tokens = new Set(); + #setupPending = new Signal(0); + #acceptor: "undecided" | "default" | RequestQueue = "undecided"; + #closed = false; + + // Whoever answers the peer's tokens is decided once, after the task that established + // the session: an app that calls requests() as soon as connect/accept resolves always + // wins, however quickly the peer's first token arrives. + #decided = new Promise((resolve) => setTimeout(resolve, 0)); + + constructor({ quic, version, peerGrant }: AuthSessionProps) { + this.#quic = quic; + this.#version = version; + this.#peerGrant = peerGrant; + + // Present the connection's own credential right away, so both sides learn their + // grant without waiting on the app. + if (hasAuth(version)) this.#present(new Uint8Array(), true); + } + + get grant(): Getter { + return this.#union; + } + + async add(token: string | Uint8Array): Promise { + if (!hasAuth(this.#version) || this.#closed) throw new Unsupported(); + const bytes = typeof token === "string" ? new TextEncoder().encode(token) : token; + const presented = this.#present(bytes, false); + await presented.answered; + return presented; + } + + requests(): Requests { + if (this.#acceptor !== "undecided") throw new Error("auth requests already taken or answered by default"); + const queue = new RequestQueue(); + if (!hasAuth(this.#version)) queue.close(); + this.#acceptor = queue; + return queue; + } + + /** Resolves once every token the session presented at setup has its first reply. */ + async setupAnswered(): Promise { + while (this.#setupPending.peek() > 0) await this.#setupPending.changed(); + } + + /** Answer one of the peer's AUTH streams, for the life of its token. */ + async serve(stream: Stream): Promise { + const msg = await AuthMessage.decode(stream.reader, this.#version); + await this.#decided; + if (this.#acceptor === "undecided") this.#acceptor = "default"; + + const issued = new IssuedGrant(stream, this.#version); + if (this.#acceptor instanceof RequestQueue) { + const request = new PeerRequest(msg.token, issued); + if (!this.#acceptor.push(request)) request.reject(SessionCode.Unauthorized, "not accepting tokens"); + } else if (msg.token.byteLength > 0) { + // Only the connection's own credential has a default answer. Resetting reads as + // unsupported to the presenter, the same as a peer that predates AUTH. + throw new Unsupported("no acceptor for tokens"); + } else { + issued.update(this.#peerGrant); + } + + await issued.closed; + issued.close(); + } + + /** End the session: fail every pending token and close the requests. */ + close() { + if (this.#closed) return; + this.#closed = true; + for (const token of this.#tokens) token.end(new Error("session closed")); + this.#tokens.clear(); + if (this.#acceptor instanceof RequestQueue) this.#acceptor.close(); + } + + #present(token: Uint8Array, setup: boolean): Presented { + const presented = new Presented(token, setup); + this.#tokens.add(presented); + if (setup) this.#setupPending.update((n) => n + 1); + void this.#run(presented); + return presented; + } + + async #run(token: Presented) { + let result: Error | null = null; + try { + const stream = await Stream.open(this.#quic); + token.stream = stream; + if (token.withdrawn) throw cancel(); + + await stream.writer.u53(StreamId.Auth); + await new AuthMessage(token.token).encode(stream.writer, this.#version); + + for (;;) { + const reply = await decodeAuthReplyMaybe(stream.reader, this.#version); + if (!reply) { + // The peer ended the grant without revoking it, or closed without ever + // answering. + result = token.isAnswered ? null : new Unsupported(); + break; + } + if (reply instanceof AuthOk) { + const expires = reply.expires === undefined ? undefined : Date.now() + reply.expires; + token.grant.set({ publish: reply.publish, subscribe: reply.subscribe, expires }); + this.#replied = true; + this.#answered(token); + this.#recompute(); + continue; + } + console.warn(`auth token refused: code=${reply.code} reason=${reply.reason}`); + // A refused setup token leaves an empty union, not an unknown (unrestricted) one. + this.#replied = true; + result = new SessionError(reply.code as SessionCode, { reason: reply.reason }); + stream.close(); + break; + } + } catch (err: unknown) { + if (token.withdrawn) { + result = null; + } else if (!token.isAnswered && err instanceof StreamError) { + // A peer that predates AUTH resets a stream type it does not know. + result = new Unsupported(); + } else { + result = error(err); + if (!this.#closed) console.warn("auth token ended", result); + } + } + + // A token that ends unanswered counts as answered for enforcement: its refusal is + // the reply. + const unanswered = !token.isAnswered; + token.end(result); + if (unanswered && token.setup) this.#setupPending.update((n) => n - 1); + this.#tokens.delete(token); + this.#recompute(); + } + + #answered(token: Presented) { + if (token.isAnswered) return; + token.answer(); + if (token.setup) this.#setupPending.update((n) => n - 1); + } + + #recompute() { + const granted: Grant[] = []; + for (const token of this.#tokens) { + const grant = token.grant.peek(); + if (grant) granted.push(grant); + } + // Undefined until the first reply; an empty union afterwards grants nothing. + if (granted.length === 0 && !this.#replied) return; + const next = union(granted); + if (!grantsEqual(next, this.#union.peek())) this.#union.set(next); + } +} diff --git a/js/net/src/lite/connection.ts b/js/net/src/lite/connection.ts index 3aca065ab6..62100f5635 100644 --- a/js/net/src/lite/connection.ts +++ b/js/net/src/lite/connection.ts @@ -1,15 +1,17 @@ import { type Getter, Signal } from "@moq/signals"; import type * as announce from "../announced.ts"; +import type * as Auth from "../auth.ts"; import type { Established } from "../connection/established.ts"; import { type Probe, type Stats, transportStats } from "../connection/stats.ts"; import { type Transport, transportOf } from "../connection/transport.ts"; import { error, fromClose, StreamCode, StreamError } from "../error.ts"; import { type Hop, randomHop } from "../hop.ts"; import type { Consumer as OriginConsumer } from "../origin.ts"; -import type * as Path from "../path.ts"; +import * as Path from "../path.ts"; import { type Reader, Readers, Stream, Writer } from "../stream.ts"; import { registerWire } from "../wire.ts"; import { AnnounceRequest } from "./announce.ts"; +import { AuthSession } from "./auth.ts"; import { Fetch } from "./fetch.ts"; import { Goaway } from "./goaway.ts"; import { Group } from "./group.ts"; @@ -20,7 +22,7 @@ import { DataType, StreamId } from "./stream.ts"; import { Subscribe } from "./subscribe.ts"; import { Subscriber } from "./subscriber.ts"; import { Track as TrackMessage } from "./track.ts"; -import { hasDatagrams, hasProbeRtt, hasSetupStream, type Version, versionName } from "./version.ts"; +import { hasAuth, hasDatagrams, hasProbeRtt, hasSetupStream, type Version, versionName } from "./version.ts"; /** * Constructor options for {@link Connection}. @@ -40,6 +42,12 @@ export interface ConnectionProps { discovery?: boolean; /** The origin whose broadcasts are served to the peer. Omit to publish nothing. */ publish?: OriginConsumer; + /** + * Whether this side dialed. Only the dialing side aborts on a publication its grant does + * not cover: a server publishes whatever the peer may read, not what it intends to push. + * Defaults to true. + */ + client?: boolean; } /** @@ -78,6 +86,9 @@ export class Connection implements Established { /** The peer's PROBE estimates; see {@link Established.probe}. */ readonly probe: Getter; + // Our tokens and grants, and the answers to the peer's; see {@link Established.auth}. + #auth: AuthSession; + /** Random per-connection Hop ID. Shared by Publisher (for outbound hop * chains) and Subscriber (available for optional self-filtering on announces). */ readonly hop: Hop; @@ -94,6 +105,9 @@ export class Connection implements Established { // Written by the Subscriber as PROBE messages arrive. #probe = new Signal({}); + // Whether we dialed; see {@link ConnectionProps.client}. + #client: boolean; + /** * The {@link Role} the peer advertised in its SETUP, for a server deciding whether the * peer's authorization grants the direction it intends to use. @@ -112,7 +126,7 @@ export class Connection implements Established { * * @internal */ - constructor({ url, quic, version, session, discovery = true, publish }: ConnectionProps) { + constructor({ url, quic, version, session, discovery = true, publish, client = true }: ConnectionProps) { this.url = url; this.#quic = quic; this.#session = session; @@ -124,8 +138,26 @@ export class Connection implements Established { this.probe = this.#probe; this.hop = randomHop(); - this.#publisher = new Publisher(this.#quic, this.#version, this.hop, publish); - this.#subscriber = new Subscriber(this.#quic, this.#version, this.hop, this.#probe, this.#peerSetup); + // What the peer's connection credential earns by default: publishing anything to us, + // since we consume on demand, and subscribing to whatever we publish. + this.#auth = new AuthSession({ + quic, + version, + peerGrant: { + publish: new Path.Patterns([Path.Pattern.all()]), + subscribe: new Path.Patterns(publish ? [Path.Pattern.all()] : []), + }, + }); + this.#publisher = new Publisher(this.#quic, this.#version, this.hop, publish, this.#auth.grant); + this.#subscriber = new Subscriber( + this.#quic, + this.#version, + this.hop, + this.#probe, + this.#peerSetup, + this.#auth.grant, + ); + this.#client = client; registerWire(this, { consume: (path) => this.#subscriber.consume(path) }); void this.#run(); @@ -134,7 +166,13 @@ export class Connection implements Established { /** * Closes the connection. */ + /** Our tokens and grants; see {@link Established.auth}. */ + get auth(): Auth.Auth { + return this.#auth; + } + close() { + this.#auth.close(); this.#publisher.close(); this.#subscriber.close(); @@ -155,6 +193,12 @@ export class Connection implements Established { tasks.push(this.#subscriber.runProbe()); + // Fail loud on a publication our grant never covers, once the peer has answered the + // credential we presented at setup. + if (this.#client && hasAuth(this.#version)) { + tasks.push(this.#publisher.runEnforce(this.#auth.setupAnswered())); + } + // Route incoming QUIC datagrams into their subscriptions (lite-05+; runDatagrams // no-ops on a transport that doesn't carry them). if (hasDatagrams(this.#version)) { @@ -244,6 +288,8 @@ export class Connection implements Established { await this.#publisher.runTrackInfo(msg, stream); } else if (typ === StreamId.Probe) { await this.#publisher.runProbe(stream); + } else if (typ === StreamId.Auth) { + await this.#auth.serve(stream); } else if (typ === StreamId.Goaway) { const msg = await Goaway.decode(stream.reader, this.#version); console.info("received goaway:", msg.uri); diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index 157eef4382..969b502d68 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -1,6 +1,7 @@ import { type Dispose, type Getter, Signal } from "@moq/signals"; +import type { Grant } from "../auth.ts"; import type * as broadcast from "../broadcast.ts"; -import { error, NotFound, reason, StreamCode, StreamError } from "../error.ts"; +import { closeReason, error, NotFound, reason, SessionCode, SessionError, StreamCode, StreamError } from "../error.ts"; import type * as group from "../group.ts"; import { type Hop, type Route, routesEqual } from "../hop.ts"; import { hooks } from "../internal.ts"; @@ -352,6 +353,11 @@ export class Publisher { #publish?: OriginConsumer; + // Our grant: only what it lets us publish is announced and served, and a shrink + // withdraws what it no longer covers. Undefined until the peer answers, and forever on a + // version without AUTH, which allows everything. + #grant?: Getter; + // TRACK_INFO is immutable per track, so resolve it from the application once // (via a throwaway subscribe whose info() resolves when the app calls accept) // and reuse it for every later TRACK request of the same track. Keyed by the @@ -366,11 +372,19 @@ export class Publisher { * @param version - Negotiated protocol version * @param origin - Hop id shared with the Subscriber * @param publish - The origin whose broadcasts this session serves; omit to publish nothing + * @param grant - The union of our tokens' grants, which bounds what we publish * * @internal */ - constructor(quic: WebTransport, version: Version, hop: Hop, publish?: OriginConsumer) { + constructor( + quic: WebTransport, + version: Version, + hop: Hop, + publish?: OriginConsumer, + grant?: Getter, + ) { this.#quic = quic; + this.#grant = grant; this.version = version; this.hop = hop; const origin = publish && wireOf(publish); @@ -453,21 +467,42 @@ export class Publisher { await encodeAnnounceBroadcast(stream.writer, { status: "endedId", id }, this.version); }; + // What the peer currently sees: the table under the prefix, less whatever our grant + // does not let us publish. + const visible = (table: ReadonlyMap): Map => { + const out = presented(msg.prefix, table); + const grant = this.#grant?.peek(); + if (grant) { + for (const suffix of [...out.keys()]) { + if (!grant.publish.matches(Path.join(msg.prefix, suffix))) out.delete(suffix); + } + } + return out; + }; + // Subscribe BEFORE writing anything: every encode below awaits the wire, and a publish // landing in that window only notifies the listeners already registered. One created // afterwards would sleep through it, leaving the change unannounced until something - // unrelated moved. + // unrelated moved. A grant change re-diffs the same way, withdrawing what it no longer + // covers and announcing what it now does. // TODO Make a better helper within Signals. - let dispose!: Dispose; - let changed = new Promise | undefined>((resolve) => { - dispose = this.#advertised.changed(resolve); - }); + let dispose: Dispose = () => {}; + const arm = () => + new Promise<"changed">((resolve) => { + const table = this.#advertised.changed(() => resolve("changed")); + const grant = this.#grant?.changed(() => resolve("changed")); + dispose = () => { + table(); + grant?.(); + }; + }); + let changed = arm(); try { const initial = this.#advertised.peek(); if (!initial) return; // closed - for (const [name, snap] of presented(msg.prefix, initial)) { + for (const [name, snap] of visible(initial)) { active.set(name, snap); } @@ -499,22 +534,17 @@ export class Publisher { } for (;;) { - const advertised = await Promise.race([changed, stream.reader.closed]); + const woke = await Promise.race([changed, stream.reader.closed.then(() => "closed" as const)]); dispose(); - if (!advertised) break; + if (woke === "closed") break; // Re-arm before reading, so an advertise that lands while we write is not lost. - changed = new Promise | undefined>((resolve) => { - dispose = this.#advertised.changed(resolve); - }); + changed = arm(); const latest = this.#advertised.peek(); if (!latest) break; - const updated = new Map(); - for (const [name, snap] of presented(msg.prefix, latest)) { - updated.set(name, snap); - } + const updated = visible(latest); for (const [suffix, snap] of active) { const cur = updated.get(suffix); @@ -544,6 +574,14 @@ export class Publisher { * @internal */ async runSubscribe(msg: Subscribe, stream: Stream) { + // Serve only what our grant lets us publish, and stop once it no longer does. + // Checked before resolving, so a denied request never reaches the origin. + const denied = () => this.#denied(msg.broadcast); + if (denied()) { + stream.writer.reset(new SessionError(SessionCode.Unauthorized, { reason: msg.broadcast })); + return; + } + let front: broadcast.Consumer | undefined; try { front = @@ -577,6 +615,14 @@ export class Publisher { let datagrams = Promise.resolve(); let controls: SubscriptionControls | undefined; + const revoked = new SessionError(SessionCode.Unauthorized, { reason: msg.broadcast }); + const disposeGrant = this.#grant?.subscribe(() => { + if (!denied()) return; + console.debug(`publish revoked: broadcast=${msg.broadcast} track=${track.name}`); + track.close(revoked); + stream.abort(revoked); + }); + try { let timescale: Timescale = Timescale.MILLI; @@ -650,6 +696,8 @@ export class Publisher { track.close(e); stream.abort(e); await Promise.all([datagrams, controls?.decoding]); + } finally { + disposeGrant?.(); } } @@ -663,6 +711,10 @@ export class Publisher { stream.writer.reset(new Error("fetch requires moq-lite-05 or newer")); return; } + if (this.#denied(msg.broadcast)) { + stream.writer.reset(new SessionError(SessionCode.Unauthorized, { reason: msg.broadcast })); + return; + } let front: broadcast.Consumer | undefined; try { @@ -865,6 +917,10 @@ export class Publisher { * @internal */ async runTrackInfo(msg: TrackMessage, stream: Stream) { + if (this.#denied(msg.broadcast)) { + stream.writer.reset(new SessionError(SessionCode.Unauthorized, { reason: msg.broadcast })); + return; + } try { const front = this.#broadcasts.peek()?.get(msg.broadcast) ?? @@ -881,6 +937,12 @@ export class Publisher { } } + // Whether our grant excludes publishing this broadcast. No grant yet allows it. + #denied(broadcast: Path.Valid): boolean { + const grant = this.#grant?.peek(); + return grant !== undefined && !grant.publish.matches(broadcast); + } + // Resolve (and cache) a track's immutable TRACK_INFO by asking the application. // `resolveTrackInfo` triggers a TrackRequest the app answers with accept(TrackInfo); // only the immutable properties are needed (not the groups). Cached because they're @@ -1168,6 +1230,68 @@ export class Publisher { } } + /** + * Close the session when our origin publishes a broadcast our grant does not cover, + * instead of leaving it to wait for a subscription that never comes. + * + * Starts once the tokens the session presented at setup are answered, then checks each + * broadcast when it first appears. A grant that later shrinks withdraws what it no longer + * covers (see {@link runAnnounce}) without closing anything: the grant is read before the + * table, so a revocation is never mistaken for a new unauthorized publication. + * + * @internal + */ + async runEnforce(setupAnswered: Promise): Promise { + const closed = this.#quic.closed.then( + () => "closed" as const, + () => "closed" as const, + ); + if ((await Promise.race([setupAnswered.then(() => "ready" as const), closed])) === "closed") return; + + // Every broadcast admitted so far that is still published. + const live = new Set(); + for (;;) { + let dispose: Dispose = () => {}; + const woke = new Promise<"changed">((resolve) => { + const table = this.#advertised.changed(() => resolve("changed")); + const grant = this.#grant?.changed(() => resolve("changed")); + dispose = () => { + table(); + grant?.(); + }; + }); + + const grant = this.#grant?.peek(); + const table = this.#advertised.peek(); + if (!table) { + dispose(); + return; + } + if (grant) { + for (const path of live) { + if (!table.has(path)) live.delete(path); + } + for (const path of table.keys()) { + if (live.has(path)) continue; + if (!grant.publish.matches(path)) { + console.error(`publishing outside our grant; closing the session: broadcast=${path}`); + this.#quic.close({ + closeCode: SessionCode.Unauthorized, + reason: closeReason(`unauthorized: ${path}`), + }); + dispose(); + return; + } + live.add(path); + } + } + + const why = await Promise.race([woke, closed]); + dispose(); + if (why === "closed") return; + } + } + close() { // The broadcasts belong to the origin, which outlives this session; closing here // only drops the borrow. The peer sees the unannounce when the streams die. diff --git a/js/net/src/lite/stream.ts b/js/net/src/lite/stream.ts index 9060566f2b..8907a90604 100644 --- a/js/net/src/lite/stream.ts +++ b/js/net/src/lite/stream.ts @@ -16,6 +16,7 @@ export const StreamId = { Probe: 4, Goaway: 5, Track: 6, + Auth: 7, ClientCompat: 0x20, ServerCompat: 0x21, } as const; diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index 7ef0f22e36..4fd1d97c76 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -1,9 +1,20 @@ -import { Signal } from "@moq/signals"; +import { type Getter, Signal } from "@moq/signals"; import * as announce from "../announced.ts"; +import type { Grant } from "../auth.ts"; import * as broadcast from "../broadcast.ts"; import type { Probe as ProbeStats } from "../connection/stats.ts"; import { BroadcastCache } from "../consume.ts"; -import { controlTimeout, error, ProtocolViolation, reason, StreamCode, StreamError } from "../error.ts"; +import { + closeReason, + controlTimeout, + error, + ProtocolViolation, + reason, + SessionCode, + SessionError, + StreamCode, + StreamError, +} from "../error.ts"; import * as netGroup from "../group.ts"; import { Cost, type Hop, MAX_HOPS, type Route, routesEqual, UNKNOWN_HOP } from "../hop.ts"; import { groupBounds, scopeCaptures, scopeHead, scopeOverlaps } from "../internal.ts"; @@ -83,20 +94,6 @@ interface SubscribeEntry { // two report the same thing, where the default 0 would tell the peer it closed cleanly. const PROTOCOL_VIOLATION_CODE = 15; -// WebTransport rejects a close reason over 1024 bytes of UTF-8 by throwing, so a reason -// built from peer-supplied data has to be bounded before it gets there. A broadcast path -// is peer-supplied and long enough to reach this on its own. -const MAX_CLOSE_REASON = 1024; - -// The longest prefix of `text` that fits a close reason. `encodeInto` stops on a whole -// code point, so `read` never lands mid-character the way slicing bytes would. -function closeReason(text: string): string { - const encoder = new TextEncoder(); - const buf = new Uint8Array(MAX_CLOSE_REASON); - const { read } = encoder.encodeInto(text, buf); - return text.slice(0, read); -} - export class Subscriber { #quic: WebTransport; @@ -129,6 +126,11 @@ export class Subscriber { // Distinguishes failures from streams torn down by Subscriber.close(). #closed = new AbortController(); + + // Our grant: a subscription it stops covering is cancelled. Undefined until the peer + // answers, and forever on a version without AUTH, which allows everything. + #grant?: Getter; + /** * Creates a new Subscriber instance. * @param quic - The WebTransport session to use @@ -136,6 +138,7 @@ export class Subscriber { * @param origin - Hop id shared with the Publisher * @param probe - Optional sink for the peer's PROBE estimates * @param peerSetup - Optional peer SETUP slot for capability gating (lite-05+) + * @param grant - The union of our tokens' grants, which bounds what we subscribe to * * @internal */ @@ -145,12 +148,20 @@ export class Subscriber { hop: Hop, probe?: Signal, peerSetup?: Signal, + grant?: Getter, ) { this.#quic = quic; this.version = version; this.hop = hop; this.#probe = probe; this.#peerSetup = peerSetup; + this.#grant = grant; + } + + // Whether our grant no longer lets us subscribe to `broadcast`. + #denied(broadcast: Path.Valid): boolean { + const grant = this.#grant?.peek(); + return grant !== undefined && !grant.subscribe.matches(broadcast); } /** @@ -498,6 +509,11 @@ export class Subscriber { request.reject(new Error(EMPTY_RANGE)); return; } + const unauthorized = new SessionError(SessionCode.Unauthorized, { reason: broadcast }); + if (this.#denied(broadcast)) { + request.reject(unauthorized); + return; + } // `timescale` stays undefined until TRACK_INFO (or, on older drafts, // implicit defaults) resolves it; runGroup blocks on it before decoding. @@ -547,6 +563,13 @@ export class Subscriber { } const { stream, producer } = opened; + // Losing the grant ends the subscription, leaving the session alone. + const disposeGrant = this.#grant?.subscribe(() => { + if (!this.#denied(broadcast)) return; + console.debug(`subscribe revoked: id=${id} broadcast=${broadcast} track=${request.name}`); + producer.close(unauthorized); + stream.abort(unauthorized); + }); try { // Watch for subscription changes and send SUBSCRIBE_UPDATE. Lite01/Lite02 // don't carry SUBSCRIBE_UPDATE on the wire, so skip the watcher there @@ -586,6 +609,7 @@ export class Subscriber { console.warn(`subscribe error: id=${id} broadcast=${broadcast} track=${request.name} error=${reason(e)}`); stream.abort(e); } finally { + disposeGrant?.(); this.#subscribes.delete(id); } } diff --git a/js/net/src/lite/version.ts b/js/net/src/lite/version.ts index 5f098968c9..395f06f923 100644 --- a/js/net/src/lite/version.ts +++ b/js/net/src/lite/version.ts @@ -62,6 +62,24 @@ export function hasDatagrams(version: Version): boolean { } } +/** + * Whether either endpoint may open an Auth Stream (0x7) to present a token and learn its + * grant. Added to lite-06, whose peers that predate it reset the stream as an unknown type. + */ +export function hasAuth(version: Version): boolean { + // Explicitly list older versions so future versions default to carrying AUTH. + switch (version) { + case Version.DRAFT_01: + case Version.DRAFT_02: + case Version.DRAFT_03: + case Version.DRAFT_04: + case Version.DRAFT_05: + return false; + default: + return true; + } +} + /** Whether announce streams begin with ANNOUNCE_OK and omit the sender's origin from each hop chain. */ export function hasAnnounceOk(version: Version): boolean { // Explicitly list older versions so future versions keep the lite-05+ announce behavior. diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index 9ffe7a6664..bb31199af0 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -89,9 +89,6 @@ existing lite-06 ALPN. ## Quests -- [Lite stream](/quest/m1/auth/lite.md) - both sides of a lite-06 session - exchange grants over AUTH streams, exposed as `Session::auth()`, and an - out-of-scope announce aborts the session - [Relay tokens](/quest/m1/auth/relay-refresh.md) - the relay verifies tokens sent in band, unions their grants, and cancels only work that loses access - [moq-transport](/quest/m1/auth/moq-transport.md) - the same exchange as a diff --git a/quest/m1/auth/bindings.md b/quest/m1/auth/bindings.md index 596e8437d9..35311cd6d3 100644 --- a/quest/m1/auth/bindings.md +++ b/quest/m1/auth/bindings.md @@ -39,6 +39,5 @@ Additive. ## Required -- [Lite stream](/quest/m1/auth/lite.md) - supplies `Session::auth()` - [Relay tokens](/quest/m1/auth/relay-refresh.md) - supplies the connection accessor and a relay that answers a real token, which the wrapper tests need diff --git a/quest/m1/auth/lite.md b/quest/m1/auth/lite.md deleted file mode 100644 index 08b65bd7f6..0000000000 --- a/quest/m1/auth/lite.md +++ /dev/null @@ -1,151 +0,0 @@ -# [L] Lite stream - -## Goal - -Both sides of a moq-lite-06 session learn what they may publish and subscribe -to. Right after SETUP each side opens an AUTH stream carrying an empty token -and receives its grant; `Session::auth()` exposes the union of every grant and -lets the app present more tokens, each on its own stream. A publisher whose -origin announces a broadcast outside that union aborts the session with -`Unauthorized` naming the path instead of waiting for a solicitation that -never comes. Rust and JavaScript both speak it, and the lite draft specifies -it. - -## Plan - -Use pattern unions in public grants from their first introduction, matching -dev origin scopes. Legacy AUTH wire codecs convert only representable prefix -unions explicitly and refuse every unrepresentable grant. Never widen an -exact or suffix pattern into a prefix. The pattern-interest quest changes wire -encoding without another public grant-type migration. - -### Wire - -Add stream type `0x7` AUTH, creator either, to the bidirectional stream table -in `drafts/draft-lcurley-moq-lite.md` and a section beside Probe modeled on -it. One stream carries one token for the life of that token. Messages, -encoded like PROBE with the `(i)`, `(b)`, `(s)` notation the draft uses: - -- `AUTH { Token (b) }`, written once by the opener as the stream's first - message. An empty token means the credential the connection already - presented, or nothing. -- `AUTH_OK { Publish Count (i), Publish Prefix (s)..., Subscribe Count (i), - Subscribe Prefix (s)..., Expires (i) }`, written by the acceptor. The - prefixes use the encoding and rules ANNOUNCE_REQUEST's prefix uses: a list - holding the empty prefix grants everything, an empty list grants nothing. - Expires is milliseconds until the grant lapses, zero for never. The acceptor - MAY write further AUTH_OK messages on the same stream to update that - token's grant, such as a lowered expiry; the latest one is the token's grant. -- `AUTH_ERROR { Code (i), Reason (s) }`, written by the acceptor. As the - first reply it refuses the token; after an AUTH_OK it revokes it. Either - way the acceptor then closes the stream. - -A session's scope is the union of the grants of its open AUTH streams. The -opener withdraws a token by closing or resetting its stream. A stream reset -before any reply, which is what a peer does with a stream type it does not -understand (the existing rule at the STREAM_TYPE section), leaves the opener -with no grant for that token, and the client reports it as `Unsupported` -rather than a refusal. Record it in the lite-06 changelog. Run -`just drafts check`. - -### Model - -New module `rs/moq-net/src/auth.rs`, public as `moq_net::auth`: - -- `auth::Grant { publish: Patterns, subscribe: Patterns, expires: - Option }`, a plain value in the presenter's root. -- `auth::Handle`, cloneable, returned by `Session::auth()`. `grant()` is a - watchable union of every open token's grant (`None` until the first - AUTH_OK; the `kio` shared cell the session already uses for the peer SETUP - is the pattern). `add(token) -> Result` opens a stream - and resolves with the first reply. `auth::Token` exposes that token's own - watchable `grant()` and `closed()`, and dropping it closes the stream, - withdrawing the token. `requests()` yields `auth::Request { token, - accept(Grant) -> auth::Issued, reject(code, reason) }` for the acceptor - side, where `auth::Issued` can `update(Grant)` and `revoke(code, reason)` - and its drop ends the stream; a request left unanswered is a bug, so - dropping one rejects with `Unauthorized`. -- The default acceptor needs no app code, and whether it is in charge is - decided once, before the driver starts, never per message: the server-side - `moq_net::Request` builder exposes `auth()` before `.ok()`, and a caller - that takes `requests()` there owns every AUTH the session receives, the - initial empty one included. With no such consumer, the driver answers the - empty token from the session's own origin handles, publish from the - subscriber half's `origin::Producer::allowed()` and subscribe from the - publisher half's `origin::Consumer::allowed()`, with no expiry. A missing - half grants nothing. That is what makes the cluster case free: both relays - present mTLS, both learn the unrestricted grant. A non-empty token with no - consumer is rejected `Unsupported`; the relay is the consumer in - [Relay tokens](/quest/m1/auth/relay-refresh.md). - -`Session` is protocol-agnostic (`rs/moq-net/src/session.rs` holds a version, -bandwidth handles, and a boxed driver), so `lite::start` builds the handle and -hands it to `Session::new`; `ietf::start` hands one whose `grant()` stays -`None` and whose `add` fails `Unsupported` until -[moq-transport](/quest/m1/auth/moq-transport.md) fills it in. - -### Session - -Accepted bidi streams are dispatched by the publisher half alone -(`Publisher::handle` in `rs/moq-net/src/lite/publisher.rs`), which holds only -the publish-side `origin::Consumer`; the grant needs both halves' `allowed()`, -so the handle is built in `lite::start` with both handles and shared into the -publisher and subscriber, the way the peer-setup slot is. Opening: the -subscriber half opens the empty-token AUTH stream where it opens Probe, after -the peer SETUP resolves, and `add` opens further ones on demand. Accepting: a -new `recv_auth` beside `recv_probe` runs one stream for the life of its -token, on error aborting only that stream. - -Fail loud: the publisher half already scopes its origin per solicited prefix; -add a check that every path its origin announces is covered by the union's -publish prefixes at initial admission and when a new publication is -attempted. A later grant shrink withdraws previously authorized publications -and cancels subscriptions that lose access on this session, without aborting -the session or mutating the shared origin. Process this resize before -checking new attempts, so a revocation is not mistaken for a new unauthorized -publication. It waits only for the tokens the session presented itself at setup -(the empty one, and the configured ones once -[Token in band](/quest/m1/auth/token-in-band.md) lands), so a peer that never -answers an app-added token cannot suspend enforcement; an app awaits `add` -before publishing what that token unlocks. On a miss, abort the session with -`Error::Unauthorized` and put the path in the close reason (`lite::start`'s -teardown already formats one; log it at error level too, since `Error` -carries no payload). - -Gate everything on `Version::Lite06`; older versions never open the stream -and `auth().grant()` stays `None` there. Land the accept side and the -`Unsupported` handling before any build opens the stream, since -`moq-lite-06` is one ALPN with no sub-version. - -### JavaScript - -Mirror in `js/net/src/lite/`: `stream.ts` gains `Auth: 7`, an `auth.ts` with -the three messages, `connection.ts` opens and answers the streams, and -`Established` in `js/net/src/connection/established.ts` gains `auth` with -`grant: Getter` (a `@moq/signals` signal like `probe`), -`add(token): Promise`, and `requests()`. The publisher in -`js/net/src/lite/publisher.ts` aborts the connection the same way. - -### Docs and tests - -Update `doc/concept` where sessions and authorization are described, and the -moq-net guide if it lists stream types. Tests: encode and decode round trips -and the version gate; both sides receive a grant from scoped origins, -including the empty-prefix and empty-list spellings; a publish-only session -grants no subscribe; an out-of-scope announce aborts with `Unauthorized` and -names the path in the reason, while an in-scope one does not; two tokens union -and closing one stream shrinks the union on both sides without disconnecting -or aborting work still covered; revoked publications are withdrawn even if -they remain in the shared origin; an empty union can later be authorized -again; an update AUTH_OK -replaces one token's grant without touching the other; an unanswered -app-added token does not suspend the check; a reset AUTH stream reports -`Unsupported`; Rust to JS and JS to Rust interop in the existing -cross-language harness. - -On main, additive. - -## Related - -- [Pattern interest](/quest/m1/path-patterns.md) - moves the prefix - fields here and in ANNOUNCE_REQUEST to patterns together diff --git a/quest/m1/auth/moq-transport.md b/quest/m1/auth/moq-transport.md index d6d7c56724..8bf5a64270 100644 --- a/quest/m1/auth/moq-transport.md +++ b/quest/m1/auth/moq-transport.md @@ -65,8 +65,8 @@ capability limit in the draft and token-in-band guidance. option round trip with the tri-state `from_setup` shape solicit uses, the three messages as `Message` impls with IDs from the draft, and negotiation recorded on the peer state. `run_dispatch` in `ietf/session.rs` routes an AUTH -request stream to the shared `auth::Handle` from -[Lite stream](/quest/m1/auth/lite.md); `ietf::start` opens the empty-token +request stream to the shared `auth::Handle` the lite wire already uses; +`ietf::start` opens the empty-token stream after SETUP when negotiated and answers the peer's from the origin handles exactly as lite does, and `add` opens further ones. The fail-loud check moves into the shared handle so the IETF subscriber half consults it @@ -99,8 +99,3 @@ reference relay) sees no AUTH stream and keeps working. Run `just test interop --all`. On main, additive. - -## Required - -- [Lite stream](/quest/m1/auth/lite.md) - supplies `moq_net::auth` and the - shared handle this binds to the IETF wire diff --git a/quest/m1/auth/peer-grant.md b/quest/m1/auth/peer-grant.md index 7a839443d8..02dc36914e 100644 --- a/quest/m1/auth/peer-grant.md +++ b/quest/m1/auth/peer-grant.md @@ -61,7 +61,6 @@ Additive. ## Required -- [Lite stream](/quest/m1/auth/lite.md) - the AUTH stream the grant rides - [Relay tokens](/quest/m1/auth/relay-refresh.md) - the relay owns AUTH and knows the session's paths ## Related diff --git a/quest/m1/auth/relay-refresh.md b/quest/m1/auth/relay-refresh.md index dbfb2b92e0..b726289383 100644 --- a/quest/m1/auth/relay-refresh.md +++ b/quest/m1/auth/relay-refresh.md @@ -13,8 +13,10 @@ expiry, an expiry that leaves the union intact ends only that token, and ## Plan - `Connection::run` in `rs/moq-relay/src/connection.rs` takes `requests()` - from the `moq_net::Request` builder before `.ok()`, so the relay owns the - initial empty AUTH too and the driver's fallback never races it. An empty + from `moq_net::server::Handshake::auth()` before `.ok()`, so the relay owns + the initial empty AUTH too and the driver's fallback never races it. + `moq_tokio::server::Request::ok` spawns the driver itself, so it needs the + same `auth()` passthrough first. An empty token is answered from the origin handles as the default does, plus the admitted grant's `expires`. A non-empty token is presented through `Client::attach(&connection_lease, request)`, a method this quest adds: @@ -80,5 +82,3 @@ Additive. - [Origin narrowing](/quest/m1/origin-narrowing.md) - the live re-scope a shrinking token union needs, so no temporary close-on-shrink policy ships - [Pattern interest](/quest/m1/path-patterns.md) - AUTH can represent the complete grants relay revalidation returns -- [Lite stream](/quest/m1/auth/lite.md) - supplies the AUTH stream and - `auth::Request` this consumes diff --git a/quest/m1/path-patterns.md b/quest/m1/path-patterns.md index 4900d1beef..76180c91e5 100644 --- a/quest/m1/path-patterns.md +++ b/quest/m1/path-patterns.md @@ -92,10 +92,6 @@ effect of this wire work. Test Rust and JavaScript interop, leading wildcards, `**` zero-segment matches, containment refusal, and old-version behavior. -## Required - -- [Lite auth](/quest/m1/auth/lite.md) - establish the AUTH exchange before upgrading its grants to patterns - ## Related - [Wildcard advertisements](/quest/m1/wildcard/README.md) - routing adopts the diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs new file mode 100644 index 0000000000..9b9a877dfb --- /dev/null +++ b/rs/moq-net/src/auth.rs @@ -0,0 +1,722 @@ +//! In-band authorization: present tokens to the peer and learn what they grant. +//! +//! Each side of a moq-lite-06 session presents the credential its connection +//! already carried (the URL, a client certificate, or nothing) right after +//! setup, and learns the [`Grant`] it earned. [`Session::auth`](crate::Session::auth) +//! returns the [`Handle`]: [`grant`](Handle::grant) is the union of every token +//! this side presented, and [`add`](Handle::add) presents another without +//! reconnecting. Dropping the returned [`Token`] withdraws it. +//! +//! The other direction, answering the peer's tokens, is automatic: the session +//! grants what its own origin handles allow. An application that verifies tokens +//! itself takes [`requests`](Handle::requests) before running the session's +//! driver, and then answers every token the peer presents. +//! +//! Older versions and moq-transport carry no AUTH exchange: there the grant stays +//! `None` and [`add`](Handle::add) fails with [`Error::Unsupported`]. + +use std::{ + collections::{BTreeMap, VecDeque}, + task::Poll, +}; + +use bytes::Bytes; + +use crate::{Error, Patterns, Result, SessionError, time::Instant}; + +/// `ready!` for a `kio::Shared` poll, which yields a guard rather than a result. +macro_rules! ready_or { + ($e:expr) => { + match $e { + Poll::Ready(guard) => guard, + Poll::Pending => return Poll::Pending, + } + }; +} + +/// What a peer lets this side do, in this side's own paths. +/// +/// The paths are relative to the session's root, the same paths the session +/// announces and subscribes with. `Grant::default()` grants nothing. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Grant { + /// The broadcasts this side may publish to the peer. + pub publish: Patterns, + /// The broadcasts this side may subscribe to from the peer. + pub subscribe: Patterns, + /// When the grant lapses, or `None` for never. + pub expires: Option, +} + +impl Grant { + /// A grant of everything that never expires. + pub fn all() -> Self { + Self { + publish: crate::Pattern::all().into(), + subscribe: crate::Pattern::all().into(), + expires: None, + } + } + + fn patterns(&self, direction: Direction) -> &Patterns { + match direction { + Direction::Publish => &self.publish, + Direction::Subscribe => &self.subscribe, + } + } + + /// Fold `other` into this grant: the paths either allows, lapsing at the + /// earlier expiry, which is when the union next shrinks. + fn union(&mut self, other: &Self) { + self.publish.extend(other.publish.iter().cloned()); + self.subscribe.extend(other.subscribe.iter().cloned()); + self.expires = match (self.expires, other.expires) { + (Some(a), Some(b)) => Some(a.min(b)), + (a, b) => a.or(b), + }; + } +} + +/// The shared state behind every auth handle of one session. +#[derive(Default)] +pub(crate) struct State { + /// Whether the negotiated version carries AUTH at all. + supported: bool, + /// Set once the session ends; every waiter resolves with it. + closed: Option, + next_id: u64, + /// Every token this side presented that has not ended. + tokens: BTreeMap, + /// Tokens waiting for the driver to open their stream. + opening: VecDeque, + /// The union of every open token's grant, `None` until the peer first replies. + union: Option, + /// The peer replied to some token, so the union is known even when empty. + replied: bool, + /// Bumped whenever the union changes, so the session's per-stream gates can + /// skip re-matching paths on every wakeup. + epoch: u64, + acceptor: Acceptor, +} + +/// One presented token. +pub(crate) struct Slot { + token: Bytes, + /// Presented by the session itself at setup, so enforcement waits on it. + setup: bool, + /// The latest AUTH_OK, `None` before the first and after the token ends. + grant: Option, + /// The first reply: `Ok` for an AUTH_OK, the refusal otherwise. + answered: Option>, + /// Why the token ended, once it has. + ended: Option, + /// The [`Token`] handle dropped: the driver closes the stream. + withdrawn: bool, +} + +/// Who answers the peer's tokens. Decided once, when the driver first runs. +#[derive(Default)] +enum Acceptor { + /// Nobody asked yet; the app may still take [`Handle::requests`]. + #[default] + Undecided, + /// The app answers every token. + App(kio::Queue), + /// The session answers from its own origin handles. + Default, +} + +impl State { + fn recompute(&mut self) { + let mut granted = self.tokens.values().filter_map(|slot| slot.grant.as_ref()).peekable(); + if granted.peek().is_none() && !self.replied { + return; + } + let mut union = Grant::default(); + for grant in granted { + union.union(grant); + } + if self.union.as_ref() != Some(&union) { + self.union = Some(union); + self.epoch += 1; + } + } + + /// The error handed to anything waiting on a session that has ended. + fn closed(&self) -> Option { + self.closed.clone() + } +} + +/// The session's auth handle, returned by [`Session::auth`](crate::Session::auth). +/// +/// Cheap to clone; every clone shares the session's tokens. +#[derive(Clone)] +pub struct Handle { + state: kio::Shared, +} + +impl Handle { + /// A handle for a session that speaks AUTH (`supported`) or never will. + pub(crate) fn new(supported: bool) -> Self { + Self { + state: kio::Shared::new(State { + supported, + ..Default::default() + }), + } + } + + /// The union of every grant this side holds: `None` until the peer first + /// answers a token (with a grant or a refusal), and forever on a version + /// without AUTH. + pub fn grant(&self) -> Watch { + Watch::new(self.state.clone(), Selector::Union) + } + + /// Present another token, resolving once the peer answers it. + /// + /// The grant joins the union for as long as the returned [`Token`] lives. + /// + /// # Errors + /// + /// [`Error::Unsupported`] when the version or the peer carries no AUTH (or the + /// peer takes no tokens in band), the peer's refusal as + /// [`Error::Session`], or the session's close. + pub async fn add(&self, token: impl Into) -> Result { + let token = self.present(token.into(), false)?; + kio::wait(|waiter| token.poll_answered(waiter)).await?; + Ok(token) + } + + /// Queue a token for the driver to present. + pub(crate) fn present(&self, token: Bytes, setup: bool) -> Result { + let mut state = self.state.lock(); + if !state.supported { + return Err(Error::Unsupported); + } + if let Some(err) = state.closed() { + return Err(err); + } + let id = state.next_id; + state.next_id += 1; + state.tokens.insert( + id, + Slot { + token, + setup, + grant: None, + answered: None, + ended: None, + withdrawn: false, + }, + ); + state.opening.push_back(id); + Ok(Token { + state: self.state.clone(), + id, + }) + } + + /// Answer every token the peer presents, instead of the default acceptor. + /// + /// Call it before running the session's driver: whoever answers is decided + /// once, on the driver's first poll, so a later call cannot take over tokens + /// already in flight. Without it the session grants the peer what its own + /// origin handles allow for the connection's credential, and refuses any other + /// token as unsupported. + /// + /// # Errors + /// + /// [`Error::Duplicate`] if the requests were already taken, or the driver + /// already started answering them itself. + pub fn requests(&self) -> Result { + let mut state = self.state.lock(); + if !matches!(state.acceptor, Acceptor::Undecided) { + return Err(Error::Duplicate); + } + let queue = kio::Queue::new(); + // A version without AUTH never receives a token, so its requests end with + // the session like any other. + if !state.supported { + queue.close(); + } + state.acceptor = Acceptor::App(queue.clone()); + Ok(Requests { queue }) + } + + /// Decide who answers the peer's tokens: the app if it took the requests, + /// otherwise the session itself (`None`). Idempotent. + pub(crate) fn acceptor(&self) -> Option> { + let mut state = self.state.lock(); + match &state.acceptor { + Acceptor::App(queue) => Some(queue.clone()), + Acceptor::Default => None, + Acceptor::Undecided => { + state.acceptor = Acceptor::Default; + None + } + } + } + + /// The next token to open a stream for, or `Ready(None)` once the session ends. + pub(crate) fn poll_opening(&self, waiter: &kio::Waiter) -> Poll> { + let mut state = ready_or!(self.state.poll(waiter, |state| { + match state.opening.is_empty() && state.closed.is_none() { + true => Poll::Pending, + false => Poll::Ready(()), + } + })); + if state.closed.is_some() { + return Poll::Ready(None); + } + let id = state.opening.pop_front().expect("checked non-empty"); + let token = state.tokens.get(&id).map(|slot| slot.token.clone()); + match token { + Some(token) => Poll::Ready(Some((id, token))), + // Withdrawn before its stream opened: nothing to present. + None => { + drop(state); + self.poll_opening(waiter) + } + } + } + + /// Whether the token's handle dropped, so its stream should close. + pub(crate) fn poll_withdrawn(&self, id: u64, waiter: &kio::Waiter) -> Poll<()> { + let _ = ready_or!(self.state.poll(waiter, |state| match state.tokens.get(&id) { + Some(slot) if !slot.withdrawn => Poll::Pending, + _ => Poll::Ready(()), + })); + Poll::Ready(()) + } + + /// Record an AUTH_OK for the token. + pub(crate) fn granted(&self, id: u64, grant: Grant) { + let mut state = self.state.lock(); + let Some(slot) = state.tokens.get_mut(&id) else { + return; + }; + slot.grant = Some(grant); + slot.answered.get_or_insert(Ok(())); + state.replied = true; + state.recompute(); + } + + /// Record an AUTH_ERROR for the token: a reply that grants nothing, so a refused + /// setup token leaves an empty union rather than an unknown (unrestricted) one. + /// The driver still ends the token with [`ended`](Self::ended). + pub(crate) fn refused(&self, id: u64) { + let mut state = self.state.lock(); + if state.tokens.contains_key(&id) { + state.replied = true; + } + } + + /// End the token: refused or revoked by the peer, withdrawn by us, or its + /// stream lost. A token that was never answered records `err` as its answer. + pub(crate) fn ended(&self, id: u64, err: Error) { + let mut state = self.state.lock(); + let Some(slot) = state.tokens.get_mut(&id) else { + return; + }; + slot.grant = None; + slot.answered.get_or_insert(Err(err.clone())); + slot.ended = Some(err); + // The handle is gone and the token is over: nothing will read the slot again. + if slot.withdrawn { + state.tokens.remove(&id); + } + state.recompute(); + } + + /// Resolve once every token the session presented at setup has its first + /// reply, so enforcement never waits on a token the app added later. + pub(crate) fn poll_setup_answered(&self, waiter: &kio::Waiter) -> Poll<()> { + let _ = ready_or!(self.state.poll(waiter, |state| { + match state.tokens.values().any(|slot| slot.setup && slot.answered.is_none()) { + true => Poll::Pending, + false => Poll::Ready(()), + } + })); + Poll::Ready(()) + } + + /// The union once it changed since `epoch` last saw it, advancing `epoch`. It + /// only ever changes into `Some`: no answer yet is the initial state. + pub(crate) fn poll_union(&self, epoch: &mut u64, waiter: &kio::Waiter) -> Poll> { + let seen = *epoch; + let state = ready_or!(self.state.poll(waiter, |state| match state.epoch != seen { + true => Poll::Ready(()), + false => Poll::Pending, + })); + *epoch = state.epoch; + Poll::Ready(state.union.clone()) + } + + /// Whether the union allows `path` right now. A union that is still `None` (no + /// answer yet, or a version without AUTH) allows everything. + pub(crate) fn allows(&self, direction: Direction, path: &str) -> bool { + let state = self.state.read(); + state + .union + .as_ref() + .is_none_or(|union| union.patterns(direction).matches(path)) + } + + /// End the session: fail every pending token, end every watch, and close the + /// requests. + pub(crate) fn close(&self, err: Error) { + let mut state = self.state.lock(); + if state.closed.is_some() { + return; + } + state.closed = Some(err.clone()); + state.opening.clear(); + for slot in state.tokens.values_mut() { + slot.grant = None; + slot.answered.get_or_insert(Err(err.clone())); + slot.ended.get_or_insert(err.clone()); + } + state.recompute(); + if let Acceptor::App(queue) = &state.acceptor { + queue.close(); + } + } +} + +/// A token this side presented. Dropping it withdraws the token, closing its +/// stream and removing its grant from the union. +pub struct Token { + state: kio::Shared, + id: u64, +} + +impl Token { + /// This token's own grant: `None` until the peer answers, and again once the + /// token ends. + pub fn grant(&self) -> Watch { + Watch::new(self.state.clone(), Selector::Token(self.id)) + } + + /// Wait for the token to end: the peer's revocation as [`Error::Session`], + /// [`Error::Cancel`] when the peer closed it without one, or the session's + /// close. + pub async fn closed(&self) -> Error { + kio::wait(|waiter| { + let state = ready_or!(self.state.poll(waiter, |state| match state.tokens.get(&self.id) { + Some(slot) if slot.ended.is_none() => Poll::Pending, + _ => Poll::Ready(()), + })); + let err = state.tokens.get(&self.id).and_then(|slot| slot.ended.clone()); + Poll::Ready(err.unwrap_or(Error::Cancel)) + }) + .await + } + + fn poll_answered(&self, waiter: &kio::Waiter) -> Poll> { + let state = ready_or!(self.state.poll(waiter, |state| match state.tokens.get(&self.id) { + Some(slot) if slot.answered.is_none() => Poll::Pending, + _ => Poll::Ready(()), + })); + let answered = state.tokens.get(&self.id).and_then(|slot| slot.answered.clone()); + Poll::Ready(answered.unwrap_or(Err(Error::Cancel))) + } +} + +impl Drop for Token { + fn drop(&mut self) { + let mut state = self.state.lock(); + let Some(slot) = state.tokens.get_mut(&self.id) else { + return; + }; + slot.withdrawn = true; + // Already over, or never reached the wire: nothing left to close. + if slot.ended.is_some() || state.opening.contains(&self.id) { + state.opening.retain(|id| *id != self.id); + state.tokens.remove(&self.id); + state.recompute(); + } + } +} + +enum Selector { + Union, + Token(u64), +} + +/// Watches a grant: the session's union, or one token's. +/// +/// Created by [`Handle::grant`] and [`Token::grant`]. +pub struct Watch { + state: kio::Shared, + selector: Selector, + last: Option, +} + +impl Watch { + fn new(state: kio::Shared, selector: Selector) -> Self { + let mut watch = Self { + state, + selector, + last: None, + }; + watch.last = watch.peek(); + watch + } + + fn current(&self, state: &State) -> Option { + match self.selector { + Selector::Union => state.union.clone(), + Selector::Token(id) => state.tokens.get(&id).and_then(|slot| slot.grant.clone()), + } + } + + /// Whether nothing will change the grant again. + fn ended(&self, state: &State) -> Option { + if let Some(err) = state.closed() { + return Some(err); + } + match self.selector { + Selector::Union => None, + Selector::Token(id) => match state.tokens.get(&id) { + Some(slot) => slot.ended.clone(), + None => Some(Error::Cancel), + }, + } + } + + /// The grant right now. + pub fn peek(&self) -> Option { + self.current(&self.state.read()) + } + + /// Poll for the grant to differ from the last one this watch returned (or saw + /// at creation). + /// + /// `Err` once nothing will change it again: the session closed, or for a + /// token's watch, the token ended. + pub fn poll_changed(&mut self, waiter: &kio::Waiter) -> Poll>> { + let last = &self.last; + let selector = &self.selector; + let state = ready_or!(self.state.poll(waiter, |state| { + let current = match selector { + Selector::Union => state.union.as_ref(), + Selector::Token(id) => state.tokens.get(id).and_then(|slot| slot.grant.as_ref()), + }; + let ended = state.closed.is_some() + || match selector { + Selector::Union => false, + Selector::Token(id) => state.tokens.get(id).is_none_or(|slot| slot.ended.is_some()), + }; + match current != last.as_ref() || ended { + true => Poll::Ready(()), + false => Poll::Pending, + } + })); + let current = self.current(&state); + if current != self.last { + self.last = current.clone(); + return Poll::Ready(Ok(current)); + } + Poll::Ready(Err(self.ended(&state).unwrap_or(Error::Cancel))) + } + + /// Wait for the grant to change, returning the new one. + /// + /// # Errors + /// + /// See [`poll_changed`](Self::poll_changed). + pub async fn changed(&mut self) -> Result> { + kio::wait(|waiter| self.poll_changed(waiter)).await + } +} + +/// The tokens the peer presents, for an application that answers them itself. +/// +/// Returned by [`Handle::requests`]. Dropping it refuses every later token. +pub struct Requests { + queue: kio::Queue, +} + +impl Requests { + /// Poll for the next token, or `None` once the session ends. + pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll> { + self.queue.poll_pop(waiter).map(|res| res.ok()) + } + + /// Wait for the next token the peer presents, or `None` once the session ends. + pub async fn next(&mut self) -> Option { + self.queue.pop().await.ok() + } +} + +impl Drop for Requests { + fn drop(&mut self) { + self.queue.close(); + // The session keeps a handle to the queue, so drop what is already queued here: + // each request refuses its token as it drops. + while let Ok(Some(_)) = self.queue.try_pop() {} + } +} + +/// What the acceptor writes on one peer token's stream. +pub(crate) enum Reply { + Grant(Grant), + Refuse { code: SessionError, reason: String }, +} + +/// The acceptor's side of one peer token's stream, shared with the driver. +#[derive(Default)] +pub(crate) struct Issue { + /// Replies for the driver to write, in order. + pub outbox: VecDeque, + /// The app is done with the stream: the driver closes it once the outbox drains. + pub done: bool, + /// Why the stream ended on the peer's side, once it has. + pub peer: Option, +} + +impl Issue { + /// A fresh stream's shared state. + pub(crate) fn shared() -> kio::Shared { + kio::Shared::default() + } +} + +/// A token the peer presented, waiting for an answer. +/// +/// Dropping it unanswered refuses the token with [`SessionError::Unauthorized`]. +pub struct Request { + token: Bytes, + issue: Option>, +} + +impl Request { + pub(crate) fn new(token: Bytes, issue: kio::Shared) -> Self { + Self { + token, + issue: Some(issue), + } + } + + /// The token the peer presented. Empty means the credential its connection + /// already carried, or none. + pub fn token(&self) -> &Bytes { + &self.token + } + + /// Grant the token. The grant holds until the returned [`Issued`] is revoked + /// or dropped, or the peer withdraws the token. + /// + /// The session only tells the peer: the origin handles this side serves and + /// accepts with are what enforce the grant, and revoking it once `expires` + /// passes is the acceptor's job. + pub fn accept(mut self, grant: Grant) -> Issued { + let issue = self.issue.take().expect("answered once"); + issue.lock().outbox.push_back(Reply::Grant(grant)); + Issued { issue } + } + + /// Refuse the token with a session error code and a reason for the peer. + pub fn reject(mut self, code: SessionError, reason: impl Into) { + let issue = self.issue.take().expect("answered once"); + refuse(&issue, code, reason.into()); + } +} + +impl Drop for Request { + fn drop(&mut self) { + if let Some(issue) = self.issue.take() { + refuse(&issue, SessionError::Unauthorized, "unanswered".to_string()); + } + } +} + +fn refuse(issue: &kio::Shared, code: SessionError, reason: String) { + let mut issue = issue.lock(); + issue.outbox.push_back(Reply::Refuse { code, reason }); + issue.done = true; +} + +/// A grant issued to one of the peer's tokens. Dropping it ends the grant. +pub struct Issued { + issue: kio::Shared, +} + +impl Issued { + /// Replace the grant, such as with a lowered expiry. + pub fn update(&self, grant: Grant) { + let mut issue = self.issue.lock(); + if !issue.done { + issue.outbox.push_back(Reply::Grant(grant)); + } + } + + /// Revoke the grant with a session error code and a reason for the peer. + pub fn revoke(self, code: SessionError, reason: impl Into) { + refuse(&self.issue, code, reason.into()); + } + + /// Wait for the peer to withdraw the token ([`Error::Cancel`]), or for its + /// stream or the session to end. + pub async fn closed(&self) -> Error { + kio::wait(|waiter| { + let issue = ready_or!(self.issue.poll(waiter, |issue| match issue.peer.is_some() { + true => Poll::Ready(()), + false => Poll::Pending, + })); + Poll::Ready(issue.peer.clone().expect("checked above")) + }) + .await + } +} + +impl Drop for Issued { + fn drop(&mut self) { + self.issue.lock().done = true; + } +} + +/// Which half of a grant a [`Gate`] checks. +#[derive(Clone, Copy)] +pub(crate) enum Direction { + Publish, + Subscribe, +} + +/// Watches whether the union still allows one path, for a long-lived stream that +/// must end once it does not. +/// +/// Cheap to poll on every wakeup: the path is only re-matched when the union +/// changes. +pub(crate) struct Gate { + handle: Handle, + path: crate::PathOwned, + direction: Direction, + epoch: u64, +} + +impl Gate { + pub(crate) fn new(handle: Handle, path: crate::PathOwned, direction: Direction) -> Self { + Self { + handle, + path, + direction, + epoch: 0, + } + } + + /// Resolve once the union no longer allows the path. A union that is still + /// `None` (no answer yet, or a version without AUTH) allows everything. + pub(crate) fn poll_denied(&mut self, waiter: &kio::Waiter) -> Poll<()> { + loop { + let Some(union) = ready_or!(self.handle.poll_union(&mut self.epoch, waiter)) else { + continue; + }; + if !union.patterns(self.direction).matches(self.path.as_str()) { + return Poll::Ready(()); + } + } + } +} diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index 4f567d715b..5f820e80e2 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -181,6 +181,7 @@ impl Client { let start = lite::start(lite::Config { runtime: runtime.clone(), + client: true, session: session.clone(), setup_stream: None, publish, @@ -189,6 +190,7 @@ impl Client { version, our_setup, peer_setup: None, + auth: crate::auth::Handle::new(version.has_auth()), })?; Ok(Session::new( @@ -198,6 +200,7 @@ impl Client { start.recv_bandwidth, crate::driver::Protocol::Lite(Box::new(start.driver)), start.goaway, + start.auth, )) } @@ -277,6 +280,7 @@ impl Client { None, crate::driver::Protocol::Ietf(protocol), goaway, + crate::auth::Handle::new(false), )); } Some(ALPN_16) => { @@ -357,11 +361,12 @@ impl Client { .copied() .ok_or(Error::Version)?; - let (recv_bw, protocol, goaway) = match version { + let (recv_bw, protocol, goaway, auth) = match version { Version::Lite(v) => { let stream = stream.with_version(v); let start = lite::start(lite::Config { runtime: runtime.clone(), + client: true, session: session.clone(), setup_stream: Some(stream), publish: publish.clone(), @@ -372,12 +377,15 @@ impl Client { // (pre-lite-05), which have no Setup Stream. our_setup: lite::Setup::default(), peer_setup: None, + // Negotiated over the bidi SETUP: lite 01/02, which carry no AUTH. + auth: crate::auth::Handle::new(v.has_auth()), })?; ( start.recv_bandwidth, crate::driver::Protocol::Lite(Box::new(start.driver)), start.goaway, + start.auth, ) } Version::Ietf(v) => { @@ -409,11 +417,16 @@ impl Client { peer_setup_stream: None, peer_declared: Some(peer_declared), })?; - (None, crate::driver::Protocol::Ietf(protocol), goaway) + ( + None, + crate::driver::Protocol::Ietf(protocol), + goaway, + crate::auth::Handle::new(false), + ) } }; - Ok(Session::new(runtime, session, version, recv_bw, protocol, goaway)) + Ok(Session::new(runtime, session, version, recv_bw, protocol, goaway, auth)) } } diff --git a/rs/moq-net/src/lib.rs b/rs/moq-net/src/lib.rs index bb07231e07..1f08856ca5 100644 --- a/rs/moq-net/src/lib.rs +++ b/rs/moq-net/src/lib.rs @@ -70,6 +70,7 @@ // `Arc` stays and the lint is unactionable here. #![cfg_attr(target_arch = "wasm32", allow(clippy::arc_with_non_send_sync))] +pub mod auth; mod client; mod coding; mod driver; diff --git a/rs/moq-net/src/lite/auth.rs b/rs/moq-net/src/lite/auth.rs new file mode 100644 index 0000000000..01319d7352 --- /dev/null +++ b/rs/moq-net/src/lite/auth.rs @@ -0,0 +1,304 @@ +use std::time::Duration; + +use bytes::Bytes; + +use crate::coding::*; +use crate::{Path, Pattern, Patterns}; + +use super::{Message, Version}; + +/// The first message on an Auth Stream: the token the opener presents. Lite06+. +/// +/// An empty token means the credential the connection already presented (the +/// URL, a client certificate), or nothing. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Auth { + pub token: Bytes, +} + +impl Message for Auth { + fn decode_msg(r: &mut R, version: Version) -> Result { + if !version.has_auth() { + return Err(DecodeError::Version); + } + Ok(Self { + token: Bytes::decode(r, version)?, + }) + } + + fn encode_msg(&self, w: &mut W, version: Version) -> Result<(), EncodeError> { + if !version.has_auth() { + return Err(EncodeError::Version); + } + self.token.encode(w, version) + } +} + +/// The grant a token earns, as the acceptor writes it on the Auth Stream. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuthOk { + /// What the opener may publish to the acceptor. + pub publish: Patterns, + /// What the opener may subscribe to from the acceptor. + pub subscribe: Patterns, + /// How long until the grant lapses, or `None` for never. + pub expires: Option, +} + +/// Largest millisecond count every implementation carries losslessly. +const MAX_EXPIRES_MS: u64 = (1 << 53) - 1; + +/// Encode a grant's patterns as the wire's prefix list. +/// +/// The wire carries prefixes, the ANNOUNCE_REQUEST encoding, so only a union of +/// subtrees is representable. Anything else is refused rather than widened: +/// sending `room/**` for a grant of the literal `room/alice` would hand out more +/// than was granted. +fn encode_prefixes(patterns: &Patterns, w: &mut W, version: Version) -> Result<(), EncodeError> { + patterns.len().encode(w, version)?; + for pattern in patterns { + let prefix = pattern.as_prefix().ok_or(EncodeError::Unsupported)?; + Path::new(prefix).encode(w, version)?; + } + Ok(()) +} + +fn decode_prefixes(r: &mut R, version: Version) -> Result { + let count = usize::decode(r, version)?; + let mut patterns = Patterns::new(); + // No preallocation: the count is peer-controlled, and the message size limit + // is what bounds how many prefixes actually fit. + for _ in 0..count { + let prefix = Path::decode(r, version)?; + let pattern = Pattern::subtree(prefix.as_str()).map_err(|_| DecodeError::InvalidValue)?; + patterns.insert(pattern); + } + Ok(patterns) +} + +impl Message for AuthOk { + fn decode_msg(r: &mut R, version: Version) -> Result { + if !version.has_auth() { + return Err(DecodeError::Version); + } + let publish = decode_prefixes(r, version)?; + let subscribe = decode_prefixes(r, version)?; + let expires = match u64::decode(r, version)? { + 0 => None, + ms => Some(Duration::from_millis(ms)), + }; + Ok(Self { + publish, + subscribe, + expires, + }) + } + + fn encode_msg(&self, w: &mut W, version: Version) -> Result<(), EncodeError> { + if !version.has_auth() { + return Err(EncodeError::Version); + } + encode_prefixes(&self.publish, w, version)?; + encode_prefixes(&self.subscribe, w, version)?; + // 0 means never, so a grant that has already lapsed rounds up to the + // smallest value that still reads as an expiry. + let expires = match self.expires { + None => 0, + Some(expires) => (expires.as_nanos().div_ceil(1_000_000).min(MAX_EXPIRES_MS as u128) as u64).max(1), + }; + expires.encode(w, version) + } +} + +/// The acceptor refusing a token (as its first reply) or revoking it (after an +/// AUTH_OK). The acceptor closes the stream afterward. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuthError { + /// A code from the session error registry. + pub code: u64, + pub reason: String, +} + +/// Longest AUTH_ERROR reason, in bytes. Matches the GOAWAY URI cap: generous for a +/// human-readable reason, and rejected from the length prefix alone. +const MAX_REASON: usize = 8192; + +impl Message for AuthError { + fn decode_msg(r: &mut R, version: Version) -> Result { + if !version.has_auth() { + return Err(DecodeError::Version); + } + let code = u64::decode(r, version)?; + let len = usize::decode(r, version)?; + if len > MAX_REASON { + return Err(DecodeError::InvalidValue); + } + if r.remaining() < len { + return Err(DecodeError::Short); + } + let reason = String::from_utf8(r.copy_to_bytes(len).to_vec())?; + Ok(Self { code, reason }) + } + + fn encode_msg(&self, w: &mut W, version: Version) -> Result<(), EncodeError> { + if !version.has_auth() { + return Err(EncodeError::Version); + } + if self.reason.len() > MAX_REASON { + return Err(EncodeError::TooLarge); + } + self.code.encode(w, version)?; + self.reason.as_str().encode(w, version) + } +} + +/// A message the acceptor writes on the Auth Stream, prefixed with its type. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AuthReply { + Ok(AuthOk), + Error(AuthError), +} + +const AUTH_OK: u64 = 0; +const AUTH_ERROR: u64 = 1; + +/// Write a `type` varint followed by the size-prefixed message body. +fn encode_typed( + w: &mut W, + typ: u64, + msg: &M, + version: Version, +) -> Result<(), EncodeError> { + typ.encode(w, version)?; + msg.encode(w, version) +} + +impl Encode for AuthReply { + fn encode(&self, w: &mut W, version: Version) -> Result<(), EncodeError> { + match self { + Self::Ok(ok) => encode_typed(w, AUTH_OK, ok, version), + Self::Error(err) => encode_typed(w, AUTH_ERROR, err, version), + } + } +} + +impl Decode for AuthReply { + fn decode(buf: &mut B, version: Version) -> Result { + match u64::decode(buf, version)? { + AUTH_OK => Ok(Self::Ok(AuthOk::decode(buf, version)?)), + AUTH_ERROR => Ok(Self::Error(AuthError::decode(buf, version)?)), + typ => Err(DecodeError::InvalidMessage(typ)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn patterns(prefixes: &[&str]) -> Patterns { + prefixes.iter().map(|p| Pattern::subtree(p).unwrap()).collect() + } + + fn round_trip + Decode>(msg: &T) -> T { + let mut buf = bytes::BytesMut::new(); + msg.encode(&mut buf, Version::Lite06).unwrap(); + let mut slice = &buf[..]; + let got = T::decode(&mut slice, Version::Lite06).unwrap(); + assert!(slice.is_empty(), "trailing bytes after decode"); + got + } + + #[test] + fn auth_round_trips() { + for token in [Bytes::new(), Bytes::from_static(b"eyJhbGciOi.jwt")] { + let msg = Auth { token }; + assert_eq!(round_trip(&msg), msg); + } + } + + /// The empty prefix grants everything and the empty list grants nothing; both + /// spellings survive the trip distinctly. + #[test] + fn auth_ok_round_trips() { + for (publish, subscribe, expires) in [ + (patterns(&[""]), patterns(&[]), None), + ( + patterns(&["room/alice", "room/bob"]), + patterns(&["room"]), + Some(Duration::from_secs(60)), + ), + ] { + let msg = AuthReply::Ok(AuthOk { + publish, + subscribe, + expires, + }); + assert_eq!(round_trip(&msg), msg); + } + assert_eq!(patterns(&[""]), Patterns::from(Pattern::all())); + } + + #[test] + fn auth_error_round_trips() { + let msg = AuthReply::Error(AuthError { + code: 0x2, + reason: "expired".to_string(), + }); + assert_eq!(round_trip(&msg), msg); + } + + /// A lapsed grant still reads as an expiry, never as "never". + #[test] + fn zero_expiry_rounds_up() { + let msg = AuthReply::Ok(AuthOk { + publish: Patterns::new(), + subscribe: Patterns::new(), + expires: Some(Duration::ZERO), + }); + let AuthReply::Ok(got) = round_trip(&msg) else { + panic!("expected AUTH_OK"); + }; + assert_eq!(got.expires, Some(Duration::from_millis(1))); + } + + /// Only subtrees fit the prefix encoding; anything narrower is refused, never + /// widened to its head. + #[test] + fn unrepresentable_grants_are_refused() { + for pattern in ["room/alice", "room/*/cam", "*/**"] { + let msg = AuthReply::Ok(AuthOk { + publish: Patterns::from(Pattern::try_from(pattern).unwrap()), + subscribe: Patterns::new(), + expires: None, + }); + let mut buf = bytes::BytesMut::new(); + assert!( + matches!(msg.encode(&mut buf, Version::Lite06), Err(EncodeError::Unsupported)), + "{pattern} encoded" + ); + } + } + + #[test] + fn older_versions_have_no_auth() { + for version in [ + Version::Lite01, + Version::Lite02, + Version::Lite03, + Version::Lite04, + Version::Lite05, + ] { + let mut buf = bytes::BytesMut::new(); + assert!(matches!( + Auth { token: Bytes::new() }.encode(&mut buf, version), + Err(EncodeError::Version) + )); + let mut slice: &[u8] = &[0]; + assert!(matches!( + Auth::decode_msg(&mut slice, version), + Err(DecodeError::Version) + )); + } + } +} diff --git a/rs/moq-net/src/lite/mod.rs b/rs/moq-net/src/lite/mod.rs index bafa81b58c..d7c76b6128 100644 --- a/rs/moq-net/src/lite/mod.rs +++ b/rs/moq-net/src/lite/mod.rs @@ -5,6 +5,7 @@ //! Specification: [] mod announce; +mod auth; mod datagram; mod fetch; mod goaway; @@ -26,6 +27,7 @@ mod track; mod version; pub use announce::*; +pub use auth::*; #[allow(unused_imports)] pub use datagram::*; #[allow(unused_imports)] diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index e618843c2d..a16a448942 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -37,6 +37,13 @@ pub(super) struct PublisherConfig { /// The origin (hop) id assigned to the peer, used whenever the peer doesn't /// declare one itself. See `Client::with_peer_hop`. pub peer_hop: Option, + /// Our tokens' grants, which bound what we announce and serve, and the app's + /// claim on answering the peer's tokens. + pub auth: crate::auth::Handle, + /// What the default acceptor grants the peer's connection credential. + pub peer_grant: crate::auth::Grant, + /// Whether we dialed: only then does a publication outside our grant abort. + pub client: bool, } /// Context shared by every control-stream child. @@ -59,6 +66,9 @@ struct Shared { priority: PriorityQueue, version: Version, goaway: crate::goaway::Protocol, + auth: crate::auth::Handle, + peer_grant: crate::auth::Grant, + runtime: crate::time::Clock, } /// Largest millisecond duration every implementation can carry losslessly. @@ -137,6 +147,8 @@ pub(super) struct Publisher { // shared context stays behind the Arc for the per-stream children. accept: S, children: kio::Tasks>, + /// Aborts the session on a publication outside our grant (dialing side only). + enforce: Option, } impl Publisher { @@ -146,7 +158,9 @@ impl Publisher { // across every session, required for cross-session loop detection. let self_origin = config.origin.hop(); let accept = config.session.clone(); + let enforce = (config.client && config.version.has_auth()).then(Enforce::default); Self { + enforce, shared: Arc::new(Shared { session: config.session, origin: config.origin, @@ -157,6 +171,9 @@ impl Publisher { priority: Default::default(), version: config.version, goaway: config.goaway, + auth: config.auth, + peer_grant: config.peer_grant, + runtime: config.runtime.clone(), }), runtime: config.runtime, accept, @@ -170,6 +187,13 @@ where S: crate::transport::poll::Session, { pub fn poll(&mut self, waiter: &kio::Waiter) -> Poll> { + if let Some(enforce) = &mut self.enforce + && let Poll::Ready(res) = enforce.poll(&self.shared, waiter) + { + res?; + self.enforce = None; + } + let _ = self.children.poll(waiter); let mut cx = Context::from_waker(waiter.waker()); @@ -230,6 +254,7 @@ enum ControlState { Fetch(FetchServe), TrackInfo(TrackInfoServe), Probe(ProbeServe), + Auth(AuthServe), /// Decoding the peer's GOAWAY, surfaced through [`crate::Session::draining`]. Goaway { stream: Stream, @@ -271,6 +296,7 @@ impl Control { lite::ControlType::Probe => { ControlState::Probe(ProbeServe::new(self.shared.clone(), self.runtime.clone(), stream)) } + lite::ControlType::Auth => ControlState::Auth(AuthServe::new(self.shared.clone(), stream)?), lite::ControlType::Goaway => ControlState::Goaway { stream }, lite::ControlType::Session => return Poll::Ready(Err(Error::UnexpectedStream)), }; @@ -280,6 +306,7 @@ impl Control { ControlState::Fetch(serve) => return serve.poll(waiter), ControlState::TrackInfo(serve) => return serve.poll(waiter), ControlState::Probe(serve) => return serve.poll(waiter), + ControlState::Auth(serve) => return serve.poll(waiter), ControlState::Goaway { stream } => { // A decode error propagates to the caller, which logs and continues: a // malformed GOAWAY must not tear down the session it is trying to drain. @@ -313,6 +340,236 @@ impl Control { } } +/// Answers one of the peer's tokens: the app's verdict when it took the requests, +/// otherwise the grant our own origin handles allow for the connection's +/// credential. The stream lives as long as the token. +struct AuthServe { + shared: Arc>, + stream: Option>, + /// Shared with the app's [`crate::auth::Request`] / [`crate::auth::Issued`], or + /// filled in by the default acceptor. + issue: Option>, + /// Our side is finished: FIN sent, waiting for the acknowledgement. + finished: bool, +} + +impl AuthServe { + fn new(shared: Arc>, stream: Stream) -> Result { + // The Auth Stream is lite-06+ only. + if !shared.version.has_auth() { + return Err(Error::UnexpectedStream); + } + Ok(Self { + shared, + stream: Some(stream), + issue: None, + finished: false, + }) + } + + fn poll(&mut self, waiter: &kio::Waiter) -> Poll> { + let res = ready!(self.poll_serve(waiter)); + let err = match res { + Ok(()) => Error::Cancel, + Err(err) => { + match &err { + Error::Cancel | Error::Unsupported | Error::Stream(_) | Error::Session(_) | Error::Transport(_) => { + tracing::debug!(%err, "auth stream ended") + } + err => tracing::warn!(%err, "auth stream error"), + } + if let Some(stream) = self.stream.take() { + stream.writer.abort(&err); + } + err + } + }; + if let Some(issue) = &self.issue { + issue.lock().peer.get_or_insert(err); + } + Poll::Ready(Ok(())) + } + + fn poll_serve(&mut self, waiter: &kio::Waiter) -> Poll> { + let mut cx = Context::from_waker(waiter.waker()); + let stream = self.stream.as_mut().expect("stream present"); + + let issue = match &self.issue { + Some(issue) => issue.clone(), + None => { + let msg = ready!(stream.reader.poll_decode::(&mut cx))?; + let issue = crate::auth::Issue::shared(); + match self.shared.auth.acceptor() { + Some(requests) => { + // A closed queue hands the request back, and dropping it refuses + // the token on this stream. + let _ = requests.try_push(crate::auth::Request::new(msg.token, issue.clone())); + } + // Only the connection's own credential has a default answer; a token + // needs someone to verify it. Resetting reads as "unsupported" to + // the presenter, the same as a peer that predates AUTH. + None if !msg.token.is_empty() => return Poll::Ready(Err(Error::Unsupported)), + None => { + let grant = self.shared.peer_grant.clone(); + issue.lock().outbox.push_back(crate::auth::Reply::Grant(grant)); + } + } + self.issue = Some(issue.clone()); + issue + } + }; + + loop { + ready!(stream.writer.poll_flush(&mut cx))?; + if self.finished { + return stream.writer.poll_closed(&mut cx); + } + + // The presenter withdrew the token, by FIN or by a cancelling reset, or its + // stream died. + if let Poll::Ready(res) = stream.reader.poll_closed(&mut cx) { + match res { + Ok(()) | Err(Error::Stream(crate::StreamError::Cancel)) => {} + Err(err) => return Poll::Ready(Err(err)), + } + stream.writer.finish()?; + self.finished = true; + continue; + } + + let mut state = match issue.poll(waiter, |issue| match issue.outbox.is_empty() && !issue.done { + true => Poll::Pending, + false => Poll::Ready(()), + }) { + Poll::Ready(state) => state, + Poll::Pending => return Poll::Pending, + }; + match state.outbox.pop_front() { + Some(reply) => { + drop(state); + Self::write(&self.shared, &issue, stream, reply)?; + } + // The app is done with the grant, or refused the token: close our side. + None => { + drop(state); + stream.writer.finish()?; + self.finished = true; + } + } + } + } + + fn write( + shared: &Shared, + issue: &kio::Shared, + stream: &mut Stream, + reply: crate::auth::Reply, + ) -> Result<(), Error> { + let msg = match reply { + crate::auth::Reply::Grant(grant) => { + let now = crate::runtime::Timers::now(&shared.runtime); + lite::AuthReply::Ok(lite::AuthOk { + publish: grant.publish, + subscribe: grant.subscribe, + expires: grant.expires.map(|at| at.saturating_duration_since(now)), + }) + } + crate::auth::Reply::Refuse { code, reason } => lite::AuthReply::Error(lite::AuthError { + code: code.to_code().into(), + reason, + }), + }; + match stream.writer.buffer(&msg) { + // This wire carries prefixes only, so a pattern grant cannot be told, only + // withheld: reset the stream, which the presenter reads as unsupported + // rather than refused. Never widen it. A pattern grant is routine until the + // wire carries patterns, so it is not worth a warning. + Err(Error::Encode(crate::coding::EncodeError::Unsupported)) => { + tracing::debug!("auth grant not representable as prefixes; resetting the token's stream"); + issue.lock().done = true; + Err(Error::Unsupported) + } + res => res, + } + } +} + +/// Aborts the session when our origin announces a broadcast our grant does not +/// cover, instead of leaving it to wait for a subscription that never comes. +/// +/// Waits until the tokens the session presented at setup are answered, then +/// checks each broadcast when it is first announced. A grant that later shrinks +/// withdraws what it no longer covers (see [`AnnounceRun`]) without aborting: +/// the union is processed before new announcements, so a revocation is never +/// mistaken for a new unauthorized publication. +#[derive(Default)] +struct Enforce { + epoch: u64, + permit: Option, + announced: Option, + /// Every broadcast admitted so far, still announced. + live: std::collections::HashSet, + setup: bool, +} + +impl Enforce { + fn poll( + &mut self, + shared: &Shared, + waiter: &kio::Waiter, + ) -> Poll> { + if !self.setup { + ready!(shared.auth.poll_setup_answered(waiter)); + self.setup = true; + } + while let Poll::Ready(union) = shared.auth.poll_union(&mut self.epoch, waiter) { + self.permit = union.map(|grant| grant.publish); + } + // No grant yet (the peer never answered with one): nothing to check against. + let Some(permit) = &self.permit else { + return Poll::Pending; + }; + + let announced = match &mut self.announced { + Some(announced) => announced, + None => { + let origin = ready!(shared.poll_serving_origin(waiter)); + self.announced.insert(origin.announced()) + } + }; + + loop { + let Some(update) = ready!(announced.poll_next(waiter)) else { + return Poll::Ready(Ok(())); + }; + match update.kind { + announce::Kind::Announced if !self.live.contains(&update.prefix) => { + if !permit.matches(update.prefix.as_str()) { + tracing::error!( + broadcast = %shared.origin.absolute(&update.prefix), + "publishing outside our grant; closing the session" + ); + // `Error` carries no payload, so the path travels in the close + // reason, in the session's own terms. + let err = Error::Unauthorized; + let reason = format!("unauthorized: {}", update.prefix); + shared + .session + .clone() + .close(SessionError::from(&err).to_code(), &reason); + return Poll::Ready(Err(err)); + } + self.live.insert(update.prefix); + } + announce::Kind::Retracted => { + self.live.remove(&update.prefix); + } + _ => {} + } + } + } +} + /// Serves one PROBE stream: periodic bandwidth estimates until the peer closes /// its side. struct ProbeServe { @@ -557,7 +814,10 @@ impl AnnounceServe { // the source we are currently advertising to that peer. let origin = origin.excluding(Hop::new(exclude_hop).unwrap_or(Hop::UNKNOWN)); let announced = origin.announced(); - let run = AnnounceRun::new(prefix, self.shared.self_origin, self.shared.version); + let mut run = AnnounceRun::new(prefix, self.shared.self_origin, self.shared.version); + if self.shared.version.has_auth() { + run.auth = Some(self.shared.auth.clone()); + } self.state = AnnounceState::Run { origin, announced, run }; } } @@ -577,6 +837,12 @@ struct AnnounceRun { // prefix. The value is the announce id on versions that assign them. live: HashMap>, phase: AnnouncePhase, + // Our grant, on versions with AUTH: only what it lets us publish is announced, + // and a shrink withdraws what it no longer covers. + auth: Option, + epoch: u64, + // What the union lets us publish, `None` until the peer first answers. + permit: Option, } enum AnnouncePhase { @@ -596,7 +862,98 @@ impl AnnounceRun { next_announce_id: 0, live: HashMap::new(), phase: AnnouncePhase::Init, + auth: None, + epoch: 0, + permit: None, + } + } + + /// Whether our grant lets us announce `prefix` (relative to the origin's root). + fn permitted(&self, prefix: &crate::Path) -> bool { + self.permit + .as_ref() + .is_none_or(|permit| permit.matches(prefix.as_str())) + } + + /// Apply a change to our grant: withdraw what it no longer covers and, when it + /// grew, re-read the origin to announce what it now does. + fn regrant( + &mut self, + stream: &mut Stream, + origin: &origin::Consumer, + announced: &mut announce::Consumer, + permit: crate::Patterns, + ) -> Result<(), Error> { + // `None` allowed everything, so any grant only shrinks it. + let grew = self.permit.as_ref().is_some_and(|old| !old.covers(&permit)); + self.permit = Some(permit); + + if !grew { + let revoked: Vec<_> = self + .live + .keys() + .filter(|suffix| !self.permitted(&self.prefix.join(*suffix))) + .cloned() + .collect(); + for suffix in revoked { + let absolute = origin.absolute(self.prefix.join(&suffix)); + tracing::debug!(route = %absolute, "announce no longer authorized"); + self.retract(stream, suffix, &absolute)?; + } + return Ok(()); } + + // A route withheld earlier left no trace, so take a fresh cursor: its initial + // burst is the origin's current state, which is then diffed against what the + // peer holds. + *announced = origin.announced(); + let mut current: HashMap = HashMap::new(); + while let Some(update) = announced.try_next() { + let suffix = self.suffix(&update); + match update.kind.is_active() { + true => current.insert(suffix, update.route), + false => current.remove(&suffix), + }; + } + + let stale: Vec<_> = self + .live + .keys() + .filter(|suffix| !current.contains_key(*suffix) || !self.permitted(&self.prefix.join(*suffix))) + .cloned() + .collect(); + for suffix in stale { + let absolute = origin.absolute(self.prefix.join(&suffix)); + self.retract(stream, suffix, &absolute)?; + } + + for (suffix, route) in current { + let prefix = self.prefix.join(&suffix); + if !self.permitted(&prefix) { + continue; + } + let absolute = origin.absolute(&prefix); + let Some((hops, cost)) = self.outgoing(&route, &absolute) else { + continue; + }; + match self.live.get(&suffix) { + // Still held: bring its metadata up to date in case the old cursor had + // an update pending. + Some(&Some(id)) => stream + .writer + .buffer(&lite::AnnounceBroadcast::Restart { id, hops, cost })?, + Some(&None) => {} + None => { + tracing::debug!(route = %absolute, "announce"); + let id = self.assign_id(); + self.live.insert(suffix.clone(), id); + stream + .writer + .buffer(&lite::AnnounceBroadcast::Active { suffix, hops, cost })?; + } + } + } + Ok(()) } /// Where an update travels on this stream: its prefix relative to the requested @@ -718,11 +1075,14 @@ impl AnnounceRun { let suffix = self.suffix(&update); if update.kind.is_active() { + initial.retain(|(s, ..)| s != &suffix); + if !self.permitted(&update.prefix) { + continue; + } let Some((hops, cost)) = self.outgoing(&update.route, &absolute) else { continue; }; tracing::debug!(route = %absolute, "announce"); - initial.retain(|(s, ..)| s != &suffix); initial.push((suffix, hops, cost)); } else { // A potential race: a just-announced route already retracted. @@ -766,6 +1126,13 @@ impl AnnounceRun { let mut cx = Context::from_waker(waiter.waker()); if matches!(self.phase, AnnouncePhase::Init) { + // Start from whatever grant we already hold, so the initial set never + // advertises something it would withdraw a moment later. + if let Some(auth) = &self.auth + && let Poll::Ready(union) = auth.poll_union(&mut self.epoch, waiter) + { + self.permit = union.map(|grant| grant.publish); + } self.init(stream, origin, announced)?; self.phase = AnnouncePhase::Running; } @@ -778,6 +1145,16 @@ impl AnnounceRun { return stream.writer.poll_closed(&mut cx); } + // A grant change applies before the next update, so a route it no longer + // covers is withdrawn rather than re-sent. + if let Some(auth) = &self.auth + && let Poll::Ready(Some(grant)) = auth.poll_union(&mut self.epoch, waiter) + && self.permit.as_ref() != Some(&grant.publish) + { + self.regrant(stream, origin, announced, grant.publish)?; + continue; + } + if let Poll::Ready(res) = stream.reader.poll_closed(&mut cx) { return Poll::Ready(res); } @@ -796,7 +1173,7 @@ impl AnnounceRun { let absolute = origin.absolute(&update.prefix); let suffix = self.suffix(&update); - if !update.kind.is_active() { + if !update.kind.is_active() || !self.permitted(&update.prefix) { self.retract(stream, suffix, &absolute)?; continue; } @@ -916,7 +1293,15 @@ impl TrackInfoServe { self.absolute = self.shared.origin.absolute(&msg.broadcast).to_owned(); self.track = msg.track.to_string(); tracing::debug!(broadcast = %self.absolute, track = %self.track, "track info requested"); + // Checked before anything is resolved, like a subscription. + let allowed = self + .shared + .auth + .allows(crate::auth::Direction::Publish, msg.broadcast.as_str()); self.state = TrackInfoState::Hop { msg }; + if !allowed { + return Poll::Ready(Err(Error::Unauthorized)); + } } TrackInfoState::Hop { .. } => { // The peer requested this exact path, so it has already seen an @@ -969,6 +1354,8 @@ struct SubscribeServe { shared: Arc>, stream: Option>, state: SubscribeState, + /// Ends the subscription once our grant stops covering its broadcast. + gate: Option, // Log context, filled in after the decode. id: u64, absolute: crate::PathOwned, @@ -1010,6 +1397,7 @@ impl SubscribeServe { shared, stream: Some(stream), state: SubscribeState::Decode, + gate: None, id: 0, absolute: Default::default(), track: Default::default(), @@ -1044,6 +1432,11 @@ impl SubscribeServe { } fn poll_serve(&mut self, waiter: &kio::Waiter) -> Poll> { + if let Some(gate) = &mut self.gate + && gate.poll_denied(waiter).is_ready() + { + return Poll::Ready(Err(Error::Unauthorized)); + } loop { match &mut self.state { SubscribeState::Decode => { @@ -1056,6 +1449,21 @@ impl SubscribeServe { self.track = msg.track.to_string(); tracing::info!(id = self.id, broadcast = %self.absolute, track = %self.track, "subscribed started"); + if self.shared.version.has_auth() { + let mut gate = crate::auth::Gate::new( + self.shared.auth.clone(), + msg.broadcast.to_owned(), + crate::auth::Direction::Publish, + ); + // Checked before anything is resolved, so a broadcast our grant + // does not cover is never served, not just cut off later. + if gate.poll_denied(waiter).is_ready() { + self.state = SubscribeState::Hop { msg }; + return Poll::Ready(Err(Error::Unauthorized)); + } + self.gate = Some(gate); + } + self.state = SubscribeState::Hop { msg }; } SubscribeState::Hop { .. } => { @@ -1290,7 +1698,15 @@ impl FetchServe { self.group = msg.group; tracing::info!(broadcast = %self.absolute, track = %self.track, group = %self.group, "fetch started"); + // Checked before anything is resolved, like a subscription. + let allowed = self + .shared + .auth + .allows(crate::auth::Direction::Publish, msg.broadcast.as_str()); self.state = FetchState::Hop { msg }; + if !allowed { + return Poll::Ready(Err(Error::Unauthorized)); + } } FetchState::Hop { .. } => { // The peer fetched this exact path, so it has already seen an @@ -3048,6 +3464,9 @@ mod tests { peer_setup, goaway, peer_hop: Some(assigned), + auth: crate::auth::Handle::new(false), + peer_grant: Default::default(), + client: false, }); let serving = kio::wait(|waiter| publisher.shared.poll_serving_origin(waiter)).await; @@ -3150,6 +3569,9 @@ mod tests { peer_setup: crate::lite::PeerSetup::default(), goaway, peer_hop: None, + auth: crate::auth::Handle::new(false), + peer_grant: Default::default(), + client: false, }); ProbeServe::new(publisher.shared.clone(), publisher.runtime.clone(), stream) } diff --git a/rs/moq-net/src/lite/session.rs b/rs/moq-net/src/lite/session.rs index 0d00e5a29d..076f2d14ea 100644 --- a/rs/moq-net/src/lite/session.rs +++ b/rs/moq-net/src/lite/session.rs @@ -18,6 +18,8 @@ pub(crate) struct SessionStart { pub driver: Driver, /// The session-side GOAWAY halves, stored on the public [`crate::Session`]. pub goaway: crate::goaway::Handle, + /// The session's AUTH tokens and grants, stored on the public [`crate::Session`]. + pub auth: crate::auth::Handle, } /// Server: read the peer's single SETUP message off its Setup Stream before starting @@ -54,6 +56,11 @@ pub struct Config { /// The runtime that arms the session's timers. pub runtime: crate::time::Clock, + /// Whether we dialed the session. Only the dialing side aborts on a publication + /// its grant does not cover: a server's publish origin is everything the peer + /// may read, not what it intends to push. + pub client: bool, + /// The transport carrying the session. Cloned into every loop that outlives /// [`start`], so the connection closes when the last of them drops. pub session: S, @@ -86,6 +93,11 @@ pub struct Config { /// gated on the client's path via [`accept_setup`]). Seeds the peer-setup slot so /// the Setup Stream isn't expected again. `None` reads it from the wire as usual. pub peer_setup: Option, + + /// The session's auth handle, created before [`start`] so a server can take the + /// peer's token requests during its handshake. Supports AUTH exactly when `version` + /// does. + pub auth: crate::auth::Handle, } /// Start a lite session. @@ -97,6 +109,7 @@ where { let Config { runtime, + client, session, setup_stream, publish, @@ -105,6 +118,7 @@ where version, mut our_setup, peer_setup, + auth, } = config; let recv_bw = bandwidth::Producer::new(); @@ -133,6 +147,15 @@ where .filter(|hop| hop.id() != 0); } + // What the peer's connection credential earns by default: publishing what our + // subscribe half accepts, and subscribing to what our publish half serves. A + // missing half grants nothing. + let peer_grant = crate::auth::Grant { + publish: subscribe.as_ref().map(|origin| origin.allowed()).unwrap_or_default(), + subscribe: publish.as_ref().map(|origin| origin.allowed()).unwrap_or_default(), + expires: None, + }; + // Always run both loops so inbound control (Subscribe/Announce/Probe/Goaway) // and GROUP streams are accepted regardless of which halves the caller wired. // An unset half gets an empty origin: an empty publish origin announces nothing @@ -164,6 +187,13 @@ where // Read out before the setup machine takes ownership below. let our_cost = our_setup.cost; + // Present the connection's own credential (the empty token) right away, so + // both sides learn their grant without waiting on the app. + let setup_token = match version.has_auth() { + true => Some(auth.present(bytes::Bytes::new(), true)?), + false => None, + }; + let publisher = Publisher::new(PublisherConfig { runtime: runtime.clone(), session: session.clone(), @@ -172,6 +202,9 @@ where peer_setup: peer_setup.clone(), goaway: goaway.clone(), peer_hop, + auth: auth.clone(), + peer_grant, + client, }); let subscriber = Subscriber::new(SubscriberConfig { runtime: runtime.clone(), @@ -186,9 +219,20 @@ where // for its own egress. cost: our_cost, going_away: goaway.going_away.clone(), + auth: auth.clone(), }); let driver = Driver { + auth: Present { + runtime: runtime.clone(), + session: session.clone(), + version, + handle: auth.clone(), + going_away: goaway.going_away.clone(), + tokens: kio::Tasks::new(), + started: false, + _setup: setup_token, + }, setup: version .has_setup_stream() .then(|| SendSetup::new(session.clone(), our_setup, version)), @@ -203,12 +247,15 @@ where recv_bandwidth: recv_bw_consumer, driver, goaway: goaway_handle, + auth, }) } /// The lite session driver: one poll function racing every protocol arm, in /// place of a task set of boxed futures. pub(crate) struct Driver { + /// Presenting our tokens, one AUTH stream each. + auth: Present, /// Advertising our capabilities, or `None` once sent (or on a version with no /// Setup Stream). setup: Option>, @@ -230,6 +277,10 @@ where { pub(crate) fn poll(&mut self, waiter: &kio::Waiter) -> Poll> { let res = std::task::ready!(self.poll_protocol(waiter)); + self.auth.handle.close(match &res { + Ok(()) => Error::Cancel, + Err(err) => err.clone(), + }); match &res { Err(Error::Transport(_)) => { tracing::info!("session terminated"); @@ -251,6 +302,9 @@ where fn poll_protocol(&mut self, waiter: &kio::Waiter) -> Poll> { let mut cx = Context::from_waker(waiter.waker()); + // Presenting tokens never ends the session. + self.auth.poll(waiter); + // The send-side machines never end the session; completion just retires them. if let Some(setup) = &mut self.setup && setup.poll(&mut cx).is_ready() @@ -278,6 +332,172 @@ where } } +impl Drop for Driver { + fn drop(&mut self) { + // Dropped without finishing: release anything still waiting on a token. + self.auth.handle.close(Error::Cancel); + } +} + +/// Opens one AUTH stream per token this side presents. +struct Present { + runtime: crate::time::Clock, + session: S, + version: Version, + handle: crate::auth::Handle, + going_away: crate::goaway::GoingAway, + tokens: kio::Tasks>, + /// Whether the first poll decided who answers the peer's tokens. + started: bool, + /// The connection's own credential, held for the life of the session. + _setup: Option, +} + +impl Present { + fn poll(&mut self, waiter: &kio::Waiter) { + if !self.started { + // Decided once, before any AUTH stream can be accepted: the app took the + // requests before running the driver, or the session answers itself. + let _ = self.handle.acceptor(); + self.started = true; + } + while let Poll::Ready(Some((id, token))) = self.handle.poll_opening(waiter) { + self.tokens.push(PresentToken { + runtime: self.runtime.clone(), + session: self.session.clone(), + version: self.version, + handle: self.handle.clone(), + going_away: self.going_away.clone(), + id, + state: PresentState::Open { token }, + }); + } + let _ = self.tokens.poll(waiter); + } +} + +/// One token's AUTH stream: send the token, then track the grant until either +/// side ends it. +struct PresentToken { + runtime: crate::time::Clock, + session: S, + version: Version, + handle: crate::auth::Handle, + going_away: crate::goaway::GoingAway, + id: u64, + state: PresentState, +} + +enum PresentState { + Open { token: bytes::Bytes }, + Run { stream: Stream, answered: bool }, + Done, +} + +impl kio::Task for PresentToken { + fn poll(&mut self, waiter: &kio::Waiter) -> Poll<()> { + let err = ready!(self.poll_present(waiter)); + let answered = matches!(self.state, PresentState::Run { answered: true, .. }); + if let PresentState::Run { stream, .. } = std::mem::replace(&mut self.state, PresentState::Done) { + stream.writer.abort(&Error::Cancel); + } + let err = match err { + // A peer that predates AUTH resets a stream type it does not know, and one + // that takes no tokens in band refuses the same way: either way, before any + // reply, this token earns nothing here. + Error::Stream(_) | Error::Decode(crate::DecodeError::Short) if !answered => Error::Unsupported, + err => err, + }; + match &err { + Error::Cancel | Error::Unsupported | Error::Transport(_) | Error::Session(_) => { + tracing::debug!(%err, "auth token ended") + } + err => tracing::warn!(%err, "auth token ended"), + } + self.handle.ended(self.id, err); + Poll::Ready(()) + } +} + +impl PresentToken { + /// Run the stream, resolving with why the token ended. + fn poll_present(&mut self, waiter: &kio::Waiter) -> Poll { + let mut cx = Context::from_waker(waiter.waker()); + loop { + match &mut self.state { + PresentState::Open { token } => { + // After a GOAWAY the peer must not see new streams. + if self.going_away.is_set() { + return Poll::Ready(Error::GoingAway); + } + let token = token.clone(); + let mut stream = match ready!(Stream::poll_open(&mut self.session, self.version, &mut cx)) { + Ok(stream) => stream, + Err(err) => return Poll::Ready(err), + }; + let res = stream + .writer + .buffer(&super::ControlType::Auth) + .and_then(|()| stream.writer.buffer(&super::Auth { token })); + if let Err(err) = res { + return Poll::Ready(err); + } + self.state = PresentState::Run { + stream, + answered: false, + }; + } + PresentState::Run { stream, answered } => { + if let Err(err) = ready!(stream.writer.poll_flush(&mut cx)) { + return Poll::Ready(err); + } + // Withdrawn: closing the stream is what tells the peer. + if self.handle.poll_withdrawn(self.id, waiter).is_ready() { + return Poll::Ready(Error::Cancel); + } + let reply = match ready!(stream.reader.poll_decode_maybe::(&mut cx)) { + Ok(Some(reply)) => reply, + // The peer ended the grant without revoking it, or closed without + // ever answering (mapped to unsupported by the caller). + Ok(None) if *answered => return Poll::Ready(Error::Cancel), + Ok(None) => return Poll::Ready(Error::Decode(crate::DecodeError::Short)), + Err(err) => return Poll::Ready(err), + }; + match reply { + super::AuthReply::Ok(ok) => { + let now = crate::runtime::Timers::now(&self.runtime); + // The expiry is the peer's number: one past the local clock's range + // is malformed, not a reason to panic. + let expires = match ok.expires.map(|expires| now.checked_add(expires)) { + Some(None) => return Poll::Ready(Error::ProtocolViolation), + expires => expires.flatten(), + }; + *answered = true; + self.handle.granted( + self.id, + crate::auth::Grant { + publish: ok.publish, + subscribe: ok.subscribe, + expires, + }, + ); + } + super::AuthReply::Error(refused) => { + let code = u32::try_from(refused.code).unwrap_or(u32::MAX); + let err = Error::Session(crate::SessionError::from_code(code)); + tracing::warn!(%err, reason = %refused.reason, "auth token refused"); + self.handle.refused(self.id); + *answered = true; + return Poll::Ready(err); + } + } + } + PresentState::Done => return Poll::Ready(Error::Cancel), + } + } + } +} + /// Drain SessionInfo updates off the legacy session stream, resolving only when /// the stream dies (a FIN counts: the peer abandoned the session). // TODO do something useful with the updates diff --git a/rs/moq-net/src/lite/stream.rs b/rs/moq-net/src/lite/stream.rs index 519ea3ef9b..144397ecd1 100644 --- a/rs/moq-net/src/lite/stream.rs +++ b/rs/moq-net/src/lite/stream.rs @@ -14,6 +14,7 @@ pub enum ControlType { Probe = 4, Goaway = 5, Track = 6, + Auth = 7, } impl Decode for ControlType { diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 729a23e4bc..8ed44ffbf3 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -41,6 +41,8 @@ pub(super) struct SubscriberConfig { /// Set once the peer sends a GOAWAY; new request streams are then rejected /// with [`Error::GoingAway`] (the peer told us to stop asking). pub going_away: crate::goaway::GoingAway, + /// Our tokens' grants: a subscription the union stops covering is cancelled. + pub auth: crate::auth::Handle, } #[derive(Clone)] @@ -79,6 +81,7 @@ pub(super) struct Subscriber { /// [`SourceServe`] machines. sources: kio::Queue<(PathOwned, crate::broadcast::Dynamic)>, going_away: crate::goaway::GoingAway, + auth: crate::auth::Handle, } #[derive(Clone)] @@ -110,6 +113,7 @@ impl Subscriber { cost: config.cost, sources: kio::Queue::new(), going_away: config.going_away, + auth: config.auth, } } @@ -1340,6 +1344,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), + auth: crate::auth::Handle::new(false), }); let broadcast = crate::broadcast::Info::new().produce(); @@ -1413,6 +1418,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), + auth: crate::auth::Handle::new(false), }); let subscribes = subscriber.subscribes.clone(); let serve = TrackServe { @@ -1486,6 +1492,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), + auth: crate::auth::Handle::new(false), }); let broadcast = crate::broadcast::Info::new().produce(); let producer = broadcast.create_track("catalog.json", None).unwrap(); @@ -1911,6 +1918,7 @@ mod tests { cost: None, peer_hop: Some(assigned), going_away: Default::default(), + auth: crate::auth::Handle::new(false), }); let path = Path::new("room/host").to_owned(); @@ -1967,6 +1975,7 @@ mod tests { cost: None, peer_hop: Some(assigned), going_away: Default::default(), + auth: crate::auth::Handle::new(false), }); let path = Path::new("room/host").to_owned(); @@ -2023,6 +2032,7 @@ mod tests { cost: None, peer_hop: Some(assigned), going_away: Default::default(), + auth: crate::auth::Handle::new(false), }); let path = Path::new("room/host").to_owned(); @@ -2079,6 +2089,7 @@ mod tests { cost: None, peer_hop: Some(assigned), going_away: Default::default(), + auth: crate::auth::Handle::new(false), }); // The sender's identity is already in the chain: the route came back through it. @@ -2144,6 +2155,7 @@ mod tests { cost: None, peer_hop: Some(assigned), going_away: Default::default(), + auth: crate::auth::Handle::new(false), }); // The path as advertised to the peer: our hop is already in the chain. @@ -2202,6 +2214,7 @@ mod tests { peer_hop: Some(assigned), cost: None, going_away: Default::default(), + auth: crate::auth::Handle::new(false), }); // An announce with an empty chain and no responder id: the versions that @@ -2247,6 +2260,7 @@ mod tests { cost: None, peer_hop: Some(assigned), going_away: Default::default(), + auth: crate::auth::Handle::new(false), }); let hops = crate::Hops::try_from(vec![crate::Hop::UNKNOWN, crate::Hop::UNKNOWN]).unwrap(); @@ -2310,6 +2324,7 @@ mod tests { cost: None, peer_hop: None, going_away: Default::default(), + auth: crate::auth::Handle::new(false), }); (subscriber, consumer) } @@ -2372,6 +2387,7 @@ mod tests { cost: None, peer_hop: None, going_away: Default::default(), + auth: crate::auth::Handle::new(false), }); let path = Path::new("room/host").to_owned(); @@ -2976,6 +2992,8 @@ impl Establish { struct TrackServeRun { serve: TrackServe, state: TrackRunState, + /// Cancels the track once our grant stops covering its broadcast. + gate: Option, } // A state machine's enum is its storage: one transient instance per stream, so the @@ -3006,12 +3024,46 @@ impl TrackServeRun { let info = track::Info::default().with_max_age(serve.subscriber.origin.default_max_age()); TrackRunState::Serve(ServeLoop::new(&serve, request, info, None)) }; - Self { serve, state } + let gate = serve.subscriber.version.has_auth().then(|| { + crate::auth::Gate::new( + serve.subscriber.auth.clone(), + serve.path.clone(), + crate::auth::Direction::Subscribe, + ) + }); + Self { serve, state, gate } + } + + /// Our grant no longer covers the broadcast: end the track and cancel the + /// upstream subscription, leaving the rest of the session alone. + fn revoke(&mut self) { + tracing::info!(broadcast = %self.serve.subscriber.log_path(&self.serve.path), track = %self.serve.name, "subscription no longer authorized"); + match std::mem::replace(&mut self.state, TrackRunState::Done) { + TrackRunState::Info { request, .. } => { + if let Some(request) = request { + request.reject(Error::Unauthorized); + } + } + TrackRunState::Serve(mut serve_loop) => { + let _ = serve_loop.serving.abort(Error::Unauthorized); + if let Sub::Active(active) = &mut serve_loop.sub { + self.serve.subscriber.remove_subscribe(active.id); + let _ = active.stream.writer.finish(); + } + } + TrackRunState::Done => {} + } } } impl kio::Task for TrackServeRun { fn poll(&mut self, waiter: &kio::Waiter) -> Poll<()> { + if let Some(gate) = &mut self.gate + && gate.poll_denied(waiter).is_ready() + { + self.revoke(); + return Poll::Ready(()); + } loop { match &mut self.state { TrackRunState::Info { request, info } => { diff --git a/rs/moq-net/src/lite/version.rs b/rs/moq-net/src/lite/version.rs index a3ddb60433..d2d13fe2bc 100644 --- a/rs/moq-net/src/lite/version.rs +++ b/rs/moq-net/src/lite/version.rs @@ -93,6 +93,18 @@ impl Version { } } + /// Whether either endpoint may open an Auth Stream (0x7) to present a token and + /// learn its grant. Added to lite-06, whose peers that predate it reset the + /// stream as an unknown type. + #[allow(clippy::match_like_matches_macro)] + pub fn has_auth(self) -> bool { + // Match form so future versions default forward (CLAUDE.md convention). + match self { + Self::Lite01 | Self::Lite02 | Self::Lite03 | Self::Lite04 | Self::Lite05 => false, + _ => true, + } + } + /// Whether announcements carry implicit announce ids: each `active` /// ANNOUNCE_BROADCAST assigns the next per-stream ordinal, and `ended`/`restart` /// reference that id instead of repeating the path. Added in lite-06. diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index fbf210651d..1b8261ac52 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -87,6 +87,7 @@ impl Server { version: lite::Version, client_setup: Option, peer_hop: Option, + auth: crate::auth::Handle, ) -> Result<(Session, crate::Driver), Error> where S: crate::transport::poll::Session, @@ -111,6 +112,7 @@ impl Server { let start = lite::start(lite::Config { runtime: runtime.clone(), + client: false, session: session.clone(), setup_stream: None, publish, @@ -119,6 +121,7 @@ impl Server { version, our_setup, peer_setup: client_setup, + auth, })?; Ok(Session::new( @@ -128,6 +131,7 @@ impl Server { start.recv_bandwidth, crate::driver::Protocol::Lite(Box::new(start.driver)), start.goaway, + start.auth, )) } @@ -210,11 +214,18 @@ impl Server { _ => return Err(Error::Version), }; + let auth = crate::auth::Handle::new(match &handshake { + PausedHandshake::LiteBare { version, .. } | PausedHandshake::LiteSetup { version, .. } => { + version.has_auth() + } + PausedHandshake::Boxed(_) => false, + }); Ok(Handshake { path, role, origin, assigned_hop: crate::Hop::random(), + auth, inner: Some(RequestInner { server: self.clone(), runtime, @@ -346,6 +357,8 @@ impl Server { role: None, origin: None, assigned_hop: crate::Hop::random(), + // Lite 01/02 and moq-transport 14-16 carry no AUTH. + auth: crate::auth::Handle::new(false), inner: Some(RequestInner { server: self.clone(), runtime, @@ -381,6 +394,8 @@ impl Server { // Cluster extension and declared a non-zero Hop ID. origin: peer_setup.declared.cluster.hop.filter(|h| *h != crate::Hop::UNKNOWN), assigned_hop: crate::Hop::random(), + // moq-transport carries no AUTH yet. + auth: crate::auth::Handle::new(false), inner: Some(RequestInner { server: self.clone(), runtime, @@ -409,6 +424,9 @@ pub struct Handshake { /// on the wire. Fresh per request unless the caller overrides it /// ([`Handshake::with_peer_hop`]). assigned_hop: crate::Hop, + /// The session's auth handle, available before [`Handshake::ok`] so the caller can + /// take the peer's token requests before any arrive. + auth: crate::auth::Handle, // Taken by `ok`/`close`; `Drop` rejects the handshake if neither ran. inner: Option>, } @@ -452,7 +470,13 @@ type Accept = crate::util::MaybeSendBox<'static, Result<(Session, crate::Driv /// `&Handshake: Send`, which is `Handshake: Sync`, which is this. trait Paused: MaybeSend + MaybeSync { /// Complete the handshake with the final server config. - fn ok(self: Box, server: Server, runtime: Clock, peer_hop: Option) -> Accept; + fn ok( + self: Box, + server: Server, + runtime: Clock, + peer_hop: Option, + auth: crate::auth::Handle, + ) -> Accept; /// Reject the handshake, closing the transport with `err`'s wire code. fn close(self: Box, err: Error); @@ -473,7 +497,13 @@ where S::SendStream: MaybeSync, S::RecvStream: MaybeSync, { - fn ok(self: Box, server: Server, runtime: Clock, peer_hop: Option) -> Accept { + fn ok( + self: Box, + server: Server, + runtime: Clock, + peer_hop: Option, + auth: crate::auth::Handle, + ) -> Accept { use crate::util::MaybeBoxedExt as _; async move { let Self { @@ -509,6 +539,7 @@ where None, crate::driver::Protocol::Ietf(protocol), goaway, + auth, )) } .maybe_boxed() @@ -538,7 +569,13 @@ where S::SendStream: MaybeSync, S::RecvStream: MaybeSync, { - fn ok(self: Box, server: Server, runtime: Clock, peer_hop: Option) -> Accept { + fn ok( + self: Box, + server: Server, + runtime: Clock, + peer_hop: Option, + auth: crate::auth::Handle, + ) -> Accept { use crate::util::MaybeBoxedExt as _; async move { let Self { @@ -568,12 +605,13 @@ where }; stream.writer.encode(&server_setup).await?; - let (recv_bw, protocol, goaway) = match version { + let (recv_bw, protocol, goaway, auth) = match version { Version::Lite(v) => { let stream = stream.with_version(v); // Pre-lite-05: no Setup Stream, so nothing to advertise or seed. let start = lite::start(lite::Config { runtime: runtime.clone(), + client: false, session: session.clone(), setup_stream: Some(stream), publish, @@ -582,11 +620,13 @@ where version: v, our_setup: lite::Setup::default(), peer_setup: None, + auth, })?; ( start.recv_bandwidth, crate::driver::Protocol::Lite(Box::new(start.driver)), start.goaway, + start.auth, ) } Version::Ietf(v) => { @@ -607,11 +647,11 @@ where peer_setup_stream: None, peer_declared: Some(peer_declared), })?; - (None, crate::driver::Protocol::Ietf(protocol), goaway) + (None, crate::driver::Protocol::Ietf(protocol), goaway, auth) } }; - Ok(Session::new(runtime, session, version, recv_bw, protocol, goaway)) + Ok(Session::new(runtime, session, version, recv_bw, protocol, goaway, auth)) } .maybe_boxed() } @@ -660,6 +700,14 @@ where self.origin } + /// The session's auth handle, the same one [`Session::auth`] returns once accepted. + /// + /// Take [`requests`](crate::auth::Handle::requests) here to answer the client's + /// tokens yourself; the choice is fixed once the session's driver first runs. + pub fn auth(&self) -> crate::auth::Handle { + self.auth.clone() + } + /// Publish to the connected client. Overrides any value from the [`Server`] /// builder; typically set after inspecting [`path`](Self::path). pub fn with_publisher(mut self, publish: impl Consume) -> Self { @@ -706,6 +754,7 @@ where /// Poll or spawn the returned driver to run the session. pub async fn ok(mut self) -> Result<(Session, crate::Driver), Error> { let peer_hop = Some(self.assigned_hop); + let auth = self.auth.clone(); let RequestInner { server, runtime, @@ -714,14 +763,14 @@ where match handshake { PausedHandshake::LiteBare { session, version } => { - server.start_lite(runtime, session, version, None, peer_hop) + server.start_lite(runtime, session, version, None, peer_hop, auth) } PausedHandshake::LiteSetup { session, version, client_setup, - } => server.start_lite(runtime, session, version, Some(client_setup), peer_hop), - PausedHandshake::Boxed(paused) => paused.ok(server, runtime, peer_hop).await, + } => server.start_lite(runtime, session, version, Some(client_setup), peer_hop, auth), + PausedHandshake::Boxed(paused) => paused.ok(server, runtime, peer_hop, auth).await, } } @@ -1073,6 +1122,7 @@ mod tests { role: None, origin: None, assigned_hop: Hop::random(), + auth: crate::auth::Handle::new(false), inner: Some(RequestInner { server: Server::new().with_publisher(&origin), runtime: Clock::new(tokio::time::Instant::now().into_std()), diff --git a/rs/moq-net/src/session.rs b/rs/moq-net/src/session.rs index 8f89d91a87..0d9a234c11 100644 --- a/rs/moq-net/src/session.rs +++ b/rs/moq-net/src/session.rs @@ -4,7 +4,7 @@ use std::{sync::Arc, task::Poll, time::Duration}; use web_transport_trait::Stats as _; -use crate::{Error, SessionError, Version, bandwidth, goaway}; +use crate::{Error, SessionError, Version, auth, bandwidth, goaway}; /// A close requested by a session handle, executed by the driver. #[derive(Clone)] @@ -86,6 +86,7 @@ pub struct Session { send_bandwidth: Option, recv_bandwidth: Option, goaway: Arc, + auth: auth::Handle, } impl Session { @@ -196,6 +197,15 @@ impl Session { pub fn draining(&self) -> goaway::Consumer { self.goaway.consumer() } + + /// The tokens this side presented and the grant they earned, plus the tokens + /// the peer presents. See [`auth`]. + /// + /// On moq-lite-06 each side presents its connection's credential right after + /// setup; on every other version the grant stays `None`. + pub fn auth(&self) -> auth::Handle { + self.auth.clone() + } } impl Session { @@ -206,6 +216,7 @@ impl Session { recv_bandwidth: Option, protocol: crate::driver::Protocol, goaway: goaway::Handle, + auth: auth::Handle, ) -> (Self, crate::Driver) where S: crate::transport::poll::Session, @@ -247,6 +258,7 @@ impl Session { send_bandwidth, recv_bandwidth, goaway: Arc::new(goaway), + auth, }; let driver = crate::Driver::new( runtime.clone(), diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs new file mode 100644 index 0000000000..d4dd6b3be9 --- /dev/null +++ b/rs/moq-net/tests/auth.rs @@ -0,0 +1,622 @@ +//! In-band AUTH over the in-memory mock transport: both sides learn their grant, +//! tokens union, and a publication outside the grant fails loud. + +mod support; + +use std::time::Duration; + +use moq_net::{ + Client, Error, Hop, Pattern, Patterns, Server, Session, SessionError, Version, + auth::{self, Grant}, + origin, +}; +use support::harness::{now, run}; +use support::mock::{MockSession, create_mock_session_pair}; + +/// Maximum time any single test may run before being treated as a deadlock. +const TEST_TIMEOUT: Duration = Duration::from_secs(10); + +const LITE_06: &str = "moq-lite-06"; + +/// Build an origin producer, spawning its driver on the ambient runtime. +fn produce_origin(hop: u64) -> origin::Producer { + let (producer, driver) = origin::Producer::new(origin::Config::new(Hop::new(hop).unwrap())); + tokio::spawn(run(driver)); + producer +} + +fn patterns(prefixes: &[&str]) -> Patterns { + prefixes + .iter() + .map(|prefix| Pattern::subtree(prefix).unwrap()) + .collect() +} + +fn grant(publish: &[&str], subscribe: &[&str]) -> Grant { + Grant { + publish: patterns(publish), + subscribe: patterns(subscribe), + expires: None, + } +} + +/// Wait for a watch to hold a grant satisfying `f`. +async fn wait_for(mut watch: auth::Watch, f: impl Fn(&Option) -> bool) -> Option { + loop { + let current = watch.peek(); + if f(¤t) { + return current; + } + watch.changed().await.expect("grant watch ended"); + } +} + +/// The union, once the peer has answered anything. +async fn granted(session: &Session) -> Grant { + wait_for(session.auth().grant(), Option::is_some).await.unwrap() +} + +/// Wait until `path` is announced (`true`) or retracted (`false`) in `origin`. +async fn wait_announced(origin: &origin::Consumer, path: &str, active: bool) { + let mut announced = origin.announced(); + let mut live = std::collections::HashSet::new(); + loop { + if live.contains(path) == active { + return; + } + let update = announced.next().await.expect("origin closed"); + match update.kind.is_active() { + true => live.insert(update.prefix.to_string()), + false => live.remove(update.prefix.as_str()), + }; + } +} + +#[derive(Default)] +struct Options { + client_publish: Option, + client_subscribe: Option, + server_publish: Option, + server_subscribe: Option, + /// Take the server's AUTH requests before its driver runs. + server_requests: bool, + version: Option<&'static str>, +} + +struct Pair { + client: Session, + server: Session, + client_transport: MockSession, + requests: Option, +} + +async fn connect(opts: Options) -> Pair { + let version: Version = opts.version.unwrap_or(LITE_06).parse().unwrap(); + let (client_transport, server_transport) = create_mock_session_pair(Some(version.alpn())); + + let mut client = Client::new().with_versions(version.into()); + if let Some(publish) = &opts.client_publish { + client = client.with_publisher(publish); + } + if let Some(subscribe) = opts.client_subscribe { + client = client.with_subscriber(subscribe); + } + + let mut server = Server::new().with_versions(version.into()); + if let Some(publish) = &opts.server_publish { + server = server.with_publisher(publish); + } + if let Some(subscribe) = opts.server_subscribe { + server = server.with_subscriber(subscribe); + } + + let observe = client_transport.clone(); + let client_fut = async { + let (session, driver) = client.connect(now(), client_transport).await.expect("client handshake"); + tokio::spawn(run(driver)); + session + }; + let server_fut = async { + let handshake = server + .accept_request(now(), server_transport) + .await + .expect("server handshake"); + // Taken before the session starts, the way a relay verifying tokens would. + let requests = opts + .server_requests + .then(|| handshake.auth().requests().expect("requests available before ok()")); + let (session, driver) = handshake.ok().await.expect("server accept"); + tokio::spawn(run(driver)); + (session, requests) + }; + let (client, (server, requests)) = tokio::join!(client_fut, server_fut); + + Pair { + client, + server, + client_transport: observe, + requests, + } +} + +/// Answer the peer's tokens from a table, holding every grant (and any request the +/// table leaves unanswered) for as long as the returned task lives. +fn serve( + mut requests: auth::Requests, + answer: impl Fn(&[u8]) -> Option + Send + 'static, +) -> tokio::sync::mpsc::UnboundedReceiver<(Vec, auth::Issued)> { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + tokio::spawn(async move { + let mut unanswered = Vec::new(); + while let Some(request) = requests.next().await { + let token = request.token().to_vec(); + match answer(&token) { + Some(grant) => { + let _ = tx.send((token, request.accept(grant))); + } + None => unanswered.push(request), + } + } + }); + rx +} + +fn within(f: F) -> tokio::time::Timeout { + tokio::time::timeout(TEST_TIMEOUT, f) +} + +/// Each side's default grant is what the other side's origin handles allow: +/// its subscribe half bounds what we may publish, its publish half what we may +/// subscribe to. +#[tokio::test] +async fn both_sides_learn_their_grant_from_scoped_origins() { + within(async { + let relay = produce_origin(1); + let pair = connect(Options { + client_publish: Some(produce_origin(2)), + client_subscribe: Some(produce_origin(3)), + server_publish: Some(relay.scope("", &patterns(&["room"])).unwrap()), + server_subscribe: Some(relay.scope("", &patterns(&["room/alice"])).unwrap()), + ..Default::default() + }) + .await; + + assert_eq!(granted(&pair.client).await, grant(&["room/alice"], &["room"])); + // The empty prefix: an unscoped origin grants everything. + assert_eq!(granted(&pair.server).await, grant(&[""], &[""])); + }) + .await + .expect("timed out"); +} + +/// A missing half grants nothing: the empty list, distinct from the empty prefix. +#[tokio::test] +async fn a_publish_only_session_grants_no_subscribe() { + within(async { + let pair = connect(Options { + client_publish: Some(produce_origin(2)), + server_subscribe: Some(produce_origin(1)), + ..Default::default() + }) + .await; + + // The server may subscribe to what the client publishes, but publish nothing to + // a client that never reads. + assert_eq!(granted(&pair.server).await, grant(&[], &[""])); + assert_eq!(granted(&pair.client).await, grant(&[""], &[])); + }) + .await + .expect("timed out"); +} + +/// A broadcast outside the grant aborts the session and names the path, where it +/// used to wait forever for a solicitation that never comes. +#[tokio::test] +async fn an_out_of_scope_announce_aborts_with_the_path() { + within(async { + let publisher = produce_origin(2); + let relay = produce_origin(1); + let pair = connect(Options { + client_publish: Some(publisher.clone()), + server_subscribe: Some(relay.scope("", &patterns(&["baz"])).unwrap()), + ..Default::default() + }) + .await; + + // In scope: served, and nothing aborts. + let ok = publisher.create_broadcast("baz/ok").unwrap(); + ok.announce(Default::default()).unwrap(); + wait_announced(&relay.consume(), "baz/ok", true).await; + assert_eq!(pair.client_transport.close_reason(), None); + + let bad = publisher.create_broadcast("foo/bar").unwrap(); + bad.announce(Default::default()).unwrap(); + let err = pair.server.closed().await; + assert!( + matches!(err, Error::Session(SessionError::Unauthorized)), + "server saw {err:?}" + ); + let (code, reason) = pair.client_transport.close_reason().expect("closed"); + assert_eq!(code, SessionError::Unauthorized.to_code()); + assert_eq!(reason, "unauthorized: foo/bar"); + }) + .await + .expect("timed out"); +} + +/// A broadcast published before the grant arrives is checked at admission too. +#[tokio::test] +async fn a_broadcast_published_before_the_grant_is_checked() { + within(async { + let publisher = produce_origin(2); + let early = publisher.create_broadcast("foo/bar").unwrap(); + early.announce(Default::default()).unwrap(); + + let pair = connect(Options { + client_publish: Some(publisher.clone()), + server_subscribe: Some(produce_origin(1).scope("", &patterns(&["baz"])).unwrap()), + ..Default::default() + }) + .await; + + assert!(matches!( + pair.server.closed().await, + Error::Session(SessionError::Unauthorized) + )); + assert_eq!( + pair.client_transport.close_reason().map(|(_, reason)| reason), + Some("unauthorized: foo/bar".to_string()) + ); + }) + .await + .expect("timed out"); +} + +/// Enforcement waits only for the tokens the session presented itself: a peer that +/// never answers an app-added token cannot suspend it. +#[tokio::test] +async fn an_unanswered_token_does_not_suspend_the_check() { + within(async { + let publisher = produce_origin(2); + let mut pair = connect(Options { + client_publish: Some(publisher.clone()), + server_subscribe: Some(produce_origin(1)), + server_requests: true, + ..Default::default() + }) + .await; + let _issued = serve(pair.requests.take().unwrap(), |token| { + token.is_empty().then(|| grant(&["baz"], &[])) + }); + + let auth = pair.client.auth(); + let pending = tokio::spawn(async move { auth.add("never answered").await.map(|_| ()) }); + assert_eq!(granted(&pair.client).await, grant(&["baz"], &[])); + + let bad = publisher.create_broadcast("foo/bar").unwrap(); + bad.announce(Default::default()).unwrap(); + assert!(matches!( + pair.server.closed().await, + Error::Session(SessionError::Unauthorized) + )); + // The session's close fails the token still waiting on its answer. + assert!(pending.await.unwrap().is_err()); + }) + .await + .expect("timed out"); +} + +/// Two tokens union; closing one shrinks the union and withdraws only what it alone +/// covered, without disconnecting. +#[tokio::test] +async fn tokens_union_and_withdrawing_one_shrinks_it() { + within(async { + let publisher = produce_origin(2); + let relay = produce_origin(1); + let mut pair = connect(Options { + client_publish: Some(publisher.clone()), + server_subscribe: Some(relay.clone()), + server_requests: true, + ..Default::default() + }) + .await; + let mut issued = serve(pair.requests.take().unwrap(), |token| match token { + b"" => Some(grant(&["a"], &[])), + b"t1" => Some(grant(&["b"], &[])), + _ => None, + }); + let (_, _setup) = issued.recv().await.unwrap(); + + let t1 = pair.client.auth().add("t1").await.expect("t1 granted"); + let (_, t1_issued) = issued.recv().await.unwrap(); + assert_eq!(t1.grant().peek(), Some(grant(&["b"], &[]))); + assert_eq!(granted(&pair.client).await, grant(&["a", "b"], &[])); + + let a = publisher.create_broadcast("a/x").unwrap(); + a.announce(Default::default()).unwrap(); + let b = publisher.create_broadcast("b/y").unwrap(); + b.announce(Default::default()).unwrap(); + let served = relay.consume(); + wait_announced(&served, "a/x", true).await; + wait_announced(&served, "b/y", true).await; + + drop(t1); + // The acceptor learns the token is gone, and the presenter withdraws b/y. + assert!(matches!(t1_issued.closed().await, Error::Cancel)); + wait_for(pair.client.auth().grant(), |g| g == &Some(grant(&["a"], &[]))).await; + wait_announced(&served, "b/y", false).await; + wait_announced(&served, "a/x", true).await; + assert_eq!(pair.client_transport.close_reason(), None); + }) + .await + .expect("timed out"); +} + +/// An update replaces one token's grant and leaves the other alone. +#[tokio::test] +async fn an_update_replaces_one_tokens_grant() { + within(async { + let mut pair = connect(Options { + client_publish: Some(produce_origin(2)), + server_subscribe: Some(produce_origin(1)), + server_requests: true, + ..Default::default() + }) + .await; + let mut issued = serve(pair.requests.take().unwrap(), |token| match token { + b"" => Some(grant(&["a"], &[])), + b"t1" => Some(grant(&["b"], &[])), + _ => None, + }); + let (_, _setup) = issued.recv().await.unwrap(); + let t1 = pair.client.auth().add("t1").await.unwrap(); + let (_, t1_issued) = issued.recv().await.unwrap(); + + t1_issued.update(grant(&["c"], &["c"])); + wait_for(t1.grant(), |g| g == &Some(grant(&["c"], &["c"]))).await; + assert_eq!(granted(&pair.client).await, grant(&["a", "c"], &["c"])); + }) + .await + .expect("timed out"); +} + +/// A revocation withdraws what the grant covered, even though the broadcast stays in +/// the shared origin, and an empty union can be authorized again. +#[tokio::test] +async fn a_revoked_grant_withdraws_and_can_be_restored() { + within(async { + let publisher = produce_origin(2); + let relay = produce_origin(1); + let mut pair = connect(Options { + client_publish: Some(publisher.clone()), + server_subscribe: Some(relay.clone()), + server_requests: true, + ..Default::default() + }) + .await; + let mut issued = serve(pair.requests.take().unwrap(), |token| match token { + b"" | b"again" => Some(grant(&["a"], &[])), + _ => None, + }); + let (_, setup) = issued.recv().await.unwrap(); + + let a = publisher.create_broadcast("a/x").unwrap(); + a.announce(Default::default()).unwrap(); + let served = relay.consume(); + wait_announced(&served, "a/x", true).await; + + setup.revoke(SessionError::Unauthorized, "expired"); + wait_for(pair.client.auth().grant(), |g| g == &Some(Grant::default())).await; + wait_announced(&served, "a/x", false).await; + // Still published locally, and the session survives the empty union. + assert!(publisher.consume().routed("a/x").await.is_some()); + assert_eq!(pair.client_transport.close_reason(), None); + + let _again = pair.client.auth().add("again").await.expect("re-authorized"); + wait_announced(&served, "a/x", true).await; + }) + .await + .expect("timed out"); +} + +/// A refused token surfaces the acceptor's code. +#[tokio::test] +async fn a_refused_token_reports_the_code() { + within(async { + let mut pair = connect(Options { + client_publish: Some(produce_origin(2)), + server_requests: true, + ..Default::default() + }) + .await; + let mut requests = pair.requests.take().unwrap(); + tokio::spawn(async move { + while let Some(request) = requests.next().await { + match request.token().is_empty() { + true => { + std::mem::forget(request.accept(Grant::default())); + } + false => request.reject(SessionError::Unauthorized, "bad signature"), + } + } + }); + + let err = pair.client.auth().add("forged").await.err().expect("refused"); + assert!(matches!(err, Error::Session(SessionError::Unauthorized)), "{err:?}"); + }) + .await + .expect("timed out"); +} + +/// Refusing the setup token grants nothing: the union becomes empty rather than +/// staying unknown, which the gates would read as unrestricted. +#[tokio::test] +async fn a_refused_setup_token_grants_nothing() { + within(async { + let mut pair = connect(Options { + client_publish: Some(produce_origin(2)), + server_requests: true, + ..Default::default() + }) + .await; + let mut requests = pair.requests.take().unwrap(); + tokio::spawn(async move { + while let Some(request) = requests.next().await { + request.reject(SessionError::Unauthorized, "bad credential"); + } + }); + + assert_eq!(granted(&pair.client).await, Grant::default()); + }) + .await + .expect("timed out"); +} + +/// Dropping the requests refuses tokens already queued, not only later ones. +#[tokio::test] +async fn dropping_the_requests_refuses_queued_tokens() { + within(async { + let mut pair = connect(Options { + client_publish: Some(produce_origin(2)), + server_requests: true, + ..Default::default() + }) + .await; + let mut requests = pair.requests.take().unwrap(); + // Wait for the setup token to be queued, then hand it back to the queue's owner. + let first = requests.next().await.expect("setup token"); + drop(first.accept(Grant::default())); + let auth = pair.client.auth(); + let pending = tokio::spawn(async move { auth.add("queued").await.map(drop) }); + // Give the second token time to reach the queue before dropping it. + tokio::time::sleep(Duration::from_millis(50)).await; + drop(requests); + + let err = pending.await.unwrap().expect_err("refused"); + assert!(matches!(err, Error::Session(SessionError::Unauthorized)), "{err:?}"); + }) + .await + .expect("timed out"); +} + +/// A closed session holds no grant: every token ended with it. +#[tokio::test] +async fn a_closed_session_holds_no_grant() { + within(async { + let pair = connect(Options { + client_publish: Some(produce_origin(2)), + server_subscribe: Some(produce_origin(1)), + ..Default::default() + }) + .await; + assert_ne!(granted(&pair.client).await, Grant::default()); + let token = pair.client.auth().grant(); + + pair.server.abort(Error::Cancel); + wait_for(token, |g| g == &Some(Grant::default())).await; + }) + .await + .expect("timed out"); +} + +/// A peer that takes no tokens in band resets the stream, which reads as +/// unsupported rather than a refusal: the same as a peer that predates AUTH. +#[tokio::test] +async fn a_reset_auth_stream_reports_unsupported() { + within(async { + let pair = connect(Options { + client_publish: Some(produce_origin(2)), + server_subscribe: Some(produce_origin(1)), + ..Default::default() + }) + .await; + granted(&pair.client).await; + + let err = pair.client.auth().add("token").await.err().expect("no acceptor"); + assert!(matches!(err, Error::Unsupported), "{err:?}"); + assert_eq!(pair.client_transport.close_reason(), None); + }) + .await + .expect("timed out"); +} + +/// Older versions never open the stream: no grant, and no way to add a token. +#[tokio::test] +async fn older_versions_have_no_grant() { + within(async { + let pair = connect(Options { + client_publish: Some(produce_origin(2)), + server_subscribe: Some(produce_origin(1)), + version: Some("moq-lite-05"), + ..Default::default() + }) + .await; + assert_eq!(pair.client.auth().grant().peek(), None); + assert!(matches!(pair.client.auth().add("token").await, Err(Error::Unsupported))); + }) + .await + .expect("timed out"); +} + +/// Losing a grant cancels the subscriptions it covered, in both directions, and +/// leaves the session up. +#[tokio::test] +async fn a_revoked_grant_cancels_its_subscriptions() { + within(async { + let ts = |ms| moq_net::Timestamp::from_millis(ms).unwrap(); + let prefs = || moq_net::track::Subscription::default().with_max_age(Duration::from_secs(10)); + + // The server publishes room/x to the client; the client publishes up/y to the server. + let server_origin = produce_origin(1); + let down = server_origin.create_broadcast("room/x").unwrap(); + let down_track = down.create_track("video", None).unwrap(); + down.announce(Default::default()).unwrap(); + + let client_origin = produce_origin(2); + let up = client_origin.create_broadcast("up/y").unwrap(); + let up_track = up.create_track("video", None).unwrap(); + up.announce(Default::default()).unwrap(); + + let received = produce_origin(3); + let mut pair = connect(Options { + client_publish: Some(client_origin.clone()), + client_subscribe: Some(received.clone()), + server_publish: Some(server_origin.clone()), + server_subscribe: Some(server_origin.clone()), + server_requests: true, + ..Default::default() + }) + .await; + let mut issued = serve(pair.requests.take().unwrap(), |token| { + token.is_empty().then(|| grant(&["up"], &["room"])) + }); + let (_, setup) = issued.recv().await.unwrap(); + + let mut group = down_track.append_group().unwrap(); + group.write_frame(ts(0), b"down".as_ref()).unwrap(); + let mut group = up_track.append_group().unwrap(); + group.write_frame(ts(0), b"up".as_ref()).unwrap(); + + let remote = received.consume().routed_broadcast("room/x").await.unwrap(); + let mut down_sub = remote.track("video").unwrap().subscribe(prefs()).await.unwrap(); + down_sub.recv_group().await.unwrap().unwrap(); + + let remote = server_origin.consume().routed_broadcast("up/y").await.unwrap(); + let mut up_sub = remote.track("video").unwrap().subscribe(prefs()).await.unwrap(); + up_sub.recv_group().await.unwrap().unwrap(); + + setup.revoke(SessionError::Unauthorized, "expired"); + assert!(down_sub.recv_group().await.is_err(), "subscription outlived its grant"); + // The served side ends too: the client stops serving what it may no longer publish. + loop { + match up_sub.recv_group().await { + Ok(Some(_)) => continue, + Ok(None) => panic!("served subscription finished instead of ending"), + Err(_) => break, + } + } + assert_eq!(pair.client_transport.close_reason(), None); + }) + .await + .expect("timed out"); +} diff --git a/rs/moq-net/tests/support/mock.rs b/rs/moq-net/tests/support/mock.rs index c30665b066..610f79b68b 100644 --- a/rs/moq-net/tests/support/mock.rs +++ b/rs/moq-net/tests/support/mock.rs @@ -25,6 +25,9 @@ use web_transport_trait::poll; pub struct MockError { code: Option, reason: String, + /// A stream reset rather than a session close: reported through the stream + /// registry alone, the way a real transport keeps the two apart. + stream: bool, } impl MockError { @@ -32,6 +35,7 @@ impl MockError { Self { code: Some(0), reason: "session closed".into(), + stream: false, } } @@ -39,6 +43,7 @@ impl MockError { Self { code: Some(code), reason: "stream reset".into(), + stream: true, } } } @@ -53,7 +58,10 @@ impl std::error::Error for MockError {} impl web_transport_trait::Error for MockError { fn session_error(&self) -> Option<(u32, String)> { - self.code.map(|c| (c, self.reason.clone())) + match self.stream { + true => None, + false => self.code.map(|c| (c, self.reason.clone())), + } } fn stream_error(&self) -> Option { @@ -430,6 +438,7 @@ impl poll::Session for MockSession { Some((code, reason)) => Poll::Ready(MockError { code: Some(code), reason, + stream: false, }), None => Poll::Pending, } @@ -441,6 +450,13 @@ impl poll::Session for MockSession { } impl MockSession { + /// The code and reason the connection was closed with, once either side closed it. + // Only some test binaries inspect the reason. + #[allow(dead_code)] + pub fn close_reason(&self) -> Option<(u32, String)> { + self.side.conn.close_state.lock().unwrap().clone() + } + fn close_error(&self) -> MockError { self.side .conn @@ -451,6 +467,7 @@ impl MockSession { .map(|(code, reason)| MockError { code: Some(*code), reason: reason.clone(), + stream: false, }) .unwrap_or_else(MockError::closed) } From 615f8436e6a028b2410c9c7912cc7facf266f43f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 06:46:04 -0700 Subject: [PATCH 03/53] feat(net): exchange grants over the moq-transport MoQ Auth extension (#4124) Co-authored-by: Claude Opus 5.5 Co-authored-by: Grok 4.7 Co-authored-by: bgreenway Co-authored-by: Brad Greenway --- Cargo.lock | 1 + bun.lock | 9 +- dart/moq_ffi/lib/src/moq.dart | 44 +- doc/concept/hang.md | 3 +- doc/concept/moq-lite.md | 4 +- doc/concept/standard.md | 5 +- doc/lib/c/index.md | 2 +- doc/lib/js/net.md | 2 +- doc/lib/js/publish.md | 10 + doc/lib/js/signals.md | 3 +- doc/lib/js/watch.md | 2 +- doc/lib/rs/moq-net.md | 14 +- drafts/draft-lcurley-moq-auth.md | 228 ++++++ go/wrapper/README.md | 4 + go/wrapper/publish.go | 16 + js/CLAUDE.md | 1 + js/binary/package.json | 3 +- js/binary/src/stream/consumer.ts | 3 +- js/binary/src/stream/stream.test.ts | 30 + js/json/src/stream/consumer.ts | 3 +- js/json/src/stream/stream.test.ts | 30 + js/moq-boy/src/element.tsx | 2 +- js/moq-boy/src/game.ts | 2 +- js/net/examples/wait.ts | 2 +- js/net/src/auth.test.ts | 233 +++++++ js/net/src/auth.ts | 11 +- js/net/src/auth_session.ts | 481 +++++++++++++ js/net/src/connection/accept.ts | 3 +- js/net/src/connection/connect.ts | 3 +- js/net/src/connection/established.ts | 5 +- js/net/src/connection/forward.ts | 4 +- js/net/src/connection/handshake.ts | 16 +- js/net/src/connection/pool.ts | 2 +- js/net/src/connection/reload.ts | 6 +- js/net/src/ietf/auth.test.ts | 135 ++++ js/net/src/ietf/auth.ts | 274 ++++++++ js/net/src/ietf/connection.ts | 58 +- js/net/src/ietf/index.ts | 1 + js/net/src/ietf/message.ts | 7 +- js/net/src/ietf/parameters.ts | 2 + js/net/src/ietf/publisher.ts | 133 +++- js/net/src/ietf/subscriber.ts | 60 +- js/net/src/lite/auth.test.ts | 174 +---- js/net/src/lite/auth.ts | 380 +--------- js/net/src/lite/connection.ts | 6 +- js/net/src/lite/publisher.ts | 94 +-- js/net/src/lite/subscriber.ts | 27 +- js/net/src/retention.test.ts | 157 +++++ js/net/src/stream.test.ts | 33 +- js/net/src/stream.ts | 33 +- js/publish/src/audio/capture.test.ts | 2 +- js/publish/src/audio/capture.ts | 5 +- js/publish/src/audio/encoder.ts | 2 +- js/publish/src/broadcast.ts | 3 +- js/publish/src/catalog.test.ts | 41 +- js/publish/src/catalog.ts | 14 +- js/publish/src/preview.ts | 4 +- js/publish/src/source/camera.ts | 2 +- js/publish/src/source/device.ts | 5 +- js/publish/src/source/microphone.ts | 2 +- js/publish/src/source/screen.ts | 5 +- js/publish/src/ui/components/stats-tab.ts | 2 +- js/publish/src/video/encoder.ts | 2 +- js/room/src/metadata.ts | 2 +- js/room/src/room.ts | 2 +- js/signals/src/index.test.ts | 202 +++++- js/signals/src/index.ts | 143 +++- js/watch/src/audio/decoder.ts | 5 +- js/watch/src/audio/source.ts | 9 +- js/watch/src/broadcast.ts | 4 +- js/watch/src/retention.test.ts | 64 ++ js/watch/src/sync.test.ts | 57 +- js/watch/src/sync.ts | 47 +- js/watch/src/video/decoder.ts | 3 +- js/watch/src/video/source.ts | 9 +- py/moq-rs/README.md | 4 +- py/moq-rs/moq/publish.py | 27 +- py/moq-rs/tests/test_local.py | 15 + quest/m0/README.md | 1 - quest/m0/js-retention.md | 70 -- quest/m0/release.md | 1 - ...l-clock-latency-target-for-synchronized.md | 2 +- quest/m1/README.md | 8 +- quest/m1/auth/README.md | 4 +- quest/m1/auth/interop.md | 5 +- quest/m1/auth/moq-transport.md | 101 --- quest/m1/auth/token-in-band.md | 2 - quest/m1/cli-import-clock.md | 34 + quest/m1/effect-cancel.md | 9 +- quest/m1/ietf-publish-done.md | 21 - quest/m1/lite-stream-count.md | 43 ++ quest/m1/native-clock-fixtures.md | 19 + quest/m1/nvenc-teardown.md | 26 + quest/m1/publisher-clock.md | 33 - quest/m1/rust-track-tail.md | 4 + quest/m1/signal-race.md | 30 + quest/m1/tokio-test-ports.md | 17 - ...ipewire-dma-bufs-safely-into-the-vulkan.md | 3 +- quest/m2/README.md | 5 + quest/m2/aac-encode-refusal.md | 22 + quest/m2/noq-socket-close.md | 21 + quest/m2/nvenc-pool.md | 18 + quest/m2/obs-linux-gpu.md | 5 +- quest/m2/pipewire-camera-planes.md | 16 + quest/m2/stats-delta.md | 115 +++ quest/m2/teleop/correlation.md | 2 +- quest/m3/README.md | 1 + quest/m3/pipewire-camera-hardware.md | 23 + quest/m3/video-hardware.md | 19 +- quest/m4/README.md | 3 +- quest/m4/vaapi-resize-pool.md | 19 + quest/m4/video-vaapi.md | 4 + rs/libmoq/src/api.rs | 166 ++++- rs/libmoq/src/publish.rs | 108 ++- rs/libmoq/src/test.rs | 257 +++++++ rs/moq-ffi/src/media.rs | 8 + rs/moq-ffi/src/producer.rs | 50 +- rs/moq-ffi/src/test.rs | 61 ++ rs/moq-gst/Cargo.toml | 1 + rs/moq-gst/src/sink/session.rs | 97 ++- rs/moq-mux/src/json.rs | 16 + rs/moq-net/src/auth.rs | 90 ++- rs/moq-net/src/client.rs | 6 +- rs/moq-net/src/ietf/auth.rs | 656 ++++++++++++++++++ rs/moq-net/src/ietf/mod.rs | 1 + rs/moq-net/src/ietf/parameters.rs | 2 + rs/moq-net/src/ietf/peer.rs | 5 + rs/moq-net/src/ietf/publish.rs | 54 ++ rs/moq-net/src/ietf/publisher.rs | 74 +- rs/moq-net/src/ietf/session.rs | 241 ++++++- rs/moq-net/src/ietf/subscriber.rs | 118 +++- rs/moq-net/src/lite/publisher.rs | 83 +-- rs/moq-net/src/server.rs | 6 +- rs/moq-net/src/session.rs | 5 +- rs/moq-net/tests/announce_to_serve.rs | 23 +- rs/moq-net/tests/auth.rs | 246 ++++++- rs/moq-tokio/src/client.rs | 73 ++ rs/moq-tokio/src/server.rs | 15 +- rs/moq-tokio/tests/broadcast.rs | 63 -- rs/moq-tokio/tests/reconnect.rs | 49 +- rs/moq-tokio/tests/worker.rs | 55 +- swift/Sources/Moq/Broadcast.swift | 27 +- 142 files changed, 5366 insertions(+), 1439 deletions(-) create mode 100644 drafts/draft-lcurley-moq-auth.md create mode 100644 js/net/src/auth.test.ts create mode 100644 js/net/src/auth_session.ts create mode 100644 js/net/src/ietf/auth.test.ts create mode 100644 js/net/src/ietf/auth.ts create mode 100644 js/net/src/retention.test.ts create mode 100644 js/watch/src/retention.test.ts delete mode 100644 quest/m0/js-retention.md delete mode 100644 quest/m1/auth/moq-transport.md create mode 100644 quest/m1/cli-import-clock.md delete mode 100644 quest/m1/ietf-publish-done.md create mode 100644 quest/m1/lite-stream-count.md create mode 100644 quest/m1/native-clock-fixtures.md create mode 100644 quest/m1/nvenc-teardown.md delete mode 100644 quest/m1/publisher-clock.md create mode 100644 quest/m1/signal-race.md delete mode 100644 quest/m1/tokio-test-ports.md create mode 100644 quest/m2/aac-encode-refusal.md create mode 100644 quest/m2/noq-socket-close.md create mode 100644 quest/m2/nvenc-pool.md create mode 100644 quest/m2/pipewire-camera-planes.md create mode 100644 quest/m2/stats-delta.md create mode 100644 quest/m3/pipewire-camera-hardware.md create mode 100644 quest/m4/vaapi-resize-pool.md create mode 100644 rs/moq-net/src/ietf/auth.rs diff --git a/Cargo.lock b/Cargo.lock index 29a0bdce73..4d2368190d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4378,6 +4378,7 @@ version = "0.4.3" dependencies = [ "anyhow", "bytes", + "futures", "gst-plugin-version-helper", "gstreamer", "hang", diff --git a/bun.lock b/bun.lock index b53786bfe9..49a1ec1627 100644 --- a/bun.lock +++ b/bun.lock @@ -99,6 +99,7 @@ "dependencies": { "@moq/flate": "workspace:^", "@moq/net": "workspace:^", + "@moq/signals": "workspace:^", }, "devDependencies": { "@types/bun": "^1.4.2", @@ -655,6 +656,10 @@ "@moq/hang": ["@moq/hang@workspace:js/hang"], + "@moq/interop-browser": ["@moq/interop-browser@workspace:test/interop/clients/js"], + + "@moq/interop-native": ["@moq/interop-native@workspace:test/interop/clients/js-native"], + "@moq/json": ["@moq/json@workspace:js/json"], "@moq/loc": ["@moq/loc@workspace:js/loc"], @@ -673,10 +678,6 @@ "@moq/signals": ["@moq/signals@workspace:js/signals"], - "@moq/interop-browser": ["@moq/interop-browser@workspace:test/interop/clients/js"], - - "@moq/interop-native": ["@moq/interop-native@workspace:test/interop/clients/js-native"], - "@moq/wasm": ["@moq/wasm@workspace:js/wasm"], "@moq/wasm-test": ["@moq/wasm-test@workspace:test/wasm"], diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 57a2ac3606..d4635f31c0 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -500,7 +500,13 @@ class MoqAudioInit { final MoqAudioFormat format; final Uint8List data; final String? label; - MoqAudioInit({required this.format, required this.data, this.label = null}); + final String? track; + MoqAudioInit({ + required this.format, + required this.data, + this.label = null, + this.track = null, + }); } class FfiConverterMoqAudioInit { @@ -525,8 +531,13 @@ class FfiConverterMoqAudioInit { ); final label = label_lifted.value; new_offset += label_lifted.bytesRead; + final track_lifted = FfiConverterOptionalString.read( + Uint8List.view(buf.buffer, new_offset), + ); + final track = track_lifted.value; + new_offset += track_lifted.bytesRead; return LiftRetVal( - MoqAudioInit(format: format, data: data, label: label), + MoqAudioInit(format: format, data: data, label: label, track: track), new_offset - buf.offsetInBytes, ); } @@ -536,6 +547,7 @@ class FfiConverterMoqAudioInit { FfiConverterMoqAudioFormat.allocationSize(value.format) + FfiConverterUint8List.allocationSize(value.data) + FfiConverterOptionalString.allocationSize(value.label) + + FfiConverterOptionalString.allocationSize(value.track) + 0; final buf = Uint8List(total_length); write(value, buf); @@ -556,6 +568,10 @@ class FfiConverterMoqAudioInit { value.label, Uint8List.view(buf.buffer, new_offset), ); + new_offset += FfiConverterOptionalString.write( + value.track, + Uint8List.view(buf.buffer, new_offset), + ); return new_offset - buf.offsetInBytes; } @@ -563,6 +579,7 @@ class FfiConverterMoqAudioInit { return FfiConverterMoqAudioFormat.allocationSize(value.format) + FfiConverterUint8List.allocationSize(value.data) + FfiConverterOptionalString.allocationSize(value.label) + + FfiConverterOptionalString.allocationSize(value.track) + 0; } } @@ -1308,11 +1325,13 @@ class MoqVideoInit { final Uint8List data; final String? label; final MoqVideoHint? hint; + final String? track; MoqVideoInit({ required this.format, required this.data, this.label = null, this.hint = null, + this.track = null, }); } @@ -1343,8 +1362,19 @@ class FfiConverterMoqVideoInit { ); final hint = hint_lifted.value; new_offset += hint_lifted.bytesRead; + final track_lifted = FfiConverterOptionalString.read( + Uint8List.view(buf.buffer, new_offset), + ); + final track = track_lifted.value; + new_offset += track_lifted.bytesRead; return LiftRetVal( - MoqVideoInit(format: format, data: data, label: label, hint: hint), + MoqVideoInit( + format: format, + data: data, + label: label, + hint: hint, + track: track, + ), new_offset - buf.offsetInBytes, ); } @@ -1355,6 +1385,7 @@ class FfiConverterMoqVideoInit { FfiConverterUint8List.allocationSize(value.data) + FfiConverterOptionalString.allocationSize(value.label) + FfiConverterOptionalMoqVideoHint.allocationSize(value.hint) + + FfiConverterOptionalString.allocationSize(value.track) + 0; final buf = Uint8List(total_length); write(value, buf); @@ -1379,6 +1410,10 @@ class FfiConverterMoqVideoInit { value.hint, Uint8List.view(buf.buffer, new_offset), ); + new_offset += FfiConverterOptionalString.write( + value.track, + Uint8List.view(buf.buffer, new_offset), + ); return new_offset - buf.offsetInBytes; } @@ -1387,6 +1422,7 @@ class FfiConverterMoqVideoInit { FfiConverterUint8List.allocationSize(value.data) + FfiConverterOptionalString.allocationSize(value.label) + FfiConverterOptionalMoqVideoHint.allocationSize(value.hint) + + FfiConverterOptionalString.allocationSize(value.track) + 0; } } @@ -12373,7 +12409,7 @@ void _checkApiChecksums() { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_audio() != - 47444) { + 31691) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_audio_on_track() != diff --git a/doc/concept/hang.md b/doc/concept/hang.md index a2907e3e0a..22699656f8 100644 --- a/doc/concept/hang.md +++ b/doc/concept/hang.md @@ -112,7 +112,8 @@ In Rust the catalog owns the lifetime: `catalog.json_stream(track, config)` (or `json_snapshot` / `binary_snapshot` / `binary_stream`) writes the entry and retracts it when the producer drops. Read the config from `catalog.json.tracks` or `catalog.binary.tracks`, then pair its name and config with -`moq_mux::catalog::Entry::new` to subscribe. In the browser, read the same map, +`moq_mux::catalog::Entry::new` to subscribe. In C, `moq_publish_json_*` and +`moq_publish_binary_*` do the same, retracting on `_finish`. In the browser, read the same map, subscribe by name, and hand the track to `@moq/json` or `@moq/binary`. ## Container diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 31a50e2992..2d32c0a0ce 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -47,8 +47,8 @@ A client that publishes a broadcast outside its grant closes the session with `UNAUTHORIZED` and names the path in the close reason, rather than waiting forever for a subscriber the relay will never let through. A grant is advice for the side that holds it; the side that issued it still enforces its own -scope. Older versions and moq-transport have no grant; the token in the URL -keeps working everywhere. +scope. Older versions, and moq-transport peers that do not negotiate the MoQ Auth +extension, have no grant; the token in the URL keeps working everywhere. ## Discovery diff --git a/doc/concept/standard.md b/doc/concept/standard.md index f2c7e9929e..36a91d3be0 100644 --- a/doc/concept/standard.md +++ b/doc/concept/standard.md @@ -44,8 +44,9 @@ tracks waiting for a group that never arrives. Several project drafts extend the IETF wire without breaking it, since `SETUP` ignores unknown parameters: [cluster](/draft/moq-cluster) routing hop lists, [solicit](/draft/moq-solicit) to make announcements opt-in, -[hidden](/draft/moq-hidden) to keep `.`-named namespaces out of discovery, and -[probe](/draft/moq-probe) for bandwidth estimation. +[hidden](/draft/moq-hidden) to keep `.`-named namespaces out of discovery, +[auth](/draft/moq-auth) to tell each peer what it may publish and subscribe to, +and [probe](/draft/moq-probe) for bandwidth estimation. [moq-e2ee](/draft/moq-e2ee) is not a transport extension: it encrypts application payloads so relays still forward named tracks they cannot read. diff --git a/doc/lib/c/index.md b/doc/lib/c/index.md index 15de234894..b77ae8a4bd 100644 --- a/doc/lib/c/index.md +++ b/doc/lib/c/index.md @@ -42,7 +42,7 @@ and `target/include/moq.h`. - **Server.** `moq_server_listen` binds before it returns (a bad address or certificate fails there) and hands each incoming session to `on_request` as a request handle. Read `moq_session_request_path` and `_query` to route and authenticate, then `moq_session_request_accept` (a session handle, with origins like `moq_session_connect`) or `moq_session_request_reject` with an HTTP-style code (401 and 403 become the protocol's unauthorized close). An accepted session reports `1` once SETUP completes and never reconnects. `moq_server_addr` reports an ephemeral port and `moq_server_fingerprints` the hashes a client pins for a `tls_generate` certificate. `moq_server_close` stops listening; its terminal callback fires once the sockets are released. - **Demand.** A watcher on a published track (`moq_publish_track_demand`, `moq_publish_media_demand`, `moq_encode_video_demand`, `moq_encode_audio_demand`) calls `on_demand` with `MOQ_DEMAND_USED` or `MOQ_DEMAND_UNUSED` right away and again on every change, so an encoder on a battery-powered device runs only while someone is watching. The first call is the current state, so a track that went unused before the watcher existed still reports it. `moq_publish_demand_cancel` stops it; the terminal callback still fires. A container has no single demand and is refused. Demand follows the last real subscriber: an origin that served the track drops its source copy on the unused edge and keeps only the finished groups it already cached warm for 30 seconds, so the cache linger does not delay the unused edge. - **Requests.** `moq_publish_dynamic` serves subscriptions to tracks the broadcast never declared: each arrives as a request handle, read its name with `moq_track_request_name`, then `moq_track_request_accept` (a raw track handle), `moq_track_request_video` / `_audio` (the media handle `moq_publish_video` / `_audio` return), or `moq_track_request_abort` with an application code the subscriber sees. Without a live handler an unknown name is refused. `moq_publish_track_dynamic` does the same for fetches of groups a track no longer has cached, delivered as `moq_group_request_*` (`sequence`, `priority`, `frame_start`); `moq_group_request_accept` starts the producer at `frame_start` so written frames keep their group indices. Register it with `moq_track_request_dynamic` before accepting a track that was itself requested by a fetch, so that pending group survives the transition. Both handlers stop with `moq_publish_dynamic_cancel`. -- **Everything the bindings can do** ([list](/lib/#what-every-binding-can-do)): media publish and consume with the catalog managed for you, raw pixels and PCM with the codec inside (`moq_encode_video`, `moq_encode_audio`, and the `moq_decode_*` mirrors), raw tracks with timestamps and datagrams, JSON snapshot and stream tracks, group fetch, catalog sections, shared video properties, and stalled hints. The three advertising operations are `moq_origin_create_broadcast` (unannounced producer, invisible to everyone), `moq_publish_announce` / `moq_publish_unannounce` (exact-path advertisement), and `moq_origin_dynamic` (a claim over a path prefix and everything beneath it; `""` for everything). A route is a capability, not an inventory. `moq_origin_announced` takes a literal prefix and an optional relative pattern filter; `moq_announce_update.prefix` stays relative to the origin, while `captures` reports what each wildcard matched when `has_captures` is true. Paths with a `.`-prefixed segment below the prefix are [hidden](/concept/moq-lite#hidden-broadcasts); name the dot segment in `prefix` to list them. +- **Everything the bindings can do** ([list](/lib/#what-every-binding-can-do)): media publish and consume with the catalog managed for you, raw pixels and PCM with the codec inside (`moq_encode_video`, `moq_encode_audio`, and the `moq_decode_*` mirrors), raw tracks with timestamps and datagrams, JSON and binary data tracks (snapshot or stream, each advertised in the catalog for as long as it lives), group fetch, catalog sections, shared video properties, and stalled hints. The three advertising operations are `moq_origin_create_broadcast` (unannounced producer, invisible to everyone), `moq_publish_announce` / `moq_publish_unannounce` (exact-path advertisement), and `moq_origin_dynamic` (a claim over a path prefix and everything beneath it; `""` for everything). A route is a capability, not an inventory. `moq_origin_announced` takes a literal prefix and an optional relative pattern filter; `moq_announce_update.prefix` stays relative to the origin, while `captures` reports what each wildcard matched when `has_captures` is true. Paths with a `.`-prefixed segment below the prefix are [hidden](/concept/moq-lite#hidden-broadcasts); name the dot segment in `prefix` to list them. ```c moq_client_config config; diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index 7b4a381565..9275a33810 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -53,7 +53,7 @@ for (;;) { - **Discovery** by any pattern scope (`origin.announced(scope)`, such as `room/*/chat`; default everything). Each event's `prefix` is the covered prefix relative to the origin, `captures` reports what the scope's wildcards matched when the prefix pins them, and `kind` says whether it was announced, updated, or retracted. The consumer is an async iterable. `origin.broadcasts(scope)` is a live `Getter>` of the same covered prefixes for UIs that need the current set. A borrowed `Connection.origin` also exposes `dynamic(prefix, route)` for serving paths on demand. - **Subscriptions** carry a priority, a `Time.Milli` max age, and optional `groups` bounds. Groups arrive out of order and are read frame by frame, with `Error.TooFarBehind` when a reader asks for a frame the group never held and `Error.GroupTooLarge` when a write exceeds the cache budget and aborts the group. - **Datagrams** on moq-lite 05+ and fetch-by-sequence for history. -- **Authorization** on moq-lite 06: an established session's `auth.grant` is a `Getter` with what the relay lets this side publish and subscribe to, learned right after setup. `auth.add(token)` presents another token without reconnecting and resolves with an `Auth.Token` to `close()` later; it rejects with `Auth.Unsupported` when the peer takes no tokens in band. A connection that publishes a broadcast outside its grant closes with `SessionCode.Unauthorized`, naming the path in the reason. +- **Authorization** on moq-lite 06, and on moq-transport draft-17+ when both sides negotiate the [MoQ Auth extension](/draft/moq-auth): an established session's `auth.grant` is a `Getter` with what the relay lets this side publish and subscribe to, learned right after setup. `auth.add(token)` presents another token without reconnecting and resolves with an `Auth.Token` to `close()` later; it rejects with `Auth.Unsupported` when the peer takes no tokens in band. A connection that publishes a broadcast outside its grant closes with `SessionCode.Unauthorized`, naming the path in the reason. - **Errors** live under one namespace: a stream reset throws `Error.Stream` with a `StreamCode`, while a session close gives `Error.Session` with a `SessionCode`. The registries are disjoint, so the same number means different things in each, and 64+ is yours. Named conditions such as `Error.TooFarBehind`, `Error.FrameTooLarge`, and `Error.GroupTooLarge` subclass `Error.Stream`, so one `code` check handles a condition raised here or reported by the peer. IETF streams use their own mapping: cancellation sends CANCELLED, other local failures send INTERNAL\_ERROR, and received codes remain opaque. - **Paths** with `Path.relative` for the cross-broadcast catalog references hang uses. Path patterns (`Path.Pattern`, `Path.Patterns`) are re-exported from [`@moq/pattern`](https://www.npmjs.com/package/@moq/pattern). Literal `Path` stays a coordinate. diff --git a/doc/lib/js/publish.md b/doc/lib/js/publish.md index 4da8ae931a..6c9c1dd0c5 100644 --- a/doc/lib/js/publish.md +++ b/doc/lib/js/publish.md @@ -59,6 +59,16 @@ clock when they flush frames. Catalog jitter is the spread above each rendition's own recent minimum lateness, so a constant encoder delay is not jitter. The advertised value only rises; frame duration alone does not set it. +## Clock + +Every timestamp the publisher writes is `performance.now()` in microseconds, +so camera, microphone, screen, and file sources share one timeline. The catalog +advertises that mapping as its root `clock` from the first snapshot, with PTS +zero at `performance.timeOrigin`, so a viewer or an HLS export can name any +frame's wall time. The mapping is fixed for the page: a system-clock +adjustment never retimes the broadcast. Stamp your own tracks (e.g. text cues) +on the same timeline to stay in sync. + ## Custom tracks `broadcast.net` is the underlying `Moq.Broadcast.Producer`, so an application diff --git a/doc/lib/js/signals.md b/doc/lib/js/signals.md index 515db35a9a..54b59838f6 100644 --- a/doc/lib/js/signals.md +++ b/doc/lib/js/signals.md @@ -38,7 +38,8 @@ The rules that differ from other signal libraries: - **Nothing is tracked implicitly.** `effect.get(signal)` subscribes; `signal.peek()` doesn't. - **Writes coalesce per microtask** and only notify on a real change (deep for plain objects, identity for class instances). -- **Effects own their resources.** `effect.timer`, `interval`, `animate`, `event`, `spawn`, and `run` (a nested effect) all clean up on rerun or close, so never call `setTimeout` or `addEventListener` inside one directly. A rerun waits for the previous run's `spawn` tasks to settle, and `effect.abort`/`effect.cancel` tell them to stop. +- **Effects own their resources.** `effect.timer`, `interval`, `animate`, `event`, `spawn`, and `run` (a nested effect) all clean up on rerun or close, so never call `setTimeout` or `addEventListener` inside one directly. A rerun waits for the previous run's `spawn` tasks to settle, and `effect.abort`/`effect.race` tell them to stop. +- **Race with `race`, not `Promise.race`.** `Promise.race` leaves a listener on every value that loses, so racing a long-lived one (a `closed`, a run's teardown) once per frame grows the heap. `race([...])` accepts promises and `Once` values and drops its listeners when it settles; `effect.race(promise)` also resolves `undefined` once the run is torn down. - **Dev builds warn** about effects that tracked nothing, effects garbage-collected without `close()`, and signals leaking subscribers. Components follow one shape: `in` (wired inputs), `out` (read-only derived diff --git a/doc/lib/js/watch.md b/doc/lib/js/watch.md index 6a5a79fee3..5ee0252dfa 100644 --- a/doc/lib/js/watch.md +++ b/doc/lib/js/watch.md @@ -106,7 +106,7 @@ const dispose = el.signals.run((effect) => { const consumer = new Json.Snapshot.Consumer({ track }); effect.spawn(async () => { for (;;) { - const value = await Promise.race([effect.cancel, consumer.next()]); + const value = await effect.race(consumer.next()); if (value === undefined) break; console.log("metadata", value); } diff --git a/doc/lib/rs/moq-net.md b/doc/lib/rs/moq-net.md index 1185562d59..36bd679526 100644 --- a/doc/lib/rs/moq-net.md +++ b/doc/lib/rs/moq-net.md @@ -72,10 +72,11 @@ Expiry is therefore approximate; a late `gc` extends retention. ## Authorization -`session.auth()` is the session's `auth::Handle`. On moq-lite 06 each side -presents its connection's credential right after setup, and `grant()` watches -the union of every grant this side holds: `None` until the peer answers, -forever on other versions. `add(token)` presents another token and resolves +`session.auth()` is the session's `auth::Handle`. On moq-lite 06, and on +moq-transport draft-17+ when both sides negotiate the +[MoQ Auth extension](/draft/moq-auth), each side presents its connection's +credential right after setup, and `grant()` watches the union of every grant +this side holds: `None` until the peer answers, forever otherwise. `add(token)` presents another token and resolves once the peer answers; drop the returned `auth::Token` to withdraw it. ```rust @@ -91,8 +92,9 @@ origin handles allow and refuses any other token as unsupported. To verify tokens yourself, take `handshake.auth().requests()` on the `server::Handshake` before `ok()` (or `session.auth().requests()` before first polling the driver) and answer every `auth::Request` with `accept(grant)`, which returns an -`auth::Issued` you can `update` or `revoke`, or `reject`. The wire carries -prefix grants for now, so a grant that is not a union of subtrees is refused. +`auth::Issued` you can `update` or `revoke`, or `reject`. Both wires carry +prefix grants for now, so a grant that is not a union of subtrees is refused +and the presenter sees `Unsupported`; after a grant, such an update revokes it. A client whose origin publishes a broadcast outside its grant closes the session with `Unauthorized`, naming the path in the close reason. A grant that diff --git a/drafts/draft-lcurley-moq-auth.md b/drafts/draft-lcurley-moq-auth.md new file mode 100644 index 0000000000..1e285eddd8 --- /dev/null +++ b/drafts/draft-lcurley-moq-auth.md @@ -0,0 +1,228 @@ +--- +title: "MoQ Auth Extension" +abbrev: "moq-auth" +category: info + +docname: draft-lcurley-moq-auth-latest +submissiontype: IETF # also: "independent", "editorial", "IAB", or "IRTF" +number: +date: +v: 3 +area: wit +workgroup: moq + +author: + - + fullname: Luke Curley + email: kixelated@gmail.com + +normative: + moqt: I-D.ietf-moq-transport + +informative: + moq-lite: I-D.lcurley-moq-lite + +--- abstract + +This document defines an extension for MoQ Transport {{moqt}} that tells an endpoint what its peer will let it publish and subscribe to. +Either endpoint presents a token on a request stream of its own, and the peer answers with the namespace prefixes the token grants and when that grant lapses. +Tokens can be added and withdrawn for the life of the session, so a session can outlive the credential it started with. +An endpoint that knows its grant can fail loudly on a publication that will never be accepted, instead of waiting for a subscription that never comes. + +--- note_Note_to_Readers + +This document was generated by an AI model from the implementation at [github.com/moq-dev/moq](https://github.com/moq-dev/moq) and is maintained alongside it. +Submit an [issue](https://github.com/moq-dev/moq/issues) or [PR](https://github.com/moq-dev/moq/pulls) if this spec sucks and you want to fix anything. + +--- middle + +# Conventions and Definitions +{::boilerplate bcp14-tagged} + +The **presenter** of a token is the endpoint that sends it in AUTH, and the **acceptor** is the endpoint that answers. +A **grant** is what the acceptor allows for one token: the namespaces the presenter may publish to it, the namespaces the presenter may subscribe to from it, and an optional expiry. + + +# Introduction +{{moqt}} authorizes each request on its own. +A publisher learns that a namespace is refused only by sending PUBLISH_NAMESPACE and reading the REQUEST_ERROR, and learns nothing at all from a relay that forwards only what it is asked for. +Nothing on the wire says which role an endpoint will ever be allowed to play ([moq-transport issue 1854](https://github.com/moq-wg/moq-transport/issues/1854)), so a client authorized to publish only `alice` that publishes `bob` waits forever, and neither side logs anything. + +This extension answers that question once per credential. +Right after setup each endpoint presents the credential its connection already carried, and learns the grant it earned. +Further tokens are presented on their own request streams, and the endpoint's scope is the union of every grant it holds. + +The exchange is the Auth Stream of {{moq-lite}}, carried as {{moqt}} request streams and negotiated with a Setup Option. + + +# Setup Negotiation + +An endpoint that implements this extension sends the following Setup Option ({{moqt}} Section 9.4.1): + +~~~ +AUTH Setup Option { + Option Key (vi64) = 0x40B60 + Option Value (vi64) = 1 +} +~~~ + +The extension is negotiated when both endpoints sent the option with a value of 1. +An endpoint MUST NOT send AUTH on a session that did not negotiate it, and an endpoint that receives AUTH anyway MUST close the session with PROTOCOL_VIOLATION, as it would for any unknown message type. + +This extension is defined for the versions of {{moqt}} with the unified SETUP message, draft-17 and later. + + +# Auth Requests {#requests} + +A presenter opens a bidirectional request stream for each token and sends a single AUTH message on it. +The stream lives as long as the token, the way a subscription's request stream outlives its SUBSCRIBE_OK. + +Each endpoint SHOULD open one Auth request with an empty token as soon as the extension is negotiated. +An empty token presents the credential the connection already carried: the request URI, a client certificate, an AUTHORIZATION TOKEN Setup Option ({{moqt}} Section 9.4.1), or nothing. +Its grant tells the presenter which role the acceptor will let it play. + +The acceptor answers with AUTH_OK carrying the grant, or AUTH_ERROR refusing the token. +It MAY send further AUTH_OK messages to replace the grant, such as with a lowered expiry, and the latest AUTH_OK is the token's grant. +It MAY send AUTH_ERROR after an AUTH_OK to revoke the grant. +After an AUTH_ERROR the acceptor closes its side of the stream. + +The presenter withdraws a token by closing or resetting its side of the stream, and the acceptor then closes its own. +An acceptor that closes its side without an AUTH_ERROR ends the grant without a reason. + +## Scope {#scope} + +An endpoint's scope is the union of the grants of its open Auth requests. +A request that ends, by withdrawal, revocation, or a stream error, removes its grant from the union. +An empty union grants nothing, and the session stays open for another token. + +A grant tells the presenter what the acceptor will allow; it does not replace the acceptor's own enforcement, which MUST still refuse what the grant does not cover. + +When the union shrinks, the presenter SHOULD withdraw its advertisements and cancel its subscriptions that the union no longer covers, keeping the session and everything still covered. + +A presenter that would advertise a namespace outside the union, once the tokens it presented at setup are answered, SHOULD close the session with UNAUTHORIZED instead of sending PUBLISH_NAMESPACE, since a subscription for it will never come. +A publication that loses coverage because the union shrank is withdrawn, which is no reason to close the session. + +## Prefixes {#prefixes} + +A grant names Track Namespace prefixes, encoded as the Track Namespace Prefix of SUBSCRIBE_NAMESPACE ({{moqt}}) and matched the same way: a prefix covers every namespace whose leading fields equal it. +A prefix with no fields covers every namespace, and a count of zero grants none. + +An acceptor whose grant is not a union of prefixes, such as one exact namespace without its descendants, MUST NOT widen it to a prefix. +It sends AUTH_ERROR with NOT_SUPPORTED instead: as the first reply this refuses the token, and after an AUTH_OK it revokes the earlier grant. +Other tokens on the session are unaffected. + + +# Messages + +Each message is a {{moqt}} Control Message, framed by its Message Type and a 16-bit Message Length. + +## AUTH {#auth} + +AUTH is the first message on an Auth request stream, sent by the presenter. + +~~~ +AUTH Message { + Type (vi64) = 0x40B61, + Length (16), + Request ID (vi64), + Token Length (vi64), + Token (..), +} +~~~ + +**Request ID**: +The request's identifier, allocated like that of any other request ({{moqt}}). + +**Token**: +The credential to verify, opaque to this extension. +An empty token presents the credential the connection already carried ({{requests}}). + +## AUTH_OK {#auth-ok} + +AUTH_OK grants the token, replacing any earlier grant on the same stream. + +~~~ +AUTH_OK Message { + Type (vi64) = 0x40B62, + Length (16), + Publish Count (vi64), + Publish Prefix (Track Namespace) ..., + Subscribe Count (vi64), + Subscribe Prefix (Track Namespace) ..., + Expires (vi64), +} +~~~ + +**Publish Prefix**: +A namespace prefix the presenter may advertise and serve ({{prefixes}}). + +**Subscribe Prefix**: +A namespace prefix the presenter may subscribe to and discover. + +**Expires**: +The number of milliseconds until the grant lapses, or 0 for never. +The acceptor revokes a lapsed grant with AUTH_ERROR, and the presenter uses Expires to present a replacement token in time. + +## AUTH_ERROR {#auth-error} + +AUTH_ERROR refuses the token, or revokes it after an AUTH_OK. + +~~~ +AUTH_ERROR Message { + Type (vi64) = 0x40B63, + Length (16), + Error Code (vi64), + Reason Phrase Length (vi64), + Reason Phrase (..), +} +~~~ + +**Error Code**: +A code from the REQUEST_ERROR registry ({{moqt}}): UNAUTHORIZED, MALFORMED_AUTH_TOKEN, EXPIRED_AUTH_TOKEN, or NOT_SUPPORTED ({{prefixes}}). + +**Reason Phrase**: +A human-readable reason, at most 8,192 bytes. + + +# Security Considerations + +A token presented in AUTH is as sensitive as one in the request URI or an AUTHORIZATION TOKEN, and relies on the same transport confidentiality. + +A grant is advice to the presenter, never authority for the acceptor: the acceptor MUST enforce its own authorization on every request whatever it granted. +A presenter that trusts a grant too far only fails on the acceptor's REQUEST_ERROR, as it would without this extension. + +Auth requests count against the peer's request and stream limits like any other request. + + +# IANA Considerations + +This document requests the following registrations. +High, distinctive values are requested to avoid the low ranges reserved by {{moqt}} and to minimize collisions with provisional registrations by other extensions. + +## MOQT Setup Options + +This document requests one registration in the "MOQT Setup Options" registry ({{moqt}}), whose policy is Specification Required. + +| Value | Name | Reference | +|:--------|:-----|:--------------| +| 0x40B60 | AUTH | This Document | + +AUTH is even, so its value is a bare varint. + +## MOQT Message Types + +This document requests three registrations in the "MOQT Message Types" registry ({{moqt}}). + +| Value | Name | Reference | +|:--------|:-----------|:--------------| +| 0x40B61 | AUTH | This Document | +| 0x40B62 | AUTH_OK | This Document | +| 0x40B63 | AUTH_ERROR | This Document | + + +--- back + +# Acknowledgments +{:numbered="false"} + +This document was drafted with the assistance of Claude, an AI assistant by Anthropic. diff --git a/go/wrapper/README.md b/go/wrapper/README.md index 52ed3ce69d..cf241671b5 100644 --- a/go/wrapper/README.md +++ b/go/wrapper/README.md @@ -77,6 +77,10 @@ for media tracks whose timescale should be selected by the importer. `WithVideoHint(moq.VideoHint{...})` for video catalog fields that are known before the stream reveals them. +`WithAudioTrack(name)` / `WithVideoTrack(name)` name the track instead of +deriving a unique name from the format. A duplicate name fails, and the +`OnTrack` variants refuse it because the request already names the track. + JSON tracks are available in two modes. `PublishJSONSnapshot` / `SubscribeJSONSnapshot` carry lossy latest state, while `PublishJSONStream` / `SubscribeJSONStream` carry every record in order. Producers accept any `encoding/json` value; consumers return diff --git a/go/wrapper/publish.go b/go/wrapper/publish.go index fc1e885372..6eccc6d8f6 100644 --- a/go/wrapper/publish.go +++ b/go/wrapper/publish.go @@ -30,6 +30,22 @@ func WithVideoLabel(label string) VideoOption { } } +// WithAudioTrack names the track instead of deriving a unique name from the +// format. A requested track already has a name, so the OnTrack variant refuses it. +func WithAudioTrack(track string) AudioOption { + return func(init *ffi.MoqAudioInit) { + init.Track = &track + } +} + +// WithVideoTrack names the track instead of deriving a unique name from the +// format. A requested track already has a name, so the OnTrack variant refuses it. +func WithVideoTrack(track string) VideoOption { + return func(init *ffi.MoqVideoInit) { + init.Track = &track + } +} + // WithVideoHint seeds catalog fields that a video stream cannot reveal itself. func WithVideoHint(hint VideoHint) VideoOption { return func(init *ffi.MoqVideoInit) { diff --git a/js/CLAUDE.md b/js/CLAUDE.md index c565e540eb..9e0bf43c2a 100644 --- a/js/CLAUDE.md +++ b/js/CLAUDE.md @@ -13,6 +13,7 @@ The spine of the JS code; read `signals/src/index.ts` before touching reactive c - `Signal` writes coalesce per microtask and notify only on change. Equality is deep for plain data but identity for class instances; `set(v, true)` forces a notify. `peek` reads without subscribing. - `Computed`: derived, `undefined` until first run and after `close()`. Standalone ones must be closed; `effect.computed()` closes with its parent. - `Effect`: reruns when a signal read via `effect.get(signal)` changes. Register teardown with `effect.cleanup(fn)`; it runs before the next run and on `close()`. A rerun waits for every `effect.spawn` task from the previous run to settle, so register teardown unconditionally. +- Never `Promise.race` a value that outlives the call, such as a `closed`; use `race` or `effect.race`, which release their listeners. - Use the scoped helpers (`effect.interval`, `timer`, `timeout`, `animate`, `event`, `subscribe`, `set`, `proxy`, `run`) instead of raw timers or listeners, so cleanup is automatic. Prefer nested `effect.run` over one giant effect. # Producer / consumer diff --git a/js/binary/package.json b/js/binary/package.json index 499de5a656..849e5f012a 100644 --- a/js/binary/package.json +++ b/js/binary/package.json @@ -21,7 +21,8 @@ }, "dependencies": { "@moq/flate": "workspace:^", - "@moq/net": "workspace:^" + "@moq/net": "workspace:^", + "@moq/signals": "workspace:^" }, "devDependencies": { "@types/bun": "^1.4.2", diff --git a/js/binary/src/stream/consumer.ts b/js/binary/src/stream/consumer.ts index 47d9ee635d..f4cc5cf441 100644 --- a/js/binary/src/stream/consumer.ts +++ b/js/binary/src/stream/consumer.ts @@ -1,5 +1,6 @@ import { Decoder as Flate } from "@moq/flate"; import type * as Moq from "@moq/net"; +import { race } from "@moq/signals"; import { isDeflate } from "../compression.ts"; import type { Config as CodecConfig } from "./producer.ts"; @@ -102,7 +103,7 @@ export class Consumer { if (buffered) return buffered; const frame = group.readFrame(); - const winner = await Promise.race([frame.then((frame) => ({ frame }) as const), this.#recvGroup()]); + const winner = await race([frame.then((frame) => ({ frame }) as const), this.#recvGroup()]); if ("frame" in winner) return winner.frame; if (winner.group) { diff --git a/js/binary/src/stream/stream.test.ts b/js/binary/src/stream/stream.test.ts index b1f547c3a3..b8745147fb 100644 --- a/js/binary/src/stream/stream.test.ts +++ b/js/binary/src/stream/stream.test.ts @@ -1,3 +1,4 @@ +import { heapStats } from "bun:jsc"; import { expect, test } from "bun:test"; import { DEFAULT_MAX_FRAME_SIZE } from "@moq/flate"; import { Time, Track } from "@moq/net"; @@ -146,3 +147,32 @@ test("an undecodable payload ends the log for a reader already inside the group" // Surfaces the terminal error rather than hanging on the still-open group. await expect(consumer.next()).rejects.toThrow("limit"); }); + +// A blocked read races the frame against the track's next group, which stays pending for the whole +// log. Racing it per payload must not leave a reaction behind on it each time. +test("blocked reads leave nothing behind on the pending group read", async () => { + const track = new Track.Producer("test"); + const producer = new Producer({ track }); + const subscriber = track.subscribe(); + const consumer = new Consumer({ track: subscriber }); + const promises = () => { + Bun.gc(true); + return heapStats().objectTypeCounts.Promise ?? 0; + }; + + const read = async (from: number, count: number) => { + for (let n = from; n < from + count; n++) { + const next = consumer.next(); + producer.append(new Uint8Array([n & 0xff])); + expect((await next)?.[0]).toBe(n & 0xff); + } + }; + + await read(0, 50); + const before = promises(); + await read(50, 1000); + expect(promises() - before).toBeLessThan(100); + + subscriber.close(); + producer.finish(); +}); diff --git a/js/json/src/stream/consumer.ts b/js/json/src/stream/consumer.ts index 0d93d8dd10..6ca14024be 100644 --- a/js/json/src/stream/consumer.ts +++ b/js/json/src/stream/consumer.ts @@ -1,4 +1,5 @@ import type * as Moq from "@moq/net"; +import { race } from "@moq/signals"; import { Decoder } from "./decoder.ts"; import type { Config as CodecConfig } from "./encoder.ts"; @@ -101,7 +102,7 @@ export class Consumer { if (buffered) return buffered; const frame = group.readFrame(); - const winner = await Promise.race([frame.then((frame) => ({ frame }) as const), this.#recvGroup()]); + const winner = await race([frame.then((frame) => ({ frame }) as const), this.#recvGroup()]); if ("frame" in winner) return winner.frame; if (winner.group) { diff --git a/js/json/src/stream/stream.test.ts b/js/json/src/stream/stream.test.ts index 5e93b54724..35512bac88 100644 --- a/js/json/src/stream/stream.test.ts +++ b/js/json/src/stream/stream.test.ts @@ -1,3 +1,4 @@ +import { heapStats } from "bun:jsc"; import { expect, test } from "bun:test"; import { Time, Track } from "@moq/net"; import { Consumer, Producer, Rolled } from "./index.ts"; @@ -110,3 +111,32 @@ test("a second concurrent read is refused rather than served the first one's gro expect(() => consumer.next()).toThrow("multiple calls to next not supported"); expect(await first).toEqual({ n: 0 }); }); + +// A blocked read races the frame against the track's next group, which stays pending for the whole +// log. Racing it per record must not leave a reaction behind on it each time. +test("blocked reads leave nothing behind on the pending group read", async () => { + const track = new Track.Producer("test"); + const producer = new Producer({ track }); + const subscriber = track.subscribe(); + const consumer = new Consumer({ track: subscriber }); + const promises = () => { + Bun.gc(true); + return heapStats().objectTypeCounts.Promise ?? 0; + }; + + const read = async (from: number, count: number) => { + for (let n = from; n < from + count; n++) { + const next = consumer.next(); + producer.append({ n }); + expect((await next)?.n).toBe(n); + } + }; + + await read(0, 50); + const before = promises(); + await read(50, 1000); + expect(promises() - before).toBeLessThan(100); + + subscriber.close(); + producer.finish(); +}); diff --git a/js/moq-boy/src/element.tsx b/js/moq-boy/src/element.tsx index b776fd8e52..48346b6de1 100644 --- a/js/moq-boy/src/element.tsx +++ b/js/moq-boy/src/element.tsx @@ -135,7 +135,7 @@ export default class MoqBoy extends HTMLElement { effect.spawn(async () => { for (;;) { - const entry = await Promise.race([effect.cancel, announced.next()]); + const entry = await effect.race(announced.next()); if (!entry) break; // A broad route that cannot pin the game id names nothing to open. diff --git a/js/moq-boy/src/game.ts b/js/moq-boy/src/game.ts index c9bec3f4d5..9d2a03d39d 100644 --- a/js/moq-boy/src/game.ts +++ b/js/moq-boy/src/game.ts @@ -266,7 +266,7 @@ export class Game { const consumer = new Json.Snapshot.Consumer({ track: statusTrack, schema: GameStatusSchema }); // Closing the track on cleanup unblocks a pending next() (it returns undefined), so the loop - // ends without racing effect.cancel. + // ends without racing the teardown. effect.spawn(async () => { for (;;) { let status: GameStatus | undefined; diff --git a/js/net/examples/wait.ts b/js/net/examples/wait.ts index 5bf4d8170a..79adf1babf 100644 --- a/js/net/examples/wait.ts +++ b/js/net/examples/wait.ts @@ -28,7 +28,7 @@ async function main() { effect.spawn(async () => { for (;;) { - const group = await Promise.race([effect.cancel, track.recvGroup()]); + const group = await effect.race(track.recvGroup()); if (!group) break; console.log("received:", await group.readString()); } diff --git a/js/net/src/auth.test.ts b/js/net/src/auth.test.ts new file mode 100644 index 0000000000..2c83af8c6f --- /dev/null +++ b/js/net/src/auth.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, test } from "bun:test"; +import type { Getter } from "@moq/signals"; +import { type Grant, type Issued, Unsupported } from "./auth.ts"; +import { accept as acceptSession, connect as connectSession, type Established } from "./connection/index.ts"; +import { SessionCode, SessionError } from "./error.ts"; +import * as Ietf from "./ietf/index.ts"; +import * as Lite from "./lite/index.ts"; +import { createMockTransportPair, type MockTransport } from "./mock.ts"; +import { Producer as OriginProducer } from "./origin.ts"; +import * as Path from "./path.ts"; + +const url = new URL("https://localhost:4443/test"); + +function patterns(...prefixes: string[]): Path.Patterns { + return new Path.Patterns(prefixes.map((prefix) => Path.Pattern.subtree(prefix))); +} + +function grant(publish: string[], subscribe: string[]): Grant { + return { publish: patterns(...publish), subscribe: patterns(...subscribe) }; +} + +async function waitFor(getter: Getter, ready: (value: T) => boolean): Promise { + let value = getter.peek(); + while (!ready(value)) value = await getter.changed(); + return value; +} + +interface Pair { + client: Established; + server: Established; + transport: MockTransport; +} + +async function connect(opts: { publish?: OriginProducer; serverPublish?: OriginProducer; protocol: string }) { + const pair = createMockTransportPair(opts.protocol); + const [client, server] = await Promise.all([ + connectSession({ url, transport: pair.client, publish: opts.publish?.consume() }), + acceptSession({ transport: pair.server, url, publish: opts.serverPublish?.consume() }), + ]); + return { client, server, transport: pair.client } satisfies Pair; +} + +// Every case runs on moq-lite-06 and on moq-transport with the MoQ Auth extension. +describe.each([Lite.ALPN_06, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", (protocol) => { + test("both sides learn their default grant", async () => { + const { client, server } = await connect({ publish: new OriginProducer(), protocol }); + + // The server consumes anything and publishes nothing. + const clientGrant = await waitFor(client.auth.grant, (g) => g !== undefined); + expect(clientGrant?.publish.equals(patterns(""))).toBe(true); + expect(clientGrant?.subscribe.size).toBe(0); + + // The client publishes, so the server may subscribe to anything. + const serverGrant = await waitFor(server.auth.grant, (g) => g !== undefined); + expect(serverGrant?.publish.equals(patterns(""))).toBe(true); + expect(serverGrant?.subscribe.equals(patterns(""))).toBe(true); + + client.close(); + server.close(); + }); + + test("a token without an acceptor reports unsupported", async () => { + const { client, server } = await connect({ publish: new OriginProducer(), protocol }); + await waitFor(client.auth.grant, (g) => g !== undefined); + await expect(client.auth.add("token")).rejects.toBeInstanceOf(Unsupported); + client.close(); + server.close(); + }); + + test("an out-of-scope broadcast closes the session and names the path", async () => { + const origin = new OriginProducer(); + const { client, server, transport } = await connect({ publish: origin, protocol }); + const requests = server.auth.requests(); + const issued: Issued[] = []; + void (async () => { + for (;;) { + const request = await requests.next(); + if (!request) break; + issued.push(request.accept(grant(["baz"], []))); + } + })(); + + await waitFor(client.auth.grant, (g) => g !== undefined); + origin.createBroadcast(Path.from("baz/ok")).announce(); + origin.createBroadcast(Path.from("foo/bar")).announce(); + + const info = await transport.closed; + expect(info.closeCode).toBe(SessionCode.Unauthorized); + expect(info.reason).toBe("unauthorized: foo/bar"); + server.close(); + }); + + test("a revoked grant withdraws its broadcasts without closing the session", async () => { + const origin = new OriginProducer(); + const { client, server, transport } = await connect({ publish: origin, protocol }); + const requests = server.auth.requests(); + const issued: Issued[] = []; + void (async () => { + for (;;) { + const request = await requests.next(); + if (!request) break; + issued.push(request.accept(grant(["a"], []))); + } + })(); + + await waitFor(client.auth.grant, (g) => g !== undefined); + origin.createBroadcast(Path.from("a/x")).announce(); + + const announced = server.announced(); + const first = await announced.next(); + expect(first?.prefix).toBe(Path.from("a/x")); + expect(first?.kind).toBe("announced"); + + issued[0]?.revoke(SessionCode.Unauthorized, "expired"); + const second = await announced.next(); + expect(second?.prefix).toBe(Path.from("a/x")); + expect(second?.kind).toBe("retracted"); + + // The union is empty but still a grant, and a new token restores it. + const empty = await waitFor(client.auth.grant, (g) => g !== undefined && g.publish.size === 0); + expect(empty?.subscribe.size).toBe(0); + const token = await client.auth.add("again"); + expect(token.grant.peek()?.publish.equals(patterns("a"))).toBe(true); + const third = await announced.next(); + expect(third?.kind).toBe("announced"); + + let closed = false; + void transport.closed.then(() => { + closed = true; + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(closed).toBe(false); + + announced.close(); + client.close(); + server.close(); + }); + + test("a refused token surfaces the acceptor's code and reason", async () => { + const { client, server } = await connect({ publish: new OriginProducer(), protocol }); + const requests = server.auth.requests(); + void (async () => { + for (;;) { + const request = await requests.next(); + if (!request) break; + if (request.token.byteLength === 0) request.accept(grant([], [])); + else request.reject(SessionCode.Unauthorized, "bad signature"); + } + })(); + + const err = await client.auth.add("forged").catch((e: unknown) => e); + expect(err).toBeInstanceOf(SessionError); + expect((err as SessionError).code).toBe(SessionCode.Unauthorized); + client.close(); + server.close(); + }); + + test("a grant the wire cannot carry is unsupported and leaves the rest alone", async () => { + const { client, server, transport } = await connect({ publish: new OriginProducer(), protocol }); + const requests = server.auth.requests(); + const issued: Issued[] = []; + void (async () => { + for (;;) { + const request = await requests.next(); + if (!request) break; + const token = new TextDecoder().decode(request.token); + const unions: Record = { + exact: ["room/alice"], + mixed: ["room/**", "lobby"], + wildcard: ["room/*/cam"], + }; + const union = unions[token]; + const granted = union + ? { publish: new Path.Patterns(union.map((p) => Path.Pattern.parse(p))), subscribe: patterns() } + : token === "t1" + ? grant(["b"], []) + : grant(["a"], []); + issued.push(request.accept(granted)); + } + })(); + + await waitFor(client.auth.grant, (g) => g?.publish.equals(patterns("a")) === true); + for (const token of ["exact", "mixed", "wildcard"]) { + await expect(client.auth.add(token)).rejects.toBeInstanceOf(Unsupported); + } + expect(client.auth.grant.peek()?.publish.equals(patterns("a"))).toBe(true); + + // An update the wire cannot carry revokes that token's grant, and only that one. + const t1 = await client.auth.add("t1"); + await waitFor(client.auth.grant, (g) => g?.publish.equals(patterns("a", "b")) === true); + issued[issued.length - 1]?.update({ + publish: new Path.Patterns([Path.Pattern.literal("b/exact")]), + subscribe: patterns(), + }); + await t1.closed; + await waitFor(client.auth.grant, (g) => g?.publish.equals(patterns("a")) === true); + + let closed = false; + void transport.closed.then(() => { + closed = true; + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(closed).toBe(false); + client.close(); + server.close(); + }); + + test("a refused setup token grants nothing rather than everything", async () => { + const { client, server } = await connect({ publish: new OriginProducer(), protocol }); + const requests = server.auth.requests(); + void (async () => { + for (;;) { + const request = await requests.next(); + if (!request) break; + request.reject(SessionCode.Unauthorized, "bad credential"); + } + })(); + + const empty = await waitFor(client.auth.grant, (g) => g !== undefined); + expect(empty?.publish.size).toBe(0); + expect(empty?.subscribe.size).toBe(0); + client.close(); + server.close(); + }); +}); + +test.each([Lite.ALPN_05, Ietf.ALPN.DRAFT_16])("%s has no grant", async (protocol) => { + const { client, server } = await connect({ publish: new OriginProducer(), protocol }); + expect(client.auth.grant.peek()).toBeUndefined(); + await expect(client.auth.add("token")).rejects.toBeInstanceOf(Unsupported); + client.close(); + server.close(); +}); diff --git a/js/net/src/auth.ts b/js/net/src/auth.ts index 6efbc3bd6f..164cf5adc4 100644 --- a/js/net/src/auth.ts +++ b/js/net/src/auth.ts @@ -1,10 +1,13 @@ /** * In-band authorization: present tokens to the peer and learn what they grant. * - * Each side of a moq-lite-06 session presents the credential its connection already - * carried (the URL, or nothing) right after setup, and learns the {@link Grant} it earned. - * {@link Auth.grant} is the union of every token this side presented, and {@link Auth.add} - * presents another without reconnecting. Mirrors the Rust `moq_net::auth`. + * Each side of a moq-lite-06 session, and of a moq-transport draft-17+ session when both + * sides negotiate MoQ Auth, presents the credential its connection already carried (the + * URL, or nothing) right after setup, and learns the {@link Grant} it earned. Older + * versions, and peers that do not negotiate it, carry no AUTH exchange: the grant stays + * undefined and {@link Auth.add} rejects with {@link Unsupported}. {@link Auth.grant} is + * the union of every token this side presented, and {@link Auth.add} presents another + * without reconnecting. Mirrors the Rust `moq_net::auth`. * * @module */ diff --git a/js/net/src/auth_session.ts b/js/net/src/auth_session.ts new file mode 100644 index 0000000000..5f5f16705c --- /dev/null +++ b/js/net/src/auth_session.ts @@ -0,0 +1,481 @@ +import { type Dispose, type Getter, Signal } from "@moq/signals"; +import { + type Auth as AuthApi, + type Grant, + grantsEqual, + type Issued, + type Request, + type Requests, + type Token, + Unsupported, +} from "./auth.ts"; +import { closeReason, error, SessionCode, StreamCode, StreamError } from "./error.ts"; +import * as Path from "./path.ts"; +import type { Stream } from "./stream.ts"; + +/** A grant as the wire carries it: the expiry is milliseconds from now. @internal */ +export interface WireGrant { + publish: Path.Patterns; + subscribe: Path.Patterns; + expires?: number; +} + +/** One reply read off a presented token's stream. @internal */ +export type WireReply = { grant: WireGrant } | { refused: Error }; + +/** + * How one wire carries AUTH, AUTH_OK, and AUTH_ERROR, so moq-lite and moq-transport share + * the token lifecycle. + * + * @internal + */ +export interface AuthWire { + /** Open a stream and present the token on it. */ + present(token: Uint8Array): Promise; + /** The next reply on a presented token's stream, or undefined once the acceptor finished it. */ + read(stream: Stream): Promise; + /** The token the peer presents on a stream it opened. */ + accept(stream: Stream): Promise; + /** Tell the presenter a grant. Throws {@link Unsupported} when this wire cannot represent it. */ + grant(stream: Stream, grant: WireGrant): Promise; + /** Refuse or revoke the presenter's token. */ + refuse(stream: Stream, code: SessionCode, reason: string): Promise; + /** End the stream telling the presenter its token or grant cannot be answered here. */ + unsupported(stream: Stream, reason: string): Promise; +} + +function union(grants: Iterable): Grant { + const publish = new Path.Patterns(); + const subscribe = new Path.Patterns(); + let expires: number | undefined; + for (const grant of grants) { + for (const pattern of grant.publish) publish.insert(pattern); + for (const pattern of grant.subscribe) subscribe.insert(pattern); + // The earliest expiry is when the union next shrinks. + if (grant.expires !== undefined) expires = Math.min(expires ?? grant.expires, grant.expires); + } + return { publish, subscribe, expires }; +} + +function cancel(): StreamError { + return new StreamError(StreamCode.Cancel, { message: "cancel" }); +} + +/** One token this side presented. */ +class Presented implements Token { + readonly grant = new Signal(undefined); + readonly closed: Promise; + readonly answered: Promise; + readonly setup: boolean; + readonly token: Uint8Array; + + stream?: Stream; + withdrawn = false; + isAnswered = false; + ended = false; + + #close!: (err: Error | null) => void; + #answer!: () => void; + #refuse!: (err: Error) => void; + + constructor(token: Uint8Array, setup: boolean) { + this.token = token; + this.setup = setup; + this.closed = new Promise((resolve) => { + this.#close = resolve; + }); + this.answered = new Promise((resolve, reject) => { + this.#answer = resolve; + this.#refuse = reject; + }); + // A caller that never awaits the answer must not see an unhandled rejection. + this.answered.catch(() => void 0); + } + + answer() { + if (this.isAnswered) return; + this.isAnswered = true; + this.#answer(); + } + + end(err: Error | null) { + if (this.ended) return; + this.ended = true; + this.grant.set(undefined); + if (!this.isAnswered) { + this.isAnswered = true; + this.#refuse(err ?? new Error("withdrawn")); + } + this.#close(err); + } + + close() { + if (this.withdrawn) return; + this.withdrawn = true; + // The stream's loop notices and ends the token; one still opening checks on arrival. + this.stream?.abort(cancel()); + } +} + +/** The peer's token, answered by the application. */ +class PeerRequest implements Request { + readonly token: Uint8Array; + #issued: IssuedGrant; + #answered = false; + + constructor(token: Uint8Array, issued: IssuedGrant) { + this.token = token; + this.#issued = issued; + } + + accept(grant: Grant): Issued { + if (this.#answered) throw new Error("already answered"); + this.#answered = true; + this.#issued.update(grant); + return this.#issued; + } + + reject(code: SessionCode, reason: string): void { + if (this.#answered) throw new Error("already answered"); + this.#answered = true; + this.#issued.revoke(code, reason); + } +} + +/** Our side of one of the peer's tokens: the grant we issued and its stream. */ +class IssuedGrant implements Issued { + readonly closed: Promise; + #stream: Stream; + #wire: AuthWire; + #writes = Promise.resolve(); + #done = false; + + constructor(stream: Stream, wire: AuthWire) { + this.#stream = stream; + this.#wire = wire; + // The presenter withdraws by closing or cancelling its side. + this.closed = stream.reader.closed.then( + () => null, + (err: unknown) => (err instanceof StreamError && err.code === StreamCode.Cancel ? null : error(err)), + ); + } + + #write(write: () => Promise) { + this.#writes = this.#writes.then(write).catch((err: unknown) => { + // The peer already closed the stream: nothing left to tell it. + if (err instanceof StreamError) return; + // A grant the wire cannot carry is withheld, never widened, and so is any other + // reply that fails to encode. + if (!(err instanceof Unsupported)) console.warn("auth reply not sent", err); + this.#done = true; + return this.#wire.unsupported(this.#stream, error(err).message).catch(() => void 0); + }); + } + + update(grant: Grant): void { + if (this.#done) return; + const expires = grant.expires === undefined ? undefined : grant.expires - Date.now(); + this.#write(() => + this.#wire.grant(this.#stream, { publish: grant.publish, subscribe: grant.subscribe, expires }), + ); + } + + revoke(code: SessionCode, reason: string): void { + if (this.#done) return; + this.#write(() => this.#wire.refuse(this.#stream, code, reason)); + this.close(); + } + + close(): void { + if (this.#done) return; + this.#done = true; + this.#writes = this.#writes.then(() => this.#stream.writer.close()); + } +} + +/** The peer's tokens, queued for the application. */ +class RequestQueue implements Requests { + #queue: PeerRequest[] = []; + #waiters: ((request: PeerRequest | undefined) => void)[] = []; + #closed = false; + + push(request: PeerRequest): boolean { + if (this.#closed) return false; + const waiter = this.#waiters.shift(); + if (waiter) waiter(request); + else this.#queue.push(request); + return true; + } + + next(): Promise { + const next = this.#queue.shift(); + if (next || this.#closed) return Promise.resolve(next); + return new Promise((resolve) => this.#waiters.push(resolve)); + } + + close(): void { + this.#closed = true; + for (const request of this.#queue.splice(0)) { + request.reject(SessionCode.Unauthorized, "not accepting tokens"); + } + for (const waiter of this.#waiters.splice(0)) waiter(undefined); + } +} + +/** Constructor options for {@link AuthSession}. @internal */ +export interface AuthSessionProps { + /** How this session's wire carries the exchange, or undefined when it carries none. */ + wire?: AuthWire; + /** What the default acceptor grants the peer's connection credential. */ + peerGrant: Grant; +} + +/** + * A session's tokens and grants: presents ours, one stream each, and answers the peer's. + * The wire decides only the encoding; see {@link AuthWire}. + * + * @internal + */ +export class AuthSession implements AuthApi { + #wire?: AuthWire; + #peerGrant: Grant; + + #union = new Signal(undefined); + // The peer replied to some token, so the union is known even when empty. + #replied = false; + #tokens = new Set(); + #setupPending = new Signal(0); + #acceptor: "undecided" | "default" | RequestQueue = "undecided"; + #closed = false; + + // Whoever answers the peer's tokens is decided once, after the task that established + // the session: an app that calls requests() as soon as connect/accept resolves always + // wins, however quickly the peer's first token arrives. + #decided = new Promise((resolve) => setTimeout(resolve, 0)); + + constructor({ wire, peerGrant }: AuthSessionProps) { + this.#wire = wire; + this.#peerGrant = peerGrant; + + // Present the connection's own credential right away, so both sides learn their + // grant without waiting on the app. + if (wire) this.#present(wire, new Uint8Array(), true); + } + + get grant(): Getter { + return this.#union; + } + + /** Whether this session exchanges tokens at all. */ + get negotiated(): boolean { + return this.#wire !== undefined; + } + + async add(token: string | Uint8Array): Promise { + if (!this.#wire || this.#closed) throw new Unsupported(); + const bytes = typeof token === "string" ? new TextEncoder().encode(token) : token; + const presented = this.#present(this.#wire, bytes, false); + await presented.answered; + return presented; + } + + requests(): Requests { + if (this.#acceptor !== "undecided") throw new Error("auth requests already taken or answered by default"); + const queue = new RequestQueue(); + if (!this.#wire) queue.close(); + this.#acceptor = queue; + return queue; + } + + /** Resolves once every token the session presented at setup has its first reply. */ + async setupAnswered(): Promise { + while (this.#setupPending.peek() > 0) await this.#setupPending.changed(); + } + + /** Answer one of the peer's token streams, for the life of its token. */ + async serve(stream: Stream): Promise { + const wire = this.#wire; + if (!wire) throw new Error("auth not negotiated"); + + const token = await wire.accept(stream); + await this.#decided; + if (this.#acceptor === "undecided") this.#acceptor = "default"; + + const issued = new IssuedGrant(stream, wire); + if (this.#acceptor instanceof RequestQueue) { + const request = new PeerRequest(token, issued); + if (!this.#acceptor.push(request)) request.reject(SessionCode.Unauthorized, "not accepting tokens"); + } else if (token.byteLength > 0) { + // Only the connection's own credential has a default answer; a token needs + // someone to verify it. + await wire.unsupported(stream, "no acceptor for tokens"); + return; + } else { + issued.update(this.#peerGrant); + } + + await issued.closed; + issued.close(); + } + + /** End the session: fail every pending token and close the requests. */ + close() { + if (this.#closed) return; + this.#closed = true; + for (const token of this.#tokens) token.end(new Error("session closed")); + this.#tokens.clear(); + if (this.#acceptor instanceof RequestQueue) this.#acceptor.close(); + } + + #present(wire: AuthWire, token: Uint8Array, setup: boolean): Presented { + const presented = new Presented(token, setup); + this.#tokens.add(presented); + if (setup) this.#setupPending.update((n) => n + 1); + void this.#run(wire, presented); + return presented; + } + + async #run(wire: AuthWire, token: Presented) { + let result: Error | null = null; + try { + const stream = await wire.present(token.token); + token.stream = stream; + if (token.withdrawn) throw cancel(); + + for (;;) { + const reply = await wire.read(stream); + if (!reply) { + // The peer ended the grant without revoking it, or closed without ever + // answering. + result = token.isAnswered ? null : new Unsupported(); + break; + } + if ("grant" in reply) { + const expires = reply.grant.expires === undefined ? undefined : Date.now() + reply.grant.expires; + token.grant.set({ publish: reply.grant.publish, subscribe: reply.grant.subscribe, expires }); + this.#replied = true; + this.#answered(token); + this.#recompute(); + continue; + } + console.warn("auth token refused", reply.refused); + // A refused setup token leaves an empty union, not an unknown (unrestricted) + // one. A grant the acceptor could not tell is unknown, not empty. + if (!(reply.refused instanceof Unsupported)) this.#replied = true; + result = reply.refused; + stream.close(); + break; + } + } catch (err: unknown) { + if (token.withdrawn) { + result = null; + } else if (!token.isAnswered && err instanceof StreamError) { + // A peer that predates AUTH resets a stream type it does not know. + result = new Unsupported(); + } else { + result = error(err); + if (!this.#closed) console.warn("auth token ended", result); + } + } + + // A token that ends unanswered counts as answered for enforcement: its refusal is + // the reply. + const unanswered = !token.isAnswered; + token.end(result); + if (unanswered && token.setup) this.#setupPending.update((n) => n - 1); + this.#tokens.delete(token); + this.#recompute(); + } + + #answered(token: Presented) { + if (token.isAnswered) return; + token.answer(); + if (token.setup) this.#setupPending.update((n) => n - 1); + } + + #recompute() { + const granted: Grant[] = []; + for (const token of this.#tokens) { + const grant = token.grant.peek(); + if (grant) granted.push(grant); + } + // Undefined until the first reply; an empty union afterwards grants nothing. + if (granted.length === 0 && !this.#replied) return; + const next = union(granted); + if (!grantsEqual(next, this.#union.peek())) this.#union.set(next); + } +} + +/** + * Close the session when our origin publishes a broadcast our grant does not cover, + * instead of leaving it to wait for a subscription that never comes. + * + * Starts once the tokens the session presented at setup are answered, then checks each + * broadcast when it first appears. A grant that later shrinks withdraws what it no longer + * covers without closing anything: the grant is read before the table, so a revocation is + * never mistaken for a new unauthorized publication. Only the dialing side enforces: a + * server's publish origin is everything the peer may read, not what it intends to push. + * + * @internal + */ +export async function enforceGrant({ + quic, + advertised, + grant, + setupAnswered, +}: { + quic: WebTransport; + /** The broadcasts this side publishes, undefined once the origin ends. */ + advertised: Getter | undefined>; + grant: Getter; + setupAnswered: Promise; +}): Promise { + const closed = quic.closed.then( + () => "closed" as const, + () => "closed" as const, + ); + if ((await Promise.race([setupAnswered.then(() => "ready" as const), closed])) === "closed") return; + + // Every broadcast admitted so far that is still published. + const live = new Set(); + for (;;) { + let dispose: Dispose = () => {}; + const woke = new Promise<"changed">((resolve) => { + const table = advertised.changed(() => resolve("changed")); + const granted = grant.changed(() => resolve("changed")); + dispose = () => { + table(); + granted(); + }; + }); + + const current = grant.peek(); + const table = advertised.peek(); + if (!table) { + dispose(); + return; + } + if (current) { + for (const path of live) { + if (!table.has(path)) live.delete(path); + } + for (const path of table.keys()) { + if (live.has(path)) continue; + if (!current.publish.matches(path)) { + console.error(`publishing outside our grant; closing the session: broadcast=${path}`); + quic.close({ + closeCode: SessionCode.Unauthorized, + reason: closeReason(`unauthorized: ${path}`), + }); + dispose(); + return; + } + live.add(path); + } + } + + const why = await Promise.race([woke, closed]); + dispose(); + if (why === "closed") return; + } +} diff --git a/js/net/src/connection/accept.ts b/js/net/src/connection/accept.ts index 8e1dce4ee8..c109deb2b6 100644 --- a/js/net/src/connection/accept.ts +++ b/js/net/src/connection/accept.ts @@ -117,7 +117,7 @@ async function acceptAlpn( version: Ietf.IetfVersion, wiring: SessionProps, ): Promise { - const { control, solicit, hidden, cluster } = await exchangeSetup(transport, version, "moq-lite-js"); + const { control, solicit, hidden, cluster, auth } = await exchangeSetup(transport, version, "moq-lite-js"); return new Ietf.Connection({ ...wiring, @@ -128,6 +128,7 @@ async function acceptAlpn( solicit, hidden, cluster, + auth, // v17+ uses NativeSession which manages its own request IDs; maxRequestId is unused. maxRequestId: 0n, version, diff --git a/js/net/src/connection/connect.ts b/js/net/src/connection/connect.ts index c44124423c..c537c4d4a2 100644 --- a/js/net/src/connection/connect.ts +++ b/js/net/src/connection/connect.ts @@ -365,7 +365,7 @@ async function handshakeAlpn( version: Ietf.IetfVersion, wiring: SessionProps, ): Promise { - const { control, solicit, hidden, cluster } = await exchangeSetup(session, version, "moq-lite-js"); + const { control, solicit, hidden, cluster, auth } = await exchangeSetup(session, version, "moq-lite-js"); return new Ietf.Connection({ ...wiring, @@ -376,6 +376,7 @@ async function handshakeAlpn( solicit, hidden, cluster, + auth, // v17+ uses NativeSession which manages its own request IDs; maxRequestId is unused. maxRequestId: 0n, version, diff --git a/js/net/src/connection/established.ts b/js/net/src/connection/established.ts index 6130e322a5..bde995cb76 100644 --- a/js/net/src/connection/established.ts +++ b/js/net/src/connection/established.ts @@ -29,8 +29,9 @@ export interface Established { /** * The tokens this side presented and the grant they earned, plus the tokens the peer - * presents. On moq-lite-06 each side presents its connection's credential right after - * setup; elsewhere the grant stays undefined. + * presents. On moq-lite-06, and on moq-transport draft-17+ when both sides negotiate + * MoQ Auth, each side presents its connection's credential right after setup. + * Otherwise the grant stays undefined. */ readonly auth: Auth.Auth; diff --git a/js/net/src/connection/forward.ts b/js/net/src/connection/forward.ts index a2c50a1d26..66745398a9 100644 --- a/js/net/src/connection/forward.ts +++ b/js/net/src/connection/forward.ts @@ -3,7 +3,7 @@ * * @module */ -import type { Dispose } from "@moq/signals"; +import { type Dispose, race } from "@moq/signals"; import { isActive } from "../announced.ts"; import type { Dynamic, Producer as OriginProducer, RequestSlot } from "../origin.ts"; import type * as Path from "../path.ts"; @@ -160,7 +160,7 @@ async function serveRequests(conn: Established, origin: OriginProducer): Promise // Woken by the table too, not just the requests: a path that stops being routed needs // the blind answer this loop skipped while it was. - await Promise.race([table.changed(), closed]); + await race([table.changed(), closed]); } // Session gone: withdraw our answers, waking a standby session to provide fresh ones. diff --git a/js/net/src/connection/handshake.ts b/js/net/src/connection/handshake.ts index 05cd214c8b..9433eafd7e 100644 --- a/js/net/src/connection/handshake.ts +++ b/js/net/src/connection/handshake.ts @@ -10,7 +10,8 @@ import { Reader, Stream, Writer } from "../stream.ts"; * * Returns the control stream plus what the peer's SETUP declared: whether it requires * solicitation, which decides whether we announce namespaces unprompted (see the MoQ Solicit - * extension), and its Hop ID (see the MoQ Cluster extension). We declare both ourselves on + * extension), its Hop ID (see the MoQ Cluster extension), and whether it offered MoQ Auth. + * We declare all three ourselves on * every session: we send SUBSCRIBE_NAMESPACE for each prefix we want, so an unsolicited * advertisement can tell us nothing we won't have asked for, and a peer that knows our Hop * ID can withhold the advertisements that already flowed through us. @@ -19,12 +20,19 @@ export async function exchangeSetup( transport: WebTransport, version: Ietf.IetfVersion, implementation: string, -): Promise<{ control: Stream; solicit: boolean | undefined; hidden: boolean; cluster: Ietf.Cluster.Hops }> { +): Promise<{ + control: Stream; + solicit: boolean | undefined; + hidden: boolean; + cluster: Ietf.Cluster.Hops; + auth: boolean; +}> { const encoder = new TextEncoder(); const params = new Ietf.SetupOptions(); params.setBytes(Ietf.SetupOption.Implementation, encoder.encode(implementation)); Ietf.solicitIntoSetup(params); Ietf.hiddenIntoSetup(params); + Ietf.Auth.intoSetup(params, version); // One id per session, like the moq-lite connection: nothing in this process forwards // between sessions, so there is nothing for a shared id to detect. @@ -43,6 +51,7 @@ export async function exchangeSetup( solicit: received.solicit, hidden: received.hidden, cluster: { self, peer: received.cluster }, + auth: received.auth, }; } @@ -58,7 +67,7 @@ async function sendSetup(transport: WebTransport, version: Ietf.IetfVersion, set async function receiveSetup( transport: WebTransport, version: Ietf.IetfVersion, -): Promise<{ reader: Reader; solicit: boolean | undefined; hidden: boolean; cluster: Hop | undefined }> { +): Promise<{ reader: Reader; solicit: boolean | undefined; hidden: boolean; cluster: Hop | undefined; auth: boolean }> { const uniReader = transport.incomingUnidirectionalStreams.getReader() as ReadableStreamDefaultReader< ReadableStream >; @@ -79,5 +88,6 @@ async function receiveSetup( solicit: Ietf.solicitFromSetup(setup.parameters), hidden: Ietf.hiddenFromSetup(setup.parameters), cluster: Ietf.Cluster.fromSetup(setup.parameters, version), + auth: Ietf.Auth.fromSetup(setup.parameters, version) === true, }; } diff --git a/js/net/src/connection/pool.ts b/js/net/src/connection/pool.ts index 1957d4dc1a..959697ca09 100644 --- a/js/net/src/connection/pool.ts +++ b/js/net/src/connection/pool.ts @@ -293,7 +293,7 @@ export class Connection { effect.spawn(async () => { try { for (;;) { - const entry = await Promise.race([effect.cancel, upstream.next()]); + const entry = await effect.race(upstream.next()); if (!entry) break; if (Announce.isActive(entry.kind)) active.set(entry.prefix, entry); else active.delete(entry.prefix); diff --git a/js/net/src/connection/reload.ts b/js/net/src/connection/reload.ts index df47c29a18..b7b77bcaef 100644 --- a/js/net/src/connection/reload.ts +++ b/js/net/src/connection/reload.ts @@ -255,7 +255,7 @@ export class Reload { if (pending) return; pending = true; try { - const stats = await Promise.race([effect.cancel, connection.stats()]); + const stats = await effect.race(connection.stats()); if (stats) this.#estimate.set(stats.estimatedSendRate); } finally { pending = false; @@ -341,7 +341,7 @@ export class Reload { // A cancelled effect resolves undefined, so the sentinel tells the session // closing (null for clean, an Error otherwise) apart from this run being // torn down. - const closed = await Promise.race([effect.cancel, connection.closed]); + const closed = await effect.race(connection.closed); if (closed === undefined) return; console.warn("connection closed, reconnecting"); @@ -470,7 +470,7 @@ export class Reload { effect.spawn(async () => { try { for (;;) { - const entry = await Promise.race([effect.cancel, upstream.next()]); + const entry = await effect.race(upstream.next()); if (!entry) break; if (Announce.isActive(entry.kind)) active.set(entry.prefix, entry); else active.delete(entry.prefix); diff --git a/js/net/src/ietf/auth.test.ts b/js/net/src/ietf/auth.test.ts new file mode 100644 index 0000000000..2ebcd217c7 --- /dev/null +++ b/js/net/src/ietf/auth.test.ts @@ -0,0 +1,135 @@ +import { expect, test } from "bun:test"; +import { Unsupported } from "../auth.ts"; +import { SessionError } from "../error.ts"; +import * as Path from "../path.ts"; +import { Reader, Writer } from "../stream.ts"; +import { AuthError, AuthMessage, AuthOk, fromSetup, intoSetup } from "./auth.ts"; +import { SetupOption, SetupOptions } from "./parameters.ts"; +import { Version } from "./version.ts"; + +function patterns(...prefixes: string[]): Path.Patterns { + return new Path.Patterns(prefixes.map((prefix) => Path.Pattern.subtree(prefix))); +} + +/** The bytes a write produces. */ +async function bytes(write: (w: Writer) => Promise): Promise { + const chunks: Uint8Array[] = []; + const writer = new Writer( + new WritableStream({ + write(chunk) { + chunks.push(new Uint8Array(chunk)); + }, + }), + Version.DRAFT_17, + ); + await write(writer); + writer.close(); + await writer.closed; + const out = new Uint8Array(chunks.reduce((n, c) => n + c.byteLength, 0)); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.byteLength; + } + return out; +} + +/** A reader past the message type, the way the dispatcher leaves one. */ +async function afterType(write: (w: Writer) => Promise): Promise { + const reader = new Reader(undefined, await bytes(write), Version.DRAFT_17); + await reader.u53(); + return reader; +} + +test("the setup option negotiates on draft-17+ only", () => { + for (const version of [Version.DRAFT_17, Version.DRAFT_22]) { + const params = new SetupOptions(); + expect(fromSetup(params, version)).toBeUndefined(); + intoSetup(params, version); + expect(fromSetup(params, version)).toBe(true); + } + const legacy = new SetupOptions(); + intoSetup(legacy, Version.DRAFT_16); + expect(legacy.getVarint(SetupOption.Auth)).toBeUndefined(); + + // An explicit value other than 1 is an implementation that declined. + const declined = new SetupOptions(); + declined.setVarint(SetupOption.Auth, 0n); + expect(fromSetup(declined, Version.DRAFT_17)).toBe(false); +}); + +test("AUTH round-trips its request id and token", async () => { + const r = await afterType((w) => new AuthMessage(4n, new TextEncoder().encode("jwt")).encode(w, Version.DRAFT_17)); + const msg = await AuthMessage.decode(r, Version.DRAFT_17); + expect(msg.requestId).toBe(4n); + expect(new TextDecoder().decode(msg.token)).toBe("jwt"); +}); + +test("AUTH_OK carries prefixes as namespace tuples", async () => { + const ok = new AuthOk(patterns("room/alice"), patterns(), undefined); + const wire = await bytes((w) => ok.encode(w, Version.DRAFT_17)); + // Type, 16-bit length, then: one prefix of two fields, no subscribe prefixes, never expires. + expect([...wire.slice(-15)]).toEqual([ + 0x01, + 0x02, + 0x04, + ...new TextEncoder().encode("room"), + 0x05, + ...new TextEncoder().encode("alice"), + 0x00, + 0x00, + ]); + + const r = await afterType((w) => new AuthOk(patterns(""), patterns("room"), 60_000).encode(w, Version.DRAFT_17)); + const got = await AuthOk.decode(r, Version.DRAFT_17); + // The empty prefix grants everything; the empty list grants nothing. + expect(got.publish.equals(new Path.Patterns([Path.Pattern.all()]))).toBe(true); + expect(got.subscribe.equals(patterns("room"))).toBe(true); + expect(got.expires).toBe(60_000); +}); + +test("a grant the prefix wire cannot express is refused before anything is written", async () => { + for (const union of [["room/alice"], ["room/*/cam"], ["room/**", "lobby"]]) { + const narrow = new AuthOk(new Path.Patterns(union.map((p) => Path.Pattern.parse(p))), patterns()); + let written = 0; + const writer = new Writer( + new WritableStream({ + write(chunk) { + written += chunk.byteLength; + }, + }), + Version.DRAFT_17, + ); + await expect(narrow.encode(writer, Version.DRAFT_17)).rejects.toBeInstanceOf(Unsupported); + writer.close(); + await writer.closed; + expect(written).toBe(0); + } +}); + +test("a grant too large for one message is refused before anything is written", async () => { + const huge = new AuthOk(patterns(...Array.from({ length: 20 }, (_, i) => `${i}${"x".repeat(4000)}`)), patterns()); + let written = 0; + const writer = new Writer( + new WritableStream({ + write(chunk) { + written += chunk.byteLength; + }, + }), + Version.DRAFT_17, + ); + await expect(huge.encode(writer, Version.DRAFT_17)).rejects.toThrow("Message too large"); + writer.close(); + await writer.closed; + expect(written).toBe(0); +}); + +test("NOT_SUPPORTED is unsupported, every other code a refusal", async () => { + const r = await afterType((w) => new AuthError(0x3, "prefixes only").encode(w, Version.DRAFT_17)); + expect((await AuthError.decode(r, Version.DRAFT_17)).toError()).toBeInstanceOf(Unsupported); + expect(new AuthError(0x1, "bad").toError()).toBeInstanceOf(SessionError); +}); + +test("drafts before 17 carry no AUTH", async () => { + await expect(bytes((w) => new AuthMessage(0n, new Uint8Array()).encode(w, Version.DRAFT_16))).rejects.toThrow(); +}); diff --git a/js/net/src/ietf/auth.ts b/js/net/src/ietf/auth.ts new file mode 100644 index 0000000000..672b19db5e --- /dev/null +++ b/js/net/src/ietf/auth.ts @@ -0,0 +1,274 @@ +import { Unsupported } from "../auth.ts"; +import type { AuthWire, WireGrant, WireReply } from "../auth_session.ts"; +import { SessionCode, SessionError } from "../error.ts"; +import * as Path from "../path.ts"; +import type { Reader, Stream, Writer } from "../stream.ts"; +import * as Message from "./message.ts"; +import * as Namespace from "./namespace.ts"; +import { SetupOption, type SetupOptions } from "./parameters.ts"; +import { type IetfVersion, Version } from "./version.ts"; + +/** + * The MoQ Auth extension (draft-lcurley-moq-auth-00): the moq-lite Auth Stream as + * moq-transport request streams, negotiated with the AUTH Setup Option on draft-17+. + * + * The wire carries namespace prefixes, so a grant is told only when it is a union of + * subtrees; anything narrower is refused with NOT_SUPPORTED rather than widened. + * + * @module + * @internal + */ + +/** Whether a version negotiates the extension. @internal */ +export function supported(version: IetfVersion): boolean { + return version >= Version.DRAFT_17; +} + +/** + * What the peer declared: undefined for no option, otherwise whether it offered the + * extension. Only an explicit 1 negotiates it. + * + * @internal + */ +export function fromSetup(params: SetupOptions, version: IetfVersion): boolean | undefined { + if (!supported(version)) return undefined; + const value = params.getVarint(SetupOption.Auth); + return value === undefined ? undefined : value === 1n; +} + +/** Offer the extension, on the versions that negotiate it. @internal */ +export function intoSetup(params: SetupOptions, version: IetfVersion) { + if (supported(version)) params.setVarint(SetupOption.Auth, 1n); +} + +/** The REQUEST_ERROR codes AUTH_ERROR carries. */ +const UNAUTHORIZED = 0x1; +const NOT_SUPPORTED = 0x3; + +/** Longest AUTH_ERROR reason, in bytes, matching the Rust decoder. */ +const MAX_REASON = 8192; + +function guard(version: IetfVersion) { + if (!supported(version)) throw new Error("auth not supported for this version"); +} + +/** + * AUTH: the first message on an Auth request stream, presenting a token. Each message + * here encodes its own type, which the dispatcher consumes before decoding. + * + * @internal + */ +export class AuthMessage { + static id = 0x40b61; + + requestId: bigint; + token: Uint8Array; + + constructor(requestId: bigint, token: Uint8Array) { + this.requestId = requestId; + this.token = token; + } + + async encode(w: Writer, version: IetfVersion): Promise { + guard(version); + return Message.encode( + w, + async (wr) => { + await wr.u62(this.requestId); + await wr.u53(this.token.byteLength); + if (this.token.byteLength > 0) await wr.write(this.token); + }, + AuthMessage.id, + ); + } + + static async decode(r: Reader, version: IetfVersion): Promise { + guard(version); + return Message.decode(r, async (rd) => { + const requestId = await rd.u62(); + const size = await rd.u53(); + return new AuthMessage(requestId, await rd.read(size)); + }); + } +} + +/** Each pattern as a namespace prefix, or throw {@link Unsupported} for one that is not a subtree. */ +function prefixes(patterns: Path.Patterns): Path.Valid[] { + return patterns.toArray().map((pattern) => { + const prefix = pattern.asPrefix(); + if (prefix === undefined) throw new Unsupported(`grant not representable as namespace prefixes: ${pattern}`); + return prefix as Path.Valid; + }); +} + +async function encodePrefixes(w: Writer, list: Path.Valid[]) { + await w.u53(list.length); + for (const prefix of list) await Namespace.encode(w, prefix); +} + +async function decodePrefixes(r: Reader): Promise { + const count = await r.u53(); + const patterns = new Path.Patterns(); + for (let i = 0; i < count; i++) { + patterns.insert(Path.Pattern.subtree(await Namespace.decode(r))); + } + return patterns; +} + +/** AUTH_OK: the grant a token earns, replacing any earlier one on the stream. @internal */ +export class AuthOk { + static id = 0x40b62; + + publish: Path.Patterns; + subscribe: Path.Patterns; + /** Milliseconds until the grant lapses, or undefined for never. */ + expires?: number; + + constructor(publish: Path.Patterns, subscribe: Path.Patterns, expires?: number) { + this.publish = publish; + this.subscribe = subscribe; + this.expires = expires; + } + + async encode(w: Writer, version: IetfVersion): Promise { + guard(version); + // Resolved before anything is written, so an unrepresentable grant leaves nothing + // half-sent. + const publish = prefixes(this.publish); + const subscribe = prefixes(this.subscribe); + return Message.encode( + w, + async (wr) => { + await encodePrefixes(wr, publish); + await encodePrefixes(wr, subscribe); + // 0 means never, so a lapsed grant rounds up to the smallest real expiry. + const expires = + this.expires === undefined + ? 0 + : Math.min(Math.max(Math.ceil(this.expires), 1), Number.MAX_SAFE_INTEGER); + await wr.u53(expires); + }, + AuthOk.id, + ); + } + + static async decode(r: Reader, version: IetfVersion): Promise { + guard(version); + return Message.decode(r, async (rd) => { + const publish = await decodePrefixes(rd); + const subscribe = await decodePrefixes(rd); + const expires = await rd.u53(); + return new AuthOk(publish, subscribe, expires === 0 ? undefined : expires); + }); + } +} + +/** AUTH_ERROR: the acceptor refusing a token, or revoking it after an AUTH_OK. @internal */ +export class AuthError { + static id = 0x40b63; + + /** A code from the REQUEST_ERROR registry. */ + code: number; + reason: string; + + constructor(code: number, reason: string) { + this.code = code; + this.reason = reason; + } + + async encode(w: Writer, version: IetfVersion): Promise { + guard(version); + if (new TextEncoder().encode(this.reason).byteLength > MAX_REASON) { + throw new Error("AUTH_ERROR reason exceeds 8,192 bytes"); + } + return Message.encode( + w, + async (wr) => { + await wr.u53(this.code); + await wr.string(this.reason); + }, + AuthError.id, + ); + } + + static async decode(r: Reader, version: IetfVersion): Promise { + guard(version); + return Message.decode(r, async (rd) => { + const code = await rd.u53(); + const reason = await rd.string(); + if (new TextEncoder().encode(reason).byteLength > MAX_REASON) { + throw new Error("AUTH_ERROR reason exceeds 8,192 bytes"); + } + return new AuthError(code, reason); + }); + } + + /** What the refusal means to the presenter: NOT_SUPPORTED is a grant it could not tell. */ + toError(): Error { + if (this.code === NOT_SUPPORTED) return new Unsupported(this.reason); + return new SessionError(SessionCode.Unauthorized, { reason: this.reason }); + } +} + +/** The moq-transport binding of the token lifecycle. @internal */ +export class IetfAuthWire implements AuthWire { + #openBi: () => Promise; + #nextRequestId: () => Promise; + #version: IetfVersion; + + constructor(props: { + openBi: () => Promise; + nextRequestId: () => Promise; + version: IetfVersion; + }) { + this.#openBi = props.openBi; + this.#nextRequestId = props.nextRequestId; + this.#version = props.version; + } + + async present(token: Uint8Array): Promise { + const requestId = await this.#nextRequestId(); + if (requestId === undefined) throw new Error("no request id available"); + const stream = await this.#openBi(); + await new AuthMessage(requestId, token).encode(stream.writer, this.#version); + return stream; + } + + async read(stream: Stream): Promise { + if (await stream.reader.done()) return undefined; + const id = await stream.reader.u53(); + switch (id) { + case AuthOk.id: { + const ok = await AuthOk.decode(stream.reader, this.#version); + return { grant: { publish: ok.publish, subscribe: ok.subscribe, expires: ok.expires } }; + } + case AuthError.id: { + const err = await AuthError.decode(stream.reader, this.#version); + return { refused: err.toError() }; + } + default: + throw new Error(`unexpected message on an auth request: 0x${id.toString(16)}`); + } + } + + /** The type id is already consumed by the dispatcher. */ + async accept(stream: Stream): Promise { + const msg = await AuthMessage.decode(stream.reader, this.#version); + return msg.token; + } + + async grant(stream: Stream, grant: WireGrant): Promise { + await new AuthOk(grant.publish, grant.subscribe, grant.expires).encode(stream.writer, this.#version); + } + + async refuse(stream: Stream, code: SessionCode, reason: string): Promise { + // The public API speaks session codes; this registry distinguishes only a version mismatch. + const wire = code === SessionCode.Version ? NOT_SUPPORTED : UNAUTHORIZED; + await new AuthError(wire, reason).encode(stream.writer, this.#version); + } + + async unsupported(stream: Stream, reason: string): Promise { + await new AuthError(NOT_SUPPORTED, reason).encode(stream.writer, this.#version); + await stream.writer.close(); + } +} diff --git a/js/net/src/ietf/connection.ts b/js/net/src/ietf/connection.ts index 7ceb9f99dc..989dd762f9 100644 --- a/js/net/src/ietf/connection.ts +++ b/js/net/src/ietf/connection.ts @@ -1,15 +1,17 @@ import { type Getter, Signal } from "@moq/signals"; import type * as announce from "../announced.ts"; -import * as Auth from "../auth.ts"; +import type * as Auth from "../auth.ts"; +import { AuthSession } from "../auth_session.ts"; import type { Established } from "../connection/established.ts"; import { type Probe, type Stats, transportStats } from "../connection/stats.ts"; import { type Transport, transportOf } from "../connection/transport.ts"; import { error, fromClose, ProtocolViolation, StreamCode, StreamError } from "../error.ts"; import type { Consumer as OriginConsumer } from "../origin.ts"; -import type * as Path from "../path.ts"; +import * as Path from "../path.ts"; import { type Reader, Readers, type Stream } from "../stream.ts"; import { registerWire } from "../wire.ts"; import { ControlStreamAdapter, NativeSession, type Session } from "./adapter.ts"; +import { AuthMessage, supported as authSupported, IetfAuthWire } from "./auth.ts"; import * as Cluster from "./cluster.ts"; import { GoAway } from "./goaway.ts"; import { Group } from "./object.ts"; @@ -43,8 +45,17 @@ export class Connection implements Established { /** moq-transport has no PROBE, so this stays empty; see {@link Established.probe}. */ readonly probe: Getter = new Signal({}); - /** moq-transport carries no AUTH exchange yet; see {@link Established.auth}. */ - readonly auth: Auth.Auth = new Auth.None(); + /** Our tokens and grants, when the peer negotiated MoQ Auth; see {@link Established.auth}. */ + get auth(): Auth.Auth { + return this.#auth; + } + + // Our tokens and grants, and the answers to the peer's (MoQ Auth). + #auth: AuthSession; + + // Whether this peer initiated the session; only the dialing side fails loud on a + // publication its grant does not cover. + #client: boolean; // The established WebTransport session. #quic: WebTransport; @@ -91,6 +102,7 @@ export class Connection implements Established { solicit, hidden = false, cluster, + auth = false, }: { url: URL; quic: WebTransport; @@ -114,6 +126,8 @@ export class Connection implements Established { * cannot negotiate the extension, as is a `peer` the peer never declared. */ cluster?: Cluster.Hops; + /** Whether the peer's SETUP offered MoQ Auth (draft-17+). */ + auth?: boolean; }) { this.url = url; this.discovery = discovery; @@ -136,16 +150,37 @@ export class Connection implements Established { }); } + // What the peer's connection credential earns by default: publishing anything to us, + // since we consume on demand, and subscribing to whatever we publish. + const session = this.#session; + this.#auth = new AuthSession({ + wire: + auth && authSupported(version) + ? new IetfAuthWire({ + openBi: async () => session.openBi(), + nextRequestId: () => session.nextRequestId(), + version, + }) + : undefined, + peerGrant: { + publish: new Path.Patterns([Path.Pattern.all()]), + subscribe: new Path.Patterns(publish ? [Path.Pattern.all()] : []), + }, + }); + this.#client = client; + this.#publisher = new Publisher({ quic: this.#quic, session: this.#session, publish, requiresSolicitation: solicit ?? false, cluster, + grant: this.#auth.grant, + ready: this.#auth.setupAnswered(), }); this.#solicit = solicit; this.#cluster = cluster; - this.#subscriber = new Subscriber({ session: this.#session, cluster, hidden }); + this.#subscriber = new Subscriber({ session: this.#session, cluster, hidden, grant: this.#auth.grant }); registerWire(this, { consume: (path) => this.#subscriber.consume(path) }); void this.#run(); @@ -164,6 +199,7 @@ export class Connection implements Established { this.#closed = true; + this.#auth.close(); this.#session.close(); try { @@ -175,7 +211,11 @@ export class Connection implements Established { async #run(): Promise { try { - await Promise.all([this.#runBidis(), this.#runUnis(), this.#publisher.runPublishNamespaces()]); + const tasks = [this.#runBidis(), this.#runUnis(), this.#publisher.runPublishNamespaces()]; + // Fail loud on a publication our grant never covers, once the peer has answered the + // credential we presented at setup. + if (this.#client) tasks.push(this.#publisher.runEnforce(this.#auth.setupAnswered())); + await Promise.all(tasks); } catch (err) { if (!this.#closed) { console.error("fatal error running connection", err); @@ -281,6 +321,12 @@ export class Connection implements Established { await this.#subscriber.runPublishNamespace(msg, stream); break; } + case AuthMessage.id: { + // Only a peer that negotiated MoQ Auth may send one. + if (!this.#auth.negotiated) throw new ProtocolViolation("AUTH without MoQ Auth"); + await this.#auth.serve(stream); + break; + } case Publish.id: { const msg = await Publish.decode(stream.reader, this.#session.version); await this.#subscriber.runPublish(msg, stream); diff --git a/js/net/src/ietf/index.ts b/js/net/src/ietf/index.ts index e1ebb8199d..f430591246 100644 --- a/js/net/src/ietf/index.ts +++ b/js/net/src/ietf/index.ts @@ -1,4 +1,5 @@ export * from "./adapter.ts"; +export * as Auth from "./auth.ts"; export * as Cluster from "./cluster.ts"; export * from "./connection.ts"; export * from "./control.ts"; diff --git a/js/net/src/ietf/message.ts b/js/net/src/ietf/message.ts index a767ddd413..06be4faacd 100644 --- a/js/net/src/ietf/message.ts +++ b/js/net/src/ietf/message.ts @@ -1,7 +1,8 @@ import { Reader, Writer } from "../stream.ts"; -// Encodes a message with a u16 (16-bit) size prefix as per draft-14. -export async function encode(writer: Writer, f: (w: Writer) => Promise) { +// Encodes a message with a u16 (16-bit) size prefix as per draft-14. A type `id` is written +// only once the body fits, so an oversized message leaves nothing on the stream. +export async function encode(writer: Writer, f: (w: Writer) => Promise, id?: number) { let scratch = new Uint8Array(); const temp = new Writer( @@ -44,6 +45,8 @@ export async function encode(writer: Writer, f: (w: Writer) => Promise) { throw new Error(`Message too large: ${scratch.byteLength} bytes (max 65535)`); } + if (id !== undefined) await writer.u53(id); + // Write u16 size (2 bytes, big-endian) await writer.u16(scratch.byteLength); await writer.write(scratch); diff --git a/js/net/src/ietf/parameters.ts b/js/net/src/ietf/parameters.ts index f2a6a3120b..65b1c65047 100644 --- a/js/net/src/ietf/parameters.ts +++ b/js/net/src/ietf/parameters.ts @@ -18,6 +18,8 @@ export const SetupOption = { Solicit: 0x40b5an, /** HIDDEN, from the MoQ Hidden extension. See `hidden.ts`. */ Hidden: 0x40b5cn, + /** AUTH, from the MoQ Auth extension. See `auth.ts`. */ + Auth: 0x40b60n, } as const; /// Setup Options — used in SETUP messages. diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index 2cdd94bf57..6413cc0d0d 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -1,4 +1,6 @@ -import { type Dispose, type Getter, Signal } from "@moq/signals"; +import { type Dispose, type Getter, race, Signal } from "@moq/signals"; +import type { Grant } from "../auth.ts"; +import { enforceGrant } from "../auth_session.ts"; import type * as broadcast from "../broadcast.ts"; import { controlTimeout, error, reason, StreamCode, StreamError } from "../error.ts"; import type * as group from "../group.ts"; @@ -55,6 +57,7 @@ function sameAdvert(a: Advertised | undefined, b: Advertised | undefined): boole /** PUBLISH_DONE statuses this implementation emits. Stable across drafts 14 through 19. */ const PUBLISH_DONE_STATUS = { INTERNAL_ERROR: 0x0, + UNAUTHORIZED: 0x1, TRACK_ENDED: 0x2, } as const; @@ -166,6 +169,15 @@ export class Publisher { // back came from us. `undefined` when nothing negotiated it. #advert?: Cluster.Advert; + // Our grant (MoQ Auth): only what it lets us publish is advertised and served, and a + // shrink withdraws what it no longer covers. Undefined until the peer answers, which + // allows everything. + #grant: Getter; + + // Resolves once the tokens this session presented at setup are answered, so nothing is + // advertised before the grant it would be checked against. + #ready: Promise; + /** * Creates a new Publisher instance. * @@ -177,6 +189,8 @@ export class Publisher { publish, requiresSolicitation, cluster, + grant = new Signal(undefined), + ready = Promise.resolve(), }: { /** The WebTransport session, for uni streams. */ quic: WebTransport; @@ -188,8 +202,14 @@ export class Publisher { requiresSolicitation: boolean; /** The Hop IDs the SETUP exchange settled (MoQ Cluster). */ cluster?: Cluster.Hops; + /** The union of our tokens' grants (MoQ Auth), which bounds what we publish. */ + grant?: Getter; + /** Resolves once the setup tokens are answered (MoQ Auth). */ + ready?: Promise; }) { this.#quic = quic; + this.#grant = grant; + this.#ready = ready; this.#session = session; const origin = publish && wireOf(publish); this.#advertised = origin?.advertised ?? new Signal(new Map()); @@ -210,9 +230,19 @@ export class Publisher { let broadcast: broadcast.Consumer | undefined; let refusal: { errorCode: number; reasonPhrase: string } | undefined; try { + // Serve only what our grant lets us publish. Checked before resolving, so a denied + // request never reaches the origin. + if (this.#denied(name)) { + refusal = { + errorCode: toRequestCode("unauthorized", "subscribe", version), + reasonPhrase: "not granted", + }; + } broadcast = - this.#publish && (wireOf(this.#publish).local(name) ?? (await wireOf(this.#publish).demand(name))); - if (!broadcast) { + !refusal && this.#publish + ? (wireOf(this.#publish).local(name) ?? (await wireOf(this.#publish).demand(name))) + : undefined; + if (!broadcast && !refusal) { refusal = { errorCode: toRequestCode("does_not_exist", "subscribe", version), reasonPhrase: "broadcast not found", @@ -392,11 +422,30 @@ export class Publisher { }) : Promise.resolve(); + // Losing the grant ends the subscription, leaving the session alone. + let revoke!: () => void; + const revoked = new Promise<"revoked">((resolve) => { + revoke = () => resolve("revoked"); + }); + const disposeGrant = this.#grant.subscribe(() => { + if (this.#denied(name)) revoke(); + }); + // The grant may have shrunk during setup, before this watcher existed. + if (this.#denied(name)) revoke(); + let publishError: Error | undefined; + let unauthorized = false; try { - await Promise.race([Promise.all([serving, filling]), stream.reader.closed]); + const end = await race([Promise.all([serving, filling]), stream.reader.closed, revoked]); + if (end === "revoked") { + console.info(`subscription no longer authorized: broadcast=${name} track=${track.name}`); + unauthorized = true; + unsubscribe(); + } } catch (err: unknown) { publishError = error(err); + } finally { + disposeGrant(); } console.debug(`publish done: broadcast=${name} track=${track.name}`); @@ -413,8 +462,12 @@ export class Publisher { version === Version.DRAFT_14 || version === Version.DRAFT_15 || version === Version.DRAFT_16 ? msg.requestId : undefined, - statusCode: publishError ? PUBLISH_DONE_STATUS.INTERNAL_ERROR : PUBLISH_DONE_STATUS.TRACK_ENDED, - reasonPhrase: publishError ? "internal error" : "track ended", + statusCode: unauthorized + ? PUBLISH_DONE_STATUS.UNAUTHORIZED + : publishError + ? PUBLISH_DONE_STATUS.INTERNAL_ERROR + : PUBLISH_DONE_STATUS.TRACK_ENDED, + reasonPhrase: unauthorized ? "not granted" : publishError ? "internal error" : "track ended", }); await done.encode(stream.writer, version); } catch { @@ -495,7 +548,7 @@ export class Publisher { // Reading from the filter's start drops the objects below it: they are outside // the requested range, so skipping them is not a gap. - const read = await Promise.race([hooks.readGroupFrame(group, slice.skip), stream.closed]); + const read = await race([hooks.readGroupFrame(group, slice.skip), stream.closed]); if (!read) break; next = read.sequence + 1; if (slice.until !== undefined && read.sequence >= slice.until) { @@ -608,11 +661,7 @@ export class Publisher { if (fill.until !== undefined && next >= fill.until) break; // Reading from the fill's start drops everything below it; see the same read in #runGroup. - const frame = await Promise.race([ - group.readFrameSequence({ from: Number(fill.skip) }), - stream.closed, - cancelled, - ]); + const frame = await race([group.readFrameSequence({ from: Number(fill.skip) }), stream.closed, cancelled]); if (left) throw new Error("unsubscribed before the fill finished"); if (!frame) break; next = BigInt(frame.sequence) + 1n; @@ -629,6 +678,22 @@ export class Publisher { } } + // Whether our grant excludes publishing this broadcast. No grant yet allows it. + #denied(broadcast: Path.Valid): boolean { + const grant = this.#grant.peek(); + return grant !== undefined && !grant.publish.matches(broadcast); + } + + /** + * Close the session when our origin publishes a broadcast our grant does not cover; + * see {@link enforceGrant}. + * + * @internal + */ + async runEnforce(setupAnswered: Promise): Promise { + await enforceGrant({ quic: this.#quic, advertised: this.#advertised, grant: this.#grant, setupAnswered }); + } + /** * Handles an incoming SUBSCRIBE_NAMESPACE on a bidi stream. * @@ -667,7 +732,11 @@ export class Publisher { // from the empty prefix unasked, so this stream carries only what that hid. const carries = (covered: Path.Valid) => (msg.hidden || !hiddenBelow(prefix, covered)) && - (this.#requiresSolicitation || hiddenBelow(Path.empty(), covered)); + (this.#requiresSolicitation || hiddenBelow(Path.empty(), covered)) && + !this.#denied(covered); + + // Nothing is advertised before the grant it would be checked against. + await this.#ready; // Reports whether the peer now holds the namespace: an inline entry always // lands, but a PUBLISH_NAMESPACE request can be declined. @@ -705,8 +774,15 @@ export class Publisher { // waits for its reply only notifies listeners already registered. // TODO Make a better helper within Signals. let dispose!: Dispose; - const changed = new Promise | undefined>((resolve) => { - dispose = this.#advertised.changed(resolve); + const changed = new Promise<"changed">((resolve) => { + const table = this.#advertised.changed(() => resolve("changed")); + // A grant change re-diffs the same way, withdrawing what it no longer covers. + // The loop top re-reads the table, so an ended origin still stops it there. + const grant = this.#grant.changed(() => resolve("changed")); + dispose = () => { + table(); + grant(); + }; }); const advertised = this.#advertised.peek(); @@ -767,8 +843,8 @@ export class Publisher { // Wait for the next change, or for the peer to unsubscribe. const next = await (retry - ? Promise.race([changed, stream.reader.closed, retryAfter(retry).then(() => advertised)]) - : Promise.race([changed, stream.reader.closed])); + ? race([changed, stream.reader.closed, retryAfter(retry).then(() => advertised)]) + : race([changed, stream.reader.closed])); dispose(); if (!next) break; } @@ -819,6 +895,9 @@ export class Publisher { let dispose: Dispose | undefined; try { + // Nothing is advertised before the grant it would be checked against. + if ((await Promise.race([this.#ready.then(() => "ready" as const), closed])) !== "ready") return; + // What the peer holds: keyed by path, valued by identity plus route, so a // republish diffs as withdraw-then-advertise rather than nothing. let active = new Map(); @@ -835,8 +914,15 @@ export class Publisher { // through it and leave the namespace unadvertised until something unrelated // changed. // TODO Make a better helper within Signals. - const changed = new Promise | undefined>((resolve) => { - dispose = this.#advertised.changed(resolve); + const changed = new Promise<"changed">((resolve) => { + const table = this.#advertised.changed(() => resolve("changed")); + // A grant change re-diffs the same way, withdrawing what it no longer covers. + // The loop top re-reads the table, so an ended origin still stops it there. + const grant = this.#grant.changed(() => resolve("changed")); + dispose = () => { + table(); + grant(); + }; }); const advertised = this.#advertised.peek(); @@ -847,8 +933,9 @@ export class Publisher { const updated = new Map(); for (const [covered, snap] of advertised) { - // Unasked, a hidden namespace stays off the wire (MoQ Hidden). - if (hiddenBelow(Path.empty(), covered)) continue; + // Unasked, a hidden namespace stays off the wire (MoQ Hidden), and nothing + // our grant does not cover reaches it at all (MoQ Auth). + if (hiddenBelow(Path.empty(), covered) || this.#denied(covered)) continue; updated.set(covered, snap); } @@ -895,8 +982,8 @@ export class Publisher { // Wait for the next change, which has already fired if one landed above. const next = await (retry - ? Promise.race([changed, closed, retryAfter(retry).then(() => advertised)]) - : Promise.race([changed, closed])); + ? race([changed, closed, retryAfter(retry).then(() => advertised)]) + : race([changed, closed])); dispose?.(); if (!next) break; } diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index e95246d2f3..754ec3a6ca 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -1,8 +1,9 @@ -import { Signal } from "@moq/signals"; +import { type Dispose, type Getter, race, Signal } from "@moq/signals"; import * as announce from "../announced.ts"; +import type { Grant } from "../auth.ts"; import * as broadcast from "../broadcast.ts"; import { BroadcastCache } from "../consume.ts"; -import { controlTimeout, error, ProtocolViolation, reason } from "../error.ts"; +import { controlTimeout, error, ProtocolViolation, reason, SessionCode, SessionError } from "../error.ts"; import * as netGroup from "../group.ts"; import { Cost, type Route, routesEqual, UNKNOWN_HOP } from "../hop.ts"; import { hiddenBelow, hooks, scopeCaptures, scopeHead, scopeOverlaps } from "../internal.ts"; @@ -123,6 +124,10 @@ export class Subscriber { // Whether the peer understands the HIDDEN parameter (MoQ Hidden). #hidden: boolean; + // Our grant (MoQ Auth): a subscription it stops covering is cancelled. Undefined until + // the peer answers, which allows everything. + #grant?: Getter; + /** * Creates a new Subscriber instance. * @@ -132,6 +137,7 @@ export class Subscriber { session, cluster, hidden = false, + grant, }: { /** The session abstraction for bidi streams and request IDs. */ session: Session; @@ -139,10 +145,19 @@ export class Subscriber { cluster?: Cluster.Hops; /** Whether the peer understands the HIDDEN parameter (MoQ Hidden). */ hidden?: boolean; + /** The union of our tokens' grants (MoQ Auth), which bounds what we subscribe to. */ + grant?: Getter; }) { this.#session = session; this.#cluster = cluster; this.#hidden = hidden; + this.#grant = grant; + } + + // Whether our grant no longer lets us subscribe to `broadcast`. No grant yet allows it. + #denied(broadcast: Path.Valid): boolean { + const grant = this.#grant?.peek(); + return grant !== undefined && !grant.subscribe.matches(broadcast); } /** @@ -396,7 +411,7 @@ export class Subscriber { }); // Wait for either the read loop or the announced to close - await Promise.race([readLoop, announced.closed]); + await race([readLoop, announced.closed]); // For v14/v15: send UnsubscribeNamespace before closing if (version === Version.DRAFT_14 || version === Version.DRAFT_15) { @@ -470,6 +485,12 @@ export class Subscriber { } async #runSubscribe(broadcast: Path.Valid, request: track.Request) { + const unauthorized = new SessionError(SessionCode.Unauthorized, { reason: broadcast }); + if (this.#denied(broadcast)) { + request.reject(unauthorized); + return; + } + const requestId = await this.#session.nextRequestId(); if (requestId === undefined) { request.reject(new Error("session closed")); @@ -506,7 +527,7 @@ export class Subscriber { let stream: Stream; let trackAlias: bigint; try { - const result = await Promise.race([ + const result = await race([ withTimeout( setup, SUBSCRIBE_OK_TIMEOUT_MS, @@ -568,18 +589,33 @@ export class Subscriber { return; } + let disposeGrant: Dispose | undefined; try { // Which terminal fired decides whether we owe the publisher a cancellation, so // tag them rather than racing bare promises. const publisherEnded = Symbol("publisher"); const localEnded = Symbol("local"); + const revokedEnded = Symbol("revoked"); const idle = Symbol("idle"); + // Losing the grant ends the subscription, leaving the session alone. + let revoke!: () => void; + const revoked = new Promise((resolve) => { + revoke = () => resolve(revokedEnded); + }); + disposeGrant = this.#grant?.subscribe(() => { + if (this.#denied(broadcast)) revoke(); + }); + // The grant may have shrunk during setup, before this watcher existed. + if (this.#denied(broadcast)) revoke(); + // Terminal conditions settle at most once (stream close = PublishDone, track close = - // local unsubscribe); race them once so the demand loop doesn't re-subscribe each pass. - const done = Promise.race([ + // local unsubscribe, a revoked grant); race them once so the demand loop doesn't + // re-subscribe each pass. + const done = race([ stream.reader.closed.then(() => publisherEnded), producer.closed.then(() => localEnded), + revoked, ]); // Serve until a terminal condition fires or the last local subscriber leaves. The unused @@ -587,7 +623,7 @@ export class Subscriber { // down resumes on the same stream. let terminal = localEnded; for (;;) { - const reason = await Promise.race([done, producer.unused().then(() => idle)]); + const reason = await race([done, producer.unused().then(() => idle)]); if (reason === idle && producer.closed.peek() === undefined && producer.used.peek()) continue; terminal = reason; break; @@ -597,7 +633,12 @@ export class Subscriber { // reopens the window the demand re-check above just closed, and a subscriber that // returned during it would be closed by this line. The lite subscriber closes // straight out of its loop for the same reason. - producer.close(); + if (terminal === revokedEnded) { + console.info(`subscription no longer authorized: broadcast=${broadcast} track=${request.name}`); + producer.close(unauthorized); + } else { + producer.close(); + } // The publisher already ended the request, so there is nothing to cancel. Sending // UNSUBSCRIBE here would name a request it has already torn down. @@ -613,6 +654,7 @@ export class Subscriber { `subscribe error: id=${requestId} broadcast=${broadcast} track=${request.name} error=${reason(e)}`, ); } finally { + disposeGrant?.(); // Only the owner tears down the alias metadata: a later subscription may have // reclaimed the alias and installed its own timescale. if (this.#aliases.retire(trackAlias, producer)) this.#timescales.delete(trackAlias); @@ -963,7 +1005,7 @@ export class Subscriber { track.writeGroup(producer); for (;;) { - const done = await Promise.race([stream.done(), producer.closed, track.closed]); + const done = await race([stream.done(), producer.closed, track.closed]); if (done !== false) break; const frame = await Frame.decode( diff --git a/js/net/src/lite/auth.test.ts b/js/net/src/lite/auth.test.ts index f1fb805ab1..77006495a3 100644 --- a/js/net/src/lite/auth.test.ts +++ b/js/net/src/lite/auth.test.ts @@ -1,25 +1,15 @@ import { expect, test } from "bun:test"; -import type { Getter } from "@moq/signals"; -import { type Grant, type Issued, Unsupported } from "../auth.ts"; -import { accept as acceptSession, connect as connectSession, type Established } from "../connection/index.ts"; -import { SessionCode, SessionError } from "../error.ts"; -import { createMockTransportPair, type MockTransport } from "../mock.ts"; -import { Producer as OriginProducer } from "../origin.ts"; +import { Unsupported } from "../auth.ts"; +import { SessionCode } from "../error.ts"; import * as Path from "../path.ts"; import { Reader, Writer } from "../stream.ts"; import { AuthError, AuthMessage, AuthOk, decodeAuthReplyMaybe, encodeAuthReply } from "./auth.ts"; import * as Lite from "./index.ts"; -const url = new URL("https://localhost:4443/test"); - function patterns(...prefixes: string[]): Path.Patterns { return new Path.Patterns(prefixes.map((prefix) => Path.Pattern.subtree(prefix))); } -function grant(publish: string[], subscribe: string[]): Grant { - return { publish: patterns(...publish), subscribe: patterns(...subscribe) }; -} - /** Round-trip bytes through a writer and back out of a reader. */ async function roundTrip(write: (w: Writer) => Promise): Promise { const chunks: Uint8Array[] = []; @@ -79,163 +69,3 @@ test("lite-05 carries no AUTH", async () => { roundTrip((w) => new AuthMessage(new Uint8Array()).encode(w, Lite.Version.DRAFT_05)), ).rejects.toThrow(); }); - -async function waitFor(getter: Getter, ready: (value: T) => boolean): Promise { - let value = getter.peek(); - while (!ready(value)) value = await getter.changed(); - return value; -} - -interface Pair { - client: Established; - server: Established; - transport: MockTransport; -} - -async function connect(opts: { publish?: OriginProducer; serverPublish?: OriginProducer; protocol?: string }) { - const pair = createMockTransportPair(opts.protocol ?? Lite.ALPN_06); - const [client, server] = await Promise.all([ - connectSession({ url, transport: pair.client, publish: opts.publish?.consume() }), - acceptSession({ transport: pair.server, url, publish: opts.serverPublish?.consume() }), - ]); - return { client, server, transport: pair.client } satisfies Pair; -} - -test("both sides learn their default grant", async () => { - const { client, server } = await connect({ publish: new OriginProducer() }); - - // The server consumes anything and publishes nothing. - const clientGrant = await waitFor(client.auth.grant, (g) => g !== undefined); - expect(clientGrant?.publish.equals(patterns(""))).toBe(true); - expect(clientGrant?.subscribe.size).toBe(0); - - // The client publishes, so the server may subscribe to anything. - const serverGrant = await waitFor(server.auth.grant, (g) => g !== undefined); - expect(serverGrant?.publish.equals(patterns(""))).toBe(true); - expect(serverGrant?.subscribe.equals(patterns(""))).toBe(true); - - client.close(); - server.close(); -}); - -test("a token without an acceptor reports unsupported", async () => { - const { client, server } = await connect({ publish: new OriginProducer() }); - await waitFor(client.auth.grant, (g) => g !== undefined); - await expect(client.auth.add("token")).rejects.toBeInstanceOf(Unsupported); - client.close(); - server.close(); -}); - -test("an out-of-scope broadcast closes the session and names the path", async () => { - const origin = new OriginProducer(); - const { client, server, transport } = await connect({ publish: origin }); - const requests = server.auth.requests(); - const issued: Issued[] = []; - void (async () => { - for (;;) { - const request = await requests.next(); - if (!request) break; - issued.push(request.accept(grant(["baz"], []))); - } - })(); - - await waitFor(client.auth.grant, (g) => g !== undefined); - origin.createBroadcast(Path.from("baz/ok")).announce(); - origin.createBroadcast(Path.from("foo/bar")).announce(); - - const info = await transport.closed; - expect(info.closeCode).toBe(SessionCode.Unauthorized); - expect(info.reason).toBe("unauthorized: foo/bar"); - server.close(); -}); - -test("a revoked grant withdraws its broadcasts without closing the session", async () => { - const origin = new OriginProducer(); - const { client, server, transport } = await connect({ publish: origin }); - const requests = server.auth.requests(); - const issued: Issued[] = []; - void (async () => { - for (;;) { - const request = await requests.next(); - if (!request) break; - issued.push(request.accept(grant(["a"], []))); - } - })(); - - await waitFor(client.auth.grant, (g) => g !== undefined); - origin.createBroadcast(Path.from("a/x")).announce(); - - const announced = server.announced(); - const first = await announced.next(); - expect(first?.prefix).toBe(Path.from("a/x")); - expect(first?.kind).toBe("announced"); - - issued[0]?.revoke(SessionCode.Unauthorized, "expired"); - const second = await announced.next(); - expect(second?.prefix).toBe(Path.from("a/x")); - expect(second?.kind).toBe("retracted"); - - // The union is empty but still a grant, and a new token restores it. - const empty = await waitFor(client.auth.grant, (g) => g !== undefined && g.publish.size === 0); - expect(empty?.subscribe.size).toBe(0); - const token = await client.auth.add("again"); - expect(token.grant.peek()?.publish.equals(patterns("a"))).toBe(true); - const third = await announced.next(); - expect(third?.kind).toBe("announced"); - - let closed = false; - void transport.closed.then(() => { - closed = true; - }); - await new Promise((resolve) => setTimeout(resolve, 10)); - expect(closed).toBe(false); - - announced.close(); - client.close(); - server.close(); -}); - -test("a refused token surfaces the acceptor's code and reason", async () => { - const { client, server } = await connect({ publish: new OriginProducer() }); - const requests = server.auth.requests(); - void (async () => { - for (;;) { - const request = await requests.next(); - if (!request) break; - if (request.token.byteLength === 0) request.accept(grant([], [])); - else request.reject(SessionCode.Unauthorized, "bad signature"); - } - })(); - - const err = await client.auth.add("forged").catch((e: unknown) => e); - expect(err).toBeInstanceOf(SessionError); - expect((err as SessionError).code).toBe(SessionCode.Unauthorized); - client.close(); - server.close(); -}); - -test("a refused setup token grants nothing rather than everything", async () => { - const { client, server } = await connect({ publish: new OriginProducer() }); - const requests = server.auth.requests(); - void (async () => { - for (;;) { - const request = await requests.next(); - if (!request) break; - request.reject(SessionCode.Unauthorized, "bad credential"); - } - })(); - - const empty = await waitFor(client.auth.grant, (g) => g !== undefined); - expect(empty?.publish.size).toBe(0); - expect(empty?.subscribe.size).toBe(0); - client.close(); - server.close(); -}); - -test("older versions have no grant", async () => { - const { client, server } = await connect({ publish: new OriginProducer(), protocol: Lite.ALPN_05 }); - expect(client.auth.grant.peek()).toBeUndefined(); - await expect(client.auth.add("token")).rejects.toBeInstanceOf(Unsupported); - client.close(); - server.close(); -}); diff --git a/js/net/src/lite/auth.ts b/js/net/src/lite/auth.ts index 747c9c3530..8a6bf2bd08 100644 --- a/js/net/src/lite/auth.ts +++ b/js/net/src/lite/auth.ts @@ -1,15 +1,6 @@ -import { type Getter, Signal } from "@moq/signals"; -import { - type Auth as AuthApi, - type Grant, - grantsEqual, - type Issued, - type Request, - type Requests, - type Token, - Unsupported, -} from "../auth.ts"; -import { error, SessionCode, SessionError, StreamCode, StreamError } from "../error.ts"; +import { Unsupported } from "../auth.ts"; +import type { AuthWire, WireGrant, WireReply } from "../auth_session.ts"; +import { type SessionCode, SessionError } from "../error.ts"; import * as Path from "../path.ts"; import { type Reader, Stream, type Writer } from "../stream.ts"; import * as Message from "./message.ts"; @@ -179,361 +170,48 @@ export async function decodeAuthReplyMaybe(r: Reader, version: Version): Promise } } -function union(grants: Iterable): Grant { - const publish = new Path.Patterns(); - const subscribe = new Path.Patterns(); - let expires: number | undefined; - for (const grant of grants) { - for (const pattern of grant.publish) publish.insert(pattern); - for (const pattern of grant.subscribe) subscribe.insert(pattern); - // The earliest expiry is when the union next shrinks. - if (grant.expires !== undefined) expires = Math.min(expires ?? grant.expires, grant.expires); - } - return { publish, subscribe, expires }; -} - -function cancel(): StreamError { - return new StreamError(StreamCode.Cancel, { message: "cancel" }); -} - -/** One token this side presented. */ -class Presented implements Token { - readonly grant = new Signal(undefined); - readonly closed: Promise; - readonly answered: Promise; - readonly setup: boolean; - readonly token: Uint8Array; - - stream?: Stream; - withdrawn = false; - isAnswered = false; - ended = false; - - #close!: (err: Error | null) => void; - #answer!: () => void; - #refuse!: (err: Error) => void; - - constructor(token: Uint8Array, setup: boolean) { - this.token = token; - this.setup = setup; - this.closed = new Promise((resolve) => { - this.#close = resolve; - }); - this.answered = new Promise((resolve, reject) => { - this.#answer = resolve; - this.#refuse = reject; - }); - // A caller that never awaits the answer must not see an unhandled rejection. - this.answered.catch(() => void 0); - } - - answer() { - if (this.isAnswered) return; - this.isAnswered = true; - this.#answer(); - } - - end(err: Error | null) { - if (this.ended) return; - this.ended = true; - this.grant.set(undefined); - if (!this.isAnswered) { - this.isAnswered = true; - this.#refuse(err ?? new Error("withdrawn")); - } - this.#close(err); - } - - close() { - if (this.withdrawn) return; - this.withdrawn = true; - // The stream's loop notices and ends the token; one still opening checks on arrival. - this.stream?.abort(cancel()); - } -} - -/** The peer's token, answered by the application. */ -class PeerRequest implements Request { - readonly token: Uint8Array; - #issued: IssuedGrant; - #answered = false; - - constructor(token: Uint8Array, issued: IssuedGrant) { - this.token = token; - this.#issued = issued; - } - - accept(grant: Grant): Issued { - if (this.#answered) throw new Error("already answered"); - this.#answered = true; - this.#issued.update(grant); - return this.#issued; - } - - reject(code: SessionCode, reason: string): void { - if (this.#answered) throw new Error("already answered"); - this.#answered = true; - this.#issued.revoke(code, reason); - } -} - -/** Our side of one of the peer's tokens: the grant we issued and its stream. */ -class IssuedGrant implements Issued { - readonly closed: Promise; - #stream: Stream; - #version: Version; - #writes = Promise.resolve(); - #done = false; - - constructor(stream: Stream, version: Version) { - this.#stream = stream; - this.#version = version; - // The presenter withdraws by closing or cancelling its side. - this.closed = stream.reader.closed.then( - () => null, - (err: unknown) => (err instanceof StreamError && err.code === StreamCode.Cancel ? null : error(err)), - ); - } - - #write(reply: AuthReply) { - this.#writes = this.#writes - .then(() => encodeAuthReply(this.#stream.writer, reply, this.#version)) - .catch((err: unknown) => { - // The peer already closed the stream: nothing left to tell it. - if (err instanceof StreamError) return; - // This wire carries prefixes only, so a pattern grant cannot be told, only - // withheld: reset the stream, which the presenter reads as unsupported rather - // than refused. Never widen it. Any other reply that fails to encode resets - // the same way. - if (!(err instanceof Unsupported)) console.warn("auth reply not sent", err); - this.#done = true; - this.#stream.writer.reset(err); - }); - } - - update(grant: Grant): void { - if (this.#done) return; - const expires = grant.expires === undefined ? undefined : grant.expires - Date.now(); - this.#write(new AuthOk(grant.publish, grant.subscribe, expires)); - } - - revoke(code: SessionCode, reason: string): void { - if (this.#done) return; - this.#write(new AuthError(code, reason)); - this.close(); - } - - close(): void { - if (this.#done) return; - this.#done = true; - this.#writes = this.#writes.then(() => this.#stream.writer.close()); - } -} - -/** The peer's tokens, queued for the application. */ -class RequestQueue implements Requests { - #queue: PeerRequest[] = []; - #waiters: ((request: PeerRequest | undefined) => void)[] = []; - #closed = false; - - push(request: PeerRequest): boolean { - if (this.#closed) return false; - const waiter = this.#waiters.shift(); - if (waiter) waiter(request); - else this.#queue.push(request); - return true; - } - - next(): Promise { - const next = this.#queue.shift(); - if (next || this.#closed) return Promise.resolve(next); - return new Promise((resolve) => this.#waiters.push(resolve)); - } - - close(): void { - this.#closed = true; - for (const request of this.#queue.splice(0)) { - request.reject(SessionCode.Unauthorized, "not accepting tokens"); - } - for (const waiter of this.#waiters.splice(0)) waiter(undefined); - } -} - -/** Constructor options for {@link AuthSession}. @internal */ -export interface AuthSessionProps { - quic: WebTransport; - version: Version; - /** What the default acceptor grants the peer's connection credential. */ - peerGrant: Grant; -} - -/** - * A lite session's tokens and grants: presents ours, one AUTH stream each, and answers - * the peer's. - * - * @internal - */ -export class AuthSession implements AuthApi { +/** The moq-lite binding of the token lifecycle: one Auth Stream per token. @internal */ +export class LiteAuthWire implements AuthWire { #quic: WebTransport; #version: Version; - #peerGrant: Grant; - - #union = new Signal(undefined); - // The peer replied to some token, so the union is known even when empty. - #replied = false; - #tokens = new Set(); - #setupPending = new Signal(0); - #acceptor: "undecided" | "default" | RequestQueue = "undecided"; - #closed = false; - - // Whoever answers the peer's tokens is decided once, after the task that established - // the session: an app that calls requests() as soon as connect/accept resolves always - // wins, however quickly the peer's first token arrives. - #decided = new Promise((resolve) => setTimeout(resolve, 0)); - - constructor({ quic, version, peerGrant }: AuthSessionProps) { + + constructor(quic: WebTransport, version: Version) { this.#quic = quic; this.#version = version; - this.#peerGrant = peerGrant; - - // Present the connection's own credential right away, so both sides learn their - // grant without waiting on the app. - if (hasAuth(version)) this.#present(new Uint8Array(), true); - } - - get grant(): Getter { - return this.#union; - } - - async add(token: string | Uint8Array): Promise { - if (!hasAuth(this.#version) || this.#closed) throw new Unsupported(); - const bytes = typeof token === "string" ? new TextEncoder().encode(token) : token; - const presented = this.#present(bytes, false); - await presented.answered; - return presented; - } - - requests(): Requests { - if (this.#acceptor !== "undecided") throw new Error("auth requests already taken or answered by default"); - const queue = new RequestQueue(); - if (!hasAuth(this.#version)) queue.close(); - this.#acceptor = queue; - return queue; } - /** Resolves once every token the session presented at setup has its first reply. */ - async setupAnswered(): Promise { - while (this.#setupPending.peek() > 0) await this.#setupPending.changed(); + async present(token: Uint8Array): Promise { + const stream = await Stream.open(this.#quic); + await stream.writer.u53(StreamId.Auth); + await new AuthMessage(token).encode(stream.writer, this.#version); + return stream; } - /** Answer one of the peer's AUTH streams, for the life of its token. */ - async serve(stream: Stream): Promise { - const msg = await AuthMessage.decode(stream.reader, this.#version); - await this.#decided; - if (this.#acceptor === "undecided") this.#acceptor = "default"; - - const issued = new IssuedGrant(stream, this.#version); - if (this.#acceptor instanceof RequestQueue) { - const request = new PeerRequest(msg.token, issued); - if (!this.#acceptor.push(request)) request.reject(SessionCode.Unauthorized, "not accepting tokens"); - } else if (msg.token.byteLength > 0) { - // Only the connection's own credential has a default answer. Resetting reads as - // unsupported to the presenter, the same as a peer that predates AUTH. - throw new Unsupported("no acceptor for tokens"); - } else { - issued.update(this.#peerGrant); + async read(stream: Stream): Promise { + const reply = await decodeAuthReplyMaybe(stream.reader, this.#version); + if (!reply) return undefined; + if (reply instanceof AuthOk) { + return { grant: { publish: reply.publish, subscribe: reply.subscribe, expires: reply.expires } }; } - - await issued.closed; - issued.close(); - } - - /** End the session: fail every pending token and close the requests. */ - close() { - if (this.#closed) return; - this.#closed = true; - for (const token of this.#tokens) token.end(new Error("session closed")); - this.#tokens.clear(); - if (this.#acceptor instanceof RequestQueue) this.#acceptor.close(); + return { refused: new SessionError(reply.code as SessionCode, { reason: reply.reason }) }; } - #present(token: Uint8Array, setup: boolean): Presented { - const presented = new Presented(token, setup); - this.#tokens.add(presented); - if (setup) this.#setupPending.update((n) => n + 1); - void this.#run(presented); - return presented; + /** The stream type is already consumed by the dispatcher. */ + async accept(stream: Stream): Promise { + const msg = await AuthMessage.decode(stream.reader, this.#version); + return msg.token; } - async #run(token: Presented) { - let result: Error | null = null; - try { - const stream = await Stream.open(this.#quic); - token.stream = stream; - if (token.withdrawn) throw cancel(); - - await stream.writer.u53(StreamId.Auth); - await new AuthMessage(token.token).encode(stream.writer, this.#version); - - for (;;) { - const reply = await decodeAuthReplyMaybe(stream.reader, this.#version); - if (!reply) { - // The peer ended the grant without revoking it, or closed without ever - // answering. - result = token.isAnswered ? null : new Unsupported(); - break; - } - if (reply instanceof AuthOk) { - const expires = reply.expires === undefined ? undefined : Date.now() + reply.expires; - token.grant.set({ publish: reply.publish, subscribe: reply.subscribe, expires }); - this.#replied = true; - this.#answered(token); - this.#recompute(); - continue; - } - console.warn(`auth token refused: code=${reply.code} reason=${reply.reason}`); - // A refused setup token leaves an empty union, not an unknown (unrestricted) one. - this.#replied = true; - result = new SessionError(reply.code as SessionCode, { reason: reply.reason }); - stream.close(); - break; - } - } catch (err: unknown) { - if (token.withdrawn) { - result = null; - } else if (!token.isAnswered && err instanceof StreamError) { - // A peer that predates AUTH resets a stream type it does not know. - result = new Unsupported(); - } else { - result = error(err); - if (!this.#closed) console.warn("auth token ended", result); - } - } - - // A token that ends unanswered counts as answered for enforcement: its refusal is - // the reply. - const unanswered = !token.isAnswered; - token.end(result); - if (unanswered && token.setup) this.#setupPending.update((n) => n - 1); - this.#tokens.delete(token); - this.#recompute(); + async grant(stream: Stream, grant: WireGrant): Promise { + await encodeAuthReply(stream.writer, new AuthOk(grant.publish, grant.subscribe, grant.expires), this.#version); } - #answered(token: Presented) { - if (token.isAnswered) return; - token.answer(); - if (token.setup) this.#setupPending.update((n) => n - 1); + async refuse(stream: Stream, code: SessionCode, reason: string): Promise { + await encodeAuthReply(stream.writer, new AuthError(code, reason), this.#version); } - #recompute() { - const granted: Grant[] = []; - for (const token of this.#tokens) { - const grant = token.grant.peek(); - if (grant) granted.push(grant); - } - // Undefined until the first reply; an empty union afterwards grants nothing. - if (granted.length === 0 && !this.#replied) return; - const next = union(granted); - if (!grantsEqual(next, this.#union.peek())) this.#union.set(next); + /** Resetting reads as unsupported to the presenter, the same as a peer that predates AUTH. */ + async unsupported(stream: Stream, reason: string): Promise { + stream.writer.reset(new Unsupported(reason)); } } diff --git a/js/net/src/lite/connection.ts b/js/net/src/lite/connection.ts index 38964015ed..be9a90c508 100644 --- a/js/net/src/lite/connection.ts +++ b/js/net/src/lite/connection.ts @@ -1,6 +1,7 @@ import { type Getter, Signal } from "@moq/signals"; import type * as announce from "../announced.ts"; import type * as Auth from "../auth.ts"; +import { AuthSession } from "../auth_session.ts"; import type { Established } from "../connection/established.ts"; import { type Probe, type Stats, transportStats } from "../connection/stats.ts"; import { type Transport, transportOf } from "../connection/transport.ts"; @@ -11,7 +12,7 @@ import * as Path from "../path.ts"; import { type Reader, Readers, Stream, Writer } from "../stream.ts"; import { registerWire } from "../wire.ts"; import { AnnounceRequest } from "./announce.ts"; -import { AuthSession } from "./auth.ts"; +import { LiteAuthWire } from "./auth.ts"; import { Fetch } from "./fetch.ts"; import { Goaway } from "./goaway.ts"; import { Group } from "./group.ts"; @@ -141,8 +142,7 @@ export class Connection implements Established { // What the peer's connection credential earns by default: publishing anything to us, // since we consume on demand, and subscribing to whatever we publish. this.#auth = new AuthSession({ - quic, - version, + wire: hasAuth(version) ? new LiteAuthWire(quic, version) : undefined, peerGrant: { publish: new Path.Patterns([Path.Pattern.all()]), subscribe: new Path.Patterns(publish ? [Path.Pattern.all()] : []), diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index 9c453cddd9..ed7a7a6d4e 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -1,7 +1,8 @@ -import { type Dispose, type Getter, Signal } from "@moq/signals"; +import { type Dispose, type Getter, race, Signal } from "@moq/signals"; import type { Grant } from "../auth.ts"; +import { enforceGrant } from "../auth_session.ts"; import type * as broadcast from "../broadcast.ts"; -import { closeReason, error, NotFound, reason, SessionCode, SessionError, StreamCode, StreamError } from "../error.ts"; +import { error, NotFound, reason, SessionCode, SessionError, StreamCode, StreamError } from "../error.ts"; import type * as group from "../group.ts"; import { type Hop, type Route, routesEqual } from "../hop.ts"; import { hiddenBelow, hooks } from "../internal.ts"; @@ -226,19 +227,23 @@ class SubscriptionControls { /** Returns false when peer departure supersedes a blocked response write. */ async response(pending: Promise): Promise { - const result = await Promise.race([ + // `#ended` lives as long as the stream, so it is raced as-is rather than mapped per call. + const result = await race([ pending.then( () => ({ kind: "sent" }) as const, (err: unknown) => ({ kind: "error", error: error(err) }) as const, ), - this.#ended.then((end) => ({ kind: "ended", end }) as const), + this.#ended, ]); - if (result.kind === "sent") return true; - if (result.kind === "error") throw result.error; - // Promise.race leaves the blocked encode running, so reset the writable half too. - this.#writer.reset(result.end ?? new StreamError(StreamCode.Cancel, { message: "cancel" })); - if (result.end) throw result.end; + if (result !== null && !(result instanceof Error)) { + if (result.kind === "sent") return true; + throw result.error; + } + + // The race leaves the blocked encode running, so reset the writable half too. + this.#writer.reset(result ?? new StreamError(StreamCode.Cancel, { message: "cancel" })); + if (result) throw result; return false; } @@ -533,9 +538,9 @@ export class Publisher { } for (;;) { - const woke = await Promise.race([changed, stream.reader.closed.then(() => "closed" as const)]); + const woke = await race([changed, stream.reader.closed]); dispose(); - if (woke === "closed") break; + if (woke !== "changed") break; // Re-arm before reading, so an advertise that lands while we write is not lost. changed = arm(); @@ -1023,14 +1028,14 @@ export class Publisher { // as `startFrame`. Skipping the head here is the only thing keeping those numbers // honest; a group that ends before we reach it can't be served at all. for (let i = 0; i < startFrame; i++) { - if (!(await Promise.race([group.readFrame(), stream.closed]))) { + if (!(await race([group.readFrame(), stream.closed]))) { throw new Error(`fetch group ended at frame ${i}, before the requested start ${startFrame}`); } } let prevTs = 0n; for (let index = startFrame; endFrame === undefined || index <= endFrame; index++) { - const frame = await Promise.race([group.readFrame(), stream.closed]); + const frame = await race([group.readFrame(), stream.closed]); if (!frame) break; const ts = BigInt(Math.round(frame.timestamp.as(timescale))); @@ -1094,7 +1099,7 @@ export class Publisher { let reached = startFrame === 0; for (;;) { - const read = await Promise.race([hooks.readGroupFrame(group), stream.closed]); + const read = await race([hooks.readGroupFrame(group), stream.closed]); if (!read) { // The group ended before the frame the subscriber asked to start // at, so this publisher can't serve the range at all. FINning here @@ -1176,7 +1181,7 @@ export class Publisher { const timeout = new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), PROBE_INTERVAL), ); - const result = await Promise.race([timeout, stream.reader.closed]); + const result = await race([timeout, stream.reader.closed]); if (result !== "timeout") break; // The two fields are independent on the wire, each using 0 for @@ -1231,65 +1236,14 @@ export class Publisher { } /** - * Close the session when our origin publishes a broadcast our grant does not cover, - * instead of leaving it to wait for a subscription that never comes. - * - * Starts once the tokens the session presented at setup are answered, then checks each - * broadcast when it first appears. A grant that later shrinks withdraws what it no longer - * covers (see {@link runAnnounce}) without closing anything: the grant is read before the - * table, so a revocation is never mistaken for a new unauthorized publication. + * Close the session when our origin publishes a broadcast our grant does not cover; + * see {@link enforceGrant}. * * @internal */ async runEnforce(setupAnswered: Promise): Promise { - const closed = this.#quic.closed.then( - () => "closed" as const, - () => "closed" as const, - ); - if ((await Promise.race([setupAnswered.then(() => "ready" as const), closed])) === "closed") return; - - // Every broadcast admitted so far that is still published. - const live = new Set(); - for (;;) { - let dispose: Dispose = () => {}; - const woke = new Promise<"changed">((resolve) => { - const table = this.#advertised.changed(() => resolve("changed")); - const grant = this.#grant?.changed(() => resolve("changed")); - dispose = () => { - table(); - grant?.(); - }; - }); - - const grant = this.#grant?.peek(); - const table = this.#advertised.peek(); - if (!table) { - dispose(); - return; - } - if (grant) { - for (const path of live) { - if (!table.has(path)) live.delete(path); - } - for (const path of table.keys()) { - if (live.has(path)) continue; - if (!grant.publish.matches(path)) { - console.error(`publishing outside our grant; closing the session: broadcast=${path}`); - this.#quic.close({ - closeCode: SessionCode.Unauthorized, - reason: closeReason(`unauthorized: ${path}`), - }); - dispose(); - return; - } - live.add(path); - } - } - - const why = await Promise.race([woke, closed]); - dispose(); - if (why === "closed") return; - } + if (!this.#grant) return; + await enforceGrant({ quic: this.#quic, advertised: this.#advertised, grant: this.#grant, setupAnswered }); } close() { diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index cecd054ec3..f16480c707 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -1,4 +1,4 @@ -import { type Getter, Signal } from "@moq/signals"; +import { type Getter, race, Signal } from "@moq/signals"; import * as announce from "../announced.ts"; import type { Grant } from "../auth.ts"; import * as broadcast from "../broadcast.ts"; @@ -283,7 +283,7 @@ export class Subscriber { // Receive announce updates (for Draft03, this includes initial state) for (;;) { - const announce = await Promise.race([ + const announce = await race([ decodeAnnounceBroadcastMaybe(stream.reader, this.version), announced.closed, ]); @@ -598,14 +598,14 @@ export class Subscriber { // once; race them into one stable promise so the demand loop doesn't re-subscribe each pass. const terminal: PromiseLike[] = [closed, producer.closed]; if (subscriptionUpdates !== undefined) terminal.push(subscriptionUpdates); - const done = Promise.race(terminal); + const done = race(terminal); // Serve until a terminal condition fires or the last local subscriber leaves. The unused // wake is level-triggered: re-check demand so a subscriber that returns before we tear // down (e.g. a quickly unmuted tile) resumes on the same subscription. const idle = Symbol("idle"); for (;;) { - const reason = await Promise.race([done, producer.unused().then(() => idle)]); + const reason = await race([done, producer.unused().then(() => idle)]); if (reason === idle && producer.closed.peek() === undefined && producer.used.peek()) continue; break; } @@ -783,14 +783,13 @@ export class Subscriber { // Serve until the stream FINs, the group closes, or every reader leaves. A group can // stay open indefinitely (a catalog or JSON stream), so an abandoned fetch is stopped by - // demand, not by the stream ending. `closed` and `unused` are watched across frames as - // stable promises (not re-subscribed to the signals each frame); the unused check is - // level-triggered, so a coalesced fetch that arrives before we cancel re-arms and resumes. + // demand, not by the stream ending. `unused` is watched across frames as one stable + // promise; the check is level-triggered, so a coalesced fetch that arrives before we + // cancel re-arms and resumes. const idle = Symbol("idle"); - const closed = Promise.resolve(group.closed); let unused = group.unused().then(() => idle); for (;;) { - const done = await Promise.race([stream.reader.done(), closed, unused]); + const done = await race([stream.reader.done(), group.closed, unused]); if (done === idle) { if (!group.isClosed && group.used.peek()) { unused = group.unused().then(() => idle); @@ -820,7 +819,7 @@ export class Subscriber { // Drains SUBSCRIBE_START/END/DROP on the subscribe stream until FIN (lite-05+). // The resolved range is informational here; the producer already orders groups. // Resolves (never rejects) on FIN or on the stream being reset out from under it, - // so it's safe to drop from a Promise.race without an unhandled rejection. + // so it's safe to drop from a race without an unhandled rejection. async #drainResponses(stream: Stream): Promise { try { for (;;) { @@ -836,7 +835,7 @@ export class Subscriber { * Send SUBSCRIBE_UPDATE messages whenever the track's aggregate subscription changes. * * Resolves cleanly when the stream or track closes, so the caller can include - * this in Promise.race without leaving a dangling pending write that would + * this in a race without leaving a dangling pending write that would * become an unhandled rejection if the user calls update after close. * * Peeks the signal at the top of every iteration so that updates which landed @@ -850,7 +849,7 @@ export class Subscriber { msg: Subscribe, stream: Stream, ): Promise { - const stopped: Promise = Promise.race([track.closed, stream.reader.closed]).then(() => null); + const stopped: Promise = race([track.closed, stream.reader.closed]).then(() => null); let lastSent: track.Subscription = { priority: msg.priority, maxAge: Time.Milli(msg.maxAge), @@ -864,7 +863,7 @@ export class Subscriber { const current = track.subscription.peek(); if (current === undefined || this.#sameSubscription(current, lastSent)) { // Nothing new to send; wait for a change or termination. - const next = await Promise.race([track.subscription.changed(), stopped]); + const next = await race([track.subscription.changed(), stopped]); if (next === null) return; continue; } @@ -941,7 +940,7 @@ export class Subscriber { let prevTs = 0n; for (;;) { - const done = await Promise.race([stream.done(), track.closed, producer.closed]); + const done = await race([stream.done(), track.closed, producer.closed]); if (done !== false) break; let timestamp: Time.Timestamp; diff --git a/js/net/src/retention.test.ts b/js/net/src/retention.test.ts new file mode 100644 index 0000000000..3325f9489d --- /dev/null +++ b/js/net/src/retention.test.ts @@ -0,0 +1,157 @@ +import { afterAll, afterEach, expect, spyOn, test } from "bun:test"; +import { type Dispose, Once } from "@moq/signals"; +import { accept, connect } from "./connection/index.ts"; +import type * as Group from "./group.ts"; +import * as Ietf from "./ietf/index.ts"; +import * as Lite from "./lite/index.ts"; +import { createMockTransportPair } from "./mock.ts"; +import { Producer as OriginProducer } from "./origin.ts"; +import * as Path from "./path.ts"; +import { Timestamp } from "./time.ts"; +import { wireOf } from "./wire.ts"; + +// A subscription races its track's `closed` once per frame. These count the listeners left on +// every Once that is still pending, so a per-frame leak shows up as a slope in frames. + +const url = new URL("https://localhost:4443/test"); + +// Listeners attached to each Once. A `then` on a pending Once holds its listener until it +// settles, so it is never released here. +const attached = new Map, Set>(); + +function attach(once: Once): Dispose { + const token = {}; + let set = attached.get(once); + if (!set) { + set = new Set(); + attached.set(once, set); + } + set.add(token); + const owner = set; + return () => owner.delete(token); +} + +function pendingListeners(): number { + let count = 0; + for (const [once, set] of attached) { + if (once.peek() === undefined) count += set.size; + } + return count; +} + +const proto = Once.prototype as Once; +const { subscribe, changed, then } = proto; +const spies = [ + spyOn(proto, "subscribe").mockImplementation(function (this: Once, fn) { + const release = attach(this); + const dispose = subscribe.call(this, fn); + return () => { + release(); + dispose(); + }; + }), + spyOn(proto, "changed").mockImplementation(function (this: Once, fn?: (value: unknown) => void) { + if (!fn) { + attach(this); + return (changed as () => Promise).call(this); + } + const release = attach(this); + const dispose = changed.call(this, fn); + return () => { + release(); + dispose(); + }; + } as typeof proto.changed), + spyOn(proto, "then").mockImplementation(function (this: Once, onFulfilled, onRejected) { + if (this.peek() === undefined) attach(this); + return then.call(this, onFulfilled, onRejected); + } as typeof proto.then), +]; + +afterEach(() => attached.clear()); +afterAll(() => { + for (const spy of spies) spy.mockRestore(); +}); + +async function session(alpn: string) { + const pair = createMockTransportPair(alpn); + const origin = new OriginProducer(); + const [client, server] = await Promise.all([ + connect({ url, transport: pair.client }), + accept({ transport: pair.server, url, publish: origin.consume() }), + ]); + + const broadcast = origin.createBroadcast(Path.from("test")); + broadcast.announce(); + const producer = broadcast.createTrack("video"); + + const remote = wireOf(client).consume(Path.from("test")); + const track = remote.track("video").subscribe().ordered(); + + const close = () => { + track.close(); + remote.close(); + broadcast.close(); + client.close(); + server.close(); + }; + return { producer, track, close }; +} + +function frame(i: number): Group.Frame { + return { payload: new Uint8Array([i & 0xff]), timestamp: Timestamp.fromMillis(i) }; +} + +async function readFrames(group: Group.Consumer, count: number): Promise { + for (let i = 0; i < count; i++) { + if (!(await group.readFrame())) throw new Error(`group ended after ${i} frames`); + } +} + +for (const [name, alpn] of [ + ["lite-03", Lite.ALPN_03], + ["lite-05", Lite.ALPN_05], + ["ietf-19", Ietf.ALPN.DRAFT_19], +]) { + test(`${name}: the frames of one long group leave no listener behind`, async () => { + const { producer, track, close } = await session(alpn); + const group = producer.appendGroup(); + + group.writeFrame(frame(0)); + const consumer = await track.nextGroup(); + if (!consumer) throw new Error("no group"); + await readFrames(consumer, 1); + + for (let i = 1; i < 50; i++) group.writeFrame(frame(i)); + await readFrames(consumer, 49); + const before = pendingListeners(); + + for (let i = 50; i < 1050; i++) group.writeFrame(frame(i)); + await readFrames(consumer, 1000); + expect(pendingListeners() - before).toBeLessThan(10); + + close(); + }); + + test(`${name}: single-frame groups leave no listener behind`, async () => { + const { producer, track, close } = await session(alpn); + + const send = async (count: number, offset: number) => { + for (let i = 0; i < count; i++) { + const group = producer.appendGroup(); + group.writeFrame(frame(offset + i)); + group.close(); + const consumer = await track.nextGroup(); + if (!consumer) throw new Error("no group"); + await readFrames(consumer, 1); + } + }; + + await send(50, 0); + const before = pendingListeners(); + await send(200, 50); + expect(pendingListeners() - before).toBeLessThan(10); + + close(); + }); +} diff --git a/js/net/src/stream.test.ts b/js/net/src/stream.test.ts index 7d6bf130c9..8a4dc2773f 100644 --- a/js/net/src/stream.test.ts +++ b/js/net/src/stream.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from "bun:test"; +import { expect, spyOn, test } from "bun:test"; import { FrameTooLarge, GroupTooLarge, @@ -561,6 +561,22 @@ test("tryOpen gives up when cancelled, resetting a stream that opens afterwards" await aborted; }); +test("tryOpen rethrows a rejected cancel, resetting a stream that opens afterwards", async () => { + const { quic, freeSlot, aborted } = stalledTransport(); + + let fail!: (err: Error) => void; + const cancelled = new Promise((_, reject) => { + fail = reject; + }); + + const opening = Writer.tryOpen(quic, { cancel: cancelled }); + fail(new Error("stop sending")); + await expect(opening).rejects.toThrow("stop sending"); + + freeSlot(); + await aborted; +}); + // Without a deadline a peer that withholds stream credit while keeping the subscription // open queues work forever, since waitUntilAvailable never rejects. test("tryOpen gives up when the peer never frees a slot", async () => { @@ -581,6 +597,21 @@ test("tryOpen returns the stream when a slot is available", async () => { expect(await opening).toBeInstanceOf(Writer); }); +// A subscription hands the same `cancel` to every group it opens, so each open must not leave a +// reaction behind on it for the life of the subscription. +test("tryOpen shares one reaction on a cancel reused across opens", async () => { + const quic = { + createUnidirectionalStream: async () => new WritableStream(), + } as unknown as WebTransport; + + const cancel = new Promise(() => {}); + const reactions = spyOn(cancel, "then"); + for (let i = 0; i < 100; i++) { + expect(await Writer.tryOpen(quic, { cancel })).toBeInstanceOf(Writer); + } + expect(reactions).toHaveBeenCalledTimes(1); +}); + test("open waits for a stream slot instead of rejecting once the peer's limit is full", async () => { const options: unknown[] = []; const quic = { diff --git a/js/net/src/stream.ts b/js/net/src/stream.ts index d043014158..eaf3e8ab6d 100644 --- a/js/net/src/stream.ts +++ b/js/net/src/stream.ts @@ -1,3 +1,4 @@ +import { race } from "@moq/signals"; import { fromTransport, StreamCode, StreamError, toStreamCode, toTransport } from "./error.ts"; import type { IetfVersion } from "./ietf/version.ts"; import { Version } from "./ietf/version.ts"; @@ -100,8 +101,8 @@ export interface OpenOptions { /** Options for {@link Writer.tryOpen}. */ export interface TryOpenOptions extends OpenOptions { - /** Give up once this settles, however it settles. */ - cancel: Promise; + /** Give up once this resolves. */ + cancel: Promise; } export class Stream { @@ -523,27 +524,29 @@ export class Writer { * an over-limit open instead of rejecting it. */ static async tryOpen(quic: WebTransport, options: TryOpenOptions): Promise { - // A rejected `cancel` (STOP_SENDING) means the peer is gone too, so both settle - // paths mean "give up" and neither is left unhandled. Built before the open, and - // raced ahead of it, so an already-cancelled caller wins even against a slot that - // is free right now. - const cancelled = options.cancel.then( - () => undefined, - () => undefined, - ); + // Raced ahead of the open, so an already-cancelled caller wins even against a slot + // that is free right now. `race` rather than `Promise.race`: the caller shares one + // `cancel` across every group of a subscription, which must not gain a reaction per call. const open = Writer.open(quic, options); + // Resets a stream that opens after we gave up; a no-op if the open itself failed. + const abandon = () => { + const abandoned = new Error("abandoned waiting for a stream slot"); + open.then((w) => w.reset(abandoned)).catch(() => void 0); + }; + try { - const stream = await Promise.race([cancelled, open]); + const stream = await race([options.cancel, open]); if (stream) return stream; } catch (err: unknown) { // open already discarded the late stream on its way out. - if (!(err instanceof TimeoutError)) throw err; - return undefined; + if (err instanceof TimeoutError) return undefined; + // A rejected `cancel` still leaves the open pending. + abandon(); + throw err; } - const abandoned = new Error("abandoned waiting for a stream slot"); - open.then((w) => w.reset(abandoned)).catch(() => void 0); + abandon(); return undefined; } } diff --git a/js/publish/src/audio/capture.test.ts b/js/publish/src/audio/capture.test.ts index 53526249ec..dbdfce5929 100644 --- a/js/publish/src/audio/capture.test.ts +++ b/js/publish/src/audio/capture.test.ts @@ -109,7 +109,7 @@ test("does not construct an AudioWorkletNode when torn down mid worklet load", a await settle(); // Tear the run down before the module finishes loading. cleanup() calls context.close(), which on - // Firefox/Safari leaves .state === "suspended", then effect.cancel wins the race. + // Firefox/Safari leaves .state === "suspended", then the teardown wins the race. capture.close(); await settle(); diff --git a/js/publish/src/audio/capture.ts b/js/publish/src/audio/capture.ts index 15b0114645..5484de9297 100644 --- a/js/publish/src/audio/capture.ts +++ b/js/publish/src/audio/capture.ts @@ -163,10 +163,7 @@ export class Capture { // module registration was abandoned, so building against its name would throw. Gate on the race // result, not `context.state`, because `AudioContext.close()` only flips `.state` to "closed" // synchronously on Chrome (Firefox/Safari report "suspended"). - const ok = await Promise.race([ - context.audioWorklet.addModule(CaptureWorklet).then(() => true), - effect.cancel, - ]); + const ok = await effect.race(context.audioWorklet.addModule(CaptureWorklet).then(() => true)); if (ok) loaded.set(true); }); diff --git a/js/publish/src/audio/encoder.ts b/js/publish/src/audio/encoder.ts index 8499c80d31..0cab7ea977 100644 --- a/js/publish/src/audio/encoder.ts +++ b/js/publish/src/audio/encoder.ts @@ -224,7 +224,7 @@ export class Encoder { effect.spawn(async () => { for (;;) { - const next = await Promise.race([reader.read(), effect.cancel]); + const next = await effect.race(reader.read()); if (!next?.value) break; const format = capture.out.format.peek(); diff --git a/js/publish/src/broadcast.ts b/js/publish/src/broadcast.ts index 1ed9f1fc66..465e221dd7 100644 --- a/js/publish/src/broadcast.ts +++ b/js/publish/src/broadcast.ts @@ -111,7 +111,8 @@ export class Broadcast { * * Set the returned rendition's `config` to a {@link Catalog.TextConfig}, then write one cue per * group into its `track` with `Hang.Container.Legacy.Producer` (each cue is a keyframe, so it opens - * its own group). See the module docs for the cue framing. + * its own group). See the module docs for the cue framing. Stamp cues with `performance.now()` in + * microseconds, the broadcast clock the catalog advertises. */ text(name: string): Rendition { return this.#register(name, "text"); diff --git a/js/publish/src/catalog.test.ts b/js/publish/src/catalog.test.ts index 9a0294afc4..58873544ec 100644 --- a/js/publish/src/catalog.test.ts +++ b/js/publish/src/catalog.test.ts @@ -45,7 +45,7 @@ test("catalog producer publishes every update as a snapshot group", async () => const first = await subscriber.nextGroup(); expect(first?.sequence).toBe(0); - expect(await first?.readJson()).toEqual({ video: { renditions: {} } }); + expect(await first?.readJson()).toEqual({ clock: expect.anything(), video: { renditions: {} } }); expect(first?.done).toBe(true); catalog.mutate((c) => { @@ -54,12 +54,46 @@ test("catalog producer publishes every update as a snapshot group", async () => const second = await subscriber.nextGroup(); expect(second?.sequence).toBe(1); - expect(await second?.readJson()).toEqual({ video: { renditions: {} }, scte35: { splices: [] } }); + expect(await second?.readJson()).toEqual({ + clock: expect.anything(), + video: { renditions: {} }, + scte35: { splices: [] }, + }); expect(second?.done).toBe(true); effect.close(); }); +test("catalog producer advertises the page clock from the first snapshot", async () => { + const catalog = new CatalogProducer(); + + const effect = new Effect(); + const track = new Track.Producer("catalog.json"); + catalog.serve(track, effect); + const consumer = new Json.Snapshot.Consumer({ track: track.subscribe() }); + + // Before any rendition: a live-only publisher exposes its clock without an archive. + const first = Catalog.RootSchema.parse(await consumer.next()); + if (!first.clock) throw new Error("expected a root clock"); + expect(first.archive).toBeUndefined(); + expect(first.clock.timescale).toBe(1_000_000); + + // A timestamp stamped the way capture does (performance.now() in microseconds) maps onto the + // page's own wall timeline, not Date.now(), which a system-clock adjustment can move. + const now = performance.now(); + const wall = Catalog.wallClockTime(first.clock, Math.round(now * 1000), 1_000_000).getTime(); + expect(Math.abs(wall - (performance.timeOrigin + now))).toBeLessThanOrEqual(1); + + // Later edits keep the mapping: it is fixed for the broadcast. + catalog.mutate((c) => { + c.video = { renditions: {} }; + }); + const second = Catalog.RootSchema.parse(await consumer.next()); + expect(second.clock).toEqual(first.clock); + + effect.close(); +}); + test("a reconnecting subscriber is seeded with the full current catalog", async () => { const catalog = new CatalogProducer(); catalog.mutate((c) => { @@ -105,7 +139,8 @@ test("catalog producer refuses zero jitter before retaining an edit", () => { ).toThrow("omit jitter"); } catalog.mutate((value) => { - expect(value).toEqual({}); + expect(value.audio).toBeUndefined(); + expect(value.video).toBeUndefined(); }); }); diff --git a/js/publish/src/catalog.ts b/js/publish/src/catalog.ts index 1c37d4f72d..7c45ec7962 100644 --- a/js/publish/src/catalog.ts +++ b/js/publish/src/catalog.ts @@ -1,4 +1,4 @@ -import type * as Catalog from "@moq/hang/catalog"; +import * as Catalog from "@moq/hang/catalog"; import * as Json from "@moq/json"; import type * as Moq from "@moq/net"; import type { Effect } from "@moq/signals"; @@ -11,9 +11,13 @@ import type { Effect } from "@moq/signals"; * current catalog before receiving updates. Independent owners (the base `video`/`audio` and an * application's own sections, e.g. `scte35`) each edit only their own keys, so their sections * compose instead of clobbering one another. + * + * The root `clock` is advertised from the first snapshot: every js/publish timestamp is + * `performance.now()` in microseconds, so PTS zero is `performance.timeOrigin`. The mapping is fixed + * for the page, so a system-clock adjustment never retimes the broadcast. */ export class CatalogProducer { - #value: Catalog.Root = {}; + #value: Catalog.Root = { clock: pageClock() }; #outputs = new Set>(); /** Edit the catalog in place; the result is published to all current subscribers. */ @@ -54,3 +58,9 @@ export class CatalogProducer { }); } } + +// The wall time of `performance.now() === 0`, the zero every js/publish timestamp counts from. +function pageClock(): Catalog.Clock { + const wall = Math.round((performance.timeOrigin - Catalog.MOQ_EPOCH_UNIX_MILLIS) * 1000); + return { wall: Catalog.u53(wall), timescale: 1_000_000 }; +} diff --git a/js/publish/src/preview.ts b/js/publish/src/preview.ts index 0b34be9306..4d04c027a5 100644 --- a/js/publish/src/preview.ts +++ b/js/publish/src/preview.ts @@ -124,7 +124,7 @@ export class Renderer { effect.spawn(async () => { for (;;) { - const next = await Promise.race([reader.read(), effect.cancel]); + const next = await effect.race(reader.read()); if (!next?.value) break; this.#latest.update((prev) => { @@ -269,7 +269,7 @@ export class Transcode { inner.spawn(async () => { for (;;) { - const next = await Promise.race([reader.read(), inner.cancel]); + const next = await inner.race(reader.read()); if (!next?.value) break; // Ours now, so close it once the encoder has taken what it needs. diff --git a/js/publish/src/source/camera.ts b/js/publish/src/source/camera.ts index a61a43f64c..cd0e8601f3 100644 --- a/js/publish/src/source/camera.ts +++ b/js/publish/src/source/camera.ts @@ -121,7 +121,7 @@ export class Camera { let stream: MediaStream | undefined; try { - stream = await Promise.race([media, effect.cancel.then(() => undefined)]); + stream = await effect.race(media); } catch (error) { if (effect.abort.aborted) return; this.#out.error.set(error instanceof Error ? error : new Error(String(error))); diff --git a/js/publish/src/source/device.ts b/js/publish/src/source/device.ts index 8236a84847..1f9667f852 100644 --- a/js/publish/src/source/device.ts +++ b/js/publish/src/source/device.ts @@ -92,10 +92,7 @@ export class Device { effect.get(this.out.permission); // Ignore permission errors for now. - let devices = await Promise.race([ - navigator.mediaDevices.enumerateDevices().catch(() => undefined), - effect.cancel, - ]); + let devices = await effect.race(navigator.mediaDevices.enumerateDevices().catch(() => undefined)); if (!devices) return; // cancelled, keep stale values devices = devices.filter((d) => d.kind === `${this.kind}input`); diff --git a/js/publish/src/source/microphone.ts b/js/publish/src/source/microphone.ts index 7cf8c0602a..45258f294c 100644 --- a/js/publish/src/source/microphone.ts +++ b/js/publish/src/source/microphone.ts @@ -107,7 +107,7 @@ export class Microphone { let stream: MediaStream | undefined; try { - stream = await Promise.race([media, effect.cancel.then(() => undefined)]); + stream = await effect.race(media); } catch (error) { if (effect.abort.aborted) return; this.#out.error.set(error instanceof Error ? error : new Error(String(error))); diff --git a/js/publish/src/source/screen.ts b/js/publish/src/source/screen.ts index e885db1680..8e32f6e4c7 100644 --- a/js/publish/src/source/screen.ts +++ b/js/publish/src/source/screen.ts @@ -83,7 +83,7 @@ export class Screen { } effect.spawn(async () => { - const media = await Promise.race([ + const media = await effect.race( navigator.mediaDevices .getDisplayMedia({ video, @@ -97,8 +97,7 @@ export class Screen { // systemAudio: "exclude", }) .catch(() => undefined), - effect.cancel, - ]); + ); if (!media) return; const v = media.getVideoTracks().at(0) as Video.StreamTrack | undefined; diff --git a/js/publish/src/ui/components/stats-tab.ts b/js/publish/src/ui/components/stats-tab.ts index bdb9ee4943..07e7b05c28 100644 --- a/js/publish/src/ui/components/stats-tab.ts +++ b/js/publish/src/ui/components/stats-tab.ts @@ -115,7 +115,7 @@ export function statsTab(parent: Effect, publish: MoqPublish): HTMLElement { effect.spawn(async () => { for (;;) { - const next = await Promise.race([reader.read(), effect.cancel]); + const next = await effect.race(reader.read()); if (!next?.value) break; frames++; diff --git a/js/publish/src/video/encoder.ts b/js/publish/src/video/encoder.ts index 30e23d0a6e..a89c58b5b6 100644 --- a/js/publish/src/video/encoder.ts +++ b/js/publish/src/video/encoder.ts @@ -298,7 +298,7 @@ export class Encoder { effect.spawn(async () => { for (;;) { - const next = await Promise.race([reader.read(), effect.cancel]); + const next = await effect.race(reader.read()); if (!next?.value) break; // Ours now: every path below has to close it. diff --git a/js/room/src/metadata.ts b/js/room/src/metadata.ts index 0dd054d176..7ff0a458c9 100644 --- a/js/room/src/metadata.ts +++ b/js/room/src/metadata.ts @@ -231,7 +231,7 @@ function subscribeJson( const consumer = new Json.Snapshot.Consumer({ track }); effect.spawn(async () => { for (;;) { - const value = await Promise.race([effect.cancel, consumer.next()]); + const value = await effect.race(consumer.next()); if (value === undefined) break; update(value); } diff --git a/js/room/src/room.ts b/js/room/src/room.ts index 21ae254bcc..0cf2a48ba0 100644 --- a/js/room/src/room.ts +++ b/js/room/src/room.ts @@ -80,7 +80,7 @@ export class Room { async #run(announced: Moq.Announce.Consumer, prefix: Moq.Path.Valid, effect: Effect): Promise { for (;;) { - const update = await Promise.race([effect.cancel, announced.next()]); + const update = await effect.race(announced.next()); if (!update) break; // The scope's `**` captures what lies beneath the prefix. A broad route diff --git a/js/signals/src/index.test.ts b/js/signals/src/index.test.ts index 4cf02417f0..71c5b190d6 100644 --- a/js/signals/src/index.test.ts +++ b/js/signals/src/index.test.ts @@ -1,5 +1,5 @@ import { describe, expect, spyOn, test } from "bun:test"; -import { Computed, Effect, Once, Signal } from "./index.ts"; +import { Computed, type Dispose, Effect, type GetPromise, Once, race, Signal } from "./index.ts"; const NO_SUBSCRIPTION_WARNING = "Effect did not subscribe to any signals; it will never rerun."; @@ -1049,3 +1049,203 @@ describe("Once", () => { expect(seen).toEqual(["x"]); }); }); + +// A GetPromise over a Once that counts the listeners currently attached to it. +function counted(once = new Once()): GetPromise & { listeners: number; once: Once } { + const wrapper = { + once, + listeners: 0, + peek: () => once.peek(), + changed: ((fn?: (value: T | undefined) => void) => + fn ? track(once.changed(fn)) : once.changed()) as GetPromise["changed"], + subscribe: (fn: (value: T | undefined) => void) => track(once.subscribe(fn)), + // biome-ignore lint/suspicious/noThenProperty: mirrors Once. + then: once.then.bind(once) as GetPromise["then"], + }; + function track(dispose: Dispose): Dispose { + wrapper.listeners++; + let live = true; + return () => { + if (!live) return; + live = false; + wrapper.listeners--; + dispose(); + }; + } + return wrapper; +} + +// A thenable that counts how many reactions were attached to it. +function thenable(): PromiseLike & { reactions: number; resolve: (value: T) => void } { + const inner = Promise.withResolvers(); + return { + reactions: 0, + resolve: inner.resolve, + // biome-ignore lint/suspicious/noThenProperty: a counting thenable. + then(onFulfilled, onRejected) { + this.reactions++; + return inner.promise.then(onFulfilled, onRejected); + }, + }; +} + +describe("race", () => { + test("settles with the first value to settle", async () => { + const slow = new Promise((resolve) => setTimeout(() => resolve("slow"), 20)); + const fast = new Promise((resolve) => setTimeout(() => resolve("fast"), 1)); + expect(await race([slow, fast])).toBe("fast"); + }); + + test("rejects with the first rejection", async () => { + const pending = new Promise(() => {}); + await expect(race([pending, Promise.reject(new Error("boom"))])).rejects.toThrow("boom"); + }); + + test("an already settled Once wins at once", async () => { + const once = new Once(); + once.set("done"); + expect(await race([Promise.resolve("promise"), once])).toBe("done"); + }); + + test("resolves when a pending Once settles", async () => { + const once = new Once(); + const result = race([new Promise(() => {}), once]); + once.set(3); + expect(await result).toBe(3); + }); + + test("many races leave a pending Once without listeners", async () => { + const closed = counted(); + for (let i = 0; i < 1000; i++) { + expect(await race([Promise.resolve(i), closed])).toBe(i); + } + expect(closed.listeners).toBe(0); + }); + + test("many races attach one reaction to a long-lived promise", async () => { + const closed = thenable(); + for (let i = 0; i < 1000; i++) { + expect(await race([Promise.resolve(i), closed])).toBe(i); + } + expect(closed.reactions).toBe(1); + + closed.resolve("closed"); + expect(await race([new Promise(() => {}), closed])).toBe("closed"); + expect(closed.reactions).toBe(1); + }); + + test("many races against a long-lived promise leave it no listeners", async () => { + const closed = new Promise(() => {}); + const add = spyOn(Set.prototype, "add"); + let sets: Set[]; + try { + for (let i = 0; i < 1000; i++) await race([Promise.resolve(i), closed]); + sets = [...add.mock.contexts] as Set[]; + } finally { + add.mockRestore(); + } + + // `closed` is the last value each race listens to, so the last set added to is its listeners. + const listeners = sets.at(-1); + expect(sets.filter((set) => set === listeners).length).toBe(1000); + expect(sets.every((set) => set.size === 0)).toBe(true); + }); +}); + +describe("effect.race", () => { + test("resolves with the value while the run is live", async () => { + const effect = new Effect(); + expect(await effect.race(Promise.resolve(5))).toBe(5); + effect.close(); + }); + + test("resolves undefined when the run is torn down", async () => { + const trigger = new Signal(0); + let result: Promise | undefined; + const effect = new Effect((effect) => { + if (effect.get(trigger) === 0) result = effect.race(new Promise(() => {})); + }); + await settle(); + trigger.set(1); + expect(await result).toBeUndefined(); + effect.close(); + }); + + test("resolves undefined on close, and at once after it", async () => { + const effect = new Effect(); + const result = effect.race(new Promise(() => {})); + effect.close(); + expect(await result).toBeUndefined(); + expect(await effect.race(Promise.resolve(1))).toBeUndefined(); + }); + + test("many races leave neither teardown nor the value with a listener", async () => { + const added = spyOn(AbortSignal.prototype, "addEventListener"); + const removed = spyOn(AbortSignal.prototype, "removeEventListener"); + try { + const closed = counted(); + const effect = new Effect(); + for (let i = 0; i < 1000; i++) { + expect(await effect.race(Promise.resolve(i), closed)).toBe(i); + } + expect(closed.listeners).toBe(0); + expect(added.mock.calls.length).toBe(removed.mock.calls.length); + + // A teardown that wins releases the value's listener too. + const pending = effect.race(closed); + expect(closed.listeners).toBe(1); + effect.close(); + expect(await pending).toBeUndefined(); + expect(closed.listeners).toBe(0); + } finally { + added.mockRestore(); + removed.mockRestore(); + } + }); +}); + +describe("spawn retention", () => { + test("an effect that never reruns drops settled tasks", async () => { + const effect = new Effect(); + const add = spyOn(Set.prototype, "add"); + let sets: Set[]; + try { + for (let i = 0; i < 100; i++) effect.spawn(async () => {}); + sets = [...add.mock.contexts] as Set[]; + } finally { + add.mockRestore(); + } + + // The effect's own task set, found by what spawn added to it. + const tasks = sets[0]; + expect(sets.every((set) => set === tasks)).toBe(true); + expect(tasks?.size).toBe(100); + + await settle(); + expect(tasks?.size).toBe(0); + effect.close(); + }); + + test("a rerun still waits for a pending task", async () => { + const trigger = new Signal(0); + const task = Promise.withResolvers(); + let runs = 0; + const effect = new Effect((effect) => { + runs++; + effect.get(trigger); + if (runs === 1) { + effect.spawn(async () => {}); + effect.spawn(() => task.promise); + } + }); + await settle(); + trigger.set(1); + await settle(); + expect(runs).toBe(1); + + task.resolve(); + await settle(); + expect(runs).toBe(2); + effect.close(); + }); +}); diff --git a/js/signals/src/index.ts b/js/signals/src/index.ts index fd7c085fef..5b4681faf9 100644 --- a/js/signals/src/index.ts +++ b/js/signals/src/index.ts @@ -277,6 +277,114 @@ export class Once implements GetPromise { } } +// Every promise a race has watched, mapped to the listeners of the races still waiting on it. A +// native reaction can never be removed, so each promise gets exactly one, shared by every race: +// racing a long-lived promise per frame then costs a removable listener, not a reaction per call. +type Settled = { ok: true; value: unknown } | { ok: false; error: unknown }; +type Listener = (settled: Settled) => void; +type Watched = { settled?: Settled; listeners: Set }; +const watched = new WeakMap(); + +function isThenable(value: unknown): value is PromiseLike & object { + return ( + (typeof value === "object" || typeof value === "function") && + value !== null && + typeof (value as PromiseLike).then === "function" + ); +} + +// Calls `fn` once when `value` settles, synchronously if it already has. Returns a disposer. +function listen(value: unknown, fn: Listener): Dispose { + if (!isThenable(value)) { + fn({ ok: true, value }); + return noop; + } + + if (getterShaped(value)) { + const readable = value as GetPromise; + const current = readable.peek(); + if (current !== undefined) { + fn({ ok: true, value: current }); + return noop; + } + + const dispose = readable.subscribe((next) => { + if (next === undefined) return; + dispose(); + fn({ ok: true, value: next }); + }); + return dispose; + } + + let entry = watched.get(value); + if (!entry) { + const created: Watched = { listeners: new Set() }; + Promise.resolve(value).then( + (value) => settle(created, { ok: true, value }), + (error: unknown) => settle(created, { ok: false, error }), + ); + watched.set(value, created); + entry = created; + } + + if (entry.settled) { + fn(entry.settled); + return noop; + } + + const listeners = entry.listeners; + listeners.add(fn); + return () => listeners.delete(fn); +} + +function settle(entry: Watched, settled: Settled): void { + entry.settled = settled; + const listeners = [...entry.listeners]; + entry.listeners.clear(); + for (const fn of listeners) fn(settled); +} + +// Settles with the first of `values`, or `undefined` once `abort` fires, then drops every listener. +function raceUntil(values: readonly unknown[], abort?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const disposes: Dispose[] = []; + let done = false; + const finish = (settled: Settled) => { + if (done) return; + done = true; + for (const dispose of disposes) dispose(); + if (settled.ok) resolve(settled.value as T); + else reject(settled.error); + }; + + if (abort) { + if (abort.aborted) return finish({ ok: true, value: undefined }); + const stop = () => finish({ ok: true, value: undefined }); + abort.addEventListener("abort", stop); + disposes.push(() => abort.removeEventListener("abort", stop)); + } + + for (const value of values) { + const dispose = listen(value, finish); + if (done) return dispose(); + disposes.push(dispose); + } + }); +} + +/** + * Settles with the first of `values` to settle, like `Promise.race`, then drops every listener it + * registered. + * + * Accepts promises and {@link GetPromise} values such as a {@link Once}. Racing a value that + * outlives the call (a `closed` pending for the whole track) once per frame therefore leaks nothing, + * where `Promise.race` would leave a listener or reaction behind on every call. An already settled + * value wins at once. + */ +export function race(values: T): Promise> { + return raceUntil(values); +} + type SetterType = S extends Setter ? T : never; /** The value type a {@link Getter} yields, e.g. `number` for `Getter`. */ @@ -405,7 +513,7 @@ export class Effect { #draining?: Dispose[]; #drained = 0; #unwatch: Dispose[] = []; - #async: Promise[] = []; + #async = new Set>(); #stack?: string; #scheduled = false; @@ -479,7 +587,7 @@ export class Effect { // would hand that task's cleanup registrations, and its `abort` signal, to a run it never // belonged to. The dispose functions above already closed whatever the task was awaiting, // so anything that observes cancellation unwinds from here. - if (this.#async.length > 0) { + if (this.#async.size > 0) { // Diagnostic only: a task that ignores cancellation stalls the rerun, so name it. const warn = DEV ? setTimeout(() => { @@ -496,14 +604,14 @@ export class Effect { try { // A task can spawn another as it unwinds, so drain until nothing new is queued. - while (this.#dispose !== undefined && this.#async.length > 0) { + while (this.#dispose !== undefined && this.#async.size > 0) { const pending = this.#async; - this.#async = []; + this.#async = new Set(); // close() has to release the wait rather than wait behind it. It already ran // every dispose function, and there is no next run left to protect, so a task // that never settles must not pin this loop (or the timer below) forever. - await Promise.race([Promise.all(pending), this.#closed.promise]); + await race([Promise.all(pending), this.#closed.promise]); } } catch (error) { console.error("async effect error", error); @@ -534,7 +642,7 @@ export class Effect { this.#dispose !== undefined && this.#unwatch.length === 0 && this.#dispose.length === 0 && - this.#async.length === 0 && + this.#async.size === 0 && !this.#abortUsed ) { console.warn("Effect did not subscribe to any signals; it will never rerun.", this.#stack); @@ -604,7 +712,12 @@ export class Effect { // rerun path drains it, so pushing now would pin the task on an effect that can never rerun. if (this.#dispose === undefined) return; - this.#async.push(promise); + // A settled task has nothing left for a rerun to wait on. Dropping it matters for an effect + // that never reruns (`new Effect()` spawning per group), which would otherwise keep every one. + // The set, not `this`, so a task that never settles cannot pin a closed effect. + const tasks = this.#async; + tasks.add(promise); + void promise.then(() => tasks.delete(promise)); } /** Runs `fn` after `ms` milliseconds, unless the effect reruns or closes first. */ @@ -920,7 +1033,7 @@ export class Effect { for (const signal of this.#unwatch) signal(); this.#unwatch.length = 0; - this.#async.length = 0; + this.#async.clear(); if (DEV) { Effect.#finalizer.unregister(this); @@ -932,11 +1045,23 @@ export class Effect { return this.#closed.promise; } - /** Resolves when the current run is about to be torn down, by a rerun or close. */ + /** + * Resolves when the current run is about to be torn down, by a rerun or close. + * + * @internal Racing it adds a reaction per call that lives until the run ends; use {@link race}. + */ get cancel(): Promise { return this.#stopped.promise; } + /** + * Settles with the first of `values`, like the free {@link race}, or resolves `undefined` once the + * current run is torn down. Either way it drops every listener it registered. + */ + race(...values: T): Promise | undefined> { + return raceUntil(values, this.#abort.signal); + } + /** An AbortSignal that fires when the current run is torn down. */ get abort(): AbortSignal { this.#abortUsed = true; diff --git a/js/watch/src/audio/decoder.ts b/js/watch/src/audio/decoder.ts index c469aabdff..8745d04af4 100644 --- a/js/watch/src/audio/decoder.ts +++ b/js/watch/src/audio/decoder.ts @@ -181,10 +181,7 @@ export class Decoder { // abandoned, so building against its name would throw. Gate on the race result, not // `context.state`, because `AudioContext.close()` only flips `.state` to "closed" synchronously // on Chrome (Firefox/Safari report "suspended"). - const loaded = await Promise.race([ - context.audioWorklet.addModule(RenderWorklet).then(() => true), - effect.cancel, - ]); + const loaded = await effect.race(context.audioWorklet.addModule(RenderWorklet).then(() => true)); if (!loaded) return; // Create the worklet node. outputChannelCount must be set explicitly diff --git a/js/watch/src/audio/source.ts b/js/watch/src/audio/source.ts index 7961836df5..8503e10a74 100644 --- a/js/watch/src/audio/source.ts +++ b/js/watch/src/audio/source.ts @@ -85,13 +85,10 @@ export class Source { effect.spawn(async () => { const available: Record = {}; - // `supported` comes from the consumer, so we cannot assume it ever settles. A rerun - // waits for the tasks it spawned, so an unraced probe would hold the next run shut - // for good. Captured here so it stays this run's promise once we start awaiting. - const cancelled = effect.cancel.then(() => undefined); - for (const [name, config] of Object.entries(renditions)) { - const isSupported = await Promise.race([supported(config), cancelled]); + // `supported` comes from the consumer, so we cannot assume it ever settles. A rerun + // waits for the tasks it spawned, so an unraced probe would hold the next run shut. + const isSupported = await effect.race(supported(config)); // Torn down: stop probing and publish nothing, since the rerun redoes this. if (effect.abort.aborted) return; diff --git a/js/watch/src/broadcast.ts b/js/watch/src/broadcast.ts index 65b435f720..1064a0708c 100644 --- a/js/watch/src/broadcast.ts +++ b/js/watch/src/broadcast.ts @@ -200,7 +200,7 @@ export class Broadcast { effect.spawn(async () => { for (;;) { - const entry = await Promise.race([effect.cancel, announced.next()]); + const entry = await effect.race(announced.next()); if (!entry) break; this.#announced.mutate((active) => { if (!active) return; @@ -335,7 +335,7 @@ export class Broadcast { effect.spawn(async () => { try { for (;;) { - const update = await Promise.race([effect.cancel, fetchNext()]); + const update = await effect.race(fetchNext()); if (!update) break; console.debug("received catalog", format, this.in.name.peek(), update); diff --git a/js/watch/src/retention.test.ts b/js/watch/src/retention.test.ts new file mode 100644 index 0000000000..af0af0860e --- /dev/null +++ b/js/watch/src/retention.test.ts @@ -0,0 +1,64 @@ +import { heapStats } from "bun:jsc"; +import { expect, test } from "bun:test"; +import { Container } from "@moq/hang"; +import * as Moq from "@moq/net"; +import { Time } from "@moq/net"; +import { Effect } from "@moq/signals"; +import { nextMedia, subscribeMedia } from "./media"; +import { Sync } from "./sync"; + +// The player path a decoder drives, one frame per group like AAC audio: the container consumer +// reads each frame, the shared clock anchors on it, and presentation waits on the clock against +// the effect's teardown. Retention anywhere along it grows the heap with the frame count. +test("a long subscription through the player path keeps a flat heap", async () => { + const broadcast = new Moq.Broadcast.Producer(); + // A tiny publisher window, so the track's own replay cache stays flat too. + const track = broadcast.createTrack("audio", { maxAge: Time.Milli(1) }); + const format = new Container.Legacy.Format("audio"); + const producer = new Container.Legacy.Producer(track, format); + + const sync = new Sync({ delay: Time.Milli(10) }); + const effect = new Effect(); + const sub = subscribeMedia(effect, { + broadcast: broadcast.consume(), + track: "audio", + priority: 0, + maxAge: sync.out.maxAge, + }); + if (!sub) throw new Error("no subscription"); + const consumer = new Container.Consumer(sub, { format, maxAge: sync.out.maxAge }); + + // Presentations overlap, as a decoder's outputs do, so the clock's sleeps are shared. + const presenting = new Set>(); + const play = async (count: number) => { + for (let i = 0; i < count; i++) { + producer.encode(new Uint8Array([i & 0xff]), Math.round(Time.Micro.now()) as Time.Micro, true); + // A new group closes the previous one with a duration marker, which reads as no frame. + let next = await nextMedia(consumer); + while (next && !next.frame) next = await nextMedia(consumer); + if (!next?.frame) throw new Error("the track ended"); + + const timestamp = Time.Milli.fromMicro(next.frame.timestamp); + sync.received(timestamp); + const presented = effect.race(sync.wait(timestamp)); + presenting.add(presented); + void presented.finally(() => presenting.delete(presented)); + } + await Promise.all(presenting); + }; + + const heap = () => { + Bun.gc(true); + return heapStats().objectCount; + }; + + await play(200); + const before = heap(); + await play(2000); + expect(heap() - before).toBeLessThan(1000); + + consumer.close(); + effect.close(); + sync.close(); + broadcast.close(); +}); diff --git a/js/watch/src/sync.test.ts b/js/watch/src/sync.test.ts index ed2411a8ec..2376f625e9 100644 --- a/js/watch/src/sync.test.ts +++ b/js/watch/src/sync.test.ts @@ -1,7 +1,8 @@ +import { heapStats } from "bun:jsc"; import { describe, expect, it } from "bun:test"; -import type { Time } from "@moq/net"; +import { Time } from "@moq/net"; import { Signal } from "@moq/signals"; -import { Sync } from "./sync"; +import { type Delay, Sync } from "./sync"; // Effects in @moq/signals flush on a microtask, so let pending updates drain before asserting. const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); @@ -96,3 +97,55 @@ describe("delay and buffer", () => { sync.close(); }); }); + +describe("wait", () => { + const promises = () => { + Bun.gc(true); + return heapStats().objectTypeCounts.Promise ?? 0; + }; + + it("leaves nothing behind on a stable clock", async () => { + const sync = new Sync({ delay: 10 as Time.Milli }); + await flush(); + sync.received(Time.Milli.now()); + + const before = promises(); + for (let round = 0; round < 10; round++) { + const now = Time.Milli.now(); + await Promise.all(Array.from({ length: 100 }, () => sync.wait(now))); + } + expect(promises() - before).toBeLessThan(100); + sync.close(); + }); + + it("wakes a sleeping wait when the delay switches to instant", async () => { + const delay = new Signal(10_000 as Time.Milli); + const sync = new Sync({ delay }); + await flush(); + sync.received(Time.Milli.now()); + + let woke = false; + const waiting = sync.wait(Time.Milli.now()).then(() => { + woke = true; + }); + await flush(); + expect(woke).toBe(false); + + delay.set("instant"); + await waiting; + expect(woke).toBe(true); + sync.close(); + }); + + it("wakes a sleeping wait on reset", async () => { + const sync = new Sync({ delay: 10_000 as Time.Milli }); + await flush(); + sync.received(Time.Milli.now()); + + const waiting = sync.wait(Time.Milli.now()); + await flush(); + sync.reset(); + await waiting; + sync.close(); + }); +}); diff --git a/js/watch/src/sync.ts b/js/watch/src/sync.ts index 07a5da3d12..c6d3404be7 100644 --- a/js/watch/src/sync.ts +++ b/js/watch/src/sync.ts @@ -87,10 +87,6 @@ export class Sync { }; readonly out = readonlys(this.#out); - // A ghetto way to learn when the reference/buffer changes. - // There's probably a way to use Effect, but lets keep it simple for now. - #update: PromiseWithResolvers; - // Per-label late-frame tracking: accumulate count and max lateness, flush on recovery. #late = new Map(); @@ -108,8 +104,6 @@ export class Sync { probe: getter(props?.probe), }; - this.#update = Promise.withResolvers(); - this.#signals.run(this.#runJitter.bind(this)); this.#signals.run(this.#runDelay.bind(this)); this.#signals.run(this.#runMaxAge.bind(this)); @@ -178,9 +172,6 @@ export class Sync { const instant = effect.get(this.in.delay) === "instant"; const delay = instant ? Time.Milli.zero : Time.Milli.add(media, jitter); this.#out.delay.set(delay); - - this.#update.resolve(); - this.#update = Promise.withResolvers(); } // Fold a newly received frame into the reference. The reference anchors playback to the @@ -193,7 +184,7 @@ export class Sync { // First frame anchors the reference. if (currentRef === undefined) { - this.#setReference(ref); + this.#out.reference.set(ref); return; } @@ -229,13 +220,7 @@ export class Sync { if (sleep <= cap) return; // within budget: let the buffer grow instead of skipping ahead // Over the cap: re-anchor down so the resulting lookahead is exactly the cap. - this.#setReference(Time.Milli.add(ref, Time.Milli.sub(cap, delay))); - } - - #setReference(ref: Time.Milli): void { - this.#out.reference.set(ref); - this.#update.resolve(); - this.#update = Promise.withResolvers(); + this.#out.reference.set(Time.Milli.add(ref, Time.Milli.sub(cap, delay))); } // Re-anchor playback to the next frame received. Call this at an utterance boundary @@ -244,8 +229,6 @@ export class Sync { reset(): void { this.#out.reference.set(undefined); this.#late.clear(); - this.#update.resolve(); - this.#update = Promise.withResolvers(); } // The PTS that should be rendering right now, derived from the reference + buffer. @@ -268,7 +251,7 @@ export class Sync { } for (;;) { - // Switching to "instant" resolves `#update`, so frames parked here wake and leave. + // Switching to "instant" wakes the sleep below, so frames parked here leave. if (this.in.delay.peek() === "instant") return; // Sleep until it's time to decode the next frame. @@ -285,13 +268,29 @@ export class Sync { // Skip setTimeout for small sleeps; the timer resolution (~4ms) would overshoot. if (sleep < 5) return; - const wait = new Promise((resolve) => setTimeout(resolve, sleep)).then(() => true); - - const ok = await Promise.race([this.#update.promise, wait]); - if (ok) return; + if (await this.#sleep(sleep)) return; } } + // Sleeps for `ms`, or returns false early once anything the sleep was computed from changes. + // Releases every listener either way: frames sleep once each, so a listener left on a signal + // that never changes would pile up for the life of the player. + #sleep(ms: number): Promise { + return new Promise((resolve) => { + const wake = (ok: boolean) => { + clearTimeout(timer); + for (const dispose of disposes) dispose(); + resolve(ok); + }; + const timer = setTimeout(() => wake(true), ms); + const disposes = [ + this.in.delay.changed(() => wake(false)), + this.#out.delay.changed(() => wake(false)), + this.#out.reference.changed(() => wake(false)), + ]; + }); + } + static #formatDuration(ms: number): string { ms = Math.round(ms); if (ms < 1000) return `${ms}ms`; diff --git a/js/watch/src/video/decoder.ts b/js/watch/src/video/decoder.ts index d8bab4511a..c6319ff5e9 100644 --- a/js/watch/src/video/decoder.ts +++ b/js/watch/src/video/decoder.ts @@ -339,8 +339,7 @@ class DecoderTrack { } // Returns immediately when the latency is "instant". - const wait = this.sync.wait(timestamp).then(() => true); - const ok = await Promise.race([wait, effect.cancel]); + const ok = await effect.race(this.sync.wait(timestamp).then(() => true)); if (!ok) return; if (generation !== this.#discontinuity) return; // a rewind happened while waiting diff --git a/js/watch/src/video/source.ts b/js/watch/src/video/source.ts index 047060df11..b6e4ac831e 100644 --- a/js/watch/src/video/source.ts +++ b/js/watch/src/video/source.ts @@ -289,11 +289,6 @@ export class Source { effect.spawn(async () => { const available: Record = {}; - // `supported` comes from the consumer, so we cannot assume it ever settles. A rerun - // waits for the tasks it spawned, so an unraced probe would hold the next run shut - // for good. Captured here so it stays this run's promise once we start awaiting. - const cancelled = effect.cancel.then(() => undefined); - for (const [name, config] of Object.entries(renditions)) { const cacheKey = (supported as CacheableSupported)[supportCacheKey]; const key = cacheKey ? cacheKey(config) : JSON.stringify(config); @@ -304,7 +299,9 @@ export class Source { } else { let failed = false; try { - isSupported = await Promise.race([supported(config), cancelled]); + // `supported` comes from the consumer, so we cannot assume it ever settles. A + // rerun waits for the tasks it spawned, so an unraced probe would hold it shut. + isSupported = await effect.race(supported(config)); } catch (err) { failed = true; console.warn( diff --git a/py/moq-rs/README.md b/py/moq-rs/README.md index d3d6c3c7e5..6add3e7adb 100644 --- a/py/moq-rs/README.md +++ b/py/moq-rs/README.md @@ -151,8 +151,8 @@ client = moq.Client( - **`BroadcastProducer()`**. Create a broadcast to publish tracks into. - `.dynamic() → BroadcastDynamic` - - `.publish_audio(format, init, *, label=None) → MediaProducer`. `init` is required: an OpusHead or AudioSpecificConfig resolves the whole rendition. - - `.publish_video(format, init=b"", *, label=None, hint=None) → MediaProducer`. `init` may be empty for a format that resolves in band; a `VideoHint` pins catalog fields the stream can't reveal (bitrate) or publishes the catalog before the first keyframe. + - `.publish_audio(format, init, *, label=None, track=None) → MediaProducer`. `init` is required: an OpusHead or AudioSpecificConfig resolves the whole rendition. `track` names the track; otherwise a unique name is derived from the format. + - `.publish_video(format, init=b"", *, label=None, hint=None, track=None) → MediaProducer`. `init` may be empty for a format that resolves in band; a `VideoHint` pins catalog fields the stream can't reveal (bitrate) or publishes the catalog before the first keyframe. `track` names the track as in `publish_audio`. - `.encode_video(input, output, *, bandwidth=None) → VideoProducer`. Encode raw `VideoFrame`s inside the binding; `.write(frame)` each one. - `.encode_audio(name, input, output, *, bandwidth=None) → AudioProducer`. Encode raw PCM `AudioFrame`s; the codec is `output.codec`, e.g. `AudioCodec.opus()`, with `output.frame_duration_us` setting the Opus frame length. - `.finish()` diff --git a/py/moq-rs/moq/publish.py b/py/moq-rs/moq/publish.py index bbebf04259..2a5929954a 100644 --- a/py/moq-rs/moq/publish.py +++ b/py/moq-rs/moq/publish.py @@ -52,12 +52,14 @@ from .subscribe import BroadcastConsumer, GroupConsumer, TrackConsumer -def _audio_init(format: AudioFormat, init: bytes, label: str | None) -> MoqAudioInit: - return MoqAudioInit(format=format, data=init, label=label) +def _audio_init(format: AudioFormat, init: bytes, label: str | None, track: str | None = None) -> MoqAudioInit: + return MoqAudioInit(format=format, data=init, label=label, track=track) -def _video_init(format: VideoFormat, init: bytes, label: str | None, hint: VideoHint | None) -> MoqVideoInit: - return MoqVideoInit(format=format, data=init, label=label, hint=hint) +def _video_init( + format: VideoFormat, init: bytes, label: str | None, hint: VideoHint | None, track: str | None = None +) -> MoqVideoInit: + return MoqVideoInit(format=format, data=init, label=label, hint=hint, track=track) class MediaProducer: @@ -665,11 +667,13 @@ def publish_audio( init: bytes, *, label: str | None = None, + track: str | None = None, ) -> MediaProducer: """Publish one audio codec as a new track. `init` is required: audio resolves its whole rendition from those bytes (an OpusHead, an AudioSpecificConfig, a STREAMINFO). `label` is - the human-readable rendition name stored in the catalog.""" - return MediaProducer(self._inner.publish_audio(_audio_init(format, init, label))) + the human-readable rendition name stored in the catalog. `track` names the track; otherwise + a unique name is derived from the format.""" + return MediaProducer(self._inner.publish_audio(_audio_init(format, init, label, track))) def publish_video( self, @@ -678,11 +682,13 @@ def publish_video( *, label: str | None = None, hint: VideoHint | None = None, + track: str | None = None, ) -> MediaProducer: """Publish one video codec as a new track. `init` may be empty for a format that resolves in band. `hint` seeds catalog fields the stream can't reveal (bitrate) or publishes the catalog - before the first keyframe. See :class:`VideoHint`.""" - return MediaProducer(self._inner.publish_video(_video_init(format, init, label, hint))) + before the first keyframe. See :class:`VideoHint`. `track` names the track; otherwise a + unique name is derived from the format.""" + return MediaProducer(self._inner.publish_video(_video_init(format, init, label, hint, track))) def publish_container( self, @@ -722,11 +728,12 @@ def publish_video_stream( *, label: str | None = None, hint: VideoHint | None = None, + track: str | None = None, ) -> MediaStreamProducer: """Publish a video track fed by a raw byte stream (unknown frame boundaries). Only the self-delimiting formats work: `AVC3`, `HEV1`, `AV01`. There is no audio counterpart, since - audio has no frame boundaries to infer.""" - return MediaStreamProducer(self._inner.publish_video_stream(_video_init(format, b"", label, hint))) + audio has no frame boundaries to infer. `track` names the track as in :meth:`publish_video`.""" + return MediaStreamProducer(self._inner.publish_video_stream(_video_init(format, b"", label, hint, track))) def publish_container_stream(self, format: ContainerFormat) -> ContainerStreamProducer: """Publish a container fed by a raw byte stream, which recovers its own framing.""" diff --git a/py/moq-rs/tests/test_local.py b/py/moq-rs/tests/test_local.py index 6b64a0ce09..ad004a566f 100644 --- a/py/moq-rs/tests/test_local.py +++ b/py/moq-rs/tests/test_local.py @@ -237,6 +237,21 @@ async def test_video_publish_consume(): break +async def test_video_publish_named_track(): + origin = moq.OriginProducer() + broadcast = create_announced(origin, "video-named-test") + media = broadcast.publish_video(moq.VideoFormat.AVC3, h264_init(), track="hd") + assert media.name == "hd" + + consumer = origin.consume() + + async for announcement in consumer.announced(): + broadcast_consumer = await consumer.request_broadcast(announcement.prefix) + catalog = await broadcast_consumer.catalog() + assert list(catalog.video.keys()) == ["hd"] + break + + async def test_multiple_frames_ordering(): origin = moq.OriginProducer() broadcast = create_announced(origin, "ordering-test") diff --git a/quest/m0/README.md b/quest/m0/README.md index b71009509c..eb67865d6d 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -93,7 +93,6 @@ do not add another media abstraction or a renderer crate during stabilization. ## Quests -- [JS retention](/quest/m0/js-retention.md) - no `js/` package retains a listener, reaction, or task per frame, so a long-running player keeps a flat heap - [Release](/quest/m0/release.md) - the release moq.pro adopts: binding docs, an upgrade page, and a staging soak gate it rather than the merge - [Binary stats](/quest/m0/stats-binary/README.md) - an allocation-free stats tick and an on-demand FlatBuffers `.fb.z` flavor with a checked-in schema - [Audio jitter target](/quest/m0/audio-jitter-target/README.md) - the audio playout target is a measured estimate of arrival timing in both languages, not a round-trip guess diff --git a/quest/m0/js-retention.md b/quest/m0/js-retention.md deleted file mode 100644 index 7588c2e833..0000000000 --- a/quest/m0/js-retention.md +++ /dev/null @@ -1,70 +0,0 @@ -# [L] A long-running JS player or publisher keeps a flat heap - -## Goal - -No `js/` package retains a listener, promise reaction, or task per frame or -group. A four-camera player measured over minutes today grows from about 715k -to 2M heap nodes and doubles its major-GC pauses (#4024, #4025, #4026); after -this quest its heap stays flat for the life of a subscription or effect. - -The cause is one pattern: a per-frame wait that attaches to a value living as -long as the track or effect run, and never detaches. - -- `Promise.race` against a pending `Once` (`track.closed`, `producer.closed`) - registers a listener per call through `Once.then`, released only when the - track closes (#4024, lite and IETF subscribers). -- `Promise.race` against a pending native promise (`effect.cancel`, `Sync`'s - update promise) adds a reaction per call, released only on rerun (#4025). -- `Effect.spawn` keeps every settled task on an effect that never reruns, one - per group in `@moq/hang`'s container `Consumer` (#4026). - -## Plan - -Settled with the maintainer: - -- Add `effect.race(promise)` to `@moq/signals`: resolves with the promise's - value, or `undefined` once the current run tears down, and removes its - teardown listener either way. One promise; a site racing several combines - them with `race` first. A teardown that wins must also dispose the inner - `race`'s subscriptions, or each run leaks them; settle how at PR time. -- Export a free `race(values)` from `@moq/signals`, shaped like - `Promise.race`: it accepts promises and `GetPromise` values, subscribes to - pending ones (an already settled one wins at once), and disposes every - subscription when the first settles. A native promise's reaction cannot be - removed, so a caller never passes one that outlives the call. It sits beside - `Signal.race`. -- Every internal `effect.cancel` race moves to `effect.race`, and - `effect.cancel` is marked `@internal`, per the `js/CLAUDE.md` deprecation - convention. Its deletion is a published break, - left to [Remove effect.cancel](/quest/m1/effect-cancel.md). Both additions - are additive, so this quest targets main. -- `Effect.spawn` drops a task once it settles; a rerun still waits on pending - ones, and close still releases them without waiting. -- Audit every `Promise.race` and `Once.then` under `js/`, not only the reported - sites: signals, net, hang, watch, publish, room, and the rest. Convert each - race whose operand outlives the call. `Sync.wait` is internal, so it can wake - on signal changes it disposes, as the reporter's proxy does, rather than a - shared native promise. -- The reporter still saw about 48 `Promise` objects/s growing after their - patches, with reactions and closures flat (#4025). Find and fix it. -- Add a rule to `js/CLAUDE.md`: never race a value that outlives the call; use - `effect.race` or `race`. Read `PROMPTING.md` first and keep it one line. -- Update `doc/lib/js/signals.md` and the `effect.cancel` example in - `doc/lib/js/watch.md` inline. - -Tests, in per-PR CI: - -- Per site, a deterministic test that many frames or groups leave the - long-lived value's listener count unchanged, and for `race` and - `effect.race` themselves. -- One heap backstop: a long mock subscription through the player path under - forced GC, asserting heap does not grow with the number of frames. - -Reproduce each leak before fixing it. The issues carry measured numbers and -the reporter's local patches, useful as a reference but not as the design. - -## Closes - -- [#4024](https://github.com/moq-dev/moq/issues/4024) - close this issue when the quest finishes -- [#4025](https://github.com/moq-dev/moq/issues/4025) - close this issue when the quest finishes -- [#4026](https://github.com/moq-dev/moq/issues/4026) - close this issue when the quest finishes diff --git a/quest/m0/release.md b/quest/m0/release.md index ab2b22979f..81072cc0a6 100644 --- a/quest/m0/release.md +++ b/quest/m0/release.md @@ -86,5 +86,4 @@ Public API: none beyond the required quests. Wire: none. ## Required -- [JS retention](/quest/m0/js-retention.md) - the player no longer grows its heap per frame - The merged relay has soaked on moq.pro staging and the maintainer has signed it off diff --git a/quest/m1/2278-watch-absolute-wall-clock-latency-target-for-synchronized.md b/quest/m1/2278-watch-absolute-wall-clock-latency-target-for-synchronized.md index 5eaff2b733..80800d1b8a 100644 --- a/quest/m1/2278-watch-absolute-wall-clock-latency-target-for-synchronized.md +++ b/quest/m1/2278-watch-absolute-wall-clock-latency-target-for-synchronized.md @@ -30,7 +30,7 @@ a live-only broadcast with no archive timeline. ## Required -- [Publisher clocks](/quest/m1/publisher-clock.md) - built-in publishers populate the mapping applications read +- [CLI import clock](/quest/m1/cli-import-clock.md) - built-in publishers populate the mapping applications read ## Closes diff --git a/quest/m1/README.md b/quest/m1/README.md index 6f8b01252c..60c9c1c16f 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -21,19 +21,21 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Missing fetch group](/quest/m1/fetch-missing-group.md) - HTTP /fetch answers 404 and `moq fetch` fails cleanly for a group the track lacks - [libmoq hidden opt-in](/quest/m1/libmoq-hidden.md) - `moq_origin_announced` takes a `hidden` flag so C callers can list `.`-named broadcasts - [JS track tail](/quest/m1/js-track-tail.md) - a `@moq/net` subscriber delivers every group up to the declared end over lite and IETF, and JS publishers drain their groups before ending a subscription +- [lite-07 stream count](/quest/m1/lite-stream-count.md) - moq-lite-07 replaces SUBSCRIBE_DROP with a group-stream count in SUBSCRIBE_END, like moq-transport - [Rust track tail](/quest/m1/rust-track-tail.md) - a moq-net subscriber accepts groups that arrive after the subscription's end, and PublishDone carries the real stream count - [Session death error](/quest/m1/session-death-error.md) - a dying session ends its tracks with its own error in Rust and JS, never a clean end, `Dropped`, or `Cancel` +- [Signal.race cleanup](/quest/m1/signal-race.md) - `Signal.race` releases its signal listeners when its result loses a race - [Origin narrowing](/quest/m1/origin-narrowing.md) - a live origin grant narrows in place and ends the subscriptions it no longer covers, the deafen boundary #2714 asked for - [Auth embedder](/quest/m1/auth-embedder.md) - the lease owns its re-check clock, a gateway session holds a lease, and `Cluster::admit` scopes and tags origins in one call - [Auth expiry clock](/quest/m1/auth-expiry-clock.md) - moq-auth and the relay hold one fixed expiry deadline and honour the same skew allowance - [Binding surface](/quest/m1/binding-surface.md) - moq-ffi, libmoq, and every wrapper expose the decode delay, route source, and connection timing - [FFI shape](/quest/m1/ffi-shape/README.md) - the bindings mirror Rust's layers: net at the root, then media, json, audio, and video namespaces built from the handle below - [Track demand](/quest/m1/track-demand.md) - Rust and JS watch a track's subscribers through `demand()` alone -- [IETF subscriptions end cleanly](/quest/m1/ietf-publish-done.md) - a finished moq-transport track ends cleanly for its subscriber instead of reading PUBLISH_DONE as an error - [Data sections](/quest/m1/data-sections.md) - an application lists JSON and binary tracks in its own catalog section with its own per-track fields, published in one moq-mux call; data entries gain `bitrate` and `jitter` - [Broadcast close](/quest/m1/broadcast-close/README.md) - `close()` is the one way to end a broadcast in every language, a permanent retraction that leaves in-flight tracks alone - [Relay peer set](/quest/m1/relay-peer-set.md) - a wire consumer tells a client hop from a peer hop, and every mesh credential can mark a peer -- [Publisher clocks](/quest/m1/publisher-clock.md) - wire the shared clock through native and browser publisher restarts +- [CLI import clock](/quest/m1/cli-import-clock.md) - fMP4, TS, and FLV imports publish on the shared broadcast clock across restarts +- [Native clock fixtures](/quest/m1/native-clock-fixtures.md) - CI drives native capture through clock edge cases and asserts the published timestamps - [CLI inspection](/quest/m1/cli-inspect/README.md) - `moq ls` lists what is live and `moq fetch` reads a group over MoQ, and a guide shows how to inspect a relay - [JS caught up](/quest/m1/js-announce-caught-up.md) - @moq/net's announce consumer says when the initial set has landed, like Rust - [Bindings caught up](/quest/m1/announce-live-bindings.md) - moq-ffi, libmoq, and every wrapper yield the same flat announce event, `Live` included @@ -49,13 +51,13 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Tooling](/quest/m1/tooling/README.md) - justfiles become a one-line menu over `sh/`, one impact map scopes CI, and every workflow step runs a recipe - [Path patterns](/quest/m1/path-patterns.md) - one matcher for every predicate over broadcast paths: tokens, origins, interest - [In-band auth](/quest/m1/auth/README.md) - a session tells its peer what it may publish and subscribe to, unions tokens presented in band, and fails loud on an out-of-scope publish -- [Test ports](/quest/m1/tokio-test-ports.md) - moq-tokio tests bind QUIC and WebSocket on independent ephemeral ports, so a parallel run cannot collide - [Decoded frame ownership](/quest/m1/decoded-frames.md) - retain moq-video Frames across bindings, with native views or CPU conversion as needed - [C++ through moq-ffi](/quest/m1/cpp/README.md) - generated C++ over moq-ffi with futures and expected-style errors, shipped as a tarball, vcpkg, and Conan, and adopted by the OBS plugin - [OBS native codecs](/quest/m1/obs-moq-video/README.md) - remove FFmpeg decoding dependencies, deliver GPU frames, and use native audio/video encoders - [Audio codecs](/quest/m1/audio-codecs/README.md) - platform audio codecs, explicit unsupported cases, and channel layouts up to 7.1 - [Opus descriptions](/quest/m1/audio-opus-input.md) - validate headers and honor codec clock, pre-skip, and gain - [Capture formats](/quest/m1/audio-capture-format.md) - unsupported overrides refuse before device open and channel counts cannot wrap +- [NVENC teardown](/quest/m1/nvenc-teardown.md) - a rejected NVENC encode no longer hangs process shutdown - [NVENC recovery](/quest/m1/nvenc-recovery.md) - partial initialization and rejected rate changes preserve valid state - [GPU pool reservation](/quest/m1/gpu-pool-reservation.md) - a full GPU frame pool is a `None` reservation the caller drops on, not an error to match - [Transcode source](/quest/m1/transcode-source.md) - select a rendition the chosen backend can actually decode diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index 960d8d6b1d..f2e080d67e 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -90,13 +90,11 @@ existing lite-06 ALPN. ## Quests - [Interop grants](/quest/m1/auth/interop.md) - the interop matrix asserts - each lite-06 cell's grant and that a publish outside it fails loud + each AUTH cell's grant and that a publish outside it fails loud - [Unauthorized reset](/quest/m1/auth/unauthorized.md) - a subscription that loses access resets with a dedicated UNAUTHORIZED stream code - [Relay tokens](/quest/m1/auth/relay-refresh.md) - the relay verifies tokens sent in band, unions their grants, and cancels only work that loses access -- [moq-transport](/quest/m1/auth/moq-transport.md) - the same exchange as a - setup-option extension on draft-17+, specified in a new draft - [Bindings](/quest/m1/auth/bindings.md) - grants and tokens reach every binding through moq-ffi and libmoq - [Token in band](/quest/m1/auth/token-in-band.md) - the credential can leave diff --git a/quest/m1/auth/interop.md b/quest/m1/auth/interop.md index d1f33f4970..a376e202f7 100644 --- a/quest/m1/auth/interop.md +++ b/quest/m1/auth/interop.md @@ -2,7 +2,8 @@ ## Goal -`just test interop --all` fails a lite-06 cell whose client did not receive +`just test interop --all` fails a cell with AUTH (lite-06, or moq-transport +draft-17+ with MoQ Auth) whose client did not receive the grant its relay token implies, and a new negative round passes only when a client publishing outside its grant fails loud with `Unauthorized`. Today the matrix checks media alone, so a malformed AUTH_OK that leaves a session @@ -15,6 +16,6 @@ parseable line; `test/interop/interop.sh` compares it against the token the cell minted. The negative round mints a token that excludes the published path and expects the publisher's session to close with `Unauthorized` naming the path, and the subscriber to see -nothing. Cells below lite-06 skip both checks, as do binding clients until +nothing. Cells without AUTH skip both checks, as do binding clients until [Bindings](/quest/m1/auth/bindings.md) gives them a grant to print; that quest adds them to the same assertion. No public API or wire change. diff --git a/quest/m1/auth/moq-transport.md b/quest/m1/auth/moq-transport.md deleted file mode 100644 index 8bf5a64270..0000000000 --- a/quest/m1/auth/moq-transport.md +++ /dev/null @@ -1,101 +0,0 @@ -# [M] moq-transport - -## Goal - -The grant exchange works on a moq-transport session between two moq-net -peers, with the same grant lifecycle through `Session::auth()`. This initial -IETF encoding supports prefix-representable grants and explicitly refuses -other pattern unions. An IETF publisher fails loud on an out-of-scope -PUBLISH_NAMESPACE before sending it. A -new `drafts/draft-lcurley-moq-auth.md` specifies it as an extension a -conforming peer can ignore. - -## Plan - -### Draft - -`drafts/draft-lcurley-moq-auth.md`, modeled on `draft-lcurley-moq-solicit.md` -for the setup option and on `draft-lcurley-moq-cluster.md` for the IANA -tables. It declares: - -- Setup Option `AUTH`, an even key in the `0x40B5x` series the cluster and - solicit options use, value `1`. Both endpoints send it; the extension is - negotiated only when both did, per the moqt extension rule that the set is - fixed once both SETUPs are seen. Draft-17 and later only, since that is the - first unified SETUP, the same gate `cluster::supported` applies. -- One AUTH request stream per token, living as long as the token, the way a - subscribe request stream outlives its SUBSCRIBE_OK. AUTH carries a Request - ID like every request plus the token; AUTH_OK and AUTH_ERROR carry the lite - fields and may repeat on the stream with the lite update and revoke - meanings; closing the stream withdraws the token. Track namespaces are - tuples on this wire, so a prefix is a namespace tuple, matching how - SUBSCRIBE_NAMESPACE spells one. Sent only after negotiation; an endpoint - that receives one without negotiating closes with PROTOCOL_VIOLATION, which - is what moq-net already does for an unknown request stream. -- Which existing codes AUTH_ERROR reuses: `UNAUTHORIZED`, `EXPIRED_AUTH_TOKEN`, - `MALFORMED_AUTH_TOKEN`, and `NOT_SUPPORTED` from the request error registry. - An unrepresentable pattern grant returns `NOT_SUPPORTED`, surfaced by the - public handle as `Unsupported`, rather than widening it into a namespace - prefix. The shared request error mapping already supports that code. -- A note relating it to the AUTHORIZATION TOKEN setup option: a token - presented there is the connection credential an empty AUTH refers to. - -Cite [moq-wg #1854](https://github.com/moq-wg/moq-transport/issues/1854) in -the introduction: the grant answers which role a peer will play. Run -`just drafts check`; `doc/.vitepress/drafts.ts` discovers the file by name. - -### Grant conversion - -Keep the public grant pattern-valued. Convert only unions that can be expressed -exactly as namespace-prefix tuples; a subtree and the all-path grant are -representable, while exact-path, suffix, and segment-wildcard grants are not. -Validate the whole publish/subscribe union before emitting AUTH_OK. Never -silently drop a member, broaden it to a literal head, or leave the request -waiting indefinitely. An unsupported initial grant refuses that token; an -unsupported update revokes its previous grant with AUTH_ERROR and closes the -stream. Other tokens and work still covered by them remain on the session. - -Full-pattern IETF encoding would require a separately negotiated extension -revision and is outside this initial prefix-tuple contract. Document this -capability limit in the draft and token-in-band guidance. - -### Code - -`rs/moq-net/src/ietf/auth.rs` beside `solicit.rs` and `cluster.rs`: the setup -option round trip with the tri-state `from_setup` shape solicit uses, the -three messages as `Message` impls with IDs from the draft, and negotiation -recorded on the peer state. `run_dispatch` in `ietf/session.rs` routes an AUTH -request stream to the shared `auth::Handle` the lite wire already uses; -`ietf::start` opens the empty-token -stream after SETUP when negotiated and answers the peer's from the origin -handles exactly as lite does, and `add` opens further ones. The fail-loud -check moves into the shared handle so the IETF subscriber half consults it -before a new PUBLISH_NAMESPACE, aborting with `Unauthorized` and the path. -A shrinking grant instead withdraws previously authorized publications and -cancels only affected subscriptions, preserving the session as lite does. - -`js/net/src/ietf/auth.ts` mirrors it, wired through `handshake.ts` like -`Ietf.Cluster.intoSetup` and `fromSetup`, and `ietf/connection.ts` dispatches -the request streams. - -Version-gate on draft-17+; earlier drafts leave `grant()` at `None` and `add` -at `Unsupported`. - -### Tests - -Cross-language fixtures cover prefix unions and the root grant, refusal of -exact/suffix/segment-wildcard and mixed unions, plus an update from a supported -grant to an unsupported one that revokes the old permission without changing -other tokens. Assert `Unsupported` completes promptly and no unauthorized -PUBLISH_NAMESPACE is sent. - -Setup option round trip on every supported draft and absence on 14 to 16; -negotiation requires both sides; grants from scoped origins over an IETF -session in Rust, JS, and across; two tokens union and closing one shrinks the -union without disconnecting, cancelling only work that loses authorization; -an out-of-scope new publication aborts before any PUBLISH_NAMESPACE is written; a -peer without the option (the relay built without it, and the interop runner's -reference relay) sees no AUTH stream and keeps working. Run -`just test interop --all`. - -On main, additive. diff --git a/quest/m1/auth/token-in-band.md b/quest/m1/auth/token-in-band.md index 6b9077aaf1..5bd8bdbff4 100644 --- a/quest/m1/auth/token-in-band.md +++ b/quest/m1/auth/token-in-band.md @@ -75,5 +75,3 @@ Additive. widen path the configured tokens reuse - [Bindings](/quest/m1/auth/bindings.md) - supplies the client surface the new token setters sit beside -- [moq-transport](/quest/m1/auth/moq-transport.md) - supplies the IETF AUTH - exchange the setup-option token pairs with diff --git a/quest/m1/cli-import-clock.md b/quest/m1/cli-import-clock.md new file mode 100644 index 0000000000..ccdf7f764b --- /dev/null +++ b/quest/m1/cli-import-clock.md @@ -0,0 +1,34 @@ +# [M] CLI imports publish on the broadcast clock + +## Goal + +`moq import` of fMP4, TS, and FLV publishes timestamps on the shared broadcast +clock, like native capture and `js/publish` already do, including source +restarts, late first frames, and real idle gaps. Today the imports publish +source PTS verbatim against a wall clock sampled at startup, so a TS feed with +a large starting PTS or a late first frame advertises the wrong wall time. + +## Plan + +Use `moq_mux::Clock` and `SourceMap` with the root catalog `clock`; this adds +no clock API or catalog field. Select each source's initial mapping once, +account for a delayed first frame, and translate source resets onto the same +monotonic clock while preserving real idle gaps. System-wall adjustments do not +retime a running broadcast or old archive records. Preserve allowed B-frame +ordering within a group. + +- fMP4 is passthrough, so translation must rewrite `tfdt`. +- A muxed source needs one mapping for all of its tracks, since interleaved + audio and video can step back further than `SourceMap::MAX_REORDER`. +- Keep conversion at the adapter boundary and refuse an unmappable source + explicitly. Discontinuity markers signal the existing playhead contract; + they do not replace the wall epoch. + +CI fixtures drive the import path, not only the clock helper: simultaneous +A/V, a late first frame, a restart to zero, a restart after idle, and retained +archive playback. Update the import docs. + +## Related + +- [Native clock fixtures](/quest/m1/native-clock-fixtures.md) - the same scenarios through native capture +- [GStreamer clock](/quest/m1/3021-moq-gst-anchor-generated-media-timelines-to-wall-clock.md) - separate source adapter diff --git a/quest/m1/effect-cancel.md b/quest/m1/effect-cancel.md index 037be1cdd3..540fa9db1a 100644 --- a/quest/m1/effect-cancel.md +++ b/quest/m1/effect-cancel.md @@ -3,15 +3,10 @@ ## Goal `@moq/signals` no longer exports `Effect.cancel`. Its only use was racing a -per-run pending promise, which leaks a reaction per call; -[JS retention](/quest/m0/js-retention.md) deprecates it in favor of -`effect.race` and moves every internal caller off it. +per-run pending promise, which leaks a reaction per call. It is already +marked `@internal`, and every internal caller uses `effect.race` instead. ## Plan A published break to `@moq/signals`, so it targets dev. Delete the getter and the promise backing it, and fix any caller or doc the deprecation left behind. - -## Required - -- [JS retention](/quest/m0/js-retention.md) - adds `effect.race` and moves the callers diff --git a/quest/m1/ietf-publish-done.md b/quest/m1/ietf-publish-done.md deleted file mode 100644 index 27e2f8e671..0000000000 --- a/quest/m1/ietf-publish-done.md +++ /dev/null @@ -1,21 +0,0 @@ -# [S] IETF subscriptions end cleanly - -## Goal - -A moq-transport subscription whose publisher finishes the track ends cleanly -for its subscriber, on every negotiated draft, the way it does over moq-lite. -Today it ends in error. - -## Plan - -Found while testing announce-to-serve over the mock session -(`rs/moq-net/tests/announce_to_serve.rs`): a finished track arrives whole, -then the subscriber reports `short buffer` instead of the end. The Rust -publisher writes PUBLISH_DONE before closing the subscribe stream, and the -Rust subscriber's `run_subscribe` waits on `stream.reader.poll_closed`, which -treats any trailing bytes as a decode error, so it never reads the message. -Decode PUBLISH_DONE and map its status to a clean finish or an abort. Check -`js/net`'s IETF subscriber for the same gap. - -Once fixed, the IETF case in `announce_to_serve.rs` stops special-casing the -track's end: it should match the local and moq-lite runs exactly. diff --git a/quest/m1/lite-stream-count.md b/quest/m1/lite-stream-count.md new file mode 100644 index 0000000000..30f8a05363 --- /dev/null +++ b/quest/m1/lite-stream-count.md @@ -0,0 +1,43 @@ +# [M] moq-lite-07 counts group streams instead of dropping groups + +## Goal + +On moq-lite-07, a subscriber knows a subscription has delivered everything +once it has seen as many group streams as the publisher opened, the way +moq-transport's PUBLISH_DONE Stream Count works. SUBSCRIBE_DROP is gone from +lite-07: a group the publisher skipped or never opened is simply not counted, +so nothing has to name it. Published versions (lite-01 to -06) keep decoding +SUBSCRIBE_DROP unchanged. + +Where reliable reset is negotiated, the count is exact and the subscriber +waits for nothing else. Where it is not (browsers today), a stream reset +before its header arrived is still invisible, so the track-tail grace stays. + +## Plan + +- Wire: SUBSCRIBE_END gains `Stream Count`, the number of group streams the + publisher opened for this subscription. The publisher sends it once every + group stream below the end has been opened (not finished), like + PUBLISH_DONE, so the boundary arrives slightly later than today. Remove + SUBSCRIBE_DROP and its type from lite-07 and reword the Subscribe Stream + section: the FIN follows once every counted stream has finished or been + reset. lite-07 is unpublished, so this changes it in place; update + `drafts/draft-lcurley-moq-lite.md` and its changelog. +- Rust and JS publishers count the streams they open per subscription and + send the count; a relay counts its own downstream streams, never forwarding + the upstream count. +- Subscribers on lite-07 stop waiting once the count is reached, accepting a + late stream below the end within the grace. On lite-05 and -06, the + DROP accounting from JS track tail (#4086) stays as it is. +- Tests in both languages: a late stream after SUBSCRIBE_END, a skipped group + that is never counted, a reset stream, and a count of zero. Add a Rust-JS + interop case. + +This lands before lite-07 is published. Rust has never sent or acted on +SUBSCRIBE_DROP, so [Rust track tail](/quest/m1/rust-track-tail.md) builds its +lite accounting on the count rather than on drops. + +## Related + +- [Rust track tail](/quest/m1/rust-track-tail.md) - builds on this count for moq-lite +- [Reliable stream reset](/quest/m1/quic/reliable-reset.md) - makes the count exact by keeping a reset stream's header diff --git a/quest/m1/native-clock-fixtures.md b/quest/m1/native-clock-fixtures.md new file mode 100644 index 0000000000..88b158e028 --- /dev/null +++ b/quest/m1/native-clock-fixtures.md @@ -0,0 +1,19 @@ +# [S] Native capture proves the broadcast clock in CI + +## Goal + +Per-PR CI drives the native video and audio capture publishers through clock +edge cases and asserts the published timestamps: simultaneous A/V, a late +first frame, a restart to zero, a restart after idle, a system-wall +adjustment, and retained archive playback. Anything they catch is fixed here. + +## Plan + +Native video already maps the device timeline onto `catalog.clock()` at open, +and native audio stamps arrival on it. The fixtures exercise publisher +integration with a synthetic device source and an injected clock, rather than +only the clock helper. No new clock API or catalog representation. + +## Related + +- [CLI import clock](/quest/m1/cli-import-clock.md) - the same scenarios through `moq import` diff --git a/quest/m1/nvenc-teardown.md b/quest/m1/nvenc-teardown.md new file mode 100644 index 0000000000..cde5459bee --- /dev/null +++ b/quest/m1/nvenc-teardown.md @@ -0,0 +1,26 @@ +# [S] A failed NVENC encode does not hang shutdown + +## Goal + +After NVENC rejects an encode (P7 with high-quality tuning returns +`InvalidParam`), the process shuts down promptly. Today it hangs on exit. + +## Plan + +Reproduce it first with the `encode-presets` example from #4099. Suspects, +from reading the code: `encode::Sink` runs NVENC on the `moq-video-encode` +thread, and `Worker::drop` joins it. That thread then drops the encoder, and +`Session::drop` calls a synchronous end-of-stream `encode_picture`. A +`Pending` that did not finish runs a blocking `lock_bitstream`. Either one can +wedge on a session the driver already refused, and the join then waits +forever. + +Fix the cause, for example by skipping end-of-stream on a session whose +encode failed. A timeout on the join doesn't count as a fix. Add a regression +test that forces the failing configuration on hardware where NVENC exists and +asserts teardown returns. Wire it into the nightly GPU lane if there is one; +otherwise say where it runs. + +## Related + +- [NVENC recovery](/quest/m1/nvenc-recovery.md) - the other NVENC failure path, rate changes and partial init diff --git a/quest/m1/publisher-clock.md b/quest/m1/publisher-clock.md deleted file mode 100644 index 23f437f7c9..0000000000 --- a/quest/m1/publisher-clock.md +++ /dev/null @@ -1,33 +0,0 @@ -# [L] Use the broadcast clock across media publishers - -## Goal - -Native video/audio capture, CLI imports, and `js/publish` publish timestamps -against the shared broadcast clock, including source and encoder restarts. -A live-only publisher exposes its clock without constructing an archive. - -## Plan - -Use the dev clock owner and catalog schema. Select each source's initial mapping -once, account for delayed first frames, and translate source resets onto the -same monotonic clock while preserving real idle gaps. Share the clock between -audio and video. System-wall adjustments do not retime a running broadcast or -old archive records. Preserve allowed B-frame ordering within a group. - -Keep source-specific timestamp conversion at the adapter boundary. Refuse an -unmappable source explicitly. Discontinuity markers signal the existing -playhead contract; they do not replace the wall epoch. In `js/publish`, the -video encoder marks a break through `Container.Legacy.Producer.cut()` whenever -its encode loop stops (demand gap or capture swap). The audio encoder writes its -own marker on a demand gap only: an audio pipeline rebuild and the framer's -input-gap reset still write none. - -Add CI fixtures for simultaneous A/V, late first frames, restart to zero, -restart after idle, system-wall adjustment, and retained archive playback. -The fixtures must exercise publisher integration rather than only the clock -helper. Update publisher and import docs; this quest adds no new clock API or -catalog representation. GStreamer's clock observation remains its own quest. - -## Related - -- [GStreamer clock](/quest/m1/3021-moq-gst-anchor-generated-media-timelines-to-wall-clock.md) - separate source adapter diff --git a/quest/m1/rust-track-tail.md b/quest/m1/rust-track-tail.md index 09a528d793..5af1ff663d 100644 --- a/quest/m1/rust-track-tail.md +++ b/quest/m1/rust-track-tail.md @@ -47,6 +47,10 @@ session in `rs/moq-net/tests/support`. Add a Rust-JS interop case to `just test smoke --all` for a publisher that ends a track with a group still in flight. +## Required + +- [lite-07 stream count](/quest/m1/lite-stream-count.md) - the moq-lite accounting this builds on, instead of SUBSCRIBE_DROP + ## Related - [JS track tail](/quest/m1/js-track-tail.md) - the same rule in `@moq/net` diff --git a/quest/m1/signal-race.md b/quest/m1/signal-race.md new file mode 100644 index 0000000000..90695f5847 --- /dev/null +++ b/quest/m1/signal-race.md @@ -0,0 +1,30 @@ +# [S] Signal.race releases its listeners when it loses a race + +## Goal + +`Signal.race` from `@moq/signals` no longer leaves a `changed` listener on +each signal when its own promise is raced and loses. Today it disposes them +only when one of the signals changes, so `js/net/src/origin.ts`'s +`#changed()`, raced against `closed` in `connection/forward.ts`, keeps +listeners for as long as the table stays quiet. Awaiting it directly still +works, with the same signature. + +## Plan + +Make it consistent with the free `race()` and `effect.race` from +[JS retention](https://github.com/moq-dev/moq/pull/4085), which already +subscribe to `Once`/`GetPromise` values and dispose them when the race +settles. `Signal.race` returns that same kind of awaitable instead of a +native promise: it attaches its signal listeners when first awaited or +subscribed, and releases them when it settles or when its last subscriber +detaches. Racing it through `race()` or `effect.race` then cleans up both +sides. Settle the exact return type at PR time, keeping `await` source +compatible; if the change is a published type break, stop and bring it back. + +Audit the callers in `js/net` (`announced`, `broadcast`, `group`, `origin`, +`track`, both subscribers) for the ones that race the result, and add a +listener-count test for the `origin.ts` case that fails today. + +## Required + +- JS retention (#4085) merged, which adds the free `race()` this builds on diff --git a/quest/m1/tokio-test-ports.md b/quest/m1/tokio-test-ports.md deleted file mode 100644 index f94afdb374..0000000000 --- a/quest/m1/tokio-test-ports.md +++ /dev/null @@ -1,17 +0,0 @@ -# [XS] moq-tokio tests bind their ports independently - -## Goal - -moq-tokio's integration tests never fail on a port another process or -parallel test holds. `websocket_forbidden_does_not_end_a_quic_connect` -(`rs/moq-tokio/tests/broadcast.rs`) fails about 1 in 15 local runs today. - -## Plan - -`test_server()` takes an ephemeral UDP port and then binds TCP on the same -number, which was never reserved on TCP. Bind the QUIC and WebSocket -listeners each on `:0` and hand the test client the WebSocket port -explicitly, adding a client config override for the fallback URL's port if -none exists (test-only if possible; otherwise note it as a public API -addition). No retry: this replaces the bounded bind retry -[#4055](https://github.com/moq-dev/moq/pull/4055) added. No wire change. diff --git a/quest/m2/2819-moq-video-carry-pipewire-dma-bufs-safely-into-the-vulkan.md b/quest/m2/2819-moq-video-carry-pipewire-dma-bufs-safely-into-the-vulkan.md index b1ec015db1..aa313e8dba 100644 --- a/quest/m2/2819-moq-video-carry-pipewire-dma-bufs-safely-into-the-vulkan.md +++ b/quest/m2/2819-moq-video-carry-pipewire-dma-bufs-safely-into-the-vulkan.md @@ -46,7 +46,7 @@ The PipeWire producer must therefore retain the dequeued buffer until the last ` - \[x] Document the wgpu device feature required for DMA-BUF import. A custom device-creation helper is unnecessary with wgpu 30. - \[ ] Import multi-plane NV12 with explicit DRM modifier plane layouts. - \[ ] Copy imported NV12 Y/UV planes into wgpu-sampleable R8/RG8 textures without touching the CPU. -- \[ ] Handle unsupported Intel tiling with a VAAPI VPP re-tile path. The current `moq-vaapi` API does not expose VPP yet. +- \[ ] Handle unsupported Intel tiling with a VAAPI VPP re-tile path. `Processor` blits exist (moq-vaapi 0.1.0); this item is the renderer using one when a modifier will not import. #### Validation gates @@ -75,4 +75,5 @@ into VAAPI or NVENC. ## Related +- [Capture multi-plane PipeWire cameras](/quest/m2/pipewire-camera-planes.md) - separate memory blocks from a camera, which is the capture offer rather than this renderer import - [#2893: video: validate PipeWire DMA-BUF capture on KDE hardware](/quest/m3/2893-video-validate-pipewire-dma-buf-capture-on-kde-hardware.md) - related open work diff --git a/quest/m2/README.md b/quest/m2/README.md index 33404a8e59..1a0426d872 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -33,10 +33,13 @@ upstream release waits in [m4](/quest/m4/README.md). - [Media Foundation encode](/quest/m2/audio-encode-mediafoundation.md) - Windows encodes AAC-LC - [MediaCodec decode](/quest/m2/audio-decode-mediacodec.md) - Android decodes HE-AAC, multichannel AAC, and what else the device offers - [MediaCodec encode](/quest/m2/audio-encode-mediacodec.md) - Android encodes AAC-LC +- [AAC encode refusal](/quest/m2/aac-encode-refusal.md) - AAC config encode refuses channel counts it cannot name, on dev - [Video codec coverage](/quest/m2/video-codec-coverage.md) - prioritize remaining native AV1 and portable decoder gaps - [#2147](/quest/m2/2147-moq-video-10-bit-hevc-and-av1-support-in-the-nvidia-codec.md) - moq-video: 10-bit HEVC and AV1 support in the NVIDIA codec path +- [NVENC buffer pool](/quest/m2/nvenc-pool.md) - NVENC reuses input and output buffers instead of allocating per frame, if a benchmark shows it wins - [Direct3D11 render import](/quest/m2/render-d3d11.md) - Windows presents without downloading every frame to system memory - [Intra-refresh GOPs](/quest/m2/intra-refresh/README.md) - video with periodic intra refresh publishes, imports, and tunes in cleanly with one group per sweep and a catalog `warmup` +- [Capture multi-plane PipeWire cameras](/quest/m2/pipewire-camera-planes.md) - I420 and NV12 cameras that deliver one memory block per plane - [#2819](/quest/m2/2819-moq-video-carry-pipewire-dma-bufs-safely-into-the-vulkan.md) - moq-video: carry PipeWire DMA-BUFs safely into the Vulkan renderer - [Unreal prototype](/quest/m2/unreal.md) - a UE5 module on the C++ package with exceptions disabled, rendering a subscribed broadcast to a texture - [Unity prototype](/quest/m2/unity.md) - the C# package under IL2CPP, playing subscribed audio @@ -44,6 +47,7 @@ upstream release waits in [m4](/quest/m4/README.md). - [vcpkg registry](/quest/m2/cpp-vcpkg.md) - a registry we own serves the prebuilt package to `vcpkg` manifests - [Conan remote](/quest/m2/cpp-conan.md) - a remote we own serves the same tarball to `conan install` - [Compressed tracks](/quest/m2/flate/README.md) - any track compresses per group from every language, not only the JSON modes +- [Binary delta stats](/quest/m2/stats-delta.md) - an on-demand varint delta flavor of every stats track, if relay encode CPU still matters after the JSON fixes - [#3115](/quest/m2/3115-moqsink-the-publication-has-no-generation-so-a-flush.md) - moqsink: a flushing restart after EOS opens a new publication generation - [Redundant ingest](/quest/m2/redundant-ingest.md) - decide whether two publishers sharing one epoch may splice, and who declares the incumbent dead before the keep-alive does - [Multipath spike](/quest/m2/multipath-spike.md) - whether bonded contribution over multipath QUIC is worth building, given it needs noq on both ends @@ -56,6 +60,7 @@ upstream release waits in [m4](/quest/m4/README.md). - [L4S on the backbone](/quest/m2/quic-ecn.md) - an ECT(1) option in the fork, an `ecn` config knob, and a dualpi2 measurement - [Careful resume on reconnect](/quest/m2/quic-careful-resume.md) - a redial starts at the previous connection's rate - [Keep-alive by deadline](/quest/m2/quic-keep-alive.md) - a PING only when the idle deadline nears, no fixed timer +- [noq socket close](/quest/m2/noq-socket-close.md) - noq releases an endpoint's socket on close, so moq-tokio drops its wrapper - [Bounded announce prefix table](/quest/m2/announce-prefix-table.md) - compress repeated path tuples on each ordered lite-08 announce stream, with bounded state and measured QUIC-byte savings - [Drop the hidden cluster exemption](/quest/m2/hidden-exemption.md) - relays stop forcing hidden broadcasts on cluster peers once every peer opts in on the wire - [Routing cost domains](/quest/m2/routing-cost-domains.md) - design operator boundaries and policy without adding incomparable costs diff --git a/quest/m2/aac-encode-refusal.md b/quest/m2/aac-encode-refusal.md new file mode 100644 index 0000000000..3008bf0702 --- /dev/null +++ b/quest/m2/aac-encode-refusal.md @@ -0,0 +1,22 @@ +# [S] AAC encode refuses a channel count it cannot name + +## Goal + +Writing an AudioSpecificConfig for a channel count that no AAC +channelConfiguration names is an error, not a stereo config with a warning, +in `moq_mux::codec::aac::Config::encode` and `@moq/hang`'s +`audioSpecificConfig`. This mirrors the parse side, which since #4093 refuses +reserved values instead of guessing stereo. + +## Plan + +Both functions become fallible, a published API break in each language, so +this targets `dev`. Counts with a PCE-free configuration map as today. For +the others, either write channelConfiguration 0 with a PCE derived from the +layout, or refuse. Pick one at PR time and apply it in both languages. Test +every count from 1 to 8 and one beyond. + +## Related + +- [AAC PCE](https://github.com/moq-dev/moq/pull/4093) - the parse half +- [Layout](/quest/m1/audio-codecs/layout.md) - the layout a PCE would be derived from diff --git a/quest/m2/noq-socket-close.md b/quest/m2/noq-socket-close.md new file mode 100644 index 0000000000..3fa7a9621b --- /dev/null +++ b/quest/m2/noq-socket-close.md @@ -0,0 +1,21 @@ +# [M] noq releases an endpoint's socket on close + +## Goal + +A noq endpoint can close its UDP socket, and report when it has, without +waiting for every connection handle to drop. moq-tokio then deletes the +closable socket wrapper that #4087 added to make `Listener::close` release the +port. + +## Plan + +In moq-dev/noq, add an endpoint operation that closes the endpoint, waits +until each connection has sent its close, and then releases the socket. Later +sends are dropped and receives end. Test it there. Cut a noq release, bump the +pin, and replace moq-tokio's wrapper with the upstream call. The Go +`TestReconnectAcrossRelayRestart` and moq-tokio's +`close_releases_quic_socket` stay green. + +## Related + +- [Listener close](https://github.com/moq-dev/moq/pull/4087) - the local wrapper this replaces diff --git a/quest/m2/nvenc-pool.md b/quest/m2/nvenc-pool.md new file mode 100644 index 0000000000..c1723df728 --- /dev/null +++ b/quest/m2/nvenc-pool.md @@ -0,0 +1,18 @@ +# [M] NVENC reuses its input and output buffers + +## Goal + +The NVENC backend stops allocating per frame. At 1080p, creating the output +bitstream and input buffer (or registering the CUDA resource) is about half +of frame-to-packet time. A benchmark shows the pooled path is faster at +720p and 1080p, with the numbers in the PR. If it doesn't win, abandon the +quest and report the numbers. + +## Plan + +`rs/moq-video/src/encode/backend/nvenc.rs` calls `create_output_bitstream`, +`create_input_buffer` (CPU frames) or `register_generic_resource` (CUDA +frames) on every `encode`, and frees them all at the end of the call. Keep a +small pool sized by the frames in flight, which is one today since B-frames +are off. Re-register a CUDA resource only when its pointer changes. Measure +with the `encode-presets` example from #4099. diff --git a/quest/m2/obs-linux-gpu.md b/quest/m2/obs-linux-gpu.md index 7bad013317..7d29c34ec7 100644 --- a/quest/m2/obs-linux-gpu.md +++ b/quest/m2/obs-linux-gpu.md @@ -7,7 +7,7 @@ A supported Linux OBS graphics/encoder combination publishes composited video wi ## Plan - Start with an API feasibility probe. OBS exposes DMA-BUF import in `graphics.h`; that is not proof its compositor textures can be exported. Determine whether EGL/GL allocation export is available, or whether an upstream OBS hook or an encoder-owned exportable render target is required. -- The current VAAPI adapter converts every surface through I420 and uploads NV12. Coordinate its native import with the existing VAAPI quest; OBS allocation export alone cannot remove that download. +- moq-video encodes a `Surface::DmaBuf` on VAAPI without a download, and scales one through VPP (moq-vaapi 0.1.0). This quest still has to turn an OBS compositor texture into a DMA-BUF that import accepts. - Negotiate DRM device, fourcc, plane offsets/strides, modifiers, and synchronization. Reuse `Surface::DmaBuf` and the hardware encoder's real import path, retaining allocation ownership until completion. A borrowed fd or an importable packed RGB texture does not prove the encoder accepts NV12 on the same device. - Prefer direct import; otherwise GPU-convert into exportable NV12 surfaces. Document and measure each GPU copy. Keep CPU staging as a visible fallback, not as the successful accelerated result. - Validate at least one Intel/AMD VAAPI path on hardware before promising broad support. Scope NVIDIA/CUDA interoperability separately if it needs another allocation or synchronization strategy. Test unsupported modifiers, multi-plane buffers, fd closure, cancellation, device loss, resize, and repeated pool reuse. @@ -15,10 +15,9 @@ A supported Linux OBS graphics/encoder combination publishes composited video wi ## Required -- [VAAPI encode and decode](/quest/m4/video-vaapi.md) - supply the native DMA-BUF encoder import required by the Intel/AMD no-readback path - - [Encoder adapter](/quest/m1/obs-moq-video/adapter.md) - frame ownership, queue policy, packet output, and comparison baseline ## Related +- [VAAPI encode and decode](/quest/m4/video-vaapi.md) - H.265 and checked-in bindings. The DMA-BUF encoder import this quest needs is already on main (moq-vaapi 0.1.0). - [Video hardware validation](/quest/m3/video-hardware.md) - native input and encoder acceptance need hardware evidence diff --git a/quest/m2/pipewire-camera-planes.md b/quest/m2/pipewire-camera-planes.md new file mode 100644 index 0000000000..d33e8fb114 --- /dev/null +++ b/quest/m2/pipewire-camera-planes.md @@ -0,0 +1,16 @@ +# [M] Capture multi-plane PipeWire cameras + +## Goal + +A PipeWire camera that delivers I420 or NV12 in separate memory blocks produces frames. Single-block cameras keep working. Other pixel formats stay unsupported. Importing multi-plane NV12 into Vulkan stays with the PipeWire DMA-BUF quest. + +## Plan + +The buffer offer sets `SPA_PARAM_BUFFERS_blocks` to 1, so a producer that puts each plane in its own block never links. NV12 is already negotiated. Offer up to two blocks for NV12 and map them into the NV12 conversion that exists. I420 is not. `camera::RAW_FORMATS` drops it before selection, and `convert` has no I420 arm, so negotiate it, offer up to three blocks, and pack its planes into the I420 frame the rest of the pipeline already takes. A one-block buffer of a format already supported keeps the current mapping. + +Unit-test the offer, a multi-block NV12 buffer, and a multi-block I420 buffer, with no camera attached. `doc/lib/rs/moq-video.md` already says a Pi CSI camera and a sandboxed camera are reachable. This quest is what makes the separate-plane case of that sentence true. No new page. + +## Related + +- [Validate PipeWire cameras on a portal and a Pi](/quest/m3/pipewire-camera-hardware.md) - the pass that shows whether a real Pi or portal camera delivers separate planes +- [PipeWire DMA-BUFs into Vulkan](/quest/m2/2819-moq-video-carry-pipewire-dma-bufs-safely-into-the-vulkan.md) - multi-plane NV12 import in the renderer diff --git a/quest/m2/stats-delta.md b/quest/m2/stats-delta.md new file mode 100644 index 0000000000..19bd074bb2 --- /dev/null +++ b/quest/m2/stats-delta.md @@ -0,0 +1,115 @@ +# [M] Binary delta stats flavor + +## Goal + +A consumer can request a binary delta flavor of every stats track (name TBD, +for example `publisher.bin.z`, `subscriber.bin.z`, `sessions.bin.z`, per +tier) that a relay encodes and a reader decodes several times cheaper than +`.json.z`, in fewer bytes. The relay serves it only on request, so an +unrequested flavor costs nothing, and `moq_stats::Consumer`, the aggregate, +and a dependency-free JS decoder read it into the same frame types as JSON. +The JSON tracks stay unchanged on the wire. + +Not here: replacing or deprecating `.json` / `.json.z`, a relay config switch, +or moving the demo dashboard off JSON. + +## Plan + +**Gate first.** Bandwidth is small in absolute terms (about 0.8 Mbps per +stats subscriber in the worst scenario below), so bytes alone do not justify +a new format. The motivation is relay encode CPU at 10k+ broadcasts and +aggregator fan-in. #4019 already cut `.json.z` decode allocations about 42x +(time 2.5-4x) by dropping per-key path tracking; `rs/moq-stats/benches/decode.rs` +measures it. Proceed only if CPU still matters after a profile of the +moq-json snapshot encoder (about 100 ms per tick at 10k broadcasts) has fixed +or ruled out its cost. Re-run the benchmark against that baseline; if JSON is +close enough, abandon this quest with the numbers. + +**Evidence.** A prototype benchmark on top of #3955's commit `d86496be2` +simulated relay traffic from how `moq_net::stats` counts and how +`moq_stats::produce` emits (live-or-changed rows, pruned a drain after they +close), through the same group policy for every format and checked against +ground truth every tick. Only the traffic tracks were modelled, not +`sessions`. Bytes per tick, mean over 600 ticks: + +| format | steady 1k | churn (conference) | large 10k x3 tiers | idle cams 2k | +|---|---:|---:|---:|---:| +| `.json.z` today | 14735 | 55211 | 101472 | 888 | +| `.fb.z` full snapshot (#3955) | 73653 | 87048 | 616416 | 20906 | +| stable-slot FlatBuffers XOR | 8105 | 18798 | 57464 | 1040 | +| protobuf merge patch, absolute values | 10670 | 53294 | 97011 | 834 | +| custom varint delta | 3754 | 9494 | 25616 | 364 | + +At 10k broadcasts x 3 tiers, per tick: `.json.z` encodes in 98 ms and +decodes in 76 ms with 1M decode allocations (before #4019); a typed +`.json.z` merge patch (same wire, not landed) cut decode to 5.7 ms and 445 +allocations; the varint delta encodes in 10 ms and decodes in 0.8 ms with 12 +and 167 allocations. Against the best JSON decode, the remaining wins are +encode CPU (7-13x), bytes (2.4-5.8x), and a smaller decode margin (3-9x). + +What the numbers taught, so the design does not relearn it: + +- A full snapshot per frame fails once it outgrows DEFLATE's 32 KiB window; + that is what sank `.fb.z`. +- Absolute cumulative counters do not compress: each is 5-6 near-random + bytes. Varint deltas of monotonic counters are 1-3 bytes. +- Naive XOR against the previous frame keyframes on 95-99% of frames because + any row join or leave shifts the layout. + +**Format sketch** (the prototype's; adjust as the implementation learns): + +- Each group is one DEFLATE window, rolled by the same policy as + `moq_json::snapshot` (frame count, growth against the first frame, the + cache bound). The first frame of a group is a keyframe: every row's path + and absolute values. +- A delta frame carries new paths inline (ids assigned implicitly, continuing + the group's dictionary), then each changed row as `(id gap varint, + changed-field bitmask, one varint delta per set bit)`, then the removed ids + as gaps. Ids ascend within each list, so gaps are small. +- A counter that goes backwards is a remove plus a re-add under a new id, + which keeps every delta unsigned and matches the existing "a decrease starts + a fresh segment" rule. +- An unchanged tick writes no frame. + +Design points left open for the implementer: + +- Extensibility: if every field is a varint, a reader can skip mask bits it + does not know, so fields append without breaking old readers. Decide that + or refuse unknown bits loudly, and say which. +- Gauges: the [client stats](/quest/m1/qos/stats/README.md) extension adds + non-monotonic gauges (a latency, a target bitrate). Encode them absolute or + zigzag, or have a producer with a non-`()` extension refuse this flavor, and + document the contract. +- Measure `sessions` (`Presence`) too; the benchmark skipped it. +- Sweep the aggregate over node publishers x entries per node, for both + flavors; the benchmark above only varied one relay's stream, so it cannot + show the fan-in cost the gate cites. + +**Serving.** Like `.fb.z` was planned: a flavor suffix accepted by +`requested_track_shape` and the track-name helpers for every name that takes +`.json.z`, created only when requested rather than with the plain/compressed +pair. Weigh replacing `compressed: bool` in the helpers with a flavor enum, +which is a published API break and goes to `dev` unless additive. + +**Readers.** `Consumer` and the aggregate read either flavor into +`TrafficFrame` / `SessionsFrame`. The JS decoder is about 120 lines and +dependency-free apart from inflate (`@moq/flate`); read varints through +`BigInt` or split 32-bit halves, since counters exceed 2^53. It belongs with +the JS stats reader, `@moq/stats` if [browser reporters](/quest/m1/qos/stats/js.md) +has landed by then. Share fixtures between Rust and JS so the two stay wire +identical, and keep the benchmark in-tree, wired into CI at least nightly. + +**Docs and spec.** No IETF draft covers stats today. Write the format into +the stats format page ([stats-binary docs](/quest/m0/stats-binary/docs.md)) +and the stats section of `doc/bin/relay/config.md`, plus the moq-stats crate +docs. Whether stats needs its own `draft-lcurley-moq-stats.md` is the +maintainer's call; ask before writing one. + +Public API impact: additive on moq-stats unless the helpers change. Wire +impact: new on-demand tracks; existing tracks unchanged. + +## Related + +- [Stats format page](/quest/m0/stats-binary/docs.md) - where the new flavor is documented +- [Client stats](/quest/m1/qos/stats/README.md) - the extension and gauges the format must carry or refuse +- [Compressed tracks](/quest/m2/flate/README.md) - the group-window discipline this flavor repeats diff --git a/quest/m2/teleop/correlation.md b/quest/m2/teleop/correlation.md index 7a0c44f451..779d6f6e07 100644 --- a/quest/m2/teleop/correlation.md +++ b/quest/m2/teleop/correlation.md @@ -30,4 +30,4 @@ the same property that makes an MCAP recording valuable. ## Required - [Robot teleoperation primitive](/quest/m2/teleop/robot.md) -- [Publisher clocks](/quest/m1/publisher-clock.md) - publishers populate the fixed broadcast mapping used to join tracks +- [CLI import clock](/quest/m1/cli-import-clock.md) - publishers populate the fixed broadcast mapping used to join tracks diff --git a/quest/m3/README.md b/quest/m3/README.md index 8442c55773..d4403e5bef 100644 --- a/quest/m3/README.md +++ b/quest/m3/README.md @@ -16,6 +16,7 @@ condition clears, move the quest to the milestone its work belongs in. - [DPDK](/quest/m3/dpdk.md) - a kernel-bypass UDP path for the relay, once a provider offers SR-IOV or bare metal - [Video hardware validation](/quest/m3/video-hardware.md) - run the encode, capture, and zero-copy paths that were written but never run on real machines +- [Validate PipeWire cameras on a portal and a Pi](/quest/m3/pipewire-camera-hardware.md) - run the shipped PipeWire camera through the camera portal and a Pi CSI node - [#2893](/quest/m3/2893-video-validate-pipewire-dma-buf-capture-on-kde-hardware.md) - video: validate PipeWire DMA-BUF capture on KDE hardware - [Embedded video](/quest/m3/video-embedded.md) - EGL import in the renderer, so moq-video presents on a Pi - [Vision worker](/quest/m3/processor-vision.md) - a documented customer-run vision worker proves the processor contract diff --git a/quest/m3/pipewire-camera-hardware.md b/quest/m3/pipewire-camera-hardware.md new file mode 100644 index 0000000000..29ac05026d --- /dev/null +++ b/quest/m3/pipewire-camera-hardware.md @@ -0,0 +1,23 @@ +# [S] Validate PipeWire cameras on a portal and a Pi + +## Goal + +The xdg-desktop-portal Camera path and a Raspberry Pi CSI camera each capture frames through the PipeWire camera that already shipped, or this quest records what stopped the pass. There is no new capture API. + +## Plan + +Open `pipewire` and one `pipewire:` in a sandbox, where the portal raises its permission dialog, and on a Pi whose CSI camera is a PipeWire node (spa-libcamera). Record the mode that opened, whether frames arrived, and whether the producer used one memory block or one per plane. + +Fix only a defect the pass hits. A separate-plane producer belongs to the multi-plane quest. If that is why a Pi produces nothing, write that down and stop. `doc/lib/rs/moq-video.md` says both paths are reachable. If a path cannot capture, correct that sentence in the same change. + +A libcamera source stays out. Embedded video already leaves `rpicam-vid` to the application. This pass uses the PipeWire camera only. + +## Required + +- A sandbox that can show the camera portal dialog, and a Raspberry Pi whose CSI camera appears as a PipeWire node + +## Related + +- [Capture multi-plane PipeWire cameras](/quest/m2/pipewire-camera-planes.md) - separate-plane I420 and NV12, when the pass finds them +- [Video hardware validation](/quest/m3/video-hardware.md) - the other encode and capture runs that still need a machine +- [Embedded video path](/quest/m3/video-embedded.md) - presenting on a Pi, which is a different gap diff --git a/quest/m3/video-hardware.md b/quest/m3/video-hardware.md index fac7614ee3..355e929ae5 100644 --- a/quest/m3/video-hardware.md +++ b/quest/m3/video-hardware.md @@ -10,15 +10,13 @@ real hardware get run on it, and what breaks gets fixed. Every item here is blocked on a physical machine rather than on code, which is why they sit together and why they sit in m3. -- **VAAPI encode on an Intel or AMD box**: low-power against full entrypoint, - the NV12 upload round trip, and `cargo deny` license resolution. The - backend's own comment says NOT YET VALIDATED ON HARDWARE. The opt-in `vaapi` - feature still dlopens libva, so this run either clears that comment or - records what still blocks it. It covers the - Intel-based ground robots and NUC companions the teleoperation line needs. -- **VAAPI zero-copy dmabuf input**: the backend uses an NV12 surface upload - today. Exercise the `Surface::DmaBuf` path with a V4L2 `VIDIOC_EXPBUF` - source instead. +- **VAAPI low-power entrypoint and a second GPU.** H.264 encode, DMA-BUF + input, and VPP resize ran on Intel Meteor Lake with iHD (moq-vaapi 0.1.0). + Still unrun: the low-power encode entrypoint, which that device does not + expose, and `MOQ_VAAPI_DEVICE` naming a node other than the first render + node. +- **VAAPI input from V4L2 `VIDIOC_EXPBUF`.** DMA-BUF encode ran from a VA-API + decode and from PipeWire. A V4L2 export has not been the source. - **Windows Media Foundation capture**: on-demand open and close, so the camera LED is off when nobody is watching, and NV12 delivery from MJPEG and YUY2 cameras. @@ -31,4 +29,5 @@ come from plain `cuMemAlloc`. That is not a bug any amount of review finds. ## Related -- [PipeWire DMA-BUF on KDE](/quest/m3/2893-video-validate-pipewire-dma-buf-capture-on-kde-hardware.md) - the same kind of gate, for the capture side +- [Validate PipeWire cameras on a portal and a Pi](/quest/m3/pipewire-camera-hardware.md) - the camera portal and a Pi CSI node, which are a different machine from this list +- [PipeWire DMA-BUF on KDE](/quest/m3/2893-video-validate-pipewire-dma-buf-capture-on-kde-hardware.md) - the same kind of gate, for screen capture diff --git a/quest/m4/README.md b/quest/m4/README.md index 4cda7b3210..de01a3059f 100644 --- a/quest/m4/README.md +++ b/quest/m4/README.md @@ -13,7 +13,8 @@ the milestone its priority belongs in. ## Quests -- [VAAPI encode and decode](/quest/m4/video-vaapi.md) - DMA-BUF encode, H.265 decode, and pre-generated bindings that remove the libclang build dependency, all gated on a moq-dev/vaapi release +- [VAAPI encode and decode](/quest/m4/video-vaapi.md) - H.265 encode and decode, and pre-generated bindings that remove the libclang build dependency, gated on a moq-dev/vaapi release +- [Pool VAAPI resize surfaces](/quest/m4/vaapi-resize-pool.md) - a resize reuses one output surface per size once moq-vaapi ships that pool - [#2907](/quest/m4/2907-bind-the-browser-through-moq-ffi-uniffi-instead-of-a.md) - the browser reaches moq-ffi through a generated TypeScript binding once a JS generator is stable - [Safari WebTransport](/quest/m4/safari-webtransport.md) - WebKit browsers return to WebTransport once WebKit 319818 ships fixed - [MSFTS convergence](/quest/m4/msfts-convergence.md) - the demultiplexed TS lane maps onto MSFTS ES-level carriage once msfts#33 settles the payload unit diff --git a/quest/m4/vaapi-resize-pool.md b/quest/m4/vaapi-resize-pool.md new file mode 100644 index 0000000000..6041e66901 --- /dev/null +++ b/quest/m4/vaapi-resize-pool.md @@ -0,0 +1,19 @@ +# [S] Pool VAAPI resize surfaces + +## Goal + +A VAAPI resize reuses one output surface per destination size instead of allocating one per frame. `Surface::resize` stays the same call. The decoder pool is unchanged. + +## Plan + +`Processor` in moq-vaapi allocates the blit output with `ExportedFrame::from_surface` on every resize. Keep one surface per output size. When the exported frame drops, that surface returns and the next blit of the same size uses it. A frame the consumer still holds is not overwritten; the processor allocates another. The pool keeps one free surface per size and destroys any surface returned past that, so a burst of released frames cannot park them all. That is the decoder pool's rule, applied to resize outputs. + +moq-video already blits through `Processor` and exports the result. The reuse test belongs in moq-vaapi. Here, bump the workspace requirement and confirm a resize still returns an NV12 DMA-BUF. + +## Required + +- A `moq-vaapi` release whose `Processor` reuses one output surface per destination size and destroys free surfaces past one per size + +## Related + +- [VAAPI encode and decode](/quest/m4/video-vaapi.md) - H.265 and checked-in bindings, the other moq-vaapi gate diff --git a/quest/m4/video-vaapi.md b/quest/m4/video-vaapi.md index e2bc947fce..1f3ee5658a 100644 --- a/quest/m4/video-vaapi.md +++ b/quest/m4/video-vaapi.md @@ -48,3 +48,7 @@ already falls back cleanly, since `Encoder::new` returns `Err` and - A `moq-dev/vaapi` release exposing an HEVC encoder (H.264 decode is in 0.0.4; DMA-BUF encode and VPP shipped in 0.1.0) and pre-generated bindings instead of a bindgen build script + +## Related + +- [Pool VAAPI resize surfaces](/quest/m4/vaapi-resize-pool.md) - reuse one VPP output surface per size, a separate moq-vaapi release diff --git a/rs/libmoq/src/api.rs b/rs/libmoq/src/api.rs index 16559d7a53..42bfcecbe7 100644 --- a/rs/libmoq/src/api.rs +++ b/rs/libmoq/src/api.rs @@ -433,6 +433,22 @@ pub struct moq_json_stream_config { pub compression: bool, } +/// Options for a binary data track, in either mode. +/// +/// The mode is fixed by which constructor is called ([moq_publish_binary_snapshot] or +/// [moq_publish_binary_stream]), so it is not in here. +#[repr(C)] +#[allow(non_camel_case_types)] +pub struct moq_binary_config { + /// DEFLATE-compress each payload, advertised in the catalog entry. + pub compression: bool, + + /// The payloads' media type (e.g. `image/jpeg`), or NULL to leave it unstated. + pub mime: *const c_char, + /// Length of `mime` in bytes. + pub mime_len: usize, +} + /// A JSON value delivered by a consumer callback. #[repr(C)] #[allow(non_camel_case_types)] @@ -2902,10 +2918,12 @@ pub extern "C" fn moq_publish_group_abort(group: u32, error_code: u16) -> i32 { /// Create a JSON snapshot track (lossy latest-value) on a broadcast. /// /// Values published via [moq_publish_json_snapshot_update] reach subscribers as a single latest -/// state; a late joiner only sees the newest. Advertise the track in the catalog with -/// [moq_publish_catalog_section] if consumers should discover it. +/// state; a late joiner only sees the newest. The track is advertised in the broadcast's catalog +/// under `json.tracks.` with `mode: snapshot` (and `compression: deflate` when set), and the +/// entry is retired when the track finishes or fails, so consumers discover it with no extra call. /// -/// Returns a non-zero handle to the JSON producer on success, or a negative code on failure. +/// Returns a non-zero handle to the JSON producer on success, or a negative code on failure, +/// including a mux error when the catalog already carries an entry named `name`. /// /// # Safety /// - The caller must ensure `name` is a valid pointer to `name_len` bytes and `config` a valid pointer. @@ -2920,13 +2938,9 @@ pub unsafe extern "C" fn moq_publish_json_snapshot( let broadcast = ffi::parse_id(broadcast)?; let name = unsafe { ffi::parse_str(name, name_len)? }; let config = unsafe { config.as_ref() }.ok_or(Error::InvalidPointer)?; - let mut producer = moq_json::snapshot::Config::default(); - producer.delta_ratio = config.delta_ratio; - producer.compression = if config.compression { - moq_json::Compression::Deflate - } else { - moq_json::Compression::None - }; + let producer = moq_mux::json::Config::default() + .with_compression(config.compression) + .with_delta_ratio(config.delta_ratio); State::lock().publish.json_snapshot(broadcast, name, producer) }) } @@ -2962,8 +2976,11 @@ pub extern "C" fn moq_publish_json_snapshot_finish(json: u32) -> i32 { /// Create a JSON stream track (lossless append-log) on a broadcast. /// /// Every record appended via [moq_publish_json_stream_append] is preserved and delivered in order. +/// The track is advertised in the broadcast's catalog under `json.tracks.` with +/// `mode: stream`, for as long as the track lives. /// -/// Returns a non-zero handle to the JSON stream producer on success, or a negative code on failure. +/// Returns a non-zero handle to the JSON stream producer on success, or a negative code on failure, +/// including a mux error when the catalog already carries an entry named `name`. /// /// # Safety /// - The caller must ensure `name` is a valid pointer to `name_len` bytes and `config` a valid pointer. @@ -2978,10 +2995,7 @@ pub unsafe extern "C" fn moq_publish_json_stream( let broadcast = ffi::parse_id(broadcast)?; let name = unsafe { ffi::parse_str(name, name_len)? }; let config = unsafe { config.as_ref() }.ok_or(Error::InvalidPointer)?; - let mut producer = moq_json::stream::Config::default(); - if config.compression { - producer.compression = moq_json::Compression::Deflate; - } + let producer = moq_mux::json::Config::default().with_compression(config.compression); State::lock().publish.json_stream(broadcast, name, producer) }) } @@ -3013,6 +3027,128 @@ pub extern "C" fn moq_publish_json_stream_finish(stream: u32) -> i32 { }) } +/// Parse a [moq_binary_config] into the mux's binary track config. +/// +/// # Safety +/// - `config` must be a valid pointer, and its `mime` a valid pointer to `mime_len` bytes when not NULL. +unsafe fn binary_config(config: *const moq_binary_config) -> Result { + let config = unsafe { config.as_ref() }.ok_or(Error::InvalidPointer)?; + let mut binary = moq_mux::binary::Config::default().with_compression(config.compression); + if let Some(mime) = unsafe { ffi::parse_str_optional(config.mime, config.mime_len)? } { + binary = binary.with_mime(mime); + } + Ok(binary) +} + +/// Create a binary snapshot track (lossy latest-value) on a broadcast: each payload supersedes the +/// last, and a late joiner only sees the newest, e.g. the latest thumbnail of a camera. +/// +/// The track is advertised in the broadcast's catalog under `binary.tracks.` with +/// `mode: snapshot` (plus `mime` and `compression` when set), and the entry is retired when the +/// track finishes or fails. +/// +/// Returns a non-zero handle to the binary producer on success, or a negative code on failure, +/// including a mux error when the catalog already carries an entry named `name`. +/// +/// # Safety +/// - The caller must ensure `name` is a valid pointer to `name_len` bytes and `config` a valid pointer. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn moq_publish_binary_snapshot( + broadcast: u32, + name: *const c_char, + name_len: usize, + config: *const moq_binary_config, +) -> i32 { + ffi::enter(move || { + let broadcast = ffi::parse_id(broadcast)?; + let name = unsafe { ffi::parse_str(name, name_len)? }; + let config = unsafe { binary_config(config)? }; + State::lock().publish.binary_snapshot(broadcast, name, config) + }) +} + +/// Publish a new payload to a binary snapshot track, superseding the last. +/// +/// Returns a zero on success, or a negative code on failure. +/// +/// # Safety +/// - The caller must ensure `payload` is a valid pointer to `payload_len` bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn moq_publish_binary_snapshot_update( + binary: u32, + payload: *const u8, + payload_len: usize, +) -> i32 { + ffi::enter(move || { + let binary = ffi::parse_id(binary)?; + let payload = unsafe { ffi::parse_slice(payload, payload_len)? }; + State::lock().publish.binary_snapshot_update(binary, payload) + }) +} + +/// Finish a binary snapshot track and retire its catalog entry. No more payloads can be published. +/// +/// Returns a zero on success, or a negative code on failure. +#[unsafe(no_mangle)] +pub extern "C" fn moq_publish_binary_snapshot_finish(binary: u32) -> i32 { + ffi::enter(move || { + let binary = ffi::parse_id(binary)?; + State::lock().publish.binary_snapshot_finish(binary) + }) +} + +/// Create a binary stream track (lossless append-log) on a broadcast: every payload is preserved +/// and delivered in order. +/// +/// The track is advertised in the broadcast's catalog under `binary.tracks.` with +/// `mode: stream` (plus `mime` and `compression` when set), for as long as the track lives. +/// +/// Returns a non-zero handle to the binary stream producer on success, or a negative code on +/// failure, including a mux error when the catalog already carries an entry named `name`. +/// +/// # Safety +/// - The caller must ensure `name` is a valid pointer to `name_len` bytes and `config` a valid pointer. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn moq_publish_binary_stream( + broadcast: u32, + name: *const c_char, + name_len: usize, + config: *const moq_binary_config, +) -> i32 { + ffi::enter(move || { + let broadcast = ffi::parse_id(broadcast)?; + let name = unsafe { ffi::parse_str(name, name_len)? }; + let config = unsafe { binary_config(config)? }; + State::lock().publish.binary_stream(broadcast, name, config) + }) +} + +/// Append one payload to a binary stream track. +/// +/// Returns a zero on success, or a negative code on failure. +/// +/// # Safety +/// - The caller must ensure `payload` is a valid pointer to `payload_len` bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn moq_publish_binary_stream_append(stream: u32, payload: *const u8, payload_len: usize) -> i32 { + ffi::enter(move || { + let stream = ffi::parse_id(stream)?; + let payload = unsafe { ffi::parse_slice(payload, payload_len)? }; + State::lock().publish.binary_stream_append(stream, payload) + }) +} + +/// Finish a binary stream track and retire its catalog entry. No more payloads can be appended. +/// +/// Returns a zero on success, or a negative code on failure. +#[unsafe(no_mangle)] +pub extern "C" fn moq_publish_binary_stream_finish(stream: u32) -> i32 { + ffi::enter(move || { + let stream = ffi::parse_id(stream)?; + State::lock().publish.binary_stream_finish(stream) + }) +} + /// Create a catalog consumer for a broadcast. /// /// `on_catalog` is invoked with a positive catalog ID for each catalog update diff --git a/rs/libmoq/src/publish.rs b/rs/libmoq/src/publish.rs index fafa224c0e..fd7db2f681 100644 --- a/rs/libmoq/src/publish.rs +++ b/rs/libmoq/src/publish.rs @@ -70,11 +70,18 @@ pub struct Publish { /// Raw group producers, created from a raw track producer. groups: NonZeroSlab, - /// JSON snapshot producers (lossy latest-value tracks). - json_snapshot: NonZeroSlab>, + /// JSON snapshot producers (lossy latest-value tracks), each advertised in its broadcast's + /// catalog for as long as it lives. + json_snapshot: NonZeroSlab>, - /// JSON stream producers (lossless append-log tracks). - json_stream: NonZeroSlab>, + /// JSON stream producers (lossless append-log tracks), advertised the same way. + json_stream: NonZeroSlab>, + + /// Binary snapshot producers (lossy latest-value tracks of opaque bytes), advertised the same way. + binary_snapshot: NonZeroSlab>, + + /// Binary stream producers (lossless append-log tracks of opaque bytes), advertised the same way. + binary_stream: NonZeroSlab>, /// Demand watchers. Close signals shutdown; the task delivers a final callback, then removes itself. demand: NonZeroSlab>, @@ -132,6 +139,12 @@ impl Publish { Ok(&mut self.broadcasts.get_mut(id).ok_or(Error::BroadcastNotFound)?.catalog) } + /// The broadcast's current catalog, as consumers would receive it next. + #[cfg(test)] + pub fn catalog_snapshot(&mut self, id: Id) -> Result, Error> { + Ok(self.catalog(id)?.snapshot()) + } + /// Mutable access to both the broadcast and its catalog producer. /// Used by sibling modules (e.g. `audio`) that need to attach a new /// track to an existing publish. @@ -736,20 +749,26 @@ impl Publish { Ok(()) } - /// Create a JSON snapshot track (lossy latest-value) on a broadcast. - /// - /// Values published via [`Self::json_snapshot_update`] reach subscribers as a single latest - /// state; a late joiner only sees the newest value. Advertise the track in the catalog with - /// [`Self::catalog_section_set`] if consumers should discover it. - pub fn json_snapshot( + /// Create a track on a broadcast and hand it, with the broadcast's catalog, to `publish`, which + /// wraps it in a data producer that advertises the track in that catalog. + fn data_track( &mut self, broadcast: Id, name: &str, - config: moq_json::snapshot::Config, - ) -> Result { - let broadcast = self.producer(broadcast)?; - let track = broadcast.create_track(name, None)?; - let producer = moq_json::snapshot::Producer::new(track, config); + publish: impl FnOnce(&moq_mux::catalog::Producer, moq_net::track::Producer) -> moq_mux::Result, + ) -> Result { + let broadcast = self.broadcasts.get_mut(broadcast).ok_or(Error::BroadcastNotFound)?; + let track = broadcast.producer.create_track(name, None)?; + Ok(publish(&broadcast.catalog, track)?) + } + + /// Create a JSON snapshot track (lossy latest-value) on a broadcast, advertised in its catalog. + /// + /// Values published via [`Self::json_snapshot_update`] reach subscribers as a single latest + /// state; a late joiner only sees the newest value. The catalog entry (`json.tracks.`, + /// `mode: snapshot`) is written now and retired when the track finishes or fails. + pub fn json_snapshot(&mut self, broadcast: Id, name: &str, config: moq_mux::json::Config) -> Result { + let producer = self.data_track(broadcast, name, |catalog, track| catalog.json_snapshot(track, config))?; self.json_snapshot.insert(producer) } @@ -760,20 +779,19 @@ impl Publish { Ok(()) } - /// Finish a JSON snapshot track. No more values can be published. + /// Finish a JSON snapshot track and retire its catalog entry. No more values can be published. pub fn json_snapshot_finish(&mut self, json: Id) -> Result<(), Error> { - let mut producer = self.json_snapshot.remove(json).ok_or(Error::TrackNotFound)?; + let producer = self.json_snapshot.remove(json).ok_or(Error::TrackNotFound)?; producer.finish()?; Ok(()) } - /// Create a JSON stream track (lossless append-log) on a broadcast. + /// Create a JSON stream track (lossless append-log) on a broadcast, advertised in its catalog. /// /// Every record appended via [`Self::json_stream_append`] is preserved and delivered in order. - pub fn json_stream(&mut self, broadcast: Id, name: &str, config: moq_json::stream::Config) -> Result { - let broadcast = self.producer(broadcast)?; - let track = broadcast.create_track(name, None)?; - let producer = moq_json::stream::Producer::new(track, config); + /// The catalog entry (`json.tracks.`, `mode: stream`) lives as long as the track. + pub fn json_stream(&mut self, broadcast: Id, name: &str, config: moq_mux::json::Config) -> Result { + let producer = self.data_track(broadcast, name, |catalog, track| catalog.json_stream(track, config))?; self.json_stream.insert(producer) } @@ -784,9 +802,51 @@ impl Publish { Ok(()) } - /// Finish a JSON stream track. No more records can be appended. + /// Finish a JSON stream track and retire its catalog entry. No more records can be appended. pub fn json_stream_finish(&mut self, stream: Id) -> Result<(), Error> { - let mut producer = self.json_stream.remove(stream).ok_or(Error::TrackNotFound)?; + let producer = self.json_stream.remove(stream).ok_or(Error::TrackNotFound)?; + producer.finish()?; + Ok(()) + } + + /// Create a binary snapshot track (lossy latest-value) on a broadcast, advertised in its catalog + /// as `binary.tracks.`, `mode: snapshot`. + pub fn binary_snapshot(&mut self, broadcast: Id, name: &str, config: moq_mux::binary::Config) -> Result { + let producer = self.data_track(broadcast, name, |catalog, track| catalog.binary_snapshot(track, config))?; + self.binary_snapshot.insert(producer) + } + + /// Publish a new payload to a binary snapshot track, superseding the last. + pub fn binary_snapshot_update(&mut self, binary: Id, payload: &[u8]) -> Result<(), Error> { + let producer = self.binary_snapshot.get_mut(binary).ok_or(Error::TrackNotFound)?; + producer.update(bytes::Bytes::copy_from_slice(payload))?; + Ok(()) + } + + /// Finish a binary snapshot track and retire its catalog entry. + pub fn binary_snapshot_finish(&mut self, binary: Id) -> Result<(), Error> { + let producer = self.binary_snapshot.remove(binary).ok_or(Error::TrackNotFound)?; + producer.finish()?; + Ok(()) + } + + /// Create a binary stream track (lossless append-log) on a broadcast, advertised in its catalog + /// as `binary.tracks.`, `mode: stream`. + pub fn binary_stream(&mut self, broadcast: Id, name: &str, config: moq_mux::binary::Config) -> Result { + let producer = self.data_track(broadcast, name, |catalog, track| catalog.binary_stream(track, config))?; + self.binary_stream.insert(producer) + } + + /// Append one payload to a binary stream track. + pub fn binary_stream_append(&mut self, stream: Id, payload: &[u8]) -> Result<(), Error> { + let producer = self.binary_stream.get_mut(stream).ok_or(Error::TrackNotFound)?; + producer.append(bytes::Bytes::copy_from_slice(payload))?; + Ok(()) + } + + /// Finish a binary stream track and retire its catalog entry. + pub fn binary_stream_finish(&mut self, stream: Id) -> Result<(), Error> { + let producer = self.binary_stream.remove(stream).ok_or(Error::TrackNotFound)?; producer.finish()?; Ok(()) } diff --git a/rs/libmoq/src/test.rs b/rs/libmoq/src/test.rs index 00673bcc31..535d3574d8 100644 --- a/rs/libmoq/src/test.rs +++ b/rs/libmoq/src/test.rs @@ -4833,3 +4833,260 @@ fn server_listen_refuses_bad_config() { MOQ_ERROR_INVALID_CONFIG ); } + +/// Subscribe to `name` on `consume` as a raw track and return the payloads of its first `count` +/// frames. +fn read_raw_frames(consume: u32, name: &[u8], count: usize) -> Vec> { + let frame_cb = Callback::new(); + let subscription = moq_subscription { + priority: 0, + max_age_us: 1_000_000, + group_start: 0, + group_start_present: false, + group_end: 0, + group_end_present: false, + }; + let track = id(unsafe { + moq_consume_track( + consume, + name.as_ptr() as *const c_char, + name.len(), + &subscription, + Some(channel_callback), + frame_cb.ptr, + ) + }); + let mut payloads = Vec::with_capacity(count); + for _ in 0..count { + let frame_id = id(frame_cb.recv()); + let mut frame = moq_frame { + payload: std::ptr::null(), + payload_size: 0, + timestamp_us: 0, + keyframe: false, + }; + assert_eq!(unsafe { moq_consume_track_frame(frame_id, &mut frame) }, 0); + payloads.push(unsafe { std::slice::from_raw_parts(frame.payload, frame.payload_size) }.to_vec()); + assert_eq!(moq_consume_track_frame_free(frame_id), 0); + } + assert_eq!(moq_consume_track_cancel(track), 0); + // The callback context must outlive libmoq's last call into it: wait for the terminal. + assert_eq!(frame_cb.recv_terminal(), 0, "clean cancel delivers terminal 0"); + payloads +} + +/// The broadcast's current catalog, read on the publish side. +fn published_catalog(broadcast: u32) -> moq_mux::catalog::hang::Catalog { + let id = crate::Id::try_from(broadcast).expect("valid broadcast id"); + crate::State::lock() + .publish + .catalog_snapshot(id) + .expect("broadcast exists") +} + +#[test] +fn json_tracks_are_advertised_in_the_catalog() { + let origin = id(moq_origin_create()); + let broadcast = publish_broadcast(origin, b"json-catalog"); + + let status = b"status"; + let snapshot = id(unsafe { + moq_publish_json_snapshot( + broadcast, + status.as_ptr() as *const c_char, + status.len(), + &moq_json_snapshot_config { + delta_ratio: 4, + compression: true, + }, + ) + }); + let events = b"events"; + let stream = id(unsafe { + moq_publish_json_stream( + broadcast, + events.as_ptr() as *const c_char, + events.len(), + &moq_json_stream_config { compression: false }, + ) + }); + + let catalog = published_catalog(broadcast); + let entry = catalog.json.tracks.get("status").expect("snapshot track advertised"); + assert_eq!(entry.mode, hang::catalog::Mode::Snapshot); + assert_eq!(entry.compression, Some(hang::catalog::Compression::Deflate)); + let entry = catalog.json.tracks.get("events").expect("stream track advertised"); + assert_eq!(entry.mode, hang::catalog::Mode::Stream); + assert_eq!(entry.compression, None); + + // Finishing a track retires its entry; the other stays. + assert_eq!(moq_publish_json_snapshot_finish(snapshot), 0); + let catalog = published_catalog(broadcast); + assert!( + !catalog.json.tracks.contains_key("status"), + "finished track still advertised" + ); + assert!(catalog.json.tracks.contains_key("events")); + + assert_eq!(moq_publish_json_stream_finish(stream), 0); + assert!(published_catalog(broadcast).json.tracks.is_empty()); + + assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_origin_close(origin), 0); +} + +#[test] +fn binary_snapshot_is_advertised_and_delivered() { + let origin = id(moq_origin_create()); + let path = b"binary-snapshot"; + let broadcast = publish_broadcast(origin, path); + + let name = b"thumbnail"; + let mime = b"image/jpeg"; + let producer = id(unsafe { + moq_publish_binary_snapshot( + broadcast, + name.as_ptr() as *const c_char, + name.len(), + &moq_binary_config { + compression: false, + mime: mime.as_ptr() as *const c_char, + mime_len: mime.len(), + }, + ) + }); + + let catalog = published_catalog(broadcast); + let entry = catalog.binary.tracks.get("thumbnail").expect("binary track advertised"); + assert_eq!(entry.mode, hang::catalog::Mode::Snapshot); + assert_eq!(entry.mime.as_deref(), Some("image/jpeg")); + + // The payload reaches a raw subscriber of the same track name, untouched. + let payload = [0xff_u8, 0xd8, 0xff, 0xe0, 1, 2, 3]; + assert_eq!( + unsafe { moq_publish_binary_snapshot_update(producer, payload.as_ptr(), payload.len()) }, + 0 + ); + let consume = request_broadcast(origin, path); + let frames = read_raw_frames(consume, name, 1); + assert_eq!(frames, vec![payload.to_vec()]); + + assert_eq!(moq_publish_binary_snapshot_finish(producer), 0); + assert!( + moq_publish_binary_snapshot_finish(producer) < 0, + "double-finish should fail" + ); + assert!(published_catalog(broadcast).binary.tracks.is_empty()); + + assert_eq!(moq_consume_close(consume), 0); + assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_origin_close(origin), 0); +} + +#[test] +fn binary_stream_is_advertised_and_delivered() { + let origin = id(moq_origin_create()); + let path = b"binary-stream"; + let broadcast = publish_broadcast(origin, path); + + // A NULL mime leaves the media type unstated. + let name = b"blobs"; + let producer = id(unsafe { + moq_publish_binary_stream( + broadcast, + name.as_ptr() as *const c_char, + name.len(), + &moq_binary_config { + compression: false, + mime: std::ptr::null(), + mime_len: 0, + }, + ) + }); + + let catalog = published_catalog(broadcast); + let entry = catalog.binary.tracks.get("blobs").expect("binary stream advertised"); + assert_eq!(entry.mode, hang::catalog::Mode::Stream); + assert_eq!(entry.mime, None); + + // Every appended payload is delivered, in order. + let payloads: [&[u8]; 2] = [b"first", b"second"]; + for payload in payloads { + assert_eq!( + unsafe { moq_publish_binary_stream_append(producer, payload.as_ptr(), payload.len()) }, + 0 + ); + } + let consume = request_broadcast(origin, path); + let frames = read_raw_frames(consume, name, payloads.len()); + assert_eq!(frames, payloads.map(<[u8]>::to_vec)); + + assert_eq!(moq_publish_binary_stream_finish(producer), 0); + assert!(published_catalog(broadcast).binary.tracks.is_empty()); + + assert_eq!(moq_consume_close(consume), 0); + assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_origin_close(origin), 0); +} + +#[test] +fn data_track_names_cannot_collide() { + let origin = id(moq_origin_create()); + let broadcast = publish_broadcast(origin, b"data-collide"); + + let name = b"state"; + let first = id(unsafe { + moq_publish_json_snapshot( + broadcast, + name.as_ptr() as *const c_char, + name.len(), + &moq_json_snapshot_config { + delta_ratio: 0, + compression: false, + }, + ) + }); + // A second data track under the same name is refused rather than silently replacing the + // first entry. + assert!( + unsafe { + moq_publish_binary_stream( + broadcast, + name.as_ptr() as *const c_char, + name.len(), + &moq_binary_config { + compression: false, + mime: std::ptr::null(), + mime_len: 0, + }, + ) + } < 0, + "a duplicate data track name should fail" + ); + assert_eq!( + published_catalog(broadcast) + .json + .tracks + .get("state") + .map(|e| e.mode.clone()), + Some(hang::catalog::Mode::Snapshot), + "the refused duplicate must leave the first entry in place" + ); + + // A NULL config is refused. + let other = b"other"; + assert!( + unsafe { + moq_publish_binary_stream( + broadcast, + other.as_ptr() as *const c_char, + other.len(), + std::ptr::null(), + ) + } < 0 + ); + + assert_eq!(moq_publish_json_snapshot_finish(first), 0); + assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_origin_close(origin), 0); +} diff --git a/rs/moq-ffi/src/media.rs b/rs/moq-ffi/src/media.rs index b3318bed09..5042c51083 100644 --- a/rs/moq-ffi/src/media.rs +++ b/rs/moq-ffi/src/media.rs @@ -242,6 +242,10 @@ pub struct MoqAudioInit { /// Human-readable rendition name for a track picker. #[uniffi(default = None)] pub label: Option, + /// Track name. `None` derives a unique name from the format. Refused on a requested track, + /// which already carries its name. + #[uniffi(default = None)] + pub track: Option, } /// What a video publish needs: a format, optional init bytes, a label, and hints. @@ -260,6 +264,10 @@ pub struct MoqVideoInit { /// Catalog fields the stream cannot reveal itself. #[uniffi(default = None)] pub hint: Option, + /// Track name. `None` derives a unique name from the format. Refused on a requested track, + /// which already carries its name. + #[uniffi(default = None)] + pub track: Option, } /// What a container publish needs: a format and its leading bytes. diff --git a/rs/moq-ffi/src/producer.rs b/rs/moq-ffi/src/producer.rs index 9fd9b3aafc..04822ca67c 100644 --- a/rs/moq-ffi/src/producer.rs +++ b/rs/moq-ffi/src/producer.rs @@ -317,19 +317,17 @@ impl MoqBroadcastProducer { /// Publish one audio codec as a new track. /// - /// The track is named after the format (`0.opus`), so the catalog is how a subscriber finds it. - /// [`MoqAudioInit::data`] is required: audio resolves its rendition entirely from those bytes. - pub fn publish_audio(&self, init: MoqAudioInit) -> Result, MoqError> { + /// The track is [`MoqAudioInit::track`], or else named after the format (`0.opus`), so the + /// catalog is how a subscriber finds it. [`MoqAudioInit::data`] is required: audio resolves its + /// rendition entirely from those bytes. + pub fn publish_audio(&self, mut init: MoqAudioInit) -> Result, MoqError> { let _guard = crate::ffi::enter(); let guard = self.state.lock().unwrap(); let state = guard.as_ref().ok_or(MoqError::Closed)?; + let track = init.track.take(); let init: moq_mux::import::AudioInit = init.into(); - let broadcast = state.broadcast.clone(); - let name = broadcast.unique_name(&format!(".{}", init.format)); - let request = broadcast - .reserve_track(name) - .map_err(|err| MoqError::Codec(format!("init failed: {err}")))?; + let request = reserve_track(&state.broadcast, track, &init.format)?; let import = moq_mux::import::Track::audio(request, state.catalog.reserve(), init) .map_err(|err| MoqError::Codec(format!("init failed: {err}")))?; @@ -341,17 +339,14 @@ impl MoqBroadcastProducer { /// Named as in [`publish_audio`](Self::publish_audio). [`MoqVideoInit::data`] may be empty for a /// format that resolves in band; a hint carrying the codec publishes the catalog before the /// first keyframe. - pub fn publish_video(&self, init: MoqVideoInit) -> Result, MoqError> { + pub fn publish_video(&self, mut init: MoqVideoInit) -> Result, MoqError> { let _guard = crate::ffi::enter(); let guard = self.state.lock().unwrap(); let state = guard.as_ref().ok_or(MoqError::Closed)?; + let track = init.track.take(); let init: moq_mux::import::VideoInit = init.into(); - let broadcast = state.broadcast.clone(); - let name = broadcast.unique_name(&format!(".{}", init.format)); - let request = broadcast - .reserve_track(name) - .map_err(|err| MoqError::Codec(format!("init failed: {err}")))?; + let request = reserve_track(&state.broadcast, track, &init.format)?; let import = moq_mux::import::Track::video(request, state.catalog.reserve(), init) .map_err(|err| MoqError::Codec(format!("init failed: {err}")))?; @@ -387,6 +382,9 @@ impl MoqBroadcastProducer { let guard = self.state.lock().unwrap(); let state = guard.as_ref().ok_or(MoqError::Closed)?; + if init.track.is_some() { + return Err(MoqError::Codec("a requested track already has a name".into())); + } let request = request.take()?; let import = moq_mux::import::Track::audio(request, state.catalog.reserve(), init.into()) .map_err(|err| MoqError::Codec(format!("init failed: {err}")))?; @@ -404,6 +402,9 @@ impl MoqBroadcastProducer { let guard = self.state.lock().unwrap(); let state = guard.as_ref().ok_or(MoqError::Closed)?; + if init.track.is_some() { + return Err(MoqError::Codec("a requested track already has a name".into())); + } let request = request.take()?; let import = moq_mux::import::Track::video(request, state.catalog.reserve(), init.into()) .map_err(|err| MoqError::Codec(format!("init failed: {err}")))?; @@ -414,17 +415,14 @@ impl MoqBroadcastProducer { /// /// Only the self-delimiting formats work here (`Avc3`, `Hev1`, `Av01`); the rest need length /// prefixes or an out-of-band config record. There is no audio counterpart for the same reason. - pub fn publish_video_stream(&self, init: MoqVideoInit) -> Result, MoqError> { + pub fn publish_video_stream(&self, mut init: MoqVideoInit) -> Result, MoqError> { let _guard = crate::ffi::enter(); let guard = self.state.lock().unwrap(); let state = guard.as_ref().ok_or(MoqError::Closed)?; + let track = init.track.take(); let init: moq_mux::import::VideoInit = init.into(); - let broadcast = state.broadcast.clone(); - let name = broadcast.unique_name(&format!(".{}", init.format)); - let request = broadcast - .reserve_track(name) - .map_err(|err| MoqError::Codec(format!("init failed: {err}")))?; + let request = reserve_track(&state.broadcast, track, &init.format)?; let import = moq_mux::import::TrackStream::video(request, state.catalog.reserve(), init) .map_err(|err| MoqError::Codec(format!("init failed: {err}")))?; @@ -1134,3 +1132,15 @@ impl MoqContainerStreamProducer { Ok(()) } } + +/// Reserve the named track, or a unique one named after the format. +fn reserve_track( + broadcast: &moq_net::broadcast::Producer, + track: Option, + format: &impl std::fmt::Display, +) -> Result { + let name = track.unwrap_or_else(|| broadcast.unique_name(&format!(".{format}"))); + broadcast + .reserve_track(name) + .map_err(|err| MoqError::Codec(format!("init failed: {err}"))) +} diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index 04dae275d6..4963dff98f 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -155,6 +155,7 @@ fn audio_init(format: MoqAudioFormat, data: Vec) -> MoqAudioInit { format, data, label: None, + track: None, } } @@ -164,6 +165,7 @@ fn video_init(format: MoqVideoFormat, data: Vec) -> MoqVideoInit { data, label: None, hint: None, + track: None, } } @@ -1239,6 +1241,65 @@ async fn requested_track_dynamic_survives_accept() { assert_eq!(frame.timestamp_us, 180_000); } +#[tokio::test] +async fn video_publish_named_track() { + let broadcast = MoqBroadcastProducer::new().unwrap(); + let consumer = broadcast.consume().unwrap(); + let catalog_consumer = consumer.subscribe_catalog().await.unwrap(); + + let named = |track: &str| MoqVideoInit { + track: Some(track.into()), + ..video_init(MoqVideoFormat::Avc3, h264_init()) + }; + let hd = broadcast.publish_video(named("hd")).unwrap(); + assert_eq!(hd.name().unwrap(), "hd"); + let sd = broadcast.publish_video_stream(named("sd")).unwrap(); + drop(sd); + + // A name is the caller's contract, so a duplicate fails rather than being made unique. + assert!(matches!(broadcast.publish_video(named("hd")), Err(MoqError::Codec(_)))); + + let catalog = tokio::time::timeout(TIMEOUT, catalog_consumer.next()) + .await + .expect("timed out waiting for catalog") + .unwrap() + .expect("expected a catalog"); + assert!(catalog.video.contains_key("hd"), "catalog: {:?}", catalog.video.keys()); +} + +#[tokio::test] +async fn requested_track_refuses_a_name() { + let broadcast = MoqBroadcastProducer::new().unwrap(); + let dynamic = broadcast.dynamic().unwrap(); + let consumer = broadcast.consume().unwrap(); + let subscribe = tokio::spawn(async move { + consumer + .subscribe_media("requested".into(), crate::media::MoqContainer::Legacy, None) + .await + }); + + let request = tokio::time::timeout(TIMEOUT, dynamic.requested_track()) + .await + .expect("timed out waiting for requested track") + .unwrap(); + + let named = MoqVideoInit { + track: Some("other".into()), + ..video_init(MoqVideoFormat::Avc3, h264_init()) + }; + assert!(matches!( + broadcast.publish_video_on_track(&request, named), + Err(MoqError::Codec(_)) + )); + + // The refusal leaves the request unaccepted, so it still publishes under its own name. + let media = broadcast + .publish_video_on_track(&request, video_init(MoqVideoFormat::Avc3, h264_init())) + .unwrap(); + assert_eq!(media.name().unwrap(), "requested"); + subscribe.abort(); +} + #[tokio::test] async fn dynamic_track_request_can_publish_media() { let broadcast = MoqBroadcastProducer::new().unwrap(); diff --git a/rs/moq-gst/Cargo.toml b/rs/moq-gst/Cargo.toml index dd465ad391..327a77d7c2 100644 --- a/rs/moq-gst/Cargo.toml +++ b/rs/moq-gst/Cargo.toml @@ -32,4 +32,5 @@ url = { workspace = true } gst-plugin-version-helper = "0.8" [dev-dependencies] +futures = { workspace = true } tokio = { workspace = true, features = ["test-util"] } diff --git a/rs/moq-gst/src/sink/session.rs b/rs/moq-gst/src/sink/session.rs index 9e00dc8f76..58ff9b107a 100644 --- a/rs/moq-gst/src/sink/session.rs +++ b/rs/moq-gst/src/sink/session.rs @@ -251,6 +251,8 @@ impl SessionRegistration { /// `Session` (or the producers held by the element) tears it down. pub(crate) struct Session { join: tokio::task::JoinHandle<()>, + /// The reconnect loop, held so [`stop`](Self::stop) can wait for it to end. + connection: moq_tokio::Connection, status: Arc, /// The live send-bitrate estimate, tracked across reconnects by the reconnect loop. Read directly /// by the `estimated-send-rate` getter. @@ -307,7 +309,7 @@ impl Session { // installing this session, and its bus error would be discarded for belonging to no live one. let gate = Arc::new(tokio::sync::Notify::new()); let join = RUNTIME.spawn(forward( - reconnect, + reconnect.clone(), origin, status.clone(), completion.clone(), @@ -318,6 +320,7 @@ impl Session { Ok(( Self { join, + connection: reconnect, status, send_bandwidth, recv_bandwidth, @@ -360,9 +363,38 @@ impl Session { self.completion.clone() } - /// Stop the session: a clean local close, never an error. [`Drop`] aborts the task, cancelling the - /// in-flight connect or reconnect loop at its next await point and dropping the connection. - pub fn stop(self) {} + /// Stop the session: a clean local close, never an error. Returns once the reconnect loop has ended. + /// + /// Aborting only cancels the loop at its next await point, and a worker may be mid-dial, generating + /// TLS randomness. A process exiting in that window (`gst-launch` right after NULL) races aws-lc's + /// exit destructors, which free its seed DRBG and then `abort()` a thread asking for more. + pub fn stop(self) { + // The status task goes first, so it never reports the loop's end as a failure. + self.join.abort(); + self.connection.abort(moq_net::Error::Cancel); + // Parks the thread rather than entering an executor, which would panic under a caller's own. + let closed = || { + let waiter = moq_net::kio::Waiter::new(Arc::new(Unpark(std::thread::current())).into()); + while self.connection.poll_closed(&waiter).is_pending() { + std::thread::park(); + } + }; + match tokio::runtime::Handle::try_current().map(|handle| handle.runtime_flavor()) { + // A state change from a notify or bus sync handler runs on a worker, whose queue may hold the + // loop's cancellation. Handing the worker off lets it run while this thread blocks. + Ok(tokio::runtime::RuntimeFlavor::MultiThread) => tokio::task::block_in_place(closed), + _ => closed(), + } + } +} + +/// Wakes a thread parked in [`Session::stop`]. +struct Unpark(std::thread::Thread); + +impl std::task::Wake for Unpark { + fn wake(self: Arc) { + self.0.unpark(); + } } impl Drop for Session { @@ -383,8 +415,8 @@ impl Drop for Session { /// presence change notifies `sessions` and `connection-stats`, and a bitrate change notifies just that /// bitrate. The loop stops only on a terminal error (a non-retryable auth failure, or a bounded backoff's /// give-up), which the `Err` arm posts as a bus error. -/// [`Session`]'s `Drop` aborts this task, which drops the `Connection` handle and quietly tears the loop -/// down. +/// [`Session`] aborts this task on stop or drop, and dropping its own `Connection` handle with it quietly +/// tears the loop down. async fn forward( reconnect: moq_tokio::Connection, origin: moq_net::origin::Producer, @@ -519,6 +551,59 @@ mod tests { assert_eq!(structure.get::("ended"), Ok(2)); } + fn started() -> (Session, moq_tokio::Connection) { + gst::init().unwrap(); + let settings = ResolvedSettings { + url: "https://127.0.0.1:1".parse().unwrap(), + broadcast: "test".into(), + tls_disable_verify: false, + quic_idle_timeout: None, + quic_keep_alive: None, + }; + let (session, registration, _, _) = Session::start(settings, glib::WeakRef::new()).unwrap(); + registration.mark_registered(); + let connection = session.connection.clone(); + (session, connection) + } + + fn is_closed(connection: &moq_tokio::Connection) -> bool { + connection.poll_closed(&moq_net::kio::Waiter::noop()).is_ready() + } + + // A dial still running once the element reached NULL can outlive `main`, and aws-lc aborts the + // process when a thread asks it for randomness after its exit destructors ran. + #[test] + fn stop_returns_after_the_reconnect_loop_ends() { + let (session, connection) = started(); + session.stop(); + assert!(is_closed(&connection)); + } + + // A notify or bus sync handler can stop the element from a runtime worker. With the loop parked, its + // cancellation lands in that worker's own LIFO slot, which no other worker can steal, so blocking the + // worker outright would never let it run. + #[test] + fn stop_from_a_runtime_worker_does_not_deadlock() { + let (session, connection) = started(); + let metrics = RUNTIME.metrics(); + // An odd count means that worker is parked. + while metrics.global_queue_depth() > 0 + || (0..metrics.num_workers()).any(|worker| metrics.worker_park_unpark_count(worker).is_multiple_of(2)) + { + std::thread::yield_now(); + } + RUNTIME.block_on(RUNTIME.spawn(async move { session.stop() })).unwrap(); + assert!(is_closed(&connection)); + } + + // An application driving its own executor can reach NULL from inside it, and executors refuse to nest. + #[test] + fn stop_inside_another_executor() { + let (session, connection) = started(); + futures::executor::block_on(async move { session.stop() }); + assert!(is_closed(&connection)); + } + #[tokio::test] async fn a_terminal_result_waits_until_the_session_is_registered() { let gate = Arc::new(tokio::sync::Notify::new()); diff --git a/rs/moq-mux/src/json.rs b/rs/moq-mux/src/json.rs index 026c489d88..26692fa745 100644 --- a/rs/moq-mux/src/json.rs +++ b/rs/moq-mux/src/json.rs @@ -74,6 +74,13 @@ pub struct Config { /// An optional identifier for the shape of each value, typically a JSON Schema URL. pub schema: Option, + + /// Override the snapshot encoder's [`delta_ratio`](moq_json::snapshot::Config::delta_ratio), + /// or `None` for its default. Only a [`Snapshot`] reads it: a stream has no deltas. + /// + /// Not part of the catalog entry: deltas are a property of the frames, which every consumer + /// decodes the same way, so a reader needs nothing from the entry to follow them. + pub delta_ratio: Option, } impl Config { @@ -89,6 +96,12 @@ impl Config { self } + /// Set [`delta_ratio`](Self::delta_ratio) (a builder, since the struct is `#[non_exhaustive]`). + pub fn with_delta_ratio(mut self, delta_ratio: u32) -> Self { + self.delta_ratio = Some(delta_ratio); + self + } + /// The catalog entry describing a track published under this config in `mode`. pub(crate) fn entry(&self, mode: Mode) -> JsonConfig { let mut entry = JsonConfig::new(mode); @@ -117,6 +130,9 @@ impl Snapshot { if config.compression { json.compression = moq_json::Compression::Deflate; } + if let Some(delta_ratio) = config.delta_ratio { + json.delta_ratio = delta_ratio; + } let inner = moq_json::snapshot::Producer::new(track, json); rendition.set(config.entry(Mode::Snapshot))?; Ok(Self { inner, rendition }) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 9b9a877dfb..ceee1b52fa 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -1,6 +1,6 @@ //! In-band authorization: present tokens to the peer and learn what they grant. //! -//! Each side of a moq-lite-06 session presents the credential its connection +//! Each side of a session presents the credential its connection //! already carried (the URL, a client certificate, or nothing) right after //! setup, and learns the [`Grant`] it earned. [`Session::auth`](crate::Session::auth) //! returns the [`Handle`]: [`grant`](Handle::grant) is the union of every token @@ -12,8 +12,10 @@ //! itself takes [`requests`](Handle::requests) before running the session's //! driver, and then answers every token the peer presents. //! -//! Older versions and moq-transport carry no AUTH exchange: there the grant stays -//! `None` and [`add`](Handle::add) fails with [`Error::Unsupported`]. +//! moq-transport draft-17+ carries the same exchange when both sides negotiate the +//! MoQ Auth extension. Older versions, and peers that do not negotiate it, carry no +//! AUTH exchange: there the grant stays `None` and [`add`](Handle::add) fails with +//! [`Error::Unsupported`]. use std::{ collections::{BTreeMap, VecDeque}, @@ -364,6 +366,23 @@ impl Handle { .is_none_or(|union| union.patterns(direction).matches(path)) } + /// The peer turned out not to negotiate AUTH: fail every token as unsupported and + /// close the requests, leaving the union unknown. + pub(crate) fn unsupported(&self) { + let mut state = self.state.lock(); + state.supported = false; + state.opening.clear(); + for slot in state.tokens.values_mut() { + slot.answered.get_or_insert(Err(Error::Unsupported)); + slot.ended.get_or_insert(Error::Unsupported); + } + // Nothing will read a withdrawn slot again. + state.tokens.retain(|_, slot| !slot.withdrawn); + if let Acceptor::App(queue) = &state.acceptor { + queue.close(); + } + } + /// End the session: fail every pending token, end every watch, and close the /// requests. pub(crate) fn close(&self, err: Error) { @@ -720,3 +739,68 @@ impl Gate { } } } + +/// The close reason naming a broadcast published outside the grant. [`Error`] carries +/// no payload, so the path travels here, in the session's own terms. +pub(crate) fn unauthorized_reason(path: &crate::Path) -> String { + format!("unauthorized: {path}") +} + +/// Finds a broadcast this side publishes that its grant never covered, so the session +/// can fail loudly instead of waiting for a subscription that never comes. +/// +/// Waits until the tokens the session presented at setup are answered, then checks +/// each broadcast when it is first announced. A grant that later shrinks withdraws what +/// it no longer covers without aborting: the union is processed before new +/// announcements, so a revocation is never mistaken for a new unauthorized +/// publication. Only the dialing side enforces: a server's publish origin is everything +/// the peer may read, not what it intends to push. +#[derive(Default)] +pub(crate) struct Enforce { + epoch: u64, + permit: Option, + /// Every broadcast admitted so far, still announced. + live: std::collections::HashSet, + setup: bool, +} + +impl Enforce { + /// Resolve with the first broadcast announced outside the union, or `None` once the + /// origin ends. + pub(crate) fn poll( + &mut self, + handle: &Handle, + announced: &mut crate::announce::Consumer, + waiter: &kio::Waiter, + ) -> Poll> { + if !self.setup { + ready_or!(handle.poll_setup_answered(waiter)); + self.setup = true; + } + while let Poll::Ready(union) = handle.poll_union(&mut self.epoch, waiter) { + self.permit = union.map(|grant| grant.publish); + } + // No grant yet (the peer never answered with one): nothing to check against. + let Some(permit) = &self.permit else { + return Poll::Pending; + }; + + loop { + let Some(update) = ready_or!(announced.poll_next(waiter)) else { + return Poll::Ready(None); + }; + match update.kind { + crate::announce::Kind::Announced if !self.live.contains(&update.prefix) => { + if !permit.matches(update.prefix.as_str()) { + return Poll::Ready(Some(update.prefix)); + } + self.live.insert(update.prefix); + } + crate::announce::Kind::Retracted => { + self.live.remove(&update.prefix); + } + _ => {} + } + } + } +} diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index 510eb44098..2b23f53b05 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -257,6 +257,8 @@ impl Client { // Draft-17+: SETUP is exchanged by the connection driver. // We advertise the request path in our SETUP for URL-less transports. + // The peer's SETUP decides whether AUTH is negotiated. + let auth = crate::auth::Handle::new(true); let (protocol, goaway) = ietf::start(ietf::Config { runtime: runtime.clone(), session: session.clone(), @@ -271,6 +273,7 @@ impl Client { path: self.setup_path.clone(), peer_setup_stream: None, peer_declared: None, + auth: auth.clone(), })?; tracing::debug!(version = ?v, "connected"); @@ -281,7 +284,7 @@ impl Client { None, crate::driver::Protocol::Ietf(protocol), goaway, - crate::auth::Handle::new(false), + auth, )); } Some(ALPN_16) => { @@ -420,6 +423,7 @@ impl Client { path: None, peer_setup_stream: None, peer_declared: Some(peer_declared), + auth: crate::auth::Handle::new(false), })?; ( None, diff --git a/rs/moq-net/src/ietf/auth.rs b/rs/moq-net/src/ietf/auth.rs new file mode 100644 index 0000000000..d4ed90b5db --- /dev/null +++ b/rs/moq-net/src/ietf/auth.rs @@ -0,0 +1,656 @@ +//! The MoQ Auth extension (draft-lcurley-moq-auth-00). +//! +//! The moq-transport binding of the lite-06 Auth Stream (see [`crate::auth`]): each +//! token rides a request stream of its own, answered with the namespace prefixes it +//! grants. Negotiated with the AUTH Setup Option on draft-17+ only, where SETUP is a +//! Key-Value-Pair block. +//! +//! The wire carries prefixes, so a grant is told only when it is a union of subtrees. +//! Anything narrower is refused with NOT_SUPPORTED rather than widened. + +use std::task::Poll; +use std::time::Duration; + +use bytes::Bytes; + +use crate::auth::{Grant, Handle, Issue, Reply, Request}; +use crate::coding::{Decode, DecodeError, Encode, EncodeError, Sizer, Stream}; +use crate::{Error, Path, Pattern, Patterns, SessionError}; + +use super::namespace::{decode_namespace, encode_namespace}; +use super::{Control, Message, RequestId, Version, cluster, peer}; + +/// AUTH Setup Option: the sender speaks this extension. Even, so the value is a bare +/// varint. +pub const AUTH: u64 = 0x40B60; + +/// Whether a version negotiates this extension: draft-17+, like MoQ Cluster. +pub fn supported(version: Version) -> bool { + cluster::supported(version) +} + +/// What the peer declared: `None` for no option, otherwise whether it offered the +/// extension. Only an explicit 1 negotiates it. +pub fn from_setup(params: &super::Parameters, version: Version) -> Option { + if !supported(version) { + return None; + } + params.get_varint(super::ParameterVarInt::Auth).map(|value| value == 1) +} + +/// Offer the extension, on the versions that negotiate it. +pub fn into_setup(params: &mut super::Parameters, version: Version) { + if supported(version) { + params.set_varint(super::ParameterVarInt::Auth, 1); + } +} + +/// Refuse a message on a version that cannot negotiate the extension. +fn check_version(version: Version) -> Result<(), DecodeError> { + match supported(version) { + true => Ok(()), + false => Err(DecodeError::Version), + } +} + +/// AUTH: the first message on an Auth request stream, presenting a token. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Auth { + pub request_id: RequestId, + /// Empty presents the credential the connection already carried. + pub token: Bytes, +} + +impl Message for Auth { + const ID: u64 = 0x40B61; + + fn encode_msg(&self, w: &mut W, version: Version) -> Result<(), EncodeError> { + check_version(version).map_err(|_| EncodeError::Version)?; + self.request_id.encode(w, version)?; + self.token.encode(w, version) + } + + fn decode_msg(r: &mut R, version: Version) -> Result { + check_version(version)?; + Ok(Self { + request_id: RequestId::decode(r, version)?, + token: Bytes::decode(r, version)?, + }) + } +} + +/// AUTH_OK: the grant a token earns, replacing any earlier one on the stream. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuthOk { + /// What the presenter may publish to the acceptor. + pub publish: Patterns, + /// What the presenter may subscribe to from the acceptor. + pub subscribe: Patterns, + /// How long until the grant lapses, or `None` for never. + pub expires: Option, +} + +/// Largest millisecond count every implementation carries losslessly. +const MAX_EXPIRES_MS: u64 = (1 << 53) - 1; + +/// Encode patterns as namespace prefix tuples, refusing any that is not a subtree: +/// sending `room` for a grant of the literal `room/alice` would hand out more than was +/// granted. +fn encode_prefixes(patterns: &Patterns, w: &mut W, version: Version) -> Result<(), EncodeError> { + patterns.len().encode(w, version)?; + for pattern in patterns { + let prefix = pattern.as_prefix().ok_or(EncodeError::Unsupported)?; + encode_namespace(w, &Path::new(prefix), version)?; + } + Ok(()) +} + +fn decode_prefixes(r: &mut R, version: Version) -> Result { + let count = usize::decode(r, version)?; + let mut patterns = Patterns::new(); + // No preallocation: the count is peer-controlled, and the message size limit bounds + // how many prefixes actually fit. + for _ in 0..count { + let prefix = decode_namespace(r, version)?; + let pattern = Pattern::subtree(prefix.as_str()).map_err(|_| DecodeError::InvalidValue)?; + patterns.insert(pattern); + } + Ok(patterns) +} + +impl Message for AuthOk { + const ID: u64 = 0x40B62; + + fn encode_msg(&self, w: &mut W, version: Version) -> Result<(), EncodeError> { + check_version(version).map_err(|_| EncodeError::Version)?; + encode_prefixes(&self.publish, w, version)?; + encode_prefixes(&self.subscribe, w, version)?; + // 0 means never, so a grant that has already lapsed rounds up to the smallest + // value that still reads as an expiry. + let expires = match self.expires { + None => 0, + Some(expires) => (expires.as_nanos().div_ceil(1_000_000).min(MAX_EXPIRES_MS as u128) as u64).max(1), + }; + expires.encode(w, version) + } + + fn decode_msg(r: &mut R, version: Version) -> Result { + check_version(version)?; + let publish = decode_prefixes(r, version)?; + let subscribe = decode_prefixes(r, version)?; + let expires = match u64::decode(r, version)? { + 0 => None, + ms => Some(Duration::from_millis(ms)), + }; + Ok(Self { + publish, + subscribe, + expires, + }) + } +} + +/// AUTH_ERROR: the acceptor refusing a token, or revoking it after an AUTH_OK. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuthError { + /// A code from the REQUEST_ERROR registry. + pub code: u64, + pub reason: String, +} + +/// Longest AUTH_ERROR reason, in bytes, matching the lite wire. +const MAX_REASON: usize = 8192; + +impl Message for AuthError { + const ID: u64 = 0x40B63; + + fn encode_msg(&self, w: &mut W, version: Version) -> Result<(), EncodeError> { + check_version(version).map_err(|_| EncodeError::Version)?; + if self.reason.len() > MAX_REASON { + return Err(EncodeError::TooLarge); + } + self.code.encode(w, version)?; + self.reason.as_str().encode(w, version) + } + + fn decode_msg(r: &mut R, version: Version) -> Result { + check_version(version)?; + let code = u64::decode(r, version)?; + let reason = String::decode(r, version)?; + if reason.len() > MAX_REASON { + return Err(DecodeError::InvalidValue); + } + Ok(Self { code, reason }) + } +} + +/// The REQUEST_ERROR codes AUTH_ERROR carries. +const UNAUTHORIZED: u64 = 0x1; +const NOT_SUPPORTED: u64 = 0x3; + +/// The AUTH_ERROR code for a refusal. The public API speaks session codes, and the +/// only one this registry distinguishes is a version mismatch. +fn to_code(code: SessionError) -> u64 { + match code { + SessionError::Version => NOT_SUPPORTED, + _ => UNAUTHORIZED, + } +} + +/// Read an AUTH_ERROR code. NOT_SUPPORTED means the acceptor could not tell the grant, +/// not that it refused one; every other code is a refusal. +fn from_code(code: u64) -> Error { + match code { + NOT_SUPPORTED => Error::Unsupported, + _ => Error::Session(SessionError::Unauthorized), + } +} + +/// What the peer's connection credential earns by default: publishing what our +/// subscribe half accepts, and subscribing to what our publish half serves. +pub(super) fn peer_grant( + publish: Option<&crate::origin::Consumer>, + subscribe: Option<&crate::origin::Producer>, +) -> Grant { + Grant { + publish: subscribe.map(|origin| origin.allowed()).unwrap_or_default(), + subscribe: publish.map(|origin| origin.allowed()).unwrap_or_default(), + expires: None, + } +} + +/// Present every token this side adds, one AUTH request each, once the peer's SETUP +/// says it negotiated the extension. Never returns: a session without it just fails +/// the tokens as unsupported. +pub(super) async fn run_present( + runtime: crate::time::Clock, + session: S, + control: Control, + handle: Handle, + peer_setup: peer::PeerSetup, + version: Version, + going_away: crate::goaway::GoingAway, +) where + S: crate::transport::poll::Boxable, +{ + if !peer_setup.get().await.auth { + handle.unsupported(); + return std::future::pending().await; + } + + let mut tasks = crate::util::TaskSet::owned(); + while let Some((id, token)) = tasks.drive(|waiter| handle.poll_opening(waiter)).await { + let present = Present { + runtime: runtime.clone(), + session: session.clone(), + control: control.clone(), + handle: handle.clone(), + version, + id, + }; + let going_away = going_away.clone(); + tasks.push(async move { + // After a GOAWAY the peer must not see new requests. + let err = match going_away.is_set() { + true => Error::GoingAway, + false => present.run(token).await, + }; + match &err { + Error::Cancel | Error::Unsupported | Error::Transport(_) | Error::Session(_) => { + tracing::debug!(%err, "auth token ended") + } + err => tracing::warn!(%err, "auth token ended"), + } + present.handle.ended(present.id, err); + }); + } + std::future::pending().await +} + +/// One token's Auth request. +struct Present { + runtime: crate::time::Clock, + session: S, + control: Control, + handle: Handle, + version: Version, + id: u64, +} + +impl Present { + /// Send the token, then track its grant until either side ends it, resolving with why. + async fn run(&self, token: Bytes) -> Error { + let request_id = match self.control.next_request_id(&self.runtime).await { + Ok(id) => id, + Err(err) => return err, + }; + let mut stream = match Stream::open(&mut self.session.clone(), self.version).await { + Ok(stream) => stream, + Err(err) => return err, + }; + if let Err(err) = stream.writer.encode_message(&Auth { request_id, token }).await { + return err; + } + + let mut answered = false; + loop { + enum Next { + Withdrawn, + Reply(Result, Error>), + } + + let next = { + let mut read = std::pin::pin!(read_message(&mut stream)); + kio::wait(|waiter| { + if self.handle.poll_withdrawn(self.id, waiter).is_ready() { + return Poll::Ready(Next::Withdrawn); + } + waiter.poll_future(read.as_mut()).map(Next::Reply) + }) + .await + }; + + let (id, mut data) = match next { + // Resetting our side is what tells the peer. + Next::Withdrawn => return Error::Cancel, + Next::Reply(Ok(Some(msg))) => msg, + // The peer ended the grant without revoking it. + Next::Reply(Ok(None)) if answered => return Error::Cancel, + // A peer that closes before answering could not tell us anything. + Next::Reply(Ok(None)) => return Error::Unsupported, + Next::Reply(Err(err)) => return err, + }; + + match id { + AuthOk::ID => { + let ok = match AuthOk::decode_msg(&mut data, self.version) { + Ok(ok) => ok, + Err(err) => return err.into(), + }; + let now = crate::runtime::Timers::now(&self.runtime); + // The expiry is the peer's number: one past the local clock's range is + // malformed, not a reason to panic. + let expires = match ok.expires.map(|expires| now.checked_add(expires)) { + Some(None) => return Error::ProtocolViolation, + expires => expires.flatten(), + }; + answered = true; + self.handle.granted( + self.id, + Grant { + publish: ok.publish, + subscribe: ok.subscribe, + expires, + }, + ); + } + AuthError::ID => { + let refused = match AuthError::decode_msg(&mut data, self.version) { + Ok(refused) => refused, + Err(err) => return err.into(), + }; + let err = from_code(refused.code); + tracing::warn!(%err, code = refused.code, reason = %refused.reason, "auth token refused"); + // A grant the acceptor could not tell is unknown, not empty, so only a + // real refusal settles the union. + if !matches!(err, Error::Unsupported) { + self.handle.refused(self.id); + } + return err; + } + _ => return Error::UnexpectedMessage, + } + } + } +} + +/// Read one `[type][size][body]` message, or `None` once the peer finished the stream. +async fn read_message( + stream: &mut Stream, +) -> Result, Error> { + let Some(id) = stream.reader.decode_maybe::().await? else { + return Ok(None); + }; + let size: u16 = stream.reader.decode().await?; + let data = stream.reader.read_exact(size as usize).await?; + Ok(Some((id, data))) +} + +/// Answers the peer's Auth requests: the app's verdict when it took the requests, +/// otherwise the default grant for the connection's own credential. +#[derive(Clone)] +pub(super) struct Serve { + pub runtime: crate::time::Clock, + pub handle: Handle, + /// What the peer's connection credential earns by default. + pub peer_grant: Grant, +} + +impl Serve { + /// Answer one Auth request. The stream lives as long as the token. + pub(super) async fn run( + self, + mut stream: Stream, + msg: Auth, + version: Version, + ) { + let issue = Issue::shared(); + match self.handle.acceptor() { + Some(requests) => { + // A closed queue hands the request back, and dropping it refuses the token. + let _ = requests.try_push(Request::new(msg.token, issue.clone())); + } + // Only the connection's own credential has a default answer; a token needs + // someone to verify it. + None if !msg.token.is_empty() => { + let reply = AuthError { + code: NOT_SUPPORTED, + reason: "tokens are not verified in band".to_string(), + }; + if stream.writer.encode_message(&reply).await.is_ok() { + let _ = stream.writer.close().await; + } + return; + } + None => issue.lock().outbox.push_back(Reply::Grant(self.peer_grant)), + } + + let err = match serve_issue(&self.runtime, &issue, &mut stream, version).await { + Ok(()) => Error::Cancel, + Err(err) => { + match &err { + Error::Cancel | Error::Unsupported | Error::Stream(_) | Error::Session(_) | Error::Transport(_) => { + tracing::debug!(%err, "auth request ended") + } + err => tracing::warn!(%err, "auth request error"), + } + err + } + }; + issue.lock().peer.get_or_insert(err); + } +} + +/// Write the acceptor's replies until either side ends the token. +async fn serve_issue( + runtime: &crate::time::Clock, + issue: &kio::Shared, + stream: &mut Stream, + version: Version, +) -> Result<(), Error> { + enum Next { + Withdrawn(Result<(), Error>), + Reply(Option), + } + + loop { + let next = kio::wait(|waiter| { + let mut cx = std::task::Context::from_waker(waiter.waker()); + if let Poll::Ready(res) = stream.reader.poll_closed(&mut cx) { + return Poll::Ready(Next::Withdrawn(res)); + } + match issue.poll(waiter, |issue| match issue.outbox.is_empty() && !issue.done { + true => Poll::Pending, + false => Poll::Ready(()), + }) { + Poll::Ready(mut issue) => Poll::Ready(Next::Reply(issue.outbox.pop_front())), + Poll::Pending => Poll::Pending, + } + }) + .await; + + match next { + // The presenter withdrew the token, by FIN or by a cancelling reset. + Next::Withdrawn(Ok(()) | Err(Error::Stream(crate::StreamError::Cancel))) => break, + Next::Withdrawn(Err(err)) => return Err(err), + Next::Reply(Some(Reply::Grant(grant))) => { + let now = crate::runtime::Timers::now(runtime); + let ok = AuthOk { + publish: grant.publish, + subscribe: grant.subscribe, + expires: grant.expires.map(|at| at.saturating_duration_since(now)), + }; + // Validated before anything is written, so an unrepresentable grant never + // leaves half a message on the wire. Never widen it: refuse, which revokes + // whatever this stream granted before. + if let Err(EncodeError::Unsupported) = ok.encode_msg(&mut Sizer::default(), version) { + tracing::debug!("auth grant not representable as prefixes; refusing the token"); + issue.lock().done = true; + let refused = AuthError { + code: NOT_SUPPORTED, + reason: "grant not representable as namespace prefixes".to_string(), + }; + stream.writer.encode_message(&refused).await?; + break; + } + stream.writer.encode_message(&ok).await?; + } + Next::Reply(Some(Reply::Refuse { code, reason })) => { + let refused = AuthError { + code: to_code(code), + reason, + }; + stream.writer.encode_message(&refused).await?; + } + // The app is done with the grant, or refused the token: close our side. + Next::Reply(None) => break, + } + } + + stream.writer.finish()?; + stream.writer.closed().await +} + +#[cfg(test)] +mod tests { + use super::*; + + const VERSION: Version = Version::Draft17; + + fn patterns(prefixes: &[&str]) -> Patterns { + prefixes.iter().map(|p| Pattern::subtree(p).unwrap()).collect() + } + + fn round_trip(msg: &T) -> T { + let mut buf = bytes::BytesMut::new(); + msg.encode_msg(&mut buf, VERSION).unwrap(); + let mut slice = &buf[..]; + let got = T::decode_msg(&mut slice, VERSION).unwrap(); + assert!(slice.is_empty(), "trailing bytes after decode"); + got + } + + /// Every draft that negotiates the extension round-trips the option; the drafts + /// before the unified SETUP carry none. + #[test] + fn setup_option_round_trips_on_supported_drafts() { + for version in [ + Version::Draft17, + Version::Draft18, + Version::Draft19, + Version::Draft20, + Version::Draft21, + Version::Draft22, + ] { + let mut params = super::super::Parameters::default(); + assert_eq!(from_setup(¶ms, version), None); + into_setup(&mut params, version); + assert_eq!(from_setup(¶ms, version), Some(true), "{version:?}"); + } + for version in [Version::Draft14, Version::Draft15, Version::Draft16] { + let mut params = super::super::Parameters::default(); + into_setup(&mut params, version); + assert_eq!(params.get_varint(super::super::ParameterVarInt::Auth), None); + assert_eq!(from_setup(¶ms, version), None, "{version:?}"); + } + } + + /// An explicit value other than 1 is an implementation that declined, which is + /// not the same statement as saying nothing. + #[test] + fn only_one_negotiates() { + let mut params = super::super::Parameters::default(); + params.set_varint(super::super::ParameterVarInt::Auth, 0); + assert_eq!(from_setup(¶ms, VERSION), Some(false)); + } + + #[test] + fn auth_round_trips() { + for token in [Bytes::new(), Bytes::from_static(b"eyJhbGciOi.jwt")] { + let msg = Auth { + request_id: RequestId(4), + token, + }; + assert_eq!(round_trip(&msg), msg); + } + } + + /// The root grant and a union of prefixes survive the trip, as distinct from the + /// empty grant. + #[test] + fn auth_ok_round_trips() { + for (publish, subscribe, expires) in [ + (patterns(&[""]), patterns(&[]), None), + ( + patterns(&["room/alice", "room/bob"]), + patterns(&["room"]), + Some(Duration::from_secs(60)), + ), + ] { + let msg = AuthOk { + publish, + subscribe, + expires, + }; + assert_eq!(round_trip(&msg), msg); + } + } + + /// A prefix is a namespace tuple, the way SUBSCRIBE_NAMESPACE spells one. + #[test] + fn prefixes_are_namespace_tuples() { + let msg = AuthOk { + publish: patterns(&["room/alice"]), + subscribe: Patterns::new(), + expires: None, + }; + let mut buf = bytes::BytesMut::new(); + msg.encode_msg(&mut buf, VERSION).unwrap(); + assert_eq!(&buf[..], b"\x01\x02\x04room\x05alice\x00\x00"); + } + + #[test] + fn auth_error_round_trips() { + let msg = AuthError { + code: UNAUTHORIZED, + reason: "expired".to_string(), + }; + assert_eq!(round_trip(&msg), msg); + } + + /// Only subtrees fit the prefix encoding, alone or in a union; anything narrower is + /// refused, never widened to its head. + #[test] + fn unrepresentable_grants_are_refused() { + for union in [&["room/alice"][..], &["room/*/cam"], &["**/cam"], &["room/**", "lobby"]] { + let msg = AuthOk { + publish: union.iter().map(|p| Pattern::try_from(*p).unwrap()).collect(), + subscribe: Patterns::new(), + expires: None, + }; + let mut buf = bytes::BytesMut::new(); + assert!( + matches!(msg.encode_msg(&mut buf, VERSION), Err(EncodeError::Unsupported)), + "{union:?} encoded" + ); + } + } + + #[test] + fn older_drafts_have_no_auth() { + for version in [Version::Draft14, Version::Draft15, Version::Draft16] { + let mut buf = bytes::BytesMut::new(); + let msg = Auth { + request_id: RequestId(0), + token: Bytes::new(), + }; + assert!(matches!(msg.encode_msg(&mut buf, version), Err(EncodeError::Version))); + let mut slice: &[u8] = &[0, 0]; + assert!(matches!( + Auth::decode_msg(&mut slice, version), + Err(DecodeError::Version) + )); + } + } + + /// NOT_SUPPORTED is the acceptor unable to tell a grant, never a refusal. + #[test] + fn not_supported_is_unsupported() { + assert!(matches!(from_code(NOT_SUPPORTED), Error::Unsupported)); + assert!(matches!( + from_code(UNAUTHORIZED), + Error::Session(SessionError::Unauthorized) + )); + assert_eq!(to_code(SessionError::Unauthorized), UNAUTHORIZED); + } +} diff --git a/rs/moq-net/src/ietf/mod.rs b/rs/moq-net/src/ietf/mod.rs index 8ae7dac1ec..f07640f303 100644 --- a/rs/moq-net/src/ietf/mod.rs +++ b/rs/moq-net/src/ietf/mod.rs @@ -7,6 +7,7 @@ #[macro_use] mod parameters; mod adapter; +pub(crate) mod auth; pub mod cluster; mod control; pub(crate) mod error; diff --git a/rs/moq-net/src/ietf/parameters.rs b/rs/moq-net/src/ietf/parameters.rs index 7b3b426f88..669f4f62d7 100644 --- a/rs/moq-net/src/ietf/parameters.rs +++ b/rs/moq-net/src/ietf/parameters.rs @@ -28,6 +28,8 @@ pub enum ParameterVarInt { Solicit = super::solicit::SOLICIT, /// HIDDEN, from the MoQ Hidden extension. Hidden = super::hidden::HIDDEN, + /// AUTH, from the MoQ Auth extension. + Auth = super::auth::AUTH, #[num_enum(catch_all)] Unknown(u64), } diff --git a/rs/moq-net/src/ietf/peer.rs b/rs/moq-net/src/ietf/peer.rs index 20f793ed12..336ffb755a 100644 --- a/rs/moq-net/src/ietf/peer.rs +++ b/rs/moq-net/src/ietf/peer.rs @@ -19,6 +19,9 @@ pub(crate) struct Peer { /// MoQ Hidden: whether the peer understands the HIDDEN parameter on /// SUBSCRIBE_NAMESPACE, so we may send it. pub hidden: bool, + + /// MoQ Auth: whether both sides negotiated the Auth request streams. + pub auth: bool, } /// Shared slot for [`Peer`], filled when the peer's SETUP is read. @@ -78,6 +81,7 @@ mod tests { }, solicit: None, hidden: false, + auth: false, }; let slot = PeerSetup::default(); @@ -89,6 +93,7 @@ mod tests { }, solicit: Some(true), hidden: true, + auth: true, }); assert_eq!(slot.get().await, first); diff --git a/rs/moq-net/src/ietf/publish.rs b/rs/moq-net/src/ietf/publish.rs index cdf7086e0b..3537bee311 100644 --- a/rs/moq-net/src/ietf/publish.rs +++ b/rs/moq-net/src/ietf/publish.rs @@ -126,6 +126,8 @@ pub(crate) enum PublishDoneStatus { InternalError, /// The track is no longer being published. TrackEnded, + /// The publisher's grant no longer covers the track (MoQ Auth). + Unauthorized, } impl PublishDoneStatus { @@ -144,6 +146,7 @@ impl PublishDoneStatus { | Version::Draft21 | Version::Draft22 => match self { Self::InternalError => 0x0, + Self::Unauthorized => 0x1, Self::TrackEnded => 0x2, }, } @@ -159,6 +162,30 @@ pub struct PublishDone<'a> { pub reason_phrase: Cow<'a, str>, } +impl PublishDone<'_> { + /// How the publisher ended the subscription: cleanly, or with the error its status names. + pub(crate) fn end(&self, version: Version) -> Result<(), crate::Error> { + match self.status_code { + code if code == PublishDoneStatus::TrackEnded.code(version) => Ok(()), + // SUBSCRIPTION_ENDED: the subscription reached the end its filter asked for. + // Draft-20 removed it and left 0x3 unassigned. + 0x3 if matches!( + version, + Version::Draft14 + | Version::Draft15 + | Version::Draft16 + | Version::Draft17 + | Version::Draft18 + | Version::Draft19 + ) => + { + Ok(()) + } + code => Err(crate::Error::Remote(u32::try_from(code).unwrap_or(u32::MAX))), + } + } +} + impl Message for PublishDone<'_> { const ID: u64 = 0x0b; @@ -692,6 +719,33 @@ mod tests { } } + /// A subscriber ends the track the way the publisher said it ended: cleanly for a + /// finished track or a reached filter end, with the publisher's code otherwise. + #[test] + fn publish_done_end_follows_the_status() { + let done = |status_code| PublishDone { + request_id: None, + status_code, + stream_count: 0, + reason_phrase: "".into(), + }; + + for version in [Version::Draft14, Version::Draft19, Version::Draft20, Version::Draft22] { + assert!(done(0x2).end(version).is_ok(), "{version:?}"); + assert!( + matches!(done(0x0).end(version), Err(crate::Error::Remote(0x0))), + "{version:?}" + ); + } + + // SUBSCRIPTION_ENDED is clean until draft-20 unassigned it. + assert!(done(0x3).end(Version::Draft19).is_ok()); + assert!(matches!( + done(0x3).end(Version::Draft20), + Err(crate::Error::Remote(0x3)) + )); + } + #[test] fn test_publish_v18_round_trip() { let msg = Publish { diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 1b4d7f3af6..527d6ab2a7 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -197,6 +197,10 @@ struct Namespaces { /// The open PUBLISH_NAMESPACE request carrying each advertised namespace. Empty when /// the entries ride a SUBSCRIBE_NAMESPACE stream inline. requests: HashMap>, + /// What our grant lets us publish (MoQ Auth), `None` while unknown. + permit: Option, + /// The grant union's epoch last applied to `permit`. + epoch: u64, } impl Namespaces { @@ -206,8 +210,15 @@ impl Namespaces { target, watched: HashMap::new(), requests: HashMap::new(), + permit: None, + epoch: 0, } } + + /// Whether our grant lets us advertise `path`. An unknown grant allows everything. + fn permitted(&self, path: &crate::Path) -> bool { + self.permit.as_ref().is_none_or(|permit| permit.matches(path.as_str())) + } } /// What woke an announce-forwarding loop. @@ -218,6 +229,8 @@ enum NamespaceEvent { Update(Option), /// The retry sleep fired: re-offer whatever the peer should be holding and isn't. Retry, + /// Our grant changed (MoQ Auth): re-check every namespace against it. + Regrant(Option), } #[derive(Clone)] @@ -242,6 +255,9 @@ pub(super) struct Publisher { // Shared across request handlers; None marks a dispatched subscription still resolving. joins: kio::Shared>>, version: Version, + // Our grant (MoQ Auth): only what it lets us publish is advertised and served, and a + // shrink withdraws what it no longer covers. + auth: crate::auth::Handle, } /// The snapshot a joining FETCH inherits from its subscription. @@ -297,9 +313,16 @@ where peer_setup, joins: Default::default(), version, + auth: crate::auth::Handle::new(false), } } + /// Bound what we publish by the grant this session's tokens earn (MoQ Auth). + pub fn with_auth(mut self, auth: crate::auth::Handle) -> Self { + self.auth = auth; + self + } + /// What the peer declared in its SETUP, or the default (extension off) on a version /// that cannot negotiate it. /// @@ -470,6 +493,22 @@ where tracing::info!(id = %request_id, broadcast = %absolute, track = %track_name, "subscribe started"); + // Serve only what our grant lets us publish (MoQ Auth), and stop once it no + // longer does. Checked before resolving, so a denied request never reaches the + // origin. + let mut gate = crate::auth::Gate::new( + self.auth.clone(), + msg.track_namespace.to_owned(), + crate::auth::Direction::Publish, + ); + if !self + .auth + .allows(crate::auth::Direction::Publish, msg.track_namespace.as_str()) + { + let err = Error::Unauthorized; + return self.reject_subscribe(stream, request_id, &err, "not granted").await; + } + // Stats (subscriptions, viewer refcount, groups/frames/bytes) are counted in // the model, through the tagged `origin::Consumer` the broadcast resolves from. @@ -607,6 +646,10 @@ where if let Poll::Ready(res) = waiter.poll_future(serve.as_mut()) { return Poll::Ready(res); } + if gate.poll_denied(waiter).is_ready() { + tracing::info!(broadcast = %absolute, track = %track_name, "subscription no longer authorized"); + return Poll::Ready(Err(Error::Unauthorized)); + } let mut cx = std::task::Context::from_waker(waiter.waker()); if stream.reader.poll_closed(&mut cx).is_ready() || closed_session.poll_closed(&mut cx).is_ready() { return Poll::Ready(Ok(())); @@ -619,6 +662,7 @@ where // Send PublishDone let (status, reason) = match &res { Ok(()) => (ietf::PublishDoneStatus::TrackEnded, "track ended"), + Err(Error::Unauthorized) => (ietf::PublishDoneStatus::Unauthorized, "not granted"), Err(_) => (ietf::PublishDoneStatus::InternalError, "internal error"), }; let _ = stream.writer.encode(&ietf::PublishDone::ID).await; @@ -1308,17 +1352,23 @@ where suffix: &crate::PathOwned, path: &crate::PathOwned, ) -> Result<(), Error> { + let permitted = ns.permitted(path); let Namespaces { peer, target, watched, requests, + .. } = ns; let Some(watch) = watched.get(suffix) else { return Ok(()); }; - let advert = self.select(&watch.route, peer); + // Nothing our grant does not cover reaches the wire, and a shrink withdraws it. + let advert = match permitted { + true => self.select(&watch.route, peer), + false => Advert::None, + }; let refused = watch.refused; let wanted = advert.wanted(); let held = watch.sent.wanted(); @@ -1811,6 +1861,12 @@ where ) -> Result<(), Error> { let mut announced = origin.announced(); + // With MoQ Auth, wait for the answer to the credential we presented at setup, so + // the first advertisement is already checked against our grant. + if self.peer_setup.get().await.auth { + kio::wait(|waiter| self.auth.poll_setup_answered(waiter)).await; + } + // When to re-offer whatever the peer should hold and doesn't, and how long to wait // the next time that fails. Jittered so a relay's namespaces don't all come back on // the same tick. @@ -1833,12 +1889,17 @@ where retry.set(retry_at); let event = { - let Namespaces { target, .. } = &mut ns; + let Namespaces { target, epoch, .. } = &mut ns; kio::wait(|waiter| { let mut cx = std::task::Context::from_waker(waiter.waker()); if let Poll::Ready(res) = target.poll_closed(&mut cx) { return Poll::Ready(NamespaceEvent::Closed(res)); } + // A grant change applies before the next update, so a namespace it no + // longer covers is withdrawn rather than re-sent. + if let Poll::Ready(union) = self.auth.poll_union(epoch, waiter) { + return Poll::Ready(NamespaceEvent::Regrant(union)); + } if let Poll::Ready(update) = announced.poll_next(waiter) { return Poll::Ready(NamespaceEvent::Update(update)); } @@ -1871,6 +1932,14 @@ where self.sync_namespace(&mut ns, &suffix, &path).await?; } } + NamespaceEvent::Regrant(union) => { + ns.permit = union.map(|grant| grant.publish); + let suffixes: Vec = ns.watched.keys().cloned().collect(); + for suffix in suffixes { + let path = prefix.join(&suffix); + self.sync_namespace(&mut ns, &suffix, &path).await?; + } + } NamespaceEvent::Update(None) => { // The origin is gone: withdraw everything, then finish the // stream and wait for delivery. @@ -4338,6 +4407,7 @@ mod tests { }, solicit, hidden: false, + auth: false, }); slot } diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index f1c7258608..af424c9227 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -8,8 +8,8 @@ use crate::{ }; use super::{ - Control, Message, Publisher, Subscriber, Version, adapter::ControlStreamAdapter, cluster, hidden, peer, solicit, - subscriber::is_protocol_violation, + Control, Message, Publisher, Subscriber, Version, adapter::ControlStreamAdapter, auth, cluster, hidden, peer, + solicit, subscriber::is_protocol_violation, }; /// Everything one moq-transport session needs to start. @@ -62,6 +62,11 @@ pub struct Config { /// What that pre-read SETUP declared, so the session does not have to parse it /// twice. `None` when [`Self::peer_setup_stream`] is. pub peer_declared: Option, + + /// The session's auth handle, created before [`start`] so a server can take the + /// peer's token requests during its handshake. Supports AUTH exactly when the + /// version can negotiate it; the peer's SETUP decides whether it does. + pub auth: crate::auth::Handle, } pub fn start(config: Config) -> Result<(MaybeSendBox<'static, Result<(), Error>>, crate::goaway::Handle), Error> @@ -82,6 +87,7 @@ where path, peer_setup_stream, peer_declared, + auth, } = config; // GOAWAY wiring: the public Session holds one half (drain trigger, received @@ -90,7 +96,27 @@ where // server to open connections (draft-19 sect 10.4). let (goaway_handle, goaway) = crate::goaway::Handle::new(!client); + // What the peer's connection credential earns by default, from the caller's real + // handles before the empty-half defaulting below. + let peer_grant = auth::peer_grant(publish.as_ref(), subscribe.as_ref()); + + // Present the connection's own credential (the empty token) right away, so both + // sides learn their grant without waiting on the app. Draft-17+ only; the peer's + // SETUP then decides whether it is ever sent. + // A handle that already knows the peer declined (a gated server accept) refuses it. + let setup_token = match auth::supported(version) { + true => auth.present(bytes::Bytes::new(), true).ok(), + false => None, + }; + let driver = async move { + // Held for the life of the session. + let _setup_token = setup_token; + // Released on any exit, so nothing waits on a token the session will never answer. + let _auth_close = AuthClose(auth.clone()); + // Decided once, before any Auth request can be accepted: the app took the requests + // before running the driver, or the session answers itself. + let _ = auth.acceptor(); // Our own Hop ID, taken from whichever origin the caller actually supplied so // every session out of this process stamps the same one and cross-session loop // detection works. Read BEFORE the placeholders below: their ids are random and @@ -198,6 +224,8 @@ where dispatch_session, publisher.clone(), subscriber.clone(), + peer_setup.clone(), + None, version ))); // Unsolicited PUBLISH_NAMESPACE unless the peer requires solicitation; @@ -278,6 +306,20 @@ where }; let control = Control::new(None, client); + // Only the dialing side fails loud on a publication outside its grant: a + // server's publish origin is everything the peer may read, not what it + // intends to push. + let enforce = { + let auth = auth.clone(); + let origin = publish.clone(); + let session = session.clone(); + async move { + match client { + true => enforce_grant(auth, origin, session).await, + false => std::future::pending().await, + } + } + }; let publisher = Publisher::new( runtime.clone(), session.clone(), @@ -286,13 +328,14 @@ where peer_hop, peer_setup.clone(), version, - ); + ) + .with_auth(auth.clone()); let (tasks, mut task_set) = TaskSet::new(); let subscriber = Subscriber::new( runtime.clone(), session.clone(), subscribe, - control, + control.clone(), peer_hop, peer_setup.clone(), self_origin, @@ -300,7 +343,24 @@ where version, tasks, goaway.going_away.clone(), + ) + .with_auth(auth.clone()); + + // Our tokens, one Auth request each, once the peer's SETUP negotiates it. + let present = auth::run_present( + runtime.clone(), + session.clone(), + control.clone(), + auth.clone(), + peer_setup.clone(), + version, + goaway.going_away.clone(), ); + let serve = auth::Serve { + runtime: runtime.clone(), + handle: auth.clone(), + peer_grant, + }; let sub_ns_session = session.clone(); let sub_ns = subscriber.clone(); @@ -333,9 +393,13 @@ where session.clone(), publisher.clone(), subscriber.clone(), + peer_setup.clone(), + Some(serve), version ))); let mut goaway_recv = std::pin::pin!(err_only(goaway_recv)); + let mut present = std::pin::pin!(present); + let mut enforce = std::pin::pin!(err_only(enforce)); let mut setup = std::pin::pin!(setup); // Unsolicited PUBLISH_NAMESPACE unless the peer requires solicitation; // see `Publisher::run_publish_namespaces`. @@ -377,6 +441,11 @@ where if let Poll::Ready(err) = waiter.poll_future(goaway_recv.as_mut()) { return Poll::Ready(Err(err)); } + // Presenting tokens never ends the session. + let _ = waiter.poll_future(present.as_mut()); + if let Poll::Ready(err) = waiter.poll_future(enforce.as_mut()) { + return Poll::Ready(Err(err)); + } if waiter.poll_future(setup.as_mut()).is_ready() { return Poll::Ready(Ok(())); } @@ -395,6 +464,11 @@ where } }; + auth.close(match &res { + Ok(()) => Error::Cancel, + Err(err) => err.clone(), + }); + match &res { Err(Error::Transport(_)) => { tracing::info!("session terminated"); @@ -499,6 +573,7 @@ fn peer_from_params(params: &ietf::Parameters, version: Version) -> Result( cluster::peer_into_setup(&mut parameters, self_origin, cost, version); solicit::into_setup(&mut parameters, version); hidden::into_setup(&mut parameters, version); + auth::into_setup(&mut parameters, version); let parameters = parameters.encode_bytes(version)?; writer.encode(&setup::Setup { parameters }).await?; @@ -730,6 +806,9 @@ async fn run_dispatch( session: S, publisher: Publisher, mut subscriber: Subscriber, + peer_setup: peer::PeerSetup, + // Answers the peer's Auth requests, on the versions that can negotiate them. + serve: Option, version: Version, ) -> Result<(), Error> where @@ -741,6 +820,13 @@ where // costs a handshake round rather than blocking. let peer = subscriber.peer().await; + // An AUTH from a peer that did not negotiate MoQ Auth is an unknown request, which + // falls through to the protocol violation below. + let serve = match peer_setup.get().await.auth { + true => serve, + false => None, + }; + // From the same slot, so this costs nothing extra: it decides whether an unsolicited // advertisement is the peer ignoring our own SETUP (MoQ Solicit). let declared = subscriber.solicit().await; @@ -800,6 +886,14 @@ where ietf::Publish::ID | ietf::PublishNamespace::ID => { tasks.push(subscriber.handle_stream(id, data, stream, peer, declared)?); } + auth::Auth::ID if let Some(serve) = &serve => { + let mut data = data; + let msg = auth::Auth::decode_msg(&mut data, version)?; + if !data.is_empty() { + return Err(Error::WrongSize); + } + tasks.push(serve.clone().run(stream, msg, version)); + } _ => { tracing::warn!(id, "unexpected bidi stream type"); return Err(Error::UnexpectedStream); @@ -869,6 +963,38 @@ async fn run_goaway( } } +/// Closes the auth handle when the session driver ends without finishing, releasing +/// anything still waiting on a token. +struct AuthClose(crate::auth::Handle); + +impl Drop for AuthClose { + fn drop(&mut self) { + self.0.close(Error::Cancel); + } +} + +/// Close the session when our origin announces a broadcast our grant never covered, +/// instead of leaving it to wait for a subscription that never comes. See +/// [`crate::auth::Enforce`]. +async fn enforce_grant( + auth: crate::auth::Handle, + origin: origin::Consumer, + mut session: S, +) -> Result<(), Error> { + let mut announced = origin.announced(); + let mut check = crate::auth::Enforce::default(); + let Some(path) = kio::wait(|waiter| check.poll(&auth, &mut announced, waiter)).await else { + return Ok(()); + }; + tracing::error!(broadcast = %origin.absolute(&path), "publishing outside our grant; closing the session"); + let err = Error::Unauthorized; + session.close( + SessionError::from(&err).to_code(), + &crate::auth::unauthorized_reason(&path), + ); + Err(err) +} + #[cfg(test)] mod tests { use super::*; @@ -941,6 +1067,7 @@ mod tests { }, ..Default::default() }), + auth: crate::auth::Handle::new(false), }) .expect("start the session"); @@ -992,6 +1119,7 @@ mod tests { peer_setup_stream: None, // The requests wait on the peer's SETUP (MoQ Hidden). peer_declared: Some(peer::Peer::default()), + auth: crate::auth::Handle::new(false), }) .expect("start the session"); let _driver = tokio::spawn(driver); @@ -1042,6 +1170,7 @@ mod tests { path: None, peer_setup_stream: None, peer_declared, + auth: crate::auth::Handle::new(false), }) .expect("start the session"); let _driver = tokio::spawn(driver); @@ -1096,6 +1225,108 @@ mod tests { ); } + /// The AUTH message type as it leads an Auth request on the draft-18 wire. + fn auth_type() -> Vec { + let mut buf = Vec::new(); + auth::Auth::ID.encode(&mut buf, Version::Draft18).unwrap(); + buf + } + + /// A publishing draft-18 session with AUTH available locally, against a peer that + /// declared `auth`. Everything the session needs to keep running is held here. + struct AuthSession { + handle: crate::auth::Handle, + log: crate::lite::test_transport::Log, + _origin: crate::origin::Producer, + _cam: crate::AnnounceProducer, + _gate: kio::Producer, + _goaway: crate::goaway::Handle, + _driver: tokio::task::JoinHandle>, + } + + fn auth_session(auth: bool) -> AuthSession { + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let cam = origin.announce("solo-cam", crate::origin::Route::default()).unwrap(); + + let gate = kio::Producer::new(true); + let session = crate::lite::test_transport::SinkSession::gated_bi(gate.consume()); + let log = session.log.clone(); + + let handle = crate::auth::Handle::new(true); + let (driver, goaway) = start(Config { + runtime: crate::time::Clock::tokio(), + session, + setup: None, + request_id_max: None, + client: true, + publish: Some(origin.consume()), + subscribe: None, + peer_hop: None, + cost: None, + version: Version::Draft18, + path: None, + peer_setup_stream: None, + peer_declared: Some(peer::Peer { + auth, + ..Default::default() + }), + auth: handle.clone(), + }) + .expect("start the session"); + AuthSession { + handle, + log, + _origin: origin, + _cam: cam, + _gate: gate, + _goaway: goaway, + _driver: tokio::spawn(driver), + } + } + + /// A peer that never offered MoQ Auth sees no Auth request, the session keeps + /// working, and every token fails as unsupported rather than hanging. + #[tokio::test(start_paused = true)] + async fn a_peer_without_auth_sees_no_auth_request() { + let session = auth_session(false); + let (handle, log) = (&session.handle, &session.log); + + let err = tokio::time::timeout(std::time::Duration::from_secs(1), handle.add("token")) + .await + .expect("unsupported promptly") + .err() + .expect("no AUTH on this session"); + assert!(matches!(err, Error::Unsupported), "{err:?}"); + assert_eq!(handle.grant().peek(), None, "no grant without the extension"); + + for _ in 0..ANNOUNCE_TURNS { + if occurrences(log, b"solo-cam") > 0 { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(1)).await; + } + assert_eq!(occurrences(log, b"solo-cam"), 1, "the session stopped advertising"); + assert_eq!( + occurrences(log, &auth_type()), + 0, + "sent AUTH to a peer that never offered it" + ); + } + + /// A peer that offered it gets the connection's own credential right away. + #[tokio::test(start_paused = true)] + async fn a_negotiating_peer_gets_the_setup_token() { + let session = auth_session(true); + let log = &session.log; + for _ in 0..ANNOUNCE_TURNS { + if occurrences(log, &auth_type()) > 0 { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(1)).await; + } + assert_eq!(occurrences(log, &auth_type()), 1, "one AUTH for the setup token"); + } + /// The declared Hop ID must be the caller's own origin, whichever half carries it. /// /// A subscribe-only session (an ingest that publishes nothing) still routes, so @@ -1151,6 +1382,7 @@ mod tests { // Pre-settled, so nothing waits on a SETUP the dead stream will never // carry and the dispatch loop actually runs. peer_declared: Some(peer::Peer::default()), + auth: crate::auth::Handle::new(false), }) .expect("start the session"); @@ -1341,6 +1573,7 @@ mod tests { path: None, peer_setup_stream: None, peer_declared: None, + auth: crate::auth::Handle::new(false), }) .expect("start the session"); let driver = tokio::spawn(driver); diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index da755483d9..88274f7b00 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -426,6 +426,8 @@ pub(super) struct Subscriber { // Set once the peer sends a GOAWAY; new SUBSCRIBEs are then rejected with // Error::GoingAway (the peer told us to stop opening streams). going_away: crate::goaway::GoingAway, + // Our grant (MoQ Auth): a subscription it stops covering is cancelled. + auth: crate::auth::Handle, } /// Resolve the subscription a data stream belongs to. @@ -491,9 +493,16 @@ where tasks, version, going_away, + auth: crate::auth::Handle::new(false), } } + /// Bound what we subscribe to by the grant this session's tokens earn (MoQ Auth). + pub fn with_auth(mut self, auth: crate::auth::Handle) -> Self { + self.auth = auth; + self + } + /// Leave `alias` in the state a cancelled subscription leaves behind: bound to a /// subscription, then retired. /// @@ -1563,6 +1572,21 @@ where return; } + // Subscribe only to what our grant covers (MoQ Auth), and cancel once it no longer + // does, leaving the rest of the session alone. + if !self + .auth + .allows(crate::auth::Direction::Subscribe, broadcast_path.as_str()) + { + request.reject(Error::Unauthorized); + return; + } + let mut gate = crate::auth::Gate::new( + self.auth.clone(), + broadcast_path.to_owned(), + crate::auth::Direction::Subscribe, + ); + let subscription = request.subscription(); let join = match subscribe_join( subscription.as_ref().and_then(|s| s.start), @@ -1738,51 +1762,62 @@ where } // One event ends the subscription: the last consumer leaving, or the - // subscribe stream closing. The broadcast ending does not: a retraction + // publisher's PUBLISH_DONE. The broadcast ending does not: a retraction // does not disturb subscriptions already in flight. enum End { Unused, - StreamClosed(Result<(), Error>), + Revoked, + Done(Result<(), Error>), } let mut fetch_done = fetching.is_none(); - let cancelled = loop { - let end = kio::wait(|waiter| { - if !fetch_done - && let Some(fut) = fetching.as_mut() - && waiter.poll_future(fut.as_mut()).is_ready() - { - fetch_done = true; - } - if track.poll_unused(waiter).is_ready() { - return Poll::Ready(End::Unused); - } - let mut cx = std::task::Context::from_waker(waiter.waker()); - stream.reader.poll_closed(&mut cx).map(End::StreamClosed) - }) - .await; - - match end { - End::Unused => match track.abort_unused(Error::Cancel) { - Ok(()) => { - tracing::info!(broadcast = %self.origin.absolute(&broadcast_path), track = %track_name, "subscribe cancelled"); - break true; + let cancelled = { + let mut done = std::pin::pin!(Self::read_publish_done(&mut stream.reader, self.version)); + loop { + let end = kio::wait(|waiter| { + if !fetch_done + && let Some(fut) = fetching.as_mut() + && waiter.poll_future(fut.as_mut()).is_ready() + { + fetch_done = true; } - Err(used) => track = used, - }, - End::StreamClosed(res) => { - match res { + if gate.poll_denied(waiter).is_ready() { + return Poll::Ready(End::Revoked); + } + if track.poll_unused(waiter).is_ready() { + return Poll::Ready(End::Unused); + } + waiter.poll_future(done.as_mut()).map(End::Done) + }) + .await; + + match end { + End::Unused => match track.abort_unused(Error::Cancel) { Ok(()) => { - tracing::info!(broadcast = %self.origin.absolute(&broadcast_path), track = %track_name, "subscribe complete"); - let _ = track.finish(); + tracing::info!(broadcast = %self.origin.absolute(&broadcast_path), track = %track_name, "subscribe cancelled"); + break true; } - Err(err) => { - tracing::debug!(%err, "subscribe stream closed with error"); - let _ = track.abort(err); + Err(used) => track = used, + }, + End::Revoked => { + tracing::info!(broadcast = %self.origin.absolute(&broadcast_path), track = %track_name, "subscription no longer authorized"); + let _ = track.abort(Error::Unauthorized); + break true; + } + End::Done(res) => { + match res { + Ok(()) => { + tracing::info!(broadcast = %self.origin.absolute(&broadcast_path), track = %track_name, "subscribe complete"); + let _ = track.finish(); + } + Err(err) => { + tracing::debug!(%err, "subscribe ended with error"); + let _ = track.abort(err); + } } + // The publisher already ended the request, so there is nothing to cancel. + break false; } - // The publisher already ended the request, so there is nothing to cancel. - break false; } } }; @@ -1799,6 +1834,21 @@ where } } + /// Read the PUBLISH_DONE that ends an Established subscription, as the end it reports. + /// + /// The publisher must send it before its FIN (draft-19 section 3.3.2), so a FIN + /// without one is a failed request, not a clean end. + async fn read_publish_done(reader: &mut Reader, version: Version) -> Result<(), Error> { + match reader.decode_maybe::().await? { + Some(ietf::PublishDone::ID) => {} + Some(_) => return Err(Error::UnexpectedMessage), + None => return Err(Error::ProtocolViolation), + } + let msg: ietf::PublishDone = reader.decode().await?; + tracing::debug!(message = ?msg, "received publish done"); + msg.end(version) + } + /// Tell the publisher to stop serving a subscription we are walking away from. /// /// Every path that abandons an Established subscription goes through here, because diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 25e194c43a..12039ab3bf 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -495,21 +495,12 @@ impl AuthServe { } /// Aborts the session when our origin announces a broadcast our grant does not -/// cover, instead of leaving it to wait for a subscription that never comes. -/// -/// Waits until the tokens the session presented at setup are answered, then -/// checks each broadcast when it is first announced. A grant that later shrinks -/// withdraws what it no longer covers (see [`AnnounceRun`]) without aborting: -/// the union is processed before new announcements, so a revocation is never -/// mistaken for a new unauthorized publication. +/// cover, instead of leaving it to wait for a subscription that never comes. See +/// [`crate::auth::Enforce`]. #[derive(Default)] struct Enforce { - epoch: u64, - permit: Option, + check: crate::auth::Enforce, announced: Option, - /// Every broadcast admitted so far, still announced. - live: std::collections::HashSet, - setup: bool, } impl Enforce { @@ -518,18 +509,6 @@ impl Enforce { shared: &Shared, waiter: &kio::Waiter, ) -> Poll> { - if !self.setup { - ready!(shared.auth.poll_setup_answered(waiter)); - self.setup = true; - } - while let Poll::Ready(union) = shared.auth.poll_union(&mut self.epoch, waiter) { - self.permit = union.map(|grant| grant.publish); - } - // No grant yet (the peer never answered with one): nothing to check against. - let Some(permit) = &self.permit else { - return Poll::Pending; - }; - let announced = match &mut self.announced { Some(announced) => announced, None => { @@ -537,36 +516,19 @@ impl Enforce { self.announced.insert(origin.announced()) } }; - - loop { - let Some(update) = ready!(announced.poll_next(waiter)) else { - return Poll::Ready(Ok(())); - }; - match update.kind { - announce::Kind::Announced if !self.live.contains(&update.prefix) => { - if !permit.matches(update.prefix.as_str()) { - tracing::error!( - broadcast = %shared.origin.absolute(&update.prefix), - "publishing outside our grant; closing the session" - ); - // `Error` carries no payload, so the path travels in the close - // reason, in the session's own terms. - let err = Error::Unauthorized; - let reason = format!("unauthorized: {}", update.prefix); - shared - .session - .clone() - .close(SessionError::from(&err).to_code(), &reason); - return Poll::Ready(Err(err)); - } - self.live.insert(update.prefix); - } - announce::Kind::Retracted => { - self.live.remove(&update.prefix); - } - _ => {} - } - } + let Some(path) = ready!(self.check.poll(&shared.auth, announced, waiter)) else { + return Poll::Ready(Ok(())); + }; + tracing::error!( + broadcast = %shared.origin.absolute(&path), + "publishing outside our grant; closing the session" + ); + let err = Error::Unauthorized; + shared.session.clone().close( + SessionError::from(&err).to_code(), + &crate::auth::unauthorized_reason(&path), + ); + Poll::Ready(Err(err)) } } @@ -1133,12 +1095,13 @@ impl AnnounceRun { let mut cx = Context::from_waker(waiter.waker()); if matches!(self.phase, AnnouncePhase::Init) { - // Start from whatever grant we already hold, so the initial set never - // advertises something it would withdraw a moment later. - if let Some(auth) = &self.auth - && let Poll::Ready(union) = auth.poll_union(&mut self.epoch, waiter) - { - self.permit = union.map(|grant| grant.publish); + // Start from the grant the setup token earns, so the initial set never + // advertises something it would withdraw, or abort over, a moment later. + if let Some(auth) = &self.auth { + ready!(auth.poll_setup_answered(waiter)); + if let Poll::Ready(union) = auth.poll_union(&mut self.epoch, waiter) { + self.permit = union.map(|grant| grant.publish); + } } self.init(stream, origin, announced)?; self.phase = AnnouncePhase::Running; diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index 98146774d1..cd404d4dbf 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -396,8 +396,8 @@ impl Server { // Cluster extension and declared a non-zero Hop ID. origin: peer_setup.declared.cluster.hop.filter(|h| *h != crate::Hop::UNKNOWN), assigned_hop: crate::Hop::random(), - // moq-transport carries no AUTH yet. - auth: crate::auth::Handle::new(false), + // The client's SETUP already settled whether MoQ Auth is negotiated. + auth: crate::auth::Handle::new(peer_setup.declared.auth), inner: Some(RequestInner { server: self.clone(), runtime, @@ -532,6 +532,7 @@ where path: None, peer_setup_stream: Some(peer_setup.stream), peer_declared: Some(peer_setup.declared), + auth: auth.clone(), })?; tracing::debug!(?version, "connected"); Ok(Session::new( @@ -649,6 +650,7 @@ where path: None, peer_setup_stream: None, peer_declared: Some(peer_declared), + auth: auth.clone(), })?; (None, crate::driver::Protocol::Ietf(protocol), goaway, auth) } diff --git a/rs/moq-net/src/session.rs b/rs/moq-net/src/session.rs index 0d9a234c11..e6cdd0ea7a 100644 --- a/rs/moq-net/src/session.rs +++ b/rs/moq-net/src/session.rs @@ -201,8 +201,9 @@ impl Session { /// The tokens this side presented and the grant they earned, plus the tokens /// the peer presents. See [`auth`]. /// - /// On moq-lite-06 each side presents its connection's credential right after - /// setup; on every other version the grant stays `None`. + /// On moq-lite-06, and on moq-transport draft-17+ when both sides negotiate the + /// MoQ Auth extension, each side presents its connection's credential right after + /// setup. Older versions, and peers that do not negotiate it, leave the grant `None`. pub fn auth(&self) -> auth::Handle { self.auth.clone() } diff --git a/rs/moq-net/tests/announce_to_serve.rs b/rs/moq-net/tests/announce_to_serve.rs index dd435e6d6e..6eb4b57eb6 100644 --- a/rs/moq-net/tests/announce_to_serve.rs +++ b/rs/moq-net/tests/announce_to_serve.rs @@ -222,12 +222,19 @@ async fn remote_lite_consumer_sees_what_a_local_one_does() { #[tokio::test] async fn remote_ietf_consumer_sees_what_a_local_one_does() { tokio::time::pause(); - let mut seen = lifecycle(Observer::Remote("moq-transport-19")).await; - // Every IETF subscription ends in error today, announced or not: the subscriber - // reads the publisher's PUBLISH_DONE as trailing bytes (/quest/m1/ietf-publish-done.md). - // The track still carries on across the retraction; only its end differs. - assert_eq!(seen.remove(4), "draining: after then error dropped"); - let mut expected = EXPECTED.to_vec(); - expected.remove(4); - assert_eq!(seen, expected); + // Drafts 14 to 16 carry each request over the control stream; 17 and later give it + // its own stream. Both have to end a finished track the way moq-lite does. + for version in [ + "moq-transport-14", + "moq-transport-15", + "moq-transport-16", + "moq-transport-17", + "moq-transport-18", + "moq-transport-19", + "moq-transport-20", + "moq-transport-21", + "moq-transport-22", + ] { + assert_eq!(lifecycle(Observer::Remote(version)).await, EXPECTED, "{version}"); + } } diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs index d4dd6b3be9..f682dcd2c9 100644 --- a/rs/moq-net/tests/auth.rs +++ b/rs/moq-net/tests/auth.rs @@ -1,5 +1,6 @@ //! In-band AUTH over the in-memory mock transport: both sides learn their grant, -//! tokens union, and a publication outside the grant fails loud. +//! tokens union, and a publication outside the grant fails loud. Every case runs on +//! moq-lite-06 and on moq-transport with the MoQ Auth extension. mod support; @@ -17,6 +18,54 @@ use support::mock::{MockSession, create_mock_session_pair}; const TEST_TIMEOUT: Duration = Duration::from_secs(10); const LITE_06: &str = "moq-lite-06"; +/// The first draft that negotiates MoQ Auth, and the newest. +const MOQT_17: &str = "moq-transport-17"; +const MOQT_22: &str = "moq-transport-22"; + +/// Run each case on every version that exchanges AUTH. +macro_rules! cases { + ($($case:ident),* $(,)?) => { + mod lite_06 { + $(#[tokio::test] async fn $case() { super::$case(super::LITE_06).await })* + } + mod moqt_17 { + $(#[tokio::test] async fn $case() { super::$case(super::MOQT_17).await })* + } + mod moqt_22 { + $(#[tokio::test] async fn $case() { super::$case(super::MOQT_22).await })* + } + }; +} + +cases!( + both_sides_learn_their_grant_from_scoped_origins, + a_publish_only_session_grants_no_subscribe, + an_out_of_scope_announce_aborts_with_the_path, + a_broadcast_published_before_the_grant_is_checked, + an_unanswered_token_does_not_suspend_the_check, + tokens_union_and_withdrawing_one_shrinks_it, + an_update_replaces_one_tokens_grant, + a_revoked_grant_withdraws_and_can_be_restored, + a_refused_token_reports_the_code, + a_refused_setup_token_grants_nothing, + dropping_the_requests_refuses_queued_tokens, + a_closed_session_holds_no_grant, + a_reset_auth_stream_reports_unsupported, + a_revoked_grant_cancels_its_subscriptions, + an_unrepresentable_grant_is_unsupported, + an_unrepresentable_update_revokes_only_its_token, + nothing_outside_the_grant_reaches_the_peer, +); + +#[tokio::test] +async fn lite_05_has_no_grant() { + older_versions_have_no_grant("moq-lite-05").await +} + +#[tokio::test] +async fn moqt_16_has_no_grant() { + older_versions_have_no_grant("moq-transport-16").await +} /// Build an origin producer, spawning its driver on the ambient runtime. fn produce_origin(hop: u64) -> origin::Producer { @@ -168,11 +217,11 @@ fn within(f: F) -> tokio::time::Timeout { /// Each side's default grant is what the other side's origin handles allow: /// its subscribe half bounds what we may publish, its publish half what we may /// subscribe to. -#[tokio::test] -async fn both_sides_learn_their_grant_from_scoped_origins() { +async fn both_sides_learn_their_grant_from_scoped_origins(version: &'static str) { within(async { let relay = produce_origin(1); let pair = connect(Options { + version: Some(version), client_publish: Some(produce_origin(2)), client_subscribe: Some(produce_origin(3)), server_publish: Some(relay.scope("", &patterns(&["room"])).unwrap()), @@ -190,10 +239,10 @@ async fn both_sides_learn_their_grant_from_scoped_origins() { } /// A missing half grants nothing: the empty list, distinct from the empty prefix. -#[tokio::test] -async fn a_publish_only_session_grants_no_subscribe() { +async fn a_publish_only_session_grants_no_subscribe(version: &'static str) { within(async { let pair = connect(Options { + version: Some(version), client_publish: Some(produce_origin(2)), server_subscribe: Some(produce_origin(1)), ..Default::default() @@ -211,12 +260,12 @@ async fn a_publish_only_session_grants_no_subscribe() { /// A broadcast outside the grant aborts the session and names the path, where it /// used to wait forever for a solicitation that never comes. -#[tokio::test] -async fn an_out_of_scope_announce_aborts_with_the_path() { +async fn an_out_of_scope_announce_aborts_with_the_path(version: &'static str) { within(async { let publisher = produce_origin(2); let relay = produce_origin(1); let pair = connect(Options { + version: Some(version), client_publish: Some(publisher.clone()), server_subscribe: Some(relay.scope("", &patterns(&["baz"])).unwrap()), ..Default::default() @@ -245,14 +294,14 @@ async fn an_out_of_scope_announce_aborts_with_the_path() { } /// A broadcast published before the grant arrives is checked at admission too. -#[tokio::test] -async fn a_broadcast_published_before_the_grant_is_checked() { +async fn a_broadcast_published_before_the_grant_is_checked(version: &'static str) { within(async { let publisher = produce_origin(2); let early = publisher.create_broadcast("foo/bar").unwrap(); early.announce(Default::default()).unwrap(); let pair = connect(Options { + version: Some(version), client_publish: Some(publisher.clone()), server_subscribe: Some(produce_origin(1).scope("", &patterns(&["baz"])).unwrap()), ..Default::default() @@ -274,11 +323,11 @@ async fn a_broadcast_published_before_the_grant_is_checked() { /// Enforcement waits only for the tokens the session presented itself: a peer that /// never answers an app-added token cannot suspend it. -#[tokio::test] -async fn an_unanswered_token_does_not_suspend_the_check() { +async fn an_unanswered_token_does_not_suspend_the_check(version: &'static str) { within(async { let publisher = produce_origin(2); let mut pair = connect(Options { + version: Some(version), client_publish: Some(publisher.clone()), server_subscribe: Some(produce_origin(1)), server_requests: true, @@ -308,12 +357,12 @@ async fn an_unanswered_token_does_not_suspend_the_check() { /// Two tokens union; closing one shrinks the union and withdraws only what it alone /// covered, without disconnecting. -#[tokio::test] -async fn tokens_union_and_withdrawing_one_shrinks_it() { +async fn tokens_union_and_withdrawing_one_shrinks_it(version: &'static str) { within(async { let publisher = produce_origin(2); let relay = produce_origin(1); let mut pair = connect(Options { + version: Some(version), client_publish: Some(publisher.clone()), server_subscribe: Some(relay.clone()), server_requests: true, @@ -353,10 +402,10 @@ async fn tokens_union_and_withdrawing_one_shrinks_it() { } /// An update replaces one token's grant and leaves the other alone. -#[tokio::test] -async fn an_update_replaces_one_tokens_grant() { +async fn an_update_replaces_one_tokens_grant(version: &'static str) { within(async { let mut pair = connect(Options { + version: Some(version), client_publish: Some(produce_origin(2)), server_subscribe: Some(produce_origin(1)), server_requests: true, @@ -382,12 +431,12 @@ async fn an_update_replaces_one_tokens_grant() { /// A revocation withdraws what the grant covered, even though the broadcast stays in /// the shared origin, and an empty union can be authorized again. -#[tokio::test] -async fn a_revoked_grant_withdraws_and_can_be_restored() { +async fn a_revoked_grant_withdraws_and_can_be_restored(version: &'static str) { within(async { let publisher = produce_origin(2); let relay = produce_origin(1); let mut pair = connect(Options { + version: Some(version), client_publish: Some(publisher.clone()), server_subscribe: Some(relay.clone()), server_requests: true, @@ -420,10 +469,10 @@ async fn a_revoked_grant_withdraws_and_can_be_restored() { } /// A refused token surfaces the acceptor's code. -#[tokio::test] -async fn a_refused_token_reports_the_code() { +async fn a_refused_token_reports_the_code(version: &'static str) { within(async { let mut pair = connect(Options { + version: Some(version), client_publish: Some(produce_origin(2)), server_requests: true, ..Default::default() @@ -450,10 +499,10 @@ async fn a_refused_token_reports_the_code() { /// Refusing the setup token grants nothing: the union becomes empty rather than /// staying unknown, which the gates would read as unrestricted. -#[tokio::test] -async fn a_refused_setup_token_grants_nothing() { +async fn a_refused_setup_token_grants_nothing(version: &'static str) { within(async { let mut pair = connect(Options { + version: Some(version), client_publish: Some(produce_origin(2)), server_requests: true, ..Default::default() @@ -473,10 +522,10 @@ async fn a_refused_setup_token_grants_nothing() { } /// Dropping the requests refuses tokens already queued, not only later ones. -#[tokio::test] -async fn dropping_the_requests_refuses_queued_tokens() { +async fn dropping_the_requests_refuses_queued_tokens(version: &'static str) { within(async { let mut pair = connect(Options { + version: Some(version), client_publish: Some(produce_origin(2)), server_requests: true, ..Default::default() @@ -500,10 +549,10 @@ async fn dropping_the_requests_refuses_queued_tokens() { } /// A closed session holds no grant: every token ended with it. -#[tokio::test] -async fn a_closed_session_holds_no_grant() { +async fn a_closed_session_holds_no_grant(version: &'static str) { within(async { let pair = connect(Options { + version: Some(version), client_publish: Some(produce_origin(2)), server_subscribe: Some(produce_origin(1)), ..Default::default() @@ -519,12 +568,13 @@ async fn a_closed_session_holds_no_grant() { .expect("timed out"); } -/// A peer that takes no tokens in band resets the stream, which reads as -/// unsupported rather than a refusal: the same as a peer that predates AUTH. -#[tokio::test] -async fn a_reset_auth_stream_reports_unsupported() { +/// A peer that takes no tokens in band says so (lite resets the stream, moq-transport +/// answers NOT_SUPPORTED), which reads as unsupported rather than a refusal: the same as +/// a peer that predates AUTH. +async fn a_reset_auth_stream_reports_unsupported(version: &'static str) { within(async { let pair = connect(Options { + version: Some(version), client_publish: Some(produce_origin(2)), server_subscribe: Some(produce_origin(1)), ..Default::default() @@ -540,14 +590,13 @@ async fn a_reset_auth_stream_reports_unsupported() { .expect("timed out"); } -/// Older versions never open the stream: no grant, and no way to add a token. -#[tokio::test] -async fn older_versions_have_no_grant() { +/// Versions without AUTH never open the stream: no grant, and no way to add a token. +async fn older_versions_have_no_grant(version: &'static str) { within(async { let pair = connect(Options { + version: Some(version), client_publish: Some(produce_origin(2)), server_subscribe: Some(produce_origin(1)), - version: Some("moq-lite-05"), ..Default::default() }) .await; @@ -560,8 +609,7 @@ async fn older_versions_have_no_grant() { /// Losing a grant cancels the subscriptions it covered, in both directions, and /// leaves the session up. -#[tokio::test] -async fn a_revoked_grant_cancels_its_subscriptions() { +async fn a_revoked_grant_cancels_its_subscriptions(version: &'static str) { within(async { let ts = |ms| moq_net::Timestamp::from_millis(ms).unwrap(); let prefs = || moq_net::track::Subscription::default().with_max_age(Duration::from_secs(10)); @@ -579,12 +627,12 @@ async fn a_revoked_grant_cancels_its_subscriptions() { let received = produce_origin(3); let mut pair = connect(Options { + version: Some(version), client_publish: Some(client_origin.clone()), client_subscribe: Some(received.clone()), server_publish: Some(server_origin.clone()), server_subscribe: Some(server_origin.clone()), server_requests: true, - ..Default::default() }) .await; let mut issued = serve(pair.requests.take().unwrap(), |token| { @@ -620,3 +668,129 @@ async fn a_revoked_grant_cancels_its_subscriptions() { .await .expect("timed out"); } + +/// A grant the wire cannot carry as prefixes is never widened: the token is refused as +/// unsupported, promptly, and the union stays unknown rather than empty. +async fn an_unrepresentable_grant_is_unsupported(version: &'static str) { + within(async { + let mut pair = connect(Options { + version: Some(version), + client_publish: Some(produce_origin(2)), + server_subscribe: Some(produce_origin(1)), + server_requests: true, + ..Default::default() + }) + .await; + let mut issued = serve(pair.requests.take().unwrap(), |token| match token { + b"" => Some(grant(&["a"], &[])), + b"exact" => Some(Grant { + publish: Patterns::from(Pattern::try_from("room/alice").unwrap()), + subscribe: Patterns::new(), + expires: None, + }), + b"mixed" => Some(Grant { + publish: ["room/**", "lobby"] + .into_iter() + .map(|p| Pattern::try_from(p).unwrap()) + .collect(), + subscribe: Patterns::new(), + expires: None, + }), + b"wildcard" => Some(Grant { + publish: Patterns::from(Pattern::try_from("room/*/cam").unwrap()), + subscribe: Patterns::new(), + expires: None, + }), + _ => None, + }); + let (_, _setup) = issued.recv().await.unwrap(); + assert_eq!(granted(&pair.client).await, grant(&["a"], &[])); + + for token in ["exact", "mixed", "wildcard"] { + let err = pair.client.auth().add(token).await.err().expect("not representable"); + assert!(matches!(err, Error::Unsupported), "{token}: {err:?}"); + } + // The other token is untouched, and so is the session. + assert_eq!(pair.client.auth().grant().peek(), Some(grant(&["a"], &[]))); + assert_eq!(pair.client_transport.close_reason(), None); + }) + .await + .expect("timed out"); +} + +/// An update the wire cannot carry revokes that token's earlier grant, and only that +/// token's: the rest of the union and the session stay. +async fn an_unrepresentable_update_revokes_only_its_token(version: &'static str) { + within(async { + let mut pair = connect(Options { + version: Some(version), + client_publish: Some(produce_origin(2)), + server_subscribe: Some(produce_origin(1)), + server_requests: true, + ..Default::default() + }) + .await; + let mut issued = serve(pair.requests.take().unwrap(), |token| match token { + b"" => Some(grant(&["a"], &[])), + b"t1" => Some(grant(&["b"], &[])), + _ => None, + }); + let (_, _setup) = issued.recv().await.unwrap(); + let t1 = pair.client.auth().add("t1").await.unwrap(); + let (_, t1_issued) = issued.recv().await.unwrap(); + assert_eq!(granted(&pair.client).await, grant(&["a", "b"], &[])); + + t1_issued.update(Grant { + publish: Patterns::from(Pattern::try_from("b/exact").unwrap()), + subscribe: Patterns::new(), + expires: None, + }); + // Lite resets the stream and moq-transport answers NOT_SUPPORTED; either ends it. + t1.closed().await; + assert_eq!(t1.grant().peek(), None); + wait_for(pair.client.auth().grant(), |g| g == &Some(grant(&["a"], &[]))).await; + assert_eq!(pair.client_transport.close_reason(), None); + }) + .await + .expect("timed out"); +} + +/// The session closes before the peer ever hears of a broadcast outside the grant, +/// even one published before the grant arrived: nothing is advertised until the +/// setup token is answered. +async fn nothing_outside_the_grant_reaches_the_peer(version: &'static str) { + within(async { + let publisher = produce_origin(2); + let early = publisher.create_broadcast("foo/bar").unwrap(); + early.announce(Default::default()).unwrap(); + + // The relay accepts anything; only the grant it tells the client is narrow. + let relay = produce_origin(1); + let mut pair = connect(Options { + version: Some(version), + client_publish: Some(publisher.clone()), + server_subscribe: Some(relay.clone()), + server_requests: true, + ..Default::default() + }) + .await; + + // Hold the answer, so the peer's discovery request is in long before the grant. + let mut requests = pair.requests.take().unwrap(); + let setup = requests.next().await.expect("setup token"); + let leaked = tokio::time::timeout( + Duration::from_millis(100), + wait_announced(&relay.consume(), "foo/bar", true), + ) + .await; + assert!(leaked.is_err(), "advertised before the grant was known"); + + let _issued = setup.accept(grant(&["baz"], &[])); + assert!(matches!( + pair.server.closed().await, + Error::Session(SessionError::Unauthorized) + )); + }) + .await + .expect("timed out"); +} diff --git a/rs/moq-tokio/src/client.rs b/rs/moq-tokio/src/client.rs index 04358722bb..e9133a8e89 100644 --- a/rs/moq-tokio/src/client.rs +++ b/rs/moq-tokio/src/client.rs @@ -1158,6 +1158,79 @@ mod tests { assert_eq!(value, super::TransportRace::Quic(3)); } + /// A WebTransport-only endpoint answers the WebSocket fallback with 403 while the + /// QUIC dial is still in flight. One transport being refused is not the connect's + /// verdict: QUIC finishes the race and the session comes up. + /// + /// Inline rather than in `tests/` so each arm dials its own ephemeral port: the + /// public connect sends the fallback to the QUIC port, which nothing reserves over + /// TCP as well. + #[cfg(all(feature = "websocket", feature = "noq"))] + #[tokio::test] + async fn websocket_forbidden_does_not_end_a_quic_connect() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = tokio::net::TcpListener::bind("[::]:0").await.unwrap(); + let ws_port = listener.local_addr().unwrap().port(); + let mut forbid = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await?; + let mut buf = [0; 1024]; + let _ = stream.read(&mut buf).await?; + stream + .write_all(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .await?; + Ok::<_, std::io::Error>(()) + }); + + let mut listen = crate::listen::Config { + bind: Some("[::]:0".parse().unwrap()), + ..Default::default() + }; + listen.tls.generate = vec!["localhost".into()]; + let mut server = listen.init(Default::default()).unwrap().listen().await.unwrap(); + let quic_port = server.local_addr().unwrap().port(); + let origin = crate::origin::spawn(); + let accepted = tokio::spawn(async move { + let request = server.accept().await.expect("no incoming connection"); + request + .with_publisher(&origin) + .ok() + .await + .map(|session| (server, session)) + }); + + let mut config = crate::connect::Config::default(); + config.tls.insecure = Some(true); + // No head start, so the fallback dials while QUIC waits on the 403. + config.websocket.delay = std::time::Duration::ZERO; + let client = config.init(Default::default()).unwrap(); + + // The same race `connect_inner` runs, except the fallback dials its own port, + // as plain ws:// so the listener above can answer without TLS. + let noq = client.noq.as_ref().unwrap(); + let quic_addr: crate::connect::Addr = Url::parse(&format!("https://localhost:{quic_port}")).unwrap().into(); + let ws_addr: crate::connect::Addr = Url::parse(&format!("http://localhost:{ws_port}")).unwrap().into(); + // Hold QUIC until the fallback has been refused, so the 403 is always exercised. + let quic = async { + (&mut forbid).await.unwrap().expect("fallback listener failed"); + noq.connect(&client.tls, quic_addr, &client.versions) + .await + .map(crate::transport::Session::new) + .map_err(Error::from) + }; + + let session = tokio::time::timeout( + std::time::Duration::from_secs(10), + client.race_moq_connect(&client.moq, ws_addr, quic), + ) + .await + .expect("client connect timed out") + .expect("a fallback refused on auth must not end a connect whose QUIC arm succeeds"); + + drop(session); + accepted.await.unwrap().expect("server handshake failed"); + } + #[cfg(all(feature = "websocket", feature = "noq"))] #[tokio::test] async fn race_transport_connect_reports_auth_when_both_refuse() { diff --git a/rs/moq-tokio/src/server.rs b/rs/moq-tokio/src/server.rs index 3eff0a194f..d71a05f658 100644 --- a/rs/moq-tokio/src/server.rs +++ b/rs/moq-tokio/src/server.rs @@ -737,6 +737,12 @@ impl Listener { self.server.websocket_local_addr() } + /// The address the plain TCP (qmux) listener bound to, if one was configured. + #[cfg(feature = "tcp")] + pub fn tcp_local_addr(&self) -> Option { + self.server.streams.tcp_local_addr + } + /// A live handle to the certificates this server is serving. /// /// See [`Server::certificates`], which is also readable before listening. @@ -833,6 +839,9 @@ struct StreamListeners { versions: moq_net::Versions, #[cfg(all(feature = "uds", unix))] unix_allow: Option, + /// The address the TCP listener bound, once [`Self::start`] has run. + #[cfg(feature = "tcp")] + tcp_local_addr: Option, rx: Option>, tasks: Vec>, } @@ -854,6 +863,8 @@ impl StreamListeners { versions, #[cfg(all(feature = "uds", unix))] unix_allow, + #[cfg(feature = "tcp")] + tcp_local_addr: None, rx: None, tasks: Vec::new(), } @@ -886,7 +897,9 @@ impl StreamListeners { .await? .with_protocols(alpns) .with_accept_health(health); - tracing::info!(%addr, "listening (tcp)"); + let local = listener.local_addr()?; + tracing::info!(addr = %local, "listening (tcp)"); + self.tcp_local_addr = Some(local); bound.push(BoundListener::Tcp(listener)); } #[cfg(all(feature = "uds", unix))] diff --git a/rs/moq-tokio/tests/broadcast.rs b/rs/moq-tokio/tests/broadcast.rs index 3ae72b0740..5b7f244665 100644 --- a/rs/moq-tokio/tests/broadcast.rs +++ b/rs/moq-tokio/tests/broadcast.rs @@ -2480,69 +2480,6 @@ async fn reconnect_stops_on_websocket_unauthorized() { .expect("server task failed"); } -/// A WebTransport-only endpoint answers the WebSocket fallback with 403 while the -/// QUIC dial is still in flight. One transport being refused is not the connect's -/// verdict: QUIC finishes the race and the session comes up. -#[tracing_test::traced_test] -#[tokio::test] -async fn websocket_forbidden_does_not_end_a_quic_connect() { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - - // The fallback dials the same port over TCP. Nothing reserves a port for both - // UDP and TCP at once, and the ephemeral UDP port may already be taken over TCP, - // so pick again until both bind. - let (mut server, addr, listener) = 'bind: { - for _ in 0..20 { - let (server, addr) = test_server().await; - match tokio::net::TcpListener::bind(("::", addr.port())).await { - Ok(listener) => break 'bind (server, addr, listener), - Err(err) if err.kind() == std::io::ErrorKind::AddrInUse => continue, - Err(err) => panic!("failed to bind TCP listener: {err}"), - } - } - panic!("no port was free over both UDP and TCP"); - }; - let forbid = tokio::spawn(async move { - let (mut stream, _) = listener.accept().await?; - let mut buf = [0; 1024]; - let _ = stream.read(&mut buf).await?; - stream - .write_all(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") - .await?; - Ok::<_, anyhow::Error>(()) - }); - - let pub_origin = moq_tokio::origin::spawn(); - let server_handle = tokio::spawn(async move { - let request = server.accept().await.expect("no incoming connection"); - let session = request.with_publisher(&pub_origin).ok().await?; - session.closed().await; - Ok::<_, anyhow::Error>(()) - }); - - let mut client_config = moq_tokio::connect::Config::default(); - client_config.tls.insecure = Some(true); - // No head start, so the 403 lands before the QUIC handshake completes. - client_config.websocket.delay = Duration::ZERO; - let client = client_config.init(Default::default()).expect("failed to init client"); - // http:// dials QUIC as https:// and the fallback as plain ws://, which the listener - // above can answer without TLS. - let url: url::Url = format!("http://localhost:{}", addr.port()).parse().unwrap(); - - let (_client, connection) = tokio::time::timeout(TIMEOUT, connect_once(client, url)) - .await - .expect("client connect timed out") - .expect("a fallback refused on auth must not end a connect whose QUIC arm succeeds"); - - drop(connection); - server_handle - .await - .expect("server task panicked") - .expect("server task failed"); - // QUIC may win before the fallback ever dials, leaving the listener waiting. - forbid.abort(); -} - /// A GOAWAY ends a one-shot connection instead of being ignored. /// /// The peer here sends a GOAWAY naming no deadline and then waits, which is the diff --git a/rs/moq-tokio/tests/reconnect.rs b/rs/moq-tokio/tests/reconnect.rs index 4eef51b3eb..a3532e354c 100644 --- a/rs/moq-tokio/tests/reconnect.rs +++ b/rs/moq-tokio/tests/reconnect.rs @@ -6,7 +6,6 @@ #![cfg(feature = "tcp")] -use std::net::TcpListener; use std::time::Duration; use moq_tokio::moq_net; @@ -103,45 +102,35 @@ async fn monitor_is_cloneable_without_keeping_the_connection_alive() { assert!(monitor.snapshot().is_none()); } -/// A stream-only moq server on a free loopback TCP port. +/// A stream-only moq server on an ephemeral loopback TCP port. /// /// Returns the port, a receiver yielding every accepted session (so a test can -/// drain one), and the listener task. The free-port probe can lose a race with -/// another test between the probe closing and the real bind, so retry rather -/// than panicking in `listen`. +/// drain one), and the listener task. async fn spawn_server() -> ( u16, tokio::sync::mpsc::UnboundedReceiver, tokio::task::JoinHandle<()>, ) { - for _ in 0..20 { - let probe = TcpListener::bind("127.0.0.1:0").expect("bind probe"); - let port = probe.local_addr().expect("local addr").port(); - drop(probe); - - let mut config = moq_tokio::listen::Config::default(); - config.tcp.bind = Some(format!("127.0.0.1:{port}").parse().expect("parse addr")); - let server = config.init(Default::default()).expect("init server"); - let Ok(mut server) = server.listen().await else { - continue; - }; - - let (accepted, sessions) = tokio::sync::mpsc::unbounded_channel(); - let handle = tokio::spawn(async move { - while let Some(request) = server.accept().await { - let origin = moq_tokio::origin::spawn(); - match request.with_publisher(&origin).ok().await { - Ok(session) => { - let _ = accepted.send(session); - } - Err(err) => tracing::warn!(%err, "accept failed"), + let mut config = moq_tokio::listen::Config::default(); + config.tcp.bind = Some("127.0.0.1:0".parse().expect("parse addr")); + let server = config.init(Default::default()).expect("init server"); + let mut server = server.listen().await.expect("bind tcp listener"); + let port = server.tcp_local_addr().expect("tcp listener bound").port(); + + let (accepted, sessions) = tokio::sync::mpsc::unbounded_channel(); + let handle = tokio::spawn(async move { + while let Some(request) = server.accept().await { + let origin = moq_tokio::origin::spawn(); + match request.with_publisher(&origin).ok().await { + Ok(session) => { + let _ = accepted.send(session); } + Err(err) => tracing::warn!(%err, "accept failed"), } - }); + } + }); - return (port, sessions, handle); - } - panic!("could not bind a free TCP port after 20 attempts"); + (port, sessions, handle) } /// A client that redials fast, so a refused redirect lands back on the original diff --git a/rs/moq-tokio/tests/worker.rs b/rs/moq-tokio/tests/worker.rs index f35ce6641d..70b727d005 100644 --- a/rs/moq-tokio/tests/worker.rs +++ b/rs/moq-tokio/tests/worker.rs @@ -5,7 +5,7 @@ //! the group. #![cfg(all(target_os = "linux", feature = "noq"))] -use std::net::{SocketAddr, UdpSocket}; +use std::net::UdpSocket; use moq_tokio::worker::{self, Workers}; @@ -13,8 +13,8 @@ const WORKERS: u16 = 4; /// A UDP port nothing is bound to. /// -/// Named rather than ephemeral because these tests rebind the port, or probe it -/// while the group holds it, which needs a port known before the group starts. +/// Only for the port-lock tests: an ephemeral group takes no lock, so the first +/// group has to name its port. Everything else binds `:0` and reads it back. fn free_udp_port() -> u16 { let probe = UdpSocket::bind("127.0.0.1:0").expect("bind probe"); let port = probe.local_addr().expect("local addr").port(); @@ -70,10 +70,9 @@ async fn dropping_the_workers_releases_the_port() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let port = free_udp_port(); - let workers = - bind_workers(listen_config(&cert, &key, port), Default::default(), config(WORKERS)).expect("bind workers"); + bind_workers(listen_config(&cert, &key, 0), Default::default(), config(WORKERS)).expect("bind workers"); + let addr = workers.local_addr(); assert_eq!(workers.len(), usize::from(WORKERS)); // Serving first is the case that used to strand the threads. The accept @@ -89,7 +88,6 @@ async fn dropping_the_workers_releases_the_port() { // A plain bind refuses a port any socket still holds, reuseport or not, so this // succeeds only if every worker's socket is really gone. - let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); moq_tokio::bind::udp(moq_tokio::bind::Udp::new(addr)).expect("workers left the port bound"); } @@ -226,13 +224,11 @@ async fn dropping_unserved_workers_releases_the_port() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let port = free_udp_port(); - let workers = - bind_workers(listen_config(&cert, &key, port), Default::default(), config(WORKERS)).expect("bind workers"); + bind_workers(listen_config(&cert, &key, 0), Default::default(), config(WORKERS)).expect("bind workers"); + let addr = workers.local_addr(); drop(workers); - let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); moq_tokio::bind::udp(moq_tokio::bind::Udp::new(addr)).expect("workers left the port bound"); } @@ -397,7 +393,7 @@ async fn generated_certificates_are_refused() { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let mut listen = moq_tokio::listen::Config::default(); - listen.bind = Some(format!("127.0.0.1:{}", free_udp_port()).parse().unwrap()); + listen.bind = Some("127.0.0.1:0".parse().unwrap()); listen.tls.generate = vec!["localhost".to_string()]; let err = @@ -446,9 +442,8 @@ async fn dropping_a_server_keeps_its_socket() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let port = free_udp_port(); - - let workers = bind_workers(listen_config(&cert, &key, port), Default::default(), config(2)).expect("bind workers"); + let workers = bind_workers(listen_config(&cert, &key, 0), Default::default(), config(2)).expect("bind workers"); + let port = workers.local_addr().port(); let mut group = workers.split(); let sockets = udp_sockets_on(port); assert!(sockets >= 2, "every member holds at least one socket"); @@ -489,9 +484,8 @@ async fn completing_a_member_stops_its_siblings() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let port = free_udp_port(); - - let workers = bind_workers(listen_config(&cert, &key, port), Default::default(), config(2)).expect("bind workers"); + let workers = bind_workers(listen_config(&cert, &key, 0), Default::default(), config(2)).expect("bind workers"); + let addr = workers.local_addr(); let mut group = workers.split(); let mut members = group.members(); assert_eq!(members.len(), 2); @@ -529,7 +523,6 @@ async fn completing_a_member_stops_its_siblings() { .expect("a stopped sibling must not hang"); group.shutdown().await; - let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); moq_tokio::bind::udp(moq_tokio::bind::Udp::new(addr)).expect("the group must release its port"); } @@ -540,9 +533,8 @@ async fn cancelling_a_member_stops_its_siblings() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let port = free_udp_port(); - - let workers = bind_workers(listen_config(&cert, &key, port), Default::default(), config(2)).expect("bind workers"); + let workers = bind_workers(listen_config(&cert, &key, 0), Default::default(), config(2)).expect("bind workers"); + let addr = workers.local_addr(); let mut group = workers.split(); let mut members = group.members(); @@ -574,7 +566,6 @@ async fn cancelling_a_member_stops_its_siblings() { .expect("a cancelled sibling must not hang"); group.shutdown().await; - let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); moq_tokio::bind::udp(moq_tokio::bind::Udp::new(addr)).expect("the group must release its port"); } @@ -586,9 +577,8 @@ async fn a_panicking_member_stops_its_siblings() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let port = free_udp_port(); - - let workers = bind_workers(listen_config(&cert, &key, port), Default::default(), config(2)).expect("bind workers"); + let workers = bind_workers(listen_config(&cert, &key, 0), Default::default(), config(2)).expect("bind workers"); + let addr = workers.local_addr(); let mut group = workers.split(); let mut members = group.members(); @@ -620,7 +610,6 @@ async fn a_panicking_member_stops_its_siblings() { .expect("a panicking sibling must not hang"); group.shutdown().await; - let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); moq_tokio::bind::udp(moq_tokio::bind::Udp::new(addr)).expect("the group must release its port"); } @@ -631,9 +620,8 @@ async fn shutdown_with_work_in_flight_joins() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let port = free_udp_port(); - - let workers = bind_workers(listen_config(&cert, &key, port), Default::default(), config(2)).expect("bind workers"); + let workers = bind_workers(listen_config(&cert, &key, 0), Default::default(), config(2)).expect("bind workers"); + let addr = workers.local_addr(); let mut group = workers.split(); let mut tasks = Vec::new(); for member in group.members() { @@ -650,7 +638,6 @@ async fn shutdown_with_work_in_flight_joins() { .expect("shutdown must stop every member"); } - let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); moq_tokio::bind::udp(moq_tokio::bind::Udp::new(addr)).expect("shutdown must release the port"); } @@ -662,9 +649,8 @@ async fn dropping_the_group_with_work_in_flight_stops() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let port = free_udp_port(); - - let workers = bind_workers(listen_config(&cert, &key, port), Default::default(), config(2)).expect("bind workers"); + let workers = bind_workers(listen_config(&cert, &key, 0), Default::default(), config(2)).expect("bind workers"); + let addr = workers.local_addr(); let mut group = workers.split(); let mut tasks = Vec::new(); { @@ -684,6 +670,5 @@ async fn dropping_the_group_with_work_in_flight_stops() { .expect("dropping the group must stop every member"); } - let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); moq_tokio::bind::udp(moq_tokio::bind::Udp::new(addr)).expect("dropping the group must release the port"); } diff --git a/swift/Sources/Moq/Broadcast.swift b/swift/Sources/Moq/Broadcast.swift index 7fa4873d25..cf52ddba1b 100644 --- a/swift/Sources/Moq/Broadcast.swift +++ b/swift/Sources/Moq/Broadcast.swift @@ -172,25 +172,33 @@ public final class BroadcastProducer: Sendable { /// are interpreted (e.g. `"opus"`, `"avc3"`). `video` seeds catalog fields /// that the stream cannot reveal before its first keyframe. /// Publish one audio codec as a new track. `initData` is required: audio resolves its whole - /// rendition from those bytes. + /// rendition from those bytes. `track` names the track; otherwise a unique name is derived from + /// the format. public func publishAudio( format: AudioFormat, initData: Data, - label: String? = nil + label: String? = nil, + track: String? = nil ) throws -> MediaProducer { - MediaProducer(try ffi.publishAudio(init: MoqAudioInit(format: format, data: initData, label: label))) + MediaProducer( + try ffi.publishAudio(init: MoqAudioInit(format: format, data: initData, label: label, track: track)) + ) } /// Publish one video codec as a new track. `initData` may be empty for a format that resolves - /// in band; `hint` seeds catalog fields the stream can't reveal. + /// in band; `hint` seeds catalog fields the stream can't reveal. `track` names the track; + /// otherwise a unique name is derived from the format. public func publishVideo( format: VideoFormat, initData: Data = Data(), label: String? = nil, - hint: VideoHint? = nil + hint: VideoHint? = nil, + track: String? = nil ) throws -> MediaProducer { MediaProducer( - try ffi.publishVideo(init: MoqVideoInit(format: format, data: initData, label: label, hint: hint)) + try ffi.publishVideo( + init: MoqVideoInit(format: format, data: initData, label: label, hint: hint, track: track) + ) ) } @@ -240,10 +248,13 @@ public final class BroadcastProducer: Sendable { public func publishVideoStream( format: VideoFormat, label: String? = nil, - hint: VideoHint? = nil + hint: VideoHint? = nil, + track: String? = nil ) throws -> MediaStreamProducer { MediaStreamProducer( - try ffi.publishVideoStream(init: MoqVideoInit(format: format, data: Data(), label: label, hint: hint)) + try ffi.publishVideoStream( + init: MoqVideoInit(format: format, data: Data(), label: label, hint: hint, track: track) + ) ) } From 6f51a4a283a613da24d1761dd4c58ade5c7ac7c1 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 17:13:48 -0700 Subject: [PATCH 04/53] quest(auth): path patterns join the line so AUTH ships with pattern grants (#4200) Co-authored-by: Claude Opus 5.5 --- quest/m1/README.md | 1 - quest/m1/auth/README.md | 14 ++++++++------ quest/m1/{path-patterns.md => auth/patterns.md} | 15 ++++++++++----- quest/m1/auth/relay-refresh.md | 2 +- quest/m1/bench-coverage.md | 2 +- quest/m1/epoch.md | 2 +- quest/m1/wildcard/README.md | 8 ++++---- 7 files changed, 25 insertions(+), 19 deletions(-) rename quest/m1/{path-patterns.md => auth/patterns.md} (85%) diff --git a/quest/m1/README.md b/quest/m1/README.md index 93cb6d3458..1ea9de74ad 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -46,7 +46,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Archive](/quest/m1/archive/README.md) - record selected tracks to any object_store and replay them over FETCH or derived HLS, on the catalog and store the release ships - [Wildcard](/quest/m1/wildcard/README.md) - a relay resolves subscriptions against advertised prefixes, a service claims the prefix it could serve and refuses the rest instead of enumerating broadcasts, and the browser player treats a covering claim as availability - [Tooling](/quest/m1/tooling/README.md) - justfiles become a one-line menu over `sh/`, one impact map scopes CI, and every workflow step runs a recipe -- [Path patterns](/quest/m1/path-patterns.md) - one matcher for every predicate over broadcast paths: tokens, origins, interest - [In-band auth](/quest/m1/auth/README.md) - a session tells its peer what it may publish and subscribe to, unions tokens presented in band, and fails loud on an out-of-scope publish - [Decoded frame ownership](/quest/m1/decoded-frames.md) - retain moq-video Frames across bindings, with native views or CPU conversion as needed - [C++ through moq-ffi](/quest/m1/cpp/README.md) - generated C++ over moq-ffi with futures and expected-style errors, shipped as a tarball, vcpkg, and Conan, and adopted by the OBS plugin diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index f2e080d67e..1330f9854d 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -45,11 +45,12 @@ Decisions settled while planning, recorded so review does not relitigate them: - **A public grant contains publish patterns, subscribe patterns, and an expiry**, in the presenter's own root; the presenter never sees the relay-side root, and every token in a union shares the connection's root. Unscoped - permission is `**`; an empty union grants nothing. Legacy AUTH wire codecs - explicitly convert representable prefix unions, where `[""]` means all, - and refuse patterns they cannot represent. [Pattern interest](/quest/m1/path-patterns.md) - upgrades AUTH and ANNOUNCE_REQUEST wire fields together without changing - the public pattern-valued grant type. + permission is `**`; an empty union grants nothing. [Path patterns](/quest/m1/auth/patterns.md) + ships with AUTH, so AUTH_OK carries those patterns, wildcards and literals + alike, from the first release. There is no prefix-only AUTH_OK and no + covering-prefix workaround. Announce stays a prefix: ANNOUNCE_REQUEST and + SUBSCRIBE_NAMESPACE do not gain patterns in that change. The public grant + type stays pattern-valued. - **Fail loud by aborting the session.** A publisher whose origin announces a broadcast outside the union aborts the session with `Unauthorized`, naming the path. The check runs against the grants in hand once the tokens the @@ -93,6 +94,8 @@ existing lite-06 ALPN. each AUTH cell's grant and that a publish outside it fails loud - [Unauthorized reset](/quest/m1/auth/unauthorized.md) - a subscription that loses access resets with a dedicated UNAUTHORIZED stream code +- [Path patterns](/quest/m1/auth/patterns.md) - one matcher for every path + predicate, and AUTH_OK carries pattern grants from AUTH's first release - [Relay tokens](/quest/m1/auth/relay-refresh.md) - the relay verifies tokens sent in band, unions their grants, and cancels only work that loses access - [Bindings](/quest/m1/auth/bindings.md) - grants and tokens reach every @@ -107,7 +110,6 @@ existing lite-06 ALPN. - [Origin narrowing](/quest/m1/origin-narrowing.md) - resizes a live session when the union shrinks, for revalidation and token expiry alike -- [Pattern interest](/quest/m1/path-patterns.md) - moves AUTH's legacy wire prefixes to patterns along with ANNOUNCE_REQUEST - [Expiring media grants](/quest/m1/processor/grant-lease.md) - a worker's lease renewal is a new in-band token - [P2P](/quest/m1/p2p/README.md) - the first consumer of hop-bound peer grants diff --git a/quest/m1/path-patterns.md b/quest/m1/auth/patterns.md similarity index 85% rename from quest/m1/path-patterns.md rename to quest/m1/auth/patterns.md index 76180c91e5..31dfef10cd 100644 --- a/quest/m1/path-patterns.md +++ b/quest/m1/auth/patterns.md @@ -82,11 +82,16 @@ keeps patterns off the announce wire, so ANNOUNCE_REQUEST and SUBSCRIBE_NAMESPACE carry the prefix the caller asked for and a wildcard is an optional filter on the consume side. -Replace lite-06 AUTH grant prefixes with patterns in Rust and JavaScript in -the same change. Update the lite draft and version-gated fixtures together. -Authorize by exact containment in the subscriber's v1 grant. Older moq-lite -versions keep their existing prefix wire and behavior; a grant they cannot -represent is refused, not narrowed. Cluster peers adopt nothing as a side +AUTH has not shipped in a release yet: it lives on this line. Land patterns +here, before the line merges, so AUTH_OK carries pattern grants (wildcards and +literals alike) from its first release and never ships a prefix-only encoding. +Today's encoder refuses any grant that is not a subtree +(`rs/moq-net/src/lite/auth.rs`), so a literal grant such as `b1.hang` reaches +the client as no grant at all while the relay's origin enforces it correctly; +this quest removes that gap rather than widening the grant to a covering +prefix. Replace the AUTH grant prefixes with patterns in Rust and JavaScript in +the same change, and update the lite draft and fixtures together. Authorize by +exact containment in the subscriber's v1 grant. Cluster peers adopt nothing as a side effect of this wire work. Test Rust and JavaScript interop, leading wildcards, `**` zero-segment diff --git a/quest/m1/auth/relay-refresh.md b/quest/m1/auth/relay-refresh.md index c0607f0f01..8c319a7883 100644 --- a/quest/m1/auth/relay-refresh.md +++ b/quest/m1/auth/relay-refresh.md @@ -83,6 +83,6 @@ Additive. ## Required - [Origin narrowing](/quest/m1/origin-narrowing.md) - the live re-scope a shrinking token union needs, so no temporary close-on-shrink policy ships -- [Pattern interest](/quest/m1/path-patterns.md) - AUTH can represent the complete grants relay revalidation returns +- [Pattern interest](/quest/m1/auth/patterns.md) - AUTH can represent the complete grants relay revalidation returns - [Unauthorized reset](/quest/m1/auth/unauthorized.md) - the code this relay's revocations reset with diff --git a/quest/m1/bench-coverage.md b/quest/m1/bench-coverage.md index 05d5b3077a..9adb4c4a30 100644 --- a/quest/m1/bench-coverage.md +++ b/quest/m1/bench-coverage.md @@ -17,7 +17,7 @@ and update, `moq-auth` token verification, and `moq-pattern` path matching. size. The in-band token path gets its bench with [In-band token](/quest/m1/auth/token-in-band.md), not here. - `moq-pattern`: matching swept over pattern count and path depth. Coordinate - with [Path patterns](/quest/m1/path-patterns.md) so the matcher gets one + with [Path patterns](/quest/m1/auth/patterns.md) so the matcher gets one bench, not two. Anything that fans out gets a sweep over both axes. Name each target after diff --git a/quest/m1/epoch.md b/quest/m1/epoch.md index d829397624..4e78ce8492 100644 --- a/quest/m1/epoch.md +++ b/quest/m1/epoch.md @@ -21,7 +21,7 @@ line builds on, and it replaces the e2ee-local `moq_e2ee::Epoch`. like `@alice` is valid today and stays valid: strict parsing already keeps it from reading as an epoch. Rejecting it instead would break the path contract and land on `dev`. Check how the split interacts with - [path patterns](/quest/m1/path-patterns.md) and + [path patterns](/quest/m1/auth/patterns.md) and hidden broadcasts (a leading `.`, see `doc/concept/moq-lite.md`). - `moq-e2ee` uses the shared type. Update [draft-lcurley-moq-e2ee](/drafts/draft-lcurley-moq-e2ee.md) so the path is diff --git a/quest/m1/wildcard/README.md b/quest/m1/wildcard/README.md index e7be970d69..130cf72dd9 100644 --- a/quest/m1/wildcard/README.md +++ b/quest/m1/wildcard/README.md @@ -47,7 +47,7 @@ production cost: zero for a live publish, something large for a standby that would have to start working (a cold transcoder)" (`drafts/draft-lcurley-moq-lite.md`). `moq_auth::Claims.publish` and `origin::Producer` gain versioned patterns through -[Path patterns](/quest/m1/path-patterns.md), so advertisements reuse the +[Path patterns](/quest/m1/auth/patterns.md), so advertisements reuse the same exact containment check. `Cost { warm, cold }` (`rs/moq-net/src/model/origin.rs:426`) is the route cost since [#2925](https://github.com/moq-dev/moq/pull/2925). @@ -84,7 +84,7 @@ field. ### Decisions - **One prefix on the wire, one pattern in the token and the filter.** An - advertisement is a path prefix; the [path-patterns](/quest/m1/path-patterns.md) + advertisement is a path prefix; the [path-patterns](/quest/m1/auth/patterns.md) dialect is what tokens and the consume-side filter use, matched by the shared matcher, so nothing resembles a second grammar and nothing on the wire spells a wildcard. @@ -192,7 +192,7 @@ field. - **Patterns are independent of clustering.** The `moq-pattern` crate owns the matching semantics tokens and filters share, with no draft of its own; no announce message carries a pattern on either protocol (AUTH grants on - lite-06 do, per [Path patterns](/quest/m1/path-patterns.md)). moq-cluster adds hop + lite-06 do, per [Path patterns](/quest/m1/auth/patterns.md)). moq-cluster adds hop lists, costs, pool selection, and request resolution to prefix advertisements. @@ -266,7 +266,7 @@ than announce state. ## Related -- [path-patterns](/quest/m1/path-patterns.md) - owns the pattern dialect +- [path-patterns](/quest/m1/auth/patterns.md) - owns the pattern dialect and the shared matcher advertisements reuse - [archive](/quest/m1/archive/README.md) - an archive advertises the catch-all pattern, and its catalog names the generations a wildcard cannot From 29463bfe88665dff9eb6b4578ac93b4dc53c008a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 20:41:18 -0700 Subject: [PATCH 05/53] test(interop): assert each AUTH cell's grant and refuse a publish outside it (#4181) Co-authored-by: Claude Opus 5.5 Co-authored-by: Grok 4.7 --- quest/m1/auth/README.md | 2 - quest/m1/auth/bindings.md | 6 +- quest/m1/auth/interop.md | 21 -- rs/moq-ffi/src/binary.rs | 8 +- rs/moq-net/src/auth.rs | 8 + test/interop/README.md | 26 ++- test/interop/clients/js-native/subscribe.ts | 11 ++ test/interop/interop.sh | 200 ++++++++++++++++---- test/interop/interop.toml | 7 +- 9 files changed, 223 insertions(+), 66 deletions(-) delete mode 100644 quest/m1/auth/interop.md diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index 1330f9854d..d9aa7c2573 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -90,8 +90,6 @@ existing lite-06 ALPN. ## Quests -- [Interop grants](/quest/m1/auth/interop.md) - the interop matrix asserts - each AUTH cell's grant and that a publish outside it fails loud - [Unauthorized reset](/quest/m1/auth/unauthorized.md) - a subscription that loses access resets with a dedicated UNAUTHORIZED stream code - [Path patterns](/quest/m1/auth/patterns.md) - one matcher for every path diff --git a/quest/m1/auth/bindings.md b/quest/m1/auth/bindings.md index b4a1912420..d3aad8a50f 100644 --- a/quest/m1/auth/bindings.md +++ b/quest/m1/auth/bindings.md @@ -24,9 +24,9 @@ mid-stream can be handed a new one without the plugin reconnecting. async calls use, in `rs/libmoq/src/api.rs` and the session table; regenerate `moq.h`, and update `cpp/obs/src` only if the plugin surfaces a token field, otherwise leave it. -- Interop: the Python, Go, and C interop clients print their grant and join - the assertion [Interop grants](/quest/m1/auth/interop.md) adds for Rust - and JS. +- Interop: the Python, Go, and C interop clients print their grant as the + `auth granted publish=[...] subscribe=[...]` line and join `prints_grant` and + `enforces_grant` in `test/interop/interop.sh`, beside Rust and JS. - Wrappers: `py/moq-rs/moq/session.py`, `swift/Sources/Moq`, `kt/.../Flows.kt` (a `Flow` over `grant_changed`), `go/wrapper/moq/session.go` (context-cancellable like the rest), and `dart/moq/lib/moq.dart`. Kotlin diff --git a/quest/m1/auth/interop.md b/quest/m1/auth/interop.md deleted file mode 100644 index a376e202f7..0000000000 --- a/quest/m1/auth/interop.md +++ /dev/null @@ -1,21 +0,0 @@ -# [S] Interop asserts the AUTH grant - -## Goal - -`just test interop --all` fails a cell with AUTH (lite-06, or moq-transport -draft-17+ with MoQ Auth) whose client did not receive -the grant its relay token implies, and a new negative round passes only when -a client publishing outside its grant fails loud with `Unauthorized`. Today -the matrix checks media alone, so a malformed AUTH_OK that leaves a session -with no grant passes silently. - -## Plan - -The Rust and JS interop clients each print the grant they received in one -parseable line; `test/interop/interop.sh` -compares it against the token the cell minted. The negative round mints a -token that excludes the published path and expects the publisher's session -to close with `Unauthorized` naming the path, and the subscriber to see -nothing. Cells without AUTH skip both checks, as do binding clients until -[Bindings](/quest/m1/auth/bindings.md) gives them a grant to print; that -quest adds them to the same assertion. No public API or wire change. diff --git a/rs/moq-ffi/src/binary.rs b/rs/moq-ffi/src/binary.rs index 8e343bd2ad..3c231757f7 100644 --- a/rs/moq-ffi/src/binary.rs +++ b/rs/moq-ffi/src/binary.rs @@ -48,7 +48,9 @@ impl MoqBroadcastProducer { let _guard = crate::ffi::enter(); self.with_state(|state| { let track = state.broadcast.create_track(name, None)?; - let producer = state.catalog.binary_snapshot(track, config.into())?; + let producer = state + .catalog + .binary_snapshot(track, moq_mux::binary::Config::from(config))?; Ok(Arc::new(MoqBinarySnapshotProducer { inner: std::sync::Mutex::new(Some(producer)), })) @@ -66,7 +68,9 @@ impl MoqBroadcastProducer { let _guard = crate::ffi::enter(); self.with_state(|state| { let track = state.broadcast.create_track(name, None)?; - let producer = state.catalog.binary_stream(track, config.into())?; + let producer = state + .catalog + .binary_stream(track, moq_mux::binary::Config::from(config))?; Ok(Arc::new(MoqBinaryStreamProducer { inner: std::sync::Mutex::new(Some(producer)), })) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index ceee1b52fa..f7ce89cfe0 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -295,6 +295,14 @@ impl Handle { /// Record an AUTH_OK for the token. pub(crate) fn granted(&self, id: u64, grant: Grant) { + // One parseable line per AUTH_OK, so the grant the peer actually sent is observable + // without an API; the interop harness checks it against the token it minted. + let list = |patterns: &Patterns| format!("{:?}", patterns.iter().map(|p| p.to_string()).collect::>()); + tracing::debug!( + publish = %list(&grant.publish), + subscribe = %list(&grant.subscribe), + "auth granted" + ); let mut state = self.state.lock(); let Some(slot) = state.tokens.get_mut(&id) else { return; diff --git a/test/interop/README.md b/test/interop/README.md index e41a0d7179..725d5740d5 100644 --- a/test/interop/README.md +++ b/test/interop/README.md @@ -59,6 +59,30 @@ than this checkout: it's a prebuilt NAPI QUIC/HTTP3 addon, not part of the moq source tree. Everything else (`@moq/net`, `@moq/hang`, ...) resolves to the workspace packages, because the JS clients here are bun workspace members. +## Auth + +The relay verifies tokens through `moq auth serve` with a key generated for the +run; nothing is anonymous. Each publisher dials with a token for its broadcast's +subtree and each subscriber with one to read it, so every cell also covers the +`?jwt=` URL path in every client. + +Clients that print the grant the relay sent back over AUTH, as an +`auth granted publish=[...] subscribe=[...]` line, must report exactly what +their token implies: the Rust CLI (a `moq_net::auth` debug log) and the native +JS subscribers. A cell whose grant is missing or wrong fails even when media +flowed. The binding clients (Python, Go, C, GStreamer) have no grant to print +until moq-ffi exposes one, and the browser's shared connection keeps its session +private, so their cells check media alone. Every client here negotiates +moq-lite-06, so a printing client that reports nothing never got its grant. + +After the matrix, each publisher whose refusal the harness can read (Rust, the +browser) runs once more with a token that excludes its broadcast. It must fail +loud, logging Unauthorized and naming the path, and every subscriber must time +out. + +Tokens grant subtrees (`name/**`) because moq-lite-06's AUTH_OK carries prefixes: +the relay withholds a literal grant it cannot encode, and the client sees none. + ## Running locally You need the workspace toolchain on `PATH` (cargo, ffmpeg, bun, uv, go, @@ -161,7 +185,7 @@ contract](../README.md). ```text interop.sh orchestrator: build clients, run the relay + matrix or media checks -interop.toml relay config (anonymous, self-signed localhost) +interop.toml relay config (token auth via `moq auth serve`, self-signed localhost) clients/ python/interop.py publish/subscribe via py/moq-rs (import moq) go/main.go publish/subscribe via go/wrapper (import moq-go/moq) diff --git a/test/interop/clients/js-native/subscribe.ts b/test/interop/clients/js-native/subscribe.ts index 59ec48610a..3a23bacc6b 100644 --- a/test/interop/clients/js-native/subscribe.ts +++ b/test/interop/clients/js-native/subscribe.ts @@ -51,6 +51,16 @@ if (role !== "subscribe" || !url || !broadcast || !Number.isFinite(timeoutMs) || async function run(): Promise { const origin = new Moq.Origin.Producer(); const connection = await Moq.Connection.connect({ url: new URL(url as string), consume: origin }); + // The grant the relay sent, in the Rust client's `auth granted` shape, so the harness can + // check it against the token this cell minted. + const printGrant = (grant: Moq.Auth.Grant | undefined) => { + if (!grant) return; + const publish = JSON.stringify(grant.publish); + const subscribe = JSON.stringify(grant.subscribe); + console.error(`auth granted publish=${publish} subscribe=${subscribe}`); + }; + printGrant(connection.auth.grant.peek()); + const unwatch = connection.auth.grant.subscribe(printGrant); let requested: Moq.Origin.Requesting | undefined; try { const path = Moq.Path.from(broadcast as string); @@ -104,6 +114,7 @@ async function run(): Promise { } throw new Error("no frame data received"); } finally { + unwatch(); requested?.close(); connection.close(); // returns void, not a promise origin.close(); diff --git a/test/interop/interop.sh b/test/interop/interop.sh index 713d4e6865..895674360c 100755 --- a/test/interop/interop.sh +++ b/test/interop/interop.sh @@ -12,6 +12,12 @@ # broadcast and confirms every subscriber sees data flowing before the timeout. # Every publisher but the Rust CLI also carries audio. The browser subscriber # verifies rendered WebCodecs output, player pause/resume, and that audio. +# +# Every client dials with a token minted for its cell and verified by +# `moq auth serve`. Clients that print the grant the relay sent back over AUTH +# must report exactly what their token implies, and a final round per enforcing +# publisher mints a token that excludes its broadcast: the publisher must fail +# loud with Unauthorized and no subscriber may see data. set -euo pipefail INTEROP_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) @@ -34,6 +40,7 @@ SIZE="${INTEROP_SIZE:-320x240}" # Empty means "any reserved port"; INTEROP_PORT pins one instead. PORT="${INTEROP_PORT:-}" URL="" +KEY="" # the HMAC key every cell's token is signed with (set once the relay's auth server starts) NEGATIVE=0 MEDIA=0 @@ -385,10 +392,34 @@ if harness_probe "$URL/certificate.sha256"; then exit 1 fi +# The relay's auth server: `moq auth serve` verifying every token this run mints +# against one fresh key. It answers only POST, so readiness is any HTTP reply. +harness_port auth +AUTH_URL="http://127.0.0.1:${HARNESS_PORT}/" +auth_up() { curl -s -o /dev/null --max-time 1 "$AUTH_URL"; } +if auth_up; then + echo "error: something is already listening on $AUTH_URL" >&2 + exit 1 +fi +KEY="$HARNESS_RUN/key.jwk" +"$MOQ" auth generate --out "$KEY" +harness_spawn auth "$HARNESS_RUN/auth.log" "$MOQ" auth serve --listen "127.0.0.1:${HARNESS_PORT}" --key "$KEY" +AUTH_PID="$HARNESS_PID" +deadline=$((SECONDS + 30)) +until auth_up; do + if ((SECONDS >= deadline)) || harness_exited "$AUTH_PID"; then + echo "auth server never became ready" >&2 + sed 's/^/ auth: /' "$HARNESS_RUN/auth.log" >&2 || true + exit 1 + fi + sleep 0.05 +done + echo "starting relay on 127.0.0.1:${PORT}..." -# interop.toml is the source of truth; rewrite its port into a scratch copy so the +# interop.toml is the source of truth; rewrite its ports into a scratch copy so the # committed file never has to be edited for a run. -sed "s/4443/${PORT}/g" "$INTEROP_DIR/interop.toml" >"$HARNESS_RUN/relay.toml" +sed -e "s|:4443\"|:${PORT}\"|g" -e "s|http://127.0.0.1:4440/|${AUTH_URL}|" \ + "$INTEROP_DIR/interop.toml" >"$HARNESS_RUN/relay.toml" harness_spawn relay "$HARNESS_RUN/relay.log" "$RELAY" "$HARNESS_RUN/relay.toml" if ! harness_ready "$URL/certificate.sha256" 30 "$HARNESS_PID"; then echo "relay never became ready" >&2 @@ -397,6 +428,54 @@ if ! harness_ready "$URL/certificate.sha256" 30 "$HARNESS_PID"; then fi harness_endpoint relay "$URL" +# ── tokens ────────────────────────────────────────────────────────────────── +# Print the relay URL carrying a fresh token; the arguments are `moq auth sign`'s +# (`--publish P`, `--subscribe S`). +token_url() { + local token + token=$("$MOQ" auth sign --key "$KEY" "$@") + printf '%s/?jwt=%s' "$URL" "$token" +} + +# moq-cli logs each grant it receives over AUTH at debug, under `moq_net::auth`. +CLI_LOG="${RUST_LOG:-info},moq_net::auth=debug" + +# The clients that print the grant they received as an `auth granted` line. The +# binding clients (python, go, c, gst) have no grant to print until moq-ffi +# exposes one, and the browser's shared connection keeps its session private. +prints_grant() { + case "$1" in + rust | js-native-node | js-native-bun) return 0 ;; + *) return 1 ;; + esac +} + +# The `auth granted` line a token minted with at most one publish and one +# subscribe pattern implies. Every client on this relay negotiates moq-lite-06, +# which carries AUTH, so a client that prints nothing never got its grant. +grant_line() { + local publish="${1:+\"$1\"}" subscribe="${2:+\"$2\"}" + printf 'auth granted publish=[%s] subscribe=[%s]' "$publish" "$subscribe" +} + +# The last grant a client printed to , with ANSI colour dropped and list +# separators normalized so the Rust and JS renderings compare equal. +reported_grant() { + sed 's/\x1b\[[0-9;]*m//g' "$1" 2>/dev/null | + grep -o 'auth granted publish=\[[^]]*\] subscribe=\[[^]]*\]' | + tail -n 1 | sed 's/", "/","/g' || true +} + +# Check that 's log reports ; prints why not and fails otherwise. +check_grant() { + local lang="$1" log="$2" expected="$3" got + prints_grant "$lang" || return 0 + got=$(reported_grant "$log") + [[ "$got" == "$expected" ]] && return 0 + echo "grant: got '${got:-nothing}', token implies '$expected'" + return 1 +} + # ── client dispatch ───────────────────────────────────────────────────────── # Encode an endless H.264 Annex-B stream from a synthetic source to stdout. # Paced with -re so the broadcast streams in real time until the reader closes. @@ -416,23 +495,23 @@ ffmpeg_h264() { # importers only frame-and-forward). # shellcheck disable=SC2329 # invoked indirectly via 'harness_spawn' run_publisher() { - local lang="$1" broadcast="$2" + local lang="$1" broadcast="$2" url="$3" case "$lang" in rust) - ffmpeg_h264 | "$MOQ" --connect "$URL" --broadcast "$broadcast" import avc3 + ffmpeg_h264 | RUST_LOG="$CLI_LOG" "$MOQ" --connect "$url" --broadcast "$broadcast" import avc3 ;; python) ffmpeg_h264 | "$PY" "$CLIENTS/python/interop.py" \ - publish --url "$URL" --broadcast "$broadcast" + publish --url "$url" --broadcast "$broadcast" ;; go) - ffmpeg_h264 | "$GO_INTEROP" publish --url "$URL" --broadcast "$broadcast" + ffmpeg_h264 | "$GO_INTEROP" publish --url "$url" --broadcast "$broadcast" ;; js) # Headless Chromium encodes its own H.264 from a fake camera via # WebCodecs (lazily, once a subscriber creates demand). cd "$CLIENTS/js" && bun driver.ts publish \ - --url "$URL" --broadcast "$broadcast" + --url "$url" --broadcast "$broadcast" ;; *) echo "unknown publisher: $lang" >&2 @@ -441,11 +520,12 @@ run_publisher() { esac } +# start_publisher , logging to pub-.log. # Sets global PUB_PID to the publisher's process group leader. PUB_PID="" start_publisher() { - local lang="$1" broadcast="$2" - harness_spawn "pub-$lang" "$HARNESS_RUN/pub-$lang.log" run_publisher "$lang" "$broadcast" + local round="$1" lang="$2" broadcast="$3" url="$4" + harness_spawn "pub-$round" "$HARNESS_RUN/pub-$round.log" run_publisher "$lang" "$broadcast" "$url" PUB_PID="$HARNESS_PID" } @@ -464,25 +544,25 @@ run_native() { # shellcheck disable=SC2329 # reached from a function 'harness_spawn' invokes run_subscriber() { - local lang="$1" broadcast="$2" publisher="${3:-}" + local lang="$1" broadcast="$2" url="$3" publisher="${4:-}" case "$lang" in rust) # moq-cli only handles SIGINT, so -k forces SIGKILL if it ignores the # SIGTERM that fires when no data arrives within the timeout. local n - n=$(timeout -k 3 "$TIMEOUT" "$MOQ" --connect "$URL" --broadcast "$broadcast" \ + n=$(RUST_LOG="$CLI_LOG" timeout -k 3 "$TIMEOUT" "$MOQ" --connect "$url" --broadcast "$broadcast" \ export fmp4 | head -c 1 | wc -c | tr -d ' ' || true) [[ "${n:-0}" -ge 1 ]] ;; python) "$PY" "$CLIENTS/python/interop.py" \ - subscribe --url "$URL" --broadcast "$broadcast" --timeout "$TIMEOUT" + subscribe --url "$url" --broadcast "$broadcast" --timeout "$TIMEOUT" ;; go) - "$GO_INTEROP" subscribe --url "$URL" --broadcast "$broadcast" --timeout "$TIMEOUT" + "$GO_INTEROP" subscribe --url "$url" --broadcast "$broadcast" --timeout "$TIMEOUT" ;; c) - "$C_INTEROP" subscribe --url "$URL" --broadcast "$broadcast" --timeout "$TIMEOUT" + "$C_INTEROP" subscribe --url "$url" --broadcast "$broadcast" --timeout "$TIMEOUT" ;; gst) # moqsrc exposes each rendition as a Sometimes pad (video_%u / audio_%u), @@ -499,7 +579,7 @@ run_subscriber() { local n n=$(GST_PLUGIN_PATH_1_0="$GST_PLUGIN_DIR" GST_REGISTRY_1_0="$HARNESS_RUN/gst-run-registry.bin" \ timeout -k 3 "$TIMEOUT" gst-launch-1.0 -q \ - moqsrc name=s url="$URL" broadcast="$broadcast" \ + moqsrc name=s url="$url" broadcast="$broadcast" \ s.video_0 ! filesink location=/dev/stdout buffer-mode=2 \ 2>/dev/null | head -c 1 | wc -c | tr -d ' ' || true) [[ "${n:-0}" -ge 1 ]] @@ -511,21 +591,21 @@ run_subscriber() { # FFI clients from a synthetic Opus tone), so validate audio there. if [[ "$publisher" != "rust" ]]; then (cd "$CLIENTS/js" && bun driver.ts subscribe \ - --url "$URL" --broadcast "$broadcast" --timeout "$TIMEOUT" --expect-audio) + --url "$url" --broadcast "$broadcast" --timeout "$TIMEOUT" --expect-audio) else (cd "$CLIENTS/js" && bun driver.ts subscribe \ - --url "$URL" --broadcast "$broadcast" --timeout "$TIMEOUT") + --url "$url" --broadcast "$broadcast" --timeout "$TIMEOUT") fi ;; js-native-bun) # Native @moq/net via moq's WebTransport polyfill, under bun. run_native bun subscribe.ts subscribe \ - --url "$URL" --broadcast "$broadcast" --timeout "$TIMEOUT" + --url "$url" --broadcast "$broadcast" --timeout "$TIMEOUT" ;; js-native-node) # Same, under node (tsx runs the TS directly). run_native node --import tsx subscribe.ts subscribe \ - --url "$URL" --broadcast "$broadcast" --timeout "$TIMEOUT" + --url "$url" --broadcast "$broadcast" --timeout "$TIMEOUT" ;; *) echo "unknown subscriber: $lang" >&2 @@ -546,53 +626,58 @@ overall=0 # creeping up on $TIMEOUT is a near-miss worth seeing before it fails. # shellcheck disable=SC2329 # invoked indirectly via 'harness_spawn' run_cell() { - local pub="$1" sub="$2" broadcast="$3" started=$SECONDS status=0 + local pub="$1" sub="$2" broadcast="$3" url="$4" started=$SECONDS status=0 # `|| status=$?` rather than a bare call: under `set -e` a failing subscriber # would exit before it recorded anything, and a failure is exactly when the # duration is worth reading. - run_subscriber "$sub" "$broadcast" "$pub" || status=$? + run_subscriber "$sub" "$broadcast" "$url" "$pub" || status=$? echo "$((SECONDS - started))" >"$HARNESS_RUN/$pub-$sub.secs" return "$status" } +# run_round : every subscriber dials with a +# token for alone, and must see data (want_pass=1) or time out (0). +# names the publisher in the output and the logs. run_round() { - local pub="$1" broadcast="$2" pub_pid="$3" - local pids=() names=() i sub + local pub="$1" broadcast="$2" pub_pid="$3" want_pass="$4" + local pids=() names=() i sub sub_url sub_grant why + sub_url=$(token_url --subscribe "$broadcast/**") + sub_grant=$(grant_line "" "$broadcast/**") for sub in "${SUB_LIST[@]}"; do if is_broken "$sub"; then echo " FAIL $pub -> $sub (subscriber client unavailable)" overall=1 continue fi - harness_spawn "$pub-$sub" "$HARNESS_RUN/$pub-$sub.log" run_cell "$pub" "$sub" "$broadcast" + harness_spawn "$pub-$sub" "$HARNESS_RUN/$pub-$sub.log" run_cell "$pub" "$sub" "$broadcast" "$sub_url" pids+=("$HARNESS_PID") names+=("$sub") done # A publisher that streams forever should still be alive; if it died, the # subscriber failures below are a publisher bug, so surface its log. - if [[ -n "$pub_pid" ]] && ! kill -0 "$pub_pid" 2>/dev/null; then + if [[ "$want_pass" -eq 1 && -n "$pub_pid" ]] && ! kill -0 "$pub_pid" 2>/dev/null; then echo " WARN publisher '$pub' exited early:" sed 's/^/ /' "$HARNESS_RUN/pub-$pub.log" 2>/dev/null || true fi - local want_pass=1 got round_pass=0 elapsed - [[ "$NEGATIVE" -eq 1 ]] && want_pass=0 + local got round_pass=0 elapsed # ${arr[@]+...} guard: a round may have no live subscribers (all broken), # and bash 3.2 (macOS) errors on "${!pids[@]}" for an empty array under `set -u`. for i in ${pids[@]+"${!pids[@]}"}; do + why="" if harness_wait "${pids[$i]}"; then got=1; else got=0; fi elapsed=$(cat "$HARNESS_RUN/$pub-${names[$i]}.secs" 2>/dev/null || echo "?") - if [[ "$got" -eq "$want_pass" ]]; then + if [[ "$got" -eq "$want_pass" ]] && why=$(check_grant "${names[$i]}" "$HARNESS_RUN/$pub-${names[$i]}.log" "$sub_grant"); then echo " PASS $pub -> ${names[$i]} (${elapsed}s)" round_pass=1 else - echo " FAIL $pub -> ${names[$i]} (${elapsed}s of ${TIMEOUT}s)" + echo " FAIL $pub -> ${names[$i]} (${elapsed}s of ${TIMEOUT}s)${why:+ $why}" sed 's/^/ /' "$HARNESS_RUN/$pub-${names[$i]}.log" 2>/dev/null || true overall=1 fi done # Every leg failing points at the publisher; surface its log even when the # process is still alive (e.g. connected and announcing but producing nothing). - if [[ "$NEGATIVE" -eq 0 && "$round_pass" -eq 0 && ${#pids[@]} -gt 0 && -n "$pub_pid" ]]; then + if [[ "$want_pass" -eq 1 && "$round_pass" -eq 0 && ${#pids[@]} -gt 0 && -n "$pub_pid" ]]; then echo " INFO publisher '$pub' log:" sed 's/^/ /' "$HARNESS_RUN/pub-$pub.log" 2>/dev/null || true fi @@ -611,7 +696,7 @@ run_media() { shift log="$HARNESS_RUN/media-${name//[^[:alnum:]._-]/-}.log" started=$SECONDS - (cd "$CLIENTS/js" && bun media.ts --url "$URL" --timeout "$TIMEOUT" "$@") >"$log" 2>&1 || status=$? + (cd "$CLIENTS/js" && bun media.ts --url "$MEDIA_URL" --timeout "$TIMEOUT" "$@") >"$log" 2>&1 || status=$? if [[ "$status" -eq 0 ]]; then echo " PASS $name ($((SECONDS - started))s)" # The measurements are the point even when nothing fails: a skew or frame rate creeping @@ -624,7 +709,26 @@ run_media() { fi } +# The publishers whose refusal the harness can read: the Rust CLI's log and the +# browser page's console. The binding publishers enforce the grant too, but +# surface it only through their bindings. +enforces_grant() { + case "$1" in + rust | js) return 0 ;; + *) return 1 ;; + esac +} + +# Check that a publisher refused for failed loud: Unauthorized, naming the path. +check_denied() { + local log="$1" broadcast="$2" plain + plain=$(sed 's/\x1b\[[0-9;]*m//g' "$log" 2>/dev/null || true) + grep -qi 'unauthorized' <<<"$plain" && grep -q "outside our grant.*$broadcast" <<<"$plain" +} + if [[ "$MEDIA" -eq 1 ]]; then + # One token for the whole run: every media case publishes and watches its own broadcast. + MEDIA_URL=$(token_url --publish '**' --subscribe '**') # Media output and lifecycle, browser to browser, against the deterministic fixture. The # negative controls below inject a defect and name the assertion that has to catch it; each # passes only by failing there, which is what keeps the positive run from being vacuous. @@ -643,7 +747,7 @@ elif [[ "$NEGATIVE" -eq 1 ]]; then # Negative control: no publisher. Every subscriber must FAIL (time out with # no data), proving the harness can actually report failure. echo "=== negative control: subscribers expect NO data ===" - run_round "none" "interop-missing-$$-$RANDOM.hang" "" + run_round "none" "interop-missing-$$-$RANDOM.hang" "" 0 else for pub in "${PUB_LIST[@]}"; do broadcast="interop-${pub}-$$-${RANDOM}.hang" @@ -655,8 +759,36 @@ else overall=1 continue fi - start_publisher "$pub" "$broadcast" - run_round "$pub" "$broadcast" "$PUB_PID" + start_publisher "$pub" "$pub" "$broadcast" "$(token_url --publish "$broadcast/**")" + run_round "$pub" "$broadcast" "$PUB_PID" 1 + if why=$(check_grant "$pub" "$HARNESS_RUN/pub-$pub.log" "$(grant_line "$broadcast/**" "")"); then + prints_grant "$pub" && echo " PASS $pub grant" + else + echo " FAIL $pub $why" + overall=1 + fi + done + + # A publisher whose token excludes its broadcast must abort its session with + # Unauthorized naming the path, and no subscriber may see the broadcast. + for pub in "${PUB_LIST[@]}"; do + if ! enforces_grant "$pub" || is_broken "$pub"; then continue; fi + broadcast="interop-denied-${pub}-$$-${RANDOM}.hang" + allowed="interop-allowed-$$" + echo "=== publisher: $pub broadcast: $broadcast (token grants only $allowed/**) ===" + start_publisher "$pub-denied" "$pub" "$broadcast" "$(token_url --publish "$allowed/**")" + run_round "$pub-denied" "$broadcast" "$PUB_PID" 0 + log="$HARNESS_RUN/pub-$pub-denied.log" + if ! check_denied "$log" "$broadcast"; then + echo " FAIL $pub publisher did not fail with Unauthorized naming $broadcast:" + sed 's/^/ /' "$log" 2>/dev/null || true + overall=1 + elif ! why=$(check_grant "$pub" "$log" "$(grant_line "$allowed/**" "")"); then + echo " FAIL $pub $why" + overall=1 + else + echo " PASS $pub publisher refused: Unauthorized" + fi done fi diff --git a/test/interop/interop.toml b/test/interop/interop.toml index 083145e27e..59ac7ab87d 100644 --- a/test/interop/interop.toml +++ b/test/interop/interop.toml @@ -1,5 +1,5 @@ # Relay config for the cross-language interop test. -# Anonymous access, self-signed localhost cert, QUIC + WebSocket on 127.0.0.1:4443. +# Token auth, self-signed localhost cert, QUIC + WebSocket on 127.0.0.1:4443. [log] level = "info" @@ -14,5 +14,6 @@ tls.generate = ["localhost", "127.0.0.1"] listen = "127.0.0.1:4443" [auth] -# Allow anonymous access to everything. -public = "**" +# `moq auth serve`, verifying the tokens interop.sh mints for each cell. No +# anonymous access: a client that drops its token is refused. +url = "http://127.0.0.1:4440/" From 0bb0aa2b03f75ba546bd1acb3fc1628a264e162c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 22:01:18 -0700 Subject: [PATCH 06/53] feat(net): reset revoked lite streams with UNAUTHORIZED (#4179) Co-authored-by: Claude Opus 5.5 Co-authored-by: Grok 4.7 --- doc/concept/moq-lite.md | 4 +- doc/lib/js/net.md | 2 +- doc/lib/rs/moq-net.md | 3 +- drafts/draft-lcurley-moq-lite.md | 4 ++ js/net/src/auth.test.ts | 71 ++++++++++++++++++++++++++- js/net/src/error.test.ts | 2 + js/net/src/error.ts | 12 +++++ js/net/src/ietf/error.test.ts | 1 + js/net/src/ietf/subscriber.ts | 8 +-- js/net/src/lite/publisher.ts | 10 ++-- js/net/src/lite/subscriber.ts | 11 ++--- quest/m1/auth/README.md | 2 - quest/m1/auth/relay-refresh.md | 2 - quest/m1/auth/unauthorized.md | 21 -------- rs/libmoq/src/error.rs | 1 + rs/libmoq/src/test.rs | 6 +++ rs/moq-ffi/src/error.rs | 16 +++++- rs/moq-net/src/coding/reader.rs | 7 +-- rs/moq-net/src/error.rs | 16 ++++-- rs/moq-net/src/ietf/error.rs | 23 ++++++++- rs/moq-net/src/lite/publisher.rs | 3 ++ rs/moq-net/src/lite/subscriber.rs | 8 +-- rs/moq-net/src/model/resume.rs | 81 +++++++++++++++---------------- rs/moq-net/tests/auth.rs | 29 +++++++++-- rs/moq-net/tests/support/mock.rs | 33 +++++++++++-- test/interop/README.md | 2 +- 26 files changed, 267 insertions(+), 111 deletions(-) delete mode 100644 quest/m1/auth/unauthorized.md diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 1fef47c65e..7837d70dc5 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -43,7 +43,9 @@ certificate, or nothing), so a publisher learns before anyone subscribes whether its broadcasts can reach the peer. More tokens can be presented later without reconnecting; the session's scope is the union of every open token's grant, and withdrawing, revoking, or narrowing one withdraws only what it alone -covered. +covered. A subscription or fetch that loses access resets with the +`UNAUTHORIZED` stream code, so the peer can tell it apart from the session +closing. A client that publishes a broadcast outside its grant closes the session with `UNAUTHORIZED` and names the path in the close reason, rather than waiting diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index 40d31fe866..e24d431fce 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -54,7 +54,7 @@ for (;;) { - **Subscriptions** carry a priority, a `Time.Milli` max age, and optional `groups` bounds. Groups arrive out of order and are read frame by frame, with `Error.TooFarBehind` when a reader asks for a frame the group never held and `Error.GroupTooLarge` when a write exceeds the cache budget and aborts the group. - **Track ends**: `close()` ends a track at its live edge, while `finishAt(n)` declares the exclusive end ahead of it and still accepts the groups below. A subscriber reads the end with `final()` or awaits `finished()`. A remote track ends only once every group below its end has arrived or was dropped; one reset before its header arrived is skipped after the subscription's max age on moq-lite (one second without one), or after one second on IETF. - **Datagrams** on moq-lite 05+ and fetch-by-sequence for history. -- **Authorization** on moq-lite 06, and on moq-transport draft-17+ when both sides negotiate the [MoQ Auth extension](/draft/moq-auth): an established session's `auth.grant` is a `Getter` with what the relay lets this side publish and subscribe to, learned right after setup. `auth.add(token)` presents another token without reconnecting and resolves with an `Auth.Token` to `close()` later; it rejects with `Auth.Unsupported` when the peer takes no tokens in band. A connection that publishes a broadcast outside its grant closes with `SessionCode.Unauthorized`, naming the path in the reason. +- **Authorization** on moq-lite 06, and on moq-transport draft-17+ when both sides negotiate the [MoQ Auth extension](/draft/moq-auth): an established session's `auth.grant` is a `Getter` with what the relay lets this side publish and subscribe to, learned right after setup. `auth.add(token)` presents another token without reconnecting and resolves with an `Auth.Token` to `close()` later; it rejects with `Auth.Unsupported` when the peer takes no tokens in band. A connection that publishes a broadcast outside its grant closes with `SessionCode.Unauthorized`, naming the path in the reason. On moq-lite, a subscription the grant stops covering resets with `StreamCode.Unauthorized` and the session stays up. - **Errors** live under one namespace: a stream reset throws `Error.Stream` with a `StreamCode`, while a session close gives `Error.Session` with a `SessionCode`. The registries are disjoint, so the same number means different things in each, and 64+ is yours. Named conditions such as `Error.TooFarBehind`, `Error.FrameTooLarge`, and `Error.GroupTooLarge` subclass `Error.Stream`, so one `code` check handles a condition raised here or reported by the peer. IETF streams use their own mapping: cancellation sends CANCELLED, other local failures send INTERNAL\_ERROR, and received codes remain opaque. - **Paths** with `Path.relative` for the cross-broadcast catalog references hang uses. Path patterns (`Path.Pattern`, `Path.Patterns`) are re-exported from [`@moq/pattern`](https://www.npmjs.com/package/@moq/pattern). Literal `Path` stays a coordinate. diff --git a/doc/lib/rs/moq-net.md b/doc/lib/rs/moq-net.md index 36bd679526..be18b198ae 100644 --- a/doc/lib/rs/moq-net.md +++ b/doc/lib/rs/moq-net.md @@ -99,7 +99,8 @@ and the presenter sees `Unsupported`; after a grant, such an update revokes it. A client whose origin publishes a broadcast outside its grant closes the session with `Unauthorized`, naming the path in the close reason. A grant that shrinks withdraws the announcements and cancels the subscriptions it no longer -covers, and leaves the session up. +covers, and leaves the session up. On moq-lite each cancelled stream resets with +`StreamError::Unauthorized`. ## Patterns diff --git a/drafts/draft-lcurley-moq-lite.md b/drafts/draft-lcurley-moq-lite.md index d08749f81e..41dacc6fc8 100644 --- a/drafts/draft-lcurley-moq-lite.md +++ b/drafts/draft-lcurley-moq-lite.md @@ -325,6 +325,8 @@ Sent when resetting a stream (RESET_STREAM), or when refusing to receive one (ST | ------- | ------------- | ----------- | | 0x39 | TIMESTAMP_MISMATCH | A frame's timestamp does not match its track's timescale. | | ------- | ------------- | ----------- | +| 0x3A | UNAUTHORIZED | The [scope](#auth-stream) does not cover this request, or no longer does. The session stays up. | +| ------- | ------------- | ----------- | Note that CANCELLED is 0x1, not 0x0: a stream reset with 0x0 is an INTERNAL_ERROR, not a routine cancellation. An endpoint terminating a stream because the session is ending SHOULD use SESSION_CLOSED rather than the session's own code, since the two spaces are disjoint. @@ -512,6 +514,7 @@ The opener withdraws a token by closing or resetting its side of the stream, and A grant names the paths the opener may publish to the acceptor and subscribe to from it, relative to the session. An endpoint's scope is the union of the grants of its open Auth Streams, and a stream that ends removes its grant from the union. When the union shrinks, an endpoint SHOULD withdraw its announcements and cancel its subscriptions that the union no longer covers, keeping the session and everything still covered. +An endpoint that cancels a subscription, or refuses or stops serving a request, because the union does not cover it resets the stream with UNAUTHORIZED. An endpoint that announces a broadcast outside the union once its own setup tokens are answered SHOULD close the session with UNAUTHORIZED, rather than wait for a subscription that will never come. An acceptor that does not verify a token in band, or cannot express its grant in AUTH_OK, resets the stream, the same as a peer that does not support the Auth Stream (see [STREAM_TYPE](#stream_type)). @@ -1426,6 +1429,7 @@ The `Message Length` describes the payload size on the wire. - Split the reserved stream error range: 32 through 47 stays reserved, and 48 through 63 is moq-lite's own, assigned by the tables and mapped rather than forwarded across a bridge. Assigned 0x30 NO_CAPACITY there: it permits one re-resolution within the tier excluding the refusing advertiser, and a receiver that has spent or lacks that retry resets downstream with another code. Assigned 0x32 GROUP_TOO_LARGE: a group that grew past the publisher's cache budget is aborted. Every other code is terminal. - Assigned 0x33 NOT_FOUND, 0x34 OLD, and 0x35 EVICTED in the stream error table: a group the publisher cannot serve because it was never here, has been superseded, or was dropped under memory pressure. - Assigned 0x36 UNROUTABLE, 0x37 WRONG_SIZE, 0x38 FRAME_TOO_LARGE, and 0x39 TIMESTAMP_MISMATCH in the stream error table, moving them out of the reserved 32 through 47 range, which no longer carries provisional placeholders. +- Assigned 0x3A UNAUTHORIZED in the stream error table: a request reset because the scope does not cover it, or no longer does, distinct from SESSION_CLOSED. - Assigned 0x31 CONTROL_TIMEOUT in the stream error table: a request stream torn down because the peer never answered, which DELIVERY_TIMEOUT described as late content. It has no moq-transport value and bridges to INTERNAL_ERROR. - A disallowed stream type, a role mismatch, or a missing extension is a PROTOCOL_VIOLATION; the session table gains no code for them, so nothing is sent from the reserved 32 through 47 range in either registry. - Added implicit Announce IDs: each ANNOUNCE_START assigns the next per-stream ordinal. diff --git a/js/net/src/auth.test.ts b/js/net/src/auth.test.ts index 2c83af8c6f..208f82186f 100644 --- a/js/net/src/auth.test.ts +++ b/js/net/src/auth.test.ts @@ -1,13 +1,16 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import type { Getter } from "@moq/signals"; import { type Grant, type Issued, Unsupported } from "./auth.ts"; import { accept as acceptSession, connect as connectSession, type Established } from "./connection/index.ts"; -import { SessionCode, SessionError } from "./error.ts"; +import { SessionCode, SessionError, StreamCode, toStreamCode } from "./error.ts"; import * as Ietf from "./ietf/index.ts"; import * as Lite from "./lite/index.ts"; import { createMockTransportPair, type MockTransport } from "./mock.ts"; import { Producer as OriginProducer } from "./origin.ts"; import * as Path from "./path.ts"; +import { Writer } from "./stream.ts"; +import { Timestamp } from "./time.ts"; +import { wireOf } from "./wire.ts"; const url = new URL("https://localhost:4443/test"); @@ -231,3 +234,67 @@ test.each([Lite.ALPN_05, Ietf.ALPN.DRAFT_16])("%s has no grant", async (protocol client.close(); server.close(); }); + +// moq-transport has no stream code for it, so only moq-lite resets with UNAUTHORIZED. +test("a revoked grant resets its subscriptions with UNAUTHORIZED", async () => { + const clientOrigin = new OriginProducer(); + const up = clientOrigin.createBroadcast(Path.from("up/y")); + up.announce(); + const upTrack = up.createTrack("video"); + + const serverOrigin = new OriginProducer(); + const down = serverOrigin.createBroadcast(Path.from("room/x")); + down.announce(); + const downTrack = down.createTrack("video"); + + const { client, server } = await connect({ + publish: clientOrigin, + serverPublish: serverOrigin, + protocol: Lite.ALPN_06, + }); + const requests = server.auth.requests(); + const issued: Issued[] = []; + void (async () => { + for (;;) { + const request = await requests.next(); + if (!request) break; + issued.push(request.accept(grant(["up"], ["room"]))); + } + })(); + await waitFor(client.auth.grant, (g) => g !== undefined && g.subscribe.size > 0); + + const frame = { payload: new Uint8Array([1]), timestamp: Timestamp.fromMillis(0) }; + downTrack.appendGroup().writeFrame(frame); + upTrack.appendGroup().writeFrame(frame); + + // The client subscribes to the server, and the server to the client. + const downSub = wireOf(client).consume(Path.from("room/x")).track("video").subscribe().ordered(); + const upSub = wireOf(server).consume(Path.from("up/y")).track("video").subscribe().ordered(); + expect(await downSub.nextGroup()).toBeDefined(); + expect(await upSub.nextGroup()).toBeDefined(); + + const resets = spyOn(Writer.prototype, "reset"); + try { + issued[0]?.revoke(SessionCode.Unauthorized, "expired"); + const drained = async (track: typeof downSub) => { + for (;;) if (!(await track.nextGroup())) break; + }; + await Promise.all([drained(downSub).catch(() => void 0), drained(upSub).catch(() => void 0)]); + + const reasons = resets.mock.calls.map(([reason]) => reason); + // Both sides of the revocation: the subscription the client cancels, and the one it + // stops serving. Nothing reads as the session closing. + const messages = reasons.map((reason) => (reason as Error).message); + expect(messages).toContain("unauthorized: room/x"); + expect(messages).toContain("unauthorized: up/y"); + for (const reason of reasons) { + expect([StreamCode.Unauthorized, StreamCode.Cancel]).toContain(toStreamCode(reason)); + } + } finally { + resets.mockRestore(); + downSub.close(); + upSub.close(); + client.close(); + server.close(); + } +}); diff --git a/js/net/src/error.test.ts b/js/net/src/error.test.ts index 61bb89b056..ec84dd75dc 100644 --- a/js/net/src/error.test.ts +++ b/js/net/src/error.test.ts @@ -228,6 +228,7 @@ test("the code tables match the spec", () => { StreamCode.Old, StreamCode.Evicted, StreamCode.FrameTooLarge, + StreamCode.Unauthorized, ]; for (const code of Object.values(StreamCode)) { if (assignedLite.includes(code)) { @@ -245,6 +246,7 @@ test("the code tables match the spec", () => { expect(Number(StreamCode.Old)).toBe(0x34); expect(Number(StreamCode.Evicted)).toBe(0x35); expect(Number(StreamCode.FrameTooLarge)).toBe(0x38); + expect(Number(StreamCode.Unauthorized)).toBe(0x3a); // The spaces are disjoint: 0 ends a session cleanly but fails a stream. expect(Number(SessionCode.Cancel)).not.toBe(Number(StreamCode.Cancel)); diff --git a/js/net/src/error.ts b/js/net/src/error.ts index 9a65d22933..fcfe377526 100644 --- a/js/net/src/error.ts +++ b/js/net/src/error.ts @@ -92,6 +92,8 @@ export const StreamCode = Object.freeze( Evicted: 0x35 as StreamCode, /** A frame declared a payload larger than the receiver accepts. */ FrameTooLarge: 0x38 as StreamCode, + /** The grant does not cover this request, or no longer does. The session stays up. */ + Unauthorized: 0x3a as StreamCode, /** The publisher could serve this request but has no capacity for it now. */ NoCapacity: 0x30 as StreamCode, /** A group grew past its cache budget and was aborted. */ @@ -332,6 +334,16 @@ export function controlTimeout(cause: unknown): Stream { return new Stream(StreamCode.ControlTimeout, { cause, message }); } +/** + * The {@link StreamCode.Unauthorized} error for a request the grant does not cover, naming + * the broadcast. Unlike {@link SessionCode.Unauthorized}, it ends only this stream. + * + * @internal + */ +export function unauthorized(broadcast: string): Stream { + return new Stream(StreamCode.Unauthorized, { message: `unauthorized: ${broadcast}` }); +} + /** * Decode a transport failure into a {@link Stream} when it carries a stream reset code, * otherwise pass it through. diff --git a/js/net/src/ietf/error.test.ts b/js/net/src/ietf/error.test.ts index dbee25afa3..12d415cbcb 100644 --- a/js/net/src/ietf/error.test.ts +++ b/js/net/src/ietf/error.test.ts @@ -182,6 +182,7 @@ test("stream reset codes are shared only where the draft agrees", () => { StreamCode.Evicted, StreamCode.FrameTooLarge, StreamCode.GroupTooLarge, + StreamCode.Unauthorized, ]) { expect(sharedStreamCode(code, version)).toBe(false); } diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index 94d31a740c..d6b38911ef 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -3,7 +3,7 @@ import * as announce from "../announced.ts"; import type { Grant } from "../auth.ts"; import * as broadcast from "../broadcast.ts"; import { BroadcastCache } from "../consume.ts"; -import { controlTimeout, error, ProtocolViolation, reason, SessionCode, SessionError } from "../error.ts"; +import { controlTimeout, error, ProtocolViolation, reason, unauthorized } from "../error.ts"; import * as netGroup from "../group.ts"; import { Cost, type Route, routesEqual, UNKNOWN_HOP } from "../hop.ts"; import { hiddenBelow, hooks, scopeCaptures, scopeHead, scopeOverlaps } from "../internal.ts"; @@ -495,9 +495,9 @@ export class Subscriber { } async #runSubscribe(broadcast: Path.Valid, request: track.Request) { - const unauthorized = new SessionError(SessionCode.Unauthorized, { reason: broadcast }); + const refused = unauthorized(broadcast); if (this.#denied(broadcast)) { - request.reject(unauthorized); + request.reject(refused); return; } @@ -646,7 +646,7 @@ export class Subscriber { // straight out of its loop for the same reason. if (terminal === revokedEnded) { console.info(`subscription no longer authorized: broadcast=${broadcast} track=${request.name}`); - producer.close(unauthorized); + producer.close(refused); } else { producer.close(); } diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index 606b494d84..2aba7ef9a7 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -2,7 +2,7 @@ import { type Dispose, type Getter, race, Signal } from "@moq/signals"; import type { Grant } from "../auth.ts"; import { enforceGrant } from "../auth_session.ts"; import type * as broadcast from "../broadcast.ts"; -import { error, NotFound, reason, SessionCode, SessionError, StreamCode, StreamError } from "../error.ts"; +import { error, NotFound, reason, StreamCode, StreamError, unauthorized } from "../error.ts"; import type * as group from "../group.ts"; import { type Hop, type Route, routesEqual } from "../hop.ts"; import { hiddenBelow, hooks } from "../internal.ts"; @@ -587,7 +587,7 @@ export class Publisher { // Checked before resolving, so a denied request never reaches the origin. const denied = () => this.#denied(msg.broadcast); if (denied()) { - stream.writer.reset(new SessionError(SessionCode.Unauthorized, { reason: msg.broadcast })); + stream.writer.reset(unauthorized(msg.broadcast)); return; } @@ -624,7 +624,7 @@ export class Publisher { let datagrams = Promise.resolve(); let controls: SubscriptionControls | undefined; - const revoked = new SessionError(SessionCode.Unauthorized, { reason: msg.broadcast }); + const revoked = unauthorized(msg.broadcast); const disposeGrant = this.#grant?.subscribe(() => { if (!denied()) return; console.debug(`publish revoked: broadcast=${msg.broadcast} track=${track.name}`); @@ -721,7 +721,7 @@ export class Publisher { return; } if (this.#denied(msg.broadcast)) { - stream.writer.reset(new SessionError(SessionCode.Unauthorized, { reason: msg.broadcast })); + stream.writer.reset(unauthorized(msg.broadcast)); return; } @@ -946,7 +946,7 @@ export class Publisher { */ async runTrackInfo(msg: TrackMessage, stream: Stream) { if (this.#denied(msg.broadcast)) { - stream.writer.reset(new SessionError(SessionCode.Unauthorized, { reason: msg.broadcast })); + stream.writer.reset(unauthorized(msg.broadcast)); return; } try { diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index 06bd57a672..9954f43d10 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -10,10 +10,9 @@ import { error, ProtocolViolation, reason, - SessionCode, - SessionError, StreamCode, StreamError, + unauthorized, } from "../error.ts"; import * as netGroup from "../group.ts"; import { Cost, type Hop, MAX_HOPS, type Route, routesEqual, UNKNOWN_HOP } from "../hop.ts"; @@ -527,9 +526,9 @@ export class Subscriber { request.reject(new Error(EMPTY_RANGE)); return; } - const unauthorized = new SessionError(SessionCode.Unauthorized, { reason: broadcast }); + const refused = unauthorized(broadcast); if (this.#denied(broadcast)) { - request.reject(unauthorized); + request.reject(refused); return; } @@ -586,8 +585,8 @@ export class Subscriber { const disposeGrant = this.#grant?.subscribe(() => { if (!this.#denied(broadcast)) return; console.debug(`subscribe revoked: id=${id} broadcast=${broadcast} track=${request.name}`); - producer.close(unauthorized); - stream.abort(unauthorized); + producer.close(refused); + stream.abort(refused); }); try { // Watch for subscription changes and send SUBSCRIBE_UPDATE. Lite01/Lite02 diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index d9aa7c2573..413a92caf6 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -90,8 +90,6 @@ existing lite-06 ALPN. ## Quests -- [Unauthorized reset](/quest/m1/auth/unauthorized.md) - a subscription that - loses access resets with a dedicated UNAUTHORIZED stream code - [Path patterns](/quest/m1/auth/patterns.md) - one matcher for every path predicate, and AUTH_OK carries pattern grants from AUTH's first release - [Relay tokens](/quest/m1/auth/relay-refresh.md) - the relay verifies tokens diff --git a/quest/m1/auth/relay-refresh.md b/quest/m1/auth/relay-refresh.md index 8c319a7883..83bed06a1f 100644 --- a/quest/m1/auth/relay-refresh.md +++ b/quest/m1/auth/relay-refresh.md @@ -84,5 +84,3 @@ Additive. - [Origin narrowing](/quest/m1/origin-narrowing.md) - the live re-scope a shrinking token union needs, so no temporary close-on-shrink policy ships - [Pattern interest](/quest/m1/auth/patterns.md) - AUTH can represent the complete grants relay revalidation returns -- [Unauthorized reset](/quest/m1/auth/unauthorized.md) - the code this - relay's revocations reset with diff --git a/quest/m1/auth/unauthorized.md b/quest/m1/auth/unauthorized.md deleted file mode 100644 index 7a9a275413..0000000000 --- a/quest/m1/auth/unauthorized.md +++ /dev/null @@ -1,21 +0,0 @@ -# [S] A subscription that loses access resets with UNAUTHORIZED - -## Goal - -When a grant shrinks and a subscription, fetch, or announce loses access, -its stream resets with a dedicated `UNAUTHORIZED` stream code, so the peer -tells revocation apart from a session closing. Today it resets with -`0x3 SESSION_CLOSED` (`StreamError::Session(Unauthorized)`). - -## Plan - -Assign `0x3A UNAUTHORIZED` in the lite draft's stream error table, the next -code in the application block after `TIMESTAMP_MISMATCH`, on lite-06: a -published lite-06 peer already maps an unknown stream code to a generic -error, so the addition is compatible. Add the matching `StreamError` -variant (the enum is non-exhaustive) in Rust and JS, send it from every -revocation path the lite stream added, and cover it in the Rust and JS -tests. Map it to the existing `Unauthorized` protocol kind in -`stream_kind` in `rs/moq-ffi/src/error.rs` and `rs/libmoq/src/error.rs`, -whose wildcard arms would otherwise report `Unknown`, with a test for each. -Update `drafts/draft-lcurley-moq-lite.md` and run `just drafts check`. diff --git a/rs/libmoq/src/error.rs b/rs/libmoq/src/error.rs index ddea6193fa..87d4cb0e76 100644 --- a/rs/libmoq/src/error.rs +++ b/rs/libmoq/src/error.rs @@ -400,6 +400,7 @@ fn stream_kind(err: &moq_net::StreamError) -> moq_protocol_kind { moq_net::StreamError::WrongSize => MOQ_PROTOCOL_KIND_WRONG_SIZE, moq_net::StreamError::FrameTooLarge => MOQ_PROTOCOL_KIND_FRAME_TOO_LARGE, moq_net::StreamError::TimestampMismatch => MOQ_PROTOCOL_KIND_TIMESTAMP_MISMATCH, + moq_net::StreamError::Unauthorized => MOQ_PROTOCOL_KIND_UNAUTHORIZED, moq_net::StreamError::App(_) => MOQ_PROTOCOL_KIND_APP, moq_net::StreamError::Unknown(_) => MOQ_PROTOCOL_KIND_UNKNOWN, _ => MOQ_PROTOCOL_KIND_UNKNOWN, diff --git a/rs/libmoq/src/test.rs b/rs/libmoq/src/test.rs index 535d3574d8..bbbfe45c4f 100644 --- a/rs/libmoq/src/test.rs +++ b/rs/libmoq/src/test.rs @@ -333,6 +333,12 @@ fn last_error_protocol_captures_session_known_app_and_unknown() { 0x2, moq_protocol_kind::MOQ_PROTOCOL_KIND_UNAUTHORIZED as u32, ), + ( + moq_net::Error::from(moq_net::StreamError::Unauthorized), + moq_error_scope::MOQ_ERROR_SCOPE_STREAM as u32, + 0x3a, + moq_protocol_kind::MOQ_PROTOCOL_KIND_UNAUTHORIZED as u32, + ), ( moq_net::Error::from(moq_net::SessionError::App(7)), moq_error_scope::MOQ_ERROR_SCOPE_SESSION as u32, diff --git a/rs/moq-ffi/src/error.rs b/rs/moq-ffi/src/error.rs index cce70fb82f..87731e55c3 100644 --- a/rs/moq-ffi/src/error.rs +++ b/rs/moq-ffi/src/error.rs @@ -25,7 +25,7 @@ pub enum MoqProtocolKind { Cancel, /// Something went wrong that isn't worth a dedicated code. Session 1, stream 0. Internal, - /// The peer's token does not grant the requested path or operation. + /// The peer's token does not grant the requested path or operation. Session 2, stream 0x3A. Unauthorized, /// The peer broke a protocol rule; the session is unusable. ProtocolViolation, @@ -143,6 +143,7 @@ fn stream_kind(err: &moq_net::StreamError) -> MoqProtocolKind { moq_net::StreamError::WrongSize => MoqProtocolKind::WrongSize, moq_net::StreamError::FrameTooLarge => MoqProtocolKind::FrameTooLarge, moq_net::StreamError::TimestampMismatch => MoqProtocolKind::TimestampMismatch, + moq_net::StreamError::Unauthorized => MoqProtocolKind::Unauthorized, moq_net::StreamError::App(_) => MoqProtocolKind::App, moq_net::StreamError::Unknown(_) => MoqProtocolKind::Unknown, _ => MoqProtocolKind::Unknown, @@ -467,6 +468,19 @@ mod tests { } } + #[test] + fn stream_unauthorized_keeps_its_kind() { + let err = MoqError::from(moq_net::Error::from(moq_net::StreamError::Unauthorized)); + match err { + MoqError::Protocol { details: protocol } => { + assert_eq!(protocol.scope, MoqErrorScope::Stream); + assert_eq!(protocol.code, 0x3a); + assert_eq!(protocol.kind, MoqProtocolKind::Unauthorized); + } + other => panic!("expected Protocol, got {other}"), + } + } + #[test] fn stream_app_code_is_verbatim() { let err = MoqError::from(moq_net::Error::from(moq_net::StreamError::App(7))); diff --git a/rs/moq-net/src/coding/reader.rs b/rs/moq-net/src/coding/reader.rs index 2005bd11ae..726e83995f 100644 --- a/rs/moq-net/src/coding/reader.rs +++ b/rs/moq-net/src/coding/reader.rs @@ -301,7 +301,7 @@ mod tests { /// STOP_SENDING refuses a stream, so it must carry a stream code. Reusing the session /// table here would have the peer decode it against the wrong registry: `Cancel` would - /// arrive as INTERNAL_ERROR, and `Unauthorized` as KEY_VALUE_FORMATTING_ERROR. + /// arrive as INTERNAL_ERROR, and `Unauthorized` as DELIVERY_TIMEOUT. #[test] fn abort_stops_with_a_stream_code() { const VERSION: crate::lite::Version = crate::lite::Version::Lite05; @@ -309,10 +309,7 @@ mod tests { for (err, expected) in [ (Error::Cancel, StreamError::Cancel.to_code()), (Error::Lagged, StreamError::TooFarBehind.to_code()), - ( - Error::Unauthorized, - StreamError::Session(crate::SessionError::Unauthorized).to_code(), - ), + (Error::Unauthorized, StreamError::Unauthorized.to_code()), ] { let mut reader = Reader::new(StopLog::default(), VERSION); reader.abort(&err); diff --git a/rs/moq-net/src/error.rs b/rs/moq-net/src/error.rs index 148a857f64..b09ef3e5fb 100644 --- a/rs/moq-net/src/error.rs +++ b/rs/moq-net/src/error.rs @@ -175,6 +175,11 @@ pub enum StreamError { #[error("frame timestamp doesn't match track timescale")] TimestampMismatch, + /// The grant does not cover this subscription, fetch, or announcement, or no longer + /// does. Unlike [`SessionError::Unauthorized`], the session stays up. + #[error("unauthorized")] + Unauthorized, + /// An application-chosen code, offset into the 64+ range on the wire. #[error("app code={0}")] App(u16), @@ -207,6 +212,7 @@ impl StreamError { Self::WrongSize => 0x37, Self::FrameTooLarge => 0x38, Self::TimestampMismatch => 0x39, + Self::Unauthorized => 0x3a, Self::App(app) => *app as u32 + 64, Self::Unknown(code) => *code, } @@ -238,6 +244,7 @@ impl StreamError { 0x37 => Self::WrongSize, 0x38 => Self::FrameTooLarge, 0x39 => Self::TimestampMismatch, + 0x3a => Self::Unauthorized, code @ 64.. => match u16::try_from(code - 64) { Ok(app) => Self::App(app), Err(_) => Self::Unknown(code), @@ -573,6 +580,8 @@ impl From<&Error> for StreamError { Error::FrameTooLarge => Self::FrameTooLarge, Error::GroupTooLarge => Self::GroupTooLarge, Error::TimestampMismatch => Self::TimestampMismatch, + // Losing access ends this stream, not the session. + Error::Unauthorized => Self::Unauthorized, Error::Timeout => Self::DeliveryTimeout, Error::GoingAway => Self::GoingAway, // Our own parse failure is, from the peer's side, a malformed track. @@ -587,8 +596,7 @@ impl From<&Error> for StreamError { // A stream refused on its own is not a session failure, so it does not claim // SESSION_CLOSED; there is no stream-scoped PROTOCOL_VIOLATION to send instead. Error::UnexpectedStream => Self::Internal, - Error::Unauthorized - | Error::Version + Error::Version | Error::UnknownAlpn(_) | Error::TooManyParameters | Error::GoawayTimeout @@ -676,6 +684,7 @@ mod tests { StreamError::WrongSize, StreamError::FrameTooLarge, StreamError::TimestampMismatch, + StreamError::Unauthorized, StreamError::App(7), ]; for err in registered { @@ -694,6 +703,7 @@ mod tests { (StreamError::WrongSize, 0x37), (StreamError::FrameTooLarge, 0x38), (StreamError::TimestampMismatch, 0x39), + (StreamError::Unauthorized, 0x3a), ] { assert_eq!(err.to_code(), code, "{err:?} moved off its assigned code"); } @@ -730,7 +740,7 @@ mod tests { // Registered stream codes survive the hop unchanged. for code in [ - 0x0, 0x1, 0x2, 0x4, 0x5, 0x12, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, + 0x0, 0x1, 0x2, 0x4, 0x5, 0x12, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, ] { let relayed = StreamError::from(&Error::from(StreamError::from_code(code))); assert_eq!(relayed.to_code(), code, "stream {code:#x} changed across a relay"); diff --git a/rs/moq-net/src/ietf/error.rs b/rs/moq-net/src/ietf/error.rs index 66f1163408..e6358fd60c 100644 --- a/rs/moq-net/src/ietf/error.rs +++ b/rs/moq-net/src/ietf/error.rs @@ -284,7 +284,9 @@ pub(crate) mod request { /// would say something the peer's registry gives a different meaning. pub(crate) fn to_code(err: &Error, kind: Kind, version: Version) -> u64 { let registered = match err { - Error::Unauthorized | Error::Session(SessionError::Unauthorized) => return UNAUTHORIZED, + Error::Unauthorized + | Error::Session(SessionError::Unauthorized) + | Error::Stream(StreamError::Unauthorized) => return UNAUTHORIZED, Error::Timeout | Error::Stream(StreamError::DeliveryTimeout) | Error::Session(SessionError::Timeout) => { return TIMEOUT; } @@ -375,6 +377,20 @@ pub(crate) mod request { Error::Remote(0x30), ]; + /// A relay bridging a moq-lite revocation onto a moq-transport request refuses it as + /// UNAUTHORIZED, the same as a local refusal. + #[test] + fn a_bridged_revocation_is_unauthorized() { + for version in ALL { + for kind in KINDS { + assert_eq!( + to_code(&Error::Stream(StreamError::Unauthorized), kind, version), + UNAUTHORIZED + ); + } + } + } + /// Draft-14 numbers a missing track 0x4 and draft-15 moved it to 0x10, which is /// draft-14's MALFORMED_AUTH_TOKEN. Getting this backwards tells a peer its token is /// broken when the broadcast simply is not here, so it re-authenticates instead of @@ -702,6 +718,7 @@ mod tests { StreamError::FrameTooLarge, StreamError::GroupTooLarge, StreamError::TimestampMismatch, + StreamError::Unauthorized, StreamError::ControlTimeout, StreamError::App(7), StreamError::Unknown(0x1234), @@ -728,6 +745,7 @@ mod tests { 0x37, 0x38, 0x39, + 0x3a, 64 + 7, ] { assert_eq!(from_stream_code(code, Version::Draft20), StreamError::Unknown(code)); @@ -740,7 +758,7 @@ mod tests { /// Every stream error this crate can hold, so the conformance check below covers the /// whole space rather than the variants someone remembered. A new variant belongs here. - const EVERY_ERROR: [StreamError; 18] = [ + const EVERY_ERROR: [StreamError; 19] = [ StreamError::Session(SessionError::Cancel), StreamError::Internal, StreamError::Cancel, @@ -757,6 +775,7 @@ mod tests { StreamError::FrameTooLarge, StreamError::GroupTooLarge, StreamError::TimestampMismatch, + StreamError::Unauthorized, StreamError::App(7), StreamError::Unknown(0x22), ]; diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 12039ab3bf..5d2b167e4b 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -1391,6 +1391,9 @@ impl SubscribeServe { | Error::Transport(_) => { tracing::info!(id = self.id, broadcast = %self.absolute, track = %self.track, "subscribed cancelled") } + Error::Unauthorized | Error::Stream(crate::StreamError::Unauthorized) => { + tracing::info!(id = self.id, broadcast = %self.absolute, track = %self.track, "subscribed unauthorized") + } err => { tracing::warn!(id = self.id, broadcast = %self.absolute, track = %self.track, %err, "subscribed error") } diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 589278c4fd..2ad01b183f 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -3062,11 +3062,13 @@ impl TrackServeRun { request.reject(Error::Unauthorized); } } - TrackRunState::Serve(mut serve_loop) => { + TrackRunState::Serve(serve_loop) => { let _ = serve_loop.serving.abort(Error::Unauthorized); - if let Sub::Active(active) = &mut serve_loop.sub { + // Reset rather than finish, so the publisher reads a revocation instead of + // a routine unsubscribe. + if let Sub::Active(active) = serve_loop.sub { self.serve.subscriber.remove_subscribe(active.id); - let _ = active.stream.writer.finish(); + active.stream.writer.abort(&Error::Unauthorized); } } TrackRunState::Done => {} diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index e2227a17f1..572e934ec4 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -1401,8 +1401,8 @@ impl SegmentSub { } /// Move an active cursor into terminal retention and mark the segment done. - fn complete(&mut self, count: Option) { - let previous = std::mem::replace(&mut self.sub, SubState::Done(count)); + fn complete(&mut self, end: Result) { + let previous = std::mem::replace(&mut self.sub, SubState::Done(end)); if let SubState::Active(sub) = previous { self.terminal = Some(*sub); } @@ -1445,11 +1445,11 @@ enum SubState { /// Live cursor over the underlying track. Boxed: the subscriber dwarfs the other /// variants, and every segment holds this enum. Active(Box), - /// The underlying track ended: `Some` with the group count when it finished - /// cleanly, `None` when it aborted or was dropped. An abort is deliberately - /// not surfaced: a dead route stalls the logical track until the next switch - /// replaces it. - Done(Option), + /// The underlying track ended: `Ok` with the group count when it finished + /// cleanly, the error it died with otherwise. An abort is deliberately not + /// surfaced while a switch can still replace the route; it only decides how an + /// orphaned track ends. + Done(Result), } /// A live subscription spliced across every segment of a logical track. @@ -1460,7 +1460,7 @@ enum SubState { /// the producer [`finish`](Producer::finish)es and the final segment completes. /// The producer itself going away without a terminal state ends the track as its /// final segment's track ended, once the remaining segments drain: cleanly if it -/// finished, [`Error::Dropped`] if it died. With nobody left to splice a +/// finished, with its error if it died. With nobody left to splice a /// replacement, a stall would never end. pub struct Subscriber { state: kio::Consumer, @@ -1531,7 +1531,7 @@ impl Subscriber { break; } if seg.pruned { - seg.sub = SubState::Done(None); + seg.sub = SubState::Done(Err(Error::Dropped)); seg.terminal = None; seg.parked.clear(); cut -= 1; @@ -1827,7 +1827,7 @@ impl Subscriber { // The copy was asked for the cache's newest group and started past it: its // source judged that group stale, so the older cache is no use to live reads. if start.is_some_and(|start| edge.is_some_and(|edge| start > edge)) { - seg.complete(None); + seg.complete(Err(Error::Dropped)); return Poll::Ready(()); } } @@ -1848,7 +1848,7 @@ impl Subscriber { seg.sub = SubState::Active(Box::new(sub)); } // The underlying track was rejected or closed: stall, not error. - Err(_) => seg.sub = SubState::Done(None), + Err(err) => seg.sub = SubState::Done(Err(err)), } } Poll::Ready(()) @@ -1877,18 +1877,17 @@ impl Subscriber { return Poll::Ready(Some(group)); } Ok(None) => { - let count = sub.poll_finished(waiter).map(|res| res.ok()); - let count = match count { - Poll::Ready(count) => count, - Poll::Pending => None, + let end = match sub.poll_finished(waiter) { + Poll::Ready(end) => end, + Poll::Pending => Err(Error::Dropped), }; - seg.complete(count); + seg.complete(end); return Poll::Ready(None); } // A dead segment stalls the logical track rather than erroring; // the next switch resumes it. - Err(_) => { - seg.complete(None); + Err(err) => { + seg.complete(Err(err)); return Poll::Ready(None); } }, @@ -1967,15 +1966,15 @@ impl Subscriber { } // The track ran out at or below the floor: the segment drained. Poll::Ready(Ok(None)) => { - let count = match sub.poll_finished(waiter) { - Poll::Ready(count) => count.ok(), - Poll::Pending => None, + let end = match sub.poll_finished(waiter) { + Poll::Ready(end) => end, + Poll::Pending => Err(Error::Dropped), }; - seg.complete(count); + seg.complete(end); } // A dead segment stalls the logical track rather than erroring; // the next switch resumes it. - Poll::Ready(Err(_)) => seg.complete(None), + Poll::Ready(Err(err)) => seg.complete(Err(err)), Poll::Pending => all_done = false, }, SubState::Pending(_) => all_done = false, @@ -2026,7 +2025,7 @@ impl Subscriber { /// Returns `Poll::Ready(Ok(None))` once every segment completed and the /// producer finished, or was dropped with the final segment's track finished. /// Returns `Poll::Ready(Err(_))` if the producer aborted, or was dropped and - /// the final segment's track died ([`Error::Dropped`]). + /// the final segment's track died (with that track's error). pub fn poll_recv_group(&mut self, waiter: &kio::Waiter) -> Poll>> { self.poll_sync(waiter); @@ -2219,10 +2218,7 @@ impl Subscriber { // The producer is gone: no takeover is coming, so the track ends when // its newest segment does, and the way it did. if self.closed && !pending_activation { - return match ready!(self.poll_final(waiter)) { - Some(_) => Poll::Ready(Ok(None)), - None => Poll::Ready(Err(Error::Dropped)), - }; + return Poll::Ready(ready!(self.poll_final(waiter)).map(|_| None)); } Poll::Pending } @@ -2250,35 +2246,35 @@ impl Subscriber { true => Poll::Ready(Ok(end.unwrap_or(0))), // The producer is gone without finishing: the track ends as its final // segment did. - false => Poll::Ready(end.ok_or(Error::Dropped)), + false => Poll::Ready(end), } } /// Wait for the final segment's track to end: its group count when it - /// finished, `None` when it died or there is no segment. Earlier segments don't + /// finished, the error it died with, or [`Error::Dropped`] when there is no segment. Earlier segments don't /// decide the end. Only the subscription is resolved here: consuming groups, or /// completing the segment, would steal them from a `recv_group` caller on the /// same subscriber. - fn poll_final(&mut self, waiter: &kio::Waiter) -> Poll> { + fn poll_final(&mut self, waiter: &kio::Waiter) -> Poll> { let Some(seg) = self.segments.last_mut() else { - return Poll::Ready(None); + return Poll::Ready(Err(Error::Dropped)); }; ready!(Self::poll_activate(seg, &self.last_prefs, self.min_sequence, waiter)); match &mut seg.sub { - SubState::Done(count) => Poll::Ready(*count), + SubState::Done(end) => Poll::Ready(end.clone()), // Observe only: the cursor may still hold groups, so the read path // completes the segment once it drains. - SubState::Active(sub) => Poll::Ready(ready!(sub.poll_finished(waiter)).ok()), + SubState::Active(sub) => Poll::Ready(ready!(sub.poll_finished(waiter))), SubState::Pending(_) => unreachable!("poll_activate resolved above"), } } /// How a logical track whose producer went away without finishing ends, once - /// every segment drained: cleanly if its final segment's track finished, since - /// that is where the content ended, and [`Error::Dropped`] otherwise. + /// every segment drained: as its final segment's track did, since that is where + /// the content ended, or [`Error::Dropped`] when there is none. fn orphan_end(&self) -> Result<()> { match self.segments.last().map(|seg| &seg.sub) { - Some(SubState::Done(Some(_))) => Ok(()), + Some(SubState::Done(end)) => end.clone().map(|_| ()), _ => Err(Error::Dropped), } } @@ -4880,7 +4876,7 @@ mod test { } #[tokio::test] - async fn dropped_producer_errors_once_drained() { + async fn dropped_producer_errors_once_drained_with_the_segment_error() { let (mut track_a, consumer_a) = track_pair("a"); let mut producer = Producer::new(); @@ -4891,12 +4887,13 @@ mod test { assert_eq!(recv(&mut sub), 0); // The segment dies, then the producer goes away without finish/abort: - // no takeover can ever come, so stalling would hang forever. - track_a.abort(Error::Cancel).unwrap(); + // no takeover can ever come, so stalling would hang forever. The reader + // learns why the segment died, not a generic drop. + track_a.abort(Error::Unauthorized).unwrap(); drop(producer); let result = sub.recv_group().now_or_never().expect("must not stall forever"); - assert!(matches!(result, Err(Error::Dropped))); + assert!(matches!(result, Err(Error::Unauthorized)), "{:?}", result.err()); } #[tokio::test] @@ -4944,7 +4941,7 @@ mod test { let result = sub.finished().now_or_never().expect("must not stall forever"); match clean { true => assert!(matches!(result, Ok(0))), - false => assert!(matches!(result, Err(Error::Dropped))), + false => assert!(matches!(result, Err(Error::Cancel)), "{result:?}"), } } } diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs index f682dcd2c9..a7964daab5 100644 --- a/rs/moq-net/tests/auth.rs +++ b/rs/moq-net/tests/auth.rs @@ -7,7 +7,7 @@ mod support; use std::time::Duration; use moq_net::{ - Client, Error, Hop, Pattern, Patterns, Server, Session, SessionError, Version, + Client, Error, Hop, Pattern, Patterns, Server, Session, SessionError, StreamError, Version, auth::{self, Grant}, origin, }; @@ -654,16 +654,37 @@ async fn a_revoked_grant_cancels_its_subscriptions(version: &'static str) { up_sub.recv_group().await.unwrap().unwrap(); setup.revoke(SessionError::Unauthorized, "expired"); - assert!(down_sub.recv_group().await.is_err(), "subscription outlived its grant"); + let err = down_sub + .recv_group() + .await + .err() + .expect("subscription outlived its grant"); + assert!(matches!(err, Error::Unauthorized), "{err:?}"); // The served side ends too: the client stops serving what it may no longer publish. - loop { + let err = loop { match up_sub.recv_group().await { Ok(Some(_)) => continue, Ok(None) => panic!("served subscription finished instead of ending"), - Err(_) => break, + Err(err) => break err, } + }; + // The relay's own reader learns the peer's code across the origin's splice, not a + // generic drop. moq-transport reports it in PUBLISH_DONE instead. + if version == LITE_06 { + assert!(matches!(err, Error::Stream(StreamError::Unauthorized)), "{err:?}"); } assert_eq!(pair.client_transport.close_reason(), None); + + // Neither stream claims the session closed. moq-lite resets both with UNAUTHORIZED; + // moq-transport has no such stream code and reports it on the request instead. + let resets = pair.client_transport.resets(); + let closed = StreamError::Session(SessionError::Unauthorized).to_code(); + assert!(!resets.contains(&closed), "{resets:x?}"); + if version == LITE_06 { + let unauthorized = StreamError::Unauthorized.to_code(); + let count = resets.iter().filter(|&&code| code == unauthorized).count(); + assert_eq!(count, 2, "both subscriptions reset with UNAUTHORIZED: {resets:x?}"); + } }) .await .expect("timed out"); diff --git a/rs/moq-net/tests/support/mock.rs b/rs/moq-net/tests/support/mock.rs index 610f79b68b..42ac87c78c 100644 --- a/rs/moq-net/tests/support/mock.rs +++ b/rs/moq-net/tests/support/mock.rs @@ -103,6 +103,8 @@ impl ClosedSignal { /// A mock send stream backed by a queue to the peer's reader. pub struct MockSendStream { tx: Option>, + /// Every code the owning side reset a stream with. + resets: Resets, closed: Arc, park: kio::Park, } @@ -131,6 +133,7 @@ impl poll::SendStream for MockSendStream { fn reset(&mut self, code: u32) { if let Some(tx) = self.tx.take() { + self.resets.lock().unwrap().push(code); let _ = tx.try_push(StreamChunk::Reset(code)); } } @@ -254,13 +257,17 @@ impl Drop for MockRecvStream { // ── Stream pair constructor ───────────────────────────────────────── -/// Create a linked (send, recv) stream pair. -fn new_stream_pair() -> (MockSendStream, MockRecvStream) { +/// The stream reset codes one side has sent. +type Resets = Arc>>; + +/// Create a linked (send, recv) stream pair, logging the sender's resets to `resets`. +fn new_stream_pair(resets: &Resets) -> (MockSendStream, MockRecvStream) { let queue = kio::Queue::new(); let closed = Arc::new(ClosedSignal::default()); let send = MockSendStream { tx: Some(queue.clone()), + resets: resets.clone(), closed: closed.clone(), park: kio::Park::default(), }; @@ -306,6 +313,9 @@ struct SessionSide { protocol: Option<&'static str>, /// Connection-level close state shared with the peer. conn: Arc, + /// Stream resets sent by this side, and by the peer. + resets: Resets, + peer_resets: Resets, } /// An in-memory mock WebTransport session. @@ -369,8 +379,8 @@ impl poll::Session for MockSession { _cx: &mut Context<'_>, ) -> Poll> { // Create two stream pairs: one for each direction. - let (our_send, peer_recv) = new_stream_pair(); - let (peer_send, our_recv) = new_stream_pair(); + let (our_send, peer_recv) = new_stream_pair(&self.side.resets); + let (peer_send, our_recv) = new_stream_pair(&self.side.peer_resets); // Deliver (peer_send, peer_recv) to the peer's accept_bi. match self.side.peer_bidi.try_push((peer_send, peer_recv)) { @@ -380,7 +390,7 @@ impl poll::Session for MockSession { } fn poll_open_uni(&mut self, _cx: &mut Context<'_>) -> Poll> { - let (our_send, peer_recv) = new_stream_pair(); + let (our_send, peer_recv) = new_stream_pair(&self.side.resets); // Deliver peer_recv to the peer's accept_uni. match self.side.peer_uni.try_push(peer_recv) { @@ -457,6 +467,13 @@ impl MockSession { self.side.conn.close_state.lock().unwrap().clone() } + /// Every code this side has reset a stream with, in order. + // Only some test binaries inspect the codes. + #[allow(dead_code)] + pub fn resets(&self) -> Vec { + self.side.resets.lock().unwrap().clone() + } + fn close_error(&self) -> MockError { self.side .conn @@ -491,6 +508,8 @@ pub fn create_mock_session_pair(protocol: Option<&'static str>) -> (MockSession, let s2c_uni = kio::Queue::new(); let c2s_datagrams = kio::Queue::new(); let s2c_datagrams = kio::Queue::new(); + let client_resets = Resets::default(); + let server_resets = Resets::default(); let client_side = Arc::new(SessionSide { bidi: s2c_bidi.clone(), @@ -501,6 +520,8 @@ pub fn create_mock_session_pair(protocol: Option<&'static str>) -> (MockSession, peer_datagrams: c2s_datagrams.clone(), protocol, conn: conn.clone(), + resets: client_resets.clone(), + peer_resets: server_resets.clone(), }); let server_side = Arc::new(SessionSide { @@ -512,6 +533,8 @@ pub fn create_mock_session_pair(protocol: Option<&'static str>) -> (MockSession, peer_datagrams: s2c_datagrams, protocol, conn, + resets: server_resets, + peer_resets: client_resets, }); let new = |side| MockSession { diff --git a/test/interop/README.md b/test/interop/README.md index 725d5740d5..40bd927b03 100644 --- a/test/interop/README.md +++ b/test/interop/README.md @@ -80,7 +80,7 @@ browser) runs once more with a token that excludes its broadcast. It must fail loud, logging Unauthorized and naming the path, and every subscriber must time out. -Tokens grant subtrees (`name/**`) because moq-lite-06's AUTH_OK carries prefixes: +Tokens grant subtrees (`name/**`) because moq-lite-06's AUTH\_OK carries prefixes: the relay withholds a literal grant it cannot encode, and the client sees none. ## Running locally From 404d05ca2316a599d853160d08a2987e32039ee4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 12:54:18 -0700 Subject: [PATCH 07/53] fix(net): a withdrawn token ends with Cancel even if the acceptor's FIN fails Main's mock now reports a write to a dropped receiver as an error, which exposed the acceptor recording its own FIN failure as the token's end. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/ietf/auth.rs | 8 ++++++-- rs/moq-net/src/lite/publisher.rs | 3 +++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/rs/moq-net/src/ietf/auth.rs b/rs/moq-net/src/ietf/auth.rs index d4ed90b5db..76214462b5 100644 --- a/rs/moq-net/src/ietf/auth.rs +++ b/rs/moq-net/src/ietf/auth.rs @@ -460,8 +460,12 @@ async fn serve_issue( .await; match next { - // The presenter withdrew the token, by FIN or by a cancelling reset. - Next::Withdrawn(Ok(()) | Err(Error::Stream(crate::StreamError::Cancel))) => break, + // The presenter withdrew the token, by FIN or by a cancelling reset. That is + // why it ended, whether or not our own FIN reaches a presenter that left. + Next::Withdrawn(Ok(()) | Err(Error::Stream(crate::StreamError::Cancel))) => { + issue.lock().peer.get_or_insert(Error::Cancel); + break; + } Next::Withdrawn(Err(err)) => return Err(err), Next::Reply(Some(Reply::Grant(grant))) => { let now = crate::runtime::Timers::now(runtime); diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 0a136cc844..57e339192c 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -435,6 +435,9 @@ impl AuthServe { Ok(()) | Err(Error::Stream(crate::StreamError::Cancel)) => {} Err(err) => return Poll::Ready(Err(err)), } + // That is why the token ended, whether or not our own FIN reaches a + // presenter that left. + issue.lock().peer.get_or_insert(Error::Cancel); stream.writer.finish()?; self.finished = true; continue; From 3fdc123ca4269913e2b0dd2e9a40f448bf857343 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 22:18:35 -0700 Subject: [PATCH 08/53] feat(net): carry path patterns in moq-lite AUTH_OK grants (#4277) Co-authored-by: Claude Opus 5.5 --- doc/concept/moq-lite.md | 4 +- doc/lib/js/net.md | 2 +- doc/lib/rs/moq-net.md | 7 +- drafts/draft-lcurley-moq-lite.md | 35 +++++++-- js/net/src/auth.test.ts | 74 +++++++++++++----- js/net/src/lite/auth.test.ts | 50 ++++++++++-- js/net/src/lite/auth.ts | 30 ++++---- js/pattern/src/index.ts | 4 +- quest/m1/auth/README.md | 7 +- quest/m1/auth/patterns.md | 103 ------------------------- quest/m1/auth/relay-refresh.md | 1 - quest/m1/bench-coverage.md | 4 +- quest/m1/epoch.md | 2 +- quest/m1/wildcard/README.md | 11 +-- rs/moq-net/src/auth.rs | 4 +- rs/moq-net/src/lite/auth.rs | 115 ++++++++++++++++----------- rs/moq-net/src/lite/publisher.rs | 22 +----- rs/moq-net/tests/auth.rs | 124 ++++++++++++++++++++++++++++-- rs/moq-pattern/src/pattern.rs | 3 + rs/moq-pattern/tests/pattern.json | 4 + test/interop/README.md | 6 +- test/interop/interop.sh | 21 ++--- test/ts/relay.toml | 21 +++++ test/ts/run.sh | 4 +- 24 files changed, 399 insertions(+), 259 deletions(-) delete mode 100644 quest/m1/auth/patterns.md create mode 100644 test/ts/relay.toml diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index d46c8851ae..0a1eac8e09 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -43,7 +43,9 @@ certificate, or nothing), so a publisher learns before anyone subscribes whether its broadcasts can reach the peer. More tokens can be presented later without reconnecting; the session's scope is the union of every open token's grant, and withdrawing, revoking, or narrowing one withdraws only what it alone -covered. A subscription or fetch that loses access resets with the +covered. A grant is a union of [path patterns](#path-patterns), so `room/*/cam` +or the exact broadcast `room/alice` arrives as issued rather than widened to a +prefix. A subscription or fetch that loses access resets with the `UNAUTHORIZED` stream code, so the peer can tell it apart from the session closing. diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index b897297151..658066a594 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -54,7 +54,7 @@ for (;;) { - **Subscriptions** carry a priority, a `Time.Milli` max age, and optional `groups` bounds. Groups arrive out of order and are read frame by frame, with `Error.TooFarBehind` when a reader asks for a frame the group never held and `Error.GroupTooLarge` when a write exceeds the cache budget and aborts the group. - **Track ends**: `close()` ends a track at its live edge, while `finishAt(n)` declares the exclusive end ahead of it and still accepts the groups below. A subscriber reads the end with `final()` or awaits `finished()`. A remote track ends only once every group below its end has arrived or was dropped; one reset before its header arrived is skipped after the subscription's max age on moq-lite (one second without one), or after one second on IETF. - **Datagrams** on moq-lite 05+ and fetch-by-sequence for history. `track.fetchGroup(sequence)` on moq-lite resolves when the publisher sends the first response byte or finishes an empty group. A missing group rejects the fetch with `StreamCode.NotFound`, including every concurrent caller sharing that fetch. -- **Authorization** on moq-lite 06, and on moq-transport draft-17+ when both sides negotiate the [MoQ Auth extension](/draft/moq-auth): an established session's `auth.grant` is a `Getter` with what the relay lets this side publish and subscribe to, learned right after setup. `auth.add(token)` presents another token without reconnecting and resolves with an `Auth.Token` to `close()` later; it rejects with `Auth.Unsupported` when the peer takes no tokens in band. A connection that publishes a broadcast outside its grant closes with `SessionCode.Unauthorized`, naming the path in the reason. On moq-lite, a subscription the grant stops covering resets with `StreamCode.Unauthorized` and the session stays up. +- **Authorization** on moq-lite 06, and on moq-transport draft-17+ when both sides negotiate the [MoQ Auth extension](/draft/moq-auth): an established session's `auth.grant` is a `Getter` with what the relay lets this side publish and subscribe to, learned right after setup. `auth.add(token)` presents another token without reconnecting and resolves with an `Auth.Token` to `close()` later; it rejects with `Auth.Unsupported` when the peer takes no tokens in band, or on moq-transport when the grant is not a union of subtrees, which its namespace prefixes cannot carry. A connection that publishes a broadcast outside its grant closes with `SessionCode.Unauthorized`, naming the path in the reason. On moq-lite, a subscription the grant stops covering resets with `StreamCode.Unauthorized` and the session stays up. - **Errors** live under one namespace: a stream reset throws `Error.Stream` with a `StreamCode`, while a session close gives `Error.Session` with a `SessionCode`. The registries are disjoint, so the same number means different things in each, and 64+ is yours. Named conditions such as `Error.TooFarBehind`, `Error.FrameTooLarge`, and `Error.GroupTooLarge` subclass `Error.Stream`, so one `code` check handles a condition raised here or reported by the peer. IETF streams use their own mapping: cancellation sends CANCELLED, other local failures send INTERNAL\_ERROR, and received codes remain opaque. - **Paths** with `Path.relative` for the cross-broadcast catalog references hang uses. Path patterns (`Path.Pattern`, `Path.Patterns`) are re-exported from [`@moq/pattern`](https://www.npmjs.com/package/@moq/pattern). Literal `Path` stays a coordinate. diff --git a/doc/lib/rs/moq-net.md b/doc/lib/rs/moq-net.md index 1bab190e34..f8b8d09451 100644 --- a/doc/lib/rs/moq-net.md +++ b/doc/lib/rs/moq-net.md @@ -93,9 +93,10 @@ origin handles allow and refuses any other token as unsupported. To verify tokens yourself, take `handshake.auth().requests()` on the `server::Handshake` before `ok()` (or `session.auth().requests()` before first polling the driver) and answer every `auth::Request` with `accept(grant)`, which returns an -`auth::Issued` you can `update` or `revoke`, or `reject`. Both wires carry -prefix grants for now, so a grant that is not a union of subtrees is refused -and the presenter sees `Unsupported`; after a grant, such an update revokes it. +`auth::Issued` you can `update` or `revoke`, or `reject`. moq-lite carries any +pattern grant as issued. moq-transport carries namespace prefixes, so there a +grant that is not a union of subtrees is refused and the presenter sees +`Unsupported`; after a grant, such an update revokes it. A client whose origin publishes a broadcast outside its grant closes the session with `Unauthorized`, naming the path in the close reason. A grant that diff --git a/drafts/draft-lcurley-moq-lite.md b/drafts/draft-lcurley-moq-lite.md index 0ce3f98769..2b4662f099 100644 --- a/drafts/draft-lcurley-moq-lite.md +++ b/drafts/draft-lcurley-moq-lite.md @@ -1321,24 +1321,43 @@ AUTH_OK Message { Type (i) = 0x0 Message Length (i) Publish Count (i) - Publish Prefix (s) ... + Publish Pattern (s) ... Subscribe Count (i) - Subscribe Prefix (s) ... + Subscribe Pattern (s) ... Expires (i) } ~~~ -**Publish Prefix**: -A path prefix the opener may announce and serve, using the encoding and matching rules of the ANNOUNCE_REQUEST [prefix](#announce-request). -An empty prefix grants every path, and a count of zero grants none. +**Publish Pattern**: +A [pattern](#path-pattern) matching broadcast paths the opener may announce and serve. +A count of zero grants none. -**Subscribe Prefix**: -A path prefix the opener may subscribe to, with the same rules. +**Subscribe Pattern**: +A [pattern](#path-pattern) matching broadcast paths the opener may subscribe to. **Expires**: The number of milliseconds until the grant lapses, or 0 for never. The acceptor revokes a lapsed grant with AUTH_ERROR; the opener uses Expires to present a replacement token in time. +### Path Pattern {#path-pattern} +A pattern is a set of broadcast paths, written as `/`-separated segments. +Each segment is one of: + +- a literal, matching that segment exactly; +- `*`, matching any one segment; +- a literal with one `*` inside it, such as `cam-*.hang`, matching any segment that starts with the bytes before the `*` and ends with the bytes after it, without overlapping them; +- `**`, matching zero or more segments. + +A pattern matches a path when its segments match the path's segments in order, covering the whole path. +The empty pattern matches only the empty path, `room/**` matches `room` and every path beneath it, and `**` matches every path. + +A pattern has at most 32 segments and at most one `**`. +It has no leading, trailing, or repeated `/`, no segment with more than one `*`, and no `**` combined with other bytes in a segment. +It is canonical: a `**` is never immediately preceded by a `*` segment, since `*/**` and `**/*` match the same paths and only `**/*` is valid. +A pattern that breaks any of these rules is a PROTOCOL_VIOLATION. + +A grant covers a request when one of its patterns matches the path. + ## AUTH_ERROR {#auth-error} AUTH_ERROR refuses a token, or revokes it after an AUTH_OK. @@ -1425,7 +1444,7 @@ The `Message Length` describes the payload size on the wire. ## moq-lite-06 - Assigned `moq-lite-06` as this draft's protocol identifier. -- Added the Auth Stream (0x7) with AUTH, AUTH_OK, and AUTH_ERROR: either endpoint presents a token on its own stream and learns the paths it may publish and subscribe to. The union of a session's open grants is its scope. A shrink withdraws what it no longer covers, and an announcement outside the scope closes the session with UNAUTHORIZED. A peer without the stream resets it. +- Added the Auth Stream (0x7) with AUTH, AUTH_OK, and AUTH_ERROR: either endpoint presents a token on its own stream and learns the [path patterns](#path-pattern) it may publish and subscribe to. The union of a session's open grants is its scope. A shrink withdraws what it no longer covers, and an announcement outside the scope closes the session with UNAUTHORIZED. A peer without the stream resets it. - Require error-code translation when bridging protocols and draft versions. - Made a repeated non-zero Hop ID in one announcement's Hop ID list a PROTOCOL_VIOLATION, matching draft-lcurley-moq-cluster. Repeated 0 entries stay legal. - Moved the Qmux-over-WebSocket binding details to draft-lcurley-qmux-websocket; the binding itself is unchanged. diff --git a/js/net/src/auth.test.ts b/js/net/src/auth.test.ts index 208f82186f..dd1a947897 100644 --- a/js/net/src/auth.test.ts +++ b/js/net/src/auth.test.ts @@ -158,7 +158,29 @@ describe.each([Lite.ALPN_06, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", (pro server.close(); }); - test("a grant the wire cannot carry is unsupported and leaves the rest alone", async () => { + test("a refused setup token grants nothing rather than everything", async () => { + const { client, server } = await connect({ publish: new OriginProducer(), protocol }); + const requests = server.auth.requests(); + void (async () => { + for (;;) { + const request = await requests.next(); + if (!request) break; + request.reject(SessionCode.Unauthorized, "bad credential"); + } + })(); + + const empty = await waitFor(client.auth.grant, (g) => g !== undefined); + expect(empty?.publish.size).toBe(0); + expect(empty?.subscribe.size).toBe(0); + client.close(); + server.close(); + }); +}); + +// moq-transport carries namespace prefixes, so a grant that is not a union of subtrees +// is refused there rather than widened. +describe.each([Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", (protocol) => { + test("a grant namespace prefixes cannot carry is unsupported and leaves the rest alone", async () => { const { client, server, transport } = await connect({ publish: new OriginProducer(), protocol }); const requests = server.auth.requests(); const issued: Issued[] = []; @@ -188,7 +210,7 @@ describe.each([Lite.ALPN_06, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", (pro } expect(client.auth.grant.peek()?.publish.equals(patterns("a"))).toBe(true); - // An update the wire cannot carry revokes that token's grant, and only that one. + // An update namespace prefixes cannot carry revokes that token's grant, and only that one. const t1 = await client.auth.add("t1"); await waitFor(client.auth.grant, (g) => g?.publish.equals(patterns("a", "b")) === true); issued[issued.length - 1]?.update({ @@ -207,24 +229,40 @@ describe.each([Lite.ALPN_06, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", (pro client.close(); server.close(); }); +}); - test("a refused setup token grants nothing rather than everything", async () => { - const { client, server } = await connect({ publish: new OriginProducer(), protocol }); - const requests = server.auth.requests(); - void (async () => { - for (;;) { - const request = await requests.next(); - if (!request) break; - request.reject(SessionCode.Unauthorized, "bad credential"); - } - })(); +// moq-lite carries patterns, so every grant arrives exactly as issued. +test("lite-06 carries literal and wildcard grants exactly", async () => { + const { client, server } = await connect({ publish: new OriginProducer(), protocol: Lite.ALPN_06 }); + const requests = server.auth.requests(); + const unions: Record = { + "": [["a/**"], []], + exact: [["room/alice"], []], + wildcard: [["room/*/cam"], ["**/demo.hang"]], + mixed: [["room/**", "lobby", "cam-*.hang"], []], + root: [[""], ["**"]], + }; + const parse = (texts: string[]) => new Path.Patterns(texts.map((text) => Path.Pattern.parse(text))); + const issued: Issued[] = []; + void (async () => { + for (;;) { + const request = await requests.next(); + if (!request) break; + const [publish, subscribe] = unions[new TextDecoder().decode(request.token)] ?? [[], []]; + issued.push(request.accept({ publish: parse(publish), subscribe: parse(subscribe) })); + } + })(); - const empty = await waitFor(client.auth.grant, (g) => g !== undefined); - expect(empty?.publish.size).toBe(0); - expect(empty?.subscribe.size).toBe(0); - client.close(); - server.close(); - }); + await waitFor(client.auth.grant, (g) => g !== undefined); + for (const token of ["exact", "wildcard", "mixed", "root"]) { + const [publish, subscribe] = unions[token]; + const added = await client.auth.add(token); + const got = added.grant.peek(); + expect(got?.publish.equals(parse(publish))).toBe(true); + expect(got?.subscribe.equals(parse(subscribe))).toBe(true); + } + client.close(); + server.close(); }); test.each([Lite.ALPN_05, Ietf.ALPN.DRAFT_16])("%s has no grant", async (protocol) => { diff --git a/js/net/src/lite/auth.test.ts b/js/net/src/lite/auth.test.ts index 77006495a3..43b34b632c 100644 --- a/js/net/src/lite/auth.test.ts +++ b/js/net/src/lite/auth.test.ts @@ -1,13 +1,12 @@ import { expect, test } from "bun:test"; -import { Unsupported } from "../auth.ts"; import { SessionCode } from "../error.ts"; import * as Path from "../path.ts"; import { Reader, Writer } from "../stream.ts"; import { AuthError, AuthMessage, AuthOk, decodeAuthReplyMaybe, encodeAuthReply } from "./auth.ts"; import * as Lite from "./index.ts"; -function patterns(...prefixes: string[]): Path.Patterns { - return new Path.Patterns(prefixes.map((prefix) => Path.Pattern.subtree(prefix))); +function patterns(...texts: string[]): Path.Patterns { + return new Path.Patterns(texts.map((text) => Path.Pattern.parse(text))); } /** Round-trip bytes through a writer and back out of a reader. */ @@ -40,7 +39,7 @@ test("AUTH round-trips its token", async () => { }); test("AUTH_OK and AUTH_ERROR round-trip behind their type", async () => { - const ok = new AuthOk(patterns(""), patterns(), 60_000); + const ok = new AuthOk(patterns("**"), patterns(), 60_000); const err = new AuthError(SessionCode.Unauthorized, "expired"); const r = await roundTrip(async (w) => { await encodeAuthReply(w, ok, Lite.Version.DRAFT_06); @@ -49,7 +48,7 @@ test("AUTH_OK and AUTH_ERROR round-trip behind their type", async () => { const first = await decodeAuthReplyMaybe(r, Lite.Version.DRAFT_06); expect(first).toBeInstanceOf(AuthOk); if (!(first instanceof AuthOk)) throw new Error("unreachable"); - // The empty prefix grants everything; the empty list grants nothing. + // `**` grants everything; the empty list grants nothing. expect(first.publish.equals(new Path.Patterns([Path.Pattern.all()]))).toBe(true); expect(first.subscribe.size).toBe(0); expect(first.expires).toBe(60_000); @@ -59,9 +58,44 @@ test("AUTH_OK and AUTH_ERROR round-trip behind their type", async () => { expect(await decodeAuthReplyMaybe(r, Lite.Version.DRAFT_06)).toBeUndefined(); }); -test("a grant the prefix wire cannot express is refused, never widened", async () => { - const narrow = new AuthOk(new Path.Patterns([Path.Pattern.literal("room/alice")]), patterns()); - await expect(roundTrip((w) => narrow.encode(w, Lite.Version.DRAFT_06))).rejects.toBeInstanceOf(Unsupported); +test("literal and wildcard grants travel exactly, never widened", async () => { + const ok = new AuthOk(patterns("room/alice", "room/*/cam", "**/demo.hang"), patterns("", "lobby/**")); + const got = await AuthOk.decode(await roundTrip((w) => ok.encode(w, Lite.Version.DRAFT_06)), Lite.Version.DRAFT_06); + expect(got.publish.equals(ok.publish)).toBe(true); + expect(got.subscribe.equals(ok.subscribe)).toBe(true); +}); + +// The same bytes as `auth_ok_golden` in `rs/moq-net/src/lite/auth.rs`. +test("AUTH_OK matches the Rust encoding", async () => { + const ok = new AuthOk(patterns("room/*/cam", "**/b.hang"), patterns(""), 1000); + const r = await roundTrip((w) => encodeAuthReply(w, ok, Lite.Version.DRAFT_06)); + const text = (s: string) => [s.length, ...new TextEncoder().encode(s)]; + expect([...(await r.readAll())]).toEqual([ + 0x00, // AUTH_OK + 0x1a, // length + 0x02, // publish count, in canonical order + ...text("**/b.hang"), + ...text("room/*/cam"), + 0x01, // subscribe count + 0x00, // the empty pattern: the root alone + 0x43, + 0xe8, // expires: 1000ms + ]); +}); + +test("only valid, canonical patterns decode", async () => { + for (const text of ["*/**", "/room", "room/", "room//a", "a*b*c", "**/**", "a**"]) { + const r = await roundTrip(async (w) => { + const body = new TextEncoder().encode(text); + await w.u53(0); // AUTH_OK + await w.u53(1 + 1 + body.byteLength + 1 + 1); + await w.u53(1); + await w.string(text); + await w.u53(0); + await w.u53(0); + }); + await expect(decodeAuthReplyMaybe(r, Lite.Version.DRAFT_06)).rejects.toThrow(); + } }); test("lite-05 carries no AUTH", async () => { diff --git a/js/net/src/lite/auth.ts b/js/net/src/lite/auth.ts index 8a6bf2bd08..f1a29d42d7 100644 --- a/js/net/src/lite/auth.ts +++ b/js/net/src/lite/auth.ts @@ -43,23 +43,21 @@ export class AuthMessage { } } -// The wire carries prefixes, the ANNOUNCE_REQUEST encoding, so only a union of subtrees is -// representable. Anything else is refused rather than widened to its head. -async function encodePrefixes(w: Writer, patterns: Path.Patterns) { - const prefixes = patterns.toArray().map((pattern) => { - const prefix = pattern.asPrefix(); - if (prefix === undefined) throw new Unsupported(`grant not representable as prefixes: ${pattern}`); - return prefix; - }); - await w.u53(prefixes.length); - for (const prefix of prefixes) await w.string(prefix); +// Each pattern travels as its canonical text. +async function encodePatterns(w: Writer, patterns: Path.Patterns) { + await w.u53(patterns.size); + for (const pattern of patterns) await w.string(pattern.text); } -async function decodePrefixes(r: Reader): Promise { +async function decodePatterns(r: Reader): Promise { const count = await r.u53(); const patterns = new Path.Patterns(); for (let i = 0; i < count; i++) { - patterns.insert(Path.Pattern.subtree(await r.string())); + const text = await r.string(); + const pattern = Path.Pattern.parse(text); + // Only the canonical spelling is valid, so each pattern has one encoding. + if (pattern.text !== text) throw new Error(`non-canonical pattern: ${text}`); + patterns.insert(pattern); } return patterns; } @@ -78,8 +76,8 @@ export class AuthOk { } async #encode(w: Writer) { - await encodePrefixes(w, this.publish); - await encodePrefixes(w, this.subscribe); + await encodePatterns(w, this.publish); + await encodePatterns(w, this.subscribe); // 0 means never, so a lapsed grant rounds up to the smallest real expiry. const expires = this.expires === undefined ? 0 : Math.min(Math.max(Math.ceil(this.expires), 1), Number.MAX_SAFE_INTEGER); @@ -87,8 +85,8 @@ export class AuthOk { } static async #decode(r: Reader): Promise { - const publish = await decodePrefixes(r); - const subscribe = await decodePrefixes(r); + const publish = await decodePatterns(r); + const subscribe = await decodePatterns(r); const expires = await r.u53(); return new AuthOk(publish, subscribe, expires === 0 ? undefined : expires); } diff --git a/js/pattern/src/index.ts b/js/pattern/src/index.ts index c95676ea42..84482d20e4 100644 --- a/js/pattern/src/index.ts +++ b/js/pattern/src/index.ts @@ -402,7 +402,9 @@ export class Pattern { */ static parse(text: string): Pattern { if (text === "") return new Pattern([]); - return new Pattern(text.split("/").map(parseSegment)); + // One past the limit is enough for the constructor to refuse, without splitting + // a peer's megabytes of `a/a/...` into segments first. + return new Pattern(text.split("/", MAX_PATTERN_SEGMENTS + 1).map(parseSegment)); } /** A pattern from its segments, validating the grammar. Throws {@link InvalidPattern}. */ diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index 6becd2760c..242ca019e1 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -45,9 +45,8 @@ Decisions settled while planning, recorded so review does not relitigate them: - **A public grant contains publish patterns, subscribe patterns, and an expiry**, in the presenter's own root; the presenter never sees the relay-side root, and every token in a union shares the connection's root. Unscoped - permission is `**`; an empty union grants nothing. [Path patterns](/quest/m1/auth/patterns.md) - ships with AUTH, so AUTH_OK carries those patterns, wildcards and literals - alike, from the first release. There is no prefix-only AUTH_OK and no + permission is `**`; an empty union grants nothing. AUTH_OK carries those + patterns, wildcards and literals alike, from the first release. There is no prefix-only AUTH_OK and no covering-prefix workaround. Announce stays a prefix: ANNOUNCE_REQUEST and SUBSCRIBE_NAMESPACE do not gain patterns in that change. The public grant type stays pattern-valued. @@ -90,8 +89,6 @@ existing lite-06 ALPN. ## Quests -- [Path patterns](/quest/m1/auth/patterns.md) - one matcher for every path - predicate, and AUTH_OK carries pattern grants from AUTH's first release - [Origin narrowing](/quest/m1/auth/narrowing.md) - a live grant narrows in place: subscriptions outside it reset, publishes outside it abort, and relay revalidation stops closing the session diff --git a/quest/m1/auth/patterns.md b/quest/m1/auth/patterns.md deleted file mode 100644 index bbc5779357..0000000000 --- a/quest/m1/auth/patterns.md +++ /dev/null @@ -1,103 +0,0 @@ -# [M] Path patterns - -## Goal - -Every predicate over a MoQ broadcast path uses one matcher. Tokens, -origin scopes, announce interests, public access rules, and wildcard -advertisements can express `pid/*/chat` and `**/transcode.pro` without -maintaining competing glob dialects. - -Literal paths remain coordinates, not sets. Roots, joins, exact broadcast -names, URL paths, filesystem paths, and object-store keys keep their own -types. The relay's `/announced/*prefix` debug endpoint remains a prefix-only -exception. - -## Plan - -### Dialect - -A v1 pattern is canonical `/`-separated segments: - -- a literal; -- `*`, matching one complete segment; -- `lit*lit`, with one `*` matching bytes inside one segment (`*.hang`, `foo*`, - `foo.*.hang`); -- `**`, matching zero or more complete segments, at most once per pattern. - -Patterns are exact by default. `foo` matches only `foo`, `foo/**` matches its -subtree including `foo`, `**` matches every path, and the empty pattern matches -only the current root. Reject leading, trailing, or repeated `/`, more than one -`*` in a segment, `**` mixed with literal bytes, and more than one `**`. A -second star in a segment stays reserved: matching it is still linear, but -containment stops being two string compares. Literal `*` needs no escape: new -path construction and publication reject it, while decoders tolerate it on -legacy protocol versions during rollout. - -Construction moves `**` before adjacent `*` segments: `*/**` becomes -`**/*`. Equivalent wildcard placements therefore share one text and identity. - -A pattern list is an unordered union reduced by containment. The shared -algebra supplies matching, overlap, containment, a literal head, and exact -set-valued rebasing. The set-valued result is load-bearing: rebasing `**/a` at -`a` must preserve both the root match and deeper paths ending in `a`. Union -containment is per member: a candidate covered only jointly by several members -(`a/**` against `a`, `a/*`, `a/*/**`) is refused, so the check stays linear and -a grant that means a subtree writes `a/**`. Pattern precedence uses one total -structural specificity everywhere rules overlap, ordered by literal segments, -then no `**`, then `lit*lit` segments, then `*` segments, then literal bytes -pinned inside `lit*lit` segments, then literal head length. That order agrees -with containment (a strict superset always ranks lower); equal patterns form -the same tier. - -### Ownership and compatibility - -`moq-pattern` and `@moq/pattern` own the grammar and algebra; `moq-net`, -`moq-auth`, `@moq/net`, and `@moq/auth` re-export them. Literal `Path` types -stay in `moq-net` / `@moq/net`. Golden cross-language vectors -(`rs/moq-pattern/tests/pattern.json`), exhaustive small cases, randomized round -trips, and the moq-net fuzz harness's `pattern` target prevent semantic drift at -the authorization boundary. Matching is linear and inherits `Path::MAX_PARTS` -(32), which also bounds residual expansion. - -Grants and claims carry no version. `moq-auth` grants are patterns: `foo` -means exactly `foo` and a subtree is `foo/**`. The published `put`/`get` -prefix encoding stays readable as subtrees, and subtree-only grants are still -written in it so older verifiers keep working. A wire message that carried -prefixes keeps them on the protocol versions that defined them; only new -versions carry patterns. - -The syntax follows Ant-style path patterns without `?`, classes, or braces. -NATS subjects motivate segment wildcards and reserved wildcard bytes; Vault -ACLs motivate structural specificity. Common Access -Token and `draft-ietf-moq-c4m-01` provide exact, prefix, and suffix matches per -namespace field, including exact depth with a trailing `nil`. Document the -exact common subset and keep the richer MoQ forms explicit rather than claiming -CAT cannot represent `pid/*/chat`. - -### Grants on the wire - -AUTH grants on moq-lite-06 carry the shared pattern semantics, without -changing older protocol versions. Interest stays a prefix: [#3770](https://github.com/moq-dev/moq/pull/3770) -keeps patterns off the announce wire, so ANNOUNCE_REQUEST and -SUBSCRIBE_NAMESPACE carry the prefix the caller asked for and a wildcard is -an optional filter on the consume side. - -AUTH has not shipped in a release yet: it lives on this line. Land patterns -here, before the line merges, so AUTH_OK carries pattern grants (wildcards and -literals alike) from its first release and never ships a prefix-only encoding. -Today's encoder refuses any grant that is not a subtree -(`rs/moq-net/src/lite/auth.rs`), so a literal grant such as `b1.hang` reaches -the client as no grant at all while the relay's origin enforces it correctly; -this quest removes that gap rather than widening the grant to a covering -prefix. Replace the AUTH grant prefixes with patterns in Rust and JavaScript in -the same change, and update the lite draft and fixtures together. Authorize by -exact containment in the subscriber's v1 grant. Cluster peers adopt nothing as a side -effect of this wire work. - -Test Rust and JavaScript interop, leading wildcards, `**` zero-segment -matches, containment refusal, and old-version behavior. - -## Related - -- [Wildcard advertisements](/quest/m1/wildcard/README.md) - routing adopts the - matcher while retaining its own cost, pool, refusal, and resolution work diff --git a/quest/m1/auth/relay-refresh.md b/quest/m1/auth/relay-refresh.md index 2a7fd5fba2..4d03299fb2 100644 --- a/quest/m1/auth/relay-refresh.md +++ b/quest/m1/auth/relay-refresh.md @@ -83,4 +83,3 @@ Additive. ## Required - [Origin narrowing](/quest/m1/auth/narrowing.md) - the live re-scope a shrinking token union needs, so no temporary close-on-shrink policy ships -- [Pattern interest](/quest/m1/auth/patterns.md) - AUTH can represent the complete grants relay revalidation returns diff --git a/quest/m1/bench-coverage.md b/quest/m1/bench-coverage.md index 9adb4c4a30..5b2abc9aff 100644 --- a/quest/m1/bench-coverage.md +++ b/quest/m1/bench-coverage.md @@ -16,9 +16,7 @@ and update, `moq-auth` token verification, and `moq-pattern` path matching. - `moq-auth`: JWT verification per connection, swept over algorithm and claim size. The in-band token path gets its bench with [In-band token](/quest/m1/auth/token-in-band.md), not here. -- `moq-pattern`: matching swept over pattern count and path depth. Coordinate - with [Path patterns](/quest/m1/auth/patterns.md) so the matcher gets one - bench, not two. +- `moq-pattern`: matching swept over pattern count and path depth. Anything that fans out gets a sweep over both axes. Name each target after what it measures, so the CI comment reads without opening the file. diff --git a/quest/m1/epoch.md b/quest/m1/epoch.md index 4e78ce8492..fffcfc2850 100644 --- a/quest/m1/epoch.md +++ b/quest/m1/epoch.md @@ -21,7 +21,7 @@ line builds on, and it replaces the e2ee-local `moq_e2ee::Epoch`. like `@alice` is valid today and stays valid: strict parsing already keeps it from reading as an epoch. Rejecting it instead would break the path contract and land on `dev`. Check how the split interacts with - [path patterns](/quest/m1/auth/patterns.md) and + path patterns (`rs/moq-pattern`) and hidden broadcasts (a leading `.`, see `doc/concept/moq-lite.md`). - `moq-e2ee` uses the shared type. Update [draft-lcurley-moq-e2ee](/drafts/draft-lcurley-moq-e2ee.md) so the path is diff --git a/quest/m1/wildcard/README.md b/quest/m1/wildcard/README.md index 130cf72dd9..fd72a53ac6 100644 --- a/quest/m1/wildcard/README.md +++ b/quest/m1/wildcard/README.md @@ -46,9 +46,8 @@ Route cost already names this case: "The original publisher seeds it with its production cost: zero for a live publish, something large for a standby that would have to start working (a cold transcoder)" (`drafts/draft-lcurley-moq-lite.md`). `moq_auth::Claims.publish` and -`origin::Producer` gain versioned patterns through -[Path patterns](/quest/m1/auth/patterns.md), so advertisements reuse the -same exact containment check. `Cost { warm, cold }` +`origin::Producer` scope by `moq-pattern` patterns, so advertisements reuse +the same exact containment check. `Cost { warm, cold }` (`rs/moq-net/src/model/origin.rs:426`) is the route cost since [#2925](https://github.com/moq-dev/moq/pull/2925). @@ -84,7 +83,7 @@ field. ### Decisions - **One prefix on the wire, one pattern in the token and the filter.** An - advertisement is a path prefix; the [path-patterns](/quest/m1/auth/patterns.md) + advertisement is a path prefix; the `moq-pattern` dialect is what tokens and the consume-side filter use, matched by the shared matcher, so nothing resembles a second grammar and nothing on the wire spells a wildcard. @@ -192,7 +191,7 @@ field. - **Patterns are independent of clustering.** The `moq-pattern` crate owns the matching semantics tokens and filters share, with no draft of its own; no announce message carries a pattern on either protocol (AUTH grants on - lite-06 do, per [Path patterns](/quest/m1/auth/patterns.md)). moq-cluster adds hop + lite-06 do). moq-cluster adds hop lists, costs, pool selection, and request resolution to prefix advertisements. @@ -266,8 +265,6 @@ than announce state. ## Related -- [path-patterns](/quest/m1/auth/patterns.md) - owns the pattern dialect - and the shared matcher advertisements reuse - [archive](/quest/m1/archive/README.md) - an archive advertises the catch-all pattern, and its catalog names the generations a wildcard cannot - [pop-skipping](/quest/m1/pop-skipping/README.md) - it owns the route cost and diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index f7ce89cfe0..7ea4acf06a 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -15,7 +15,9 @@ //! moq-transport draft-17+ carries the same exchange when both sides negotiate the //! MoQ Auth extension. Older versions, and peers that do not negotiate it, carry no //! AUTH exchange: there the grant stays `None` and [`add`](Handle::add) fails with -//! [`Error::Unsupported`]. +//! [`Error::Unsupported`]. moq-lite carries a grant's patterns as they are; +//! moq-transport carries namespace prefixes, so it refuses a grant that is not a +//! union of subtrees rather than widen it. use std::{ collections::{BTreeMap, VecDeque}, diff --git a/rs/moq-net/src/lite/auth.rs b/rs/moq-net/src/lite/auth.rs index 01319d7352..bef39617a5 100644 --- a/rs/moq-net/src/lite/auth.rs +++ b/rs/moq-net/src/lite/auth.rs @@ -3,7 +3,7 @@ use std::time::Duration; use bytes::Bytes; use crate::coding::*; -use crate::{Path, Pattern, Patterns}; +use crate::{Pattern, Patterns}; use super::{Message, Version}; @@ -48,29 +48,27 @@ pub struct AuthOk { /// Largest millisecond count every implementation carries losslessly. const MAX_EXPIRES_MS: u64 = (1 << 53) - 1; -/// Encode a grant's patterns as the wire's prefix list. -/// -/// The wire carries prefixes, the ANNOUNCE_REQUEST encoding, so only a union of -/// subtrees is representable. Anything else is refused rather than widened: -/// sending `room/**` for a grant of the literal `room/alice` would hand out more -/// than was granted. -fn encode_prefixes(patterns: &Patterns, w: &mut W, version: Version) -> Result<(), EncodeError> { +/// Encode a grant's patterns as their canonical text. +fn encode_patterns(patterns: &Patterns, w: &mut W, version: Version) -> Result<(), EncodeError> { patterns.len().encode(w, version)?; for pattern in patterns { - let prefix = pattern.as_prefix().ok_or(EncodeError::Unsupported)?; - Path::new(prefix).encode(w, version)?; + pattern.as_str().encode(w, version)?; } Ok(()) } -fn decode_prefixes(r: &mut R, version: Version) -> Result { +fn decode_patterns(r: &mut R, version: Version) -> Result { let count = usize::decode(r, version)?; let mut patterns = Patterns::new(); // No preallocation: the count is peer-controlled, and the message size limit - // is what bounds how many prefixes actually fit. + // is what bounds how many patterns actually fit. for _ in 0..count { - let prefix = Path::decode(r, version)?; - let pattern = Pattern::subtree(prefix.as_str()).map_err(|_| DecodeError::InvalidValue)?; + let text = String::decode(r, version)?; + let pattern = Pattern::try_from(text.as_str()).map_err(|_| DecodeError::InvalidValue)?; + // Only the canonical spelling is valid, so each pattern has one encoding. + if pattern.as_str() != text { + return Err(DecodeError::InvalidValue); + } patterns.insert(pattern); } Ok(patterns) @@ -81,8 +79,8 @@ impl Message for AuthOk { if !version.has_auth() { return Err(DecodeError::Version); } - let publish = decode_prefixes(r, version)?; - let subscribe = decode_prefixes(r, version)?; + let publish = decode_patterns(r, version)?; + let subscribe = decode_patterns(r, version)?; let expires = match u64::decode(r, version)? { 0 => None, ms => Some(Duration::from_millis(ms)), @@ -98,8 +96,8 @@ impl Message for AuthOk { if !version.has_auth() { return Err(EncodeError::Version); } - encode_prefixes(&self.publish, w, version)?; - encode_prefixes(&self.subscribe, w, version)?; + encode_patterns(&self.publish, w, version)?; + encode_patterns(&self.subscribe, w, version)?; // 0 means never, so a grant that has already lapsed rounds up to the // smallest value that still reads as an expiry. let expires = match self.expires { @@ -196,8 +194,8 @@ impl Decode for AuthReply { mod tests { use super::*; - fn patterns(prefixes: &[&str]) -> Patterns { - prefixes.iter().map(|p| Pattern::subtree(p).unwrap()).collect() + fn patterns(texts: &[&str]) -> Patterns { + texts.iter().map(|text| Pattern::try_from(*text).unwrap()).collect() } fn round_trip + Decode>(msg: &T) -> T { @@ -217,15 +215,16 @@ mod tests { } } - /// The empty prefix grants everything and the empty list grants nothing; both - /// spellings survive the trip distinctly. + /// `**` grants everything, the empty pattern only the root, and the empty list + /// nothing; literals and wildcards travel exactly, never widened to a prefix. #[test] fn auth_ok_round_trips() { for (publish, subscribe, expires) in [ - (patterns(&[""]), patterns(&[]), None), + (patterns(&["**"]), patterns(&[]), None), + (patterns(&[""]), patterns(&["room/**"]), None), ( - patterns(&["room/alice", "room/bob"]), - patterns(&["room"]), + patterns(&["room/alice", "room/*/cam", "**/demo.hang"]), + patterns(&["room/cam-*.hang", "lobby/**"]), Some(Duration::from_secs(60)), ), ] { @@ -236,7 +235,53 @@ mod tests { }); assert_eq!(round_trip(&msg), msg); } - assert_eq!(patterns(&[""]), Patterns::from(Pattern::all())); + } + + /// The exact bytes, shared with `js/net/src/lite/auth.test.ts` so both encoders agree. + #[test] + fn auth_ok_golden() { + let msg = AuthReply::Ok(AuthOk { + publish: patterns(&["room/*/cam", "**/b.hang"]), + subscribe: patterns(&[""]), + expires: Some(Duration::from_millis(1000)), + }); + let mut buf = bytes::BytesMut::new(); + msg.encode(&mut buf, Version::Lite06).unwrap(); + #[rustfmt::skip] + let want: &[u8] = &[ + 0x00, // AUTH_OK + 0x1a, // length + 0x02, // publish count, in canonical order + 0x09, b'*', b'*', b'/', b'b', b'.', b'h', b'a', b'n', b'g', + 0x0a, b'r', b'o', b'o', b'm', b'/', b'*', b'/', b'c', b'a', b'm', + 0x01, // subscribe count + 0x00, // the empty pattern: the root alone + 0x43, 0xe8, // expires: 1000ms + ]; + assert_eq!(&buf[..], want); + } + + /// Only valid, canonical text decodes: each pattern has exactly one encoding. + #[test] + fn invalid_patterns_are_refused() { + for text in ["*/**", "/room", "room/", "room//a", "a*b*c", "**/**", "a**"] { + let mut buf = bytes::BytesMut::new(); + AUTH_OK.encode(&mut buf, Version::Lite06).unwrap(); + let mut body = bytes::BytesMut::new(); + 1usize.encode(&mut body, Version::Lite06).unwrap(); + text.encode(&mut body, Version::Lite06).unwrap(); + 0usize.encode(&mut body, Version::Lite06).unwrap(); + 0u64.encode(&mut body, Version::Lite06).unwrap(); + body.len().encode(&mut buf, Version::Lite06).unwrap(); + buf.extend_from_slice(&body); + assert!( + matches!( + AuthReply::decode(&mut &buf[..], Version::Lite06), + Err(DecodeError::InvalidValue) + ), + "{text} decoded" + ); + } } #[test] @@ -262,24 +307,6 @@ mod tests { assert_eq!(got.expires, Some(Duration::from_millis(1))); } - /// Only subtrees fit the prefix encoding; anything narrower is refused, never - /// widened to its head. - #[test] - fn unrepresentable_grants_are_refused() { - for pattern in ["room/alice", "room/*/cam", "*/**"] { - let msg = AuthReply::Ok(AuthOk { - publish: Patterns::from(Pattern::try_from(pattern).unwrap()), - subscribe: Patterns::new(), - expires: None, - }); - let mut buf = bytes::BytesMut::new(); - assert!( - matches!(msg.encode(&mut buf, Version::Lite06), Err(EncodeError::Unsupported)), - "{pattern} encoded" - ); - } - } - #[test] fn older_versions_have_no_auth() { for version in [ diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 57e339192c..5858e48864 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -453,7 +453,7 @@ impl AuthServe { match state.outbox.pop_front() { Some(reply) => { drop(state); - Self::write(&self.shared, &issue, stream, reply)?; + Self::write(&self.shared, stream, reply)?; } // The app is done with the grant, or refused the token: close our side. None => { @@ -465,12 +465,7 @@ impl AuthServe { } } - fn write( - shared: &Shared, - issue: &kio::Shared, - stream: &mut Stream, - reply: crate::auth::Reply, - ) -> Result<(), Error> { + fn write(shared: &Shared, stream: &mut Stream, reply: crate::auth::Reply) -> Result<(), Error> { let msg = match reply { crate::auth::Reply::Grant(grant) => { let now = crate::runtime::Timers::now(&shared.runtime); @@ -485,18 +480,7 @@ impl AuthServe { reason, }), }; - match stream.writer.buffer(&msg) { - // This wire carries prefixes only, so a pattern grant cannot be told, only - // withheld: reset the stream, which the presenter reads as unsupported - // rather than refused. Never widen it. A pattern grant is routine until the - // wire carries patterns, so it is not worth a warning. - Err(Error::Encode(crate::coding::EncodeError::Unsupported)) => { - tracing::debug!("auth grant not representable as prefixes; resetting the token's stream"); - issue.lock().done = true; - Err(Error::Unsupported) - } - res => res, - } + stream.writer.buffer(&msg) } } diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs index a7964daab5..2cd2592cd4 100644 --- a/rs/moq-net/tests/auth.rs +++ b/rs/moq-net/tests/auth.rs @@ -52,11 +52,37 @@ cases!( a_closed_session_holds_no_grant, a_reset_auth_stream_reports_unsupported, a_revoked_grant_cancels_its_subscriptions, + nothing_outside_the_grant_reaches_the_peer, +); + +/// Run each case on moq-transport alone, whose namespace prefixes cannot carry every +/// pattern. +macro_rules! prefix_cases { + ($($case:ident),* $(,)?) => { + mod moqt_17_prefixes { + $(#[tokio::test] async fn $case() { super::$case(super::MOQT_17).await })* + } + mod moqt_22_prefixes { + $(#[tokio::test] async fn $case() { super::$case(super::MOQT_22).await })* + } + }; +} + +prefix_cases!( an_unrepresentable_grant_is_unsupported, an_unrepresentable_update_revokes_only_its_token, - nothing_outside_the_grant_reaches_the_peer, ); +#[tokio::test] +async fn lite_06_carries_pattern_grants() { + pattern_grants_arrive_exactly(LITE_06).await +} + +#[tokio::test] +async fn lite_06_enforces_a_wildcard_grant() { + a_wildcard_grant_is_enforced(LITE_06).await +} + #[tokio::test] async fn lite_05_has_no_grant() { older_versions_have_no_grant("moq-lite-05").await @@ -690,8 +716,8 @@ async fn a_revoked_grant_cancels_its_subscriptions(version: &'static str) { .expect("timed out"); } -/// A grant the wire cannot carry as prefixes is never widened: the token is refused as -/// unsupported, promptly, and the union stays unknown rather than empty. +/// A grant moq-transport cannot carry as prefixes is never widened: the token is refused +/// as unsupported, promptly, and the union stays unknown rather than empty. async fn an_unrepresentable_grant_is_unsupported(version: &'static str) { within(async { let mut pair = connect(Options { @@ -739,8 +765,8 @@ async fn an_unrepresentable_grant_is_unsupported(version: &'static str) { .expect("timed out"); } -/// An update the wire cannot carry revokes that token's earlier grant, and only that -/// token's: the rest of the union and the session stay. +/// An update moq-transport cannot carry revokes that token's earlier grant, and only +/// that token's: the rest of the union and the session stay. async fn an_unrepresentable_update_revokes_only_its_token(version: &'static str) { within(async { let mut pair = connect(Options { @@ -766,7 +792,6 @@ async fn an_unrepresentable_update_revokes_only_its_token(version: &'static str) subscribe: Patterns::new(), expires: None, }); - // Lite resets the stream and moq-transport answers NOT_SUPPORTED; either ends it. t1.closed().await; assert_eq!(t1.grant().peek(), None); wait_for(pair.client.auth().grant(), |g| g == &Some(grant(&["a"], &[]))).await; @@ -776,6 +801,93 @@ async fn an_unrepresentable_update_revokes_only_its_token(version: &'static str) .expect("timed out"); } +fn pattern_set(texts: &[&str]) -> Patterns { + texts.iter().map(|text| Pattern::try_from(*text).unwrap()).collect() +} + +/// Literal, wildcard, and mixed grants reach the presenter exactly as issued, never +/// widened to a covering prefix. +async fn pattern_grants_arrive_exactly(version: &'static str) { + within(async { + let mut pair = connect(Options { + version: Some(version), + client_publish: Some(produce_origin(2)), + server_subscribe: Some(produce_origin(1)), + server_requests: true, + ..Default::default() + }) + .await; + let table = |token: &[u8]| -> Option { + let (publish, subscribe) = match token { + b"" => (pattern_set(&["a/**"]), pattern_set(&[])), + b"exact" => (pattern_set(&["room/alice"]), pattern_set(&[])), + b"wildcard" => (pattern_set(&["room/*/cam"]), pattern_set(&["**/demo.hang"])), + b"mixed" => (pattern_set(&["room/**", "lobby", "cam-*.hang"]), pattern_set(&[])), + b"root" => (pattern_set(&[""]), pattern_set(&["**"])), + _ => return None, + }; + Some(Grant { + publish, + subscribe, + expires: None, + }) + }; + let mut issued = serve(pair.requests.take().unwrap(), table); + let (_, _setup) = issued.recv().await.unwrap(); + assert_eq!(granted(&pair.client).await, table(b"").unwrap()); + + let mut held = Vec::new(); + for token in ["exact", "wildcard", "mixed", "root"] { + let added = pair.client.auth().add(token).await.expect(token); + assert_eq!(added.grant().peek(), table(token.as_bytes()), "{token}"); + held.push(added); + } + assert_eq!(pair.client_transport.close_reason(), None); + }) + .await + .expect("timed out"); +} + +/// A wildcard grant admits what it matches, including a leading `**` matching zero +/// segments, and a publish outside it still aborts naming the path. +async fn a_wildcard_grant_is_enforced(version: &'static str) { + within(async { + let publisher = produce_origin(2); + let relay = produce_origin(1); + let scope = pattern_set(&["room/*/cam", "**/b.hang"]); + let pair = connect(Options { + version: Some(version), + client_publish: Some(publisher.clone()), + server_subscribe: Some(relay.scope("", &scope).unwrap()), + ..Default::default() + }) + .await; + assert_eq!(granted(&pair.client).await.publish, scope); + + let mut held = Vec::new(); + for path in ["room/alice/cam", "b.hang", "deep/x/b.hang"] { + let broadcast = publisher.create_broadcast(path).unwrap(); + broadcast.announce(Default::default()).unwrap(); + wait_announced(&relay.consume(), path, true).await; + held.push(broadcast); + } + assert_eq!(pair.client_transport.close_reason(), None); + + let bad = publisher.create_broadcast("room/alice/mic").unwrap(); + bad.announce(Default::default()).unwrap(); + assert!(matches!( + pair.server.closed().await, + Error::Session(SessionError::Unauthorized) + )); + assert_eq!( + pair.client_transport.close_reason().map(|(_, reason)| reason), + Some("unauthorized: room/alice/mic".to_string()) + ); + }) + .await + .expect("timed out"); +} + /// The session closes before the peer ever hears of a broadcast outside the grant, /// even one published before the grant arrived: nothing is advertised until the /// setup token is answered. diff --git a/rs/moq-pattern/src/pattern.rs b/rs/moq-pattern/src/pattern.rs index a1ce23a99d..1789c1681b 100644 --- a/rs/moq-pattern/src/pattern.rs +++ b/rs/moq-pattern/src/pattern.rs @@ -817,6 +817,9 @@ impl FromStr for Pattern { return Self::new([]); } text.split('/') + // One past the limit is enough for `new` to refuse, without splitting a + // peer's megabytes of `a/a/...` into segments first. + .take(Self::MAX_SEGMENTS + 1) .map(Segment::parse) .collect::, _>>() .and_then(Self::new) diff --git a/rs/moq-pattern/tests/pattern.json b/rs/moq-pattern/tests/pattern.json index bf479d8c36..b05d65ae01 100644 --- a/rs/moq-pattern/tests/pattern.json +++ b/rs/moq-pattern/tests/pattern.json @@ -111,6 +111,10 @@ "text": "a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z/a/b/c/d/e/f/g", "error": "too-many-segments" }, + { + "text": "a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z/a/b/c/d/e/f/g/a*b*c", + "error": "too-many-segments" + }, { "text": "*.hang", "segments": [ diff --git a/test/interop/README.md b/test/interop/README.md index c3cd9fbd6b..c02211fda5 100644 --- a/test/interop/README.md +++ b/test/interop/README.md @@ -80,8 +80,10 @@ browser) runs once more with a token that excludes its broadcast. It must fail loud, logging Unauthorized and naming the path, and every subscriber must time out. -Tokens grant subtrees (`name/**`) because moq-lite-06's AUTH\_OK carries prefixes: -the relay withholds a literal grant it cannot encode, and the client sees none. +Tokens use patterns no prefix could carry, so every cell checks that AUTH\_OK +delivers them as minted: a publisher is granted its exact broadcast, a subscriber +`**/name` (a leading `**` matching zero segments), and the refused publisher +`interop-allowed-*.hang`. ## Running locally diff --git a/test/interop/interop.sh b/test/interop/interop.sh index 008ef5c14a..10de96c581 100755 --- a/test/interop/interop.sh +++ b/test/interop/interop.sh @@ -638,13 +638,15 @@ run_cell() { } # run_round : every subscriber dials with a -# token for alone, and must see data (want_pass=1) or time out (0). +# token for alone, and must see data (want_pass=1) or time out (0). The +# leading `**` matches zero segments, so the grant reaches every client as a pattern +# no prefix could carry. # names the publisher in the output and the logs. run_round() { local pub="$1" broadcast="$2" pub_pid="$3" want_pass="$4" local pids=() names=() i sub sub_url sub_grant why - sub_url=$(token_url --subscribe "$broadcast/**") - sub_grant=$(grant_line "" "$broadcast/**") + sub_url=$(token_url --subscribe "**/$broadcast") + sub_grant=$(grant_line "" "**/$broadcast") for sub in "${SUB_LIST[@]}"; do if is_broken "$sub"; then echo " FAIL $pub -> $sub (subscriber client unavailable)" @@ -761,9 +763,10 @@ else overall=1 continue fi - start_publisher "$pub" "$pub" "$broadcast" "$(token_url --publish "$broadcast/**")" + # The exact broadcast, not its subtree. + start_publisher "$pub" "$pub" "$broadcast" "$(token_url --publish "$broadcast")" run_round "$pub" "$broadcast" "$PUB_PID" 1 - if why=$(check_grant "$pub" "$HARNESS_RUN/pub-$pub.log" "$(grant_line "$broadcast/**" "")"); then + if why=$(check_grant "$pub" "$HARNESS_RUN/pub-$pub.log" "$(grant_line "$broadcast" "")"); then prints_grant "$pub" && echo " PASS $pub grant" else echo " FAIL $pub $why" @@ -776,16 +779,16 @@ else for pub in "${PUB_LIST[@]}"; do if ! enforces_grant "$pub" || is_broken "$pub"; then continue; fi broadcast="interop-denied-${pub}-$$-${RANDOM}.hang" - allowed="interop-allowed-$$" - echo "=== publisher: $pub broadcast: $broadcast (token grants only $allowed/**) ===" - start_publisher "$pub-denied" "$pub" "$broadcast" "$(token_url --publish "$allowed/**")" + allowed="interop-allowed-*.hang" + echo "=== publisher: $pub broadcast: $broadcast (token grants only $allowed) ===" + start_publisher "$pub-denied" "$pub" "$broadcast" "$(token_url --publish "$allowed")" run_round "$pub-denied" "$broadcast" "$PUB_PID" 0 log="$HARNESS_RUN/pub-$pub-denied.log" if ! check_denied "$log" "$broadcast"; then echo " FAIL $pub publisher did not fail with Unauthorized naming $broadcast:" sed 's/^/ /' "$log" 2>/dev/null || true overall=1 - elif ! why=$(check_grant "$pub" "$log" "$(grant_line "$allowed/**" "")"); then + elif ! why=$(check_grant "$pub" "$log" "$(grant_line "$allowed" "")"); then echo " FAIL $pub $why" overall=1 else diff --git a/test/ts/relay.toml b/test/ts/relay.toml new file mode 100644 index 0000000000..b2e55c9d06 --- /dev/null +++ b/test/ts/relay.toml @@ -0,0 +1,21 @@ +# Relay config for the TS compliance round-trip. +# Anonymous access, self-signed localhost cert, QUIC + HTTP on 127.0.0.1. +# +# run.sh overrides the ports on the command line (`--listen` / +# `--web-http-listen`) with the one it reserved. + +[log] +level = "info" + +[listen] +# QUIC on UDP. 127.0.0.1 avoids IPv6 flakiness on CI runners. +bind = "127.0.0.1:4470" +tls.generate = ["localhost", "127.0.0.1"] + +[web.http] +# HTTP on TCP, serving /certificate.sha256 for cert pinning. +listen = "127.0.0.1:4470" + +[auth] +# Allow anonymous access to everything. +public = "**" diff --git a/test/ts/run.sh b/test/ts/run.sh index 1b3ad5b0aa..d66df1e0d6 100755 --- a/test/ts/run.sh +++ b/test/ts/run.sh @@ -264,8 +264,8 @@ if harness_probe "$URL/certificate.sha256"; then fi echo "### starting relay on 127.0.0.1:${PORT}" -sed "s/4443/${PORT}/g" "$DIR/../interop/interop.toml" >"$HARNESS_RUN/relay.toml" -harness_spawn relay "$HARNESS_RUN/relay.log" "$RELAY" "$HARNESS_RUN/relay.toml" +harness_spawn relay "$HARNESS_RUN/relay.log" "$RELAY" "$DIR/relay.toml" \ + --listen "127.0.0.1:${PORT}" --web-http-listen "127.0.0.1:${PORT}" if ! harness_ready "$URL/certificate.sha256" 30 "$HARNESS_PID"; then echo "error: relay never became ready" >&2 sed 's/^/ relay: /' "$HARNESS_RUN/relay.log" >&2 || true From c534db8c419ab4b4a50a76f39096bbd28b4aff50 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 19:27:33 -0700 Subject: [PATCH 09/53] chore(quest): claim auth-ok-preflight Co-Authored-By: Claude Sonnet 5.5 From 4b927ac1e2846d59bac41d59fe73c3ef6a15aa69 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 19:35:22 -0700 Subject: [PATCH 10/53] fix(net): refuse an AUTH_OK that cannot be encoded, on any sizing error Co-Authored-By: Claude Sonnet 5.5 --- drafts/draft-lcurley-moq-auth.md | 1 + quest/m1/auth/README.md | 1 - quest/m1/auth/auth-ok-preflight.md | 24 ----------------- rs/moq-net/src/ietf/auth.rs | 13 ++++----- rs/moq-net/src/lite/auth.rs | 13 +++++++++ rs/moq-net/src/lite/message.rs | 4 +++ rs/moq-net/src/lite/publisher.rs | 7 ++++- rs/moq-net/tests/auth.rs | 42 ++++++++++++++++++++++++++++++ 8 files changed, 73 insertions(+), 32 deletions(-) delete mode 100644 quest/m1/auth/auth-ok-preflight.md diff --git a/drafts/draft-lcurley-moq-auth.md b/drafts/draft-lcurley-moq-auth.md index 1e285eddd8..af2674cfdb 100644 --- a/drafts/draft-lcurley-moq-auth.md +++ b/drafts/draft-lcurley-moq-auth.md @@ -109,6 +109,7 @@ A prefix with no fields covers every namespace, and a count of zero grants none. An acceptor whose grant is not a union of prefixes, such as one exact namespace without its descendants, MUST NOT widen it to a prefix. It sends AUTH_ERROR with NOT_SUPPORTED instead: as the first reply this refuses the token, and after an AUTH_OK it revokes the earlier grant. +The same holds for a grant too large to encode as one AUTH_OK: an acceptor MUST NOT trim it, and nothing of the AUTH_OK is written. Other tokens on the session are unaffected. diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index b930917037..b9d2c27a09 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -89,7 +89,6 @@ existing lite-06 ALPN. ## Required -- [AUTH_OK preflight](/quest/m1/auth/auth-ok-preflight.md) - an unencodable IETF grant answers NOT_SUPPORTED with nothing written, as JS already does - [AUTH endings](/quest/m1/auth/error-codes.md) - an out-of-range AUTH_ERROR code is refused, and both sides settle and recompute grants when a stream ends - [Origin narrowing](/quest/m1/auth/narrowing.md) - a live grant narrows in place: subscriptions outside it reset, publishes outside it abort, and relay diff --git a/quest/m1/auth/auth-ok-preflight.md b/quest/m1/auth/auth-ok-preflight.md deleted file mode 100644 index eecead51ac..0000000000 --- a/quest/m1/auth/auth-ok-preflight.md +++ /dev/null @@ -1,24 +0,0 @@ -# [XS] An AUTH_OK that cannot be sent is refused, not half-written - -## Goal - -When the Rust IETF acceptor's grant cannot be encoded as one AUTH_OK for any -reason, it answers `AUTH_ERROR { NOT_SUPPORTED }` and nothing of the AUTH_OK -reaches the wire, the same as JS. Today the preflight in -`rs/moq-net/src/ietf/auth.rs` (`serve_issue`) catches only -`EncodeError::Unsupported`, so a prefix grant too large for the `u16` message -size falls through to `encode_message`, which fails mid-write and ends the -stream with a transport error instead of a refusal the presenter can read. -Found in review of [#4124](https://github.com/moq-dev/moq/pull/4124). - -## Plan - -- Refuse on any sizing error, not only `Unsupported`, including the message - size ceiling the writer enforces. Never trim or widen the grant to make it - fit: withholding it is the only safe answer. -- Check whether the lite AUTH_OK path has the same gap and fix both if so. -- Regression test mirroring JS's "a grant too large for one message is - refused before anything is written": the presenter sees `Unsupported` and - no AUTH_OK bytes were written. - -Public API: none. Wire: none. diff --git a/rs/moq-net/src/ietf/auth.rs b/rs/moq-net/src/ietf/auth.rs index 76214462b5..1e15970faa 100644 --- a/rs/moq-net/src/ietf/auth.rs +++ b/rs/moq-net/src/ietf/auth.rs @@ -474,15 +474,16 @@ async fn serve_issue( subscribe: grant.subscribe, expires: grant.expires.map(|at| at.saturating_duration_since(now)), }; - // Validated before anything is written, so an unrepresentable grant never - // leaves half a message on the wire. Never widen it: refuse, which revokes - // whatever this stream granted before. - if let Err(EncodeError::Unsupported) = ok.encode_msg(&mut Sizer::default(), version) { - tracing::debug!("auth grant not representable as prefixes; refusing the token"); + // Sized as the full message before anything is written, so a grant that cannot + // be encoded for any reason (not a prefix subtree, over the u16 message size) + // never leaves half a message on the wire. Never widen or trim it: refuse, + // which revokes whatever this stream granted before. + if let Err(err) = ok.encode(&mut Sizer::default(), version) { + tracing::debug!(%err, "auth grant cannot be encoded as one AUTH_OK; refusing the token"); issue.lock().done = true; let refused = AuthError { code: NOT_SUPPORTED, - reason: "grant not representable as namespace prefixes".to_string(), + reason: "grant cannot be encoded as one AUTH_OK".to_string(), }; stream.writer.encode_message(&refused).await?; break; diff --git a/rs/moq-net/src/lite/auth.rs b/rs/moq-net/src/lite/auth.rs index bef39617a5..3271c4f8c9 100644 --- a/rs/moq-net/src/lite/auth.rs +++ b/rs/moq-net/src/lite/auth.rs @@ -307,6 +307,19 @@ mod tests { assert_eq!(got.expires, Some(Duration::from_millis(1))); } + /// A message the peer would refuse as too large is never encoded, so the acceptor can + /// answer instead of sending it. + #[test] + fn oversized_message_is_refused() { + let msg = Auth { + token: Bytes::from(vec![0; super::super::message::MAX_MESSAGE_SIZE + 1]), + }; + assert!(matches!( + msg.encode(&mut Sizer::default(), Version::Lite06), + Err(EncodeError::TooLarge) + )); + } + #[test] fn older_versions_have_no_auth() { for version in [ diff --git a/rs/moq-net/src/lite/message.rs b/rs/moq-net/src/lite/message.rs index 0d28eb735f..cd1976b83b 100644 --- a/rs/moq-net/src/lite/message.rs +++ b/rs/moq-net/src/lite/message.rs @@ -35,6 +35,10 @@ impl Encode for T { tracing::trace!(?self, "encoding"); let mut sizer = Sizer::default(); self.encode_msg(&mut sizer, version)?; + // The peer refuses anything larger before decoding it, so it never leaves here. + if sizer.size > MAX_MESSAGE_SIZE { + return Err(EncodeError::TooLarge); + } sizer.size.encode(w, version)?; self.encode_msg(w, version) } diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 321af8bb81..6998fa9dec 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -479,7 +479,12 @@ impl AuthServe { reason, }), }; - stream.writer.buffer(&msg) + // A grant that cannot be encoded is withheld, never trimmed. Nothing is buffered, and + // the reset that follows reads as "unsupported" to the presenter. + stream.writer.buffer(&msg).map_err(|err| { + tracing::debug!(%err, "auth reply cannot be encoded; refusing the token"); + Error::Unsupported + }) } } diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs index 2cd2592cd4..e828124af6 100644 --- a/rs/moq-net/tests/auth.rs +++ b/rs/moq-net/tests/auth.rs @@ -71,6 +71,7 @@ macro_rules! prefix_cases { prefix_cases!( an_unrepresentable_grant_is_unsupported, an_unrepresentable_update_revokes_only_its_token, + a_grant_too_large_for_one_message_is_unsupported, ); #[tokio::test] @@ -801,6 +802,47 @@ async fn an_unrepresentable_update_revokes_only_its_token(version: &'static str) .expect("timed out"); } +/// A grant that fits no single AUTH_OK is withheld, never trimmed: the presenter is told +/// it is unsupported, nothing of the message reaches the wire, and the session and the +/// token that did fit are untouched. +async fn a_grant_too_large_for_one_message_is_unsupported(version: &'static str) { + // Past the u16 message size. The moq-lite ceiling is 64 MiB, too slow to build here; + // `lite::auth` tests it on the encoder. + let (count, len) = (20, 4_000); + within(async { + let mut pair = connect(Options { + version: Some(version), + client_publish: Some(produce_origin(2)), + server_subscribe: Some(produce_origin(1)), + server_requests: true, + ..Default::default() + }) + .await; + let huge: Patterns = (0..count) + .map(|i| Pattern::subtree(&format!("{i}{}", "x".repeat(len))).unwrap()) + .collect(); + let mut issued = serve(pair.requests.take().unwrap(), move |token| match token { + b"" => Some(grant(&["a"], &[])), + b"huge" => Some(Grant { + publish: huge.clone(), + subscribe: Patterns::new(), + expires: None, + }), + _ => None, + }); + let (_, _setup) = issued.recv().await.unwrap(); + assert_eq!(granted(&pair.client).await, grant(&["a"], &[])); + + let err = pair.client.auth().add("huge").await.err().expect("too large"); + assert!(matches!(err, Error::Unsupported), "{err:?}"); + // No AUTH_OK for it reached the wire: the union is still just the setup grant. + assert_eq!(pair.client.auth().grant().peek(), Some(grant(&["a"], &[]))); + assert_eq!(pair.client_transport.close_reason(), None); + }) + .await + .expect("timed out"); +} + fn pattern_set(texts: &[&str]) -> Patterns { texts.iter().map(|text| Pattern::try_from(*text).unwrap()).collect() } From ee275b2675e35d89f533d9ccb85cd7239cd65091 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 10:37:54 -0700 Subject: [PATCH 11/53] fix(net): mirror the lite 64 MiB encode ceiling in JS A lite AUTH_OK or AUTH_ERROR now writes its type only once the body fits, matching the IETF encoder, so an oversized reply leaves nothing on the stream and the session resets it as unsupported. Co-Authored-By: Claude Opus 5.5 --- js/net/src/lite/auth.test.ts | 24 +++++++++++++++++++----- js/net/src/lite/auth.ts | 29 ++++++++++++----------------- js/net/src/lite/message.ts | 12 +++++++++++- 3 files changed, 42 insertions(+), 23 deletions(-) diff --git a/js/net/src/lite/auth.test.ts b/js/net/src/lite/auth.test.ts index 43b34b632c..ab4c2f0cd9 100644 --- a/js/net/src/lite/auth.test.ts +++ b/js/net/src/lite/auth.test.ts @@ -2,8 +2,9 @@ import { expect, test } from "bun:test"; import { SessionCode } from "../error.ts"; import * as Path from "../path.ts"; import { Reader, Writer } from "../stream.ts"; -import { AuthError, AuthMessage, AuthOk, decodeAuthReplyMaybe, encodeAuthReply } from "./auth.ts"; +import { AuthError, AuthMessage, AuthOk, decodeAuthReplyMaybe } from "./auth.ts"; import * as Lite from "./index.ts"; +import * as Message from "./message.ts"; function patterns(...texts: string[]): Path.Patterns { return new Path.Patterns(texts.map((text) => Path.Pattern.parse(text))); @@ -42,8 +43,8 @@ test("AUTH_OK and AUTH_ERROR round-trip behind their type", async () => { const ok = new AuthOk(patterns("**"), patterns(), 60_000); const err = new AuthError(SessionCode.Unauthorized, "expired"); const r = await roundTrip(async (w) => { - await encodeAuthReply(w, ok, Lite.Version.DRAFT_06); - await encodeAuthReply(w, err, Lite.Version.DRAFT_06); + await ok.encode(w, Lite.Version.DRAFT_06); + await err.encode(w, Lite.Version.DRAFT_06); }); const first = await decodeAuthReplyMaybe(r, Lite.Version.DRAFT_06); expect(first).toBeInstanceOf(AuthOk); @@ -60,7 +61,9 @@ test("AUTH_OK and AUTH_ERROR round-trip behind their type", async () => { test("literal and wildcard grants travel exactly, never widened", async () => { const ok = new AuthOk(patterns("room/alice", "room/*/cam", "**/demo.hang"), patterns("", "lobby/**")); - const got = await AuthOk.decode(await roundTrip((w) => ok.encode(w, Lite.Version.DRAFT_06)), Lite.Version.DRAFT_06); + const r = await roundTrip((w) => ok.encode(w, Lite.Version.DRAFT_06)); + const got = await decodeAuthReplyMaybe(r, Lite.Version.DRAFT_06); + if (!(got instanceof AuthOk)) throw new Error("expected AUTH_OK"); expect(got.publish.equals(ok.publish)).toBe(true); expect(got.subscribe.equals(ok.subscribe)).toBe(true); }); @@ -68,7 +71,7 @@ test("literal and wildcard grants travel exactly, never widened", async () => { // The same bytes as `auth_ok_golden` in `rs/moq-net/src/lite/auth.rs`. test("AUTH_OK matches the Rust encoding", async () => { const ok = new AuthOk(patterns("room/*/cam", "**/b.hang"), patterns(""), 1000); - const r = await roundTrip((w) => encodeAuthReply(w, ok, Lite.Version.DRAFT_06)); + const r = await roundTrip((w) => ok.encode(w, Lite.Version.DRAFT_06)); const text = (s: string) => [s.length, ...new TextEncoder().encode(s)]; expect([...(await r.readAll())]).toEqual([ 0x00, // AUTH_OK @@ -83,6 +86,17 @@ test("AUTH_OK matches the Rust encoding", async () => { ]); }); +// Mirrors `oversized_message_is_refused` in `rs/moq-net/src/lite/auth.rs`: the peer would +// refuse it, so the acceptor withholds it, type included, and resets the stream instead. +// Building a 64 MiB grant from patterns is too slow, so the body is raw bytes. +test("a reply larger than the peer reads writes nothing", async () => { + const body = new Uint8Array(Message.MAX_MESSAGE_SIZE + 1); + const r = await roundTrip(async (w) => { + await expect(Message.encode(w, (w) => w.write(body), AuthOk.id)).rejects.toThrow("message too large"); + }); + expect((await r.readAll()).byteLength).toBe(0); +}); + test("only valid, canonical patterns decode", async () => { for (const text of ["*/**", "/room", "room/", "room//a", "a*b*c", "**/**", "a**"]) { const r = await roundTrip(async (w) => { diff --git a/js/net/src/lite/auth.ts b/js/net/src/lite/auth.ts index f1a29d42d7..a614c0bd67 100644 --- a/js/net/src/lite/auth.ts +++ b/js/net/src/lite/auth.ts @@ -62,8 +62,10 @@ async function decodePatterns(r: Reader): Promise { return patterns; } -/** The grant a token earns, as the acceptor writes it. */ +/** The grant a token earns, as the acceptor writes it. Encodes its own type. */ export class AuthOk { + static id = 0; + publish: Path.Patterns; subscribe: Path.Patterns; /** Milliseconds until the grant lapses, or undefined for never. */ @@ -93,7 +95,7 @@ export class AuthOk { async encode(w: Writer, version: Version): Promise { guardAuth(version); - return Message.encode(w, this.#encode.bind(this)); + return Message.encode(w, this.#encode.bind(this), AuthOk.id); } static async decode(r: Reader, version: Version): Promise { @@ -102,8 +104,10 @@ export class AuthOk { } } -/** The acceptor refusing a token, or revoking it after an AUTH_OK. */ +/** The acceptor refusing a token, or revoking it after an AUTH_OK. Encodes its own type. */ export class AuthError { + static id = 1; + /** A code from the session error registry. */ code: number; reason: string; @@ -132,7 +136,7 @@ export class AuthError { async encode(w: Writer, version: Version): Promise { guardAuth(version); - return Message.encode(w, this.#encode.bind(this)); + return Message.encode(w, this.#encode.bind(this), AuthError.id); } static async decode(r: Reader, version: Version): Promise { @@ -144,24 +148,15 @@ export class AuthError { /** A message the acceptor writes on the Auth Stream. */ export type AuthReply = AuthOk | AuthError; -const AUTH_OK = 0; -const AUTH_ERROR = 1; - -/** Encode an AUTH_OK or AUTH_ERROR behind its type. */ -export async function encodeAuthReply(w: Writer, reply: AuthReply, version: Version): Promise { - await w.u53(reply instanceof AuthOk ? AUTH_OK : AUTH_ERROR); - await reply.encode(w, version); -} - /** Decode the next AUTH_OK or AUTH_ERROR, or undefined once the stream ends. */ export async function decodeAuthReplyMaybe(r: Reader, version: Version): Promise { guardAuth(version); if (await r.done()) return undefined; const typ = await r.u53(); switch (typ) { - case AUTH_OK: + case AuthOk.id: return AuthOk.decode(r, version); - case AUTH_ERROR: + case AuthError.id: return AuthError.decode(r, version); default: throw new Error(`unknown auth reply type: ${typ}`); @@ -201,11 +196,11 @@ export class LiteAuthWire implements AuthWire { } async grant(stream: Stream, grant: WireGrant): Promise { - await encodeAuthReply(stream.writer, new AuthOk(grant.publish, grant.subscribe, grant.expires), this.#version); + await new AuthOk(grant.publish, grant.subscribe, grant.expires).encode(stream.writer, this.#version); } async refuse(stream: Stream, code: SessionCode, reason: string): Promise { - await encodeAuthReply(stream.writer, new AuthError(code, reason), this.#version); + await new AuthError(code, reason).encode(stream.writer, this.#version); } /** Resetting reads as unsupported to the presenter, the same as a peer that predates AUTH. */ diff --git a/js/net/src/lite/message.ts b/js/net/src/lite/message.ts index eb289d6bf7..1643e6cd9e 100644 --- a/js/net/src/lite/message.ts +++ b/js/net/src/lite/message.ts @@ -1,6 +1,11 @@ import { Reader, Writer } from "../stream.ts"; -export async function encode(writer: Writer, f: (w: Writer) => Promise) { +/** The largest message body a peer reads, matching the reader's ceiling and Rust. */ +export const MAX_MESSAGE_SIZE = 64 * 1024 * 1024; + +// Encodes a message with a varint size prefix. A type `id` is written only once the body +// fits, so a message the peer would refuse leaves nothing on the stream. +export async function encode(writer: Writer, f: (w: Writer) => Promise, id?: number) { let scratch = new Uint8Array(); const temp = new Writer( @@ -33,6 +38,11 @@ export async function encode(writer: Writer, f: (w: Writer) => Promise) { temp.close(); await temp.closed; + if (scratch.byteLength > MAX_MESSAGE_SIZE) { + throw new Error(`message too large: ${scratch.byteLength} bytes (max ${MAX_MESSAGE_SIZE})`); + } + + if (id !== undefined) await writer.u53(id); await writer.u53(scratch.byteLength); if (scratch.byteLength > 0) { await writer.write(scratch); From acc9a3d9f02bf1cc94726a6b7fa9404618f02dcb Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 13:10:15 -0700 Subject: [PATCH 12/53] docs(net): say what a lite auth reset means after a grant Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/lite/publisher.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 6998fa9dec..e5d3e3b65a 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -480,7 +480,8 @@ impl AuthServe { }), }; // A grant that cannot be encoded is withheld, never trimmed. Nothing is buffered, and - // the reset that follows reads as "unsupported" to the presenter. + // the reset that follows ends the token: as the first reply it reads as "unsupported", + // and after an AUTH_OK it revokes the earlier grant. stream.writer.buffer(&msg).map_err(|err| { tracing::debug!(%err, "auth reply cannot be encoded; refusing the token"); Error::Unsupported From 240a2b890ab1f2791acbb96db2c702a9a21af86f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 13:31:00 -0700 Subject: [PATCH 13/53] chore(quest): claim quest/m1/auth/error-codes Co-Authored-By: Claude Opus 5.5 From 4186b504eae5b19a4bc8d934a2c26567f4635b47 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 15:05:29 -0700 Subject: [PATCH 14/53] quest(auth/narrowing): claim Co-Authored-By: Claude Opus 5.5 From c63b1d169f4a4b01a6afc9cc52bfe147230b5264 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 15:05:42 -0700 Subject: [PATCH 15/53] fix(net): make every AUTH stream ending exact on both sides A lite AUTH_ERROR code past u32 closes the session with PROTOCOL_VIOLATION instead of saturating. JS AuthSession.close() recomputes the union, and a JS presenter closes its side when the acceptor FINs. A dropped serve task settles Issued::closed with the session's error. Co-Authored-By: Claude Opus 5.5 --- drafts/draft-lcurley-moq-lite.md | 1 + js/net/src/auth.test.ts | 30 +++++++++++++ js/net/src/auth_session.ts | 4 +- quest/m1/auth/README.md | 1 - quest/m1/auth/error-codes.md | 37 ---------------- rs/moq-net/src/auth.rs | 32 ++++++++++++-- rs/moq-net/src/ietf/auth.rs | 9 ++-- rs/moq-net/src/ietf/session.rs | 6 +++ rs/moq-net/src/lite/publisher.rs | 17 ++++---- rs/moq-net/src/lite/session.rs | 74 ++++++++++++++++++++++++++++++-- rs/moq-net/tests/auth.rs | 33 ++++++++++++-- 11 files changed, 182 insertions(+), 62 deletions(-) delete mode 100644 quest/m1/auth/error-codes.md diff --git a/drafts/draft-lcurley-moq-lite.md b/drafts/draft-lcurley-moq-lite.md index 2b4662f099..6c3ea0fbaf 100644 --- a/drafts/draft-lcurley-moq-lite.md +++ b/drafts/draft-lcurley-moq-lite.md @@ -1372,6 +1372,7 @@ AUTH_ERROR Message { **Error Code**: A code from the [session error registry](#session-error-codes), such as UNAUTHORIZED. +A code that does not fit in 32 bits is a PROTOCOL_VIOLATION that closes the session. **Reason Phrase**: A human-readable reason, at most 8,192 bytes. diff --git a/js/net/src/auth.test.ts b/js/net/src/auth.test.ts index dd1a947897..a2f0123fe7 100644 --- a/js/net/src/auth.test.ts +++ b/js/net/src/auth.test.ts @@ -158,6 +158,36 @@ describe.each([Lite.ALPN_06, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", (pro server.close(); }); + test("closing the session drops the grant", async () => { + const { client, server } = await connect({ publish: new OriginProducer(), protocol }); + await waitFor(client.auth.grant, (g) => g !== undefined && g.publish.size > 0); + + // Checked before any stream notices the transport closing. + client.close(); + const closed = client.auth.grant.peek(); + expect(closed?.publish.size).toBe(0); + expect(closed?.subscribe.size).toBe(0); + server.close(); + }); + + test("an acceptor that ends a grant sees its stream close", async () => { + const { client, server } = await connect({ publish: new OriginProducer(), protocol }); + const requests = server.auth.requests(); + const request = await requests.next(); + const issued = request?.accept(grant(["a"], [])); + await waitFor(client.auth.grant, (g) => g !== undefined && g.publish.size > 0); + + issued?.close(); + let timer: ReturnType | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve("timeout"), 1000); + }); + expect(await Promise.race([issued?.closed, timeout])).toBeNull(); + clearTimeout(timer); + client.close(); + server.close(); + }); + test("a refused setup token grants nothing rather than everything", async () => { const { client, server } = await connect({ publish: new OriginProducer(), protocol }); const requests = server.auth.requests(); diff --git a/js/net/src/auth_session.ts b/js/net/src/auth_session.ts index 5f5f16705c..ac9c3824c6 100644 --- a/js/net/src/auth_session.ts +++ b/js/net/src/auth_session.ts @@ -324,6 +324,7 @@ export class AuthSession implements AuthApi { this.#closed = true; for (const token of this.#tokens) token.end(new Error("session closed")); this.#tokens.clear(); + this.#recompute(); if (this.#acceptor instanceof RequestQueue) this.#acceptor.close(); } @@ -346,8 +347,9 @@ export class AuthSession implements AuthApi { const reply = await wire.read(stream); if (!reply) { // The peer ended the grant without revoking it, or closed without ever - // answering. + // answering. Close our side too, which is what settles its Issued.closed. result = token.isAnswered ? null : new Unsupported(); + stream.close(); break; } if ("grant" in reply) { diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index b930917037..adc08e4670 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -90,7 +90,6 @@ existing lite-06 ALPN. ## Required - [AUTH_OK preflight](/quest/m1/auth/auth-ok-preflight.md) - an unencodable IETF grant answers NOT_SUPPORTED with nothing written, as JS already does -- [AUTH endings](/quest/m1/auth/error-codes.md) - an out-of-range AUTH_ERROR code is refused, and both sides settle and recompute grants when a stream ends - [Origin narrowing](/quest/m1/auth/narrowing.md) - a live grant narrows in place: subscriptions outside it reset, publishes outside it abort, and relay revalidation stops closing the session diff --git a/quest/m1/auth/error-codes.md b/quest/m1/auth/error-codes.md deleted file mode 100644 index 71881c1f51..0000000000 --- a/quest/m1/auth/error-codes.md +++ /dev/null @@ -1,37 +0,0 @@ -# [S] AUTH endings are exact on both sides - -## Goal - -The loose ends Codex left on [#4062](https://github.com/moq-dev/moq/pull/4062) -are closed, so every way an AUTH stream ends reports what actually happened: - -- A lite AUTH_ERROR whose code does not fit a `u32` is a protocol violation. - Today `rs/moq-net/src/lite/session.rs` saturates it with - `u32::try_from(refused.code).unwrap_or(u32::MAX)`, so distinct peer codes - collapse into one and the app sees a code the peer never sent. -- JS `AuthSession.close()` in `js/net/src/auth_session.ts` clears its tokens - but never recomputes the union, so a retained `auth.grant` keeps reporting - a live grant after the session closed. Rust already clears it. -- A JS presenter whose acceptor ends the grant with a clean FIN exits the read - loop without closing its own write half, unlike the AUTH_ERROR branch, so - the acceptor's `Issued.closed` stays pending until the session ends. -- A Rust `Issued::closed()` never resolves if the session drops the task - serving that token (`AuthServe` in the lite publisher, `Serve` in - `ietf/auth.rs`): only the task's own completion records `issue.peer`. - -## Plan - -- Fail loud on the unrepresentable code rather than widen the error type: - the codes AUTH_ERROR carries are session codes, which are `u32` everywhere - else. A malformed AUTH_ERROR closes the session with PROTOCOL_VIOLATION, - the same rule as a malformed AUTH_OK, rather than ending only the token. -- Settle a dropped serve task from a drop path (a guard that records the - session's error on `Issue` and wakes waiters), so no exit path can forget - it. -- One regression test per item, each failing without its fix. - -The fifth deferred item, a grant recheck after async origin resolution, -belongs to [Origin narrowing](/quest/m1/auth/narrowing.md) with the other -watcher races. - -Public API: none. Wire: none. diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 7ea4acf06a..cd02cd17f8 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -606,10 +606,34 @@ pub(crate) struct Issue { pub peer: Option, } -impl Issue { - /// A fresh stream's shared state. - pub(crate) fn shared() -> kio::Shared { - kio::Shared::default() +/// The serving task's hold on one peer token's [`Issue`]. +/// +/// Dropping it settles [`Issued::closed`] with the session's error, unless the task +/// already recorded why the stream ended, so no exit path leaves the acceptor waiting. +pub(crate) struct Serving { + pub issue: kio::Shared, + handle: Handle, +} + +impl Serving { + /// A fresh stream's shared state, settled by the session `handle` if the task drops. + pub(crate) fn new(handle: Handle) -> Self { + Self { + issue: kio::Shared::default(), + handle, + } + } + + /// Record why the stream ended on the peer's side; the first reason wins. + pub(crate) fn end(&self, err: Error) { + self.issue.lock().peer.get_or_insert(err); + } +} + +impl Drop for Serving { + fn drop(&mut self) { + let err = self.handle.state.read().closed().unwrap_or(Error::Cancel); + self.end(err); } } diff --git a/rs/moq-net/src/ietf/auth.rs b/rs/moq-net/src/ietf/auth.rs index 76214462b5..c72ad14a34 100644 --- a/rs/moq-net/src/ietf/auth.rs +++ b/rs/moq-net/src/ietf/auth.rs @@ -13,7 +13,7 @@ use std::time::Duration; use bytes::Bytes; -use crate::auth::{Grant, Handle, Issue, Reply, Request}; +use crate::auth::{Grant, Handle, Issue, Reply, Request, Serving}; use crate::coding::{Decode, DecodeError, Encode, EncodeError, Sizer, Stream}; use crate::{Error, Path, Pattern, Patterns, SessionError}; @@ -394,7 +394,8 @@ impl Serve { msg: Auth, version: Version, ) { - let issue = Issue::shared(); + let serving = Serving::new(self.handle.clone()); + let issue = &serving.issue; match self.handle.acceptor() { Some(requests) => { // A closed queue hands the request back, and dropping it refuses the token. @@ -415,7 +416,7 @@ impl Serve { None => issue.lock().outbox.push_back(Reply::Grant(self.peer_grant)), } - let err = match serve_issue(&self.runtime, &issue, &mut stream, version).await { + let err = match serve_issue(&self.runtime, issue, &mut stream, version).await { Ok(()) => Error::Cancel, Err(err) => { match &err { @@ -427,7 +428,7 @@ impl Serve { err } }; - issue.lock().peer.get_or_insert(err); + serving.end(err); } } diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index 496f954165..b641463874 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -466,6 +466,12 @@ where Poll::Pending }) .await; + // Before this arm's Auth serve tasks drop, so each settles with the + // session's error rather than a bare cancel. + auth.close(match &res { + Ok(()) => Error::Cancel, + Err(err) => err.clone(), + }); if let Err(err) = &res { // Every track this session was receiving ends with its error. subscriber.abort(err); diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 321af8bb81..46313a747c 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -350,7 +350,7 @@ struct AuthServe { stream: Option>, /// Shared with the app's [`crate::auth::Request`] / [`crate::auth::Issued`], or /// filled in by the default acceptor. - issue: Option>, + serving: Option, /// Our side is finished: FIN sent, waiting for the acknowledgement. finished: bool, } @@ -364,7 +364,7 @@ impl AuthServe { Ok(Self { shared, stream: Some(stream), - issue: None, + serving: None, finished: false, }) } @@ -386,8 +386,8 @@ impl AuthServe { err } }; - if let Some(issue) = &self.issue { - issue.lock().peer.get_or_insert(err); + if let Some(serving) = &self.serving { + serving.end(err); } Poll::Ready(Ok(())) } @@ -396,11 +396,12 @@ impl AuthServe { let mut cx = Context::from_waker(waiter.waker()); let stream = self.stream.as_mut().expect("stream present"); - let issue = match &self.issue { - Some(issue) => issue.clone(), + let issue = match &self.serving { + Some(serving) => serving.issue.clone(), None => { let msg = ready!(stream.reader.poll_decode::(&mut cx))?; - let issue = crate::auth::Issue::shared(); + let serving = crate::auth::Serving::new(self.shared.auth.clone()); + let issue = serving.issue.clone(); match self.shared.auth.acceptor() { Some(requests) => { // A closed queue hands the request back, and dropping it refuses @@ -416,7 +417,7 @@ impl AuthServe { issue.lock().outbox.push_back(crate::auth::Reply::Grant(grant)); } } - self.issue = Some(issue.clone()); + self.serving = Some(serving); issue } }; diff --git a/rs/moq-net/src/lite/session.rs b/rs/moq-net/src/lite/session.rs index 85b5e33bba..4a7a833b80 100644 --- a/rs/moq-net/src/lite/session.rs +++ b/rs/moq-net/src/lite/session.rs @@ -230,6 +230,7 @@ where handle: auth.clone(), going_away: goaway.going_away.clone(), tokens: kio::Tasks::new(), + violation: Default::default(), started: false, _setup: setup_token, }, @@ -306,8 +307,10 @@ where fn poll_protocol(&mut self, waiter: &kio::Waiter) -> Poll> { let mut cx = Context::from_waker(waiter.waker()); - // Presenting tokens never ends the session. - self.auth.poll(waiter); + // Presenting tokens ends the session only when the peer breaks the protocol. + if let Poll::Ready(err) = self.auth.poll(waiter) { + return Poll::Ready(Err(err)); + } // The send-side machines never end the session; completion just retires them. if let Some(setup) = &mut self.setup @@ -351,6 +354,8 @@ struct Present { handle: crate::auth::Handle, going_away: crate::goaway::GoingAway, tokens: kio::Tasks>, + /// Set by a token whose peer broke the protocol, which ends the session. + violation: kio::Shared>, /// Whether the first poll decided who answers the peer's tokens. started: bool, /// The connection's own credential, held for the life of the session. @@ -358,7 +363,8 @@ struct Present { } impl Present { - fn poll(&mut self, waiter: &kio::Waiter) { + /// Resolve only when a token's peer broke the protocol. + fn poll(&mut self, waiter: &kio::Waiter) -> Poll { if !self.started { // Decided once, before any AUTH stream can be accepted: the app took the // requests before running the driver, or the session answers itself. @@ -372,11 +378,17 @@ impl Present { version: self.version, handle: self.handle.clone(), going_away: self.going_away.clone(), + violation: self.violation.clone(), id, state: PresentState::Open { token }, }); } let _ = self.tokens.poll(waiter); + // A token sets it from inside the poll above, so nothing needs waking. + match self.violation.lock().take() { + Some(err) => Poll::Ready(err), + None => Poll::Pending, + } } } @@ -388,6 +400,7 @@ struct PresentToken { version: Version, handle: crate::auth::Handle, going_away: crate::goaway::GoingAway, + violation: kio::Shared>, id: u64, state: PresentState, } @@ -418,6 +431,9 @@ impl kio::Task for PresentToken { } err => tracing::warn!(%err, "auth token ended"), } + if let Error::ProtocolViolation = err { + self.violation.lock().get_or_insert(err.clone()); + } self.handle.ended(self.id, err); Poll::Ready(()) } @@ -487,7 +503,10 @@ impl PresentToken { ); } super::AuthReply::Error(refused) => { - let code = u32::try_from(refused.code).unwrap_or(u32::MAX); + // Session codes are u32 everywhere else, so a wider one is malformed. + let Ok(code) = u32::try_from(refused.code) else { + return Poll::Ready(Error::ProtocolViolation); + }; let err = Error::Session(crate::SessionError::from_code(code)); tracing::warn!(%err, reason = %refused.reason, "auth token refused"); self.handle.refused(self.id); @@ -735,3 +754,50 @@ impl SendGoaway { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::coding::Encode; + use crate::lite::test_transport::ScriptedSession; + + /// An AUTH_ERROR code the session registry cannot hold is the peer breaking the + /// protocol: it closes the session rather than surfacing a code the peer never sent. + #[tokio::test(start_paused = true)] + async fn an_auth_error_code_past_u32_closes_the_session() { + let refused = crate::lite::AuthReply::Error(crate::lite::AuthError { + code: u64::from(u32::MAX) + 1, + reason: String::new(), + }); + let mut script = Vec::new(); + refused.encode(&mut script, Version::Lite06).unwrap(); + // The setup token's AUTH stream is the first one the session opens. + let transport = ScriptedSession::per_stream(vec![script]); + let log = transport.log.clone(); + + let start = start(Config { + runtime: crate::time::Clock::tokio(), + client: true, + session: transport, + setup_stream: None, + publish: None, + subscribe: None, + peer_hop: None, + version: Version::Lite06, + our_setup: Setup::default(), + peer_setup: None, + auth: crate::auth::Handle::new(true), + }) + .unwrap(); + let mut driver = start.driver; + + let res = tokio::time::timeout( + std::time::Duration::from_secs(10), + kio::wait(|waiter| driver.poll(waiter)), + ) + .await + .expect("the session outlived a malformed AUTH_ERROR"); + assert!(matches!(res, Err(Error::ProtocolViolation)), "{res:?}"); + assert_eq!(log.closes()[0].0, SessionError::ProtocolViolation.to_code()); + } +} diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs index 2cd2592cd4..f5879844bd 100644 --- a/rs/moq-net/tests/auth.rs +++ b/rs/moq-net/tests/auth.rs @@ -50,6 +50,7 @@ cases!( a_refused_setup_token_grants_nothing, dropping_the_requests_refuses_queued_tokens, a_closed_session_holds_no_grant, + an_issued_grant_ends_with_its_session, a_reset_auth_stream_reports_unsupported, a_revoked_grant_cancels_its_subscriptions, nothing_outside_the_grant_reaches_the_peer, @@ -163,6 +164,8 @@ struct Pair { server: Session, client_transport: MockSession, requests: Option, + /// Aborting it drops the server's driver without letting it finish. + server_driver: tokio::task::AbortHandle, } async fn connect(opts: Options) -> Pair { @@ -201,16 +204,17 @@ async fn connect(opts: Options) -> Pair { .server_requests .then(|| handshake.auth().requests().expect("requests available before ok()")); let (session, driver) = handshake.ok().await.expect("server accept"); - tokio::spawn(run(driver)); - (session, requests) + let driver = tokio::spawn(run(driver)).abort_handle(); + (session, requests, driver) }; - let (client, (server, requests)) = tokio::join!(client_fut, server_fut); + let (client, (server, requests, server_driver)) = tokio::join!(client_fut, server_fut); Pair { client, server, client_transport: observe, requests, + server_driver, } } @@ -594,6 +598,29 @@ async fn a_closed_session_holds_no_grant(version: &'static str) { .expect("timed out"); } +/// A grant this side issued settles once its session ends, even when the task serving +/// its stream is dropped rather than run to completion. +async fn an_issued_grant_ends_with_its_session(version: &'static str) { + within(async { + let mut pair = connect(Options { + version: Some(version), + client_publish: Some(produce_origin(2)), + server_requests: true, + ..Default::default() + }) + .await; + let mut requests = pair.requests.take().unwrap(); + let issued = requests.next().await.expect("setup token").accept(Grant::default()); + granted(&pair.client).await; + + pair.server_driver.abort(); + let err = issued.closed().await; + assert!(matches!(err, Error::Cancel), "{err:?}"); + }) + .await + .expect("timed out"); +} + /// A peer that takes no tokens in band says so (lite resets the stream, moq-transport /// answers NOT_SUPPORTED), which reads as unsupported rather than a refusal: the same as /// a peer that predates AUTH. From c82f9c7af80d1ebf2b98aaf9ddaafa2b966ae212 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 15:43:16 -0700 Subject: [PATCH 16/53] quest(auth): AUTH protocol violations close the session everywhere Co-Authored-By: Claude Opus 5.5 --- quest/m1/auth/README.md | 1 + quest/m1/auth/violations.md | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 quest/m1/auth/violations.md diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index b9d2c27a09..27d85b944a 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -90,6 +90,7 @@ existing lite-06 ALPN. ## Required - [AUTH endings](/quest/m1/auth/error-codes.md) - an out-of-range AUTH_ERROR code is refused, and both sides settle and recompute grants when a stream ends +- [AUTH violations](/quest/m1/auth/violations.md) - every AUTH protocol violation closes the session in Rust and JS, lite and IETF - [Origin narrowing](/quest/m1/auth/narrowing.md) - a live grant narrows in place: subscriptions outside it reset, publishes outside it abort, and relay revalidation stops closing the session diff --git a/quest/m1/auth/violations.md b/quest/m1/auth/violations.md new file mode 100644 index 0000000000..c07bdf9f1a --- /dev/null +++ b/quest/m1/auth/violations.md @@ -0,0 +1,31 @@ +# [S] AUTH protocol violations close the session everywhere + +## Goal + +Every AUTH stream message that the lite or IETF drafts call a +PROTOCOL_VIOLATION closes the session in Rust and `@moq/net` alike, instead +of ending only its token. Examples: an AUTH_ERROR code past `u32`, an +out-of-range AUTH_OK expiry, or an AUTH_OK or AUTH_ERROR that fails to decode, +such as a bad pattern. + +## Plan + +[AUTH endings](/quest/m1/auth/error-codes.md) (#4550) closes the session only +on an explicit `ProtocolViolation` in Rust lite, and left three gaps: + +- JS reports the exact oversized code but doesn't close the session. Let the + JS connection close on an `AuthSession` protocol violation. +- An out-of-range IETF AUTH_OK expiry still ends only its token. +- An AUTH_OK or AUTH_ERROR that fails to decode ends only the token, like + every other lite stream, although the draft calls a bad pattern a + PROTOCOL_VIOLATION. + +Close the session on each, in both languages and on both protocols, with one +regression test per case, and check the lite and IETF draft text agrees. Run +`just test interop --all`. + +Public API: none. Wire: none beyond draft wording. + +## Related + +- [AUTH endings](/quest/m1/auth/error-codes.md) - the Rust lite case this generalizes From 7e6dd2f451b7539618f2888e10d4988ba83b612c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 15:45:31 -0700 Subject: [PATCH 17/53] feat(net): narrow a live session's grant in place Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/auth.rs | 245 +++++++++++++++++++++++++----- rs/moq-net/src/client.rs | 11 +- rs/moq-net/src/ietf/auth.rs | 15 +- rs/moq-net/src/ietf/publisher.rs | 26 ++-- rs/moq-net/src/ietf/session.rs | 7 +- rs/moq-net/src/ietf/subscriber.rs | 41 ++++- rs/moq-net/src/lite/publisher.rs | 135 +++++++++------- rs/moq-net/src/lite/subscriber.rs | 177 ++++++++++++++++++--- rs/moq-net/tests/auth.rs | 232 ++++++++++++++++++++++++++++ 9 files changed, 752 insertions(+), 137 deletions(-) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 7ea4acf06a..c12b0f3a6a 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -10,7 +10,9 @@ //! The other direction, answering the peer's tokens, is automatic: the session //! grants what its own origin handles allow. An application that verifies tokens //! itself takes [`requests`](Handle::requests) before running the session's -//! driver, and then answers every token the peer presents. +//! driver, and then answers every token the peer presents. Either way, +//! [`narrow`](Handle::narrow) takes access away from the peer on a live session, +//! on every version, whether or not it speaks AUTH. //! //! moq-transport draft-17+ carries the same exchange when both sides negotiate the //! MoQ Auth extension. Older versions, and peers that do not negotiate it, carry no @@ -62,22 +64,37 @@ impl Grant { } } - fn patterns(&self, direction: Direction) -> &Patterns { - match direction { - Direction::Publish => &self.publish, - Direction::Subscribe => &self.subscribe, - } - } - /// Fold `other` into this grant: the paths either allows, lapsing at the /// earlier expiry, which is when the union next shrinks. fn union(&mut self, other: &Self) { self.publish.extend(other.publish.iter().cloned()); self.subscribe.extend(other.subscribe.iter().cloned()); - self.expires = match (self.expires, other.expires) { - (Some(a), Some(b)) => Some(a.min(b)), - (a, b) => a.or(b), + self.expires = earliest(self.expires, other.expires); + } + + /// The paths both grants allow, lapsing at the earlier expiry. + /// + /// Fails closed: an intersection too large to hold grants nothing, never more. + fn intersect(&self, other: &Self) -> Self { + let both = |a: &Patterns, b: &Patterns| { + a.intersect(b).unwrap_or_else(|err| { + tracing::warn!(%err, "grant intersection too large; granting nothing"); + Patterns::new() + }) }; + Self { + publish: both(&self.publish, &other.publish), + subscribe: both(&self.subscribe, &other.subscribe), + expires: earliest(self.expires, other.expires), + } + } +} + +/// The earlier of two optional deadlines, where `None` is never. +fn earliest(a: Option, b: Option) -> Option { + match (a, b) { + (Some(a), Some(b)) => Some(a.min(b)), + (a, b) => a.or(b), } } @@ -97,8 +114,11 @@ pub(crate) struct State { union: Option, /// The peer replied to some token, so the union is known even when empty. replied: bool, - /// Bumped whenever the union changes, so the session's per-stream gates can - /// skip re-matching paths on every wakeup. + /// The most this side lets the peer do, in the peer's terms: `None` until + /// [`Handle::narrow`], then only ever narrower. + ceiling: Option, + /// Bumped whenever the union or the ceiling changes, so the session's per-stream + /// gates can skip re-matching paths on every wakeup. epoch: u64, acceptor: Acceptor, } @@ -150,6 +170,71 @@ impl State { fn closed(&self) -> Option { self.closed.clone() } + + /// The union's and the ceiling's patterns for `direction`, each `None` while + /// unrestricted. + fn parts(&self, direction: Direction) -> (Option<&Patterns>, Option<&Patterns>) { + // The peer's grant names what we may do; the ceiling names what the peer may, + // so what we send is what the peer may subscribe to, and the other way around. + match direction { + Direction::Publish => ( + self.union.as_ref().map(|union| &union.publish), + self.ceiling.as_ref().map(|ceiling| &ceiling.subscribe), + ), + Direction::Subscribe => ( + self.union.as_ref().map(|union| &union.subscribe), + self.ceiling.as_ref().map(|ceiling| &ceiling.publish), + ), + } + } + + /// What `direction` allows right now. + fn permit(&self, direction: Direction) -> Permit { + let (granted, ceiling) = self.parts(direction); + Permit { + granted: granted.cloned(), + ceiling: ceiling.cloned(), + } + } + + /// Whether `direction` allows `path` right now, without copying the patterns. + fn allows(&self, direction: Direction, path: &str) -> bool { + let (granted, ceiling) = self.parts(direction); + granted.is_none_or(|granted| granted.matches(path)) && ceiling.is_none_or(|ceiling| ceiling.matches(path)) + } +} + +/// What one direction of a session allows: the grant the peer gave this side (the +/// union of its tokens) and the ceiling this side narrowed the peer to, each `None` +/// while unrestricted. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct Permit { + granted: Option, + ceiling: Option, +} + +impl Permit { + /// Whether both allow `path`. + pub(crate) fn matches(&self, path: &str) -> bool { + self.granted.as_ref().is_none_or(|granted| granted.matches(path)) && self.within_ceiling(path) + } + + /// Whether the ceiling alone allows `path`: for what the peer offers us, which our + /// own grant does not decide. + pub(crate) fn within_ceiling(&self, path: &str) -> bool { + self.ceiling.as_ref().is_none_or(|ceiling| ceiling.matches(path)) + } + + /// Whether this allows everything `other` does, judged part by part, so a `false` + /// may be spurious but a `true` never is. + pub(crate) fn covers(&self, other: &Self) -> bool { + let covers = |this: &Option, other: &Option| match (this, other) { + (None, _) => true, + (Some(_), None) => false, + (Some(this), Some(other)) => this.covers(other), + }; + covers(&self.granted, &other.granted) && covers(&self.ceiling, &other.ceiling) + } } /// The session's auth handle, returned by [`Session::auth`](crate::Session::auth). @@ -354,26 +439,72 @@ impl Handle { Poll::Ready(()) } + /// Narrow what the peer may do on this session to at most `grant`, in the + /// session's own paths. + /// + /// A narrowing only ever takes away: the session keeps what both its current + /// ceiling and `grant` allow, so a wider grant changes nothing. What falls outside + /// ends at once, with [`Error::Unauthorized`] where it has a reader: announcements + /// to the peer retract, its new requests are refused, its subscriptions reset, and + /// the broadcasts it published abort. The rest of the session carries on. It works + /// on every version, since the session enforces it without the peer's help. + /// + /// When the session answers the peer's connection credential itself, the peer is + /// told the narrowed grant, `expires` included. An application answering tokens + /// through [`requests`](Self::requests) updates its own [`Issued`] grants. + pub fn narrow(&self, grant: &Grant) { + let mut state = self.state.lock(); + let ceiling = match &state.ceiling { + Some(ceiling) => ceiling.intersect(grant), + None => grant.clone(), + }; + if state.ceiling.as_ref() != Some(&ceiling) { + state.ceiling = Some(ceiling); + state.epoch += 1; + } + } + /// The union once it changed since `epoch` last saw it, advancing `epoch`. It /// only ever changes into `Some`: no answer yet is the initial state. pub(crate) fn poll_union(&self, epoch: &mut u64, waiter: &kio::Waiter) -> Poll> { - let seen = *epoch; - let state = ready_or!(self.state.poll(waiter, |state| match state.epoch != seen { - true => Poll::Ready(()), - false => Poll::Pending, - })); + let state = ready_or!(self.poll_epoch(*epoch, waiter)); *epoch = state.epoch; Poll::Ready(state.union.clone()) } - /// Whether the union allows `path` right now. A union that is still `None` (no - /// answer yet, or a version without AUTH) allows everything. + /// What `direction` allows once it may have changed since `epoch` last saw it, + /// advancing `epoch`. + pub(crate) fn poll_permit(&self, direction: Direction, epoch: &mut u64, waiter: &kio::Waiter) -> Poll { + let state = ready_or!(self.poll_epoch(*epoch, waiter)); + *epoch = state.epoch; + Poll::Ready(state.permit(direction)) + } + + /// The ceiling once it may have changed since `epoch` last saw it, advancing `epoch`. + pub(crate) fn poll_ceiling(&self, epoch: &mut u64, waiter: &kio::Waiter) -> Poll> { + let state = ready_or!(self.poll_epoch(*epoch, waiter)); + *epoch = state.epoch; + Poll::Ready(state.ceiling.clone()) + } + + fn poll_epoch(&self, seen: u64, waiter: &kio::Waiter) -> Poll> { + self.state.poll(waiter, |state| match state.epoch != seen { + true => Poll::Ready(()), + false => Poll::Pending, + }) + } + + /// Whether `direction` allows `path` right now: the union (allowing everything + /// while it is `None`, before an answer or on a version without AUTH) and the + /// ceiling both. pub(crate) fn allows(&self, direction: Direction, path: &str) -> bool { - let state = self.state.read(); - state - .union - .as_ref() - .is_none_or(|union| union.patterns(direction).matches(path)) + self.state.read().allows(direction, path) + } + + /// Whether the ceiling alone allows `direction` at `path`: for a route the peer + /// offers us, which our own grant does not decide. + pub(crate) fn within_ceiling(&self, direction: Direction, path: &str) -> bool { + self.state.read().parts(direction).1.is_none_or(|ceiling| ceiling.matches(path)) } /// The peer turned out not to negotiate AUTH: fail every token as unsupported and @@ -707,18 +838,21 @@ impl Drop for Issued { } } -/// Which half of a grant a [`Gate`] checks. +/// Which way media flows, from this side's view. #[derive(Clone, Copy)] pub(crate) enum Direction { + /// This side sends to the peer: our grant's `publish`, and the ceiling's `subscribe`. Publish, + /// The peer sends to this side: our grant's `subscribe`, and the ceiling's `publish`. Subscribe, } -/// Watches whether the union still allows one path, for a long-lived stream that -/// must end once it does not. +/// Watches whether the session still allows one path, for a request that must end +/// once it does not. /// -/// Cheap to poll on every wakeup: the path is only re-matched when the union -/// changes. +/// Create it at the request's first check and hold it to the end, so a change +/// landing in between is never missed. Cheap to poll on every wakeup: the path is +/// only re-matched when the union or the ceiling changes. pub(crate) struct Gate { handle: Handle, path: crate::PathOwned, @@ -736,20 +870,59 @@ impl Gate { } } - /// Resolve once the union no longer allows the path. A union that is still - /// `None` (no answer yet, or a version without AUTH) allows everything. + /// Resolve once the session no longer allows the path. A union that is still + /// `None` (no answer yet, or a version without AUTH) and a session never narrowed + /// allow everything. pub(crate) fn poll_denied(&mut self, waiter: &kio::Waiter) -> Poll<()> { loop { - let Some(union) = ready_or!(self.handle.poll_union(&mut self.epoch, waiter)) else { - continue; - }; - if !union.patterns(self.direction).matches(self.path.as_str()) { + let permit = ready_or!(self.handle.poll_permit(self.direction, &mut self.epoch, waiter)); + if !permit.matches(self.path.as_str()) { return Poll::Ready(()); } } } } +/// The default acceptor's answer to the peer's connection credential: the grant +/// the session's origin handles allow, narrowed by the ceiling as it changes. +pub(crate) struct DefaultGrant { + handle: Handle, + base: Grant, + epoch: u64, + ceiling: Option, + /// The grant last handed out, so an unrelated wakeup sends nothing. + sent: Option, +} + +impl DefaultGrant { + pub(crate) fn new(handle: Handle, base: Grant) -> Self { + let ceiling = handle.state.read().ceiling.clone(); + Self { + handle, + base, + epoch: 0, + ceiling, + sent: None, + } + } + + /// The grant to send next: the first, then each one a narrowing changes. + pub(crate) fn poll(&mut self, waiter: &kio::Waiter) -> Poll { + while let Poll::Ready(ceiling) = self.handle.poll_ceiling(&mut self.epoch, waiter) { + self.ceiling = ceiling; + } + let grant = match &self.ceiling { + Some(ceiling) => self.base.intersect(ceiling), + None => self.base.clone(), + }; + if self.sent.as_ref() == Some(&grant) { + return Poll::Pending; + } + self.sent = Some(grant.clone()); + Poll::Ready(grant) + } +} + /// The close reason naming a broadcast published outside the grant. [`Error`] carries /// no payload, so the path travels here, in the session's own terms. pub(crate) fn unauthorized_reason(path: &crate::Path) -> String { diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index b28df340f4..663e3f34b3 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -408,6 +408,8 @@ impl Client { }; let stream = stream.with_version(v); + // Draft 14-16 carry no AUTH, but the session still narrows through this handle. + let auth = crate::auth::Handle::new(false); // Draft 14-16: the path rode in the bidi SETUP above, not the uni one. let (protocol, goaway) = ietf::start(ietf::Config { runtime: runtime.clone(), @@ -423,14 +425,9 @@ impl Client { path: None, peer_setup_stream: None, peer_declared: Some(peer_declared), - auth: crate::auth::Handle::new(false), + auth: auth.clone(), })?; - ( - None, - crate::driver::Protocol::Ietf(protocol), - goaway, - crate::auth::Handle::new(false), - ) + (None, crate::driver::Protocol::Ietf(protocol), goaway, auth) } }; diff --git a/rs/moq-net/src/ietf/auth.rs b/rs/moq-net/src/ietf/auth.rs index 76214462b5..0e29661fd8 100644 --- a/rs/moq-net/src/ietf/auth.rs +++ b/rs/moq-net/src/ietf/auth.rs @@ -395,6 +395,7 @@ impl Serve { version: Version, ) { let issue = Issue::shared(); + let mut default = None; match self.handle.acceptor() { Some(requests) => { // A closed queue hands the request back, and dropping it refuses the token. @@ -412,10 +413,11 @@ impl Serve { } return; } - None => issue.lock().outbox.push_back(Reply::Grant(self.peer_grant)), + // Re-sent whenever a narrowing changes it. + None => default = Some(crate::auth::DefaultGrant::new(self.handle.clone(), self.peer_grant)), } - let err = match serve_issue(&self.runtime, &issue, &mut stream, version).await { + let err = match serve_issue(&self.runtime, &issue, default.as_mut(), &mut stream, version).await { Ok(()) => Error::Cancel, Err(err) => { match &err { @@ -431,10 +433,12 @@ impl Serve { } } -/// Write the acceptor's replies until either side ends the token. +/// Write the acceptor's replies until either side ends the token. `default` feeds +/// the default acceptor's grant into the outbox as it changes. async fn serve_issue( runtime: &crate::time::Clock, issue: &kio::Shared, + mut default: Option<&mut crate::auth::DefaultGrant>, stream: &mut Stream, version: Version, ) -> Result<(), Error> { @@ -449,6 +453,11 @@ async fn serve_issue( if let Poll::Ready(res) = stream.reader.poll_closed(&mut cx) { return Poll::Ready(Next::Withdrawn(res)); } + if let Some(default) = default.as_deref_mut() + && let Poll::Ready(grant) = default.poll(waiter) + { + issue.lock().outbox.push_back(Reply::Grant(grant)); + } match issue.poll(waiter, |issue| match issue.outbox.is_empty() && !issue.done { true => Poll::Pending, false => Poll::Ready(()), diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 56349b1650..9a28ed8f54 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -201,9 +201,9 @@ struct Namespaces { /// The open PUBLISH_NAMESPACE request carrying each advertised namespace. Empty when /// the entries ride a SUBSCRIBE_NAMESPACE stream inline. requests: HashMap>, - /// What our grant lets us publish (MoQ Auth), `None` while unknown. - permit: Option, - /// The grant union's epoch last applied to `permit`. + /// What we may advertise: our grant (MoQ Auth) and the ceiling on the peer. + permit: crate::auth::Permit, + /// The auth epoch last applied to `permit`. epoch: u64, } @@ -214,14 +214,14 @@ impl Namespaces { target, watched: HashMap::new(), requests: HashMap::new(), - permit: None, + permit: Default::default(), epoch: 0, } } - /// Whether our grant lets us advertise `path`. An unknown grant allows everything. + /// Whether we may advertise `path`. An unknown grant allows everything. fn permitted(&self, path: &crate::Path) -> bool { - self.permit.as_ref().is_none_or(|permit| permit.matches(path.as_str())) + self.permit.matches(path.as_str()) } } @@ -233,8 +233,8 @@ enum NamespaceEvent { Update(Option), /// The retry sleep fired: re-offer whatever the peer should be holding and isn't. Retry, - /// Our grant changed (MoQ Auth): re-check every namespace against it. - Regrant(Option), + /// Our grant (MoQ Auth) or the ceiling changed: re-check every namespace against it. + Regrant(crate::auth::Permit), } #[derive(Clone)] @@ -1930,8 +1930,10 @@ where } // A grant change applies before the next update, so a namespace it no // longer covers is withdrawn rather than re-sent. - if let Poll::Ready(union) = self.auth.poll_union(epoch, waiter) { - return Poll::Ready(NamespaceEvent::Regrant(union)); + if let Poll::Ready(permit) = + self.auth.poll_permit(crate::auth::Direction::Publish, epoch, waiter) + { + return Poll::Ready(NamespaceEvent::Regrant(permit)); } if let Poll::Ready(update) = announced.poll_next(waiter) { return Poll::Ready(NamespaceEvent::Update(update)); @@ -1965,8 +1967,8 @@ where self.sync_namespace(&mut ns, &suffix, &path).await?; } } - NamespaceEvent::Regrant(union) => { - ns.permit = union.map(|grant| grant.publish); + NamespaceEvent::Regrant(permit) => { + ns.permit = permit; let suffixes: Vec = ns.watched.keys().cloned().collect(); for suffix in suffixes { let path = prefix.join(&suffix); diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index 496f954165..9d6a963996 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -154,6 +154,7 @@ where let control = Control::new(request_id_max, client); let adapter = ControlStreamAdapter::new(session.clone(), control.clone(), version); + // No AUTH on these drafts, but a narrowing still reaches both halves. let publisher = Publisher::new( runtime.clone(), adapter.clone(), @@ -162,7 +163,8 @@ where peer_hop, peer_setup.clone(), version, - ); + ) + .with_auth(auth.clone()); let (tasks, mut task_set) = TaskSet::new(); let subscriber = Subscriber::new( runtime.clone(), @@ -176,7 +178,8 @@ where version, tasks.clone(), goaway.going_away.clone(), - ); + ) + .with_auth(auth.clone()); // GOAWAY send task: draft-14-16 carry GOAWAY on the shared control // stream. Parked on the drain trigger; races the transport close so diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index d6c492c16d..e826192b28 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -393,8 +393,9 @@ struct BroadcastState { route: crate::origin::Route, // The served route: dropping it (and the serve task's clone) retracts the - // route and rejects its queued requests. - dynamic: crate::origin::Dynamic, + // route and rejects its queued requests. `None` once a narrowing took it away + // while the peer still advertises it. + dynamic: Option, // active number of PUBLISH_NAMESPACE messages. count: usize, @@ -1371,16 +1372,22 @@ where // tracks keep flowing. let entry = entry.into_mut(); entry.route = route.clone(); - entry.dynamic.update(route)?; + if let Some(dynamic) = &entry.dynamic { + dynamic.update(route)?; + } Ok(()) } Entry::Vacant(entry) => { + // Nothing the session no longer lets the peer publish is accepted. + if !self.auth.within_ceiling(crate::auth::Direction::Subscribe, path.as_str()) { + return Err(Error::Unauthorized); + } // Propagates Error::Unauthorized if the namespace is out of scope. let dynamic = self.origin.dynamic(&path, route.clone())?; entry.insert(BroadcastState { route, - dynamic, + dynamic: Some(dynamic), count: 1, sources: HashMap::new(), }); @@ -1443,6 +1450,7 @@ where async fn run_route(&self, path: PathOwned) { let mut broadcasts = TaskSet::owned(); let mut closed_session = self.session.clone(); + let mut epoch = 0; loop { let next = broadcasts .drive(|waiter| { @@ -1457,11 +1465,28 @@ where if self.going_away.poll(waiter).is_ready() { self.drain_route(&path); } + // A narrowing that excludes the namespace takes the route away as a + // retraction would, closing its sources. Tracks in flight end on their + // own gates, with `Unauthorized`. + let mut narrowed = false; + while let Poll::Ready(permit) = + self.auth.poll_permit(crate::auth::Direction::Subscribe, &mut epoch, waiter) + { + narrowed |= !permit.within_ceiling(path.as_str()); + } // The route lives in the entry: stop_announce removing it retracts // the route, and this loop ends with it. let mut state = self.state.lock(); - match state.broadcasts.get_mut(&path) { - Some(entry) => entry.dynamic.poll_requested_broadcast(waiter).map(Some), + let Some(entry) = state.broadcasts.get_mut(&path) else { + return Poll::Ready(None); + }; + if narrowed && entry.dynamic.is_some() { + tracing::info!(route = %self.origin.absolute(&path), "namespace no longer authorized"); + entry.dynamic = None; + entry.sources.clear(); + } + match &mut entry.dynamic { + Some(dynamic) => dynamic.poll_requested_broadcast(waiter).map(Some), None => Poll::Ready(None), } }) @@ -1524,7 +1549,9 @@ where return; } entry.route.cost = crate::origin::Cost::DRAIN; - let _ = entry.dynamic.update(entry.route.clone()); + if let Some(dynamic) = &entry.dynamic { + let _ = dynamic.update(entry.route.clone()); + } } async fn run_broadcast(&self, path: Path<'_>, mut broadcast: broadcast::Dynamic) -> Result<(), Error> { diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 321af8bb81..edcb67d5d0 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -113,6 +113,12 @@ fn position_cursor(track: &mut track::Subscriber, version: Version, start_group: } impl Shared { + /// Watches whether the session still lets us serve `broadcast` to the peer: our + /// grant, and the ceiling on what the peer may subscribe to. + fn gate(&self, broadcast: &crate::Path) -> crate::auth::Gate { + crate::auth::Gate::new(self.auth.clone(), broadcast.to_owned(), crate::auth::Direction::Publish) + } + /// The origin to resolve a peer-requested broadcast from: excludes routes /// through the peer, so a subscription is never served data that flowed /// through the subscriber. The identity is the one the peer declared in its @@ -351,6 +357,9 @@ struct AuthServe { /// Shared with the app's [`crate::auth::Request`] / [`crate::auth::Issued`], or /// filled in by the default acceptor. issue: Option>, + /// The default acceptor's grant for the connection's credential, re-sent as a + /// narrowing changes it. `None` when the app answers. + default: Option, /// Our side is finished: FIN sent, waiting for the acknowledgement. finished: bool, } @@ -365,6 +374,7 @@ impl AuthServe { shared, stream: Some(stream), issue: None, + default: None, finished: false, }) } @@ -412,8 +422,10 @@ impl AuthServe { // the presenter, the same as a peer that predates AUTH. None if !msg.token.is_empty() => return Poll::Ready(Err(Error::Unsupported)), None => { - let grant = self.shared.peer_grant.clone(); - issue.lock().outbox.push_back(crate::auth::Reply::Grant(grant)); + self.default = Some(crate::auth::DefaultGrant::new( + self.shared.auth.clone(), + self.shared.peer_grant.clone(), + )); } } self.issue = Some(issue.clone()); @@ -442,6 +454,12 @@ impl AuthServe { continue; } + if let Some(default) = &mut self.default + && let Poll::Ready(grant) = default.poll(waiter) + { + issue.lock().outbox.push_back(crate::auth::Reply::Grant(grant)); + } + let mut state = match issue.poll(waiter, |issue| match issue.outbox.is_empty() && !issue.done { true => Poll::Pending, false => Poll::Ready(()), @@ -776,9 +794,7 @@ impl AnnounceServe { }; let announced = origin.announced(); let mut run = AnnounceRun::new(prefix, self.shared.self_origin, self.shared.version); - if self.shared.version.has_auth() { - run.auth = Some(self.shared.auth.clone()); - } + run.auth = Some(self.shared.auth.clone()); self.state = AnnounceState::Run { origin, announced, run }; } } @@ -800,12 +816,12 @@ struct AnnounceRun { // prefix. The value is the announce id on versions that assign them. live: HashMap>, phase: AnnouncePhase, - // Our grant, on versions with AUTH: only what it lets us publish is announced, - // and a shrink withdraws what it no longer covers. + // Our grant and the ceiling on the peer: only what both let us publish is + // announced, and a shrink withdraws what they no longer cover. auth: Option, epoch: u64, - // What the union lets us publish, `None` until the peer first answers. - permit: Option, + // What we may announce right now. + permit: crate::auth::Permit, } enum AnnouncePhase { @@ -827,29 +843,26 @@ impl AnnounceRun { phase: AnnouncePhase::Init, auth: None, epoch: 0, - permit: None, + permit: Default::default(), } } - /// Whether our grant lets us announce `suffix` (relative to the requested prefix). + /// Whether we may announce `suffix` (relative to the requested prefix). fn permitted(&self, suffix: &crate::Path) -> bool { - self.permit - .as_ref() - .is_none_or(|permit| permit.matches(self.prefix.join(suffix).as_str())) + self.permit.matches(self.prefix.join(suffix).as_str()) } - /// Apply a change to our grant: withdraw what it no longer covers and, when it - /// grew, re-read the origin to announce what it now does. + /// Apply a change to what we may announce: withdraw what it no longer covers and, + /// when it grew, re-read the origin to announce what it now does. fn regrant( &mut self, stream: &mut Stream, origin: &origin::Consumer, announced: &mut announce::Consumer, - permit: crate::Patterns, + permit: crate::auth::Permit, ) -> Result<(), Error> { - // `None` allowed everything, so any grant only shrinks it. - let grew = self.permit.as_ref().is_some_and(|old| !old.covers(&permit)); - self.permit = Some(permit); + let grew = !self.permit.covers(&permit); + self.permit = permit; if !grew { let revoked: Vec<_> = self @@ -1009,7 +1022,7 @@ impl AnnounceRun { let suffix = update.prefix; if update.kind.is_active() { - if self.outgoing(&update.route, &absolute).is_none() { + if !self.permitted(&suffix) || self.outgoing(&update.route, &absolute).is_none() { continue; } tracing::debug!(route = %absolute, "announce"); @@ -1023,6 +1036,10 @@ impl AnnounceRun { } } + // The peer holds these now, so a later retraction or narrowing ends them. + for suffix in &init { + self.live.insert(suffix.clone(), None); + } let announce_init = lite::AnnounceInit { suffixes: init }; stream.writer.buffer(&announce_init)?; } @@ -1088,8 +1105,8 @@ impl AnnounceRun { // advertises something it would withdraw, or abort over, a moment later. if let Some(auth) = &self.auth { ready!(auth.poll_setup_answered(waiter)); - if let Poll::Ready(union) = auth.poll_union(&mut self.epoch, waiter) { - self.permit = union.map(|grant| grant.publish); + if let Poll::Ready(permit) = auth.poll_permit(crate::auth::Direction::Publish, &mut self.epoch, waiter) { + self.permit = permit; } } self.init(stream, origin, announced)?; @@ -1107,10 +1124,10 @@ impl AnnounceRun { // A grant change applies before the next update, so a route it no longer // covers is withdrawn rather than re-sent. if let Some(auth) = &self.auth - && let Poll::Ready(Some(grant)) = auth.poll_union(&mut self.epoch, waiter) - && self.permit.as_ref() != Some(&grant.publish) + && let Poll::Ready(permit) = auth.poll_permit(crate::auth::Direction::Publish, &mut self.epoch, waiter) + && self.permit != permit { - self.regrant(stream, origin, announced, grant.publish)?; + self.regrant(stream, origin, announced, permit)?; continue; } @@ -1179,6 +1196,8 @@ struct TrackInfoServe { shared: Arc>, stream: Option>, state: TrackInfoState, + /// Ends the request once the session stops allowing its broadcast. + gate: Option, // Log context, filled in after the decode. absolute: crate::PathOwned, track: String, @@ -1215,6 +1234,7 @@ impl TrackInfoServe { shared, stream: Some(stream), state: TrackInfoState::Decode, + gate: None, absolute: Default::default(), track: Default::default(), }) @@ -1234,6 +1254,9 @@ impl TrackInfoServe { | Error::Transport(_) => { tracing::debug!(broadcast = %self.absolute, track = %self.track, "track info cancelled") } + Error::Unauthorized => { + tracing::debug!(broadcast = %self.absolute, track = %self.track, "track info unauthorized") + } err => { tracing::warn!(broadcast = %self.absolute, track = %self.track, %err, "track info error") } @@ -1245,6 +1268,11 @@ impl TrackInfoServe { } fn poll_serve(&mut self, waiter: &kio::Waiter) -> Poll> { + if let Some(gate) = &mut self.gate + && gate.poll_denied(waiter).is_ready() + { + return Poll::Ready(Err(Error::Unauthorized)); + } loop { match &mut self.state { TrackInfoState::Decode => { @@ -1255,12 +1283,11 @@ impl TrackInfoServe { self.track = msg.track.to_string(); tracing::debug!(broadcast = %self.absolute, track = %self.track, "track info requested"); // Checked before anything is resolved, like a subscription. - let allowed = self - .shared - .auth - .allows(crate::auth::Direction::Publish, msg.broadcast.as_str()); + let mut gate = self.shared.gate(&msg.broadcast); + let denied = gate.poll_denied(waiter).is_ready(); + self.gate = Some(gate); self.state = TrackInfoState::Hop { msg }; - if !allowed { + if denied { return Poll::Ready(Err(Error::Unauthorized)); } } @@ -1413,22 +1440,15 @@ impl SubscribeServe { self.track = msg.track.to_string(); tracing::info!(id = self.id, broadcast = %self.absolute, track = %self.track, "subscribed started"); - if self.shared.version.has_auth() { - let mut gate = crate::auth::Gate::new( - self.shared.auth.clone(), - msg.broadcast.to_owned(), - crate::auth::Direction::Publish, - ); - // Checked before anything is resolved, so a broadcast our grant - // does not cover is never served, not just cut off later. - if gate.poll_denied(waiter).is_ready() { - self.state = SubscribeState::Hop { msg }; - return Poll::Ready(Err(Error::Unauthorized)); - } - self.gate = Some(gate); - } - + // Checked before anything is resolved, so a broadcast the session does + // not allow is never served, not just cut off later. + let mut gate = self.shared.gate(&msg.broadcast); + let denied = gate.poll_denied(waiter).is_ready(); + self.gate = Some(gate); self.state = SubscribeState::Hop { msg }; + if denied { + return Poll::Ready(Err(Error::Unauthorized)); + } } SubscribeState::Hop { .. } => { // We just received a subscribe for this exact path, so by definition the @@ -1565,6 +1585,8 @@ struct FetchServe { shared: Arc>, stream: Option>, state: FetchState, + /// Ends the fetch, even mid-group, once the session stops allowing its broadcast. + gate: Option, // Log context, filled in after the decode. absolute: crate::PathOwned, track: String, @@ -1618,6 +1640,7 @@ impl FetchServe { shared, stream: Some(stream), state: FetchState::Decode, + gate: None, absolute: Default::default(), track: Default::default(), group: 0, @@ -1640,6 +1663,9 @@ impl FetchServe { | Error::Transport(_) => { tracing::info!(broadcast = %self.absolute, track = %self.track, group = %self.group, "fetch cancelled") } + Error::Unauthorized => { + tracing::info!(broadcast = %self.absolute, track = %self.track, group = %self.group, "fetch unauthorized") + } err => { tracing::warn!(broadcast = %self.absolute, track = %self.track, group = %self.group, %err, "fetch error") } @@ -1651,6 +1677,11 @@ impl FetchServe { } fn poll_serve(&mut self, waiter: &kio::Waiter) -> Poll> { + if let Some(gate) = &mut self.gate + && gate.poll_denied(waiter).is_ready() + { + return Poll::Ready(Err(Error::Unauthorized)); + } loop { match &mut self.state { FetchState::Decode => { @@ -1663,13 +1694,13 @@ impl FetchServe { self.group = msg.group; tracing::info!(broadcast = %self.absolute, track = %self.track, group = %self.group, "fetch started"); - // Checked before anything is resolved, like a subscription. - let allowed = self - .shared - .auth - .allows(crate::auth::Direction::Publish, msg.broadcast.as_str()); + // Checked before anything is resolved, like a subscription, and held + // until the last frame. + let mut gate = self.shared.gate(&msg.broadcast); + let denied = gate.poll_denied(waiter).is_ready(); + self.gate = Some(gate); self.state = FetchState::Hop { msg }; - if !allowed { + if denied { return Poll::Ready(Err(Error::Unauthorized)); } } diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 1ee11b7f9f..2cb602d0a7 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -326,7 +326,7 @@ impl Subscriber { // outside our scope, so don't serve it. Reflections are already // filtered above. let route = self.announced_route(hops, cost, link_cost, responder_origin); - let Ok(dynamic) = self.origin.dynamic(&path, route.clone()) else { + let Some(dynamic) = self.serve_route(&path, route.clone()) else { return Ok(false); }; @@ -335,6 +335,17 @@ impl Subscriber { Ok(true) } + /// Announce the peer's route into the origin, so paths under it resolve through + /// this session on demand. `None` when we may not accept it: outside our origin's + /// scope, or outside what the session still lets the peer publish. + fn serve_route(&self, path: &Path, route: crate::origin::Route) -> Option { + if !self.auth.within_ceiling(crate::auth::Direction::Subscribe, path.as_str()) { + tracing::debug!(route = %self.log_path(path), "declining announce outside the narrowed grant"); + return None; + } + self.origin.dynamic(path, route).ok() + } + /// The route to announce for a prefix this peer advertised, charging our /// link's price on top of the cost it advertised. /// @@ -428,7 +439,7 @@ impl Subscriber { return Ok(true); } - let Ok(dynamic) = self.origin.dynamic(&path, metadata.clone()) else { + let Some(dynamic) = self.serve_route(&path, metadata.clone()) else { announced.declined(path); return Ok(false); }; @@ -1152,6 +1163,8 @@ struct PrefixRun { // path, and lite-07 bases name it too. Tracked even for announces we drop // locally (reflected loops), since the sender doesn't know we dropped them. decoder: lite::AnnounceDecoder, + /// The auth epoch last applied, so a narrowing drops the routes it excludes. + epoch: u64, } impl AnnouncePrefix { @@ -1238,6 +1251,7 @@ impl AnnouncePrefix { link_cost, announced: Announced::default(), decoder: lite::AnnounceDecoder::default(), + epoch: 0, }; // Lite01/02 send the initial set as one ANNOUNCE_INIT message, so they @@ -1278,6 +1292,15 @@ impl AnnouncePrefix { if self.subscriber.going_away.poll(waiter).is_ready() { run.announced.drain(); } + // A narrowing aborts what the peer may no longer publish to us. The + // peer still holds each advertisement, so the path stays reserved. + while let Poll::Ready(permit) = + self.subscriber + .auth + .poll_permit(crate::auth::Direction::Subscribe, &mut run.epoch, waiter) + { + run.announced.narrow(&permit, &self.subscriber); + } loop { match stream.reader.poll_decode_maybe::(&mut cx) { Poll::Ready(Ok(Some(announce))) => { @@ -1418,10 +1441,11 @@ mod tests { .subscribe(None) .await .unwrap(); + let gate = serve.gate(); let mut running = TrackServeRun { serve, state: TrackRunState::Serve(serving), - gate: None, + gate, }; assert!( kio::Task::poll(&mut running, &kio::Waiter::noop()).is_ready(), @@ -1655,6 +1679,59 @@ mod tests { assert!(wire.is_empty(), "a second SUBSCRIBE trailed the first"); } + /// A narrowing that lands while the SUBSCRIBE is still opening resets it with + /// UNAUTHORIZED, like a live one, and releases its id. + #[tokio::test] + async fn a_narrowing_resets_a_subscribe_still_opening() { + // Writes park, so the SUBSCRIBE is on an open stream but not yet flushed. + let gate = kio::Producer::new(false); + let session = SinkSession::gated_bi(gate.consume()); + let auth = crate::auth::Handle::new(false); + + let origin = origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let subscriber = Subscriber::new(SubscriberConfig { + runtime: crate::time::Clock::tokio(), + session: session.clone(), + origin, + recv_bandwidth: None, + version: VERSION, + peer_setup: Default::default(), + peer_hop: None, + cost: None, + going_away: Default::default(), + auth: auth.clone(), + }); + let subscribes = subscriber.subscribes.clone(); + let serve = TrackServe { + subscriber, + path: Path::new("room/host").to_owned(), + name: "audio".to_string(), + }; + + let broadcast = crate::broadcast::Info::new().produce(); + let request = broadcast.reserve_track("audio").unwrap(); + let mut serving = ServeLoop::new(&serve, request, Default::default(), Some(Timescale::default())); + let establish = serve.prepare_establish(&mut serving.serving, Subscription::default(), Some(Timescale::default())); + serving.mode = ServeMode::Establish(establish); + let gate_run = serve.gate(); + let mut running = TrackServeRun { + serve, + state: TrackRunState::Serve(serving), + gate: gate_run, + }; + + assert!(kio::Task::poll(&mut running, &kio::Waiter::noop()).is_pending()); + assert_eq!(session.log.bi_opens(), 1, "the SUBSCRIBE stream is open"); + + auth.narrow(&crate::auth::Grant::default()); + assert!(kio::Task::poll(&mut running, &kio::Waiter::noop()).is_ready()); + + let unauthorized = crate::StreamError::Unauthorized.to_code(); + assert_eq!(session.log.resets(), vec![unauthorized]); + assert!(subscribes.lock().is_empty(), "the id outlived its subscription"); + drop(gate); + } + /// Everything a `handle_subscription` test needs to stay alive for the call. struct Harness { serve: TrackServe, @@ -2846,6 +2923,17 @@ impl Announced { } } + /// Drop every attached route the session no longer lets the peer publish, closing + /// its sources. Tracks in flight end on their own gates, with `Unauthorized`. + fn narrow(&mut self, permit: &crate::auth::Permit, subscriber: &Subscriber) { + for (path, route) in self.routes.iter_mut() { + if route.is_some() && !permit.within_ceiling(path.as_str()) { + tracing::info!(route = %subscriber.log_path(path), "announce no longer authorized"); + *route = None; + } + } + } + /// Re-price every attached route to a draining cost (the peer sent a GOAWAY). fn drain(&mut self) { for entry in self.routes.values_mut().flatten() { @@ -2951,6 +3039,16 @@ struct TrackServe { } impl TrackServe { + /// Watches whether the session still lets us receive this broadcast from the peer: + /// our grant, and the ceiling on what the peer may publish. + fn gate(&self) -> crate::auth::Gate { + crate::auth::Gate::new( + self.subscriber.auth.clone(), + self.path.clone(), + crate::auth::Direction::Subscribe, + ) + } + fn widen_frame_bounds(&self, subscription: &mut Subscription) { if self.subscriber.version.has_frame_bounds() { return; @@ -3283,6 +3381,14 @@ impl Establish { } } + /// Give up on the SUBSCRIBE, resetting it with `err` if it reached the wire. + fn abort(self, err: &Error) { + self.serve.subscriber.remove_subscribe(self.id); + if let EstablishState::Send { stream } | EstablishState::WaitOk { stream } = self.state { + stream.writer.abort(err); + } + } + fn activate(&self, stream: Stream) -> SubStream { SubStream { stream, @@ -3303,8 +3409,8 @@ impl Establish { struct TrackServeRun { serve: TrackServe, state: TrackRunState, - /// Cancels the track once our grant stops covering its broadcast. - gate: Option, + /// Cancels the track once the session stops allowing its broadcast. + gate: crate::auth::Gate, } // A state machine's enum is its storage: one transient instance per stream, so the @@ -3335,18 +3441,12 @@ impl TrackServeRun { let info = track::Info::default().with_max_age(serve.subscriber.origin.default_max_age()); TrackRunState::Serve(ServeLoop::new(&serve, request, info, None)) }; - let gate = serve.subscriber.version.has_auth().then(|| { - crate::auth::Gate::new( - serve.subscriber.auth.clone(), - serve.path.clone(), - crate::auth::Direction::Subscribe, - ) - }); + let gate = serve.gate(); Self { serve, state, gate } } - /// Our grant no longer covers the broadcast: end the track and cancel the - /// upstream subscription, leaving the rest of the session alone. + /// The session no longer allows the broadcast: end the track and cancel the + /// upstream subscription and fetches, leaving the rest of the session alone. fn revoke(&mut self) { tracing::info!(broadcast = %self.serve.subscriber.log_path(&self.serve.path), track = %self.serve.name, "subscription no longer authorized"); match std::mem::replace(&mut self.state, TrackRunState::Done) { @@ -3358,11 +3458,15 @@ impl TrackServeRun { TrackRunState::Serve(serve_loop) => { let _ = serve_loop.serving.abort(Error::Unauthorized); // Reset rather than finish, so the publisher reads a revocation instead of - // a routine unsubscribe. + // a routine unsubscribe. A SUBSCRIBE still opening is on the wire too. if let Sub::Active(active) = serve_loop.sub { self.serve.subscriber.remove_subscribe(active.id); active.stream.writer.abort(&Error::Unauthorized); } + if let ServeMode::Establish(establish) = serve_loop.mode { + establish.abort(&Error::Unauthorized); + } + // Each fetch in flight resets itself as it drops, seeing the same denial. } TrackRunState::Done => {} } @@ -3371,9 +3475,7 @@ impl TrackServeRun { impl kio::Task for TrackServeRun { fn poll(&mut self, waiter: &kio::Waiter) -> Poll<()> { - if let Some(gate) = &mut self.gate - && gate.poll_denied(waiter).is_ready() - { + if self.gate.poll_denied(waiter).is_ready() { self.revoke(); return Poll::Ready(()); } @@ -3870,6 +3972,45 @@ impl FetchServeRun { } } +impl Drop for FetchServeRun { + fn drop(&mut self) { + // Dropped because the session stopped allowing the broadcast: reset with that + // reason, so neither the publisher nor the waiting reader reads a routine cancel. + // Any other drop keeps the default cancel. + if matches!(self.state, FetchRunState::Done) + || self + .serve + .subscriber + .auth + .allows(crate::auth::Direction::Subscribe, self.serve.path.as_str()) + { + return; + } + let err = Error::Unauthorized; + match std::mem::replace(&mut self.state, FetchRunState::Done) { + FetchRunState::Open { request } => { + if let Some(request) = request { + request.reject(err); + } + } + FetchRunState::Send { request, stream, .. } | FetchRunState::Answer { request, stream, .. } => { + stream.writer.abort(&err); + if let Some(request) = request { + request.reject(err); + } + } + FetchRunState::Ingest { + mut stream, producer, .. + } => { + stream.reader.abort(&err); + stream.writer.abort(&err); + let _ = producer.abort(err); + } + FetchRunState::Done => {} + } + } +} + impl kio::Task for FetchServeRun { fn poll(&mut self, waiter: &kio::Waiter) -> Poll<()> { let mut cx = std::task::Context::from_waker(waiter.waker()); diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs index 2cd2592cd4..7fbae7dd79 100644 --- a/rs/moq-net/tests/auth.rs +++ b/rs/moq-net/tests/auth.rs @@ -162,6 +162,7 @@ struct Pair { client: Session, server: Session, client_transport: MockSession, + server_transport: MockSession, requests: Option, } @@ -186,6 +187,7 @@ async fn connect(opts: Options) -> Pair { } let observe = client_transport.clone(); + let observe_server = server_transport.clone(); let client_fut = async { let (session, driver) = client.connect(now(), client_transport).await.expect("client handshake"); tokio::spawn(run(driver)); @@ -210,6 +212,7 @@ async fn connect(opts: Options) -> Pair { client, server, client_transport: observe, + server_transport: observe_server, requests, } } @@ -927,3 +930,232 @@ async fn nothing_outside_the_grant_reaches_the_peer(version: &'static str) { .await .expect("timed out"); } + +/// Run each narrowing case on every version family: the session enforces a narrowing +/// itself, so a peer without AUTH (or one that ignores it) cannot keep what it lost. +macro_rules! narrow_cases { + ($($case:ident),* $(,)?) => { + mod narrow_lite_05 { + $(#[tokio::test] async fn $case() { super::$case("moq-lite-05").await })* + } + mod narrow_lite_06 { + $(#[tokio::test] async fn $case() { super::$case(super::LITE_06).await })* + } + mod narrow_moqt_16 { + $(#[tokio::test] async fn $case() { super::$case("moq-transport-16").await })* + } + mod narrow_moqt_17 { + $(#[tokio::test] async fn $case() { super::$case(super::MOQT_17).await })* + } + }; +} + +narrow_cases!( + a_narrowing_deafens_one_path, + a_narrowing_aborts_what_the_peer_published +); + +#[tokio::test] +async fn lite_05_narrowing_resets_a_fetch_in_flight() { + a_narrowing_resets_a_fetch_in_flight("moq-lite-05").await +} + +#[tokio::test] +async fn lite_06_narrowing_resets_a_fetch_in_flight() { + a_narrowing_resets_a_fetch_in_flight(LITE_06).await +} + +/// Whether `version` exchanges AUTH, so the peer also hears of a narrowing. +fn speaks_auth(version: &str) -> bool { + matches!(version, LITE_06 | MOQT_17 | MOQT_22) +} + +/// A revocation as the reader sees it: the local gate's own error, or the peer's +/// UNAUTHORIZED reset relayed across the splice. +fn unauthorized(err: &Error) -> bool { + matches!(err, Error::Unauthorized | Error::Stream(StreamError::Unauthorized)) +} + +/// Read groups until the subscription ends, returning how it ended. +async fn ended(sub: &mut moq_net::track::Subscriber) -> Error { + loop { + match sub.recv_group().await { + Ok(Some(_)) => continue, + Ok(None) => panic!("subscription finished instead of ending"), + Err(err) => return err, + } + } +} + +/// The deafen case: narrowing a live session away from one audio path resets that +/// subscription and retracts its announcement, while a sibling under the same prefix +/// keeps flowing and the session stays up. +async fn a_narrowing_deafens_one_path(version: &'static str) { + within(async { + let ts = |ms| moq_net::Timestamp::from_millis(ms).unwrap(); + let prefs = || moq_net::track::Subscription::default().with_max_age(Duration::from_secs(10)); + + let relay = produce_origin(1); + let audio = relay.create_broadcast("room/alice/audio").unwrap(); + let audio_track = audio.create_track("opus", None).unwrap(); + audio.announce(Default::default()).unwrap(); + let video = relay.create_broadcast("room/alice/video").unwrap(); + let video_track = video.create_track("h264", None).unwrap(); + video.announce(Default::default()).unwrap(); + + let received = produce_origin(3); + let pair = connect(Options { + version: Some(version), + client_subscribe: Some(received.clone()), + server_publish: Some(relay.scope("", &patterns(&["room"])).unwrap()), + ..Default::default() + }) + .await; + + let mut group = audio_track.append_group().unwrap(); + group.write_frame(ts(0), b"a".as_ref()).unwrap(); + let mut group = video_track.append_group().unwrap(); + group.write_frame(ts(0), b"v".as_ref()).unwrap(); + + let remote = received.consume().routed_broadcast("room/alice/audio").await.unwrap(); + let mut audio_sub = remote.track("opus").unwrap().subscribe(prefs()).await.unwrap(); + audio_sub.recv_group().await.unwrap().unwrap(); + let remote = received.consume().routed_broadcast("room/alice/video").await.unwrap(); + let mut video_sub = remote.track("h264").unwrap().subscribe(prefs()).await.unwrap(); + video_sub.recv_group().await.unwrap().unwrap(); + + pair.server.auth().narrow(&grant(&[], &["room/alice/video"])); + + let err = ended(&mut audio_sub).await; + assert!(unauthorized(&err), "{err:?}"); + wait_announced(&received.consume(), "room/alice/audio", false).await; + + // The sibling keeps flowing. + let mut group = video_track.append_group().unwrap(); + group.write_frame(ts(1), b"v".as_ref()).unwrap(); + let group = video_sub.recv_group().await.unwrap().expect("video still flows"); + assert_eq!(group.sequence, 1); + + // The relay enforced it, not the client: moq-lite resets the subscription with + // UNAUTHORIZED, and neither side closed the session. + if version.starts_with("moq-lite") { + let resets = pair.server_transport.resets(); + assert!(resets.contains(&StreamError::Unauthorized.to_code()), "{resets:x?}"); + } + assert_eq!(pair.client_transport.close_reason(), None); + + // A peer that speaks AUTH is told what it may still subscribe to. + if speaks_auth(version) { + let narrowed = wait_for(pair.client.auth().grant(), |grant| { + grant + .as_ref() + .is_some_and(|grant| grant.subscribe == patterns(&["room/alice/video"])) + }) + .await; + assert_eq!(narrowed, Some(grant(&[], &["room/alice/video"]))); + } + }) + .await + .expect("timed out"); +} + +/// Narrowing what the peer may publish aborts the broadcasts it published outside, +/// so the relay's own readers see `Unauthorized`, and retracts their routes, while +/// what it may still publish keeps flowing. +async fn a_narrowing_aborts_what_the_peer_published(version: &'static str) { + within(async { + let ts = |ms| moq_net::Timestamp::from_millis(ms).unwrap(); + let prefs = || moq_net::track::Subscription::default().with_max_age(Duration::from_secs(10)); + + let client_origin = produce_origin(2); + let mic = client_origin.create_broadcast("room/bob/mic").unwrap(); + let mic_track = mic.create_track("opus", None).unwrap(); + mic.announce(Default::default()).unwrap(); + let cam = client_origin.create_broadcast("room/bob/cam").unwrap(); + let cam_track = cam.create_track("h264", None).unwrap(); + cam.announce(Default::default()).unwrap(); + + let relay = produce_origin(1); + let pair = connect(Options { + version: Some(version), + client_publish: Some(client_origin.clone()), + server_subscribe: Some(relay.clone()), + ..Default::default() + }) + .await; + + let mut group = mic_track.append_group().unwrap(); + group.write_frame(ts(0), b"m".as_ref()).unwrap(); + let mut group = cam_track.append_group().unwrap(); + group.write_frame(ts(0), b"c".as_ref()).unwrap(); + + let remote = relay.consume().routed_broadcast("room/bob/mic").await.unwrap(); + let mut mic_sub = remote.track("opus").unwrap().subscribe(prefs()).await.unwrap(); + mic_sub.recv_group().await.unwrap().unwrap(); + let remote = relay.consume().routed_broadcast("room/bob/cam").await.unwrap(); + let mut cam_sub = remote.track("h264").unwrap().subscribe(prefs()).await.unwrap(); + cam_sub.recv_group().await.unwrap().unwrap(); + + pair.server.auth().narrow(&grant(&["room/bob/cam"], &[])); + + let err = ended(&mut mic_sub).await; + assert!(unauthorized(&err), "{err:?}"); + wait_announced(&relay.consume(), "room/bob/mic", false).await; + + let mut group = cam_track.append_group().unwrap(); + group.write_frame(ts(1), b"c".as_ref()).unwrap(); + let group = cam_sub.recv_group().await.unwrap().expect("cam still flows"); + assert_eq!(group.sequence, 1); + + // A narrowing is not a publication outside the grant: the client stays up. + assert_eq!(pair.client_transport.close_reason(), None); + }) + .await + .expect("timed out"); +} + +/// A fetch still streaming its group when the grant narrows away from it is reset with +/// UNAUTHORIZED by the publisher, not left to finish. +async fn a_narrowing_resets_a_fetch_in_flight(version: &'static str) { + within(async { + let ts = |ms| moq_net::Timestamp::from_millis(ms).unwrap(); + + let relay = produce_origin(1); + let broadcast = relay.create_broadcast("room/x").unwrap(); + let track = broadcast.create_track("video", None).unwrap(); + broadcast.announce(Default::default()).unwrap(); + // The group stays open, so the fetch is still in flight when the grant narrows. + let mut group = track.append_group().unwrap(); + group.write_frame(ts(0), b"first".as_ref()).unwrap(); + + let received = produce_origin(3); + let pair = connect(Options { + version: Some(version), + client_subscribe: Some(received.clone()), + server_publish: Some(relay.clone()), + ..Default::default() + }) + .await; + + let remote = received.consume().routed_broadcast("room/x").await.unwrap(); + let mut fetched = remote.track("video").unwrap().fetch_group(0, None).await.unwrap(); + let frame = fetched.read_frame().await.unwrap().expect("first frame"); + assert_eq!(frame.payload.as_ref(), b"first"); + + pair.server.auth().narrow(&grant(&[], &["room/y"])); + + let err = loop { + match fetched.read_frame().await { + Ok(Some(_)) => continue, + Ok(None) => panic!("fetch finished instead of ending"), + Err(err) => break err, + } + }; + assert!(unauthorized(&err), "{err:?}"); + let resets = pair.server_transport.resets(); + assert!(resets.contains(&StreamError::Unauthorized.to_code()), "{resets:x?}"); + drop(group); + }) + .await + .expect("timed out"); +} From 8b26a0ee055b4d45442de2c0eee1b2cf1b5d7442 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 15:55:53 -0700 Subject: [PATCH 18/53] quest(auth): cover a malformed inbound AUTH too Co-Authored-By: Claude Opus 5.5 --- quest/m1/auth/violations.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/quest/m1/auth/violations.md b/quest/m1/auth/violations.md index c07bdf9f1a..f04a20f7b9 100644 --- a/quest/m1/auth/violations.md +++ b/quest/m1/auth/violations.md @@ -5,13 +5,13 @@ Every AUTH stream message that the lite or IETF drafts call a PROTOCOL_VIOLATION closes the session in Rust and `@moq/net` alike, instead of ending only its token. Examples: an AUTH_ERROR code past `u32`, an -out-of-range AUTH_OK expiry, or an AUTH_OK or AUTH_ERROR that fails to decode, -such as a bad pattern. +out-of-range AUTH_OK expiry, or an AUTH, AUTH_OK, or AUTH_ERROR that fails to +decode, such as a bad pattern or length. ## Plan [AUTH endings](/quest/m1/auth/error-codes.md) (#4550) closes the session only -on an explicit `ProtocolViolation` in Rust lite, and left three gaps: +on an explicit `ProtocolViolation` in Rust lite, and left four gaps: - JS reports the exact oversized code but doesn't close the session. Let the JS connection close on an `AuthSession` protocol violation. @@ -19,6 +19,9 @@ on an explicit `ProtocolViolation` in Rust lite, and left three gaps: - An AUTH_OK or AUTH_ERROR that fails to decode ends only the token, like every other lite stream, although the draft calls a bad pattern a PROTOCOL_VIOLATION. +- On the acceptor side, a presenter's AUTH that fails to decode only aborts + its stream (Rust lite `AuthServe`, JS `#runBidis`), so a peer can repeat + malformed AUTH streams without closing the session. Close the session on each, in both languages and on both protocols, with one regression test per case, and check the lite and IETF draft text agrees. Run From e34649c4bf104a551c4207f8a46a03858c0cace9 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 15:56:31 -0700 Subject: [PATCH 19/53] feat(relay): narrow a live session on a narrower re-check Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/auth.rs | 78 ++++++++++++++++++ rs/moq-relay/src/auth.rs | 56 +++++++++++-- rs/moq-relay/src/connection.rs | 13 +-- rs/moq-relay/src/websocket.rs | 3 +- rs/moq-relay/tests/auth_lifetime.rs | 123 ++++++++++++++++++++++++++-- 5 files changed, 251 insertions(+), 22 deletions(-) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index c12b0f3a6a..2833c5b703 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -987,3 +987,81 @@ impl Enforce { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn patterns(prefixes: &[&str]) -> Patterns { + prefixes + .iter() + .map(|prefix| crate::Pattern::subtree(prefix).unwrap()) + .collect() + } + + fn grant(publish: &[&str], subscribe: &[&str]) -> Grant { + Grant { + publish: patterns(publish), + subscribe: patterns(subscribe), + expires: None, + } + } + + /// A narrowing keeps only what every narrowing so far allows, so a wider one + /// changes nothing, and it applies to a version without AUTH too. + #[test] + fn a_narrowing_only_ever_narrows() { + let handle = Handle::new(false); + assert!(handle.allows(Direction::Publish, "room/alice/audio")); + + handle.narrow(&grant(&["room/bob"], &["room/alice"])); + // What we send is what the peer may subscribe to, and the other way around. + assert!(handle.allows(Direction::Publish, "room/alice/audio")); + assert!(!handle.allows(Direction::Publish, "room/bob/cam")); + assert!(handle.allows(Direction::Subscribe, "room/bob/cam")); + assert!(!handle.allows(Direction::Subscribe, "room/alice/audio")); + + handle.narrow(&grant(&[""], &["room/alice/video"])); + assert!(!handle.allows(Direction::Publish, "room/alice/audio")); + assert!(handle.allows(Direction::Publish, "room/alice/video")); + assert!(handle.allows(Direction::Subscribe, "room/bob/cam")); + + handle.narrow(&Grant::all()); + assert!(!handle.allows(Direction::Publish, "room/alice/audio")); + assert!(!handle.allows(Direction::Subscribe, "other")); + } + + /// Our own grant does not decide what the peer may offer us, only the ceiling does. + #[test] + fn the_ceiling_alone_decides_what_the_peer_offers() { + let handle = Handle::new(true); + let token = handle.present(Bytes::new(), true).unwrap(); + handle.granted(0, grant(&[], &[])); + assert!(!handle.allows(Direction::Subscribe, "room/bob/cam")); + assert!(handle.within_ceiling(Direction::Subscribe, "room/bob/cam")); + + handle.narrow(&grant(&["room/alice"], &[])); + assert!(!handle.within_ceiling(Direction::Subscribe, "room/bob/cam")); + assert!(handle.within_ceiling(Direction::Subscribe, "room/alice/cam")); + drop(token); + } + + /// The default acceptor answers with its base grant, then with each grant a + /// narrowing changes, and stays quiet on a change that leaves it alone. + #[test] + fn the_default_grant_follows_the_ceiling() { + let handle = Handle::new(true); + let mut default = DefaultGrant::new(handle.clone(), grant(&["room"], &["room"])); + let waiter = kio::Waiter::noop(); + + assert_eq!(default.poll(&waiter), Poll::Ready(grant(&["room"], &["room"]))); + assert!(default.poll(&waiter).is_pending()); + + handle.narrow(&grant(&["room"], &["room/alice"])); + assert_eq!(default.poll(&waiter), Poll::Ready(grant(&["room"], &["room/alice"]))); + + // Wider than the ceiling already is: nothing to tell the peer. + handle.narrow(&Grant::all()); + assert!(default.poll(&waiter).is_pending()); + } +} diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index 018086750c..09864a8292 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -412,14 +412,21 @@ impl Token { } /// Whether `other` still covers everything this token scopes: the same root and - /// mounts, and every grant still held. A narrower re-check closes the session until - /// pattern scopes can resize it in place. + /// mounts, and every grant still held. A narrower re-check narrows the session. pub(crate) fn covered_by(&self, other: &Self) -> bool { self.root == other.root && self.mounts == other.mounts && other.subscribe.covers(&self.subscribe) && other.publish.covers(&self.publish) } + + /// Keep only what `other` also grants. Fails closed: an intersection too large + /// to hold grants nothing. + fn narrow(&mut self, other: &Self) { + let both = |this: &Patterns, other: &Patterns| this.intersect(other).unwrap_or_default(); + self.subscribe = both(&self.subscribe, &other.subscribe); + self.publish = both(&self.publish, &other.publish); + } } /// What admitted a session and keeps it admitted: the grant's lease and the @@ -432,6 +439,8 @@ impl Token { pub struct Lease { consumer: lease::Consumer, token: Token, + /// The live session a narrower re-check narrows in place, once attached. + session: Option, /// When the grant runs out, enforced here whoever drives the lease: a fixed /// grant has no driver, and an auth server's may be mid-outage. Fixed on tokio's /// clock when the grant arrives, so re-polling [`ended`](Self::ended) never @@ -450,17 +459,26 @@ impl Lease { token: Token::new(path, &grant), expires: grant.deadline(), consumer, + session: None, stats: Default::default(), } } + /// Narrow `session` in place when a re-check narrows the grant, instead of ending + /// the lease. Without it a narrower grant ends the lease. + pub(crate) fn narrowing(mut self, session: &moq_net::Session) -> Self { + self.session = Some(session.auth()); + self + } + /// Attach the session's stats context, so a re-checked tier retags it live. pub fn with_stats(mut self, stats: moq_net::stats::Session) -> Self { self.stats = stats; self } - /// The scope the session was admitted under. + /// The scope the session holds: what it was admitted under, less what any + /// re-check narrowed away since. pub fn token(&self) -> &Token { &self.token } @@ -473,11 +491,13 @@ impl Lease { /// Wait for the lease to stop covering the session: the grant expired, was /// revoked, or was re-checked into one that no longer covers the token. /// - /// A changed root or mounts, or a narrower grant, ends it: origin handles cannot yet narrow - /// a live scope in place (tracked by `quest/m1/auth/narrowing.md`). A flipped - /// `peer` ends it too, since the routes it already announced would be - /// misreported as entering here or from a peer. A changed tier keeps the - /// session and moves its [stats](Self::with_stats) to the new tier. + /// A narrower grant with the same root and mounts narrows an attached session in + /// place and keeps going; the session keeps only what both grants allow, so a + /// wider part waits for a reconnect. Without an attached session it ends the lease. + /// A changed root or mounts ends it, and so does a flipped `peer`, since the + /// routes it already announced would be misreported as entering here or from a + /// peer. A changed tier keeps the session and moves its + /// [stats](Self::with_stats) to the new tier. pub async fn ended(&mut self) -> lease::Reason { loop { let expire = async { @@ -502,7 +522,20 @@ impl Lease { return "peer changed".into(); } if !self.token.covered_by(&fresh) { - return "grant narrowed".into(); + let Some(session) = &self.session else { + return "grant narrowed".into(); + }; + self.token.narrow(&fresh); + tracing::info!( + publish = %join(&self.token.publish), + subscribe = %join(&self.token.subscribe), + "grant narrowed; narrowing the session", + ); + session.narrow(&moq_net::auth::Grant { + publish: self.token.publish.clone(), + subscribe: self.token.subscribe.clone(), + expires: None, + }); } if fresh.tier != self.token.tier { tracing::info!(from = %self.token.tier, to = %fresh.tier, "tier changed"); @@ -526,6 +559,11 @@ impl Lease { } } +/// Patterns as one comma-separated line, for logs. +fn join(patterns: &Patterns) -> String { + patterns.iter().map(|pattern| pattern.as_str()).collect::>().join(",") +} + enum Decider { Server(moq_auth::Client), /// The public rules, as a token with an empty root. diff --git a/rs/moq-relay/src/connection.rs b/rs/moq-relay/src/connection.rs index 8d5e460c84..f143a818f3 100644 --- a/rs/moq-relay/src/connection.rs +++ b/rs/moq-relay/src/connection.rs @@ -275,20 +275,21 @@ pub(crate) fn authorize( /// the internal listener re-checks this lease; `None` for a session that is /// not in the table. /// -/// The lease is the decider's live word on the grant: when it stops covering -/// the session ([`auth::Lease::ended`]) the session closes with the reason, and -/// the session's own close is reported back through the lease as the `end` event. -/// Either way, a relay shutdown drains the session with a GOAWAY instead of -/// cutting it off. +/// The lease is the decider's live word on the grant: a narrower re-check narrows +/// the session in place, and when it stops covering the session +/// ([`auth::Lease::ended`]) the session closes with the reason. The session's own +/// close is reported back through the lease as the `end` event. Either way, a relay +/// shutdown drains the session with a GOAWAY instead of cutting it off. /// /// The session handle is `Send + Sync` whatever transport carries it, so this /// runs on the shared runtime even for sessions a pinned QUIC worker drives. pub async fn supervise( session: moq_net::Session, - mut lease: auth::Lease, + lease: auth::Lease, mut shutdown: crate::shutdown::Observer, registration: Option, ) -> anyhow::Result<()> { + let mut lease = lease.narrowing(&session); loop { let nudged = async { match ®istration { diff --git a/rs/moq-relay/src/websocket.rs b/rs/moq-relay/src/websocket.rs index 6093d0ba95..192051ae49 100644 --- a/rs/moq-relay/src/websocket.rs +++ b/rs/moq-relay/src/websocket.rs @@ -127,7 +127,7 @@ struct SessionInputs { async fn handle_socket( socket: T, session: SessionInputs, - mut lease: auth::Lease, + lease: auth::Lease, pending: Option<(crate::session::Registry, moq_auth::Request)>, ) -> anyhow::Result<()> where @@ -197,6 +197,7 @@ where // The handshake is done, so this is a MoQ session now: only now can a push // be serviced, and only now does the session appear in the live table. let registration = pending.map(|(sessions, request)| sessions.register(request)); + let mut lease = lease.narrowing(&session); loop { let nudged = async { diff --git a/rs/moq-relay/tests/auth_lifetime.rs b/rs/moq-relay/tests/auth_lifetime.rs index a61e794a67..16c3c0049d 100644 --- a/rs/moq-relay/tests/auth_lifetime.rs +++ b/rs/moq-relay/tests/auth_lifetime.rs @@ -555,9 +555,11 @@ async fn a_moved_tier_retags_the_live_session() { relay.abort(); } -/// A narrower grant closes the session on the next re-check, over TCP and WebSocket. +/// A narrower grant narrows the live session in place on the next re-check, over TCP +/// and WebSocket: the deafened path resets with `Unauthorized`, a sibling under the +/// same prefix keeps flowing, and neither session closes. #[tokio::test] -async fn a_narrower_grant_closes_live_sessions() { +async fn a_narrower_grant_narrows_live_sessions() { for scheme in ["tcp", "ws"] { let script = Script::new(grant(Duration::from_secs(3600))); let auth = build_auth(script.spawn().await); @@ -565,18 +567,127 @@ async fn a_narrower_grant_closes_live_sessions() { "tcp" => spawn_relay(auth).await, _ => spawn_ws_relay(auth).await, }; - let (pub_session, sub_session) = connect_and_round_trip(&room_url(scheme, port)).await; + let url = room_url(scheme, port); + + let pub_origin = moq_tokio::origin::spawn(); + let mut tracks = Vec::new(); + for path in ["alice/audio", "alice/video"] { + let broadcast = pub_origin.create_broadcast(path).expect("create broadcast"); + let track = broadcast.create_track("media", None).expect("create track"); + broadcast.announce(Default::default()).expect("announce broadcast"); + tracks.push((broadcast, track)); + } + let pub_session = tokio::time::timeout( + TIMEOUT, + client() + .with_publisher(pub_origin.consume()) + .with_reconnect(false) + .connect(url.clone()) + .established(), + ) + .await + .expect("publisher connect timeout") + .expect("publisher connect failed"); + + let sub_origin = moq_tokio::origin::spawn(); + let sub_consumer = sub_origin.consume(); + let sub_session = tokio::time::timeout( + TIMEOUT, + client() + .with_subscriber(sub_origin) + .with_reconnect(false) + .connect(url.clone()) + .established(), + ) + .await + .expect("subscriber connect timeout") + .expect("subscriber connect failed"); + + let mut subs = Vec::new(); + for path in ["alice/audio", "alice/video"] { + let broadcast = tokio::time::timeout(TIMEOUT, sub_consumer.routed_broadcast(path)) + .await + .expect("announcement timeout") + .expect("announced broadcast resolves"); + subs.push(broadcast.track("media").unwrap().subscribe(None).await.expect("subscribe")); + } + let send = |index: usize, sequence: u64| { + let mut group = tracks[index].1.append_group().expect("append group"); + group + .write_frame(moq_net::Timestamp::ZERO, b"media".as_ref()) + .expect("write frame"); + group.finish().expect("finish group"); + assert_eq!(group.sequence, sequence); + }; + for index in 0..2 { + send(index, 0); + tokio::time::timeout(TIMEOUT, subs[index].recv_group()) + .await + .expect("recv_group timeout") + .expect("recv_group failed") + .expect("track closed prematurely"); + } + // Deafen alice's audio: everything else stays as admitted. let mut narrow = grant(Duration::from_secs(3600)); - narrow.publish = ["nobody/**".parse().unwrap()].into_iter().collect(); + narrow.subscribe = ["alice/video/**".parse().unwrap()].into_iter().collect(); script.on_revalidate(Answer::Grant(narrow)); - assert_closed(pub_session, Duration::from_secs(5), &format!("{scheme} publisher")).await; - assert_closed(sub_session, Duration::from_secs(5), &format!("{scheme} subscriber")).await; + let err = loop { + match tokio::time::timeout(TIMEOUT, subs[0].recv_group()) + .await + .expect("the deafened track never ended") + { + Ok(Some(_)) => continue, + Ok(None) => panic!("{scheme}: the deafened track finished instead of ending"), + Err(err) => break err, + } + }; + assert!( + matches!( + err, + moq_net::Error::Unauthorized | moq_net::Error::Stream(moq_net::StreamError::Unauthorized) + ), + "{scheme}: {err:?}" + ); + + send(1, 1); + let group = tokio::time::timeout(TIMEOUT, subs[1].recv_group()) + .await + .expect("recv_group timeout") + .expect("recv_group failed") + .expect("the sibling track closed"); + assert_eq!(group.sequence, 1, "{scheme}"); + + for (session, what) in [(pub_session, "publisher"), (sub_session, "subscriber")] { + assert!( + tokio::time::timeout(Duration::from_millis(200), session.closed()) + .await + .is_err(), + "{scheme}: a narrowing must not close the {what}" + ); + } relay.abort(); } } +/// A re-check that moves the root still closes the session: nothing it holds is +/// named the same way under the new one. +#[tokio::test] +async fn a_moved_root_closes_live_sessions() { + let script = Script::new(grant(Duration::from_secs(3600))); + let (port, relay) = spawn_relay(build_auth(script.spawn().await)).await; + let (pub_session, sub_session) = connect_and_round_trip(&room_url("tcp", port)).await; + + let mut moved = grant(Duration::from_secs(3600)); + moved.root = Some("elsewhere".into()); + script.on_revalidate(Answer::Grant(moved)); + + assert_closed(pub_session, Duration::from_secs(5), "publisher").await; + assert_closed(sub_session, Duration::from_secs(5), "subscriber").await; + relay.abort(); +} + /// A refusal on re-check closes the session, and the `end` says why. /// 403, 401, and an empty grant are all a no. #[tokio::test] From e2891ba144ac2a7c9ee646be623eab75dde99ff6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 16:02:06 -0700 Subject: [PATCH 20/53] fix(net): arm JS grant watches before a request's first await Co-Authored-By: Claude Opus 5.5 --- doc/bin/relay/auth.md | 11 +++- doc/lib/rs/moq-net.md | 18 ++++++ js/net/src/lite/publisher.test.ts | 94 ++++++++++++++++++++++++++++++ js/net/src/lite/publisher.ts | 89 +++++++++++++++++++++++----- js/net/src/lite/subscriber.test.ts | 31 ++++++++++ js/net/src/lite/subscriber.ts | 58 +++++++++++++----- 6 files changed, 269 insertions(+), 32 deletions(-) diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index 096a5159c8..4bf255d303 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -64,9 +64,14 @@ The client and relay snapshot each accepted grant's deadline on a monotonic clock, so later polls, outages, and wall-clock adjustments do not restart it. **Revalidate and outage.** On the cadence the relay POSTs `revalidate` with the -same request. A grant applies: a changed `root` or `mounts`, or one that no longer covers -what the session holds closes it with `Unauthorized` (the live session is not -resized in place), and so does a flipped `peer`; a changed `tier` keeps the +same request. A grant applies: one that no longer covers what the session holds +narrows the live session in place to what both grants allow, so a moderation +decision lands on the session it targets. Its subscriptions and fetches outside +the new grant reset with `Unauthorized`, the broadcasts it published outside +abort, and everything else keeps flowing; a wider part waits for a reconnect. A +one-shot HTTP `/fetch` ends instead. A +changed `root` or `mounts` closes the session with `Unauthorized`, and so does a +flipped `peer`; a changed `tier` keeps the session and moves its stats: its presence counts under the new tier from then on, as does each group and subscription it starts afterwards, while one already in flight finishes where it began. A 401 or 403 closes the session now, as does a 2xx diff --git a/doc/lib/rs/moq-net.md b/doc/lib/rs/moq-net.md index f8b8d09451..2b4b3bbe06 100644 --- a/doc/lib/rs/moq-net.md +++ b/doc/lib/rs/moq-net.md @@ -104,6 +104,24 @@ shrinks withdraws the announcements and cancels the subscriptions it no longer covers, and leaves the session up. On moq-lite each cancelled stream resets with `StreamError::Unauthorized`. +`session.auth().narrow(&grant)` takes access away from the peer on a live session, +on every version, whether or not the peer speaks AUTH: the session enforces it +itself. It only ever narrows, keeping what both the current ceiling and `grant` +allow. What falls outside ends at once: announcements to the peer retract, its +new requests are refused, its subscriptions and fetches reset with +`Unauthorized`, and the broadcasts it published abort, so local readers see +`Unauthorized` too. The rest of the session carries on. When the session answers +the peer's connection credential itself, the peer is sent the narrowed grant. + +```rust +// Deafen alice's audio: the peer may still subscribe to her video. +let video: Pattern = "room/alice/video/**".parse()?; +session.auth().narrow(&auth::Grant { + subscribe: video.into(), + ..auth::Grant::all() +}); +``` + ## Patterns `Pattern` describes a set of paths; `Patterns` is a union reduced by diff --git a/js/net/src/lite/publisher.test.ts b/js/net/src/lite/publisher.test.ts index 2d08823181..560e3d20fb 100644 --- a/js/net/src/lite/publisher.test.ts +++ b/js/net/src/lite/publisher.test.ts @@ -1,5 +1,8 @@ import { expect, mock, spyOn, test } from "bun:test"; import { Signal } from "@moq/signals"; +import type { Grant } from "../auth.ts"; +import { Producer as BroadcastProducer } from "../broadcast.ts"; +import { StreamCode, toStreamCode } from "../error.ts"; import { Producer as GroupProducer } from "../group.ts"; import { randomHop } from "../hop.ts"; import { hooks } from "../internal.ts"; @@ -1603,3 +1606,94 @@ test("lite draft-05: a group that goes stale while its stream opens writes nothi origin.close(); } }); + +function grantOf(publish: string): Grant { + return { publish: new Path.Patterns([Path.Pattern.subtree(publish)]), subscribe: new Path.Patterns([]) }; +} + +// The resets carrying our UNAUTHORIZED code, by message. +function unauthorizedResets(resets: { mock: { calls: unknown[][] } }): string[] { + return resets.mock.calls + .map(([reason]) => reason as Error) + .filter((reason) => toStreamCode(reason) === StreamCode.Unauthorized) + .map((reason) => reason.message); +} + +// The grant watch is armed before the first check, so a shrink that lands while the broadcast +// is still resolving resets the subscription instead of being missed for good. +test("lite draft-06: a grant that shrinks while the broadcast resolves resets the subscription", async () => { + const pair = createMockTransportPair(ALPN_06); + const origin = new OriginProducer(); + const handle = origin.dynamic(Path.from("live")); + const requests = handle.requested(); + const grant = new Signal(grantOf("live")); + const publisher = new Publisher(pair.server, Version.DRAFT_06, randomHop(), origin.consume(), grant); + + const client = await Stream.open(pair.client); + const server = await Stream.accept(pair.server); + if (!server) throw new Error("publisher never accepted the subscribe stream"); + + const resets = spyOn(Writer.prototype, "reset"); + const produced = new BroadcastProducer(); + try { + const msg = new Subscribe({ id: 0n, broadcast: Path.from("live/cam"), track: "video", priority: 0 }); + const serving = publisher.runSubscribe(msg, server); + + // The subscription is parked resolving the broadcast when the grant shrinks. + const request = await requests.next(); + if (request.done) throw new Error("the handler never saw the request"); + grant.set(grantOf("other")); + await Promise.resolve(); + request.value.accept(produced); + await serving; + + expect(unauthorizedResets(resets)).toContain("unauthorized: live/cam"); + } finally { + resets.mockRestore(); + produced.close(); + handle.close(); + publisher.close(); + client.close(); + origin.close(); + } +}); + +// A fetch holds its grant watch until the last frame: a group can stay open as long as its +// track, so a check at accept alone would keep serving after a shrink. +test("lite draft-06: a grant that shrinks mid-fetch resets the fetch", async () => { + const pair = createMockTransportPair(ALPN_06); + const origin = new OriginProducer(); + const grant = new Signal(grantOf("live")); + const publisher = new Publisher(pair.server, Version.DRAFT_06, randomHop(), origin.consume(), grant); + + const broadcast = publish(origin, Path.from("live")); + const track = broadcast.createTrack("video"); + // Left open, so the fetch is still serving when the grant shrinks. + const group = new GroupProducer(0); + group.writeString("first"); + track.writeGroup(group); + + const client = await Stream.open(pair.client); + const server = await Stream.accept(pair.server); + if (!server) throw new Error("publisher never accepted the fetch stream"); + + const resets = spyOn(Writer.prototype, "reset"); + try { + const msg = new Fetch({ broadcast: Path.from("live"), track: "video", priority: 0, group: 0 }); + const serving = publisher.runFetch(msg, server); + // The first frame is on the wire before the grant shrinks. + await client.reader.u8(); + + grant.set(grantOf("other")); + await serving; + + expect(unauthorizedResets(resets)).toContain("unauthorized: live"); + } finally { + resets.mockRestore(); + group.close(); + publisher.close(); + client.close(); + broadcast.close(); + origin.close(); + } +}); diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index 7a7d937179..b4e61c9d26 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -569,10 +569,46 @@ export class Publisher { * @internal */ async runSubscribe(msg: Subscribe, stream: Stream) { - // Serve only what our grant lets us publish, and stop once it no longer does. + // Serve only what our grant lets us publish, and stop once it no longer does. The + // watch is armed before the first check and held to the end, so a shrink while the + // broadcast resolves is never missed. + const revoked = unauthorized(msg.broadcast); + let serving: track.Subscriber | undefined; + const watch = this.#watch(msg.broadcast, () => { + console.debug(`publish revoked: broadcast=${msg.broadcast} track=${msg.track}`); + serving?.close(revoked); + stream.abort(revoked); + }); + try { + await this.#serveSubscribe(msg, stream, watch, (track) => { + serving = track; + }); + } finally { + watch.dispose(); + } + } + + // Watch whether our grant still lets us publish `broadcast`, calling `onRevoke` once + // it does not. Arm it before the request's first check. + #watch(broadcast: Path.Valid, onRevoke: () => void): { revoked: () => boolean; dispose: () => void } { + let revoked = false; + const dispose = + this.#grant?.subscribe(() => { + if (revoked || !this.#denied(broadcast)) return; + revoked = true; + onRevoke(); + }) ?? (() => {}); + return { revoked: () => revoked || this.#denied(broadcast), dispose }; + } + + async #serveSubscribe( + msg: Subscribe, + stream: Stream, + watch: { revoked: () => boolean }, + serving: (track: track.Subscriber) => void, + ) { // Checked before resolving, so a denied request never reaches the origin. - const denied = () => this.#denied(msg.broadcast); - if (denied()) { + if (watch.revoked()) { stream.writer.reset(unauthorized(msg.broadcast)); return; } @@ -586,6 +622,8 @@ export class Publisher { stream.writer.reset(error(err)); return; } + // Revoked while the broadcast resolved: the watch already reset the stream. + if (watch.revoked()) return; if (!front) { console.debug(`publish unknown: broadcast=${msg.broadcast}`); stream.writer.reset(new NotFound(`broadcast ${msg.broadcast}`)); @@ -601,6 +639,7 @@ export class Publisher { end: endGroup === undefined ? undefined : { excluded: endGroup }, }, }); + serving(track); positionCursor(track, this.version, msg.startGroup); hooks.replaceGroups(track, { end: endGroup === undefined ? undefined : { excluded: endGroup } }); @@ -610,14 +649,6 @@ export class Publisher { let datagrams = Promise.resolve(); let controls: SubscriptionControls | undefined; - const revoked = unauthorized(msg.broadcast); - const disposeGrant = this.#grant?.subscribe(() => { - if (!denied()) return; - console.debug(`publish revoked: broadcast=${msg.broadcast} track=${track.name}`); - track.close(revoked); - stream.abort(revoked); - }); - try { let timescale: Timescale = Timescale.MILLI; @@ -691,8 +722,6 @@ export class Publisher { track.close(e); stream.abort(e); await Promise.all([datagrams, controls?.decoding]); - } finally { - disposeGrant?.(); } } @@ -706,7 +735,31 @@ export class Publisher { stream.writer.reset(new Error("fetch requires moq-lite-05 or newer")); return; } - if (this.#denied(msg.broadcast)) { + // Like a subscription, the fetch holds its watch until the last frame: a group can stay + // open as long as its track, so a check at accept alone would keep serving after a shrink. + const revoked = unauthorized(msg.broadcast); + let fetched: group.Consumer | undefined; + const watch = this.#watch(msg.broadcast, () => { + console.debug(`fetch revoked: broadcast=${msg.broadcast} track=${msg.track} group=${msg.group}`); + fetched?.close(revoked); + stream.abort(revoked); + }); + try { + await this.#serveFetch(msg, stream, watch, (group) => { + fetched = group; + }); + } finally { + watch.dispose(); + } + } + + async #serveFetch( + msg: Fetch, + stream: Stream, + watch: { revoked: () => boolean }, + serving: (group: group.Consumer) => void, + ) { + if (watch.revoked()) { stream.writer.reset(unauthorized(msg.broadcast)); return; } @@ -725,6 +778,8 @@ export class Publisher { stream.writer.reset(new NotFound(`broadcast ${msg.broadcast}`)); return; } + // Revoked while the broadcast resolved: the watch already reset the stream. + if (watch.revoked()) return; // The subscriber opened this stream, so its send order only ranked the request. Rank the // response here, on the same scale as the group streams it competes with. @@ -736,6 +791,12 @@ export class Publisher { // come off the same front, so the metadata and the frames are one generation. const info = await this.#resolveTrackInfo(front, msg.track); group = await wireOf(front).fetchGroup(msg.track, msg.group, { priority: msg.priority }); + serving(group); + // Revoked while the group resolved: the watch already reset the stream. + if (watch.revoked()) { + group.close(unauthorized(msg.broadcast)); + return; + } await this.#runFetchGroup(group, stream.writer, { timescale: Timescale(info.timescale), start: msg.startFrame, diff --git a/js/net/src/lite/subscriber.test.ts b/js/net/src/lite/subscriber.test.ts index f9dec8b901..53499109c0 100644 --- a/js/net/src/lite/subscriber.test.ts +++ b/js/net/src/lite/subscriber.test.ts @@ -1,5 +1,6 @@ import { expect, spyOn, test } from "bun:test"; import { Signal } from "@moq/signals"; +import type { Grant } from "../auth.ts"; import type { Probe as ProbeStats } from "../connection/stats.ts"; import { error, reason, StreamCode, StreamError } from "../error.ts"; import { HopSchema, isAnonymous, MAX_HOPS, Route, UNKNOWN_HOP } from "../hop.ts"; @@ -803,3 +804,33 @@ test("a fetch started after the subscriber closes rejects without opening a stre expectCut(err, undefined); expect(streams.length).toBe(0); }); + +// The grant watch is armed before the first check, so a shrink while TRACK_INFO is still in +// flight refuses the subscription rather than opening a SUBSCRIBE the grant no longer covers. +test("a grant that shrinks while the subscription sets up refuses it", async () => { + const { quic, streams } = fakeSession(); + const scoped = (prefix: string): Grant => ({ + publish: new Path.Patterns([]), + subscribe: new Path.Patterns([Path.Pattern.subtree(prefix)]), + }); + const grant = new Signal(scoped("room")); + const subscriber = new Subscriber(quic, Version.DRAFT_05, HopSchema.parse(1n), undefined, undefined, grant); + + const track = subscriber.consume(Path.from("room/cam")).track("video").subscribe().ordered(); + const next = track.nextGroup().catch((err: unknown) => err); + + // Parked on TRACK_INFO when the grant shrinks. + await drainUntil(() => streams.length === 1); + await streams[0].reading; + grant.set(scoped("other")); + await answerTrackInfo(streams[0]); + + const err = await next; + expect(err).toBeInstanceOf(StreamError); + expect((err as StreamError).code).toBe(StreamCode.Unauthorized); + // Nothing past the TRACK stream reached the wire. + expect(streams.length).toBe(1); + + track.close(); + subscriber.close(); +}); diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index 6e6950e476..db81ef1260 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -80,6 +80,15 @@ function supportsTrackStream(version: Version): boolean { } } +// What a subscription has set up so far, so a revocation (or a timeout) can reach it at +// any stage. +interface SubscribeSetup { + stream?: Stream; + producer?: track.Producer; + // Set once our grant stops covering the broadcast. + revoked?: Error; +} + interface SubscribeEntry { // The write side: incoming GROUP streams are routed here. The application reads // the matching track.Subscriber it got from broadcast.Consumer.subscribe. @@ -531,10 +540,29 @@ export class Subscriber { return; } const refused = unauthorized(broadcast); - if (this.#denied(broadcast)) { - request.reject(refused); - return; + // Armed before the first check and held to the end, so a shrink while the + // subscription sets up is never missed: it resets whatever reached the wire. + const state: SubscribeSetup = {}; + const disposeGrant = this.#grant?.subscribe(() => { + if (state.revoked || !this.#denied(broadcast)) return; + state.revoked = refused; + console.debug(`subscribe revoked: id=${id} broadcast=${broadcast} track=${request.name}`); + state.producer?.close(refused); + state.stream?.abort(refused); + }); + try { + if (this.#denied(broadcast)) { + request.reject(refused); + return; + } + await this.#serveSubscribe(id, broadcast, request, state); + } finally { + disposeGrant?.(); } + } + + async #serveSubscribe(id: bigint, broadcast: Path.Valid, request: track.Request, state: SubscribeSetup) { + const subscription = request.subscription; // `timescale` stays undefined until TRACK_INFO (or, on older drafts, // implicit defaults) resolves it; runGroup blocks on it before decoding. @@ -555,7 +583,6 @@ export class Subscriber { // Open the stream under a timeout. The stream handle flows back via `state` // so the timeout path can abort it if it finishes opening after the deadline. - const state: { stream?: Stream } = {}; const setup = this.#openSubscribe(state, msg, request, id, timescale); let opened: { stream: Stream; entry: SubscribeEntry }; @@ -568,8 +595,10 @@ export class Subscriber { console.debug(`subscribe ok: id=${id} broadcast=${broadcast} track=${request.name}`); } catch (err) { // The setup outlived its deadline waiting for the first response: a control - // timeout, not content that arrived late. - const e = err instanceof TimeoutError ? controlTimeout(err) : await sessionCause(this.#quic, err); + // timeout, not content that arrived late. A revocation says so instead. + const e = + state.revoked ?? + (err instanceof TimeoutError ? controlTimeout(err) : await sessionCause(this.#quic, err)); request.reject(e); this.#subscribes.delete(id); console.warn(`subscribe error: id=${id} broadcast=${broadcast} track=${request.name} error=${reason(e)}`); @@ -585,13 +614,6 @@ export class Subscriber { const { stream, entry } = opened; const producer = entry.track; - // Losing the grant ends the subscription, leaving the session alone. - const disposeGrant = this.#grant?.subscribe(() => { - if (!this.#denied(broadcast)) return; - console.debug(`subscribe revoked: id=${id} broadcast=${broadcast} track=${request.name}`); - producer.close(refused); - stream.abort(refused); - }); try { // Watch for subscription changes and send SUBSCRIBE_UPDATE. Lite01/Lite02 // don't carry SUBSCRIBE_UPDATE on the wire, so skip the watcher there @@ -639,7 +661,6 @@ export class Subscriber { console.warn(`subscribe error: id=${id} broadcast=${broadcast} track=${request.name} error=${reason(e)}`); stream.abort(e); } finally { - disposeGrant?.(); this.#subscribes.delete(id); } } @@ -654,7 +675,7 @@ export class Subscriber { // SUBSCRIBE is accepted implicitly (no SUBSCRIBE_OK). Older drafts carry no // per-track properties, so they resolve to defaults and just drain SUBSCRIBE_OK. async #openSubscribe( - state: { stream?: Stream }, + state: SubscribeSetup, msg: Subscribe, request: track.Request, id: bigint, @@ -666,6 +687,7 @@ export class Subscriber { if (supportsTrackStream(this.version)) { // Fetch the immutable properties once via the TRACK stream. const info = await this.#trackInfo(msg.broadcast, msg.track); + if (state.revoked) throw state.revoked; producer = request.accept(this.#toModelInfo(info)); timescale.set(info.timescale); } else { @@ -678,8 +700,14 @@ export class Subscriber { // Register before opening SUBSCRIBE so a racing GROUP stream finds the entry. const entry: SubscribeEntry = { track: producer, timescale, tail: new Tail() }; this.#subscribes.set(id, entry); + state.producer = producer; state.stream = await Stream.open(this.#quic); + // Revoked while the stream opened: nothing reached the wire yet, so stop here. + if (state.revoked) { + state.stream.abort(state.revoked); + throw state.revoked; + } await state.stream.writer.u53(StreamId.Subscribe); await msg.encode(state.stream.writer, this.version); From 39d5f08ab3d964cde6fc1930e4f7574f47573b7e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 16:12:09 -0700 Subject: [PATCH 21/53] fix(net): log the AUTH_ERROR code limit, and drop the raw timer in its JS test Co-Authored-By: Claude Opus 5.5 --- drafts/draft-lcurley-moq-lite.md | 1 + js/net/src/auth.test.ts | 17 ++++++++--------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/drafts/draft-lcurley-moq-lite.md b/drafts/draft-lcurley-moq-lite.md index 6c3ea0fbaf..1ce2004279 100644 --- a/drafts/draft-lcurley-moq-lite.md +++ b/drafts/draft-lcurley-moq-lite.md @@ -1441,6 +1441,7 @@ The `Message Length` describes the payload size on the wire. - Removed SUBSCRIBE_DROP and its type 0x2; a group without a Group Stream is not counted. - The Subscribe Stream FIN now follows once every counted Group Stream has finished or been reset. - Added announce compression: ANNOUNCE_START gains `Path Base` and `Path Keep` to copy the head of a live advertisement's suffix, and ANNOUNCE_START and ANNOUNCE_UPDATE gain `Hop Base` and `Hop Keep` to copy the tail of a live advertisement's Hop ID list. +- An AUTH_ERROR code that does not fit in 32 bits is a PROTOCOL_VIOLATION. ## moq-lite-06 diff --git a/js/net/src/auth.test.ts b/js/net/src/auth.test.ts index a2f0123fe7..3912c7286b 100644 --- a/js/net/src/auth.test.ts +++ b/js/net/src/auth.test.ts @@ -177,15 +177,14 @@ describe.each([Lite.ALPN_06, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", (pro const issued = request?.accept(grant(["a"], [])); await waitFor(client.auth.grant, (g) => g !== undefined && g.publish.size > 0); - issued?.close(); - let timer: ReturnType | undefined; - const timeout = new Promise((resolve) => { - timer = setTimeout(() => resolve("timeout"), 1000); - }); - expect(await Promise.race([issued?.closed, timeout])).toBeNull(); - clearTimeout(timer); - client.close(); - server.close(); + try { + issued?.close(); + // A regression leaves `closed` pending, so the test runner's timeout fails it. + expect(await issued?.closed).toBeNull(); + } finally { + client.close(); + server.close(); + } }); test("a refused setup token grants nothing rather than everything", async () => { From 47abdbb22e37354441411b28bfe6d775ffa42300 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 16:15:01 -0700 Subject: [PATCH 22/53] quest(auth/narrowing): done; bench the narrowing and its gates Co-Authored-By: Claude Opus 5.5 --- quest/m1/auth/README.md | 8 +- quest/m1/auth/error-codes.md | 4 - quest/m1/auth/narrowing.md | 65 ------------- quest/m1/auth/relay-refresh.md | 8 +- quest/m1/auth/request-token.md | 2 +- rs/moq-net/benches/origin.rs | 161 ++++++++++++++++++++++++++++++++- 6 files changed, 165 insertions(+), 83 deletions(-) delete mode 100644 quest/m1/auth/narrowing.md diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index b930917037..7651c01de2 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -39,9 +39,8 @@ Decisions settled while planning, recorded so review does not relitigate them: the union and cancels publications and subscriptions that lose authorization. Other authorized work continues on the same session. An empty union leaves the session connected with no access, so it can accept a fresh token. - [Origin narrowing](/quest/m1/auth/narrowing.md) owns the common resize - operation; relay token handling requires it rather than shipping a temporary - close-on-shrink policy. + `auth::Handle::narrow` is the common resize operation; relay token handling + uses it rather than shipping a temporary close-on-shrink policy. - **A public grant contains publish patterns, subscribe patterns, and an expiry**, in the presenter's own root; the presenter never sees the relay-side root, and every token in a union shares the connection's root. Unscoped @@ -91,9 +90,6 @@ existing lite-06 ALPN. - [AUTH_OK preflight](/quest/m1/auth/auth-ok-preflight.md) - an unencodable IETF grant answers NOT_SUPPORTED with nothing written, as JS already does - [AUTH endings](/quest/m1/auth/error-codes.md) - an out-of-range AUTH_ERROR code is refused, and both sides settle and recompute grants when a stream ends -- [Origin narrowing](/quest/m1/auth/narrowing.md) - a live grant narrows in - place: subscriptions outside it reset, publishes outside it abort, and relay - revalidation stops closing the session - [Relay tokens](/quest/m1/auth/relay-refresh.md) - the relay verifies tokens sent in band, unions their grants, and cancels only work that loses access - [Request tokens](/quest/m1/auth/request-token.md) - an `AUTHORIZATION diff --git a/quest/m1/auth/error-codes.md b/quest/m1/auth/error-codes.md index 71881c1f51..b1bd9e518d 100644 --- a/quest/m1/auth/error-codes.md +++ b/quest/m1/auth/error-codes.md @@ -30,8 +30,4 @@ are closed, so every way an AUTH stream ends reports what actually happened: it. - One regression test per item, each failing without its fix. -The fifth deferred item, a grant recheck after async origin resolution, -belongs to [Origin narrowing](/quest/m1/auth/narrowing.md) with the other -watcher races. - Public API: none. Wire: none. diff --git a/quest/m1/auth/narrowing.md b/quest/m1/auth/narrowing.md deleted file mode 100644 index 2771cec520..0000000000 --- a/quest/m1/auth/narrowing.md +++ /dev/null @@ -1,65 +0,0 @@ -# [M] A live origin grant narrows in place - -## Goal - -A `moq-net` session's grant can be replaced by a narrower pattern union -while the session is live: announce cursors retract the patterns that fell -outside, resolution refuses them, subscriptions under them reset with -`UNAUTHORIZED`, and broadcasts the session published under them abort. The -relay uses it on revalidation so a moderation decision (a deafened user's -audio path) takes effect on the session it targets, which a forked client -cannot bypass. This is the per-subscriber exclusion -[#2714](https://github.com/moq-dev/moq/issues/2714) asked for; the scope and -match half already landed in #3672 and #3746. - -A narrowing always succeeds. A changed root still closes the session. - -## Plan - -- Enforce per session, not per handout. The line's session gate for received - grants (`auth::Gate`, the lite publisher resetting subscriptions it denies, - `regrant` retracting announces no longer covered, and the IETF equivalent) - is the same mechanism pointed at the relay's own grant. Reuse it rather than - building a second one. Readers that bypass the session publisher (HTTP - `/fetch` holds its own lease and closes on it; moq-hls and WHEP enforce no - per-user grant today) are out of scope. -- #3971 built narrowing alone and was closed: it refused whenever anything - live sat in the removed part, which is exactly when a grant narrows, and it - kept the ceiling in a side map. Scopes today are copied by value into every - handle and cursor (`OriginScope` in `rs/moq-net/src/model/origin.rs`), so - narrowing needs shared state. If handles need a tree to share it, keep the - ceiling on the grant node itself (an `Arc` shared by clones and children). -- Close the revocation races Codex found on - [#4179](https://github.com/moq-dev/moq/pull/4179), which the same watcher - mechanism owns: an in-flight lite FETCH keeps serving after its path is - revoked (Rust and JS check only at accept, and Rust's dropped fetches reset - `CANCELLED`, not `UNAUTHORIZED`); a Rust lite subscription still in - `Establish` when the grant shrinks is dropped with `CANCELLED` instead of - aborted `UNAUTHORIZED`; and the JS lite publisher and subscriber arm their - grant watchers only after an await (`demand()`, `#openSubscribe`), and - `Getter.subscribe` does not replay, so a shrink during setup is missed for - good. Every request holds one watcher from its first check to its end, and - rechecks when the watcher is armed. -- Publish side: routes and broadcasts the session published outside the - narrowed grant abort, so consumers see `Unauthorized` just as the subscribe - side does. Draining is not an option: a group may stay open as long as its - track. -- Relay revalidation uses it: a re-checked grant with the same root narrows - the session instead of closing it (`Lease::ended` reporting "grant - narrowed" in `rs/moq-relay/src/auth.rs`), on every accept path: native, - io_uring, and `moq --listen` share `connection::supervise`, while WebSocket - has its own copy of the loop. -- Prove the deafen case end to end: subscribe under a room prefix, narrow - with a grant that excludes that audio path, and assert the subscription - resets with `UNAUTHORIZED` and no further objects arrive, while a sibling - path under the same prefix keeps flowing. -- Benchmark the narrowing and per-session gate cost in - `rs/moq-net/benches/origin.rs`, swept over routes and subscribers, with a - session that never narrows as the baseline. - -Public API: additive on moq-net and on moq-relay's revalidate path. Wire: -none; narrowed subscriptions reset with the existing UNAUTHORIZED code. - -## Closes - -- [#2714](https://github.com/moq-dev/moq/issues/2714) - close this issue when the quest finishes diff --git a/quest/m1/auth/relay-refresh.md b/quest/m1/auth/relay-refresh.md index 4d03299fb2..677da06b6f 100644 --- a/quest/m1/auth/relay-refresh.md +++ b/quest/m1/auth/relay-refresh.md @@ -36,7 +36,9 @@ expiry, an expiry that leaves the union intact ends only that token, and publish-only session never starts receiving announcements because a later token happened to carry subscribe prefixes, and swapped in whenever the set grows; keep it behind one function, and a shrinking union goes through - [Origin narrowing](/quest/m1/auth/narrowing.md) rather than a close. + `auth::Handle::narrow` rather than a close. `narrow` only ever narrows, so + a union that grows back after a shrink also needs the session's ceiling + replaced, not just new origin handles. - Expiry: the deadline today is the admitted lease's `closed()` inside one `tokio::select!` arm. Select on one lease per token in the set instead, and on any firing recompute the union without it: unchanged means `AUTH_ERROR { Expired }` on that token's stream and the session @@ -79,7 +81,3 @@ expiry, an expiry that leaves the union intact ends only that token, and proxy-mode outage on re-check keeps the token. Additive. - -## Required - -- [Origin narrowing](/quest/m1/auth/narrowing.md) - the live re-scope a shrinking token union needs, so no temporary close-on-shrink policy ships diff --git a/quest/m1/auth/request-token.md b/quest/m1/auth/request-token.md index fa93750186..b9635432fd 100644 --- a/quest/m1/auth/request-token.md +++ b/quest/m1/auth/request-token.md @@ -40,7 +40,7 @@ on the unknown key, and the legacy drafts silently ignore it. until it lapses. When a request's grant expires or is revoked, that request alone ends with `EXPIRED_AUTH_TOKEN` or `UNAUTHORIZED`. A session grant that shrinks cancels the requests it covered, as for any request, through - [Origin narrowing](/quest/m1/auth/narrowing.md). + `auth::Handle::narrow`. - Relay: each such request gets its own lease from a per-request call on `moq_auth::Client`, not the `Client::attach` that [Relay tokens](/quest/m1/auth/relay-refresh.md) builds. `attach` connects with the diff --git a/rs/moq-net/benches/origin.rs b/rs/moq-net/benches/origin.rs index 62692ebd9d..0b0e4193c4 100644 --- a/rs/moq-net/benches/origin.rs +++ b/rs/moq-net/benches/origin.rs @@ -8,13 +8,22 @@ //! down to its prefix and beneath it, and each recomputes its best route from //! the entries at that prefix alone. //! +//! `origin/narrow*` put a session in front of the origin, since a narrowing is +//! enforced by the session serving it: every subscription holds a gate on the +//! session's grant, and a narrowing wakes each one. The baseline is a session +//! that never narrows. +//! //! Run with `cargo bench -p moq-net --bench origin`. +#[path = "../tests/support/mod.rs"] +mod support; + use std::time::Duration; use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; use futures::FutureExt; -use moq_net::{Hop, Hops, Pattern, Patterns, Timestamp, announce, broadcast, kio, origin}; +use moq_net::{Hop, Hops, Pattern, Patterns, Timestamp, announce, auth, broadcast, kio, origin, track}; +use support::harness::{MockConnectOptions, MockPair, connect_mock}; /// `(publishers, subscribers)` shapes for the fan-out benchmarks. const SHAPES: [(usize, usize); 3] = [(100, 10), (1_000, 100), (1_000, 1_000)]; @@ -423,6 +432,152 @@ fn bench_handoff(c: &mut Criterion) { group.finish(); } +/// `(routes, subscribers)` shapes for the narrowing benchmarks: [`SHAPES`] plus a +/// row that grows the routes alone, so each axis shows its own slope. +const NARROW_SHAPES: [(usize, usize); 4] = [(100, 10), (1_000, 10), (1_000, 100), (1_000, 1_000)]; + +/// A relay serving `routes` broadcasts to one client session subscribed to +/// `subscribers` of them. The first subscription is the one a narrowing to +/// `room/**` ends: its broadcast sits at `muted` instead. +struct Watching { + pair: MockPair, + tracks: Vec, + subscriptions: Vec, + _broadcasts: Vec, + _origins: [origin::Producer; 2], +} + +fn watched_path(index: usize) -> String { + match index { + 0 => "muted".to_string(), + index => format!("room/{index}"), + } +} + +async fn watching(routes: usize, subscribers: usize) -> Watching { + let spawn = |hop| { + let (producer, driver) = origin::Producer::new(origin::Config::new(Hop::new(hop).unwrap())); + tokio::spawn(support::harness::run(driver)); + producer + }; + let relay = spawn(1); + let received = spawn(2); + + let mut broadcasts = Vec::new(); + let mut tracks = Vec::new(); + for index in 0..routes { + let broadcast = relay.publish(watched_path(index), origin::Route::default()).unwrap(); + tracks.push(broadcast.create_track("video", None).unwrap()); + broadcasts.push(broadcast); + } + + let mut options = MockConnectOptions::new("moq-lite-06".parse().unwrap()); + options.server_publish = Some(relay.consume()); + options.client_subscribe = Some(received.clone()); + let pair = connect_mock(options).await; + + let mut subscriptions = Vec::new(); + for index in 0..subscribers { + let remote = received.consume().routed_broadcast(watched_path(index)).await.unwrap(); + let subscription = remote.track("video").unwrap().subscribe(None).await.unwrap(); + subscriptions.push(subscription); + } + // One round, so every subscription is live end to end before anything is timed. + let mut watching = Watching { + pair, + tracks, + subscriptions, + _broadcasts: broadcasts, + _origins: [relay, received], + }; + watching.round().await; + watching +} + +impl Watching { + /// Every subscribed track writes a group, and every subscription reads it. + async fn round(&mut self) { + for track in &self.tracks[..self.subscriptions.len()] { + let mut group = track.append_group().unwrap(); + group.write_frame(Timestamp::ZERO, b"frame".as_ref()).unwrap(); + group.finish().unwrap(); + } + for subscription in &mut self.subscriptions { + subscription.recv_group().await.unwrap().expect("track ended"); + } + } +} + +fn runtime() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .unwrap() +} + +/// Everything under `room/`, which leaves out only the `muted` subscription. +fn room() -> auth::Grant { + auth::Grant { + subscribe: Pattern::subtree("room").unwrap().into(), + ..auth::Grant::all() + } +} + +/// Steady-state delivery through a session's gates: one round across every +/// subscription, on a session that never narrows and on one narrowed to a grant +/// that still covers them all. Neither may depend on `routes`. +fn bench_narrow_delivery(c: &mut Criterion) { + let mut group = c.benchmark_group("origin/narrow_delivery"); + let rt = runtime(); + for (routes, subscribers) in NARROW_SHAPES { + group.throughput(Throughput::Elements(subscribers as u64)); + for narrowed in [false, true] { + let label = if narrowed { "narrowed" } else { "never" }; + let id = BenchmarkId::new(label, format!("{routes}r_{subscribers}s")); + group.bench_function(id, |b| { + let mut watching = rt.block_on(watching(routes, subscribers)); + if narrowed { + // Covers every subscription but the muted one, which is not subscribed here. + let mut grant = room(); + grant.subscribe.insert(Pattern::literal("muted").unwrap()); + watching.pair.server.auth().narrow(&grant); + } + b.iter(|| rt.block_on(watching.round())); + }); + } + } + group.finish(); +} + +/// One narrowing on a live session: from `narrow` until the subscription it +/// excludes resets. It wakes every subscription's gate and re-checks every route +/// the session announced, so it may grow with the session's own `subscribers` +/// and `routes`, never with anything else on the origin. +fn bench_narrow(c: &mut Criterion) { + let mut group = c.benchmark_group("origin/narrow"); + group.sample_size(10); + group.sampling_mode(criterion::SamplingMode::Flat); + let rt = runtime(); + for (routes, subscribers) in NARROW_SHAPES { + let id = BenchmarkId::from_parameter(format!("{routes}r_{subscribers}s")); + group.bench_function(id, |b| { + b.iter_custom(|iters| { + let mut elapsed = Duration::ZERO; + for _ in 0..iters { + let mut watching = rt.block_on(watching(routes, subscribers)); + let start = std::time::Instant::now(); + watching.pair.server.auth().narrow(&room()); + let ended = rt.block_on(watching.subscriptions[0].recv_group()); + elapsed += start.elapsed(); + assert!(ended.is_err(), "the muted subscription outlived the narrowing"); + } + elapsed + }); + }); + } + group.finish(); +} + criterion_group!( benches, bench_announce, @@ -433,6 +588,8 @@ criterion_group!( bench_serve_idle, bench_subscribe, bench_request, - bench_handoff + bench_handoff, + bench_narrow_delivery, + bench_narrow ); criterion_main!(benches); From 196bb9aad76e775c7b86673fe14f81d68383d781 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 16:20:39 -0700 Subject: [PATCH 23/53] chore: fmt and doc touch-ups Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/auth.rs | 4 +++- rs/moq-net/src/ietf/publisher.rs | 4 +--- rs/moq-net/src/ietf/subscriber.rs | 8 ++++++-- rs/moq-net/src/lite/publisher.rs | 3 ++- rs/moq-net/src/lite/subscriber.rs | 11 +++++++++-- rs/moq-net/tests/auth.rs | 5 +---- rs/moq-relay/src/auth.rs | 6 +++++- rs/moq-relay/tests/auth_lifetime.rs | 14 +++++++++++--- 8 files changed, 38 insertions(+), 17 deletions(-) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 2833c5b703..214c5818b3 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -504,7 +504,9 @@ impl Handle { /// Whether the ceiling alone allows `direction` at `path`: for a route the peer /// offers us, which our own grant does not decide. pub(crate) fn within_ceiling(&self, direction: Direction, path: &str) -> bool { - self.state.read().parts(direction).1.is_none_or(|ceiling| ceiling.matches(path)) + let state = self.state.read(); + let (_, ceiling) = state.parts(direction); + ceiling.is_none_or(|ceiling| ceiling.matches(path)) } /// The peer turned out not to negotiate AUTH: fail every token as unsupported and diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 9a28ed8f54..7dbe052595 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -1930,9 +1930,7 @@ where } // A grant change applies before the next update, so a namespace it no // longer covers is withdrawn rather than re-sent. - if let Poll::Ready(permit) = - self.auth.poll_permit(crate::auth::Direction::Publish, epoch, waiter) - { + if let Poll::Ready(permit) = self.auth.poll_permit(crate::auth::Direction::Publish, epoch, waiter) { return Poll::Ready(NamespaceEvent::Regrant(permit)); } if let Poll::Ready(update) = announced.poll_next(waiter) { diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index e826192b28..071090d6ae 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -1379,7 +1379,10 @@ where } Entry::Vacant(entry) => { // Nothing the session no longer lets the peer publish is accepted. - if !self.auth.within_ceiling(crate::auth::Direction::Subscribe, path.as_str()) { + if !self + .auth + .within_ceiling(crate::auth::Direction::Subscribe, path.as_str()) + { return Err(Error::Unauthorized); } // Propagates Error::Unauthorized if the namespace is out of scope. @@ -1470,7 +1473,8 @@ where // own gates, with `Unauthorized`. let mut narrowed = false; while let Poll::Ready(permit) = - self.auth.poll_permit(crate::auth::Direction::Subscribe, &mut epoch, waiter) + self.auth + .poll_permit(crate::auth::Direction::Subscribe, &mut epoch, waiter) { narrowed |= !permit.within_ceiling(path.as_str()); } diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index b7e3ed7198..0c9605999f 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -1111,7 +1111,8 @@ impl AnnounceRun { // advertises something it would withdraw, or abort over, a moment later. if let Some(auth) = &self.auth { ready!(auth.poll_setup_answered(waiter)); - if let Poll::Ready(permit) = auth.poll_permit(crate::auth::Direction::Publish, &mut self.epoch, waiter) { + if let Poll::Ready(permit) = auth.poll_permit(crate::auth::Direction::Publish, &mut self.epoch, waiter) + { self.permit = permit; } } diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 2cb602d0a7..cbe26042f4 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -339,7 +339,10 @@ impl Subscriber { /// this session on demand. `None` when we may not accept it: outside our origin's /// scope, or outside what the session still lets the peer publish. fn serve_route(&self, path: &Path, route: crate::origin::Route) -> Option { - if !self.auth.within_ceiling(crate::auth::Direction::Subscribe, path.as_str()) { + if !self + .auth + .within_ceiling(crate::auth::Direction::Subscribe, path.as_str()) + { tracing::debug!(route = %self.log_path(path), "declining announce outside the narrowed grant"); return None; } @@ -1711,7 +1714,11 @@ mod tests { let broadcast = crate::broadcast::Info::new().produce(); let request = broadcast.reserve_track("audio").unwrap(); let mut serving = ServeLoop::new(&serve, request, Default::default(), Some(Timescale::default())); - let establish = serve.prepare_establish(&mut serving.serving, Subscription::default(), Some(Timescale::default())); + let establish = serve.prepare_establish( + &mut serving.serving, + Subscription::default(), + Some(Timescale::default()), + ); serving.mode = ServeMode::Establish(establish); let gate_run = serve.gate(); let mut running = TrackServeRun { diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs index 458e68e33a..4f2a1aaeef 100644 --- a/rs/moq-net/tests/auth.rs +++ b/rs/moq-net/tests/auth.rs @@ -992,10 +992,7 @@ macro_rules! narrow_cases { }; } -narrow_cases!( - a_narrowing_deafens_one_path, - a_narrowing_aborts_what_the_peer_published -); +narrow_cases!(a_narrowing_deafens_one_path, a_narrowing_aborts_what_the_peer_published); #[tokio::test] async fn lite_05_narrowing_resets_a_fetch_in_flight() { diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index 09864a8292..266384d407 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -561,7 +561,11 @@ impl Lease { /// Patterns as one comma-separated line, for logs. fn join(patterns: &Patterns) -> String { - patterns.iter().map(|pattern| pattern.as_str()).collect::>().join(",") + patterns + .iter() + .map(|pattern| pattern.as_str()) + .collect::>() + .join(",") } enum Decider { diff --git a/rs/moq-relay/tests/auth_lifetime.rs b/rs/moq-relay/tests/auth_lifetime.rs index 16c3c0049d..0412d69b6f 100644 --- a/rs/moq-relay/tests/auth_lifetime.rs +++ b/rs/moq-relay/tests/auth_lifetime.rs @@ -4,8 +4,9 @@ //! or QUIC) or its axum WebSocket path (`serve_ws` over `ws://`), points it at a //! scripted auth server, connects a publisher and a subscriber, confirms media //! flows, then asserts the relay follows the server's word: a re-check that moves -//! the tier retags the live session's stats, a narrower grant or a refusal closes it, an outage -//! keeps it until `expires`, and every close reports `end` with what it moved. +//! the tier retags the live session's stats, a narrower grant narrows it in place, a +//! moved root or a refusal closes it, an outage keeps it until `expires`, and every +//! close reports `end` with what it moved. //! The last tests swap the server for an in-process decider answering //! `Admissions`, and prove the lease it drives reaches the session the same way. @@ -609,7 +610,14 @@ async fn a_narrower_grant_narrows_live_sessions() { .await .expect("announcement timeout") .expect("announced broadcast resolves"); - subs.push(broadcast.track("media").unwrap().subscribe(None).await.expect("subscribe")); + subs.push( + broadcast + .track("media") + .unwrap() + .subscribe(None) + .await + .expect("subscribe"), + ); } let send = |index: usize, sequence: u64| { let mut group = tracks[index].1.append_group().expect("append group"); From f211e7b6cabcccaf67aecf17586c9348fecac8d2 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 16:22:04 -0700 Subject: [PATCH 24/53] quest(auth): validate the IETF AUTH request ID too Co-Authored-By: Claude Opus 5.5 --- quest/m1/auth/violations.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/quest/m1/auth/violations.md b/quest/m1/auth/violations.md index f04a20f7b9..01ae990e25 100644 --- a/quest/m1/auth/violations.md +++ b/quest/m1/auth/violations.md @@ -11,7 +11,7 @@ decode, such as a bad pattern or length. ## Plan [AUTH endings](/quest/m1/auth/error-codes.md) (#4550) closes the session only -on an explicit `ProtocolViolation` in Rust lite, and left four gaps: +on an explicit `ProtocolViolation` in Rust lite. These gaps remain: - JS reports the exact oversized code but doesn't close the session. Let the JS connection close on an `AuthSession` protocol violation. @@ -22,6 +22,9 @@ on an explicit `ProtocolViolation` in Rust lite, and left four gaps: - On the acceptor side, a presenter's AUTH that fails to decode only aborts its stream (Rust lite `AuthServe`, JS `#runBidis`), so a peer can repeat malformed AUTH streams without closing the session. +- Both IETF AUTH acceptors (Rust `Serve::run`, JS `IetfAuthWire.accept`) + discard the decoded Request ID, so a reused ID is accepted. Validate it like + every other request, where a duplicate is session-fatal. Close the session on each, in both languages and on both protocols, with one regression test per case, and check the lite and IETF draft text agrees. Run From 7e773ee4e38b68003030deed7769420091392e20 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 16:52:37 -0700 Subject: [PATCH 25/53] test(relay): iterate subscriptions directly Co-Authored-By: Claude Opus 5.5 --- rs/moq-relay/tests/auth_lifetime.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rs/moq-relay/tests/auth_lifetime.rs b/rs/moq-relay/tests/auth_lifetime.rs index 0412d69b6f..3cf3fb0438 100644 --- a/rs/moq-relay/tests/auth_lifetime.rs +++ b/rs/moq-relay/tests/auth_lifetime.rs @@ -627,9 +627,9 @@ async fn a_narrower_grant_narrows_live_sessions() { group.finish().expect("finish group"); assert_eq!(group.sequence, sequence); }; - for index in 0..2 { + for (index, sub) in subs.iter_mut().enumerate() { send(index, 0); - tokio::time::timeout(TIMEOUT, subs[index].recv_group()) + tokio::time::timeout(TIMEOUT, sub.recv_group()) .await .expect("recv_group timeout") .expect("recv_group failed") From 5af62799dfb4b80eb5be56d3c61158dadadfa988 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 16:53:46 -0700 Subject: [PATCH 26/53] quest(auth): unknown reply types and a second AUTH, and audit the rest Co-Authored-By: Claude Opus 5.5 --- quest/m1/auth/violations.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/quest/m1/auth/violations.md b/quest/m1/auth/violations.md index 01ae990e25..204aa3da7a 100644 --- a/quest/m1/auth/violations.md +++ b/quest/m1/auth/violations.md @@ -1,4 +1,4 @@ -# [S] AUTH protocol violations close the session everywhere +# [M] AUTH protocol violations close the session everywhere ## Goal @@ -25,6 +25,14 @@ on an explicit `ProtocolViolation` in Rust lite. These gaps remain: - Both IETF AUTH acceptors (Rust `Serve::run`, JS `IetfAuthWire.accept`) discard the decoded Request ID, so a reused ID is accepted. Validate it like every other request, where a duplicate is session-fatal. +- An AUTH reply of an unknown type (neither AUTH_OK nor AUTH_ERROR) fails + the type dispatch before either decoder runs, and ends only the token. +- A second AUTH on an open IETF AUTH stream is never read: after the first, + Rust `Serve::run` and JS `AuthSession.serve` wait only for the stream to + close. Treat it like a repeated PUBLISH_NAMESPACE, a duplicate request. + +This list comes from review, not a full audit. Walk every AUTH read path on +both sides, both protocols, and both languages, and fold in anything missing. Close the session on each, in both languages and on both protocols, with one regression test per case, and check the lite and IETF draft text agrees. Run From 95c01d1720769341741c15861892b40015b5729e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 17:36:54 -0700 Subject: [PATCH 27/53] quest(auth): JS fetchGroup stops when its grant is revoked Co-Authored-By: Claude Opus 5.5 --- quest/m1/auth/README.md | 1 + quest/m1/auth/js-fetch-watch.md | 14 ++++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 quest/m1/auth/js-fetch-watch.md diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index 27d85b944a..f8e909b7d1 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -91,6 +91,7 @@ existing lite-06 ALPN. - [AUTH endings](/quest/m1/auth/error-codes.md) - an out-of-range AUTH_ERROR code is refused, and both sides settle and recompute grants when a stream ends - [AUTH violations](/quest/m1/auth/violations.md) - every AUTH protocol violation closes the session in Rust and JS, lite and IETF +- [JS fetch grant watch](/quest/m1/auth/js-fetch-watch.md) - a JS `fetchGroup` ends `Unauthorized` when its path leaves the grant - [Origin narrowing](/quest/m1/auth/narrowing.md) - a live grant narrows in place: subscriptions outside it reset, publishes outside it abort, and relay revalidation stops closing the session diff --git a/quest/m1/auth/js-fetch-watch.md b/quest/m1/auth/js-fetch-watch.md new file mode 100644 index 0000000000..4362cbd6ad --- /dev/null +++ b/quest/m1/auth/js-fetch-watch.md @@ -0,0 +1,14 @@ +# [XS] JS fetchGroup stops when its grant is revoked + +## Goal + +A `@moq/net` subscriber-side `fetchGroup` holds a grant watch from its first +check to its end, as subscriptions do, so a fetch whose path leaves the +session's grant ends with `Unauthorized` instead of finishing. + +## Plan + +Found by [Origin narrowing](/quest/m1/auth/narrowing.md) (#4560), which gave +every other JS request a watch armed before its first await. Follow the same +pattern, and add a regression test where the grant changes during setup and +mid-fetch. From 4da76543b728887d25707cd1cadc1e42f353458f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 17:50:29 -0700 Subject: [PATCH 28/53] feat(net): replace narrow with a symmetric auth::Handle::limit A limit replaces the last one, narrower or wider: widening re-announces what becomes visible, re-attaches the peer's withheld routes, and accepts requests again. Relay revalidation sets the limit to whatever the re-check returns. Co-Authored-By: Claude Opus 5.5 --- doc/bin/relay/auth.md | 13 +-- doc/lib/rs/moq-net.md | 21 ++-- quest/m1/auth/README.md | 2 +- quest/m1/auth/relay-refresh.md | 27 +++-- quest/m1/auth/request-token.md | 2 +- rs/moq-net/benches/origin.rs | 4 +- rs/moq-net/src/auth.rs | 162 ++++++++++++++-------------- rs/moq-net/src/client.rs | 2 +- rs/moq-net/src/ietf/auth.rs | 2 +- rs/moq-net/src/ietf/session.rs | 2 +- rs/moq-net/src/ietf/subscriber.rs | 104 ++++++++++++++---- rs/moq-net/src/lite/publisher.rs | 2 +- rs/moq-net/src/lite/subscriber.rs | 142 +++++++++++++++--------- rs/moq-net/tests/auth.rs | 151 +++++++++++++++++++++++--- rs/moq-relay/src/auth.rs | 63 ++++++----- rs/moq-relay/src/connection.rs | 6 +- rs/moq-relay/src/websocket.rs | 2 +- rs/moq-relay/tests/auth_lifetime.rs | 32 ++++-- 18 files changed, 492 insertions(+), 247 deletions(-) diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index 4bf255d303..45917d6a45 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -64,12 +64,13 @@ The client and relay snapshot each accepted grant's deadline on a monotonic clock, so later polls, outages, and wall-clock adjustments do not restart it. **Revalidate and outage.** On the cadence the relay POSTs `revalidate` with the -same request. A grant applies: one that no longer covers what the session holds -narrows the live session in place to what both grants allow, so a moderation -decision lands on the session it targets. Its subscriptions and fetches outside -the new grant reset with `Unauthorized`, the broadcasts it published outside -abort, and everything else keeps flowing; a wider part waits for a reconnect. A -one-shot HTTP `/fetch` ends instead. A +same request. A grant applies: its patterns resize the live session in place, +narrower or wider, so a moderation decision lands on the session it targets and +can be lifted the same way. Subscriptions and fetches outside a narrower grant +reset with `Unauthorized`, the broadcasts it published outside abort, and +everything else keeps flowing; a wider grant brings those paths back, up to what +the session was admitted with. A one-shot HTTP `/fetch` ends on a narrower grant +instead. A changed `root` or `mounts` closes the session with `Unauthorized`, and so does a flipped `peer`; a changed `tier` keeps the session and moves its stats: its presence counts under the new tier from then diff --git a/doc/lib/rs/moq-net.md b/doc/lib/rs/moq-net.md index 2b4b3bbe06..4e4c571b24 100644 --- a/doc/lib/rs/moq-net.md +++ b/doc/lib/rs/moq-net.md @@ -104,22 +104,27 @@ shrinks withdraws the announcements and cancels the subscriptions it no longer covers, and leaves the session up. On moq-lite each cancelled stream resets with `StreamError::Unauthorized`. -`session.auth().narrow(&grant)` takes access away from the peer on a live session, -on every version, whether or not the peer speaks AUTH: the session enforces it -itself. It only ever narrows, keeping what both the current ceiling and `grant` -allow. What falls outside ends at once: announcements to the peer retract, its -new requests are refused, its subscriptions and fetches reset with +`session.auth().limit(&grant)` sets the most the peer may do on a live session, +replacing any earlier limit, on every version, whether or not the peer speaks +AUTH: the session enforces it itself, and never past its origin handles. A +narrower limit ends what falls outside at once: announcements to the peer +retract, its new requests are refused, its subscriptions and fetches reset with `Unauthorized`, and the broadcasts it published abort, so local readers see -`Unauthorized` too. The rest of the session carries on. When the session answers -the peer's connection credential itself, the peer is sent the narrowed grant. +`Unauthorized` too. A wider one brings back what the old limit held back: +announcements are made again, the peer's own announcements return to the origin, +and new requests are accepted. The rest of the session carries on. When the +session answers the peer's connection credential itself, the peer is sent its +grant within the limit. ```rust // Deafen alice's audio: the peer may still subscribe to her video. let video: Pattern = "room/alice/video/**".parse()?; -session.auth().narrow(&auth::Grant { +session.auth().limit(&auth::Grant { subscribe: video.into(), ..auth::Grant::all() }); +// Undeafen. +session.auth().limit(&auth::Grant::all()); ``` ## Patterns diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index 56a7bc337f..f22d0f3353 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -39,7 +39,7 @@ Decisions settled while planning, recorded so review does not relitigate them: the union and cancels publications and subscriptions that lose authorization. Other authorized work continues on the same session. An empty union leaves the session connected with no access, so it can accept a fresh token. - `auth::Handle::narrow` is the common resize operation; relay token handling + `auth::Handle::limit` is the common resize operation; relay token handling uses it rather than shipping a temporary close-on-shrink policy. - **A public grant contains publish patterns, subscribe patterns, and an expiry**, in the presenter's own root; the presenter never sees the relay-side diff --git a/quest/m1/auth/relay-refresh.md b/quest/m1/auth/relay-refresh.md index 677da06b6f..00813f65fc 100644 --- a/quest/m1/auth/relay-refresh.md +++ b/quest/m1/auth/relay-refresh.md @@ -30,29 +30,28 @@ expiry, an expiry that leaves the union intact ends only that token, and sessions reject a non-empty token as `Unsupported`. - Union: the connection holds the set of accepted leases. Every one must carry the admitted root, else `AUTH_ERROR { Unauthorized }` naming the - root. The session's origin handles are rebuilt through `Cluster::publisher` - and `Cluster::subscriber` from the union of the set's publish and subscribe - prefixes, intersected with the role the client declared at SETUP so a + root. The session's limit is the union of the set's publish and subscribe + patterns, intersected with the role the client declared at SETUP so a publish-only session never starts receiving announcements because a later - token happened to carry subscribe prefixes, and swapped in whenever the set - grows; keep it behind one function, and a shrinking union goes through - `auth::Handle::narrow` rather than a close. `narrow` only ever narrows, so - a union that grows back after a shrink also needs the session's ceiling - replaced, not just new origin handles. + token happened to carry subscribe prefixes, and set through + `auth::Handle::limit` whenever the union changes, grown or shrunk; keep it + behind one function. A limit never reaches past the session's origin handles, + so scope those to the admitted root and role rather than to the first + token's patterns, and nothing is rebuilt or swapped in. - Expiry: the deadline today is the admitted lease's `closed()` inside one `tokio::select!` arm. Select on one lease per token in the set instead, and on any firing recompute the union without it: unchanged means `AUTH_ERROR { Expired }` on that token's stream and the session - continues, shrunk narrows the origin and resets each stream that lost - access with `UNAUTHORIZED`. A token + continues, shrunk sets the smaller limit, which resets each stream that + lost access with `UNAUTHORIZED`. A token withdrawn by the client (its stream closed or reset) recomputes the union the same way with nothing written back, since the stream is gone: unchanged - means the session continues, shrunk narrows the same way. Each lease revalidates on + means the session continues, shrunk sets the limit the same way. Each lease revalidates on its own cadence, and every re-check that changes the grant, a lower `expires` or a different `publish` or `subscribe`, writes the complete replacement grant as an update AUTH_OK on that token's stream and recomputes the union from the latest grant of every token, so the client can present a replacement in time and a narrowed token never keeps its old - scope; a shrunk union narrows as above. + scope; a changed union sets the limit as above. - Refusals map as connect-time ones do and never expose a status: every refusal `Client::attach` returns for a new token, a `403`, an empty or invalid grant, an unparseable body, or a `5xx`, is @@ -74,8 +73,8 @@ expiry, an expiry that leaves the union intact ends only that token, and - Tests: a second token with a later `exp` and the same scope keeps the session past the first token's expiry, with only the first stream ending; an expired or invalid token is refused and nothing changes; a token with a - different root is refused naming it; a token adding a prefix rebuilds the - origin scope and the prefix is now solicited; withdrawing the only token + different root is refused naming it; a token adding a prefix widens the + limit and the prefix is now solicited; withdrawing the only token covering a prefix resets that prefix's streams with `UNAUTHORIZED` and the session stays connected; an mTLS session refuses a token; a proxy-mode outage on re-check keeps the token. diff --git a/quest/m1/auth/request-token.md b/quest/m1/auth/request-token.md index b9635432fd..f31f9094b9 100644 --- a/quest/m1/auth/request-token.md +++ b/quest/m1/auth/request-token.md @@ -40,7 +40,7 @@ on the unknown key, and the legacy drafts silently ignore it. until it lapses. When a request's grant expires or is revoked, that request alone ends with `EXPIRED_AUTH_TOKEN` or `UNAUTHORIZED`. A session grant that shrinks cancels the requests it covered, as for any request, through - `auth::Handle::narrow`. + `auth::Handle::limit`. - Relay: each such request gets its own lease from a per-request call on `moq_auth::Client`, not the `Client::attach` that [Relay tokens](/quest/m1/auth/relay-refresh.md) builds. `attach` connects with the diff --git a/rs/moq-net/benches/origin.rs b/rs/moq-net/benches/origin.rs index 0b0e4193c4..d1d426ee69 100644 --- a/rs/moq-net/benches/origin.rs +++ b/rs/moq-net/benches/origin.rs @@ -540,7 +540,7 @@ fn bench_narrow_delivery(c: &mut Criterion) { // Covers every subscription but the muted one, which is not subscribed here. let mut grant = room(); grant.subscribe.insert(Pattern::literal("muted").unwrap()); - watching.pair.server.auth().narrow(&grant); + watching.pair.server.auth().limit(&grant); } b.iter(|| rt.block_on(watching.round())); }); @@ -566,7 +566,7 @@ fn bench_narrow(c: &mut Criterion) { for _ in 0..iters { let mut watching = rt.block_on(watching(routes, subscribers)); let start = std::time::Instant::now(); - watching.pair.server.auth().narrow(&room()); + watching.pair.server.auth().limit(&room()); let ended = rt.block_on(watching.subscriptions[0].recv_group()); elapsed += start.elapsed(); assert!(ended.is_err(), "the muted subscription outlived the narrowing"); diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 214c5818b3..a8f19e4cd0 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -11,8 +11,8 @@ //! grants what its own origin handles allow. An application that verifies tokens //! itself takes [`requests`](Handle::requests) before running the session's //! driver, and then answers every token the peer presents. Either way, -//! [`narrow`](Handle::narrow) takes access away from the peer on a live session, -//! on every version, whether or not it speaks AUTH. +//! [`limit`](Handle::limit) resizes what the peer may do on a live session, on +//! every version, whether or not it speaks AUTH. //! //! moq-transport draft-17+ carries the same exchange when both sides negotiate the //! MoQ Auth extension. Older versions, and peers that do not negotiate it, carry no @@ -114,10 +114,10 @@ pub(crate) struct State { union: Option, /// The peer replied to some token, so the union is known even when empty. replied: bool, - /// The most this side lets the peer do, in the peer's terms: `None` until - /// [`Handle::narrow`], then only ever narrower. - ceiling: Option, - /// Bumped whenever the union or the ceiling changes, so the session's per-stream + /// The most this side lets the peer do, in the peer's terms: `None` (only the + /// origin handles bound it) until [`Handle::limit`] sets it. + limit: Option, + /// Bumped whenever the union or the limit changes, so the session's per-stream /// gates can skip re-matching paths on every wakeup. epoch: u64, acceptor: Acceptor, @@ -171,58 +171,58 @@ impl State { self.closed.clone() } - /// The union's and the ceiling's patterns for `direction`, each `None` while + /// The union's and the limit's patterns for `direction`, each `None` while /// unrestricted. fn parts(&self, direction: Direction) -> (Option<&Patterns>, Option<&Patterns>) { - // The peer's grant names what we may do; the ceiling names what the peer may, + // The peer's grant names what we may do; the limit names what the peer may, // so what we send is what the peer may subscribe to, and the other way around. match direction { Direction::Publish => ( self.union.as_ref().map(|union| &union.publish), - self.ceiling.as_ref().map(|ceiling| &ceiling.subscribe), + self.limit.as_ref().map(|limit| &limit.subscribe), ), Direction::Subscribe => ( self.union.as_ref().map(|union| &union.subscribe), - self.ceiling.as_ref().map(|ceiling| &ceiling.publish), + self.limit.as_ref().map(|limit| &limit.publish), ), } } /// What `direction` allows right now. fn permit(&self, direction: Direction) -> Permit { - let (granted, ceiling) = self.parts(direction); + let (granted, limit) = self.parts(direction); Permit { granted: granted.cloned(), - ceiling: ceiling.cloned(), + limit: limit.cloned(), } } /// Whether `direction` allows `path` right now, without copying the patterns. fn allows(&self, direction: Direction, path: &str) -> bool { - let (granted, ceiling) = self.parts(direction); - granted.is_none_or(|granted| granted.matches(path)) && ceiling.is_none_or(|ceiling| ceiling.matches(path)) + let (granted, limit) = self.parts(direction); + granted.is_none_or(|granted| granted.matches(path)) && limit.is_none_or(|limit| limit.matches(path)) } } /// What one direction of a session allows: the grant the peer gave this side (the -/// union of its tokens) and the ceiling this side narrowed the peer to, each `None` +/// union of its tokens) and the limit this side set on the peer, each `None` /// while unrestricted. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub(crate) struct Permit { granted: Option, - ceiling: Option, + limit: Option, } impl Permit { /// Whether both allow `path`. pub(crate) fn matches(&self, path: &str) -> bool { - self.granted.as_ref().is_none_or(|granted| granted.matches(path)) && self.within_ceiling(path) + self.granted.as_ref().is_none_or(|granted| granted.matches(path)) && self.within_limit(path) } - /// Whether the ceiling alone allows `path`: for what the peer offers us, which our + /// Whether the limit alone allows `path`: for what the peer offers us, which our /// own grant does not decide. - pub(crate) fn within_ceiling(&self, path: &str) -> bool { - self.ceiling.as_ref().is_none_or(|ceiling| ceiling.matches(path)) + pub(crate) fn within_limit(&self, path: &str) -> bool { + self.limit.as_ref().is_none_or(|limit| limit.matches(path)) } /// Whether this allows everything `other` does, judged part by part, so a `false` @@ -233,7 +233,7 @@ impl Permit { (Some(_), None) => false, (Some(this), Some(other)) => this.covers(other), }; - covers(&self.granted, &other.granted) && covers(&self.ceiling, &other.ceiling) + covers(&self.granted, &other.granted) && covers(&self.limit, &other.limit) } } @@ -439,27 +439,25 @@ impl Handle { Poll::Ready(()) } - /// Narrow what the peer may do on this session to at most `grant`, in the - /// session's own paths. + /// Set the most the peer may do on this session to `grant`, in the session's own + /// paths, replacing any earlier limit. The session's origin handles still bound it, + /// so a limit never grants more than they allow. /// - /// A narrowing only ever takes away: the session keeps what both its current - /// ceiling and `grant` allow, so a wider grant changes nothing. What falls outside - /// ends at once, with [`Error::Unauthorized`] where it has a reader: announcements - /// to the peer retract, its new requests are refused, its subscriptions reset, and - /// the broadcasts it published abort. The rest of the session carries on. It works - /// on every version, since the session enforces it without the peer's help. + /// A narrower limit ends what falls outside at once, with [`Error::Unauthorized`] + /// where it has a reader: announcements to the peer retract, its new requests are + /// refused, its subscriptions reset, and the broadcasts it published abort. A wider + /// one brings back what the old limit held back: announcements to the peer are made + /// again, and so are the peer's announcements it withheld from the origin, and new + /// requests are accepted. The rest of the session carries on. It works on every + /// version, since the session enforces it without the peer's help. /// /// When the session answers the peer's connection credential itself, the peer is - /// told the narrowed grant, `expires` included. An application answering tokens - /// through [`requests`](Self::requests) updates its own [`Issued`] grants. - pub fn narrow(&self, grant: &Grant) { + /// told its grant within the limit, `expires` included. An application answering + /// tokens through [`requests`](Self::requests) updates its own [`Issued`] grants. + pub fn limit(&self, grant: &Grant) { let mut state = self.state.lock(); - let ceiling = match &state.ceiling { - Some(ceiling) => ceiling.intersect(grant), - None => grant.clone(), - }; - if state.ceiling.as_ref() != Some(&ceiling) { - state.ceiling = Some(ceiling); + if state.limit.as_ref() != Some(grant) { + state.limit = Some(grant.clone()); state.epoch += 1; } } @@ -480,11 +478,11 @@ impl Handle { Poll::Ready(state.permit(direction)) } - /// The ceiling once it may have changed since `epoch` last saw it, advancing `epoch`. - pub(crate) fn poll_ceiling(&self, epoch: &mut u64, waiter: &kio::Waiter) -> Poll> { + /// The limit once it may have changed since `epoch` last saw it, advancing `epoch`. + pub(crate) fn poll_limit(&self, epoch: &mut u64, waiter: &kio::Waiter) -> Poll> { let state = ready_or!(self.poll_epoch(*epoch, waiter)); *epoch = state.epoch; - Poll::Ready(state.ceiling.clone()) + Poll::Ready(state.limit.clone()) } fn poll_epoch(&self, seen: u64, waiter: &kio::Waiter) -> Poll> { @@ -496,17 +494,17 @@ impl Handle { /// Whether `direction` allows `path` right now: the union (allowing everything /// while it is `None`, before an answer or on a version without AUTH) and the - /// ceiling both. + /// limit both. pub(crate) fn allows(&self, direction: Direction, path: &str) -> bool { self.state.read().allows(direction, path) } - /// Whether the ceiling alone allows `direction` at `path`: for a route the peer + /// Whether the limit alone allows `direction` at `path`: for a route the peer /// offers us, which our own grant does not decide. - pub(crate) fn within_ceiling(&self, direction: Direction, path: &str) -> bool { + pub(crate) fn within_limit(&self, direction: Direction, path: &str) -> bool { let state = self.state.read(); - let (_, ceiling) = state.parts(direction); - ceiling.is_none_or(|ceiling| ceiling.matches(path)) + let (_, limit) = state.parts(direction); + limit.is_none_or(|limit| limit.matches(path)) } /// The peer turned out not to negotiate AUTH: fail every token as unsupported and @@ -843,9 +841,9 @@ impl Drop for Issued { /// Which way media flows, from this side's view. #[derive(Clone, Copy)] pub(crate) enum Direction { - /// This side sends to the peer: our grant's `publish`, and the ceiling's `subscribe`. + /// This side sends to the peer: our grant's `publish`, and the limit's `subscribe`. Publish, - /// The peer sends to this side: our grant's `subscribe`, and the ceiling's `publish`. + /// The peer sends to this side: our grant's `subscribe`, and the limit's `publish`. Subscribe, } @@ -854,7 +852,7 @@ pub(crate) enum Direction { /// /// Create it at the request's first check and hold it to the end, so a change /// landing in between is never missed. Cheap to poll on every wakeup: the path is -/// only re-matched when the union or the ceiling changes. +/// only re-matched when the union or the limit changes. pub(crate) struct Gate { handle: Handle, path: crate::PathOwned, @@ -873,7 +871,7 @@ impl Gate { } /// Resolve once the session no longer allows the path. A union that is still - /// `None` (no answer yet, or a version without AUTH) and a session never narrowed + /// `None` (no answer yet, or a version without AUTH) and a session never limited /// allow everything. pub(crate) fn poll_denied(&mut self, waiter: &kio::Waiter) -> Poll<()> { loop { @@ -886,35 +884,35 @@ impl Gate { } /// The default acceptor's answer to the peer's connection credential: the grant -/// the session's origin handles allow, narrowed by the ceiling as it changes. +/// the session's origin handles allow, within the limit as it changes. pub(crate) struct DefaultGrant { handle: Handle, base: Grant, epoch: u64, - ceiling: Option, + limit: Option, /// The grant last handed out, so an unrelated wakeup sends nothing. sent: Option, } impl DefaultGrant { pub(crate) fn new(handle: Handle, base: Grant) -> Self { - let ceiling = handle.state.read().ceiling.clone(); + let limit = handle.state.read().limit.clone(); Self { handle, base, epoch: 0, - ceiling, + limit, sent: None, } } - /// The grant to send next: the first, then each one a narrowing changes. + /// The grant to send next: the first, then each one a new limit changes. pub(crate) fn poll(&mut self, waiter: &kio::Waiter) -> Poll { - while let Poll::Ready(ceiling) = self.handle.poll_ceiling(&mut self.epoch, waiter) { - self.ceiling = ceiling; + while let Poll::Ready(limit) = self.handle.poll_limit(&mut self.epoch, waiter) { + self.limit = limit; } - let grant = match &self.ceiling { - Some(ceiling) => self.base.intersect(ceiling), + let grant = match &self.limit { + Some(limit) => self.base.intersect(limit), None => self.base.clone(), }; if self.sent.as_ref() == Some(&grant) { @@ -1009,49 +1007,49 @@ mod tests { } } - /// A narrowing keeps only what every narrowing so far allows, so a wider one - /// changes nothing, and it applies to a version without AUTH too. + /// A limit replaces the last one, narrower or wider, and applies to a version + /// without AUTH too. #[test] - fn a_narrowing_only_ever_narrows() { + fn a_limit_replaces_the_last() { let handle = Handle::new(false); assert!(handle.allows(Direction::Publish, "room/alice/audio")); - handle.narrow(&grant(&["room/bob"], &["room/alice"])); + handle.limit(&grant(&["room/bob"], &["room/alice"])); // What we send is what the peer may subscribe to, and the other way around. assert!(handle.allows(Direction::Publish, "room/alice/audio")); assert!(!handle.allows(Direction::Publish, "room/bob/cam")); assert!(handle.allows(Direction::Subscribe, "room/bob/cam")); assert!(!handle.allows(Direction::Subscribe, "room/alice/audio")); - handle.narrow(&grant(&[""], &["room/alice/video"])); + handle.limit(&grant(&[""], &["room/alice/video"])); assert!(!handle.allows(Direction::Publish, "room/alice/audio")); assert!(handle.allows(Direction::Publish, "room/alice/video")); - assert!(handle.allows(Direction::Subscribe, "room/bob/cam")); + assert!(handle.allows(Direction::Subscribe, "other")); - handle.narrow(&Grant::all()); - assert!(!handle.allows(Direction::Publish, "room/alice/audio")); - assert!(!handle.allows(Direction::Subscribe, "other")); + handle.limit(&Grant::all()); + assert!(handle.allows(Direction::Publish, "room/alice/audio")); + assert!(handle.allows(Direction::Subscribe, "other")); } - /// Our own grant does not decide what the peer may offer us, only the ceiling does. + /// Our own grant does not decide what the peer may offer us, only the limit does. #[test] - fn the_ceiling_alone_decides_what_the_peer_offers() { + fn the_limit_alone_decides_what_the_peer_offers() { let handle = Handle::new(true); let token = handle.present(Bytes::new(), true).unwrap(); handle.granted(0, grant(&[], &[])); assert!(!handle.allows(Direction::Subscribe, "room/bob/cam")); - assert!(handle.within_ceiling(Direction::Subscribe, "room/bob/cam")); + assert!(handle.within_limit(Direction::Subscribe, "room/bob/cam")); - handle.narrow(&grant(&["room/alice"], &[])); - assert!(!handle.within_ceiling(Direction::Subscribe, "room/bob/cam")); - assert!(handle.within_ceiling(Direction::Subscribe, "room/alice/cam")); + handle.limit(&grant(&["room/alice"], &[])); + assert!(!handle.within_limit(Direction::Subscribe, "room/bob/cam")); + assert!(handle.within_limit(Direction::Subscribe, "room/alice/cam")); drop(token); } - /// The default acceptor answers with its base grant, then with each grant a - /// narrowing changes, and stays quiet on a change that leaves it alone. + /// The default acceptor answers with its base grant, then with each grant a new + /// limit changes, narrower or wider, and stays quiet on a change that leaves it alone. #[test] - fn the_default_grant_follows_the_ceiling() { + fn the_default_grant_follows_the_limit() { let handle = Handle::new(true); let mut default = DefaultGrant::new(handle.clone(), grant(&["room"], &["room"])); let waiter = kio::Waiter::noop(); @@ -1059,11 +1057,15 @@ mod tests { assert_eq!(default.poll(&waiter), Poll::Ready(grant(&["room"], &["room"]))); assert!(default.poll(&waiter).is_pending()); - handle.narrow(&grant(&["room"], &["room/alice"])); + handle.limit(&grant(&["room"], &["room/alice"])); assert_eq!(default.poll(&waiter), Poll::Ready(grant(&["room"], &["room/alice"]))); - // Wider than the ceiling already is: nothing to tell the peer. - handle.narrow(&Grant::all()); + // Wider again, but never past what the origin handles allow. + handle.limit(&Grant::all()); + assert_eq!(default.poll(&waiter), Poll::Ready(grant(&["room"], &["room"]))); + + // A limit that leaves the grant as it is: nothing to tell the peer. + handle.limit(&grant(&[""], &[""])); assert!(default.poll(&waiter).is_pending()); } } diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index 663e3f34b3..91cafad15f 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -408,7 +408,7 @@ impl Client { }; let stream = stream.with_version(v); - // Draft 14-16 carry no AUTH, but the session still narrows through this handle. + // Draft 14-16 carry no AUTH, but the session is still limited through this handle. let auth = crate::auth::Handle::new(false); // Draft 14-16: the path rode in the bidi SETUP above, not the uni one. let (protocol, goaway) = ietf::start(ietf::Config { diff --git a/rs/moq-net/src/ietf/auth.rs b/rs/moq-net/src/ietf/auth.rs index bc65d529e8..cf7e591ba7 100644 --- a/rs/moq-net/src/ietf/auth.rs +++ b/rs/moq-net/src/ietf/auth.rs @@ -413,7 +413,7 @@ impl Serve { } return; } - // Re-sent whenever a narrowing changes it. + // Re-sent whenever a new limit changes it. None => default = Some(crate::auth::DefaultGrant::new(self.handle.clone(), self.peer_grant)), } diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index 9d6a963996..45141d585d 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -154,7 +154,7 @@ where let control = Control::new(request_id_max, client); let adapter = ControlStreamAdapter::new(session.clone(), control.clone(), version); - // No AUTH on these drafts, but a narrowing still reaches both halves. + // No AUTH on these drafts, but a limit still reaches both halves. let publisher = Publisher::new( runtime.clone(), adapter.clone(), diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 071090d6ae..ae86883946 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -393,10 +393,14 @@ struct BroadcastState { route: crate::origin::Route, // The served route: dropping it (and the serve task's clone) retracts the - // route and rejects its queued requests. `None` once a narrowing took it away - // while the peer still advertises it. + // route and rejects its queued requests. `None` while the session's limit holds + // it back, though the peer still advertises it. dynamic: Option, + // Bumped each time the route attaches, so a serve task outlived by a limit that + // took the route away and gave it back ends rather than serve beside the new one. + generation: u64, + // active number of PUBLISH_NAMESPACE messages. count: usize, @@ -515,12 +519,65 @@ where } } - /// Bound what we subscribe to by the grant this session's tokens earn (MoQ Auth). + /// Bound what we subscribe to by the grant this session's tokens earn (MoQ Auth), + /// and what the peer may publish to us by the session's limit, as either changes. pub fn with_auth(mut self, auth: crate::auth::Handle) -> Self { self.auth = auth; + let this = self.clone(); + self.tasks.push(async move { this.run_limit().await }); self } + /// Follow the session's limit, attaching every withheld namespace a new limit + /// covers. Each route's own serve task holds itself back when a limit no longer + /// covers it. + async fn run_limit(&self) { + let mut epoch = 0; + loop { + let permit = kio::wait(|waiter| { + self.auth + .poll_permit(crate::auth::Direction::Subscribe, &mut epoch, waiter) + }) + .await; + let mut state = self.state.lock(); + let mut attached = Vec::new(); + for (path, entry) in state.broadcasts.iter_mut() { + let allowed = permit.within_limit(path.as_str()); + match &entry.dynamic { + None if allowed => { + let mut route = entry.route.clone(); + if self.going_away.is_set() { + route.cost = crate::origin::Cost::DRAIN; + } + let Ok(dynamic) = self.origin.dynamic(path, route) else { + continue; + }; + tracing::info!(route = %self.origin.absolute(path), "namespace authorized again"); + entry.dynamic = Some(dynamic); + entry.generation += 1; + attached.push((path.clone(), entry.generation)); + } + _ => {} + } + } + drop(state); + for (path, generation) in attached { + self.serve_route(path, generation); + } + } + } + + /// Serve the requests beneath one attached namespace on its own task. + fn serve_route(&self, path: PathOwned, generation: u64) { + let this = self.clone(); + self.tasks.push(async move { + // stop_announce is the authoritative remover: it drops the entry + // (retracting the route) once the announce refcount hits zero, + // which is what makes run_route exit, as does a limit holding it back. + this.run_route(path, generation).await; + }); + } + /// End every active subscription with the error that ended the session. pub fn abort(&self, err: &Error) { self.state.lock().abort(err); @@ -1378,12 +1435,18 @@ where Ok(()) } Entry::Vacant(entry) => { - // Nothing the session no longer lets the peer publish is accepted. - if !self - .auth - .within_ceiling(crate::auth::Direction::Subscribe, path.as_str()) - { - return Err(Error::Unauthorized); + // Outside the session's limit: held, not refused, so a wider limit can + // attach it while the peer still advertises it. + if !self.auth.within_limit(crate::auth::Direction::Subscribe, path.as_str()) { + tracing::debug!(route = %self.origin.absolute(&path), "withholding announce outside the limit"); + entry.insert(BroadcastState { + route, + dynamic: None, + generation: 0, + count: 1, + sources: HashMap::new(), + }); + return Ok(()); } // Propagates Error::Unauthorized if the namespace is out of scope. let dynamic = self.origin.dynamic(&path, route.clone())?; @@ -1391,19 +1454,13 @@ where entry.insert(BroadcastState { route, dynamic: Some(dynamic), + generation: 0, count: 1, sources: HashMap::new(), }); tracing::debug!(route = %self.origin.absolute(&path), "announce"); - - let this = self.clone(); - self.tasks.push(async move { - // stop_announce is the authoritative remover: it drops the entry - // (retracting the route) once the announce refcount hits zero, - // which is what makes run_route exit. - this.run_route(path).await; - }); + self.serve_route(path, 0); Ok(()) } @@ -1450,7 +1507,7 @@ where /// per requested path and serve its track requests until the route is /// retracted or the session dies. Tracks in flight at a retraction run to /// their own end. - async fn run_route(&self, path: PathOwned) { + async fn run_route(&self, path: PathOwned, generation: u64) { let mut broadcasts = TaskSet::owned(); let mut closed_session = self.session.clone(); let mut epoch = 0; @@ -1468,15 +1525,15 @@ where if self.going_away.poll(waiter).is_ready() { self.drain_route(&path); } - // A narrowing that excludes the namespace takes the route away as a + // A limit that no longer covers the namespace holds the route back as a // retraction would, closing its sources. Tracks in flight end on their // own gates, with `Unauthorized`. - let mut narrowed = false; + let mut excluded = false; while let Poll::Ready(permit) = self.auth .poll_permit(crate::auth::Direction::Subscribe, &mut epoch, waiter) { - narrowed |= !permit.within_ceiling(path.as_str()); + excluded = !permit.within_limit(path.as_str()); } // The route lives in the entry: stop_announce removing it retracts // the route, and this loop ends with it. @@ -1484,7 +1541,10 @@ where let Some(entry) = state.broadcasts.get_mut(&path) else { return Poll::Ready(None); }; - if narrowed && entry.dynamic.is_some() { + if entry.generation != generation { + return Poll::Ready(None); + } + if excluded && entry.dynamic.is_some() { tracing::info!(route = %self.origin.absolute(&path), "namespace no longer authorized"); entry.dynamic = None; entry.sources.clear(); diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 0c9605999f..cbff7dea57 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -358,7 +358,7 @@ struct AuthServe { /// filled in by the default acceptor. issue: Option>, /// The default acceptor's grant for the connection's credential, re-sent as a - /// narrowing changes it. `None` when the app answers. + /// new limit changes it. `None` when the app answers. default: Option, /// Our side is finished: FIN sent, waiting for the acknowledgement. finished: bool, diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index cbe26042f4..07995ddfd0 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -326,27 +326,7 @@ impl Subscriber { // outside our scope, so don't serve it. Reflections are already // filtered above. let route = self.announced_route(hops, cost, link_cost, responder_origin); - let Some(dynamic) = self.serve_route(&path, route.clone()) else { - return Ok(false); - }; - - announced.attach(path, route, dynamic); - - Ok(true) - } - - /// Announce the peer's route into the origin, so paths under it resolve through - /// this session on demand. `None` when we may not accept it: outside our origin's - /// scope, or outside what the session still lets the peer publish. - fn serve_route(&self, path: &Path, route: crate::origin::Route) -> Option { - if !self - .auth - .within_ceiling(crate::auth::Direction::Subscribe, path.as_str()) - { - tracing::debug!(route = %self.log_path(path), "declining announce outside the narrowed grant"); - return None; - } - self.origin.dynamic(path, route).ok() + Ok(announced.offer(self, path, route)) } /// The route to announce for a prefix this peer advertised, charging our @@ -442,13 +422,7 @@ impl Subscriber { return Ok(true); } - let Some(dynamic) = self.serve_route(&path, metadata.clone()) else { - announced.declined(path); - return Ok(false); - }; - announced.attach(path, metadata, dynamic); - - Ok(true) + Ok(announced.offer(self, path, metadata)) } /// Remove a subscription, releasing the session's handle on its producer. @@ -1166,7 +1140,7 @@ struct PrefixRun { // path, and lite-07 bases name it too. Tracked even for announces we drop // locally (reflected loops), since the sender doesn't know we dropped them. decoder: lite::AnnounceDecoder, - /// The auth epoch last applied, so a narrowing drops the routes it excludes. + /// The auth epoch last applied, so a new limit holds back or brings back routes. epoch: u64, } @@ -1295,14 +1269,15 @@ impl AnnouncePrefix { if self.subscriber.going_away.poll(waiter).is_ready() { run.announced.drain(); } - // A narrowing aborts what the peer may no longer publish to us. The - // peer still holds each advertisement, so the path stays reserved. + // A new limit holds back what the peer may no longer publish to us, + // and brings back what it may again. The peer still holds each + // advertisement either way. while let Poll::Ready(permit) = self.subscriber .auth .poll_permit(crate::auth::Direction::Subscribe, &mut run.epoch, waiter) { - run.announced.narrow(&permit, &self.subscriber); + run.announced.limit(&permit, &self.subscriber); } loop { match stream.reader.poll_decode_maybe::(&mut cx) { @@ -1682,10 +1657,10 @@ mod tests { assert!(wire.is_empty(), "a second SUBSCRIBE trailed the first"); } - /// A narrowing that lands while the SUBSCRIBE is still opening resets it with + /// A narrower limit landing while the SUBSCRIBE is still opening resets it with /// UNAUTHORIZED, like a live one, and releases its id. #[tokio::test] - async fn a_narrowing_resets_a_subscribe_still_opening() { + async fn a_narrower_limit_resets_a_subscribe_still_opening() { // Writes park, so the SUBSCRIBE is on an open stream but not yet flushed. let gate = kio::Producer::new(false); let session = SinkSession::gated_bi(gate.consume()); @@ -1730,7 +1705,7 @@ mod tests { assert!(kio::Task::poll(&mut running, &kio::Waiter::noop()).is_pending()); assert_eq!(session.log.bi_opens(), 1, "the SUBSCRIBE stream is open"); - auth.narrow(&crate::auth::Grant::default()); + auth.limit(&crate::auth::Grant::default()); assert!(kio::Task::poll(&mut running, &kio::Waiter::noop()).is_ready()); let unauthorized = crate::StreamError::Unauthorized.to_code(); @@ -2846,13 +2821,24 @@ enum Sub { /// owns its path and announce id until it retracts or restarts it. #[derive(Default)] struct Announced { - routes: HashMap>, + routes: HashMap, /// Attached routes whose request queue woke since the last serve pass. The /// driver wakes for every group the session carries, so a pass must cost what /// was requested, not every route the peer announced. ready: kio::Queue, } +/// What we made of one advertisement the peer holds. +enum Held { + /// Not in the origin for good: a reflection, or outside our origin's scope. + Declined, + /// Outside the session's limit: the route as advertised, attached once a + /// limit allows it. + Withheld(crate::origin::Route), + /// In the origin, serving requests. + Attached(AnnouncedRoute), +} + impl Announced { fn contains(&self, path: &PathOwned) -> bool { self.routes.contains_key(path) @@ -2867,12 +2853,42 @@ impl Announced { }); let route = AnnouncedRoute::new(route, dynamic, wake); route.waker.wake_by_ref(); - self.routes.insert(path, Some(route)); + self.routes.insert(path, Held::Attached(route)); + } + + /// Put the peer's route into the origin, so paths under it resolve through this + /// session on demand, or hold it back when the session's limit does not cover it. + /// Returns whether it attached. + fn offer( + &mut self, + subscriber: &Subscriber, + path: PathOwned, + route: crate::origin::Route, + ) -> bool { + if !subscriber + .auth + .within_limit(crate::auth::Direction::Subscribe, path.as_str()) + { + tracing::debug!(route = %subscriber.log_path(&path), "withholding announce outside the limit"); + self.routes.insert(path, Held::Withheld(route)); + return false; + } + // An error means the prefix is outside our origin's scope, so don't serve it. + match subscriber.origin.dynamic(&path, route.clone()) { + Ok(dynamic) => { + self.attach(path, route, dynamic); + true + } + Err(_) => { + self.declined(path); + false + } + } } fn declined(&mut self, path: PathOwned) { // Dropping a replaced route closes its sources. - self.routes.insert(path, None); + self.routes.insert(path, Held::Declined); } /// Record an advertisement before deciding what to do with it. @@ -2886,11 +2902,14 @@ impl Announced { /// with [`Self::contains`]. Overwriting an attached route is [`Self::declined`]'s job. fn reserve(&mut self, path: PathOwned) { debug_assert!(!self.routes.contains_key(&path), "reserved a prefix already advertised"); - self.routes.insert(path, None); + self.routes.insert(path, Held::Declined); } fn attached(&mut self, path: &PathOwned) -> Option<&mut AnnouncedRoute> { - self.routes.get_mut(path)?.as_mut() + match self.routes.get_mut(path)? { + Held::Attached(route) => Some(route), + _ => None, + } } fn retire(&mut self, path: &PathOwned) { @@ -2905,7 +2924,7 @@ impl Announced { let root = subscriber.origin.root().to_owned(); while let Poll::Ready(Ok(path)) = self.ready.poll_pop(waiter) { // A route retired since it woke has nothing left to serve. - let Some(Some(entry)) = self.routes.get_mut(&path) else { + let Some(Held::Attached(entry)) = self.routes.get_mut(&path) else { continue; }; // Cleared before polling, so a request landing mid-pass queues the route again. @@ -2930,21 +2949,44 @@ impl Announced { } } - /// Drop every attached route the session no longer lets the peer publish, closing - /// its sources. Tracks in flight end on their own gates, with `Unauthorized`. - fn narrow(&mut self, permit: &crate::auth::Permit, subscriber: &Subscriber) { - for (path, route) in self.routes.iter_mut() { - if route.is_some() && !permit.within_ceiling(path.as_str()) { - tracing::info!(route = %subscriber.log_path(path), "announce no longer authorized"); - *route = None; + /// Apply a new limit: hold back every attached route it no longer covers, closing + /// its sources (tracks in flight end on their own gates, with `Unauthorized`), and + /// attach every withheld one it now does. + fn limit(&mut self, permit: &crate::auth::Permit, subscriber: &Subscriber) { + let changed: Vec = self + .routes + .iter() + .filter(|(path, held)| match held { + Held::Attached(_) => !permit.within_limit(path.as_str()), + Held::Withheld(_) => permit.within_limit(path.as_str()), + Held::Declined => false, + }) + .map(|(path, _)| path.clone()) + .collect(); + for path in changed { + match self.routes.remove(&path) { + Some(Held::Attached(entry)) => { + tracing::info!(route = %subscriber.log_path(&path), "announce no longer authorized"); + self.routes.insert(path, Held::Withheld(entry.route.clone())); + } + Some(Held::Withheld(mut route)) => { + tracing::info!(route = %subscriber.log_path(&path), "announce authorized again"); + if subscriber.going_away.is_set() { + route.cost = crate::origin::Cost::DRAIN; + } + self.offer(subscriber, path, route); + } + _ => unreachable!("only attached and withheld routes change"), } } } /// Re-price every attached route to a draining cost (the peer sent a GOAWAY). fn drain(&mut self) { - for entry in self.routes.values_mut().flatten() { - entry.drain(); + for held in self.routes.values_mut() { + if let Held::Attached(entry) = held { + entry.drain(); + } } } } diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs index 4f2a1aaeef..7bf0d3b338 100644 --- a/rs/moq-net/tests/auth.rs +++ b/rs/moq-net/tests/auth.rs @@ -136,15 +136,22 @@ async fn granted(session: &Session) -> Grant { async fn wait_announced(origin: &origin::Consumer, path: &str, active: bool) { let mut announced = origin.announced(); let mut live = std::collections::HashSet::new(); - loop { - if live.contains(path) == active { - return; - } - let update = announced.next().await.expect("origin closed"); + let apply = |live: &mut std::collections::HashSet, update: moq_net::announce::Update| { match update.kind.is_active() { true => live.insert(update.prefix.to_string()), false => live.remove(update.prefix.as_str()), }; + }; + // Take in the replay first, so a retraction is judged against what is announced now + // rather than against an empty start. + while let Some(update) = futures::FutureExt::now_or_never(announced.next()).flatten() { + apply(&mut live, update); + } + loop { + if live.contains(path) == active { + return; + } + apply(&mut live, announced.next().await.expect("origin closed")); } } @@ -973,26 +980,31 @@ async fn nothing_outside_the_grant_reaches_the_peer(version: &'static str) { .expect("timed out"); } -/// Run each narrowing case on every version family: the session enforces a narrowing -/// itself, so a peer without AUTH (or one that ignores it) cannot keep what it lost. -macro_rules! narrow_cases { +/// Run each limit case on every version family: the session enforces its limit itself, +/// so a peer without AUTH (or one that ignores it) cannot keep what it lost. +macro_rules! limit_cases { ($($case:ident),* $(,)?) => { - mod narrow_lite_05 { + mod limit_lite_05 { $(#[tokio::test] async fn $case() { super::$case("moq-lite-05").await })* } - mod narrow_lite_06 { + mod limit_lite_06 { $(#[tokio::test] async fn $case() { super::$case(super::LITE_06).await })* } - mod narrow_moqt_16 { + mod limit_moqt_16 { $(#[tokio::test] async fn $case() { super::$case("moq-transport-16").await })* } - mod narrow_moqt_17 { + mod limit_moqt_17 { $(#[tokio::test] async fn $case() { super::$case(super::MOQT_17).await })* } }; } -narrow_cases!(a_narrowing_deafens_one_path, a_narrowing_aborts_what_the_peer_published); +limit_cases!( + a_narrowing_deafens_one_path, + a_narrowing_aborts_what_the_peer_published, + a_widening_brings_back_a_deafened_path, + a_widening_brings_back_what_the_peer_published, +); #[tokio::test] async fn lite_05_narrowing_resets_a_fetch_in_flight() { @@ -1063,7 +1075,7 @@ async fn a_narrowing_deafens_one_path(version: &'static str) { let mut video_sub = remote.track("h264").unwrap().subscribe(prefs()).await.unwrap(); video_sub.recv_group().await.unwrap().unwrap(); - pair.server.auth().narrow(&grant(&[], &["room/alice/video"])); + pair.server.auth().limit(&grant(&[], &["room/alice/video"])); let err = ended(&mut audio_sub).await; assert!(unauthorized(&err), "{err:?}"); @@ -1135,7 +1147,7 @@ async fn a_narrowing_aborts_what_the_peer_published(version: &'static str) { let mut cam_sub = remote.track("h264").unwrap().subscribe(prefs()).await.unwrap(); cam_sub.recv_group().await.unwrap().unwrap(); - pair.server.auth().narrow(&grant(&["room/bob/cam"], &[])); + pair.server.auth().limit(&grant(&["room/bob/cam"], &[])); let err = ended(&mut mic_sub).await; assert!(unauthorized(&err), "{err:?}"); @@ -1181,7 +1193,7 @@ async fn a_narrowing_resets_a_fetch_in_flight(version: &'static str) { let frame = fetched.read_frame().await.unwrap().expect("first frame"); assert_eq!(frame.payload.as_ref(), b"first"); - pair.server.auth().narrow(&grant(&[], &["room/y"])); + pair.server.auth().limit(&grant(&[], &["room/y"])); let err = loop { match fetched.read_frame().await { @@ -1198,3 +1210,110 @@ async fn a_narrowing_resets_a_fetch_in_flight(version: &'static str) { .await .expect("timed out"); } + +/// Read groups until one at `sequence` or later arrives. +async fn recv_through(sub: &mut moq_net::track::Subscriber, sequence: u64) { + loop { + let group = sub.recv_group().await.unwrap().expect("track ended"); + if group.sequence >= sequence { + return; + } + } +} + +/// Widening the limit after a narrowing brings the deafened path back: it is announced +/// again and a new subscription to it flows. +async fn a_widening_brings_back_a_deafened_path(version: &'static str) { + within(async { + let ts = |ms| moq_net::Timestamp::from_millis(ms).unwrap(); + let prefs = || moq_net::track::Subscription::default().with_max_age(Duration::from_secs(10)); + + let relay = produce_origin(1); + let audio = relay.create_broadcast("room/alice/audio").unwrap(); + let audio_track = audio.create_track("opus", None).unwrap(); + audio.announce(Default::default()).unwrap(); + + let received = produce_origin(3); + let pair = connect(Options { + version: Some(version), + client_subscribe: Some(received.clone()), + server_publish: Some(relay.scope("", &patterns(&["room"])).unwrap()), + ..Default::default() + }) + .await; + + let mut group = audio_track.append_group().unwrap(); + group.write_frame(ts(0), b"a".as_ref()).unwrap(); + let remote = received.consume().routed_broadcast("room/alice/audio").await.unwrap(); + let mut sub = remote.track("opus").unwrap().subscribe(prefs()).await.unwrap(); + sub.recv_group().await.unwrap().unwrap(); + + pair.server.auth().limit(&grant(&[], &["room/alice/video"])); + let err = ended(&mut sub).await; + assert!(unauthorized(&err), "{err:?}"); + wait_announced(&received.consume(), "room/alice/audio", false).await; + drop((sub, remote)); + + pair.server.auth().limit(&grant(&[], &["room"])); + wait_announced(&received.consume(), "room/alice/audio", true).await; + let remote = received.consume().routed_broadcast("room/alice/audio").await.unwrap(); + let mut sub = remote.track("opus").unwrap().subscribe(prefs()).await.unwrap(); + let mut group = audio_track.append_group().unwrap(); + group.write_frame(ts(1), b"a".as_ref()).unwrap(); + recv_through(&mut sub, 1).await; + + // A peer that speaks AUTH is told it may subscribe again. + if speaks_auth(version) { + let widened = wait_for(pair.client.auth().grant(), |grant| { + grant + .as_ref() + .is_some_and(|grant| grant.subscribe == patterns(&["room"])) + }) + .await; + assert_eq!(widened, Some(grant(&[], &["room"]))); + } + assert_eq!(pair.client_transport.close_reason(), None); + }) + .await + .expect("timed out"); +} + +/// Widening the limit after a narrowing brings back what the peer published: its route +/// is in the origin again and the relay's own readers can subscribe to it. +async fn a_widening_brings_back_what_the_peer_published(version: &'static str) { + within(async { + let ts = |ms| moq_net::Timestamp::from_millis(ms).unwrap(); + let prefs = || moq_net::track::Subscription::default().with_max_age(Duration::from_secs(10)); + + let client_origin = produce_origin(2); + let mic = client_origin.create_broadcast("room/bob/mic").unwrap(); + let mic_track = mic.create_track("opus", None).unwrap(); + mic.announce(Default::default()).unwrap(); + + let relay = produce_origin(1); + let pair = connect(Options { + version: Some(version), + client_publish: Some(client_origin.clone()), + server_subscribe: Some(relay.clone()), + ..Default::default() + }) + .await; + wait_announced(&relay.consume(), "room/bob/mic", true).await; + + pair.server.auth().limit(&grant(&["room/bob/cam"], &[])); + wait_announced(&relay.consume(), "room/bob/mic", false).await; + + pair.server.auth().limit(&grant(&["room"], &[])); + wait_announced(&relay.consume(), "room/bob/mic", true).await; + let remote = relay.consume().routed_broadcast("room/bob/mic").await.unwrap(); + let mut sub = remote.track("opus").unwrap().subscribe(prefs()).await.unwrap(); + let mut group = mic_track.append_group().unwrap(); + group.write_frame(ts(0), b"m".as_ref()).unwrap(); + recv_through(&mut sub, 0).await; + + // Neither the narrowing nor the widening is a publication outside the grant. + assert_eq!(pair.client_transport.close_reason(), None); + }) + .await + .expect("timed out"); +} diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index 266384d407..b532de4cdb 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -412,21 +412,13 @@ impl Token { } /// Whether `other` still covers everything this token scopes: the same root and - /// mounts, and every grant still held. A narrower re-check narrows the session. + /// mounts, and every grant still held. pub(crate) fn covered_by(&self, other: &Self) -> bool { self.root == other.root && self.mounts == other.mounts && other.subscribe.covers(&self.subscribe) && other.publish.covers(&self.publish) } - - /// Keep only what `other` also grants. Fails closed: an intersection too large - /// to hold grants nothing. - fn narrow(&mut self, other: &Self) { - let both = |this: &Patterns, other: &Patterns| this.intersect(other).unwrap_or_default(); - self.subscribe = both(&self.subscribe, &other.subscribe); - self.publish = both(&self.publish, &other.publish); - } } /// What admitted a session and keeps it admitted: the grant's lease and the @@ -439,7 +431,7 @@ impl Token { pub struct Lease { consumer: lease::Consumer, token: Token, - /// The live session a narrower re-check narrows in place, once attached. + /// The live session a re-check resizes in place, once attached. session: Option, /// When the grant runs out, enforced here whoever drives the lease: a fixed /// grant has no driver, and an auth server's may be mid-outage. Fixed on tokio's @@ -464,9 +456,9 @@ impl Lease { } } - /// Narrow `session` in place when a re-check narrows the grant, instead of ending - /// the lease. Without it a narrower grant ends the lease. - pub(crate) fn narrowing(mut self, session: &moq_net::Session) -> Self { + /// Resize `session` in place when a re-check changes what the grant allows, + /// narrower or wider. Without it a narrower grant ends the lease. + pub(crate) fn limiting(mut self, session: &moq_net::Session) -> Self { self.session = Some(session.auth()); self } @@ -477,8 +469,8 @@ impl Lease { self } - /// The scope the session holds: what it was admitted under, less what any - /// re-check narrowed away since. + /// The scope the session holds: what it was admitted under, with the patterns of + /// the latest re-check that resized it. pub fn token(&self) -> &Token { &self.token } @@ -491,9 +483,10 @@ impl Lease { /// Wait for the lease to stop covering the session: the grant expired, was /// revoked, or was re-checked into one that no longer covers the token. /// - /// A narrower grant with the same root and mounts narrows an attached session in - /// place and keeps going; the session keeps only what both grants allow, so a - /// wider part waits for a reconnect. Without an attached session it ends the lease. + /// A grant with the same root and mounts sets an attached session's limit to its + /// patterns, narrower or wider, and keeps going; the origin handles the session was + /// admitted with still bound it, so it never reaches past them. Without an attached + /// session a narrower grant ends the lease and a wider one changes nothing. /// A changed root or mounts ends it, and so does a flipped `peer`, since the /// routes it already announced would be misreported as entering here or from a /// peer. A changed tier keeps the session and moves its @@ -521,21 +514,25 @@ impl Lease { if fresh.peer != self.token.peer { return "peer changed".into(); } - if !self.token.covered_by(&fresh) { - let Some(session) = &self.session else { - return "grant narrowed".into(); - }; - self.token.narrow(&fresh); - tracing::info!( - publish = %join(&self.token.publish), - subscribe = %join(&self.token.subscribe), - "grant narrowed; narrowing the session", - ); - session.narrow(&moq_net::auth::Grant { - publish: self.token.publish.clone(), - subscribe: self.token.subscribe.clone(), - expires: None, - }); + if fresh.publish != self.token.publish || fresh.subscribe != self.token.subscribe { + match &self.session { + Some(session) => { + tracing::info!( + publish = %join(&fresh.publish), + subscribe = %join(&fresh.subscribe), + "grant changed; resizing the session", + ); + session.limit(&moq_net::auth::Grant { + publish: fresh.publish.clone(), + subscribe: fresh.subscribe.clone(), + expires: None, + }); + self.token.publish = fresh.publish; + self.token.subscribe = fresh.subscribe; + } + None if !self.token.covered_by(&fresh) => return "grant narrowed".into(), + None => {} + } } if fresh.tier != self.token.tier { tracing::info!(from = %self.token.tier, to = %fresh.tier, "tier changed"); diff --git a/rs/moq-relay/src/connection.rs b/rs/moq-relay/src/connection.rs index f143a818f3..98e198eb52 100644 --- a/rs/moq-relay/src/connection.rs +++ b/rs/moq-relay/src/connection.rs @@ -275,8 +275,8 @@ pub(crate) fn authorize( /// the internal listener re-checks this lease; `None` for a session that is /// not in the table. /// -/// The lease is the decider's live word on the grant: a narrower re-check narrows -/// the session in place, and when it stops covering the session +/// The lease is the decider's live word on the grant: a re-check resizes the +/// session in place, narrower or wider, and when it stops covering the session /// ([`auth::Lease::ended`]) the session closes with the reason. The session's own /// close is reported back through the lease as the `end` event. Either way, a relay /// shutdown drains the session with a GOAWAY instead of cutting it off. @@ -289,7 +289,7 @@ pub async fn supervise( mut shutdown: crate::shutdown::Observer, registration: Option, ) -> anyhow::Result<()> { - let mut lease = lease.narrowing(&session); + let mut lease = lease.limiting(&session); loop { let nudged = async { match ®istration { diff --git a/rs/moq-relay/src/websocket.rs b/rs/moq-relay/src/websocket.rs index 192051ae49..b33b9bb1af 100644 --- a/rs/moq-relay/src/websocket.rs +++ b/rs/moq-relay/src/websocket.rs @@ -197,7 +197,7 @@ where // The handshake is done, so this is a MoQ session now: only now can a push // be serviced, and only now does the session appear in the live table. let registration = pending.map(|(sessions, request)| sessions.register(request)); - let mut lease = lease.narrowing(&session); + let mut lease = lease.limiting(&session); loop { let nudged = async { diff --git a/rs/moq-relay/tests/auth_lifetime.rs b/rs/moq-relay/tests/auth_lifetime.rs index 3cf3fb0438..4b79978418 100644 --- a/rs/moq-relay/tests/auth_lifetime.rs +++ b/rs/moq-relay/tests/auth_lifetime.rs @@ -4,7 +4,7 @@ //! or QUIC) or its axum WebSocket path (`serve_ws` over `ws://`), points it at a //! scripted auth server, connects a publisher and a subscriber, confirms media //! flows, then asserts the relay follows the server's word: a re-check that moves -//! the tier retags the live session's stats, a narrower grant narrows it in place, a +//! the tier retags the live session's stats, a changed grant resizes it in place, a //! moved root or a refusal closes it, an outage keeps it until `expires`, and every //! close reports `end` with what it moved. //! The last tests swap the server for an in-process decider answering @@ -556,11 +556,11 @@ async fn a_moved_tier_retags_the_live_session() { relay.abort(); } -/// A narrower grant narrows the live session in place on the next re-check, over TCP -/// and WebSocket: the deafened path resets with `Unauthorized`, a sibling under the -/// same prefix keeps flowing, and neither session closes. +/// A re-check resizes the live session in place, over TCP and WebSocket: a narrower +/// grant resets the deafened path with `Unauthorized` while a sibling under the same +/// prefix keeps flowing, a wider one brings the path back, and neither session closes. #[tokio::test] -async fn a_narrower_grant_narrows_live_sessions() { +async fn a_rechecked_grant_resizes_live_sessions() { for scheme in ["tcp", "ws"] { let script = Script::new(grant(Duration::from_secs(3600))); let auth = build_auth(script.spawn().await); @@ -667,12 +667,32 @@ async fn a_narrower_grant_narrows_live_sessions() { .expect("the sibling track closed"); assert_eq!(group.sequence, 1, "{scheme}"); + // Undeafen: the path is announced again and a new subscription flows. + script.on_revalidate(Answer::Grant(grant(Duration::from_secs(3600)))); + let broadcast = tokio::time::timeout(TIMEOUT, sub_consumer.routed_broadcast("alice/audio")) + .await + .expect("the undeafened path was never announced again") + .expect("announced broadcast resolves"); + let mut audio = broadcast + .track("media") + .unwrap() + .subscribe(None) + .await + .expect("subscribe"); + send(0, 1); + let group = tokio::time::timeout(TIMEOUT, audio.recv_group()) + .await + .expect("recv_group timeout") + .expect("recv_group failed") + .expect("the undeafened track closed"); + assert!(group.sequence >= 1, "{scheme}"); + for (session, what) in [(pub_session, "publisher"), (sub_session, "subscriber")] { assert!( tokio::time::timeout(Duration::from_millis(200), session.closed()) .await .is_err(), - "{scheme}: a narrowing must not close the {what}" + "{scheme}: a resize must not close the {what}" ); } relay.abort(); From ee8d254ac0715206bd948e05664fd0e1389244fc Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 17:51:53 -0700 Subject: [PATCH 29/53] chore(net): box the attached route Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/lite/subscriber.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 07995ddfd0..8bf3e12a1a 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -2836,7 +2836,7 @@ enum Held { /// limit allows it. Withheld(crate::origin::Route), /// In the origin, serving requests. - Attached(AnnouncedRoute), + Attached(Box), } impl Announced { @@ -2853,7 +2853,7 @@ impl Announced { }); let route = AnnouncedRoute::new(route, dynamic, wake); route.waker.wake_by_ref(); - self.routes.insert(path, Held::Attached(route)); + self.routes.insert(path, Held::Attached(Box::new(route))); } /// Put the peer's route into the origin, so paths under it resolve through this @@ -2907,7 +2907,7 @@ impl Announced { fn attached(&mut self, path: &PathOwned) -> Option<&mut AnnouncedRoute> { match self.routes.get_mut(path)? { - Held::Attached(route) => Some(route), + Held::Attached(route) => Some(route.as_mut()), _ => None, } } From dc2b9861373244711e06cbde9b9ad0ce92c01561 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 19:24:37 -0700 Subject: [PATCH 30/53] refactor(net): rename auth::Handle::limit to authorize Co-Authored-By: Claude Opus 5.5 --- doc/lib/rs/moq-net.md | 18 ++++++++-------- quest/m1/auth/README.md | 2 +- quest/m1/auth/relay-refresh.md | 16 +++++++------- quest/m1/auth/request-token.md | 2 +- rs/moq-net/benches/origin.rs | 4 ++-- rs/moq-net/src/auth.rs | 35 ++++++++++++++++--------------- rs/moq-net/src/lite/subscriber.rs | 2 +- rs/moq-net/tests/auth.rs | 14 ++++++------- rs/moq-relay/src/auth.rs | 12 +++++------ rs/moq-relay/src/connection.rs | 4 ++-- rs/moq-relay/src/websocket.rs | 2 +- 11 files changed, 56 insertions(+), 55 deletions(-) diff --git a/doc/lib/rs/moq-net.md b/doc/lib/rs/moq-net.md index 4e4c571b24..fbbe0477d2 100644 --- a/doc/lib/rs/moq-net.md +++ b/doc/lib/rs/moq-net.md @@ -104,27 +104,27 @@ shrinks withdraws the announcements and cancels the subscriptions it no longer covers, and leaves the session up. On moq-lite each cancelled stream resets with `StreamError::Unauthorized`. -`session.auth().limit(&grant)` sets the most the peer may do on a live session, -replacing any earlier limit, on every version, whether or not the peer speaks -AUTH: the session enforces it itself, and never past its origin handles. A -narrower limit ends what falls outside at once: announcements to the peer +`session.auth().authorize(&grant)` authorizes the peer for `grant` on a live +session; calling it again re-authorizes, narrower or wider. It works on every +version, whether or not the peer speaks AUTH: the session enforces it itself, and +never past its origin handles. A narrower grant ends what falls outside at once: announcements to the peer retract, its new requests are refused, its subscriptions and fetches reset with `Unauthorized`, and the broadcasts it published abort, so local readers see -`Unauthorized` too. A wider one brings back what the old limit held back: +`Unauthorized` too. A wider one brings back what the old grant held back: announcements are made again, the peer's own announcements return to the origin, and new requests are accepted. The rest of the session carries on. When the -session answers the peer's connection credential itself, the peer is sent its -grant within the limit. +session answers the peer's connection credential itself, the peer is sent the +grant. ```rust // Deafen alice's audio: the peer may still subscribe to her video. let video: Pattern = "room/alice/video/**".parse()?; -session.auth().limit(&auth::Grant { +session.auth().authorize(&auth::Grant { subscribe: video.into(), ..auth::Grant::all() }); // Undeafen. -session.auth().limit(&auth::Grant::all()); +session.auth().authorize(&auth::Grant::all()); ``` ## Patterns diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index f22d0f3353..6f467ec3e5 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -39,7 +39,7 @@ Decisions settled while planning, recorded so review does not relitigate them: the union and cancels publications and subscriptions that lose authorization. Other authorized work continues on the same session. An empty union leaves the session connected with no access, so it can accept a fresh token. - `auth::Handle::limit` is the common resize operation; relay token handling + `auth::Handle::authorize` is the common resize operation; relay token handling uses it rather than shipping a temporary close-on-shrink policy. - **A public grant contains publish patterns, subscribe patterns, and an expiry**, in the presenter's own root; the presenter never sees the relay-side diff --git a/quest/m1/auth/relay-refresh.md b/quest/m1/auth/relay-refresh.md index 00813f65fc..8176895768 100644 --- a/quest/m1/auth/relay-refresh.md +++ b/quest/m1/auth/relay-refresh.md @@ -30,28 +30,28 @@ expiry, an expiry that leaves the union intact ends only that token, and sessions reject a non-empty token as `Unsupported`. - Union: the connection holds the set of accepted leases. Every one must carry the admitted root, else `AUTH_ERROR { Unauthorized }` naming the - root. The session's limit is the union of the set's publish and subscribe + root. The peer is authorized for the union of the set's publish and subscribe patterns, intersected with the role the client declared at SETUP so a publish-only session never starts receiving announcements because a later - token happened to carry subscribe prefixes, and set through - `auth::Handle::limit` whenever the union changes, grown or shrunk; keep it - behind one function. A limit never reaches past the session's origin handles, + token happened to carry subscribe prefixes, re-authorized through + `auth::Handle::authorize` whenever the union changes, grown or shrunk; keep it + behind one function. A grant never reaches past the session's origin handles, so scope those to the admitted root and role rather than to the first token's patterns, and nothing is rebuilt or swapped in. - Expiry: the deadline today is the admitted lease's `closed()` inside one `tokio::select!` arm. Select on one lease per token in the set instead, and on any firing recompute the union without it: unchanged means `AUTH_ERROR { Expired }` on that token's stream and the session - continues, shrunk sets the smaller limit, which resets each stream that + continues, shrunk re-authorizes the smaller union, which resets each stream that lost access with `UNAUTHORIZED`. A token withdrawn by the client (its stream closed or reset) recomputes the union the same way with nothing written back, since the stream is gone: unchanged - means the session continues, shrunk sets the limit the same way. Each lease revalidates on + means the session continues, shrunk re-authorizes the same way. Each lease revalidates on its own cadence, and every re-check that changes the grant, a lower `expires` or a different `publish` or `subscribe`, writes the complete replacement grant as an update AUTH_OK on that token's stream and recomputes the union from the latest grant of every token, so the client can present a replacement in time and a narrowed token never keeps its old - scope; a changed union sets the limit as above. + scope; a changed union re-authorizes as above. - Refusals map as connect-time ones do and never expose a status: every refusal `Client::attach` returns for a new token, a `403`, an empty or invalid grant, an unparseable body, or a `5xx`, is @@ -74,7 +74,7 @@ expiry, an expiry that leaves the union intact ends only that token, and session past the first token's expiry, with only the first stream ending; an expired or invalid token is refused and nothing changes; a token with a different root is refused naming it; a token adding a prefix widens the - limit and the prefix is now solicited; withdrawing the only token + grant and the prefix is now solicited; withdrawing the only token covering a prefix resets that prefix's streams with `UNAUTHORIZED` and the session stays connected; an mTLS session refuses a token; a proxy-mode outage on re-check keeps the token. diff --git a/quest/m1/auth/request-token.md b/quest/m1/auth/request-token.md index f31f9094b9..a22f24e113 100644 --- a/quest/m1/auth/request-token.md +++ b/quest/m1/auth/request-token.md @@ -40,7 +40,7 @@ on the unknown key, and the legacy drafts silently ignore it. until it lapses. When a request's grant expires or is revoked, that request alone ends with `EXPIRED_AUTH_TOKEN` or `UNAUTHORIZED`. A session grant that shrinks cancels the requests it covered, as for any request, through - `auth::Handle::limit`. + `auth::Handle::authorize`. - Relay: each such request gets its own lease from a per-request call on `moq_auth::Client`, not the `Client::attach` that [Relay tokens](/quest/m1/auth/relay-refresh.md) builds. `attach` connects with the diff --git a/rs/moq-net/benches/origin.rs b/rs/moq-net/benches/origin.rs index d1d426ee69..b78bfe0813 100644 --- a/rs/moq-net/benches/origin.rs +++ b/rs/moq-net/benches/origin.rs @@ -540,7 +540,7 @@ fn bench_narrow_delivery(c: &mut Criterion) { // Covers every subscription but the muted one, which is not subscribed here. let mut grant = room(); grant.subscribe.insert(Pattern::literal("muted").unwrap()); - watching.pair.server.auth().limit(&grant); + watching.pair.server.auth().authorize(&grant); } b.iter(|| rt.block_on(watching.round())); }); @@ -566,7 +566,7 @@ fn bench_narrow(c: &mut Criterion) { for _ in 0..iters { let mut watching = rt.block_on(watching(routes, subscribers)); let start = std::time::Instant::now(); - watching.pair.server.auth().limit(&room()); + watching.pair.server.auth().authorize(&room()); let ended = rt.block_on(watching.subscriptions[0].recv_group()); elapsed += start.elapsed(); assert!(ended.is_err(), "the muted subscription outlived the narrowing"); diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index a8f19e4cd0..6403a3ac79 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -11,8 +11,8 @@ //! grants what its own origin handles allow. An application that verifies tokens //! itself takes [`requests`](Handle::requests) before running the session's //! driver, and then answers every token the peer presents. Either way, -//! [`limit`](Handle::limit) resizes what the peer may do on a live session, on -//! every version, whether or not it speaks AUTH. +//! [`authorize`](Handle::authorize) re-authorizes the peer on a live session, narrower +//! or wider, on every version, whether or not it speaks AUTH. //! //! moq-transport draft-17+ carries the same exchange when both sides negotiate the //! MoQ Auth extension. Older versions, and peers that do not negotiate it, carry no @@ -115,7 +115,8 @@ pub(crate) struct State { /// The peer replied to some token, so the union is known even when empty. replied: bool, /// The most this side lets the peer do, in the peer's terms: `None` (only the - /// origin handles bound it) until [`Handle::limit`] sets it. + /// origin handles bound it) until [`Handle::authorize`] sets it; the grant it was + /// last called with. limit: Option, /// Bumped whenever the union or the limit changes, so the session's per-stream /// gates can skip re-matching paths on every wakeup. @@ -439,22 +440,22 @@ impl Handle { Poll::Ready(()) } - /// Set the most the peer may do on this session to `grant`, in the session's own - /// paths, replacing any earlier limit. The session's origin handles still bound it, - /// so a limit never grants more than they allow. + /// Authorize the peer for `grant` on this session, in the session's own paths. + /// Calling it again re-authorizes, narrower or wider, replacing the last grant. The + /// session's origin handles still bound it, so it never grants more than they allow. /// - /// A narrower limit ends what falls outside at once, with [`Error::Unauthorized`] + /// A narrower grant ends what falls outside at once, with [`Error::Unauthorized`] /// where it has a reader: announcements to the peer retract, its new requests are /// refused, its subscriptions reset, and the broadcasts it published abort. A wider - /// one brings back what the old limit held back: announcements to the peer are made + /// one brings back what the old grant held back: announcements to the peer are made /// again, and so are the peer's announcements it withheld from the origin, and new /// requests are accepted. The rest of the session carries on. It works on every /// version, since the session enforces it without the peer's help. /// /// When the session answers the peer's connection credential itself, the peer is - /// told its grant within the limit, `expires` included. An application answering + /// told the grant, within what the origin handles allow, `expires` included. An application answering /// tokens through [`requests`](Self::requests) updates its own [`Issued`] grants. - pub fn limit(&self, grant: &Grant) { + pub fn authorize(&self, grant: &Grant) { let mut state = self.state.lock(); if state.limit.as_ref() != Some(grant) { state.limit = Some(grant.clone()); @@ -1014,19 +1015,19 @@ mod tests { let handle = Handle::new(false); assert!(handle.allows(Direction::Publish, "room/alice/audio")); - handle.limit(&grant(&["room/bob"], &["room/alice"])); + handle.authorize(&grant(&["room/bob"], &["room/alice"])); // What we send is what the peer may subscribe to, and the other way around. assert!(handle.allows(Direction::Publish, "room/alice/audio")); assert!(!handle.allows(Direction::Publish, "room/bob/cam")); assert!(handle.allows(Direction::Subscribe, "room/bob/cam")); assert!(!handle.allows(Direction::Subscribe, "room/alice/audio")); - handle.limit(&grant(&[""], &["room/alice/video"])); + handle.authorize(&grant(&[""], &["room/alice/video"])); assert!(!handle.allows(Direction::Publish, "room/alice/audio")); assert!(handle.allows(Direction::Publish, "room/alice/video")); assert!(handle.allows(Direction::Subscribe, "other")); - handle.limit(&Grant::all()); + handle.authorize(&Grant::all()); assert!(handle.allows(Direction::Publish, "room/alice/audio")); assert!(handle.allows(Direction::Subscribe, "other")); } @@ -1040,7 +1041,7 @@ mod tests { assert!(!handle.allows(Direction::Subscribe, "room/bob/cam")); assert!(handle.within_limit(Direction::Subscribe, "room/bob/cam")); - handle.limit(&grant(&["room/alice"], &[])); + handle.authorize(&grant(&["room/alice"], &[])); assert!(!handle.within_limit(Direction::Subscribe, "room/bob/cam")); assert!(handle.within_limit(Direction::Subscribe, "room/alice/cam")); drop(token); @@ -1057,15 +1058,15 @@ mod tests { assert_eq!(default.poll(&waiter), Poll::Ready(grant(&["room"], &["room"]))); assert!(default.poll(&waiter).is_pending()); - handle.limit(&grant(&["room"], &["room/alice"])); + handle.authorize(&grant(&["room"], &["room/alice"])); assert_eq!(default.poll(&waiter), Poll::Ready(grant(&["room"], &["room/alice"]))); // Wider again, but never past what the origin handles allow. - handle.limit(&Grant::all()); + handle.authorize(&Grant::all()); assert_eq!(default.poll(&waiter), Poll::Ready(grant(&["room"], &["room"]))); // A limit that leaves the grant as it is: nothing to tell the peer. - handle.limit(&grant(&[""], &[""])); + handle.authorize(&grant(&[""], &[""])); assert!(default.poll(&waiter).is_pending()); } } diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 8bf3e12a1a..a4291d8f15 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -1705,7 +1705,7 @@ mod tests { assert!(kio::Task::poll(&mut running, &kio::Waiter::noop()).is_pending()); assert_eq!(session.log.bi_opens(), 1, "the SUBSCRIBE stream is open"); - auth.limit(&crate::auth::Grant::default()); + auth.authorize(&crate::auth::Grant::default()); assert!(kio::Task::poll(&mut running, &kio::Waiter::noop()).is_ready()); let unauthorized = crate::StreamError::Unauthorized.to_code(); diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs index 7bf0d3b338..1a4711209e 100644 --- a/rs/moq-net/tests/auth.rs +++ b/rs/moq-net/tests/auth.rs @@ -1075,7 +1075,7 @@ async fn a_narrowing_deafens_one_path(version: &'static str) { let mut video_sub = remote.track("h264").unwrap().subscribe(prefs()).await.unwrap(); video_sub.recv_group().await.unwrap().unwrap(); - pair.server.auth().limit(&grant(&[], &["room/alice/video"])); + pair.server.auth().authorize(&grant(&[], &["room/alice/video"])); let err = ended(&mut audio_sub).await; assert!(unauthorized(&err), "{err:?}"); @@ -1147,7 +1147,7 @@ async fn a_narrowing_aborts_what_the_peer_published(version: &'static str) { let mut cam_sub = remote.track("h264").unwrap().subscribe(prefs()).await.unwrap(); cam_sub.recv_group().await.unwrap().unwrap(); - pair.server.auth().limit(&grant(&["room/bob/cam"], &[])); + pair.server.auth().authorize(&grant(&["room/bob/cam"], &[])); let err = ended(&mut mic_sub).await; assert!(unauthorized(&err), "{err:?}"); @@ -1193,7 +1193,7 @@ async fn a_narrowing_resets_a_fetch_in_flight(version: &'static str) { let frame = fetched.read_frame().await.unwrap().expect("first frame"); assert_eq!(frame.payload.as_ref(), b"first"); - pair.server.auth().limit(&grant(&[], &["room/y"])); + pair.server.auth().authorize(&grant(&[], &["room/y"])); let err = loop { match fetched.read_frame().await { @@ -1248,13 +1248,13 @@ async fn a_widening_brings_back_a_deafened_path(version: &'static str) { let mut sub = remote.track("opus").unwrap().subscribe(prefs()).await.unwrap(); sub.recv_group().await.unwrap().unwrap(); - pair.server.auth().limit(&grant(&[], &["room/alice/video"])); + pair.server.auth().authorize(&grant(&[], &["room/alice/video"])); let err = ended(&mut sub).await; assert!(unauthorized(&err), "{err:?}"); wait_announced(&received.consume(), "room/alice/audio", false).await; drop((sub, remote)); - pair.server.auth().limit(&grant(&[], &["room"])); + pair.server.auth().authorize(&grant(&[], &["room"])); wait_announced(&received.consume(), "room/alice/audio", true).await; let remote = received.consume().routed_broadcast("room/alice/audio").await.unwrap(); let mut sub = remote.track("opus").unwrap().subscribe(prefs()).await.unwrap(); @@ -1300,10 +1300,10 @@ async fn a_widening_brings_back_what_the_peer_published(version: &'static str) { .await; wait_announced(&relay.consume(), "room/bob/mic", true).await; - pair.server.auth().limit(&grant(&["room/bob/cam"], &[])); + pair.server.auth().authorize(&grant(&["room/bob/cam"], &[])); wait_announced(&relay.consume(), "room/bob/mic", false).await; - pair.server.auth().limit(&grant(&["room"], &[])); + pair.server.auth().authorize(&grant(&["room"], &[])); wait_announced(&relay.consume(), "room/bob/mic", true).await; let remote = relay.consume().routed_broadcast("room/bob/mic").await.unwrap(); let mut sub = remote.track("opus").unwrap().subscribe(prefs()).await.unwrap(); diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index b532de4cdb..4c3f33e7cb 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -431,7 +431,7 @@ impl Token { pub struct Lease { consumer: lease::Consumer, token: Token, - /// The live session a re-check resizes in place, once attached. + /// The live session a re-check re-authorizes in place, once attached. session: Option, /// When the grant runs out, enforced here whoever drives the lease: a fixed /// grant has no driver, and an auth server's may be mid-outage. Fixed on tokio's @@ -456,9 +456,9 @@ impl Lease { } } - /// Resize `session` in place when a re-check changes what the grant allows, + /// Re-authorize `session` in place when a re-check changes what the grant allows, /// narrower or wider. Without it a narrower grant ends the lease. - pub(crate) fn limiting(mut self, session: &moq_net::Session) -> Self { + pub(crate) fn authorizing(mut self, session: &moq_net::Session) -> Self { self.session = Some(session.auth()); self } @@ -483,7 +483,7 @@ impl Lease { /// Wait for the lease to stop covering the session: the grant expired, was /// revoked, or was re-checked into one that no longer covers the token. /// - /// A grant with the same root and mounts sets an attached session's limit to its + /// A grant with the same root and mounts re-authorizes an attached session for its /// patterns, narrower or wider, and keeps going; the origin handles the session was /// admitted with still bound it, so it never reaches past them. Without an attached /// session a narrower grant ends the lease and a wider one changes nothing. @@ -520,9 +520,9 @@ impl Lease { tracing::info!( publish = %join(&fresh.publish), subscribe = %join(&fresh.subscribe), - "grant changed; resizing the session", + "grant changed; re-authorizing the session", ); - session.limit(&moq_net::auth::Grant { + session.authorize(&moq_net::auth::Grant { publish: fresh.publish.clone(), subscribe: fresh.subscribe.clone(), expires: None, diff --git a/rs/moq-relay/src/connection.rs b/rs/moq-relay/src/connection.rs index 98e198eb52..e195650745 100644 --- a/rs/moq-relay/src/connection.rs +++ b/rs/moq-relay/src/connection.rs @@ -275,7 +275,7 @@ pub(crate) fn authorize( /// the internal listener re-checks this lease; `None` for a session that is /// not in the table. /// -/// The lease is the decider's live word on the grant: a re-check resizes the +/// The lease is the decider's live word on the grant: a re-check re-authorizes the /// session in place, narrower or wider, and when it stops covering the session /// ([`auth::Lease::ended`]) the session closes with the reason. The session's own /// close is reported back through the lease as the `end` event. Either way, a relay @@ -289,7 +289,7 @@ pub async fn supervise( mut shutdown: crate::shutdown::Observer, registration: Option, ) -> anyhow::Result<()> { - let mut lease = lease.limiting(&session); + let mut lease = lease.authorizing(&session); loop { let nudged = async { match ®istration { diff --git a/rs/moq-relay/src/websocket.rs b/rs/moq-relay/src/websocket.rs index b33b9bb1af..f67f2a5cda 100644 --- a/rs/moq-relay/src/websocket.rs +++ b/rs/moq-relay/src/websocket.rs @@ -197,7 +197,7 @@ where // The handshake is done, so this is a MoQ session now: only now can a push // be serviced, and only now does the session appear in the live table. let registration = pending.map(|(sessions, request)| sessions.register(request)); - let mut lease = lease.limiting(&session); + let mut lease = lease.authorizing(&session); loop { let nudged = async { From 75ca4c928cf0298d7fb7ad951125178380a24f6a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 19:54:18 -0700 Subject: [PATCH 31/53] fix(net): adapt to the merged base Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/lite/publisher.rs | 23 ++++++++++++++--------- rs/moq-net/src/lite/subscriber.rs | 5 +++-- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index a2f958c495..706564b19a 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -1089,7 +1089,7 @@ impl AnnounceRun { ) -> Result<(), Error> { match self.version { Version::Lite01 | Version::Lite02 => { - let mut init = Vec::new(); + let mut init: Vec<(crate::PathOwned, Hops, crate::origin::Cost)> = Vec::new(); // Send ANNOUNCE_INIT as the first message with all currently active routes. // We use `try_next()` to synchronously get the initial updates. @@ -1098,25 +1098,30 @@ impl AnnounceRun { let suffix = update.prefix; if update.kind.is_active() { - if !self.permitted(&suffix) || self.outgoing(&update.route, &absolute).is_none() { + if !self.permitted(&suffix) { continue; } + let Some((hops, cost)) = self.outgoing(&update.route, &absolute) else { + continue; + }; tracing::debug!(route = %absolute, "announce"); - if !init.contains(&suffix) { - init.push(suffix); + if !init.iter().any(|(p, ..)| p == &suffix) { + init.push((suffix, hops, cost)); } } else { // A potential race: a just-announced route already retracted. tracing::debug!(route = %absolute, "unannounce"); - init.retain(|p| p != &suffix); + init.retain(|(p, ..)| p != &suffix); } } - // The peer holds these now, so a later retraction or narrowing ends them. - for suffix in &init { - self.live.insert(suffix.clone(), None); + // The peer holds these now, so a later retraction or a narrower grant ends them. + let mut suffixes = Vec::with_capacity(init.len()); + for (suffix, hops, cost) in init { + self.live.insert(suffix.clone(), Advertised { id: None, hops, cost }); + suffixes.push(suffix); } - let announce_init = lite::AnnounceInit { suffixes: init }; + let announce_init = lite::AnnounceInit { suffixes }; stream.writer.buffer(&announce_init)?; } _ if self.version.has_announce_ok() => { diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index cffd619ae7..06f72efcf7 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -1558,10 +1558,11 @@ mod tests { .subscribe(None) .await .unwrap(); + let gate = serve.gate(); let mut running = TrackServeRun { serve, state: TrackRunState::Serve(serving), - gate: None, + gate, }; kio::wait(|waiter| kio::Task::poll(&mut running, waiter)).await; @@ -3172,7 +3173,7 @@ impl Announced { /// Retire this session's advertisement without invalidating another live /// session from the same peer. Dropping its sources closes their requests. fn withdraw(&mut self, path: &PathOwned) { - if let Some(Some(entry)) = self.routes.remove(path) { + if let Some(Held::Attached(entry)) = self.routes.remove(path) { entry.dynamic.withdrawn(); } } From 1475441e2ce2ccb5c65eecc3cbea6f410fab2304 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 20:06:55 -0700 Subject: [PATCH 32/53] quest(auth): plan lite NOT_SUPPORTED and the 65,535-byte message cap (#4573) Co-authored-by: Claude Opus 5.5 --- quest/m1/auth/README.md | 1 + quest/m1/auth/expired-error.md | 4 +++ quest/m1/auth/not-supported.md | 62 ++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 quest/m1/auth/not-supported.md diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index f8e909b7d1..d92ff173f7 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -90,6 +90,7 @@ existing lite-06 ALPN. ## Required - [AUTH endings](/quest/m1/auth/error-codes.md) - an out-of-range AUTH_ERROR code is refused, and both sides settle and recompute grants when a stream ends +- [Lite NOT_SUPPORTED](/quest/m1/auth/not-supported.md) - a lite acceptor answers AUTH_ERROR NOT_SUPPORTED after a grant too, and lite messages cap at moq-transport's 65,535 bytes - [AUTH violations](/quest/m1/auth/violations.md) - every AUTH protocol violation closes the session in Rust and JS, lite and IETF - [JS fetch grant watch](/quest/m1/auth/js-fetch-watch.md) - a JS `fetchGroup` ends `Unauthorized` when its path leaves the grant - [Origin narrowing](/quest/m1/auth/narrowing.md) - a live grant narrows in diff --git a/quest/m1/auth/expired-error.md b/quest/m1/auth/expired-error.md index 6d1decfb8e..bf765aca44 100644 --- a/quest/m1/auth/expired-error.md +++ b/quest/m1/auth/expired-error.md @@ -12,6 +12,10 @@ refusal as final. - Add the variant to the `#[non_exhaustive]` error, so the change is additive on `main`. Map it from lite's `AUTH_ERROR { Expired }` and moq-transport's `EXPIRED_AUTH_TOKEN`, and back again when refusing. +- Lite session code 0x30 is NOT_SUPPORTED + ([Lite NOT_SUPPORTED](/quest/m1/auth/not-supported.md)). `Expired` takes a + shared moq-transport session value if one means the same thing, otherwise + 0x31. - Carry it through moq-ffi's error mapping and each wrapper. Moved from m2 into the auth line: relay tokens refuse an expired diff --git a/quest/m1/auth/not-supported.md b/quest/m1/auth/not-supported.md new file mode 100644 index 0000000000..ccd1e2debd --- /dev/null +++ b/quest/m1/auth/not-supported.md @@ -0,0 +1,62 @@ +# [M] Lite NOT_SUPPORTED + +## Goal + +A lite presenter learns that its token is unsupported whenever that happens, +not only on the first reply. Today, if a grant update is too large for AUTH_OK +after a grant was issued, the acceptor resets the stream with INTERNAL_ERROR. +The grant is revoked, but the presenter sees a generic stream error, while the +moq-transport binding answers `AUTH_ERROR { NOT_SUPPORTED }`. After this +change, lite has a NOT_SUPPORTED session code, so `Error::Unsupported` +round-trips on both wires. Lite's message ceiling also drops to +moq-transport's 65,535 bytes and is stated in the lite draft, so the same +grant is too large on both wires. + +## Plan + +Decided while planning the follow-ups of +[#4446](https://github.com/moq-dev/moq/pull/4446): + +- **The carrier is AUTH_ERROR, not a stream reset code.** AUTH_ERROR after + AUTH_OK already revokes a grant, so this mirrors the IETF binding exactly. + A lite acceptor answers `AUTH_ERROR { NOT_SUPPORTED }` for every + unsupported case: no in-band verification, a grant that cannot be + expressed, or a grant too large for AUTH_OK, whether as the first reply or + after one. A presenter still reads a reset before any reply as unsupported, + because older acceptors and peers without the Auth Stream reset. +- **The code is lite session code 0x30 NOT_SUPPORTED**, the first of + moq-lite's own range (48 to 63). moq-transport's session registry has no + NOT_SUPPORTED. A bridge maps it to REQUEST_ERROR NOT_SUPPORTED (0x3), which + `Error::Unsupported` already does on IETF. + [Expired token error](/quest/m1/auth/expired-error.md) allocates after it. +- **`Error::Unsupported` maps to 0x30 everywhere on lite**, not just in + AUTH_ERROR. Rust `SessionError` and JS `SessionCode` round-trip it, so a + session closed for an unsupported feature stops reading as INTERNAL_ERROR. +- **The lite message ceiling is 65,535 bytes**, copied from moq-transport's + 16-bit control Message Length. It replaces the 64 MiB `MAX_MESSAGE_SIZE` in + `rs/moq-net/src/lite/message.rs` and `js/net/src/lite/message.ts`. The lite + draft's Message Length section makes it normative: a longer message is a + PROTOCOL_VIOLATION, and a receiver MAY reject it from the length prefix + alone. The separate 65,536-byte SETUP rule is folded into this one. Check + that frame payloads, which have their own size limit, are not counted + against it. +- **Compatibility.** An old peer that receives 0x30 treats it as an + unspecified error, as the draft requires. That is no worse than today's + reset. Lowering the receive limit on published lite versions only refuses + messages between 64 KiB and 64 MiB. AUTH_OK is unreleased and is the only + message plausibly that large; confirm nothing else can reach it before + landing. +- Drafts: add the session code and the Message Length cap to + `drafts/draft-lcurley-moq-lite.md`, and replace the reset rule in the Auth + Stream section. Update `drafts/draft-lcurley-moq-auth.md` only where it + refers to lite. `just drafts check`. +- Tests: a regression test per side (Rust and JS). An oversized update after + an AUTH_OK reports `Unsupported` and revokes the grant. Also add an interop + case, run with `just test interop --all`. + +Public API: none new (`Error::Unsupported` exists). Wire: a new lite session +code, and a lower lite message ceiling. + +## Related + +- [AUTH endings](/quest/m1/auth/error-codes.md) - a malformed or out-of-range AUTH_ERROR code is refused on the same path From d2755ac02b3e0adcc72f5acd9749337af18782c1 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 29 Sep 2026 21:48:00 -0700 Subject: [PATCH 33/53] test(net): bound the JS grant-stream regression wait A regression left `Issued.closed` pending, so the test hung until the runner's own timeout, once per protocol. Race it against the existing `withTimeout` helper, which clears its timer, and fail loud when the setup token never arrives. --- js/net/src/auth.test.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/js/net/src/auth.test.ts b/js/net/src/auth.test.ts index 3912c7286b..ab0690a2a7 100644 --- a/js/net/src/auth.test.ts +++ b/js/net/src/auth.test.ts @@ -10,6 +10,7 @@ import { Producer as OriginProducer } from "./origin.ts"; import * as Path from "./path.ts"; import { Writer } from "./stream.ts"; import { Timestamp } from "./time.ts"; +import { withTimeout } from "./util/timeout.ts"; import { wireOf } from "./wire.ts"; const url = new URL("https://localhost:4443/test"); @@ -172,15 +173,16 @@ describe.each([Lite.ALPN_06, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", (pro test("an acceptor that ends a grant sees its stream close", async () => { const { client, server } = await connect({ publish: new OriginProducer(), protocol }); - const requests = server.auth.requests(); - const request = await requests.next(); - const issued = request?.accept(grant(["a"], [])); - await waitFor(client.auth.grant, (g) => g !== undefined && g.publish.size > 0); - try { - issued?.close(); - // A regression leaves `closed` pending, so the test runner's timeout fails it. - expect(await issued?.closed).toBeNull(); + const requests = server.auth.requests(); + const request = await requests.next(); + if (!request) throw new Error("the setup token never arrived"); + const issued = request.accept(grant(["a"], [])); + await waitFor(client.auth.grant, (g) => g !== undefined && g.publish.size > 0); + + issued.close(); + // A regression leaves `closed` pending, so bound the wait rather than hang the runner. + expect(await withTimeout(Promise.resolve(issued.closed), 1000, "the grant stream never closed")).toBeNull(); } finally { client.close(); server.close(); From 6857be9ba85ec349c1168ee2c0a46ea8897247d5 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 30 Sep 2026 06:29:52 -0700 Subject: [PATCH 34/53] fix(net): settle grants with the error that ended the session `run_dispatch` owns the TaskSet that holds the AUTH serve tasks, so an error return dropped them before the driver reached its own `auth.close`. The new `Serving` guard then read a still-open handle and recorded `Cancel`, and because the first reason wins, the driver's later close with the real error could not correct it. Every grant on the session reported a bare cancel instead of, say, `UnexpectedStream`. Scope the loop so the handle closes while `tasks` is still alive. The driver closes it again with the same error, which then does nothing. No public API or wire change. Co-Authored-By: Space Bunny Free --- rs/moq-net/src/ietf/session.rs | 152 +++++++++++++++++++-------------- 1 file changed, 88 insertions(+), 64 deletions(-) diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index d296815f80..6f3f3c3fa8 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -856,74 +856,98 @@ where let declared = subscriber.solicit().await; let mut tasks = TaskSet::owned(); - let mut accept = session.clone(); - loop { - let mut stream = tasks - .drive(|waiter| { - let mut cx = std::task::Context::from_waker(waiter.waker()); - Stream::poll_accept(&mut accept, version, &mut cx) - }) - .await?; - - // The intermediate results live outside the poll closure, so a Pending - // mid-header resumes where it left off. - let mut hdr_id: Option = None; - let mut hdr_size: Option = None; - let header = tasks - .drive(|waiter| { - let mut cx = std::task::Context::from_waker(waiter.waker()); - let id = match hdr_id { - Some(id) => id, - None => *hdr_id.insert(std::task::ready!(stream.reader.poll_decode(&mut cx))?), - }; - let size = match hdr_size { - Some(size) => size, - None => *hdr_size.insert(std::task::ready!(stream.reader.poll_decode(&mut cx))?), - }; - let data = std::task::ready!(stream.reader.poll_read_exact(&mut cx, size as usize))?; - std::task::Poll::Ready(Ok::<_, Error>((id, data))) - }) - .await; - // Same tolerance as `run_unis`: a request stream that dies before its header - // is the peer abandoning that request, not the session. Anything else, a - // header that does not parse included, still fails the session. - let (id, data) = match header { - Ok(header) => header, - Err(err @ (Error::Cancel | Error::Stream(_) | Error::Remote(_) | Error::Decode(DecodeError::Short))) => { - tracing::debug!(%err, "dropping bidi stream that died before its header"); - continue; - } - Err(err) => return Err(err), - }; - - match id { - // Publisher handles: Subscribe, Fetch, SubscribeNamespace (0x50 modern / - // 0x11 legacy), TrackStatus - ietf::Subscribe::ID - | ietf::Fetch::ID - | ietf::SubscribeNamespace::ID - | ietf::SubscribeNamespaceLegacy::ID - | ietf::TrackStatus::ID => { - tasks.push(publisher.handle_stream(id, data, stream)?); - } - // Subscriber handles: Publish, PublishNamespace - ietf::Publish::ID | ietf::PublishNamespace::ID => { - tasks.push(subscriber.handle_stream(id, data, stream, peer, declared)?); - } - auth::Auth::ID if let Some(serve) = &serve => { - let mut data = data; - let msg = auth::Auth::decode_msg(&mut data, version)?; - if !data.is_empty() { - return Err(Error::WrongSize); + // Each AUTH serve task settles `Issued::closed` from whatever the auth handle + // holds when it drops, and the first reason wins. So the error that ends the + // session has to reach the handle while `tasks` is still alive, or every grant + // reports a bare cancel instead. `None` when the peer never negotiated AUTH, + // which leaves no serve task to settle. + let handle = serve.as_ref().map(|serve| serve.handle.clone()); + + // Scoped so `tasks` outlives the close below: the loop borrows it, so it only + // drops once this block's future is done. + let res: Result<(), Error> = async { + let mut accept = session.clone(); + loop { + let mut stream = tasks + .drive(|waiter| { + let mut cx = std::task::Context::from_waker(waiter.waker()); + Stream::poll_accept(&mut accept, version, &mut cx) + }) + .await?; + + // The intermediate results live outside the poll closure, so a Pending + // mid-header resumes where it left off. + let mut hdr_id: Option = None; + let mut hdr_size: Option = None; + let header = tasks + .drive(|waiter| { + let mut cx = std::task::Context::from_waker(waiter.waker()); + let id = match hdr_id { + Some(id) => id, + None => *hdr_id.insert(std::task::ready!(stream.reader.poll_decode(&mut cx))?), + }; + let size = match hdr_size { + Some(size) => size, + None => *hdr_size.insert(std::task::ready!(stream.reader.poll_decode(&mut cx))?), + }; + let data = std::task::ready!(stream.reader.poll_read_exact(&mut cx, size as usize))?; + std::task::Poll::Ready(Ok::<_, Error>((id, data))) + }) + .await; + // Same tolerance as `run_unis`: a request stream that dies before its header + // is the peer abandoning that request, not the session. Anything else, a + // header that does not parse included, still fails the session. + let (id, data) = match header { + Ok(header) => header, + Err( + err @ (Error::Cancel | Error::Stream(_) | Error::Remote(_) | Error::Decode(DecodeError::Short)), + ) => { + tracing::debug!(%err, "dropping bidi stream that died before its header"); + continue; + } + Err(err) => return Err(err), + }; + + match id { + // Publisher handles: Subscribe, Fetch, SubscribeNamespace (0x50 modern / + // 0x11 legacy), TrackStatus + ietf::Subscribe::ID + | ietf::Fetch::ID + | ietf::SubscribeNamespace::ID + | ietf::SubscribeNamespaceLegacy::ID + | ietf::TrackStatus::ID => { + tasks.push(publisher.handle_stream(id, data, stream)?); + } + // Subscriber handles: Publish, PublishNamespace + ietf::Publish::ID | ietf::PublishNamespace::ID => { + tasks.push(subscriber.handle_stream(id, data, stream, peer, declared)?); + } + auth::Auth::ID if let Some(serve) = &serve => { + let mut data = data; + let msg = auth::Auth::decode_msg(&mut data, version)?; + if !data.is_empty() { + return Err(Error::WrongSize); + } + tasks.push(serve.clone().run(stream, msg, version)); + } + _ => { + tracing::warn!(id, "unexpected bidi stream type"); + return Err(Error::UnexpectedStream); } - tasks.push(serve.clone().run(stream, msg, version)); - } - _ => { - tracing::warn!(id, "unexpected bidi stream type"); - return Err(Error::UnexpectedStream); } } } + .await; + + // Every error exit above drops the AUTH serve tasks in `tasks`, so the session's + // error has to land on the handle first. The driver closes it again with the same + // error, which then does nothing. + if let Some(handle) = &handle + && let Err(err) = &res + { + handle.close(err.clone()); + } + res } /// Monitor the peer's SETUP stream for a GOAWAY, surfacing it through From 8ad7606ce56d4b835909f23815f1f6699ec10e7d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 30 Sep 2026 07:34:53 -0700 Subject: [PATCH 35/53] quest(m1/auth): move peer grants to the P2P line Peer grants have one consumer, P2P signaling, so they move to quest/m2/p2p/peer-grant.md and leave the auth line's Required. The auth README now scopes the no-prefix-only AUTH_OK rule to lite (IETF AUTH_OK carries prefixes and answers NOT_SUPPORTED otherwise) and adopts relay-refresh's connection-owned Connection::auth() handle. Co-Authored-By: Claude Opus 5.5 --- quest/m1/auth/README.md | 26 +++++++++++++------------ quest/m2/p2p/README.md | 2 +- quest/{m1/auth => m2/p2p}/peer-grant.md | 10 +++------- quest/m2/p2p/signal.md | 6 +++--- 4 files changed, 21 insertions(+), 23 deletions(-) rename quest/{m1/auth => m2/p2p}/peer-grant.md (91%) diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index 7cbb6da657..3dbe47ddb9 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -16,9 +16,8 @@ This questline adds an AUTH exchange to both wires: one stream per token, a grant per token, the union of every accepted token as the session's scope, and a loud failure when a publish can never be honored. It ends with the credential able to travel in band, while the URL keeps working for every peer -that predates the stream. Direct peer sessions need a second credential: a -relay-signed, hop-bound grant that a browser can verify without a signing -key, which HMAC relay keys cannot provide. +that predates the stream. Hop-bound peer grants for direct sessions belong to +[P2P](/quest/m2/p2p/peer-grant.md), their only consumer. ## Plan @@ -44,11 +43,13 @@ Decisions settled while planning, recorded so review does not relitigate them: - **A public grant contains publish patterns, subscribe patterns, and an expiry**, in the presenter's own root; the presenter never sees the relay-side root, and every token in a union shares the connection's root. Unscoped - permission is `**`; an empty union grants nothing. AUTH_OK carries those - patterns, wildcards and literals alike, from the first release. There is no prefix-only AUTH_OK and no - covering-prefix workaround. Announce stays a prefix: ANNOUNCE_REQUEST and - SUBSCRIBE_NAMESPACE do not gain patterns in that change. The public grant - type stays pattern-valued. + permission is `**`; an empty union grants nothing. Lite AUTH_OK carries + those patterns, wildcards and literals alike, from the first release, with + no covering-prefix workaround. IETF AUTH_OK carries Track Namespace + prefixes, so an acceptor whose grant is not a union of subtrees answers + AUTH_ERROR NOT_SUPPORTED rather than widening it. Announce stays a prefix: + ANNOUNCE_REQUEST and SUBSCRIBE_NAMESPACE do not gain patterns. The public + grant type stays pattern-valued. - **Fail loud by aborting the session.** A publisher whose origin announces a broadcast outside the union aborts the session with `Unauthorized`, naming the path. The check runs against the grants in hand once the tokens the @@ -72,7 +73,9 @@ Decisions settled while planning, recorded so review does not relitigate them: must explicitly grant `**` for unrestricted access; AUTH does not widen a scoped grant because the caller is another relay. - **Client API.** Tokens live on `moq_tokio::connect::Config`, the - dial-side config, and `Connection` exposes the live session's auth handle. + dial-side config. `Connection::auth()` is a handle the connection owns: it + keeps every added token, presents them on each session as it reconnects, + and reports the live session's grant. - **Spec home.** The AUTH stream is lite-06 core in `drafts/draft-lcurley-moq-lite.md`, the way routing is. moq-transport gets `drafts/draft-lcurley-moq-auth.md`, a setup-option-negotiated extension with @@ -103,11 +106,10 @@ existing lite-06 ALPN. - [Token in band](/quest/m1/auth/token-in-band.md) - the credential can leave the URL: a session starts on what the URL carried and its AUTH streams add the rest, with the URL kept for peers below lite-06 -- [Peer grants](/quest/m1/auth/peer-grant.md) - the relay issues a hop-bound, - asymmetrically signed grant a browser can verify; HS256 keys issue none ## Related - [Expiring media grants](/quest/m2/processor/grant-lease.md) - a worker's lease renewal is a new in-band token -- [P2P](/quest/m2/p2p/README.md) - the first consumer of hop-bound peer grants +- [Peer grants](/quest/m2/p2p/peer-grant.md) - P2P's hop-bound credential, + built on this line's relay tokens diff --git a/quest/m2/p2p/README.md b/quest/m2/p2p/README.md index f6e40ddb04..42eaa0e9c9 100644 --- a/quest/m2/p2p/README.md +++ b/quest/m2/p2p/README.md @@ -129,6 +129,7 @@ WASM build, so the browser side stays TypeScript. ## Required - [Data channel transport](/quest/m2/p2p/transport.md) - `@moq/p2p` speaks qmux over one ordered RTCDataChannel behind the WebTransport shape `@moq/net` consumes +- [Peer grants](/quest/m2/p2p/peer-grant.md) - the relay issues a hop-bound, asymmetrically signed grant a browser can verify; HS256 keys issue none - [Signaling and policy](/quest/m2/p2p/signal.md) - opted-in peers find each other under the prefix, the application picks who to dial, and the roster-size gate decides whether STUN is used - [Native data channel transport](/quest/m2/p2p/webrtc.md) - `moq-tokio` holds a moq-net session with a browser over str0m with a full ICE agent - [moq-cli joins](/quest/m2/p2p/cli.md) - `--p2p` publishes a roster entry with its iroh endpoint, dials iroh between native peers, and serves browsers as a transit hop @@ -140,5 +141,4 @@ WASM build, so the browser side stays TypeScript. ## Related -- [Peer grants](/quest/m1/auth/peer-grant.md) - the hop-bound credential a direct session presents; HMAC keys issue none - [E2EE](/quest/m1/e2ee/README.md) - what a peer would need if the token scope stopped being the trust boundary diff --git a/quest/m1/auth/peer-grant.md b/quest/m2/p2p/peer-grant.md similarity index 91% rename from quest/m1/auth/peer-grant.md rename to quest/m2/p2p/peer-grant.md index 12e0aa9298..4492b06a4d 100644 --- a/quest/m1/auth/peer-grant.md +++ b/quest/m2/p2p/peer-grant.md @@ -45,9 +45,9 @@ against the roster, and verifies AUTH_POP with `cnf`. The origin then serves only the granted paths. A missing, expired, HMAC-signed, or unproven grant is not a grant. -P2P is the first consumer: [Signaling and policy](/quest/m2/p2p/signal.md) presents the grant in band -on each direct session. This quest does not depend on that line. +on each direct session. P2P is its only consumer, so it lives on this line +rather than the auth line (decided in the 2026-09-30 audit). Docs: `doc/bin/relay/auth.md` states that peer grants need an asymmetric key, that HS256 operators get none, and that the public JWKS is not a @@ -61,8 +61,4 @@ Additive. ## Required -- [Relay tokens](/quest/m1/auth/relay-refresh.md) - the relay owns AUTH and knows the session's paths - -## Related - -- [Signaling and policy](/quest/m2/p2p/signal.md) - the first consumer +- [In-band auth](/quest/m1/auth/README.md) - relay tokens reach `main` with this line, so the relay owns AUTH and knows the session's paths diff --git a/quest/m2/p2p/signal.md b/quest/m2/p2p/signal.md index c088426611..e1b56885c0 100644 --- a/quest/m2/p2p/signal.md +++ b/quest/m2/p2p/signal.md @@ -25,7 +25,7 @@ Rust, so the id in the roster is the id in every chain the tab forwards. Roster: each peer publishes `` with an `info.json` snapshot track: the moq ALPNs it accepts, `webrtc: true`, whether it can run qmux unordered, the presenter public key that -[peer grants](/quest/m1/auth/peer-grant.md) bind, the application's `meta`, +[peer grants](/quest/m2/p2p/peer-grant.md) bind, the application's `meta`, and for native peers an optional `webtransport: { url, fingerprint }` and `iroh` endpoint id. The schema is shared with [moq-cli](/quest/m2/p2p/cli.md). Unordered is advertised here so the dialer @@ -74,7 +74,7 @@ and short-lived, which the peer presents in band with a proof of possession; the other side verifies the relay's signature, the hop id and key against the roster, and AUTH_POP, then serves only the granted paths. Issuance, asymmetric keys, JWKS, PoP, and refresh live in -[Peer grants](/quest/m1/auth/peer-grant.md): HMAC keys cannot be given to +[Peer grants](/quest/m2/p2p/peer-grant.md): HMAC keys cannot be given to browsers without also letting them forge grants, so an HS256-only relay issues nothing. A peer session with no verifiable grant serves nothing; there is no equal-scope shortcut. @@ -82,4 +82,4 @@ there is no equal-scope shortcut. ## Required - [Data channel transport](/quest/m2/p2p/transport.md) -- [Peer grants](/quest/m1/auth/peer-grant.md) - the hop-bound, asymmetrically signed credential a direct session presents; HS256 keys issue none +- [Peer grants](/quest/m2/p2p/peer-grant.md) - the hop-bound, asymmetrically signed credential a direct session presents; HS256 keys issue none From 657ce49c6c5ed8f2dceb718f152719465df4e221 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 6 Oct 2026 14:31:55 -0700 Subject: [PATCH 36/53] quest(auth): request caps owns the lite message ceiling; FFI shape first Per the 2026-10-06 audit: Lite NOT_SUPPORTED drops the 65,535-byte message ceiling, which request caps (#4820) owns, and token-in-band and bindings require the FFI shape line so the wrappers break once. Matches main (#4946). Co-Authored-By: Claude Opus 5.5 --- quest/m1/auth/README.md | 2 +- quest/m1/auth/bindings.md | 1 + quest/m1/auth/not-supported.md | 30 +++++++++++------------------- quest/m1/auth/token-in-band.md | 1 + 4 files changed, 14 insertions(+), 20 deletions(-) diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index 6a75d5ae53..601509924a 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -96,7 +96,7 @@ published version in place, so AUTH and its stream code land in - [WebSocket refusal](/quest/m1/auth/ws-unauthorized.md) - the relay serves WebSocket through moq-tokio, so a refused token closes the session as Unauthorized, as QUIC does - [AUTH on the wip version](/quest/m1/auth/wip-version.md) - lite AUTH and UNAUTHORIZED move from lite-06 to `moq-lite-07-wip`, so no published version changes in place -- [Lite NOT_SUPPORTED](/quest/m1/auth/not-supported.md) - a lite acceptor answers AUTH_ERROR NOT_SUPPORTED after a grant too, and lite messages cap at moq-transport's 65,535 bytes +- [Lite NOT_SUPPORTED](/quest/m1/auth/not-supported.md) - a lite acceptor answers AUTH_ERROR NOT_SUPPORTED after a grant too, with a lite session code for `Error::Unsupported` - [AUTH violations](/quest/m1/auth/violations.md) - every AUTH protocol violation closes the session in Rust and JS, lite and IETF - [Malformed grant](/quest/m1/auth/malformed-grant.md) - a malformed or non-canonical grant pattern, or an out-of-range `Expires`, closes the diff --git a/quest/m1/auth/bindings.md b/quest/m1/auth/bindings.md index 329cf234a7..f582e89030 100644 --- a/quest/m1/auth/bindings.md +++ b/quest/m1/auth/bindings.md @@ -43,5 +43,6 @@ surface comes from moq-ffi rather than new `moq_session_auth_*` calls. ## Required +- [FFI shape](/quest/m1/ffi-shape/README.md) - reshapes the moq-ffi session and wrappers this edits, so the wrappers break once - [Relay tokens](/quest/m1/auth/relay-refresh.md) - supplies the connection accessor and a relay that answers a real token, which the wrapper tests need diff --git a/quest/m1/auth/not-supported.md b/quest/m1/auth/not-supported.md index 09031b14f5..236e81d736 100644 --- a/quest/m1/auth/not-supported.md +++ b/quest/m1/auth/not-supported.md @@ -8,9 +8,7 @@ after a grant was issued, the acceptor resets the stream with INTERNAL_ERROR. The grant is revoked, but the presenter sees a generic stream error, while the moq-transport binding answers `AUTH_ERROR { NOT_SUPPORTED }`. After this change, lite has a NOT_SUPPORTED session code, so `Error::Unsupported` -round-trips on both wires. Lite's message ceiling also drops to -moq-transport's 65,535 bytes and is stated in the lite draft, so the same -grant is too large on both wires. +round-trips on both wires. ## Plan @@ -32,31 +30,25 @@ Decided while planning the follow-ups of - **`Error::Unsupported` maps to 0x30 everywhere on lite**, not just in AUTH_ERROR. Rust `SessionError` and JS `SessionCode` round-trip it, so a session closed for an unsupported feature stops reading as INTERNAL_ERROR. -- **The lite message ceiling is 65,535 bytes**, copied from moq-transport's - 16-bit control Message Length. It replaces the 64 MiB `MAX_MESSAGE_SIZE` in - `rs/moq-net/src/lite/message.rs` and `js/net/src/lite/message.ts`. The lite - draft's Message Length section makes it normative: a longer message is a - PROTOCOL_VIOLATION, and a receiver MAY reject it from the length prefix - alone. The separate 65,536-byte SETUP rule is folded into this one. Check - that frame payloads, which have their own size limit, are not counted - against it. +- **The message ceiling is not this quest's** (decided in the 2026-10-06 + audit): [Request caps](/quest/m0/request-caps.md) (#4820) caps lite + messages at 65,535 bytes, moq-transport's 16-bit control Message Length, so + a grant too large for AUTH_OK is too large on both wires. This quest only + answers that case with NOT_SUPPORTED. - **Compatibility.** An old peer that receives 0x30 treats it as an unspecified error, as the draft requires. That is no worse than today's - reset. Lowering the receive limit on published lite versions only refuses - messages between 64 KiB and 64 MiB. AUTH_OK is unreleased and is the only - message plausibly that large; confirm nothing else can reach it before - landing. -- Drafts: add the session code and the Message Length cap to - `drafts/draft-lcurley-moq-lite.md`, and replace the reset rule in the Auth - Stream section. Update `drafts/draft-lcurley-moq-auth.md` only where it + reset. +- Drafts: add the session code to `drafts/draft-lcurley-moq-lite.md`, and + replace the reset rule in the Auth Stream section. Update `drafts/draft-lcurley-moq-auth.md` only where it refers to lite. `just drafts check`. - Tests: a regression test per side (Rust and JS). An oversized update after an AUTH_OK reports `Unsupported` and revokes the grant. Also add an interop case, run with `just test interop --all`. Public API: none new (`Error::Unsupported` exists). Wire: a new lite session -code, and a lower lite message ceiling. +code. ## Related +- [Request caps](/quest/m0/request-caps.md) - owns the 65,535-byte lite message ceiling that makes a grant too large for AUTH_OK - [AUTH violations](/quest/m1/auth/violations.md) - AUTH protocol violations close the session on the same path (AUTH endings landed in #4550) diff --git a/quest/m1/auth/token-in-band.md b/quest/m1/auth/token-in-band.md index 069da82d03..5a6be0335d 100644 --- a/quest/m1/auth/token-in-band.md +++ b/quest/m1/auth/token-in-band.md @@ -82,6 +82,7 @@ Additive. ## Required +- [FFI shape](/quest/m1/ffi-shape/README.md) - puts the `MoqClientConfig` record in place that `tokens` extends, so the wrappers break once - [Relay tokens](/quest/m1/auth/relay-refresh.md) - supplies the verify and widen path the configured tokens reuse - [Bindings](/quest/m1/auth/bindings.md) - supplies the client surface the new From 1d109a19d20a8b3c74b20cd8639d7a381bed5396 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 6 Oct 2026 14:33:12 -0700 Subject: [PATCH 37/53] quest(auth): drop the processor link, deleted on main (#4946) Co-Authored-By: Claude Opus 5.5 --- quest/m1/auth/README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index 601509924a..d81105229d 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -117,7 +117,5 @@ published version in place, so AUTH and its stream code land in ## Related -- [Expiring media grants](/quest/m3/processor/grant-lease.md) - a worker's - lease renewal is a new in-band token - [Peer grants](/quest/m3/p2p/peer-grant.md) - P2P's hop-bound credential, built on this line's relay tokens From 3c79ea0ac7a69ad362eeaa659613b91345d55eba Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 7 Oct 2026 16:50:38 -0700 Subject: [PATCH 38/53] feat(net)!: AUTH moves to moq-lite-07-wip (#5004) Co-authored-by: Claude Opus 5.5 --- doc/concept/moq-lite.md | 5 +- doc/lib/js/net.md | 2 +- doc/lib/rs/moq-net.md | 2 +- drafts/draft-lcurley-moq-lite.md | 7 +- js/net/src/auth.test.ts | 30 +++++-- js/net/src/auth.ts | 2 +- js/net/src/lite/auth.test.ts | 39 ++++---- js/net/src/lite/auth.ts | 2 +- js/net/src/lite/version.ts | 5 +- quest/m1/auth/README.md | 1 - quest/m1/auth/wip-version.md | 37 -------- rs/moq-net/src/ietf/auth.rs | 2 +- rs/moq-net/src/lite/auth.rs | 27 +++--- rs/moq-net/src/lite/publisher.rs | 2 +- rs/moq-net/src/lite/session.rs | 4 +- rs/moq-net/src/lite/version.rs | 13 ++- rs/moq-net/src/session.rs | 2 +- rs/moq-net/tests/auth.rs | 99 +++++++++++++++++---- rs/moq-net/tests/support/mock.rs | 25 ++++++ test/interop/README.md | 19 ++-- test/interop/clients/js-native/subscribe.ts | 9 +- test/interop/interop.sh | 25 ++++-- test/interop/interop.toml | 20 +++++ 23 files changed, 243 insertions(+), 136 deletions(-) delete mode 100644 quest/m1/auth/wip-version.md diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 256f90908f..f774821b8a 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -43,7 +43,7 @@ still refuses lite-07 values above 2^62-1 until its `VarInt` widens. ## Authorization -On moq-lite 06 each side presents a token on its own Auth stream and learns +On moq-lite 07 each side presents a token on its own Auth stream and learns what it may publish and subscribe to. Right after setup both sides present the credential the connection already carried (the URL token, a client certificate, or nothing), so a publisher learns before anyone subscribes @@ -54,7 +54,8 @@ covered. A grant is a union of [path patterns](#path-patterns), so `room/*/cam` or the exact broadcast `room/alice` arrives as issued rather than widened to a prefix. A subscription or fetch that loses access resets with the `UNAUTHORIZED` stream code, so the peer can tell it apart from the session -closing. +closing. The code dates from moq-lite 06, since a relay can narrow a session +that has no Auth stream. A client that publishes a broadcast outside its grant closes the session with `UNAUTHORIZED` and names the path in the close reason, rather than waiting diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index 59bc7e7591..9722330d65 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -57,7 +57,7 @@ for (;;) { - **Subscriptions** carry a priority, a `Time.Milli` max age, and optional `groups` bounds. Groups arrive out of order and are read frame by frame, with `Error.TooFarBehind` when a reader asks for a frame the group never held and `Error.GroupTooLarge` when a write exceeds the cache budget and aborts the group. - **Track ends**: `close()` ends a track at its live edge, while `finishAt(n)` declares the exclusive end ahead of it and still accepts the groups below. A subscriber reads the end with `final()` or awaits `finished()`. Group and datagram readers end once the live edge reaches the declared boundary, even while missing lower groups wait out the grace. The `closed` signal settles separately once every group below the end has arrived or was dropped; one reset before its header arrived is skipped after the subscription's max age on moq-lite (one second without one), or after one second on IETF. - **Datagrams** on moq-lite 05+ and fetch-by-sequence for history. `track.fetchGroup(sequence)` on moq-lite resolves when the publisher sends the first response byte or finishes an empty group. A missing group rejects the fetch with `StreamCode.NotFound`, including every concurrent caller sharing that fetch. `fetchGroup(sequence, { signal })` abandons a pending fetch with the signal's reason; the shared stream is cancelled only once every caller has left. Once resolved, close the group instead. -- **Authorization** on moq-lite 06, and on moq-transport draft-17+ when both sides negotiate the [MoQ Auth extension](/draft/moq-auth): an established session's `auth.grant` is a `Getter` with what the relay lets this side publish and subscribe to, learned right after setup. `auth.add(token)` presents another token without reconnecting and resolves with an `Auth.Token` to `close()` later; it rejects with `Auth.Unsupported` when the peer takes no tokens in band, or on moq-transport when the grant is not a union of subtrees, which its namespace prefixes cannot carry. A connection that publishes a broadcast outside its grant closes with `SessionCode.Unauthorized`, naming the path in the reason. On moq-lite, a subscription the grant stops covering resets with `StreamCode.Unauthorized` and the session stays up. +- **Authorization** on moq-lite 07 (offered only when `moq-lite-07-wip` is listed in `webtransport.protocols`; the WebSocket fallback cannot offer it), and on moq-transport draft-17+ when both sides negotiate the [MoQ Auth extension](/draft/moq-auth): an established session's `auth.grant` is a `Getter` with what the relay lets this side publish and subscribe to, learned right after setup. `auth.add(token)` presents another token without reconnecting and resolves with an `Auth.Token` to `close()` later; it rejects with `Auth.Unsupported` when the peer takes no tokens in band, or on moq-transport when the grant is not a union of subtrees, which its namespace prefixes cannot carry. A connection that publishes a broadcast outside its grant closes with `SessionCode.Unauthorized`, naming the path in the reason. On moq-lite 06+, a subscription the grant stops covering resets with `StreamCode.Unauthorized` and the session stays up. - **Errors**: deliberately closing a connection ends its received tracks cleanly. A peer close ends received tracks and open group readers with the session error and its close code. Errors live under one namespace: a stream reset throws `Error.Stream` with a `StreamCode`, while a session close gives `Error.Session` with a `SessionCode`. The registries are disjoint, so the same number means different things in each, and 64+ is yours. Named conditions such as `Error.TooFarBehind`, `Error.FrameTooLarge`, and `Error.GroupTooLarge` subclass `Error.Stream`, so one `code` check handles a condition raised here or reported by the peer. IETF streams use their own mapping: cancellation sends CANCELLED, other local failures send INTERNAL\_ERROR, and received codes remain opaque. - **Paths** with `Path.relative` for the cross-broadcast catalog references hang uses. A `Broadcast.Consumer` names its broadcast by `path`: the path it was requested at relative to the origin handle's root, or empty for a standalone broadcast. Those references resolve against it. Path patterns (`Path.Pattern`, `Path.Patterns`) are re-exported from [`@moq/pattern`](https://www.npmjs.com/package/@moq/pattern). Literal `Path` stays a coordinate. diff --git a/doc/lib/rs/moq-net.md b/doc/lib/rs/moq-net.md index 974551c87d..426f595e34 100644 --- a/doc/lib/rs/moq-net.md +++ b/doc/lib/rs/moq-net.md @@ -89,7 +89,7 @@ Expiry is therefore approximate; a late `gc` extends retention. ## Authorization -`session.auth()` is the session's `auth::Handle`. On moq-lite 06, and on +`session.auth()` is the session's `auth::Handle`. On moq-lite 07, and on moq-transport draft-17+ when both sides negotiate the [MoQ Auth extension](/draft/moq-auth), each side presents its connection's credential right after setup, and `grant()` watches the union of every grant diff --git a/drafts/draft-lcurley-moq-lite.md b/drafts/draft-lcurley-moq-lite.md index 6a1fba9ca9..0dea63410d 100644 --- a/drafts/draft-lcurley-moq-lite.md +++ b/drafts/draft-lcurley-moq-lite.md @@ -327,7 +327,7 @@ Sent when resetting a stream (RESET_STREAM), or when refusing to receive one (ST | ------- | ------------- | ----------- | | 0x39 | TIMESTAMP_MISMATCH | A frame's timestamp does not match its track's timescale. | | ------- | ------------- | ----------- | -| 0x3A | UNAUTHORIZED | The [scope](#auth-stream) does not cover this request, or no longer does. The session stays up. | +| 0x3A | UNAUTHORIZED | The endpoint does not authorize this request, or no longer does, such as when the [scope](#auth-stream) does not cover it. The session stays up. | | ------- | ------------- | ----------- | Note that CANCELLED is 0x1, not 0x0: a stream reset with 0x0 is an INTERNAL_ERROR, not a routine cancellation. @@ -1472,13 +1472,12 @@ The `Message Length` describes the payload size on the wire. - The Subscribe Stream FIN now follows once every counted Group Stream has finished or been reset. - Added announce compression: ANNOUNCE_START gains `Path Base` and `Path Keep` to copy the head of a live advertisement's suffix, and ANNOUNCE_START and ANNOUNCE_UPDATE gain `Hop Base` and `Hop Keep` to copy the tail of a live advertisement's Hop ID list. - Capped the SETUP Message Length at 65,536 bytes. -- An AUTH_ERROR code that does not fit in 32 bits is a PROTOCOL_VIOLATION. +- Added the Auth Stream (0x7) with AUTH, AUTH_OK, and AUTH_ERROR: either endpoint presents a token on its own stream and learns the [path patterns](#path-pattern) it may publish and subscribe to. The union of a session's open grants is its scope. A shrink withdraws what it no longer covers, and an announcement outside the scope closes the session with UNAUTHORIZED. A peer without the stream resets it. - A relay puts a random Hop ID, picked per session, in front of an announcement whose reconstructed path starts with 0, and writes that stamp followed by 0 for an empty path. ## moq-lite-06 - Assigned `moq-lite-06` as this draft's protocol identifier. -- Added the Auth Stream (0x7) with AUTH, AUTH_OK, and AUTH_ERROR: either endpoint presents a token on its own stream and learns the [path patterns](#path-pattern) it may publish and subscribe to. The union of a session's open grants is its scope. A shrink withdraws what it no longer covers, and an announcement outside the scope closes the session with UNAUTHORIZED. A peer without the stream resets it. - Require error-code translation when bridging protocols and draft versions. - Made a repeated non-zero Hop ID in one announcement's Hop ID list a PROTOCOL_VIOLATION, matching draft-lcurley-moq-cluster. Repeated 0 entries stay legal. - Moved the Qmux-over-WebSocket binding details to draft-lcurley-qmux-websocket; the binding itself is unchanged. @@ -1490,7 +1489,7 @@ The `Message Length` describes the payload size on the wire. - Split the reserved stream error range: 32 through 47 stays reserved, and 48 through 63 is moq-lite's own, assigned by the tables and mapped rather than forwarded across a bridge. Assigned 0x30 NO_CAPACITY there: it permits one re-resolution within the tier excluding the refusing advertiser, and a receiver that has spent or lacks that retry resets downstream with another code. Assigned 0x32 GROUP_TOO_LARGE: a group that grew past the publisher's cache budget is aborted. Every other code is terminal. - Assigned 0x33 NOT_FOUND, 0x34 OLD, and 0x35 EVICTED in the stream error table: a group the publisher cannot serve because it was never here, has been superseded, or was dropped under memory pressure. - Assigned 0x36 UNROUTABLE, 0x37 WRONG_SIZE, 0x38 FRAME_TOO_LARGE, and 0x39 TIMESTAMP_MISMATCH in the stream error table, moving them out of the reserved 32 through 47 range, which no longer carries provisional placeholders. -- Assigned 0x3A UNAUTHORIZED in the stream error table: a request reset because the scope does not cover it, or no longer does, distinct from SESSION_CLOSED. +- Assigned 0x3A UNAUTHORIZED in the stream error table: a request reset because the endpoint does not authorize it, or no longer does, distinct from SESSION_CLOSED. - Assigned 0x31 CONTROL_TIMEOUT in the stream error table: a request stream torn down because the peer never answered, which DELIVERY_TIMEOUT described as late content. It has no moq-transport value and bridges to INTERNAL_ERROR. - A disallowed stream type, a role mismatch, or a missing extension is a PROTOCOL_VIOLATION; the session table gains no code for them, so nothing is sent from the reserved 32 through 47 range in either registry. - Added implicit Announce IDs: each ANNOUNCE_START assigns the next per-stream ordinal. diff --git a/js/net/src/auth.test.ts b/js/net/src/auth.test.ts index f2c30ae7f5..a322241ad8 100644 --- a/js/net/src/auth.test.ts +++ b/js/net/src/auth.test.ts @@ -4,6 +4,7 @@ import { type Grant, type Issued, Unsupported } from "./auth.ts"; import { accept as acceptSession, connect as connectSession, type Established } from "./connection/index.ts"; import { SessionCode, SessionError, StreamCode, toStreamCode } from "./error.ts"; import * as Ietf from "./ietf/index.ts"; +import { LiteAuthWire } from "./lite/auth.ts"; import * as Lite from "./lite/index.ts"; import { createMockTransportPair, type MockTransport } from "./mock.ts"; import { Producer as OriginProducer } from "./origin.ts"; @@ -54,8 +55,8 @@ async function connect(opts: { publish?: OriginProducer; serverPublish?: OriginP return { client, server, transport: pair.client } satisfies Pair; } -// Every case runs on moq-lite-06 and on moq-transport with the MoQ Auth extension. -describe.each([Lite.ALPN_06, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", (protocol) => { +// Every case runs on moq-lite-07-wip and on moq-transport with the MoQ Auth extension. +describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", (protocol) => { test("both sides learn their default grant", async () => { const { client, server } = await connect({ publish: new OriginProducer(), protocol }); @@ -273,8 +274,8 @@ describe.each([Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", (protocol) => { }); // moq-lite carries patterns, so every grant arrives exactly as issued. -test("lite-06 carries literal and wildcard grants exactly", async () => { - const { client, server } = await connect({ publish: new OriginProducer(), protocol: Lite.ALPN_06 }); +test("lite-07 carries literal and wildcard grants exactly", async () => { + const { client, server } = await connect({ publish: new OriginProducer(), protocol: Lite.ALPN_07_WIP }); const requests = server.auth.requests(); const unions: Record = { "": [["a/**"], []], @@ -306,7 +307,7 @@ test("lite-06 carries literal and wildcard grants exactly", async () => { server.abort(); }); -test.each([Lite.ALPN_05, Ietf.ALPN.DRAFT_16])("%s has no grant", async (protocol) => { +test.each([Lite.ALPN_05, Lite.ALPN_06, Ietf.ALPN.DRAFT_16])("%s has no grant", async (protocol) => { const { client, server } = await connect({ publish: new OriginProducer(), protocol }); expect(client.auth.grant.peek()).toBeUndefined(); await expect(client.auth.add("token")).rejects.toBeInstanceOf(Unsupported); @@ -314,6 +315,23 @@ test.each([Lite.ALPN_05, Ietf.ALPN.DRAFT_16])("%s has no grant", async (protocol server.abort(); }); +// lite-07 presents the connection's credential on an Auth Stream right away; lite-06 never +// opens one. +test.each([ + [Lite.ALPN_06, false], + [Lite.ALPN_07_WIP, true], +])("%s opens an Auth Stream: %p", async (protocol, opens) => { + const presented = spyOn(LiteAuthWire.prototype, "present"); + try { + const { client, server } = await connect({ publish: new OriginProducer(), protocol }); + expect(presented).toHaveBeenCalledTimes(opens ? 2 : 0); + client.abort(); + server.abort(); + } finally { + presented.mockRestore(); + } +}); + // moq-transport has no stream code for it, so only moq-lite resets with UNAUTHORIZED. test("a revoked grant resets its subscriptions with UNAUTHORIZED", async () => { const clientOrigin = new OriginProducer(); @@ -329,7 +347,7 @@ test("a revoked grant resets its subscriptions with UNAUTHORIZED", async () => { const { client, server } = await connect({ publish: clientOrigin, serverPublish: serverOrigin, - protocol: Lite.ALPN_06, + protocol: Lite.ALPN_07_WIP, }); const requests = server.auth.requests(); const issued: Issued[] = []; diff --git a/js/net/src/auth.ts b/js/net/src/auth.ts index 164cf5adc4..a3f371426c 100644 --- a/js/net/src/auth.ts +++ b/js/net/src/auth.ts @@ -1,7 +1,7 @@ /** * In-band authorization: present tokens to the peer and learn what they grant. * - * Each side of a moq-lite-06 session, and of a moq-transport draft-17+ session when both + * Each side of a moq-lite-07 session, and of a moq-transport draft-17+ session when both * sides negotiate MoQ Auth, presents the credential its connection already carried (the * URL, or nothing) right after setup, and learns the {@link Grant} it earned. Older * versions, and peers that do not negotiate it, carry no AUTH exchange: the grant stays diff --git a/js/net/src/lite/auth.test.ts b/js/net/src/lite/auth.test.ts index 24c05a9a3a..547b774727 100644 --- a/js/net/src/lite/auth.test.ts +++ b/js/net/src/lite/auth.test.ts @@ -19,7 +19,7 @@ async function roundTrip(write: (w: Writer) => Promise): Promise { chunks.push(new Uint8Array(chunk)); }, }), - Lite.Version.DRAFT_06, + Lite.Version.DRAFT_07, ); await write(writer); writer.close(); @@ -31,12 +31,12 @@ async function roundTrip(write: (w: Writer) => Promise): Promise { buf.set(chunk, offset); offset += chunk.byteLength; } - return new Reader(undefined, buf, Lite.Version.DRAFT_06); + return new Reader(undefined, buf, Lite.Version.DRAFT_07); } test("AUTH round-trips its token", async () => { - const r = await roundTrip((w) => new AuthMessage(new TextEncoder().encode("jwt")).encode(w, Lite.Version.DRAFT_06)); - const msg = await AuthMessage.decode(r, Lite.Version.DRAFT_06); + const r = await roundTrip((w) => new AuthMessage(new TextEncoder().encode("jwt")).encode(w, Lite.Version.DRAFT_07)); + const msg = await AuthMessage.decode(r, Lite.Version.DRAFT_07); expect(new TextDecoder().decode(msg.token)).toBe("jwt"); }); @@ -44,10 +44,10 @@ test("AUTH_OK and AUTH_ERROR round-trip behind their type", async () => { const ok = new AuthOk(patterns("**"), patterns(), 60_000); const err = new AuthError(SessionCode.Unauthorized, "expired"); const r = await roundTrip(async (w) => { - await ok.encode(w, Lite.Version.DRAFT_06); - await err.encode(w, Lite.Version.DRAFT_06); + await ok.encode(w, Lite.Version.DRAFT_07); + await err.encode(w, Lite.Version.DRAFT_07); }); - const first = await decodeAuthReplyMaybe(r, Lite.Version.DRAFT_06); + const first = await decodeAuthReplyMaybe(r, Lite.Version.DRAFT_07); expect(first).toBeInstanceOf(AuthOk); if (!(first instanceof AuthOk)) throw new Error("unreachable"); // `**` grants everything; the empty list grants nothing. @@ -55,15 +55,15 @@ test("AUTH_OK and AUTH_ERROR round-trip behind their type", async () => { expect(first.subscribe.size).toBe(0); expect(first.expires).toBe(60_000); - const second = await decodeAuthReplyMaybe(r, Lite.Version.DRAFT_06); + const second = await decodeAuthReplyMaybe(r, Lite.Version.DRAFT_07); expect(second).toEqual(err); - expect(await decodeAuthReplyMaybe(r, Lite.Version.DRAFT_06)).toBeUndefined(); + expect(await decodeAuthReplyMaybe(r, Lite.Version.DRAFT_07)).toBeUndefined(); }); test("literal and wildcard grants travel exactly, never widened", async () => { const ok = new AuthOk(patterns("room/alice", "room/*/cam", "**/demo.hang"), patterns("", "lobby/**")); - const r = await roundTrip((w) => ok.encode(w, Lite.Version.DRAFT_06)); - const got = await decodeAuthReplyMaybe(r, Lite.Version.DRAFT_06); + const r = await roundTrip((w) => ok.encode(w, Lite.Version.DRAFT_07)); + const got = await decodeAuthReplyMaybe(r, Lite.Version.DRAFT_07); if (!(got instanceof AuthOk)) throw new Error("expected AUTH_OK"); expect(got.publish.equals(ok.publish)).toBe(true); expect(got.subscribe.equals(ok.subscribe)).toBe(true); @@ -72,7 +72,7 @@ test("literal and wildcard grants travel exactly, never widened", async () => { // The same bytes as `auth_ok_golden` in `rs/moq-net/src/lite/auth.rs`. test("AUTH_OK matches the Rust encoding", async () => { const ok = new AuthOk(patterns("room/*/cam", "**/b.hang"), patterns(""), 1000); - const r = await roundTrip((w) => ok.encode(w, Lite.Version.DRAFT_06)); + const r = await roundTrip((w) => ok.encode(w, Lite.Version.DRAFT_07)); const text = (s: string) => [s.length, ...new TextEncoder().encode(s)]; expect([...(await r.readAll())]).toEqual([ 0x00, // AUTH_OK @@ -82,8 +82,8 @@ test("AUTH_OK matches the Rust encoding", async () => { ...text("room/*/cam"), 0x01, // subscribe count 0x00, // the empty pattern: the root alone - 0x43, - 0xe8, // expires: 1000ms + 0x83, + 0xe8, // expires: 1000ms, as a leading-ones varint ]); }); @@ -109,12 +109,13 @@ test("only valid, canonical patterns decode", async () => { await w.u53(0); await w.u53(0); }); - await expect(decodeAuthReplyMaybe(r, Lite.Version.DRAFT_06)).rejects.toThrow(); + await expect(decodeAuthReplyMaybe(r, Lite.Version.DRAFT_07)).rejects.toThrow(); } }); -test("lite-05 carries no AUTH", async () => { - await expect( - roundTrip((w) => new AuthMessage(new Uint8Array()).encode(w, Lite.Version.DRAFT_05)), - ).rejects.toThrow(); +test.each([ + ["lite-05", Lite.Version.DRAFT_05], + ["lite-06", Lite.Version.DRAFT_06], +])("%s carries no AUTH", async (_, version) => { + await expect(roundTrip((w) => new AuthMessage(new Uint8Array()).encode(w, version))).rejects.toThrow(); }); diff --git a/js/net/src/lite/auth.ts b/js/net/src/lite/auth.ts index c75cd1d690..47b7d99cf6 100644 --- a/js/net/src/lite/auth.ts +++ b/js/net/src/lite/auth.ts @@ -14,7 +14,7 @@ function guardAuth(version: Version) { /** Longest AUTH_ERROR reason, in bytes, matching the Rust decoder. */ const MAX_REASON = 8192; -/** The first message on an Auth Stream: the token the opener presents. Lite06+. */ +/** The first message on an Auth Stream: the token the opener presents. Lite07+. */ export class AuthMessage { token: Uint8Array; diff --git a/js/net/src/lite/version.ts b/js/net/src/lite/version.ts index e90d14169b..dcaa75bd6d 100644 --- a/js/net/src/lite/version.ts +++ b/js/net/src/lite/version.ts @@ -10,7 +10,7 @@ export const Version = { /// Also adds frame-precise subscribe/fetch bounds and a GROUP frame offset. DRAFT_06: 0xff0dad06, /// Work-in-progress lite-07, only negotiated when explicitly offered. - /// Adds the ANNOUNCE_REQUEST hidden opt-in. + /// Adds the ANNOUNCE_REQUEST hidden opt-in and the Auth Stream. DRAFT_07: 0xff0dad07, } as const; @@ -67,7 +67,7 @@ export function hasDatagrams(version: Version): boolean { /** * Whether either endpoint may open an Auth Stream (0x7) to present a token and learn its - * grant. Added to lite-06, whose peers that predate it reset the stream as an unknown type. + * grant. Added in lite-07. */ export function hasAuth(version: Version): boolean { // Explicitly list older versions so future versions default to carrying AUTH. @@ -77,6 +77,7 @@ export function hasAuth(version: Version): boolean { case Version.DRAFT_03: case Version.DRAFT_04: case Version.DRAFT_05: + case Version.DRAFT_06: return false; default: return true; diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index d81105229d..7489d137f1 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -95,7 +95,6 @@ published version in place, so AUTH and its stream code land in ## Required - [WebSocket refusal](/quest/m1/auth/ws-unauthorized.md) - the relay serves WebSocket through moq-tokio, so a refused token closes the session as Unauthorized, as QUIC does -- [AUTH on the wip version](/quest/m1/auth/wip-version.md) - lite AUTH and UNAUTHORIZED move from lite-06 to `moq-lite-07-wip`, so no published version changes in place - [Lite NOT_SUPPORTED](/quest/m1/auth/not-supported.md) - a lite acceptor answers AUTH_ERROR NOT_SUPPORTED after a grant too, with a lite session code for `Error::Unsupported` - [AUTH violations](/quest/m1/auth/violations.md) - every AUTH protocol violation closes the session in Rust and JS, lite and IETF - [Malformed grant](/quest/m1/auth/malformed-grant.md) - a malformed or diff --git a/quest/m1/auth/wip-version.md b/quest/m1/auth/wip-version.md deleted file mode 100644 index 24ec28ced1..0000000000 --- a/quest/m1/auth/wip-version.md +++ /dev/null @@ -1,37 +0,0 @@ -# [M] AUTH on the wip lite version - -## Goal - -The lite Auth Stream (0x7) and the 0x3A UNAUTHORIZED stream code exist only -on the wip lite version (`moq-lite-07-wip` today), in Rust, JS, the draft, -and the interop matrix. A lite-06 session never opens or accepts an Auth -Stream, so no published version changes in place. - -## Plan - -The line built AUTH on lite-06 before the maintainer decided on 2026-10-05 -that wire work targets the wip version until it is cut, never a published -version (see the README and [Finalize moq-lite-07](/quest/m1/lite07-finalize.md)). -The gate is one predicate on each side: `Version::has_auth` -(`rs/moq-net/src/lite/version.rs`) and `hasAuth` (`js/net/src/lite/version.ts`). - -- Make both false for lite-06; the URL credential already covers peers - without the stream, so a lite-06 session keeps working on its URL token. -- Move the Auth Stream and UNAUTHORIZED entries in - `drafts/draft-lcurley-moq-lite.md` from the lite-06 changelog to lite-07's, - and run `just drafts check`. -- Tests that negotiate the default version for AUTH must ask for - `moq-lite-07-wip` explicitly, since the default version sets leave it out: - `rs/moq-net/tests/auth.rs` (`LITE_06`), `js/net/src/auth.test.ts`, - `rs/moq-relay/tests/auth_lifetime.rs`, and the AUTH rounds of - `test/interop/interop.sh`. Add one test per language that a lite-06 session - has no grant and opens no Auth Stream. -- Check the docs (`doc/concept`, `doc/lib/rs/moq-net.md`, `doc/lib/js/net.md`) - name the wip version, not lite-06. - -Public API: none. Wire: AUTH and UNAUTHORIZED leave lite-06; the line has not -landed, so no released peer speaks them there. - -## Related - -- [Finalize moq-lite-07](/quest/m1/lite07-finalize.md) - the cut that publishes AUTH with lite-07 diff --git a/rs/moq-net/src/ietf/auth.rs b/rs/moq-net/src/ietf/auth.rs index eb4f0a4756..67cfa846d5 100644 --- a/rs/moq-net/src/ietf/auth.rs +++ b/rs/moq-net/src/ietf/auth.rs @@ -1,6 +1,6 @@ //! The MoQ Auth extension (draft-lcurley-moq-auth-00). //! -//! The moq-transport binding of the lite-06 Auth Stream (see [`crate::auth`]): each +//! The moq-transport binding of the lite Auth Stream (see [`crate::auth`]): each //! token rides a request stream of its own, answered with the namespace prefixes it //! grants. Negotiated with the AUTH Setup Option on draft-17+ only, where SETUP is a //! Key-Value-Pair block. diff --git a/rs/moq-net/src/lite/auth.rs b/rs/moq-net/src/lite/auth.rs index 3271c4f8c9..6cbf69676b 100644 --- a/rs/moq-net/src/lite/auth.rs +++ b/rs/moq-net/src/lite/auth.rs @@ -7,7 +7,7 @@ use crate::{Pattern, Patterns}; use super::{Message, Version}; -/// The first message on an Auth Stream: the token the opener presents. Lite06+. +/// The first message on an Auth Stream: the token the opener presents. Lite07+. /// /// An empty token means the credential the connection already presented (the /// URL, a client certificate), or nothing. @@ -200,9 +200,9 @@ mod tests { fn round_trip + Decode>(msg: &T) -> T { let mut buf = bytes::BytesMut::new(); - msg.encode(&mut buf, Version::Lite06).unwrap(); + msg.encode(&mut buf, Version::Lite07).unwrap(); let mut slice = &buf[..]; - let got = T::decode(&mut slice, Version::Lite06).unwrap(); + let got = T::decode(&mut slice, Version::Lite07).unwrap(); assert!(slice.is_empty(), "trailing bytes after decode"); got } @@ -246,7 +246,7 @@ mod tests { expires: Some(Duration::from_millis(1000)), }); let mut buf = bytes::BytesMut::new(); - msg.encode(&mut buf, Version::Lite06).unwrap(); + msg.encode(&mut buf, Version::Lite07).unwrap(); #[rustfmt::skip] let want: &[u8] = &[ 0x00, // AUTH_OK @@ -256,7 +256,7 @@ mod tests { 0x0a, b'r', b'o', b'o', b'm', b'/', b'*', b'/', b'c', b'a', b'm', 0x01, // subscribe count 0x00, // the empty pattern: the root alone - 0x43, 0xe8, // expires: 1000ms + 0x83, 0xe8, // expires: 1000ms, as a leading-ones varint ]; assert_eq!(&buf[..], want); } @@ -266,17 +266,17 @@ mod tests { fn invalid_patterns_are_refused() { for text in ["*/**", "/room", "room/", "room//a", "a*b*c", "**/**", "a**"] { let mut buf = bytes::BytesMut::new(); - AUTH_OK.encode(&mut buf, Version::Lite06).unwrap(); + AUTH_OK.encode(&mut buf, Version::Lite07).unwrap(); let mut body = bytes::BytesMut::new(); - 1usize.encode(&mut body, Version::Lite06).unwrap(); - text.encode(&mut body, Version::Lite06).unwrap(); - 0usize.encode(&mut body, Version::Lite06).unwrap(); - 0u64.encode(&mut body, Version::Lite06).unwrap(); - body.len().encode(&mut buf, Version::Lite06).unwrap(); + 1usize.encode(&mut body, Version::Lite07).unwrap(); + text.encode(&mut body, Version::Lite07).unwrap(); + 0usize.encode(&mut body, Version::Lite07).unwrap(); + 0u64.encode(&mut body, Version::Lite07).unwrap(); + body.len().encode(&mut buf, Version::Lite07).unwrap(); buf.extend_from_slice(&body); assert!( matches!( - AuthReply::decode(&mut &buf[..], Version::Lite06), + AuthReply::decode(&mut &buf[..], Version::Lite07), Err(DecodeError::InvalidValue) ), "{text} decoded" @@ -315,7 +315,7 @@ mod tests { token: Bytes::from(vec![0; super::super::message::MAX_MESSAGE_SIZE + 1]), }; assert!(matches!( - msg.encode(&mut Sizer::default(), Version::Lite06), + msg.encode(&mut Sizer::default(), Version::Lite07), Err(EncodeError::TooLarge) )); } @@ -328,6 +328,7 @@ mod tests { Version::Lite03, Version::Lite04, Version::Lite05, + Version::Lite06, ] { let mut buf = bytes::BytesMut::new(); assert!(matches!( diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 48b3103e62..cc29370b5f 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -410,7 +410,7 @@ struct AuthServe { impl AuthServe { fn new(shared: Arc>, stream: Stream) -> Result { - // The Auth Stream is lite-06+ only. + // The Auth Stream is lite-07+ only. if !shared.version.has_auth() { return Err(Error::UnexpectedStream); } diff --git a/rs/moq-net/src/lite/session.rs b/rs/moq-net/src/lite/session.rs index edaaf1b7b1..ab1bad1ca3 100644 --- a/rs/moq-net/src/lite/session.rs +++ b/rs/moq-net/src/lite/session.rs @@ -872,7 +872,7 @@ mod tests { reason: String::new(), }); let mut script = Vec::new(); - refused.encode(&mut script, Version::Lite06).unwrap(); + refused.encode(&mut script, Version::Lite07).unwrap(); // The setup token's AUTH stream is the first one the session opens. let transport = ScriptedSession::per_stream(vec![script]); let log = transport.log.clone(); @@ -885,7 +885,7 @@ mod tests { publish: None, subscribe: None, peer_hop: None, - version: Version::Lite06, + version: Version::Lite07, our_setup: Setup::default(), peer_setup: None, auth: crate::auth::Handle::new(true), diff --git a/rs/moq-net/src/lite/version.rs b/rs/moq-net/src/lite/version.rs index ecf1568a88..89b93bec6f 100644 --- a/rs/moq-net/src/lite/version.rs +++ b/rs/moq-net/src/lite/version.rs @@ -21,9 +21,9 @@ pub enum Version { /// a `.`-prefixed segment below the requested prefix is left out. SUBSCRIBE_END /// carries the number of group streams opened, replacing SUBSCRIBE_DROP. /// ANNOUNCE_START and ANNOUNCE_UPDATE may copy a path head or hop-chain tail from a - /// live announcement on the same stream. The wire format is still work-in-progress, - /// so it is advertised over ALPN as `moq-lite-07-wip` and only when explicitly - /// requested; the default version sets leave it out. + /// live announcement on the same stream. Adds the Auth Stream. The wire format is + /// still work-in-progress, so it is advertised over ALPN as `moq-lite-07-wip` and + /// only when explicitly requested; the default version sets leave it out. Lite07, } @@ -125,13 +125,12 @@ impl Version { } /// Whether either endpoint may open an Auth Stream (0x7) to present a token and - /// learn its grant. Added to lite-06, whose peers that predate it reset the - /// stream as an unknown type. + /// learn its grant. Added in lite-07. #[allow(clippy::match_like_matches_macro)] pub fn has_auth(self) -> bool { - // Match form so future versions default forward (CLAUDE.md convention). + // Match form so future versions default forward (AGENTS.md convention). match self { - Self::Lite01 | Self::Lite02 | Self::Lite03 | Self::Lite04 | Self::Lite05 => false, + Self::Lite01 | Self::Lite02 | Self::Lite03 | Self::Lite04 | Self::Lite05 | Self::Lite06 => false, _ => true, } } diff --git a/rs/moq-net/src/session.rs b/rs/moq-net/src/session.rs index 9b76acd54d..2bbac6eee1 100644 --- a/rs/moq-net/src/session.rs +++ b/rs/moq-net/src/session.rs @@ -250,7 +250,7 @@ impl Session { /// The tokens this side presented and the grant they earned, plus the tokens /// the peer presents. See [`auth`]. /// - /// On moq-lite-06, and on moq-transport draft-17+ when both sides negotiate the + /// On moq-lite-07-wip, and on moq-transport draft-17+ when both sides negotiate the /// MoQ Auth extension, each side presents its connection's credential right after /// setup. Older versions, and peers that do not negotiate it, leave the grant `None`. pub fn auth(&self) -> auth::Handle { diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs index 5680da127d..154838ce05 100644 --- a/rs/moq-net/tests/auth.rs +++ b/rs/moq-net/tests/auth.rs @@ -1,6 +1,6 @@ //! In-band AUTH over the in-memory mock transport: both sides learn their grant, //! tokens union, and a publication outside the grant fails loud. Every case runs on -//! moq-lite-06 and on moq-transport with the MoQ Auth extension. +//! moq-lite-07-wip and on moq-transport with the MoQ Auth extension. mod support; @@ -17,7 +17,12 @@ use support::mock::{MockSession, create_mock_session_pair}; /// Maximum time any single test may run before being treated as a deadlock. const TEST_TIMEOUT: Duration = Duration::from_secs(10); +/// The first lite version with the Auth Stream, still work-in-progress. +const LITE_07: &str = "moq-lite-07-wip"; +/// The newest published lite version, which has no Auth Stream. const LITE_06: &str = "moq-lite-06"; +/// The lite Auth Stream's type byte. +const AUTH_STREAM: u8 = 0x7; /// The first draft that negotiates MoQ Auth, and the newest. const MOQT_17: &str = "moq-transport-17"; const MOQT_22: &str = "moq-transport-22"; @@ -25,8 +30,8 @@ const MOQT_22: &str = "moq-transport-22"; /// Run each case on every version that exchanges AUTH. macro_rules! cases { ($($case:ident),* $(,)?) => { - mod lite_06 { - $(#[tokio::test] async fn $case() { super::$case(super::LITE_06).await })* + mod lite_07 { + $(#[tokio::test] async fn $case() { super::$case(super::LITE_07).await })* } mod moqt_17 { $(#[tokio::test] async fn $case() { super::$case(super::MOQT_17).await })* @@ -76,13 +81,37 @@ prefix_cases!( ); #[tokio::test] -async fn lite_06_carries_pattern_grants() { - pattern_grants_arrive_exactly(LITE_06).await +async fn lite_07_carries_pattern_grants() { + pattern_grants_arrive_exactly(LITE_07).await +} + +#[tokio::test] +async fn lite_07_enforces_a_wildcard_grant() { + a_wildcard_grant_is_enforced(LITE_07).await } #[tokio::test] -async fn lite_06_enforces_a_wildcard_grant() { - a_wildcard_grant_is_enforced(LITE_06).await +async fn lite_06_has_no_grant() { + older_versions_have_no_grant(LITE_06).await +} + +/// The control for the lite versions without AUTH: on the same barrier, lite-07 has +/// opened an Auth Stream on each side, which is what makes its absence on them mean +/// something. +#[tokio::test] +async fn lite_07_opens_an_auth_stream_per_side() { + within(async { + let (pair, _broadcast) = connect_announced(LITE_07).await; + for transport in [&pair.client_transport, &pair.server_transport] { + assert!( + transport.bidi_types().contains(&AUTH_STREAM), + "{:x?}", + transport.bidi_types() + ); + } + }) + .await + .expect("timed out"); } #[tokio::test] @@ -181,7 +210,7 @@ struct Pair { } async fn connect(opts: Options) -> Pair { - let version: Version = opts.version.unwrap_or(LITE_06).parse().unwrap(); + let version: Version = opts.version.unwrap_or(LITE_07).parse().unwrap(); let (client_transport, server_transport) = create_mock_session_pair(Some(version.alpn())); let mut client = Client::new().with_versions(version.into()); @@ -660,20 +689,43 @@ async fn a_reset_auth_stream_reports_unsupported(version: &'static str) { /// Versions without AUTH never open the stream: no grant, and no way to add a token. async fn older_versions_have_no_grant(version: &'static str) { within(async { - let pair = connect(Options { - version: Some(version), - client_publish: Some(produce_origin(2)), - server_subscribe: Some(produce_origin(1)), - ..Default::default() - }) - .await; + let (pair, _broadcast) = connect_announced(version).await; assert_eq!(pair.client.auth().grant().peek(), None); assert!(matches!(pair.client.auth().add("token").await, Err(Error::Unsupported))); + // moq-transport's request streams have no stream type to look for. + if version.starts_with("moq-lite") { + for transport in [&pair.client_transport, &pair.server_transport] { + assert!( + !transport.bidi_types().contains(&AUTH_STREAM), + "{:x?}", + transport.bidi_types() + ); + } + } }) .await .expect("timed out"); } +/// Connect on `version` and wait for a client announcement to reach the server. Each +/// driver presents its setup token before it handles any other stream, so by then a +/// version with AUTH has opened its Auth Stream on both sides. +async fn connect_announced(version: &'static str) -> (Pair, moq_net::broadcast::Producer) { + let client_origin = produce_origin(2); + let broadcast = client_origin.create_broadcast("room/x").unwrap(); + broadcast.announce(Default::default()).unwrap(); + let server_origin = produce_origin(1); + let pair = connect(Options { + version: Some(version), + client_publish: Some(client_origin), + server_subscribe: Some(server_origin.clone()), + ..Default::default() + }) + .await; + wait_announced(&server_origin.consume(), "room/x", true).await; + (pair, broadcast) +} + /// Losing a grant cancels the subscriptions it covered, in both directions, and /// leaves the session up. async fn a_revoked_grant_cancels_its_subscriptions(version: &'static str) { @@ -737,7 +789,7 @@ async fn a_revoked_grant_cancels_its_subscriptions(version: &'static str) { }; // The relay's own reader learns the peer's code across the origin's splice, not a // generic drop. moq-transport reports it in PUBLISH_DONE instead. - if version == LITE_06 { + if version == LITE_07 { assert!(matches!(err, Error::Stream(StreamError::Unauthorized)), "{err:?}"); } assert_eq!(pair.client_transport.close_reason(), None); @@ -747,7 +799,7 @@ async fn a_revoked_grant_cancels_its_subscriptions(version: &'static str) { let resets = pair.client_transport.resets(); let closed = StreamError::Session(SessionError::Unauthorized).to_code(); assert!(!resets.contains(&closed), "{resets:x?}"); - if version == LITE_06 { + if version == LITE_07 { let unauthorized = StreamError::Unauthorized.to_code(); let count = resets.iter().filter(|&&code| code == unauthorized).count(); assert_eq!(count, 2, "both subscriptions reset with UNAUTHORIZED: {resets:x?}"); @@ -1020,6 +1072,9 @@ macro_rules! limit_cases { mod limit_lite_06 { $(#[tokio::test] async fn $case() { super::$case(super::LITE_06).await })* } + mod limit_lite_07 { + $(#[tokio::test] async fn $case() { super::$case(super::LITE_07).await })* + } mod limit_moqt_16 { $(#[tokio::test] async fn $case() { super::$case("moq-transport-16").await })* } @@ -1046,9 +1101,14 @@ async fn lite_06_narrowing_resets_a_fetch_in_flight() { a_narrowing_resets_a_fetch_in_flight(LITE_06).await } +#[tokio::test] +async fn lite_07_narrowing_resets_a_fetch_in_flight() { + a_narrowing_resets_a_fetch_in_flight(LITE_07).await +} + /// Whether `version` exchanges AUTH, so the peer also hears of a narrowing. fn speaks_auth(version: &str) -> bool { - matches!(version, LITE_06 | MOQT_17 | MOQT_22) + matches!(version, LITE_07 | MOQT_17 | MOQT_22) } /// A revocation as the reader sees it: the local gate's own error, or the peer's @@ -1118,7 +1178,8 @@ async fn a_narrowing_deafens_one_path(version: &'static str) { assert_eq!(group.sequence, 1); // The relay enforced it, not the client: moq-lite resets the subscription with - // UNAUTHORIZED, and neither side closed the session. + // UNAUTHORIZED, even on a version without the Auth Stream, and neither side + // closed the session. if version.starts_with("moq-lite") { let resets = pair.server_transport.resets(); assert!(resets.contains(&StreamError::Unauthorized.to_code()), "{resets:x?}"); diff --git a/rs/moq-net/tests/support/mock.rs b/rs/moq-net/tests/support/mock.rs index 3313582750..3032719871 100644 --- a/rs/moq-net/tests/support/mock.rs +++ b/rs/moq-net/tests/support/mock.rs @@ -132,6 +132,9 @@ pub struct MockSendStream { /// Drop this stream's FIN, as a peer that never completes it (see /// [`MockSession::withhold_bidi_fins`]). withhold_fin: Arc, + /// Where to log the first byte written, the stream type (see + /// [`MockSession::bidi_types`]), until it is written. + first_byte: Option, conn: Arc, } @@ -153,6 +156,11 @@ impl poll::SendStream for MockSendStream { type Error = MockError; fn poll_write(&mut self, _cx: &mut Context<'_>, buf: &[u8]) -> Poll> { + if let Some(&first) = buf.first() + && let Some(types) = self.first_byte.take() + { + types.lock().unwrap().push(first); + } Poll::Ready( self.push(StreamChunk::Data(Bytes::copy_from_slice(buf))) .map(|()| buf.len()), @@ -339,6 +347,9 @@ impl Drop for MockRecvStream { /// The stream reset codes one side has sent. type Resets = Arc>>; +/// The first byte of each bidi stream one side has opened. +type StreamTypes = Arc>>; + /// Create a linked (send, recv) stream pair, logging the sender's resets to `resets`. fn new_stream_pair(conn: &Arc, resets: &Resets) -> (MockSendStream, MockRecvStream) { let queue = kio::Queue::new(); @@ -351,6 +362,7 @@ fn new_stream_pair(conn: &Arc, resets: &Resets) -> (MockSendStr park: kio::Park::default(), ack_fin: false, withhold_fin: Arc::default(), + first_byte: None, conn: conn.clone(), }; let recv = MockRecvStream { @@ -428,6 +440,8 @@ struct SessionSide { lossy: Mutex, /// Whether this side drops the FIN of the bidi streams it opens. withhold_bidi_fins: Arc, + /// The first byte of each bidi stream this side opened. + bidi_types: StreamTypes, } /// An in-memory mock WebTransport session. @@ -484,6 +498,7 @@ impl poll::Session for MockSession { // Create two stream pairs: one for each direction. let (mut our_send, peer_recv) = new_stream_pair(&self.side.conn, &self.side.resets); our_send.withhold_fin = self.side.withhold_bidi_fins.clone(); + our_send.first_byte = Some(self.side.bidi_types.clone()); let (peer_send, our_recv) = new_stream_pair(&self.side.conn, &self.side.peer_resets); // Deliver (peer_send, peer_recv) to the peer's accept_bi. @@ -654,6 +669,14 @@ impl MockSession { self.side.conn.close_state.read().clone() } + /// The first byte, the stream type, of every bidi stream this side has written to, + /// in the order each was first written. + // Only some test binaries inspect the types. + #[allow(dead_code)] + pub fn bidi_types(&self) -> Vec { + self.side.bidi_types.lock().unwrap().clone() + } + /// Every code this side has reset a stream with, in order. // Only some test binaries inspect the codes. #[allow(dead_code)] @@ -702,6 +725,7 @@ pub fn create_mock_session_pair(protocol: Option<&'static str>) -> (MockSession, withheld: Mutex::default(), lossy: Mutex::default(), withhold_bidi_fins: Arc::default(), + bidi_types: StreamTypes::default(), }); let server_side = Arc::new(SessionSide { @@ -719,6 +743,7 @@ pub fn create_mock_session_pair(protocol: Option<&'static str>) -> (MockSession, withheld: Mutex::default(), lossy: Mutex::default(), withhold_bidi_fins: Arc::default(), + bidi_types: StreamTypes::default(), }); let new = |side| MockSession { diff --git a/test/interop/README.md b/test/interop/README.md index fea6d42ff5..a650e33df6 100644 --- a/test/interop/README.md +++ b/test/interop/README.md @@ -72,13 +72,18 @@ their token implies: the Rust CLI (a `moq_net::auth` debug log) and the native JS subscribers. A cell whose grant is missing or wrong fails even when media flowed. The binding clients (Python, Go, C, GStreamer) have no grant to print until moq-ffi exposes one, and the browser's shared connection keeps its session -private, so their cells check media alone. Every client here negotiates -moq-lite-06, so a printing client that reports nothing never got its grant. - -After the matrix, each publisher whose refusal the harness can read (Rust, the -browser) runs once more with a token that excludes its broadcast. It must fail -loud, logging Unauthorized and naming the path, and every subscriber must time -out. +private, so their cells check media alone. AUTH is only on the work-in-progress +moq-lite-07, so the printing clients dial `moq-lite-07-wip` alone and a printing +client that reports nothing never got its grant. The native JS subscribers dial +WebTransport alone, since the WebSocket fallback cannot offer it. The rest keep +their defaults, so the matrix also crosses versions through the relay, which +accepts both. + +After the matrix, each publisher whose refusal the harness can read (Rust) runs +once more with a token that excludes its broadcast. It must fail loud, logging +Unauthorized and naming the path, and every subscriber must time out. The +browser publisher enforces its grant too, but its elements cannot offer +`moq-lite-07-wip` yet. Tokens use patterns no prefix could carry, so every cell checks that AUTH\_OK delivers them as minted: a publisher is granted its exact broadcast, a subscriber diff --git a/test/interop/clients/js-native/subscribe.ts b/test/interop/clients/js-native/subscribe.ts index 3a23bacc6b..0e78ca40d5 100644 --- a/test/interop/clients/js-native/subscribe.ts +++ b/test/interop/clients/js-native/subscribe.ts @@ -50,7 +50,14 @@ if (role !== "subscribe" || !url || !broadcast || !Number.isFinite(timeoutMs) || async function run(): Promise { const origin = new Moq.Origin.Producer(); - const connection = await Moq.Connection.connect({ url: new URL(url as string), consume: origin }); + // The grant arrives over AUTH, which only the work-in-progress moq-lite-07 carries and + // no client offers by default. The WebSocket fallback cannot offer it, so it stays off. + const connection = await Moq.Connection.connect({ + url: new URL(url as string), + consume: origin, + webtransport: { protocols: ["moq-lite-07-wip"] }, + websocket: { enabled: false }, + }); // The grant the relay sent, in the Rust client's `auth granted` shape, so the harness can // check it against the token this cell minted. const printGrant = (grant: Moq.Auth.Grant | undefined) => { diff --git a/test/interop/interop.sh b/test/interop/interop.sh index d5e1af3773..8d8f0690f6 100755 --- a/test/interop/interop.sh +++ b/test/interop/interop.sh @@ -17,7 +17,9 @@ # `moq auth serve`. Clients that print the grant the relay sent back over AUTH # must report exactly what their token implies, and a final round per enforcing # publisher mints a token that excludes its broadcast: the publisher must fail -# loud with Unauthorized and no subscriber may see data. +# loud with Unauthorized and no subscriber may see data. AUTH is only on the +# work-in-progress moq-lite-07, so those clients dial it alone while the rest keep +# their defaults, and the matrix also crosses versions through the relay. set -euo pipefail INTEROP_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) @@ -460,6 +462,10 @@ token_url() { # moq-cli logs each grant it receives over AUTH at debug, under `moq_net::auth`. CLI_LOG="${RUST_LOG:-info},moq_net::auth=debug" +# The lite version with AUTH, which no client offers by default. The clients the +# harness reads a grant or a refusal from dial it. +AUTH_VERSION="moq-lite-07-wip" + # The clients that print the grant they received as an `auth granted` line. The # binding clients (python, go, c, gst) have no grant to print until moq-ffi # exposes one, and the browser's shared connection keeps its session private. @@ -471,7 +477,7 @@ prints_grant() { } # The `auth granted` line a token minted with at most one publish and one -# subscribe pattern implies. Every client on this relay negotiates moq-lite-06, +# subscribe pattern implies. Every client that prints one dials $AUTH_VERSION, # which carries AUTH, so a client that prints nothing never got its grant. grant_line() { local publish="${1:+\"$1\"}" subscribe="${2:+\"$2\"}" @@ -518,7 +524,8 @@ run_publisher() { local lang="$1" broadcast="$2" url="$3" case "$lang" in rust) - ffmpeg_h264 | RUST_LOG="$CLI_LOG" "$MOQ" --connect "$url" --broadcast "$broadcast" import avc3 + ffmpeg_h264 | RUST_LOG="$CLI_LOG" "$MOQ" --connect "$url" --connect-version "$AUTH_VERSION" \ + --broadcast "$broadcast" import avc3 ;; python) ffmpeg_h264 | "$PY" "$CLIENTS/python/interop.py" \ @@ -570,8 +577,8 @@ run_subscriber() { # moq-cli only handles SIGINT, so -k forces SIGKILL if it ignores the # SIGTERM that fires when no data arrives within the timeout. local n - n=$(RUST_LOG="$CLI_LOG" timeout -k 3 "$TIMEOUT" "$MOQ" --connect "$url" --broadcast "$broadcast" \ - export fmp4 | head -c 1 | wc -c | tr -d ' ' || true) + n=$(RUST_LOG="$CLI_LOG" timeout -k 3 "$TIMEOUT" "$MOQ" --connect "$url" --connect-version "$AUTH_VERSION" \ + --broadcast "$broadcast" export fmp4 | head -c 1 | wc -c | tr -d ' ' || true) [[ "${n:-0}" -ge 1 ]] ;; python) @@ -731,12 +738,12 @@ run_media() { fi } -# The publishers whose refusal the harness can read: the Rust CLI's log and the -# browser page's console. The binding publishers enforce the grant too, but -# surface it only through their bindings. +# The publishers whose refusal the harness can read: the Rust CLI's log. The +# binding publishers enforce the grant too, but surface it only through their +# bindings, and the browser elements have no way to offer $AUTH_VERSION. enforces_grant() { case "$1" in - rust | js) return 0 ;; + rust) return 0 ;; *) return 1 ;; esac } diff --git a/test/interop/interop.toml b/test/interop/interop.toml index 59ac7ab87d..b3802beb34 100644 --- a/test/interop/interop.toml +++ b/test/interop/interop.toml @@ -8,6 +8,26 @@ level = "info" # QUIC on UDP. 127.0.0.1 avoids IPv6 flakiness on CI runners. bind = "127.0.0.1:4443" tls.generate = ["localhost", "127.0.0.1"] +# Every default version, plus the work-in-progress moq-lite-07 that carries AUTH, +# which the defaults leave out. Keep in sync with `ALL` in rs/moq-net/src/version.rs. +version = [ + "moq-lite-07-wip", + "moq-lite-06", + "moq-lite-05", + "moq-lite-04", + "moq-lite-03", + "moq-lite-02", + "moq-lite-01", + "moq-transport-22", + "moq-transport-21", + "moq-transport-20", + "moq-transport-19", + "moq-transport-18", + "moq-transport-17", + "moq-transport-16", + "moq-transport-15", + "moq-transport-14", +] [web.http] # HTTP + WebSocket on TCP, also serving /certificate.sha256 for cert pinning. From a5e5acb1d15abadcc967c88e950a53ebc4cd09ca Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 8 Oct 2026 23:55:16 -0700 Subject: [PATCH 39/53] refactor(net): group a session's handles so Session::new stays under clippy's arity Also applies just fix formatting to the merged auth tests. Co-Authored-By: Claude Opus 5.5 --- js/net/src/lite/subscriber.test.ts | 10 +++++++++- rs/moq-net/src/client.rs | 19 ++++++++++++------- rs/moq-net/src/lite/publisher.rs | 4 +++- rs/moq-net/src/server.rs | 19 ++++++++++++------- rs/moq-net/src/session.rs | 12 +++++++++--- rs/moq-net/tests/auth.rs | 2 +- 6 files changed, 46 insertions(+), 20 deletions(-) diff --git a/js/net/src/lite/subscriber.test.ts b/js/net/src/lite/subscriber.test.ts index 5afb544012..0d9bfeaa61 100644 --- a/js/net/src/lite/subscriber.test.ts +++ b/js/net/src/lite/subscriber.test.ts @@ -1000,7 +1000,15 @@ test("a grant that shrinks while the subscription sets up refuses it", async () subscribe: new Path.Patterns([Path.Pattern.subtree(prefix)]), }); const grant = new Signal(scoped("room")); - const subscriber = new Subscriber(quic, Version.DRAFT_05, HopSchema.parse(1n), undefined, undefined, undefined, grant); + const subscriber = new Subscriber( + quic, + Version.DRAFT_05, + HopSchema.parse(1n), + undefined, + undefined, + undefined, + grant, + ); const track = subscriber.consume(Path.from("room/cam")).track("video").subscribe().ordered(); const next = track.nextGroup().catch((err: unknown) => err); diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index 7a69541578..d13d97f3c4 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -219,9 +219,11 @@ impl Client { version.into(), start.recv_bandwidth, crate::driver::Protocol::Lite(Box::new(start.driver)), - start.goaway, - start.auth, - start.setup, + crate::session::Handles { + goaway: start.goaway, + auth: start.auth, + setup: start.setup, + }, )) } @@ -308,9 +310,7 @@ impl Client { v, None, crate::driver::Protocol::Ietf(protocol), - goaway, - auth, - setup, + crate::session::Handles { goaway, auth, setup }, )); } Some(ALPN_16) => { @@ -472,7 +472,12 @@ impl Client { }; Ok(Session::new( - runtime, session, version, recv_bw, protocol, goaway, auth, setup, + runtime, + session, + version, + recv_bw, + protocol, + crate::session::Handles { goaway, auth, setup }, )) } } diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 8294bb0442..dfcc4a753d 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -1289,7 +1289,9 @@ impl AnnounceRun { } match self.outgoing(&update.route, &absolute) { - Some((hops, cost)) => self.advertise(stream, suffix, hops, cost, update.route.epoch.clone(), &absolute)?, + Some((hops, cost)) => { + self.advertise(stream, suffix, hops, cost, update.route.epoch.clone(), &absolute)? + } // The chain must not be forwarded (reflected, or full): retract // whatever the peer holds. None => self.retract(stream, suffix, &absolute)?, diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index 6420852225..95d2651ab2 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -138,9 +138,11 @@ impl Server { version.into(), start.recv_bandwidth, crate::driver::Protocol::Lite(Box::new(start.driver)), - start.goaway, - start.auth, - start.setup, + crate::session::Handles { + goaway: start.goaway, + auth: start.auth, + setup: start.setup, + }, )) } @@ -585,9 +587,7 @@ where version.into(), None, crate::driver::Protocol::Ietf(protocol), - goaway, - auth, - setup, + crate::session::Handles { goaway, auth, setup }, )) } .maybe_boxed() @@ -711,7 +711,12 @@ where }; Ok(Session::new( - runtime, session, version, recv_bw, protocol, goaway, auth, setup, + runtime, + session, + version, + recv_bw, + protocol, + crate::session::Handles { goaway, auth, setup }, )) } .maybe_boxed() diff --git a/rs/moq-net/src/session.rs b/rs/moq-net/src/session.rs index 75f86d6463..4303a0baf5 100644 --- a/rs/moq-net/src/session.rs +++ b/rs/moq-net/src/session.rs @@ -313,6 +313,13 @@ impl Session { } } +/// The handles a protocol driver shares with its [`Session`]. +pub(super) struct Handles { + pub goaway: goaway::Handle, + pub auth: auth::Handle, + pub setup: Setup, +} + impl Session { pub(super) fn new( runtime: crate::time::Clock, @@ -320,13 +327,12 @@ impl Session { version: Version, recv_bandwidth: Option, protocol: crate::driver::Protocol, - goaway: goaway::Handle, - auth: auth::Handle, - setup: Setup, + handles: Handles, ) -> (Self, crate::Driver) where S: crate::transport::poll::Session, { + let Handles { goaway, auth, setup } = handles; let sample = snapshot(&session); // Send bandwidth is version-agnostic: it depends on QUIC backend support. diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs index 42efec9f9a..6e5d02631a 100644 --- a/rs/moq-net/tests/auth.rs +++ b/rs/moq-net/tests/auth.rs @@ -6,12 +6,12 @@ mod support; use std::time::Duration; +use futures::StreamExt; use moq_net::{ Client, Error, Hop, Pattern, Patterns, Server, Session, SessionError, StreamError, Version, auth::{self, Grant}, origin, }; -use futures::StreamExt; use support::harness::{now, spawn}; use support::mock::{MockSession, create_mock_session_pair}; From f3f0ef35e68b0bb91d9f12a89fa3f837e5811524 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 9 Oct 2026 00:10:46 -0700 Subject: [PATCH 40/53] fix(net): gate standalone FETCH, lite announces, and TRACK_INFO on the grant A standalone moq-transport FETCH resolved its namespace without consulting the grant, so a peer could fetch outside what it may subscribe to; it is now refused up front and while its group loads. The JS lite publisher announced before the setup token was answered, so out-of-grant paths could reach the wire; it now waits like the Rust and IETF publishers. A JS TRACK is checked again once its broadcast and track resolve. Co-Authored-By: Claude Opus 5.5 --- js/net/src/lite/connection.ts | 5 +- js/net/src/lite/publisher.test.ts | 97 ++++++++++++++++++++++++++++++- js/net/src/lite/publisher.ts | 17 +++++- rs/moq-net/src/ietf/publisher.rs | 84 ++++++++++++++++++++++++++ 4 files changed, 197 insertions(+), 6 deletions(-) diff --git a/js/net/src/lite/connection.ts b/js/net/src/lite/connection.ts index d3ffdc88bc..51b96cec6e 100644 --- a/js/net/src/lite/connection.ts +++ b/js/net/src/lite/connection.ts @@ -159,7 +159,10 @@ export class Connection implements Established { subscribe: new Path.Patterns(publish ? [Path.Pattern.all()] : []), }, }); - this.#publisher = new Publisher(this.#quic, this.#version, this.hop, publish, this.#auth.grant); + this.#publisher = new Publisher(this.#quic, this.#version, this.hop, publish, { + grant: this.#auth.grant, + ready: this.#auth.setupAnswered(), + }); this.#subscriber = new Subscriber( this.#quic, this.#version, diff --git a/js/net/src/lite/publisher.test.ts b/js/net/src/lite/publisher.test.ts index 1605a2c920..de698ca180 100644 --- a/js/net/src/lite/publisher.test.ts +++ b/js/net/src/lite/publisher.test.ts @@ -18,6 +18,7 @@ import { sendOrder } from "./priority.ts"; import { Probe as ProbeMessage } from "./probe.ts"; import { Publisher } from "./publisher.ts"; import { decodeSubscribeResponse, Subscribe, type SubscribeEnd, SubscribeUpdate } from "./subscribe.ts"; +import { Track as TrackMessage } from "./track.ts"; import { ALPN_05, ALPN_06, ALPN_07_WIP, Version } from "./version.ts"; function publish(origin: OriginProducer, path: Path.Valid) { @@ -1658,6 +1659,92 @@ function unauthorizedResets(resets: { mock: { calls: unknown[][] } }): string[] .map((reason) => reason.message); } +// Nothing is announced before the grant it would be checked against: an ANNOUNCE_REQUEST that +// lands before the setup token is answered waits, then leaves out what the grant excludes. +test("lite draft-06: announces wait for the setup grant", async () => { + const pair = createMockTransportPair(ALPN_06); + const origin = new OriginProducer(); + const grant = new Signal(undefined); + let answered = () => {}; + const ready = new Promise((resolve) => { + answered = resolve; + }); + const publisher = new Publisher(pair.server, Version.DRAFT_06, randomHop(), origin.consume(), { grant, ready }); + const broadcast = origin.createBroadcast(Path.from("foo/bar")); + broadcast.announce(); + + const written: Uint8Array[] = []; + const stream = new Stream({ + version: Version.DRAFT_06, + readable: new ReadableStream(), + writable: new WritableStream({ + write(chunk) { + written.push(chunk); + }, + }), + }); + const settle = () => new Promise((resolve) => setTimeout(resolve, 10)); + const running = publisher.runAnnounce(new AnnounceRequest(Path.empty()), stream); + await settle(); + expect(written.length).toBe(0); + + grant.set(grantOf("baz")); + answered(); + await settle(); + // ANNOUNCE_OK alone: the grant excludes foo/bar, so it never reaches the wire. + const bytes = written.flatMap((chunk) => [...chunk]); + expect(new TextDecoder().decode(new Uint8Array(bytes))).not.toContain("foo/bar"); + + stream.close(); + await running; + publisher.close(); + broadcast.close(); + origin.close(); + pair.client.close(); + pair.server.close(); +}); + +// A TRACK is checked again once its broadcast and track resolve, so a shrink in between +// refuses it rather than sending TRACK_INFO the grant no longer covers. +test("lite draft-06: a grant that shrinks while a TRACK resolves refuses it", async () => { + const pair = createMockTransportPair(ALPN_06); + const origin = new OriginProducer(); + const handle = origin.dynamic(Path.from("live")); + const requests = handle.requested(); + const grant = new Signal(grantOf("live")); + const publisher = new Publisher(pair.server, Version.DRAFT_06, randomHop(), origin.consume(), { + grant, + ready: Promise.resolve(), + }); + + const client = await Stream.open(pair.client, { version: Version.DRAFT_06 }); + const server = await Stream.accept(pair.server, Version.DRAFT_06); + if (!server) throw new Error("publisher never accepted the track stream"); + + const resets = spyOn(Writer.prototype, "reset"); + const produced = new BroadcastProducer(); + produced.createTrack("video", { timescale: Timescale.MILLI }); + try { + const serving = publisher.runTrackInfo(new TrackMessage(Path.from("live/cam"), "video"), server); + + // The TRACK is parked resolving the broadcast when the grant shrinks. + const request = await requests.next(); + if (request.done) throw new Error("the handler never saw the request"); + grant.set(grantOf("other")); + request.value.accept(produced); + await serving; + + expect(unauthorizedResets(resets)).toContain("unauthorized: live/cam"); + } finally { + resets.mockRestore(); + produced.close(); + handle.close(); + publisher.close(); + client.close(); + origin.close(); + } +}); + // The grant watch is armed before the first check, so a shrink that lands while the broadcast // is still resolving resets the subscription instead of being missed for good. test("lite draft-06: a grant that shrinks while the broadcast resolves resets the subscription", async () => { @@ -1666,7 +1753,10 @@ test("lite draft-06: a grant that shrinks while the broadcast resolves resets th const handle = origin.dynamic(Path.from("live")); const requests = handle.requested(); const grant = new Signal(grantOf("live")); - const publisher = new Publisher(pair.server, Version.DRAFT_06, randomHop(), origin.consume(), grant); + const publisher = new Publisher(pair.server, Version.DRAFT_06, randomHop(), origin.consume(), { + grant, + ready: Promise.resolve(), + }); const client = await Stream.open(pair.client, { version: Version.DRAFT_06 }); const server = await Stream.accept(pair.server, Version.DRAFT_06); @@ -1703,7 +1793,10 @@ test("lite draft-06: a grant that shrinks mid-fetch resets the fetch", async () const pair = createMockTransportPair(ALPN_06); const origin = new OriginProducer(); const grant = new Signal(grantOf("live")); - const publisher = new Publisher(pair.server, Version.DRAFT_06, randomHop(), origin.consume(), grant); + const publisher = new Publisher(pair.server, Version.DRAFT_06, randomHop(), origin.consume(), { + grant, + ready: Promise.resolve(), + }); const broadcast = publish(origin, Path.from("live")); const track = broadcast.createTrack("video", { timescale: Timescale.MILLI }); diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index 9ace7782ac..d97b12e01d 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -399,6 +399,10 @@ export class Publisher { // version without AUTH, which allows everything. #grant?: Getter; + // Resolves once the tokens this session presented at setup are answered, so nothing is + // announced before the grant it would be checked against. + #ready: Promise; + // TRACK_INFO is immutable per track, so resolve it from the application once // (via a throwaway subscribe whose info() resolves when the app calls accept) // and reuse it for every later TRACK request of the same track. Keyed by the @@ -413,7 +417,8 @@ export class Publisher { * @param version - Negotiated protocol version * @param origin - Hop id shared with the Subscriber * @param publish - The origin whose broadcasts this session serves; omit to publish nothing - * @param grant - The union of our tokens' grants, which bounds what we publish + * @param auth - The union of our tokens' grants, which bounds what we publish, and when the + * setup tokens are answered * * @internal */ @@ -422,10 +427,11 @@ export class Publisher { version: Version, hop: Hop, publish?: OriginConsumer, - grant?: Getter, + auth?: { grant: Getter; ready: Promise }, ) { this.#quic = quic; - this.#grant = grant; + this.#grant = auth?.grant; + this.#ready = auth?.ready ?? Promise.resolve(); this.version = version; this.hop = hop; const origin = publish && wireOf(publish); @@ -551,6 +557,9 @@ export class Publisher { let changed = arm(); try { + // Nothing is announced before the grant it would be checked against. + if ((await race([this.#ready.then(() => "ready" as const), stream.reader.closed])) !== "ready") return; + const initial = this.#advertised.peek(); if (!initial) return; // closed @@ -1149,6 +1158,8 @@ export class Publisher { if (!front) throw new NotFound(`broadcast ${msg.broadcast}`); const info = await this.#resolveTrackInfo(front, msg.track, hold.signal); + // The grant may have narrowed while the broadcast and its track resolved. + if (this.#denied(msg.broadcast)) throw unauthorized(msg.broadcast); await info.encode(stream.writer, this.version); console.debug(`track info: broadcast=${msg.broadcast} track=${msg.track}`); stream.writer.close(); diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index f501497d40..d82efe62b8 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -1435,6 +1435,9 @@ where .await; } + // A joining FETCH rides its subscription's grant; a standalone one is checked here, + // before it reaches the origin, and again while its group loads. + let mut gate = None; let (track, start, end, timescale, joined) = match msg.fetch_type { FetchType::Standalone { namespace, @@ -1442,6 +1445,17 @@ where start, end, } => { + if !self.auth.allows(crate::auth::Direction::Publish, namespace.as_str()) { + return self + .reject_fetch(stream, msg.request_id, &Error::Unauthorized, "not granted") + .await; + } + gate = Some(crate::auth::Gate::new( + self.auth.clone(), + namespace.to_owned(), + crate::auth::Direction::Publish, + )); + // An End Object of 0 asks for the whole End Group. let end = match end.object { 0 => end.group.checked_add(1).map(|group| Location { group, object: 0 }), @@ -1538,6 +1552,11 @@ where if super::request_stream::poll_cancel(&mut stream, &mut finished, self.version, &mut cx).is_ready() { return Poll::Ready(None); } + if let Some(gate) = gate.as_mut() + && gate.poll_denied(waiter).is_ready() + { + return Poll::Ready(Some(Err(Error::Unauthorized))); + } waiter.poll_future(read.as_mut()).map(Some) }) .await @@ -5277,6 +5296,71 @@ mod serve_tests { /// A datagram group is never fetchable: a FETCH for one is refused like a group that /// does not exist, newest or not, and opens no fetch stream for its payload. + /// A standalone FETCH outside what the peer may subscribe to is refused before it + /// reaches the origin, and one whose grant narrows while its group loads is refused too. + #[moq_net_sim::test] + async fn a_standalone_fetch_outside_the_grant_is_refused() { + let unauthorized = |version| { + crate::ietf::error::request::to_code( + &Error::Unauthorized, + crate::ietf::error::request::Kind::Fetch, + version, + ) + }; + let nothing = crate::auth::Grant { + publish: Default::default(), + subscribe: Default::default(), + expires: None, + }; + let location = Location { group: 0, object: 0 }; + + for version in FETCH_DRAFTS { + let mut h = serve(version); + publish_pairs(&mut h, 1, None); + settle().await; + let auth = crate::auth::Handle::new(false); + auth.authorize(¬hing); + h.publisher = h.publisher.clone().with_auth(auth); + + let buf = standalone_fetch(&h, location, location, GroupOrder::Ascending).await; + assert_eq!( + fetch_refusal(buf, version), + unauthorized(version), + "{version}: denied up front" + ); + } + + for version in FETCH_DRAFTS { + // The group is still being written, so the fetch waits on it when the grant narrows. + let h = serve(version); + let _group = h.track.clone().create_group(group::Info { sequence: 0 }).unwrap(); + settle().await; + let auth = crate::auth::Handle::new(false); + let mut h = h; + h.publisher = h.publisher.clone().with_auth(auth.clone()); + + let mut fetch = std::pin::pin!(standalone_fetch( + &h, + location, + Location { group: 0, object: 1 }, + GroupOrder::Ascending + )); + assert!( + futures::poll!(fetch.as_mut()).is_pending(), + "{version}: served an unwritten group" + ); + auth.authorize(¬hing); + let buf = moq_net_sim::timeout(std::time::Duration::from_secs(1), fetch) + .await + .unwrap_or_else(|_| panic!("{version}: still loading after the grant narrowed")); + assert_eq!( + fetch_refusal(buf, version), + unauthorized(version), + "{version}: narrowed mid-load" + ); + } + } + #[moq_net_sim::test] async fn a_standalone_fetch_of_a_datagram_group_is_refused() { for version in FETCH_DRAFTS { From 2fa39d44b0abc1de3a2c2070689964719c9c5923 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 9 Oct 2026 00:59:13 -0700 Subject: [PATCH 41/53] fix(net): hold the grant across FETCH responses and TRACK_STATUS A standalone FETCH now races its whole response against the grant, so one waiting on stream credit when the grant narrows stops instead of sending what it no longer may. TRACK_STATUS is refused up front outside the grant and held to it while the track resolves. A JS lite announce waiting on the setup grant ends when the session closes locally, instead of holding the close until its deadline. Co-Authored-By: Claude Opus 5.5 --- js/net/src/lite/publisher.test.ts | 38 ++++ js/net/src/lite/publisher.ts | 12 +- rs/moq-net/src/ietf/publisher.rs | 294 ++++++++++++++++++++++---- rs/moq-net/src/lite/test_transport.rs | 11 +- 4 files changed, 306 insertions(+), 49 deletions(-) diff --git a/js/net/src/lite/publisher.test.ts b/js/net/src/lite/publisher.test.ts index de698ca180..897b32b521 100644 --- a/js/net/src/lite/publisher.test.ts +++ b/js/net/src/lite/publisher.test.ts @@ -1704,6 +1704,44 @@ test("lite draft-06: announces wait for the setup grant", async () => { pair.server.close(); }); +// A local close while an announce waits on the setup grant finishes it at once, rather than +// holding the close until its deadline. +test("lite draft-06: closing while announces wait for the setup grant does not hang", async () => { + const pair = createMockTransportPair(ALPN_06); + const origin = new OriginProducer(); + const grant = new Signal(undefined); + const publisher = new Publisher(pair.server, Version.DRAFT_06, randomHop(), origin.consume(), { + grant, + ready: new Promise(() => {}), + }); + + const written: Uint8Array[] = []; + const stream = new Stream({ + version: Version.DRAFT_06, + readable: new ReadableStream(), + writable: new WritableStream({ + write(chunk) { + written.push(chunk); + }, + }), + }); + const running = publisher.runAnnounce(new AnnounceRequest(Path.empty()), stream); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const drained = await Promise.race([ + publisher.drain().then(() => "drained" as const), + new Promise((resolve) => setTimeout(() => resolve("hung"), 500)), + ]); + expect(drained).toBe("drained"); + await running; + expect(written.length).toBe(0); + + publisher.close(); + origin.close(); + pair.client.close(); + pair.server.close(); +}); + // A TRACK is checked again once its broadcast and track resolve, so a shrink in between // refuses it rather than sending TRACK_INFO the grant no longer covers. test("lite draft-06: a grant that shrinks while a TRACK resolves refuses it", async () => { diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index d97b12e01d..4b6677cd73 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -557,8 +557,16 @@ export class Publisher { let changed = arm(); try { - // Nothing is announced before the grant it would be checked against. - if ((await race([this.#ready.then(() => "ready" as const), stream.reader.closed])) !== "ready") return; + // Nothing is announced before the grant it would be checked against. A local close + // meanwhile finishes the stream with nothing announced. + const ready = this.#ready.then(() => "ready" as const); + if ((await race([ready, stream.reader.closed, this.#withdrawal.closing])) !== "ready") { + if (this.#withdrawal.closing.peek()) { + stream.close(); + await stream.writer.closed; + } + return; + } const initial = this.#advertised.peek(); if (!initial) return; // closed diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 7a9a9db5b6..fe343f99dc 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -1678,52 +1678,74 @@ where false => end_location, }; - // FETCH_OK on every draft, never REQUEST_OK: section 5.2 allows exactly one FETCH_OK or - // REQUEST_ERROR in answer to a FETCH, and REQUEST_OK's own definition lists the other - // requests it answers without ever naming this one. - stream.writer.varint(ietf::FetchOk::ID).await?; - stream - .writer - .encode(&ietf::FetchOk { - request_id: match self.version { - Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(msg.request_id), - _ => None, - }, - // Only draft-14 encodes it, and only as the publisher restating the order. - group_order: match msg.group_order { - GroupOrder::Descending => GroupOrder::Descending, - _ => GroupOrder::Ascending, - }, - end_of_track, - end_location, - properties: Default::default(), - }) - .await?; + // The response, raced against the grant: a standalone FETCH whose grant narrows while it + // waits on stream credit stops there instead of sending what it no longer may. + let respond = async { + // FETCH_OK on every draft, never REQUEST_OK: section 5.2 allows exactly one FETCH_OK or + // REQUEST_ERROR in answer to a FETCH, and REQUEST_OK's own definition lists the other + // requests it answers without ever naming this one. + stream.writer.varint(ietf::FetchOk::ID).await?; + stream + .writer + .encode(&ietf::FetchOk { + request_id: match self.version { + Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(msg.request_id), + _ => None, + }, + // Only draft-14 encodes it, and only as the publisher restating the order. + group_order: match msg.group_order { + GroupOrder::Descending => GroupOrder::Descending, + _ => GroupOrder::Ascending, + }, + end_of_track, + end_location, + properties: Default::default(), + }) + .await?; - let uni = self.session.open_uni().await.map_err(Error::from_transport)?; - let mut writer = Writer::new(uni, self.version); - writer.set_priority(priority); - writer.varint(FetchHeader::TYPE).await?; - writer - .encode(&FetchHeader { - request_id: msg.request_id, + let uni = self.session.open_uni().await.map_err(Error::from_transport)?; + let mut writer = Writer::new(uni, self.version); + writer.set_priority(priority); + writer.varint(FetchHeader::TYPE).await?; + writer + .encode(&FetchHeader { + request_id: msg.request_id, + }) + .await?; + let mut first = true; + for (object, frame) in (group.first..).zip(group.frames) { + Self::write_fetch_object( + &mut writer, + group.sequence, + object, + FetchPrior::next(&mut first), + frame.timestamp, + timescale, + self.version, + ) + .await?; + Self::write_fetch_payload(&mut writer, frame.payload, self.version).await?; + } + writer.close().await?; + Ok::<(), Error>(()) + }; + let res = { + let mut respond = std::pin::pin!(respond); + kio::wait(|waiter| { + if let Some(gate) = gate.as_mut() + && gate.poll_denied(waiter).is_ready() + { + return Poll::Ready(Err(Error::Unauthorized)); + } + waiter.poll_future(respond.as_mut()) }) - .await?; - let mut first = true; - for (object, frame) in (group.first..).zip(group.frames) { - Self::write_fetch_object( - &mut writer, - group.sequence, - object, - FetchPrior::next(&mut first), - frame.timestamp, - timescale, - self.version, - ) - .await?; - Self::write_fetch_payload(&mut writer, frame.payload, self.version).await?; + .await + }; + if let Err(err) = res { + // The fetch stream, if open, reset as it dropped with the response. + stream.writer.abort(&err); + return Err(err); } - writer.close().await?; // FETCH_OK is the last thing this stream has to say, and [`Writer`] resets on drop: // without the finish the answer is discarded before the peer ever reads it, exactly @@ -1816,6 +1838,21 @@ where msg: ietf::TrackStatus<'_>, ) -> Result<(), Error> { let request_id = msg.request_id; + + // Answer only for what our grant lets us publish (MoQ Auth): checked before the + // track resolves, and held until the answer. + if !self + .auth + .allows(crate::auth::Direction::Publish, msg.track_namespace.as_str()) + { + return self.reject_track_status(stream, request_id, &Error::Unauthorized).await; + } + let mut gate = crate::auth::Gate::new( + self.auth.clone(), + msg.track_namespace.to_owned(), + crate::auth::Direction::Publish, + ); + let broadcast = match self .serving_origin() .await @@ -1839,6 +1876,9 @@ where if super::request_stream::poll_cancel(&mut stream, &mut finished, self.version, &mut cx).is_ready() { return Poll::Ready(None); } + if gate.poll_denied(waiter).is_ready() { + return Poll::Ready(Some(Err(Error::Unauthorized))); + } query.poll_ok(waiter).map(Some) }) .await @@ -4224,6 +4264,94 @@ mod serve_tests { } } + /// A TRACK_STATUS outside what the peer may subscribe to is refused before the track + /// resolves, and one whose grant narrows while it resolves is refused too. + #[moq_net_sim::test] + async fn track_status_outside_the_grant_is_refused() { + let nothing = || crate::auth::Grant { + publish: Default::default(), + subscribe: Default::default(), + expires: None, + }; + let unauthorized = |version| request::to_code(&Error::Unauthorized, request::Kind::TrackStatus, version); + + for version in ALL_VERSIONS { + let mut h = serve(version); + let claim = h._origin.dynamic("live", crate::origin::Route::default()).unwrap(); + let auth = crate::auth::Handle::new(false); + auth.authorize(¬hing()); + h.publisher = h.publisher.clone().with_auth(auth); + let code = moq_net_sim::timeout( + std::time::Duration::from_secs(1), + answer_code(&h, track_status("live/cam", true, version), version), + ) + .await + .unwrap_or_else(|_| panic!("{version}: waited on a broadcast it may not answer for")); + assert_eq!(code, Some(unauthorized(version)), "{version}: denied up front"); + use futures::FutureExt; + assert!( + claim.requested_broadcast().now_or_never().is_none(), + "{version}: a denied request reached the origin" + ); + } + + for version in ALL_VERSIONS { + let mut h = serve(version); + let claim = h._origin.dynamic("live", crate::origin::Route::default()).unwrap(); + let auth = crate::auth::Handle::new(false); + h.publisher = h.publisher.clone().with_auth(auth.clone()); + + let stream = Stream::open(&mut h.session.clone(), version).await.unwrap(); + let mark = h.log.writes.lock().unwrap().len(); + let body = track_status("live/cam", true, version); + let mut answer = std::pin::pin!( + h.publisher + .clone() + .handle_stream(ietf::TrackStatus::ID, ietf::Body(bytes::Bytes::from(body)), stream) + .unwrap() + ); + assert!(futures::poll!(answer.as_mut()).is_pending(), "{version}"); + + // Parked resolving the broadcast when the grant narrows. + let request = claim.requested_broadcast().await.unwrap(); + auth.authorize(¬hing()); + let output = crate::broadcast::Info::new().produce(); + let _track = output.create_track("video", None).unwrap(); + request.accept(&output); + answer.await; + + let code = refusal_code(&h.log.writes.lock().unwrap()[mark..], version); + assert_eq!(code, Some(unauthorized(version)), "{version}: narrowed while resolving"); + } + } + + /// Run one TRACK_STATUS to its end, returning the refusal code it got, if any. + async fn answer_code(h: &Serve, body: Vec, version: Version) -> Option { + let stream = Stream::open(&mut h.session.clone(), version).await.unwrap(); + let mark = h.log.writes.lock().unwrap().len(); + h.publisher + .clone() + .handle_stream(ietf::TrackStatus::ID, ietf::Body(bytes::Bytes::from(body)), stream) + .unwrap() + .await; + refusal_code(&h.log.writes.lock().unwrap()[mark..], version) + } + + /// The refusal code on the wire, or `None` for any other answer. + fn refusal_code(wire: &[u8], version: Version) -> Option { + let mut buf = Decoder::new(wire, version.into()); + let id = buf.varint().unwrap(); + match version { + Version::Draft14 if id == ietf::TRACK_STATUS_ERROR_14 => { + Some(ietf::SubscribeError::decode(&mut buf, version).unwrap().error_code) + } + _ if version != Version::Draft14 && id == ietf::RequestError::ID => { + Some(ietf::RequestError::decode(&mut buf, version).unwrap().error_code) + } + _ => None, + } + } + /// A TRACK_STATUS for a broadcast we do not serve is refused as a SUBSCRIBE would be. #[moq_net_sim::test] async fn track_status_for_a_missing_broadcast_is_refused() { @@ -5684,6 +5812,86 @@ mod serve_tests { } } + /// A grant that narrows while a standalone FETCH waits on stream credit for its response + /// stops it there: nothing it no longer may send reaches the wire. + #[moq_net_sim::test] + async fn a_standalone_fetch_narrowed_while_responding_stops() { + for version in FETCH_DRAFTS { + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let broadcast = origin.publish("room", crate::origin::Route::default()).unwrap(); + let track = broadcast.create_track("video", None).unwrap(); + let mut group = track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"0-0".to_vec()).unwrap(); + group.finish().unwrap(); + settle().await; + + // Request streams flow, but no uni stream credit is ever granted. + let bi = kio::Producer::new(true); + let credit = kio::Producer::new(false); + let session = SinkSession::gated_bi(bi.consume()).with_open_uni_gate(credit.consume()); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + let auth = crate::auth::Handle::new(false); + let publisher = Publisher::new( + crate::time::Clock::sim(), + session.clone(), + origin.consume(), + Control::new(None, false), + None, + peer_setup, + version, + ) + .with_auth(auth.clone()); + + let stream = Stream::open(&mut session.clone(), version).await.unwrap(); + let mark = session.log.writes.lock().unwrap().len(); + let location = Location { group: 0, object: 0 }; + let mut fetch = std::pin::pin!(publisher.run_fetch_stream( + stream, + ietf::Fetch { + request_id: FETCH_ID, + subscriber_priority: 128, + group_order: GroupOrder::Ascending, + fetch_type: FetchType::Standalone { + namespace: crate::Path::new("room"), + track: "video".into(), + start: location, + end: Location { group: 0, object: 1 }, + }, + range_filters: false, + fill_timeout: false, + properties_wanted: true, + }, + )); + // Driven until FETCH_OK is out, so the fetch is parked on uni stream credit. + for _ in 0..100 { + assert!( + futures::poll!(fetch.as_mut()).is_pending(), + "{version}: never waited on credit" + ); + if session.log.writes.lock().unwrap().len() > mark { + break; + } + moq_net_sim::yield_now().await; + } + assert!( + session.log.writes.lock().unwrap().len() > mark, + "{version}: FETCH_OK never went out" + ); + + auth.authorize(&crate::auth::Grant { + publish: Default::default(), + subscribe: Default::default(), + expires: None, + }); + let res = moq_net_sim::timeout(std::time::Duration::from_secs(1), fetch) + .await + .unwrap_or_else(|_| panic!("{version}: still responding after the grant narrowed")); + assert!(matches!(res, Err(Error::Unauthorized)), "{version}: {res:?}"); + drop(credit); + } + } + #[moq_net_sim::test] async fn a_standalone_fetch_of_a_datagram_group_is_refused() { for version in FETCH_DRAFTS { diff --git a/rs/moq-net/src/lite/test_transport.rs b/rs/moq-net/src/lite/test_transport.rs index d41ed5c8d5..d39c7f1582 100644 --- a/rs/moq-net/src/lite/test_transport.rs +++ b/rs/moq-net/src/lite/test_transport.rs @@ -428,10 +428,13 @@ impl SinkSession { /// Hold unidirectional stream opens until `gate` grants stream credit. pub fn gated_open_uni(gate: kio::Consumer) -> Self { - Self { - uni_open_gate: Some(gate), - ..Default::default() - } + Self::default().with_open_uni_gate(gate) + } + + /// Also hold unidirectional stream opens until `gate` grants stream credit. + pub fn with_open_uni_gate(mut self, gate: kio::Consumer) -> Self { + self.uni_open_gate = Some(gate); + self } } From a710bd8123a41feeea945c3fb01d991a61cf5d7d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 9 Oct 2026 01:00:45 -0700 Subject: [PATCH 42/53] test(net): write the mid-fetch grant test's frame with main's textFrame Co-Authored-By: Claude Opus 5.5 --- js/net/src/lite/publisher.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/net/src/lite/publisher.test.ts b/js/net/src/lite/publisher.test.ts index 9c3716bbcc..79f3131864 100644 --- a/js/net/src/lite/publisher.test.ts +++ b/js/net/src/lite/publisher.test.ts @@ -1840,7 +1840,7 @@ test("lite draft-06: a grant that shrinks mid-fetch resets the fetch", async () const track = broadcast.createTrack("video", { timescale: Timescale.MILLI }); // Left open, so the fetch is still serving when the grant shrinks. const group = new GroupProducer(0); - group.writeString("first"); + group.writeFrame(textFrame("first")); track.writeGroup(group); const client = await Stream.open(pair.client, { version: Version.DRAFT_06 }); From 74936611820259996491a06573d37b52ee0146db Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 9 Oct 2026 09:55:02 -0700 Subject: [PATCH 43/53] fix(net): cap the IETF AUTH_ERROR reason from its prefix The IETF codec now rejects an oversized reason from the length prefix, like the lite codec, and a refusal on a finished auth issue queues nothing. Also moves a misattached doc comment back to its test and notes why a narrowed FETCH after FETCH_OK ends in a reset. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/auth.rs | 6 ++++-- rs/moq-net/src/ietf/auth.rs | 19 ++++++++++++++++--- rs/moq-net/src/ietf/publisher.rs | 7 ++++--- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index f7ef7a7cec..db5028d076 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -820,8 +820,10 @@ impl Drop for Request { fn refuse(issue: &kio::Shared, code: SessionError, reason: String) { let mut issue = issue.lock(); - issue.outbox.push_back(Reply::Refuse { code, reason }); - issue.done = true; + if !issue.done { + issue.outbox.push_back(Reply::Refuse { code, reason }); + issue.done = true; + } } /// A grant issued to one of the peer's tokens. Dropping it ends the grant. diff --git a/rs/moq-net/src/ietf/auth.rs b/rs/moq-net/src/ietf/auth.rs index 44ff84e6c4..98664fed46 100644 --- a/rs/moq-net/src/ietf/auth.rs +++ b/rs/moq-net/src/ietf/auth.rs @@ -158,7 +158,8 @@ pub struct AuthError { pub reason: String, } -/// Longest AUTH_ERROR reason, in bytes, matching the lite wire. +/// Longest AUTH_ERROR reason, in bytes, matching the lite wire and rejected from the +/// length prefix alone. const MAX_REASON: usize = 8192; impl Message for AuthError { @@ -176,10 +177,11 @@ impl Message for AuthError { fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { check_version(version)?; let code = r.varint()?; - let reason = r.string()?; - if reason.len() > MAX_REASON { + let len = r.varint()?; + if len > MAX_REASON as u64 { return Err(DecodeError::InvalidValue); } + let reason = String::from_utf8(r.slice(len as usize)?.to_vec())?; Ok(Self { code, reason }) } } @@ -627,6 +629,17 @@ mod tests { assert_eq!(round_trip(&msg), msg); } + /// An oversized reason is refused from its length prefix, before any of it arrives. + #[test] + fn auth_error_reason_is_capped_by_its_prefix() { + let mut buf = Vec::new(); + let mut w = Encoder::new(&mut buf, VERSION.into()); + w.varint(UNAUTHORIZED).unwrap(); + w.varint(MAX_REASON as u64 + 1).unwrap(); + let err = AuthError::decode_msg(&mut Decoder::new(&buf, VERSION.into()), VERSION).unwrap_err(); + assert!(matches!(err, DecodeError::InvalidValue), "{err:?}"); + } + /// Only subtrees fit the prefix encoding, alone or in a union; anything narrower is /// refused, never widened to its head. #[test] diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index fe343f99dc..b45f62a447 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -1742,7 +1742,8 @@ where .await }; if let Err(err) = res { - // The fetch stream, if open, reset as it dropped with the response. + // The fetch stream, if open, reset as it dropped with the response. Once FETCH_OK + // is out, no REQUEST_ERROR can follow it, so the reset is the whole refusal. stream.writer.abort(&err); return Err(err); } @@ -5745,8 +5746,6 @@ mod serve_tests { } } - /// A datagram group is never fetchable: a FETCH for one is refused like a group that - /// does not exist, newest or not, and opens no fetch stream for its payload. /// A standalone FETCH outside what the peer may subscribe to is refused before it /// reaches the origin, and one whose grant narrows while its group loads is refused too. #[moq_net_sim::test] @@ -5892,6 +5891,8 @@ mod serve_tests { } } + /// A datagram group is never fetchable: a FETCH for one is refused like a group that + /// does not exist, newest or not, and opens no fetch stream for its payload. #[moq_net_sim::test] async fn a_standalone_fetch_of_a_datagram_group_is_refused() { for version in FETCH_DRAFTS { From 86a10fe2fd591c80fb47862ad417277708d788fd Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 9 Oct 2026 09:56:49 -0700 Subject: [PATCH 44/53] fix(net): adapt the subscription grant arm to main's run_subscription result Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/ietf/publisher.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index b4c110a26a..7488087b4a 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -830,7 +830,7 @@ where } if gate.poll_denied(waiter).is_ready() { tracing::info!(broadcast = %absolute, track = %track_name, "subscription no longer authorized"); - return Poll::Ready(Some((Err(Error::Unauthorized), false))); + return Poll::Ready(Some(Err(Error::Unauthorized))); } Poll::Pending }) From 5291e5355dc730dd9091fb81ff179bfd5923b7d0 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 9 Oct 2026 10:16:37 -0700 Subject: [PATCH 45/53] quest: hold every incoming request to the grant through a dispatcher-owned gate Co-Authored-By: Claude Opus 5.5 --- quest/m1/auth/README.md | 2 ++ quest/m1/auth/request-gate.md | 36 +++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 quest/m1/auth/request-gate.md diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index 5b7c9b76e1..ad1270707a 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -105,6 +105,8 @@ because `main` assigned 0x3A to NOT_FETCHABLE first. non-canonical grant pattern, or an out-of-range `Expires`, closes the session with PROTOCOL_VIOLATION in Rust and JS - [JS fetch grant watch](/quest/m1/auth/js-fetch-watch.md) - a JS `fetchGroup` ends `Unauthorized` when its path leaves the grant +- [Request gate](/quest/m1/auth/request-gate.md) - every incoming request is + refused up front and held to the grant through a gate its dispatcher owns - [Relay tokens](/quest/m1/auth/relay-refresh.md) - the relay verifies tokens sent in band, unions their grants, and cancels only work that loses access - [Request tokens](/quest/m1/auth/request-token.md) - an `AUTHORIZATION diff --git a/quest/m1/auth/request-gate.md b/quest/m1/auth/request-gate.md new file mode 100644 index 0000000000..b937cbfc3f --- /dev/null +++ b/quest/m1/auth/request-gate.md @@ -0,0 +1,36 @@ +# [M] Every incoming request is held to the grant by construction + +## Goal + +Every request a peer sends, on lite and IETF, in Rust and `@moq/net`, is +checked against the grant when it is dispatched and held to it for its whole +life: resolution, reads, and writes. A new request type cannot be served +without a gate. Per-handler checks already missed standalone FETCH and +TRACK_STATUS in Rust and the TRACK re-check and setup announces in JS, all +fixed in #4039. Behavior stays the same: no wire or public API change, and +the existing auth tests are the regression net. + +## Plan + +Decisions settled while planning (2026-10-09): + +- **The dispatcher owns the gate.** It refuses an out-of-grant request up + front, the same way for every type, and hands the live gate to the handler, + whose signature requires one. The handler still picks its graceful end on a + narrowed grant (PUBLISH_DONE `Unauthorized`, a REQUEST_ERROR, or a stream + reset once FETCH_OK is out). Racing the whole handler future and dropping it + was rejected because it loses those graceful refusals. +- **Rust and JS together**, so the shape and names stay mirrored. +- **Structure only.** Finding new gaps is not the goal, but any request type + the refactor shows was ungated gets a regression test in the same PR. + +Today `auth::Gate` is built inside each handler: the IETF publisher's +SUBSCRIBE, standalone FETCH, and TRACK_STATUS; the IETF subscriber; and the +lite publisher and subscriber. JS checks `#denied` and `#watch` per handler. +A joining FETCH inherits its subscription's gate rather than resolving a +namespace of its own. + +## Related + +- [JS fetch grant watch](/quest/m1/auth/js-fetch-watch.md) - the outgoing + side: a JS `fetchGroup` this session issued ends when its path leaves the grant From 22738c62af3c68e29865ad3bcd3e82c5809bb0e9 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 9 Oct 2026 10:41:10 -0700 Subject: [PATCH 46/53] fix(net): hold joining FETCH and TRACK_STATUS answers to the grant A joining FETCH answers from its subscription's cache, which outlives the subscription, so it now holds its own gate on the subscription's namespace through loading and the whole response. The FETCH gate is no longer optional. A TRACK_STATUS answer is raced against the gate while it is written and reset if the grant narrows first. Co-Authored-By: Claude Opus 5.5 --- quest/m1/auth/request-gate.md | 5 +- rs/moq-net/src/ietf/publisher.rs | 255 ++++++++++++++++++++++++++++--- 2 files changed, 234 insertions(+), 26 deletions(-) diff --git a/quest/m1/auth/request-gate.md b/quest/m1/auth/request-gate.md index b937cbfc3f..11aedea3ac 100644 --- a/quest/m1/auth/request-gate.md +++ b/quest/m1/auth/request-gate.md @@ -27,8 +27,9 @@ Decisions settled while planning (2026-10-09): Today `auth::Gate` is built inside each handler: the IETF publisher's SUBSCRIBE, standalone FETCH, and TRACK_STATUS; the IETF subscriber; and the lite publisher and subscriber. JS checks `#denied` and `#watch` per handler. -A joining FETCH inherits its subscription's gate rather than resolving a -namespace of its own. +A joining FETCH carries its subscription's namespace and holds its own gate, +since the cache it answers from outlives the subscription. JS TRACK_INFO is +re-checked after it resolves but not while its answer is written. ## Related diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 7488087b4a..8f206649cb 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -393,6 +393,8 @@ enum Joined { end: Location, cache: track::Consumer, timescale: Option, + /// The subscription's namespace, which the fetch is held to the grant on. + namespace: crate::PathOwned, }, } @@ -776,6 +778,7 @@ where }, cache: cache.clone(), timescale, + namespace: msg.track_namespace.to_owned(), }, (Filter::NextObject, None) => Joined::Empty, _ => Joined::Unsupported, @@ -1464,10 +1467,10 @@ where other => other, }; - // A joining FETCH rides its subscription's grant; a standalone one is checked here, - // before it reaches the origin, and again while its group loads. - let mut gate = None; - let (track, start, end, timescale, joined) = match fetch_type { + // Every FETCH is held to the grant while its group loads and its response is written. + // A standalone one is also checked here, before it reaches the origin; a joining one + // was checked when its subscription was. + let (track, start, end, timescale, joined, mut gate) = match fetch_type { FetchType::Standalone { namespace, track, @@ -1479,11 +1482,8 @@ where .reject_fetch(stream, msg.request_id, &Error::Unauthorized, "not granted") .await; } - gate = Some(crate::auth::Gate::new( - self.auth.clone(), - namespace.to_owned(), - crate::auth::Direction::Publish, - )); + let gate = + crate::auth::Gate::new(self.auth.clone(), namespace.to_owned(), crate::auth::Direction::Publish); // An End Object of 0 asks for the whole End Group. let end = match end.object { @@ -1508,7 +1508,7 @@ where }; // The track's timescale is only known once its group is read, below. - (track, start, end, None, false) + (track, start, end, None, false, gate) } FetchType::RelativeJoining { subscriber_request_id, .. @@ -1516,10 +1516,13 @@ where | FetchType::AbsoluteJoining { subscriber_request_id, .. } => { - let (end, cache, timescale) = match self.joined(&mut stream, subscriber_request_id).await? { + let (end, cache, timescale, namespace) = match self.joined(&mut stream, subscriber_request_id).await? { Ok(joined) => joined, Err((err, reason)) => return self.reject_fetch(stream, msg.request_id, &err, reason).await, }; + // The cache outlives the subscription, so the fetch holds its own gate rather + // than trusting the subscription's to stop it. + let gate = crate::auth::Gate::new(self.auth.clone(), namespace, crate::auth::Direction::Publish); let start = match fetch_type { FetchType::RelativeJoining { group_offset, .. } => end.group.saturating_sub(group_offset), FetchType::AbsoluteJoining { group_id, .. } if group_id <= end.group => group_id, @@ -1543,6 +1546,7 @@ where end, timescale, true, + gate, ) } // Rewritten as standalone or refused above. @@ -1581,9 +1585,7 @@ where if super::request_stream::poll_cancel(&mut stream, &mut finished, self.version, &mut cx).is_ready() { return Poll::Ready(None); } - if let Some(gate) = gate.as_mut() - && gate.poll_denied(waiter).is_ready() - { + if gate.poll_denied(waiter).is_ready() { return Poll::Ready(Some(Err(Error::Unauthorized))); } waiter.poll_future(read.as_mut()).map(Some) @@ -1735,9 +1737,7 @@ where let res = { let mut respond = std::pin::pin!(respond); kio::wait(|waiter| { - if let Some(gate) = gate.as_mut() - && gate.poll_denied(waiter).is_ready() - { + if gate.poll_denied(waiter).is_ready() { return Poll::Ready(Err(Error::Unauthorized)); } waiter.poll_future(respond.as_mut()) @@ -1765,7 +1765,8 @@ where &mut self, stream: &mut Stream, subscribe_id: RequestId, - ) -> Result), (Error, &'static str)>, Error> { + ) -> Result, crate::PathOwned), (Error, &'static str)>, Error> + { // Request streams can arrive out of order. Wait on registration, while bounding // the lifetime of a request whose subscription never arrives or resolves. let joined = { @@ -1802,7 +1803,12 @@ where let refusal = match joined { Err(Error::Timeout) => (Error::Timeout, "subscription not ready"), Err(err) => return Err(err), - Ok(Some(Joined::Group { end, cache, timescale })) => return Ok(Ok((end, cache, timescale))), + Ok(Some(Joined::Group { + end, + cache, + timescale, + namespace, + })) => return Ok(Ok((end, cache, timescale, namespace))), Ok(None) => ( match self.version { Version::Draft14 @@ -1893,18 +1899,35 @@ where None => return Ok(()), }; - stream.writer.varint(ietf::TrackStatusOk::id(self.version)).await?; - stream - .writer - .encode(&ietf::TrackStatusOk { + // The answer stays gated until the transport takes it: one parked on stream credit + // when the grant narrows is reset rather than delivered. + let respond = async { + let ok = ietf::TrackStatusOk { request_id: match self.version { Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(request_id), _ => None, }, largest: live_edge(&track).largest, properties: track_properties(&info, msg.properties_wanted), + }; + stream.writer.varint(ietf::TrackStatusOk::id(self.version)).await?; + stream.writer.encode(&ok).await?; + Ok::<(), Error>(()) + }; + let res = { + let mut respond = std::pin::pin!(respond); + kio::wait(|waiter| { + if gate.poll_denied(waiter).is_ready() { + return Poll::Ready(Err(Error::Unauthorized)); + } + waiter.poll_future(respond.as_mut()) }) - .await?; + .await + }; + if let Err(err) = res { + stream.writer.abort(&err); + return Err(err); + } // The answer is all this stream carries. See [`Self::reject_subscribe`] for why the // close is not optional. @@ -4603,6 +4626,76 @@ mod serve_tests { } } + /// A TRACK_STATUS answer parked on its request stream when the grant narrows is reset, + /// never delivered once the stream drains. + #[moq_net_sim::test] + async fn track_status_narrowed_while_answering_is_reset() { + for version in ALL_VERSIONS { + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let broadcast = origin.publish("room", crate::origin::Route::default()).unwrap(); + let _track = broadcast.create_track("video", None).unwrap(); + settle().await; + + let bi = kio::Producer::new(true); + let session = SinkSession::gated_bi(bi.consume()); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + let auth = crate::auth::Handle::new(false); + let publisher = Publisher::new( + crate::time::Clock::sim(), + session.clone(), + origin.consume(), + Control::new(None, false), + None, + peer_setup, + version, + ) + .with_auth(auth.clone()); + + let stream = Stream::open(&mut session.clone(), version).await.unwrap(); + let mark = session.log.writes.lock().unwrap().len(); + // The request stream stops taking writes, so the answer parks on it. + let Ok(mut writable) = bi.write() else { + panic!("request stream gate closed"); + }; + *writable = false; + drop(writable); + let mut answer = std::pin::pin!(publisher.clone().run_track_status_stream( + stream, + ietf::TrackStatus { + request_id: RequestId(REQUEST_ID), + track_namespace: crate::Path::new("room"), + track_name: "video".into(), + properties_wanted: true, + }, + )); + for _ in 0..100 { + assert!(futures::poll!(answer.as_mut()).is_pending(), "{version}: never parked"); + moq_net_sim::yield_now().await; + } + + auth.authorize(&crate::auth::Grant { + publish: Default::default(), + subscribe: Default::default(), + expires: None, + }); + let Ok(mut writable) = bi.write() else { + panic!("request stream gate closed"); + }; + *writable = true; + drop(writable); + let res = moq_net_sim::timeout(std::time::Duration::from_secs(1), answer) + .await + .unwrap_or_else(|_| panic!("{version}: still answering after the grant narrowed")); + assert!(matches!(res, Err(Error::Unauthorized)), "{version}: {res:?}"); + assert!( + session.log.writes.lock().unwrap()[mark..].is_empty(), + "{version}: the answer went out after the grant narrowed" + ); + assert!(!session.log.resets().is_empty(), "{version}: the answer was not reset"); + } + } + /// Run one TRACK_STATUS to its end, returning the refusal code it got, if any. async fn answer_code(h: &Serve, body: Vec, version: Version) -> Option { let stream = Stream::open(&mut h.session.clone(), version).await.unwrap(); @@ -6168,6 +6261,120 @@ mod serve_tests { } } + /// A joining FETCH answers from its subscription's cache, which outlives the + /// subscription, so it holds its own gate: one parked on stream credit when the grant + /// narrows stops there, and nothing reaches the wire once credit returns. + #[moq_net_sim::test] + async fn a_joining_fetch_narrowed_while_responding_stops() { + const PAYLOAD: &[u8] = b"joined-prefix"; + + for version in JOINING_DRAFTS { + for fetch_type in [ + FetchType::RelativeJoining { + subscriber_request_id: RequestId(REQUEST_ID), + group_offset: 0, + }, + FetchType::AbsoluteJoining { + subscriber_request_id: RequestId(REQUEST_ID), + group_id: 0, + }, + ] { + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let broadcast = origin.publish("room", crate::origin::Route::default()).unwrap(); + let track = broadcast.create_track("video", None).unwrap(); + let mut group = track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), PAYLOAD.to_vec()).unwrap(); + settle().await; + + // Request streams flow, but no uni stream credit is granted until the end. + let bi = kio::Producer::new(true); + let credit = kio::Producer::new(false); + let session = SinkSession::gated_bi(bi.consume()).with_open_uni_gate(credit.consume()); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + let auth = crate::auth::Handle::new(false); + let publisher = Publisher::new( + crate::time::Clock::sim(), + session.clone(), + origin.consume(), + Control::new(None, false), + None, + peer_setup, + version, + ) + .with_auth(auth.clone()); + + let stream = Stream::open(&mut session.clone(), version).await.unwrap(); + let mut subscription = std::pin::pin!( + publisher + .clone() + .run_subscribe_stream(stream, subscribe(Filter::NextObject, None)) + ); + let joined = publisher.joins.wait(|joins| match joins.get(&RequestId(REQUEST_ID)) { + Some(Some(_)) => Poll::Ready(()), + _ => Poll::Pending, + }); + match futures::future::select(std::pin::pin!(joined), subscription.as_mut()).await { + futures::future::Either::Left(_) => {} + futures::future::Either::Right((res, _)) => panic!("{version}: subscription ended: {res:?}"), + } + + let stream = Stream::open(&mut session.clone(), version).await.unwrap(); + let mark = session.log.writes.lock().unwrap().len(); + let mut fetch = std::pin::pin!(publisher.clone().run_fetch_stream( + stream, + ietf::Fetch { + request_id: FETCH_ID, + subscriber_priority: 128, + group_order: GroupOrder::Ascending, + fetch_type, + range_filters: false, + fill_timeout: false, + properties_wanted: true, + }, + )); + // Driven until FETCH_OK is out, so the fetch is parked on uni stream credit. + for _ in 0..100 { + assert!( + futures::poll!(fetch.as_mut()).is_pending(), + "{version}: never waited on credit" + ); + if session.log.writes.lock().unwrap().len() > mark { + break; + } + moq_net_sim::yield_now().await; + } + assert!( + session.log.writes.lock().unwrap().len() > mark, + "{version}: FETCH_OK never went out" + ); + + auth.authorize(&crate::auth::Grant { + publish: Default::default(), + subscribe: Default::default(), + expires: None, + }); + let res = moq_net_sim::timeout(std::time::Duration::from_secs(1), fetch) + .await + .unwrap_or_else(|_| panic!("{version}: still responding after the grant narrowed")); + assert!(matches!(res, Err(Error::Unauthorized)), "{version}: {res:?}"); + + let Ok(mut open) = credit.write() else { + panic!("credit gate closed"); + }; + *open = true; + drop(open); + let _ = futures::poll!(subscription.as_mut()); + settle().await; + assert_eq!( + occurrences(&session.log, PAYLOAD), + 0, + "{version}: revoked media reached the wire" + ); + } + } + } + /// A datagram group is never fetchable: a FETCH for one is refused like a group that /// does not exist, newest or not, and opens no fetch stream for its payload. #[moq_net_sim::test] From 76d08ba2d1c37b6950f5c3f39371b02d4d0695e3 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 9 Oct 2026 12:28:26 -0700 Subject: [PATCH 47/53] fix(js): reset a TRACK_INFO answer when the grant shrinks mid-write The lite publisher holds its grant watch from the first check until the answer is acknowledged, so a TRACK_INFO blocked on flow control is reset instead of finishing after revocation, matching Rust's TRACK_STATUS. Co-Authored-By: Claude Opus 5.5 --- js/net/src/lite/publisher.test.ts | 63 +++++++++++++++++++++++++++++++ js/net/src/lite/publisher.ts | 17 +++++++-- quest/m1/auth/request-gate.md | 8 ++-- rs/moq-net/src/ietf/publisher.rs | 2 + 4 files changed, 82 insertions(+), 8 deletions(-) diff --git a/js/net/src/lite/publisher.test.ts b/js/net/src/lite/publisher.test.ts index 79f3131864..24612b667c 100644 --- a/js/net/src/lite/publisher.test.ts +++ b/js/net/src/lite/publisher.test.ts @@ -1783,6 +1783,69 @@ test("lite draft-06: a grant that shrinks while a TRACK resolves refuses it", as } }); +// A TRACK_INFO blocked on flow control when the grant shrinks is reset: the rest of the +// answer never reaches the wire once the stream drains. +test("lite draft-06: a grant that shrinks while TRACK_INFO is blocked resets it", async () => { + const pair = createMockTransportPair(ALPN_06); + const origin = new OriginProducer(); + const handle = origin.dynamic(Path.from("live")); + const requests = handle.requested(); + const grant = new Signal(grantOf("live")); + const publisher = new Publisher(pair.server, Version.DRAFT_06, randomHop(), origin.consume(), { + grant, + ready: Promise.resolve(), + }); + + // The first write (the length prefix) parks until released. + let release = () => {}; + const blocked = new Promise((resolve) => { + release = resolve; + }); + let parked = () => {}; + const writing = new Promise((resolve) => { + parked = resolve; + }); + const written: Uint8Array[] = []; + const stream = new Stream({ + version: Version.DRAFT_06, + readable: new ReadableStream(), + writable: new WritableStream({ + async write(chunk) { + parked(); + await blocked; + written.push(chunk); + }, + }), + }); + + const resets = spyOn(Writer.prototype, "reset"); + const produced = new BroadcastProducer(); + produced.createTrack("video", { timescale: Timescale.MILLI }); + try { + const serving = publisher.runTrackInfo(new TrackMessage(Path.from("live/cam"), "video"), stream); + const request = await requests.next(); + if (request.done) throw new Error("the handler never saw the request"); + request.value.accept(produced); + await writing; + + grant.set(grantOf("other")); + release(); + await serving; + + expect(unauthorizedResets(resets)).toContain("unauthorized: live/cam"); + // Only the in-flight length prefix; the body never followed it. + expect(written.length).toBe(1); + } finally { + resets.mockRestore(); + produced.close(); + handle.close(); + publisher.close(); + origin.close(); + pair.client.close(); + pair.server.close(); + } +}); + // The grant watch is armed before the first check, so a shrink that lands while the broadcast // is still resolving resets the subscription instead of being missed for good. test("lite draft-06: a grant that shrinks while the broadcast resolves resets the subscription", async () => { diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index 4b6677cd73..afeb353c7a 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -1143,11 +1143,19 @@ export class Publisher { * @internal */ async runTrackInfo(msg: TrackMessage, stream: Stream) { - if (this.#denied(msg.broadcast)) { + const hold = new AbortController(); + // Armed before the first check and held until the answer is acknowledged, so a grant + // that shrinks while the answer is blocked on flow control resets it, never sends it. + const watch = this.#watch(msg.broadcast, () => { + console.debug(`track info revoked: broadcast=${msg.broadcast} track=${msg.track}`); + hold.abort(); + stream.writer.reset(unauthorized(msg.broadcast)); + }); + if (watch.revoked()) { + watch.dispose(); stream.writer.reset(unauthorized(msg.broadcast)); return; } - const hold = new AbortController(); // Watched from the start, so a requester leaving while the reply is still blocked on // flow control lets go of the track too. void stream.reader.done().then( @@ -1166,8 +1174,7 @@ export class Publisher { if (!front) throw new NotFound(`broadcast ${msg.broadcast}`); const info = await this.#resolveTrackInfo(front, msg.track, hold.signal); - // The grant may have narrowed while the broadcast and its track resolved. - if (this.#denied(msg.broadcast)) throw unauthorized(msg.broadcast); + if (watch.revoked()) throw unauthorized(msg.broadcast); await info.encode(stream.writer, this.version); console.debug(`track info: broadcast=${msg.broadcast} track=${msg.track}`); stream.writer.close(); @@ -1176,6 +1183,8 @@ export class Publisher { hold.abort(); console.debug(`track unknown: broadcast=${msg.broadcast} track=${msg.track}`); stream.writer.reset(error(err)); + } finally { + watch.dispose(); } } diff --git a/quest/m1/auth/request-gate.md b/quest/m1/auth/request-gate.md index 11aedea3ac..d03bc56f96 100644 --- a/quest/m1/auth/request-gate.md +++ b/quest/m1/auth/request-gate.md @@ -7,8 +7,9 @@ checked against the grant when it is dispatched and held to it for its whole life: resolution, reads, and writes. A new request type cannot be served without a gate. Per-handler checks already missed standalone FETCH and TRACK_STATUS in Rust and the TRACK re-check and setup announces in JS, all -fixed in #4039. Behavior stays the same: no wire or public API change, and -the existing auth tests are the regression net. +fixed in #4039, as were the joining FETCH and the TRACK_STATUS and TRACK_INFO +answers while they are written. Behavior stays the same: no wire or public API +change, and the existing auth tests are the regression net. ## Plan @@ -28,8 +29,7 @@ Today `auth::Gate` is built inside each handler: the IETF publisher's SUBSCRIBE, standalone FETCH, and TRACK_STATUS; the IETF subscriber; and the lite publisher and subscriber. JS checks `#denied` and `#watch` per handler. A joining FETCH carries its subscription's namespace and holds its own gate, -since the cache it answers from outlives the subscription. JS TRACK_INFO is -re-checked after it resolves but not while its answer is written. +since the cache it answers from outlives the subscription. ## Related diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 8f206649cb..372ff9820e 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -4688,6 +4688,8 @@ mod serve_tests { .await .unwrap_or_else(|_| panic!("{version}: still answering after the grant narrowed")); assert!(matches!(res, Err(Error::Unauthorized)), "{version}: {res:?}"); + // The sink logs a write only once its gate lets it through, so even the parked + // first write never landed. assert!( session.log.writes.lock().unwrap()[mark..].is_empty(), "{version}: the answer went out after the grant narrowed" From 8016021cf2154ac47f7ef16548b2aecd48ee8290 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 9 Oct 2026 13:39:22 -0700 Subject: [PATCH 48/53] test(interop): give the C++ cells their per-cell token URL Co-Authored-By: Claude Opus 5.5 --- test/interop/interop.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/interop/interop.sh b/test/interop/interop.sh index 19a32be88c..d04961cd13 100755 --- a/test/interop/interop.sh +++ b/test/interop/interop.sh @@ -568,7 +568,7 @@ run_publisher() { ffmpeg_h264 | "$GO_INTEROP" publish --url "$url" --broadcast "$broadcast" ;; cpp) - ffmpeg_h264 | "$CPP_INTEROP" publish --url "$URL" --broadcast "$broadcast" + ffmpeg_h264 | "$CPP_INTEROP" publish --url "$url" --broadcast "$broadcast" ;; js) # Headless Chromium encodes its own H.264 from a fake camera via @@ -625,7 +625,7 @@ run_subscriber() { "$GO_INTEROP" subscribe --url "$url" --broadcast "$broadcast" --timeout "$TIMEOUT" ;; cpp) - "$CPP_INTEROP" subscribe --url "$URL" --broadcast "$broadcast" --timeout "$TIMEOUT" + "$CPP_INTEROP" subscribe --url "$url" --broadcast "$broadcast" --timeout "$TIMEOUT" ;; c) "$C_INTEROP" subscribe --url "$url" --broadcast "$broadcast" --timeout "$TIMEOUT" From e54d5fc4d799fba0fa1fadba28775ca230f9313e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 9 Oct 2026 17:06:29 -0700 Subject: [PATCH 49/53] fix(net): hold a lite announce restart to the subscribe limit #5087's restart_announce attached the replacement route directly, skipping the grant limit that start and update apply through Announced::offer. It now goes through offer, so a restart outside the limit is withheld. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/lite/subscriber.rs | 52 ++++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index c55c6a7c77..a597b01907 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -460,13 +460,8 @@ impl Subscriber { tracing::debug!(route = %self.log_path(&path), hops = hops.len(), "restart"); let route = self.announced_route(&path, hops, cost, link_cost, responder_origin, announced); - let Ok(dynamic) = self.origin.dynamic(&path, route.clone()) else { - announced.declined(path); - return Ok(false); - }; - announced.attach(path, route, dynamic); - - Ok(true) + // Held to the limit like a start: a restart outside it is withheld, dropping the old instance. + Ok(announced.offer(self, path, route)) } /// The full chain of an advertisement replacing a live one, or `None` when it is a @@ -3363,6 +3358,49 @@ mod tests { assert_eq!(route.cost, crate::origin::Cost::new(5).charged(0)); } + /// A restart is held to the subscribe limit like a start: one the limit no longer + /// covers is withheld, ending the old instance rather than attaching the new one. + #[moq_net_sim::test] + async fn a_restart_outside_the_limit_is_withheld() { + let (mut subscriber, consumer) = restart_subscriber(SinkSession::new(Default::default())); + let auth = subscriber.auth.clone(); + + let mut announced = Announced::default(); + let path = Path::new("room/host").to_owned(); + subscriber + .start_announce( + path.clone(), + None, + crate::Hops::new(), + crate::origin::Cost::UNKNOWN, + 0, + Some(crate::Hop::new(7).unwrap()), + &mut announced, + ) + .unwrap(); + let mut cursor = consumer.announced(); + cursor.assert_next_active("room/host"); + + auth.authorize(&crate::auth::Grant { + publish: Default::default(), + subscribe: [crate::Pattern::subtree("other").unwrap()].into_iter().collect(), + expires: None, + }); + announced.restart(&path, None); + let attached = subscriber + .restart_announce( + path.clone(), + crate::Hops::new(), + crate::origin::Cost::UNKNOWN, + 0, + Some(crate::Hop::new(7).unwrap()), + &mut announced, + ) + .unwrap(); + assert!(!attached, "a restart outside the limit was attached"); + cursor.assert_next_ended("room/host"); + } + /// An announce stream that dies without an explicit `ended` retracts the route /// as promptly as an explicit retraction: a route into a dead session must not /// stay announced. From 32036675937aed650b30065a513f6eab7df49e4f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 9 Oct 2026 17:48:43 -0700 Subject: [PATCH 50/53] fix(net): keep the announce cursor when the grant grows A growing grant swapped the announce cursor for a fresh one, dropping what the old one had pending for routes the peer held: an instance replaced just then (a newer epoch, or an anonymous source still waiting out its hold) was never restarted. Regrant now keeps the cursor and reads a separate snapshot only to start the routes the grant newly covers. Also gives the test PublisherConfig from main the auth fields. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/lite/publisher.rs | 125 +++++++++++++++++++------------ 1 file changed, 76 insertions(+), 49 deletions(-) diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index e06a09f5d7..1913501ae8 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -258,8 +258,10 @@ impl Publisher { announced: &mut announce::Consumer, self_origin: Hop, version: Version, + auth: Option, ) -> Result<(), Error> { let mut run = AnnounceRun::new(crate::PathOwned::default(), self_origin, version); + run.auth = auth; kio::wait(|waiter| run.poll(stream, origin, announced, waiter)).await } } @@ -911,75 +913,50 @@ impl AnnounceRun { &mut self, stream: &mut Stream, origin: &origin::Consumer, - announced: &mut announce::Consumer, permit: crate::auth::Permit, ) -> Result<(), Error> { let grew = !self.permit.covers(&permit); self.permit = permit; + let revoked: Vec<_> = self + .live + .keys() + .filter(|suffix| !self.permitted(suffix)) + .cloned() + .collect(); + for suffix in revoked { + let absolute = origin.absolute(&suffix); + tracing::debug!(route = %absolute, "announce no longer authorized"); + self.retract(stream, suffix, &absolute)?; + } if !grew { - let revoked: Vec<_> = self - .live - .keys() - .filter(|suffix| !self.permitted(suffix)) - .cloned() - .collect(); - for suffix in revoked { - let absolute = origin.absolute(&suffix); - tracing::debug!(route = %absolute, "announce no longer authorized"); - self.retract(stream, suffix, &absolute)?; - } return Ok(()); } - // A route withheld earlier left no trace, so take a fresh cursor: its initial - // burst is the origin's current state, which is then diffed against what the - // peer holds. - *announced = origin.announced(); + // A route withheld earlier left no trace on the cursor, so read the origin's current + // state from a fresh one and start what the grant now covers. The cursor itself stays: + // whatever it has pending for the routes the peer holds (an update, a restart, an end + // still waiting out its hold) arrives through the loop as usual. + let mut snapshot = origin.announced(); let mut current: HashMap = HashMap::new(); - let mut restarted = std::collections::HashSet::new(); - while let Some(event) = announced.try_next() { + while let Some(event) = snapshot.try_next() { match event { - announce::Event::Start(update) | announce::Event::Update(update) => { - current.insert(update.prefix, update.route); - } - // Only a prefix this cursor already delivered restarts, so never in the - // initial burst, but one may follow it before the drain ends. - announce::Event::Restart(update) => { - restarted.insert(update.prefix.clone()); + announce::Event::Start(update) | announce::Event::Update(update) | announce::Event::Restart(update) => { current.insert(update.prefix, update.route); } announce::Event::End(update) => { - restarted.remove(&update.prefix); current.remove(&update.prefix); } } } - let stale: Vec<_> = self - .live - .keys() - .filter(|suffix| !current.contains_key(*suffix) || !self.permitted(suffix)) - .cloned() - .collect(); - for suffix in stale { - let absolute = origin.absolute(&suffix); - self.retract(stream, suffix, &absolute)?; - } - for (suffix, route) in current { - if !self.permitted(&suffix) { + if self.live.contains_key(&suffix) || !self.permitted(&suffix) { continue; } let absolute = origin.absolute(&suffix); - // Still held: bring its metadata up to date in case the old cursor had an - // update pending. - match self.outgoing(&route, &absolute) { - Some((hops, cost)) if restarted.contains(&suffix) && self.live.contains_key(&suffix) => { - self.restart(stream, suffix, hops, cost, route.epoch, &absolute)?; - } - Some((hops, cost)) => self.advertise(stream, suffix, hops, cost, route.epoch, &absolute)?, - None => {} + if let Some((hops, cost)) = self.outgoing(&route, &absolute) { + self.advertise(stream, suffix, hops, cost, route.epoch, &absolute)?; } } Ok(()) @@ -1308,7 +1285,7 @@ impl AnnounceRun { && let Poll::Ready(permit) = auth.poll_permit(crate::auth::Direction::Publish, &mut self.epoch, waiter) && self.permit != permit { - self.regrant(stream, origin, announced, permit)?; + self.regrant(stream, origin, permit)?; continue; } @@ -2450,6 +2427,11 @@ mod announce_test { /// [`harness`] on another version. async fn harness_on(version: Version) -> Harness { + harness_with(version, None).await + } + + /// [`harness_on`], announcing under `auth`'s grant. + async fn harness_with(version: Version, auth: Option) -> Harness { let origin = Hop::new(1).unwrap().produce(); let announcement = origin .announce( @@ -2468,7 +2450,7 @@ mod announce_test { let task = moq_net_sim::spawn(async move { let mut announced = consumer.announced(); let self_origin = consumer.hop(); - TestPublisher::run_announce(&mut stream, &consumer, &mut announced, self_origin, version).await + TestPublisher::run_announce(&mut stream, &consumer, &mut announced, self_origin, version, auth).await }); settle().await; @@ -2630,6 +2612,46 @@ mod announce_test { h.assert_idle(); } + /// A grant that grows while a new instance replaces an advertised one still restarts it, + /// whether the instance carries a newer epoch or is an anonymous source: the regrant takes + /// in what the old cursor had pending rather than dropping it. + #[moq_net_sim::test] + async fn a_restart_pending_when_the_grant_grows_still_restarts() { + // The peer's ceiling: what it may subscribe to is what we may announce to it. + let grant = |paths: &[&str]| crate::auth::Grant { + publish: Default::default(), + subscribe: paths + .iter() + .map(|path| crate::Pattern::subtree(path).unwrap()) + .collect(), + expires: None, + }; + for anonymous in [false, true] { + let auth = crate::auth::Handle::new(false); + auth.authorize(&grant(&["cam"])); + let mut h = harness_with(Version::Lite07, Some(auth.clone())).await; + + // Same chain and cost, so only the instance changed. Both land before the loop polls. + let route = crate::origin::Route::default().with_hops(pub_hops()).with_cost(7); + let epoch = (!anonymous).then(crate::Epoch::mint); + let _newer = match &epoch { + Some(epoch) => h.origin.announce("cam", route.with_epoch(epoch.clone())), + None => h.origin.clone().peer().announce("cam", route), + } + .unwrap(); + auth.authorize(&grant(&["cam", "other"])); + moq_net_sim::sleep(crate::origin::DEFAULT_UPDATE_HOLD).await; + settle().await; + match h.wire.take_announces().as_slice() { + [lite::AnnounceBroadcast::Restart { id: 0, epoch: sent, .. }] => { + assert_eq!(*sent, epoch, "anonymous={anonymous}") + } + other => panic!("expected a restart (anonymous={anonymous}), got {other:?}"), + } + h.assert_idle(); + } + } + /// Costs past the wire ceiling clamp to the same value, so moving between them /// sends nothing. #[moq_net_sim::test] @@ -2672,7 +2694,7 @@ mod announce_test { let task = moq_net_sim::spawn(async move { let mut announced = consumer.announced(); let self_origin = consumer.hop(); - TestPublisher::run_announce(&mut stream, &consumer, &mut announced, self_origin, VERSION).await + TestPublisher::run_announce(&mut stream, &consumer, &mut announced, self_origin, VERSION, None).await }); settle().await; @@ -4584,6 +4606,7 @@ mod tests { &mut announced, self_origin, Version::Lite01, + None, )); assert!(futures::poll!(run.as_mut()).is_pending()); @@ -4618,6 +4641,7 @@ mod tests { &mut announced, self_origin, Version::Lite01, + None, )); assert!(futures::poll!(run.as_mut()).is_pending()); drop(cam); @@ -5166,6 +5190,9 @@ mod tests { goaway, peer_hop: None, subscriptions: Default::default(), + auth: crate::auth::Handle::new(false), + peer_grant: Default::default(), + client: false, }); let mut script = Vec::new(); From ce47554ec0e02de9579816366673f7857292f1af Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 10 Oct 2026 06:09:34 -0700 Subject: [PATCH 51/53] quest: record the auth line landing in quest-flat-lines Co-Authored-By: Claude Opus 5.5 --- quest/m1/quest-flat-lines.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/quest/m1/quest-flat-lines.md b/quest/m1/quest-flat-lines.md index ad5aaf46c4..0d66042cfe 100644 --- a/quest/m1/quest-flat-lines.md +++ b/quest/m1/quest-flat-lines.md @@ -58,12 +58,10 @@ Remaining: - Land each line's umbrella PR with `/quest-complete`, then delete its branch. Landed so far: #4403 wildcard, #4034 archive (with #4255 folded in), #4080 obs-moq-video, #4162 audio-jitter-target, #4180 transport-upgrade, #4437 - rs2ts (with #4438 folded in), #4640 tstd, and #4653 test-flakes-2. As of + rs2ts (with #4438 folded in), #4640 tstd, #4653 test-flakes-2, and #4039 + auth (UNAUTHORIZED moved to 0x3B). As of 2026-10-08 four lines remain, and all four conflict with `main` and need it merged in again. - - #4039 auth: its `wip-version.md` child landed on the line (#5004). The - line's UNAUTHORIZED stream code collides with `main`'s NOT_FETCHABLE - (0x3A) and moves to 0x3B before it lands. - #4079 cpp: `main` merged in again and the `uniffi-bindgen-cpp` pin bumped to `v0.11.0-kixelated.4`, whose generated files end with a newline. - #4519 ffi-shape: lands now (maintainer, 2026-10-09) with the C++ port From 8fdf05d6b9ac411f181382a6cb0de8634631b3ba Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 10 Oct 2026 07:04:50 -0700 Subject: [PATCH 52/53] docs(js): name moq-lite-07-wip as the AUTH-capable lite version Co-Authored-By: Claude Opus 5.5 --- js/net/src/connection/established.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/net/src/connection/established.ts b/js/net/src/connection/established.ts index 5c7db2aa2d..41ff35bfb3 100644 --- a/js/net/src/connection/established.ts +++ b/js/net/src/connection/established.ts @@ -29,7 +29,7 @@ export interface Established { /** * The tokens this side presented and the grant they earned, plus the tokens the peer - * presents. On moq-lite-06, and on moq-transport draft-17+ when both sides negotiate + * presents. On moq-lite-07-wip, and on moq-transport draft-17+ when both sides negotiate * MoQ Auth, each side presents its connection's credential right after setup. * Otherwise the grant stays undefined. */ From 5eb399ffc5fdb9fb0980fe11c3b49c2821feab48 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 10 Oct 2026 09:13:24 -0700 Subject: [PATCH 53/53] fix(net): refuse trailing bytes in IETF AUTH replies A Rust IETF presenter decoded AUTH_OK and AUTH_ERROR bodies without checking they were consumed, unlike every framed message. Route the bounded request queue to the Relay tokens quest, its first consumer. Co-Authored-By: Claude Opus 5.5 --- quest/m1/auth/relay-refresh.md | 6 +++++ rs/moq-net/src/ietf/auth.rs | 47 ++++++++++++++++++++++++++++++---- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/quest/m1/auth/relay-refresh.md b/quest/m1/auth/relay-refresh.md index 00812358dc..f9bba19257 100644 --- a/quest/m1/auth/relay-refresh.md +++ b/quest/m1/auth/relay-refresh.md @@ -68,6 +68,12 @@ expiry, an expiry that leaves the union intact ends only that token, and the live session and forgotten. Its request-token half belongs to [Request tokens](/quest/m1/auth/request-token.md); whichever lands first adds the handle. +- Bound pending tokens: `auth::Handle::requests()` hands out an unbounded + queue today, and a closed AUTH stream leaves its request queued, so a peer + that finishes AUTH streams faster than the relay verifies them grows it + without limit (Codex security review on #4039). This quest is the first + consumer, so bound it per session here and refuse what overflows; dropping a + `Request` already refuses its token. - Docs: `doc/bin/relay/auth.md` gains an "in-band tokens" section beside revalidation stating that grants union, that a token needs the admitted root, what an expiry does to the union today, and that the grant's expiry diff --git a/rs/moq-net/src/ietf/auth.rs b/rs/moq-net/src/ietf/auth.rs index 98664fed46..8f5fc3dc17 100644 --- a/rs/moq-net/src/ietf/auth.rs +++ b/rs/moq-net/src/ietf/auth.rs @@ -323,12 +323,11 @@ impl Present { Next::Reply(Err(err)) => return err, }; - let mut data = body.decoder(self.version); match id { AuthOk::ID => { - let ok = match AuthOk::decode_msg(&mut data, self.version) { + let ok: AuthOk = match decode_reply(&body, self.version) { Ok(ok) => ok, - Err(err) => return err.into(), + Err(err) => return err, }; let now = self.runtime.now(); // The expiry is the peer's number: one past the local clock's range is @@ -348,9 +347,9 @@ impl Present { ); } AuthError::ID => { - let refused = match AuthError::decode_msg(&mut data, self.version) { + let refused: AuthError = match decode_reply(&body, self.version) { Ok(refused) => refused, - Err(err) => return err.into(), + Err(err) => return err, }; let err = from_code(refused.code); tracing::warn!(%err, code = refused.code, reason = %refused.reason, "auth token refused"); @@ -367,6 +366,16 @@ impl Present { } } +/// Decode a reply that must fill its whole body, as every framed message does. +fn decode_reply(body: &super::Body, version: Version) -> Result { + let mut data = body.decoder(version); + let msg = T::decode_msg(&mut data, version)?; + if !data.is_empty() { + return Err(Error::WrongSize); + } + Ok(msg) +} + /// Read one `[type][size][body]` message, or `None` once the peer finished the stream. async fn read_message( stream: &mut Stream, @@ -672,6 +681,34 @@ mod tests { } } + /// A reply with bytes past its message is refused, like any other framed message. + #[test] + fn replies_with_trailing_bytes_are_refused() { + let ok = AuthOk { + publish: patterns(&["room"]), + subscribe: Patterns::new(), + expires: None, + }; + let refused = AuthError { + code: UNAUTHORIZED, + reason: "expired".to_string(), + }; + let body = |msg: Vec, extra: &[u8]| super::super::Body(Bytes::from([&msg[..], extra].concat())); + + let exact = encode_msg(&ok, VERSION).unwrap(); + assert_eq!(decode_reply::(&body(exact.clone(), &[]), VERSION).unwrap(), ok); + let err = decode_reply::(&body(exact, &[0]), VERSION).unwrap_err(); + assert!(matches!(err, Error::WrongSize), "{err:?}"); + + let exact = encode_msg(&refused, VERSION).unwrap(); + assert_eq!( + decode_reply::(&body(exact.clone(), &[]), VERSION).unwrap(), + refused + ); + let err = decode_reply::(&body(exact, &[0]), VERSION).unwrap_err(); + assert!(matches!(err, Error::WrongSize), "{err:?}"); + } + /// NOT_SUPPORTED is the acceptor unable to tell a grant, never a refusal. #[test] fn not_supported_is_unsupported() {