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/bin/relay/auth.md b/doc/bin/relay/auth.md index 1c60ff2b66..c503679d30 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -39,10 +39,15 @@ nothing is admitted because the server was down. A grant that names nothing refuses, and so does one already expired: expiry is exact, with no grace for clock skew, so keep the auth server's clock in sync. -**Revalidate.** On the grant's cadence the relay asks again. A grant that no -longer covers what the session holds, or that changes `root`, `mounts`, -`peer`, or `upstream`, closes the session; it is not resized in place. A -changed `tier` keeps the session and moves its stats from then on. A 401 or +**Revalidate.** On the grant's cadence the relay asks again. A grant with the +same `root` and `mounts` resizes 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: what falls outside a narrower grant resets with `Unauthorized`, the +broadcasts the session published outside it 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`, `mounts`, `peer`, or `upstream` closes the session. A changed +`tier` keeps the session and moves its stats from then on. A 401 or 403 closes it. Any other failure retries with backoff and the session lives until `expires`, so an outage always has the bound the server chose. A grant with `revalidate` must set `expires` for that reason. diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index e9c9d4894a..743272c3b6 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -141,6 +141,24 @@ A subscriber watching under a root sees advertisements named relative to that root. When several routes advertise one prefix, each reader sees the best route its scope can use. +## Authorization + +On moq-lite 07 (`moq-lite-07-wip`, opt-in) each side presents a token on its +own Auth stream and learns what it may publish and subscribe to: a union of +[path patterns](#path-patterns), delivered exactly as issued rather than widened +to a prefix. 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 added without reconnecting; the session's scope is the +union of every open token's grant. + +A subscription or fetch that loses access resets with the `UNAUTHORIZED` stream +code and the session stays up. A client that publishes outside its grant closes +the session with `UNAUTHORIZED`, naming the path. moq-transport carries the same +exchange on draft-17+ through the [MoQ Auth extension](/draft/moq-auth), limited +to namespace prefixes. Older versions have no grant; the URL token keeps working +everywhere. + ## Subscriptions A subscriber names a broadcast and track. Delivery starts at the oldest group diff --git a/doc/concept/standard.md b/doc/concept/standard.md index 1ecede7dda..071a330b6d 100644 --- a/doc/concept/standard.md +++ b/doc/concept/standard.md @@ -39,6 +39,7 @@ 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, +[auth](/draft/moq-auth) to tell each peer what it may publish and subscribe to, [active-count](/draft/moq-active-count) to count the `NAMESPACE` messages before a `SUBSCRIBE_NAMESPACE` is caught up, and [probe](/draft/moq-probe) for bandwidth estimation. diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index e3c41b51c2..4286e8dc09 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -43,6 +43,7 @@ broadcast.announce(); - **Timing is per track, with no default.** A track that declares a `timescale` carries a `timestamp` on every frame; one without is [untimed](/concept/moq-lite#subscriptions) and its frames carry none. Unlike Rust, omitting it means untimed. Nothing stamps a frame for you: the publisher passes the timestamp, and the `Timed` type (`{ value, at }`) carries one into the `@moq/json` and `@moq/flate` producers. - **Subscriber staleness is `maxDelay`.** It is media time. Passing the old `maxAge` key throws a `TypeError` naming `maxDelay`. Publisher retention is the separate `Track.Info.maxAge`. - **Hidden paths** stay out of discovery unless the announce request opts in. See [hidden broadcasts](/concept/moq-lite#hidden-broadcasts). +- **Authorization is in band.** 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+ with the [MoQ Auth extension](/draft/moq-auth), `auth.grant` watches what the relay lets this side publish and subscribe to, and `auth.add(token)` presents another token without reconnecting. Publishing outside the grant closes the connection with `SessionCode.Unauthorized`, naming the path. On moq-lite, a subscription the grant stops covering resets with `StreamCode.Unauthorized` and the session stays up. - **A graceful close waits.** `await connection.close()` withdraws announcements and gives finished tracks up to one second. `abort()` ends immediately. Examples: diff --git a/doc/lib/rs/moq-net.md b/doc/lib/rs/moq-net.md index e28a622d05..c4ead51ffd 100644 --- a/doc/lib/rs/moq-net.md +++ b/doc/lib/rs/moq-net.md @@ -22,6 +22,7 @@ What you use it for, beyond what the concept page already describes: - **Publish only while someone is watching.** `demand()` on a track, group, or broadcast says whether a subscriber is attached, which is how capture and transcode skip work nobody asked for. Holding a broadcast consumer is not demand. Holding a track consumer is, so drop one you are not reading. A shared fetch stays up until its last reader leaves. - **You drive the session, or `moq-tokio` does.** `connect` and `accept` return a session plus a driver that never reads the clock itself. `moq_net::time::run` polls it on tokio or in the browser. `moq-tokio` and `moq-wasm` hide that. A custom transport implements `moq_net::transport::poll`. - **A session caps what its peer can hold.** `session::Limits` (via `Client::with_limits` and `Server::with_limits`) bounds announces and subscriptions per session; the defaults suit a relay mesh, so lower them for untrusted peers. Past either, the session closes with `TOO_MANY_REQUESTS`. On moq-transport drafts 14 to 16 they also size the request-ID window, which every request counts against, so very low limits can starve it. Peer-declared lengths are capped before they are buffered. +- **Authorization is in band.** On moq-lite 07 (`moq-lite-07-wip`, opt-in) and on moq-transport draft-17+ with the [MoQ Auth extension](/draft/moq-auth), `session.auth()` presents tokens without reconnecting and watches the grant the peer sent back. A server verifies tokens itself by taking `handshake.auth().requests()` before `ok()`. `authorize(&grant)` narrows or widens a live session on any version: what falls outside resets with `Unauthorized` and the session stays up. A client that publishes outside its grant closes the session, naming the path. - **A graceful close waits.** `session.close().await` withdraws announcements and gives finished tracks up to one second to deliver. `abort` ends immediately. IETF drafts 14 through 16 send withdrawals without waiting. ```bash diff --git a/drafts/draft-lcurley-moq-auth.md b/drafts/draft-lcurley-moq-auth.md new file mode 100644 index 0000000000..af2674cfdb --- /dev/null +++ b/drafts/draft-lcurley-moq-auth.md @@ -0,0 +1,229 @@ +--- +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. +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. + + +# 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/drafts/draft-lcurley-moq-lite.md b/drafts/draft-lcurley-moq-lite.md index dc963c4150..cd636a8f27 100644 --- a/drafts/draft-lcurley-moq-lite.md +++ b/drafts/draft-lcurley-moq-lite.md @@ -334,6 +334,8 @@ Sent when resetting a stream (RESET_STREAM), or when refusing to receive one (ST | ------- | ------------- | ----------- | | 0x3A | NOT_FETCHABLE | The FETCH named a group delivered only as a datagram, which is never cached (see [Datagrams](#datagrams)). It has no moq-transport value; a bridge refuses the FETCH with DOES_NOT_EXIST. | | ------- | ------------- | ----------- | +| 0x3B | 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. 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. @@ -364,6 +366,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. @@ -524,6 +528,25 @@ 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 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)). +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. @@ -1365,6 +1388,84 @@ 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 Pattern (s) ... + Subscribe Count (i) + Subscribe Pattern (s) ... + Expires (i) +} +~~~ + +**Publish Pattern**: +A [pattern](#path-pattern) matching broadcast paths the opener may announce and serve. +A count of zero grants none. + +**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. + +~~~ +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. +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. + ## GROUP The GROUP message contains information about a Group, as well as a reference to the subscription being served. @@ -1448,6 +1549,7 @@ The `Message Length` describes the payload size on the wire. - 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 Message Length of every message except FRAME at 65,535 bytes. - Added the TOO_MANY_REQUESTS (0x7) session code, closing a session whose peer goes past the endpoint's bound on subscriptions or announcements. +- 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. ## moq-lite-06 @@ -1463,6 +1565,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 0x32 GROUP_TOO_LARGE: a group that grew past the publisher's cache budget is aborted. Every 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 0x3B 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. @@ -1615,6 +1718,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.test.ts b/js/net/src/auth.test.ts new file mode 100644 index 0000000000..44dec229d1 --- /dev/null +++ b/js/net/src/auth.test.ts @@ -0,0 +1,396 @@ +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, 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"; +import * as Path from "./path.ts"; +import { Writer } from "./stream.ts"; +import { withTimeout } from "./util/timeout.ts"; +import { wireOf } from "./wire.ts"; + +const url = new URL("https://localhost:4443/test"); + +/** The next route event, skipping the live marker. */ +async function nextRoute(announced: { + next(): Promise; +}): Promise | undefined> { + for (;;) { + const event = await announced.next(); + if (event?.kind !== "live") return event as Exclude | undefined; + } +} + +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-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 }); + + // 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.abort(); + server.abort(); + }); + + 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.abort(); + server.abort(); + }); + + 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.abort(); + }); + + 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 nextRoute(announced); + expect(first?.prefix).toBe(Path.from("a/x")); + expect(first?.kind).toBe("start"); + + issued[0]?.revoke(SessionCode.Unauthorized, "expired"); + const second = await nextRoute(announced); + expect(second?.prefix).toBe(Path.from("a/x")); + expect(second?.kind).toBe("end"); + + // 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 nextRoute(announced); + expect(third?.kind).toBe("start"); + + let closed = false; + void transport.closed.then(() => { + closed = true; + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(closed).toBe(false); + + announced.close(); + client.abort(); + server.abort(); + }); + + 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.abort(); + server.abort(); + }); + + 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.abort(); + const closed = client.auth.grant.peek(); + expect(closed?.publish.size).toBe(0); + expect(closed?.subscribe.size).toBe(0); + server.abort(); + }); + + test("an acceptor that ends a grant sees its stream close", async () => { + const { client, server } = await connect({ publish: new OriginProducer(), protocol }); + try { + 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.abort(); + server.abort(); + } + }); + + 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.abort(); + server.abort(); + }); +}); + +// 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[] = []; + 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 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({ + 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.abort(); + server.abort(); + }); +}); + +// moq-lite carries patterns, so every grant arrives exactly as issued. +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/**"], []], + 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) })); + } + })(); + + 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.abort(); + server.abort(); +}); + +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); + client.abort(); + 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(); + 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_07_WIP, + }); + 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]) }; + 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.abort(); + server.abort(); + } +}); diff --git a/js/net/src/auth.ts b/js/net/src/auth.ts new file mode 100644 index 0000000000..a3f371426c --- /dev/null +++ b/js/net/src/auth.ts @@ -0,0 +1,125 @@ +/** + * In-band authorization: present tokens to the peer and learn what they grant. + * + * 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 + * 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 + */ + +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/auth_session.ts b/js/net/src/auth_session.ts new file mode 100644 index 0000000000..ac9c3824c6 --- /dev/null +++ b/js/net/src/auth_session.ts @@ -0,0 +1,483 @@ +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(); + this.#recompute(); + 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. Close our side too, which is what settles its Issued.closed. + result = token.isAnswered ? null : new Unsupported(); + stream.close(); + 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 89eedc2812..92e836ffbf 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; }; /** @@ -67,6 +69,7 @@ async function acceptInner( const wiring: SessionProps = { discovery: props.discovery ?? true, publish: props.publish, + client: false, }; if (protocol === Ietf.ALPN.DRAFT_22) { @@ -115,7 +118,7 @@ async function acceptAlpn( version: Ietf.IetfVersion, wiring: SessionProps, ): Promise { - const { control, early, solicit, hidden, cluster } = await exchangeSetup(transport, version, "moq-lite-js"); + const { control, early, solicit, hidden, cluster, auth } = await exchangeSetup(transport, version, "moq-lite-js"); return new Ietf.Connection({ ...wiring, @@ -127,6 +130,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 980fbf40de..a681d42a93 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) { @@ -361,7 +364,7 @@ async function handshakeAlpn( version: Ietf.IetfVersion, wiring: SessionProps, ): Promise { - const { control, early, solicit, hidden, cluster } = await exchangeSetup(session, version, "moq-lite-js"); + const { control, early, solicit, hidden, cluster, auth } = await exchangeSetup(session, version, "moq-lite-js"); return new Ietf.Connection({ ...wiring, @@ -373,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 0c6200bda2..41ff35bfb3 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,14 @@ 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-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. + */ + 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/connection/handshake.ts b/js/net/src/connection/handshake.ts index 81bb2052a9..04da453389 100644 --- a/js/net/src/connection/handshake.ts +++ b/js/net/src/connection/handshake.ts @@ -12,7 +12,8 @@ import { Reader, Stream, UnexpectedEnd, Writer } from "../stream.ts"; * Returns the control stream, the uni streams that arrived before it (see * {@link receiveSetup}), 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. @@ -27,12 +28,14 @@ export async function exchangeSetup( 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. @@ -52,6 +55,7 @@ export async function exchangeSetup( solicit: received.solicit, hidden: received.hidden, cluster: { self, peer: received.cluster }, + auth: received.auth, }; } @@ -79,6 +83,7 @@ async function receiveSetup( solicit: boolean | undefined; hidden: boolean; cluster: Hop | undefined; + auth: boolean; }> { const uniReader = transport.incomingUnidirectionalStreams.getReader() as ReadableStreamDefaultReader< ReadableStream @@ -120,5 +125,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/error.test.ts b/js/net/src/error.test.ts index 210d39efa7..c6d5756c2a 100644 --- a/js/net/src/error.test.ts +++ b/js/net/src/error.test.ts @@ -230,6 +230,7 @@ test("the code tables match the spec", () => { StreamCode.Unroutable, StreamCode.FrameTooLarge, StreamCode.TimestampMismatch, + StreamCode.Unauthorized, ]; for (const code of Object.values(StreamCode)) { if (assignedLite.includes(code)) { @@ -248,6 +249,7 @@ test("the code tables match the spec", () => { expect(Number(StreamCode.Unroutable)).toBe(0x36); expect(Number(StreamCode.FrameTooLarge)).toBe(0x38); expect(Number(StreamCode.TimestampMismatch)).toBe(0x39); + expect(Number(StreamCode.Unauthorized)).toBe(0x3b); // 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 f13d7dc536..c72b5236df 100644 --- a/js/net/src/error.ts +++ b/js/net/src/error.ts @@ -96,6 +96,8 @@ export const StreamCode = Object.freeze( FrameTooLarge: 0x38 as StreamCode, /** The broadcast is neither announced nor served, so there is no route to it. */ Unroutable: 0x36 as StreamCode, + /** The grant does not cover this request, or no longer does. The session stays up. */ + Unauthorized: 0x3b as StreamCode, /** A group grew past its cache budget and was aborted. */ GroupTooLarge: 0x32 as StreamCode, /** A frame's timedness or timestamp doesn't match its track's timescale. */ @@ -370,6 +372,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. @@ -463,6 +475,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); +} + /** * The session's close as the error it ends everything with, carrying the peer's code. A clean * close code is still an error here: whatever the close cut off did not end. 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 6cf0994973..37beef5614 100644 --- a/js/net/src/ietf/connection.ts +++ b/js/net/src/ietf/connection.ts @@ -1,16 +1,19 @@ import { type Getter, Once, 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 { Drain } from "../connection/goaway.ts"; import { type Probe, type Stats, transportStats } from "../connection/stats.ts"; import { type Transport, transportOf } from "../connection/transport.ts"; import { error, fromClose, ProtocolViolation, SessionCode, 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 { withTimeout } from "../util/timeout.ts"; import { registerWire } from "../wire.ts"; import { ControlStreamAdapter, NativeSession, RequestWindowError, type Session } from "./adapter.ts"; +import { AuthMessage, supported as authSupported, IetfAuthWire } from "./auth.ts"; import * as Cluster from "./cluster.ts"; import { Fetch, FetchHeader } from "./fetch.ts"; import { GoAway } from "./goaway.ts"; @@ -54,10 +57,19 @@ export class Connection implements Established { /** moq-transport has no PROBE, so this stays empty; see {@link Established.probe}. */ readonly probe: Getter = new Signal({}); + /** 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; + // The established WebTransport session. #quic: WebTransport; - // Whether this side opened the session. Only a server may name a redirect. + // Whether this side opened the session. Only a server may name a redirect, and only + // the dialing side fails loud on a publication its grant does not cover. #client: boolean; // Session abstraction: adapter for v14-v16, native for v17. @@ -106,6 +118,7 @@ export class Connection implements Established { solicit, hidden = false, cluster, + auth = false, early = [], requestWindow, }: { @@ -131,6 +144,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; /** Uni streams that arrived before the peer's SETUP, type unread (v17+). */ early?: Reader[]; /** Requests the peer may hold open on drafts 14 to 16, the window our SETUP advertised (default `REQUEST_WINDOW`). */ @@ -165,6 +180,25 @@ 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, @@ -172,6 +206,8 @@ export class Connection implements Established { requiresSolicitation: solicit ?? false, hidden, cluster, + grant: this.#auth.grant, + ready: this.#auth.setupAnswered(), }); this.#solicit = solicit; this.#cluster = cluster; @@ -182,6 +218,7 @@ export class Connection implements Established { hidden, solicit, goaway: this.#goaway, + grant: this.#auth.grant, }); registerWire(this, { consume: (path) => this.#subscriber.consume(path), goaway: this.#goaway }); @@ -215,6 +252,8 @@ export class Connection implements Established { this.#closed = true; this.#publisher.close(); + this.#auth.close(); + // Before the session, whose own close would send a clean code first. try { this.#quic.close(info); @@ -237,12 +276,16 @@ export class Connection implements Established { // solicited; otherwise it pushes PUBLISH_NAMESPACE. On draft-16 and later a // SUBSCRIBE_NAMESPACE stream is filled either way. A NAMESPACE is discovery, // not a second route. - await Promise.all([ + const tasks = [ this.#runBidis(), this.#runUnis(early), this.#runDatagrams(), 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); @@ -384,6 +427,12 @@ export class Connection implements Established { await this.#subscriber.runPublishNamespace(msg, stream); break; } + case BigInt(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 BigInt(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/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/index.ts b/js/net/src/ietf/index.ts index f475cadc0b..269f01b1b9 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 "./datagram.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 0ddfc08765..7ec969bbb4 100644 --- a/js/net/src/ietf/parameters.ts +++ b/js/net/src/ietf/parameters.ts @@ -20,6 +20,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; // Unknown SETUP options may repeat, including GREASE (draft-21 section 9.1). diff --git a/js/net/src/ietf/publish.ts b/js/net/src/ietf/publish.ts index 19b1c94cd6..4f48c79156 100644 --- a/js/net/src/ietf/publish.ts +++ b/js/net/src/ietf/publish.ts @@ -216,6 +216,7 @@ export class PublishError { /** PUBLISH_DONE status codes this implementation distinguishes. Stable across drafts 14 through 22. */ export const PublishDoneStatus = { INTERNAL_ERROR: 0x0, + UNAUTHORIZED: 0x1, TRACK_ENDED: 0x2, /** Removed in draft-20, where 0x3 is unassigned. */ SUBSCRIPTION_ENDED: 0x3, diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index 247e088e3d..265c2d38da 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -1,4 +1,6 @@ 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 { Withdrawal } from "../connection/withdrawal.ts"; import * as DatagramStream from "../datagram_stream.ts"; @@ -254,6 +256,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. * @@ -266,6 +277,8 @@ export class Publisher { requiresSolicitation, hidden = false, cluster, + grant = new Signal(undefined), + ready = Promise.resolve(), }: { /** The WebTransport session, for uni streams. */ quic: WebTransport; @@ -279,8 +292,14 @@ export class Publisher { hidden?: 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()); @@ -325,6 +344,10 @@ export class Publisher { if (unsupported) { refusal = { errorCode: toRequestCode("not_supported", "subscribe", version), reasonPhrase: unsupported }; + } else if (this.#denied(name)) { + // Serve only what our grant lets us publish. Checked before resolving, so a denied + // request never reaches the origin. + refusal = { errorCode: toRequestCode("unauthorized", "subscribe", version), reasonPhrase: "not granted" }; } else { try { broadcast = @@ -572,13 +595,33 @@ 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; let ended = false; try { const served = Symbol("served"); - ended = (await race([Promise.all([serving, filling]).then(() => served), requestEnded])) === served; + const end = await race([Promise.all([serving, filling]).then(() => served), requestEnded, revoked]); + if (end === "revoked") { + console.info(`subscription no longer authorized: broadcast=${name} track=${track.name}`); + unauthorized = true; + unsubscribe(); + } + ended = end === served; } catch (err: unknown) { publishError = error(err); + } finally { + disposeGrant(); } // PUBLISH_DONE waits until every stream this subscription will open is closed, as @@ -613,15 +656,17 @@ export class Publisher { version === Version.DRAFT_14 || version === Version.DRAFT_15 || version === Version.DRAFT_16 ? msg.requestId : undefined, - statusCode: - publishError instanceof UpdateFailed + statusCode: unauthorized + ? PublishDoneStatus.UNAUTHORIZED + : publishError instanceof UpdateFailed ? PublishDoneStatus.UPDATE_FAILED : publishError ? PublishDoneStatus.INTERNAL_ERROR : PublishDoneStatus.TRACK_ENDED, streamCount: BigInt(streams.opened), - reasonPhrase: - publishError instanceof UpdateFailed + reasonPhrase: unauthorized + ? "not granted" + : publishError instanceof UpdateFailed ? "update failed" : publishError ? "internal error" @@ -921,6 +966,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. * @@ -973,7 +1034,11 @@ export class Publisher { const visible = (covered: Path.Valid) => !this.#hidden || msg.hidden || !hiddenBelow(prefix, covered); const carries = (covered: Path.Valid) => visible(covered) && - (legacy ? this.#requiresSolicitation || (this.#hidden && hiddenBelow(Path.empty(), covered)) : true); + (legacy ? this.#requiresSolicitation || (this.#hidden && hiddenBelow(Path.empty(), covered)) : true) && + !this.#denied(covered); + + // Nothing is advertised before the grant it would be checked against. + await this.#ready; // Inline entries always land, and the receiver treats a repeated NAMESPACE as a // replacement, so repricing one is just sending it again, a new original @@ -1011,8 +1076,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((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(); @@ -1092,6 +1164,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; + // Keyed by path. const ns: Namespaces = { active: new Map(), refused: new Map(), offered: new Map() }; let retry = 0; @@ -1103,8 +1178,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((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(); @@ -1115,8 +1197,14 @@ export class Publisher { const updated = new Map(); for (const [covered, candidates] of advertised) { - // Only peers that declared MoQ Hidden filter unsolicited discovery. - if ((this.#hidden && hiddenBelow(Path.empty(), covered)) || candidates.length === 0) continue; + // Only peers that declared MoQ Hidden filter unsolicited discovery, and + // nothing our grant does not cover reaches the peer at all (MoQ Auth). + if ( + (this.#hidden && hiddenBelow(Path.empty(), covered)) || + this.#denied(covered) || + candidates.length === 0 + ) + continue; updated.set(covered, candidates[0]); } diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index 76d775fb26..4af30a1b72 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -1,5 +1,6 @@ -import { type GetPromise, race, Signal } from "@moq/signals"; +import { type Dispose, type GetPromise, 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 type { Drain } from "../connection/goaway.ts"; import { BroadcastCache } from "../consume.ts"; @@ -13,6 +14,7 @@ import { StreamCode, Stream as StreamError, sessionCause, + unauthorized, } from "../error.ts"; import * as netGroup from "../group.ts"; import { Cost, type Route, routesEqual, UNKNOWN_HOP } from "../hop.ts"; @@ -164,6 +166,9 @@ export class Subscriber { // Settles when the peer sends GOAWAY, repricing this session's routes to the drain cost. #goaway?: GetPromise; + // Our grant (MoQ Auth): a subscription it stops covering is cancelled. Undefined until + // the peer answers, which allows everything. + #grant?: Getter; /** Marks this subscriber's deliberate local session close. @internal */ close() { @@ -182,6 +187,7 @@ export class Subscriber { hidden = false, solicit, goaway, + grant, }: { /** The session abstraction for bidi streams and request IDs. */ session: Session; @@ -195,6 +201,8 @@ export class Subscriber { solicit?: boolean; /** Settles when the peer sends GOAWAY. */ goaway?: GetPromise; + /** The union of our tokens' grants (MoQ Auth), which bounds what we subscribe to. */ + grant?: Getter; }) { this.#session = session; this.#quic = quic; @@ -202,6 +210,7 @@ export class Subscriber { this.#hidden = hidden; this.#solicit = solicit; this.#goaway = goaway; + this.#grant = grant; // A draining peer usually stops publishing namespaces, so reprice from the signal // itself. Waiting for another message would leave the route primary until close. if (goaway) void goaway.then(() => this.#drainAnnounced()); @@ -228,6 +237,12 @@ export class Subscriber { } } + // 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); + } + /** * Whether an advertisement is ours coming back: its hop path already ran through us, so * subscribing via it would route us back to ourselves. @@ -586,6 +601,12 @@ export class Subscriber { } async #runSubscribe(broadcast: Path.Valid, request: track.Request) { + const refused = unauthorized(broadcast); + if (this.#denied(broadcast)) { + request.reject(refused); + return; + } + const requestId = await this.#session.nextRequestId(); if (requestId === undefined) { request.reject(await this.#closedSession()); @@ -696,18 +717,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 (PublishDone, track close = local - // unsubscribe); race them once so the demand loop doesn't re-subscribe each pass. + // unsubscribe, a revoked grant); race them once so the demand loop doesn't + // re-subscribe each pass. const done = race([ this.#runPublishDone(stream, subscription).then(() => publisherEnded), producer.closed.then(() => localEnded), + revoked, ]); // Serve until a terminal condition fires or the last local subscriber leaves. The unused @@ -726,7 +762,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(refused); + } 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. @@ -742,6 +783,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, subscription)) this.#timescales.delete(trackAlias); diff --git a/js/net/src/index.ts b/js/net/src/index.ts index 3d516a6071..380378b9c4 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/announce.ts b/js/net/src/lite/announce.ts index 53c52a5eae..d658c44dc7 100644 --- a/js/net/src/lite/announce.ts +++ b/js/net/src/lite/announce.ts @@ -514,7 +514,7 @@ export class AnnounceInit { async encode(w: Writer, version: Version): Promise { AnnounceInit.#guard(version); - return Message.encode(w, this.#encode.bind(this), AnnounceInit.MAX_SIZE); + return Message.encode(w, this.#encode.bind(this), { max: AnnounceInit.MAX_SIZE }); } static async decode(r: Reader, version: Version): Promise { diff --git a/js/net/src/lite/auth.test.ts b/js/net/src/lite/auth.test.ts new file mode 100644 index 0000000000..2f7dd3181d --- /dev/null +++ b/js/net/src/lite/auth.test.ts @@ -0,0 +1,120 @@ +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 } 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))); +} + +/** 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)); + }, + }), + Lite.Version.DRAFT_07, + ); + 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, 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_07)); + const msg = await AuthMessage.decode(r, Lite.Version.DRAFT_07); + 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 ok.encode(w, Lite.Version.DRAFT_07); + await err.encode(w, Lite.Version.DRAFT_07); + }); + 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. + 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_07); + expect(second).toEqual(err); + 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_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); +}); + +// 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_07)); + 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 + 0x83, + 0xe8, // expires: 1000ms, as a leading-ones varint + ]); +}); + +// 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. +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), { id: 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) => { + 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_07)).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 new file mode 100644 index 0000000000..47b7d99cf6 --- /dev/null +++ b/js/net/src/lite/auth.ts @@ -0,0 +1,210 @@ +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"; +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. Lite07+. */ +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); + } +} + +// 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 decodePatterns(r: Reader): Promise { + const count = await r.u53(); + const patterns = new Path.Patterns(); + for (let i = 0; i < count; i++) { + 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; +} + +/** 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. */ + 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 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); + await w.u53(expires); + } + + static async #decode(r: Reader): Promise { + const publish = await decodePatterns(r); + const subscribe = await decodePatterns(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), { id: AuthOk.id }); + } + + 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. Encodes its own type. */ +export class AuthError { + static id = 1; + + /** 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), { id: AuthError.id }); + } + + 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; + +/** 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 AuthOk.id: + return AuthOk.decode(r, version); + case AuthError.id: + return AuthError.decode(r, version); + default: + throw new Error(`unknown auth reply type: ${typ}`); + } +} + +/** The moq-lite binding of the token lifecycle: one Auth Stream per token. @internal */ +export class LiteAuthWire implements AuthWire { + #quic: WebTransport; + #version: Version; + + constructor(quic: WebTransport, version: Version) { + this.#quic = quic; + this.#version = version; + } + + async present(token: Uint8Array): Promise { + const stream = await Stream.open(this.#quic, { version: this.#version }); + await stream.writer.u53(StreamId.Auth); + await new AuthMessage(token).encode(stream.writer, this.#version); + return stream; + } + + 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 } }; + } + return { refused: new SessionError(reply.code as SessionCode, { reason: reply.reason }) }; + } + + /** 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 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 { + 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. */ + 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 bcd6e39cc5..51b96cec6e 100644 --- a/js/net/src/lite/connection.ts +++ b/js/net/src/lite/connection.ts @@ -1,5 +1,7 @@ import { type Getter, Once, 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 { Drain } from "../connection/goaway.ts"; import { type Probe, type Stats, transportStats } from "../connection/stats.ts"; @@ -7,11 +9,12 @@ import { type Transport, transportOf } from "../connection/transport.ts"; import { closeError, error, fromClose, ProtocolViolation, SessionCode, StreamCode, sessionCause } 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 { withTimeout } from "../util/timeout.ts"; import { registerWire } from "../wire.ts"; import { AnnounceRequest } from "./announce.ts"; +import { LiteAuthWire } from "./auth.ts"; import { Fetch } from "./fetch.ts"; import { Goaway } from "./goaway.ts"; import { Group } from "./group.ts"; @@ -22,7 +25,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}. @@ -42,6 +45,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; } /** @@ -81,6 +90,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; @@ -98,6 +110,8 @@ 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 peer's GOAWAY. Lite carries no deadline, so only the URI is set. #goaway = new Once(); @@ -119,7 +133,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; @@ -136,7 +150,19 @@ export class Connection implements Established { this.probe = this.#probe; this.hop = randomHop(); - this.#publisher = new Publisher(this.#quic, this.#version, this.hop, publish); + // 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({ + wire: hasAuth(version) ? new LiteAuthWire(quic, version) : undefined, + 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, { + grant: this.#auth.grant, + ready: this.#auth.setupAnswered(), + }); this.#subscriber = new Subscriber( this.#quic, this.#version, @@ -144,12 +170,19 @@ export class Connection implements Established { this.#probe, this.#peerSetup, this.#goaway, + this.#auth.grant, ); + this.#client = client; registerWire(this, { consume: (path) => this.#subscriber.consume(path), goaway: this.#goaway }); void this.#run(); } + /** Our tokens and grants; see {@link Established.auth}. */ + get auth(): Auth.Auth { + return this.#auth; + } + /** * Withdraw announcements and finish the requests this session serves, waiting up to one * second for delivery before closing. @@ -167,6 +200,7 @@ export class Connection implements Established { /** End the session immediately without waiting for delivery. */ abort(): void { + this.#auth.close(); this.#publisher.close(); this.#subscriber.close(); @@ -187,6 +221,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)) { @@ -299,6 +339,8 @@ export class Connection implements Established { ); } 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); // A peer sends at most one; a second is a protocol violation. diff --git a/js/net/src/lite/message.ts b/js/net/src/lite/message.ts index 885b7d5f7b..e0c22a96e9 100644 --- a/js/net/src/lite/message.ts +++ b/js/net/src/lite/message.ts @@ -4,10 +4,15 @@ import { Reader, Writer } from "../stream.ts"; * The largest message body either side accepts, matching the Rust implementation: the * same ceiling as SETUP, checked at the length prefix before the body is buffered. */ -const MAX_SIZE = 0xffff; +export const MAX_MESSAGE_SIZE = 0xffff; -// Encodes a message with a varint size prefix, refusing a body the peer would reject. -export async function encode(writer: Writer, f: (w: Writer) => Promise, max = MAX_SIZE) { +// Encodes a message with a varint size prefix, refusing a body the peer would reject. A type +// `id` is written only once the body fits, so a refused message leaves nothing on the stream. +export async function encode( + writer: Writer, + f: (w: Writer) => Promise, + { id, max = MAX_MESSAGE_SIZE }: { id?: number; max?: number } = {}, +) { let scratch = new Uint8Array(); const temp = new Writer( @@ -45,6 +50,7 @@ export async function encode(writer: Writer, f: (w: Writer) => Promise, ma throw new Error(`message too large: ${scratch.byteLength} bytes (max ${max})`); } + if (id !== undefined) await writer.u53(id); await writer.u53(scratch.byteLength); if (scratch.byteLength > 0) { await writer.write(scratch); @@ -52,7 +58,7 @@ export async function encode(writer: Writer, f: (w: Writer) => Promise, ma } // Reads a message with a varint size prefix, refusing an oversized body before reading it. -export async function decode(reader: Reader, f: (r: Reader) => Promise, max = MAX_SIZE): Promise { +export async function decode(reader: Reader, f: (r: Reader) => Promise, max = MAX_MESSAGE_SIZE): Promise { const size = await reader.u53(); if (size > max) { throw new Error(`message too large: ${size} bytes (max ${max})`); diff --git a/js/net/src/lite/publisher.test.ts b/js/net/src/lite/publisher.test.ts index 634e7ca98d..a34ee6283e 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"; @@ -15,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) { @@ -1728,6 +1732,290 @@ test("lite draft-05: a group that goes stale while its stream opens writes nothi } }); +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); +} + +// 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 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 () => { + 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(); + } +}); + +// 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 () => { + 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 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, + ready: Promise.resolve(), + }); + + const broadcast = publish(origin, Path.from("live")); + 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.writeFrame(textFrame("first")); + track.writeGroup(group); + + 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 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(); + } +}); + test.each([0, 1])("lite draft-07 reports the cached largest position when starting at group %s", async (startGroup) => { const version = Version.DRAFT_07; const pair = createMockTransportPair(ALPN_07_WIP); diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index e408b8918c..55014421be 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -1,13 +1,15 @@ 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 { Withdrawal } from "../connection/withdrawal.ts"; import * as DatagramStream from "../datagram_stream.ts"; -import { error, NotFound, ProtocolViolation, reason, StreamCode, StreamError } from "../error.ts"; +import { error, NotFound, ProtocolViolation, reason, StreamCode, StreamError, unauthorized } from "../error.ts"; import type * as group from "../group.ts"; import { Cost, type Hop, type Route, routesEqual } from "../hop.ts"; import { hiddenBelow, hooks, presented } from "../internal.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, type Stream, Writer } from "../stream.ts"; import { Milli, Timescale, Timestamp } from "../time.ts"; import type * as track from "../track.ts"; @@ -393,6 +395,15 @@ 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; + + // 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 @@ -407,11 +418,21 @@ 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 auth - The union of our tokens' grants, which bounds what we publish, and when the + * setup tokens are answered * * @internal */ - constructor(quic: WebTransport, version: Version, hop: Hop, publish?: OriginConsumer) { + constructor( + quic: WebTransport, + version: Version, + hop: Hop, + publish?: OriginConsumer, + auth?: { grant: Getter; ready: Promise }, + ) { this.#quic = quic; + this.#grant = auth?.grant; + this.#ready = auth?.ready ?? Promise.resolve(); this.version = version; this.hop = hop; const origin = publish && wireOf(publish); @@ -518,24 +539,56 @@ export class Publisher { await encodeAnnounceBroadcast(stream.writer, { status: "endedId", id }, this.version); }; + // A hidden route stays off the wire unless the request opted in. + const carries = (covered: Path.Valid) => msg.hidden || !hiddenBelow(msg.prefix, covered); + + // What the peer currently sees: the table under the prefix, less whatever our grant + // does not let us publish. + const visible = (table: Advertisements): Map => { + const out = presented(msg.prefix, table, carries); + 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((resolve) => { - dispose = this.#advertised.changed(resolve); - }); - - // A hidden route stays off the wire unless the request opted in. - const carries = (covered: Path.Valid) => msg.hidden || !hiddenBelow(msg.prefix, covered); + 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 { + // 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 - for (const [name, snap] of presented(msg.prefix, initial, carries)) { + for (const [name, snap] of visible(initial)) { active.set(name, snap); } @@ -567,22 +620,17 @@ export class Publisher { } for (;;) { - const advertised = await race([changed, stream.reader.closed, this.#withdrawal.closing]); + const woke = await race([changed, stream.reader.closed, this.#withdrawal.closing]); dispose(); - if (!advertised || advertised === true) break; + if (woke !== "changed") break; // Re-arm before reading, so an advertise that lands while we write is not lost. - changed = new Promise((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, carries)) { - updated.set(name, snap); - } + const updated = visible(latest); for (const suffix of active.keys()) { if (!updated.has(suffix)) await retract(suffix); @@ -618,6 +666,50 @@ 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. 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. + if (watch.revoked()) { + stream.writer.reset(unauthorized(msg.broadcast)); + return; + } + let front: broadcast.Consumer | undefined; try { front = @@ -628,6 +720,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}`)); @@ -643,6 +737,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 } }); @@ -742,6 +837,34 @@ export class Publisher { stream.writer.reset(new Error("fetch requires moq-lite-05 or newer")); return; } + // 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; + } let front: broadcast.Consumer | undefined; try { @@ -758,6 +881,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. @@ -779,6 +904,12 @@ export class Publisher { hold.abort(); } 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, @@ -1033,6 +1164,18 @@ export class Publisher { */ async runTrackInfo(msg: TrackMessage, stream: Stream) { 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; + } // 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( @@ -1051,6 +1194,7 @@ export class Publisher { if (!front) throw new NotFound(`broadcast ${msg.broadcast}`); const info = await this.#resolveTrackInfo(front, msg.track, hold.signal); + 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(); @@ -1059,9 +1203,17 @@ export class Publisher { hold.abort(); console.debug(`track unknown: broadcast=${msg.broadcast} track=${msg.track}`); stream.writer.reset(error(err)); + } finally { + watch.dispose(); } } + // 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 @@ -1389,6 +1541,17 @@ export class Publisher { } } + /** + * Close the session when our origin publishes a broadcast our grant does not cover; + * see {@link enforceGrant}. + * + * @internal + */ + async runEnforce(setupAnswered: Promise): Promise { + if (!this.#grant) return; + await enforceGrant({ quic: this.#quic, advertised: this.#advertised, grant: this.#grant, setupAnswered }); + } + /** * Owes the peer `task`, a served SUBSCRIBE, FETCH, or TRACK request, until it ends. * diff --git a/js/net/src/lite/setup.ts b/js/net/src/lite/setup.ts index 932abec81c..0e97ecfe02 100644 --- a/js/net/src/lite/setup.ts +++ b/js/net/src/lite/setup.ts @@ -268,7 +268,7 @@ export class Setup { /** Encode the SETUP message with its size prefix. Throws on pre-lite-05 versions. */ async encode(w: Writer, version: Version): Promise { Setup.#guard(version); - return Message.encode(w, (w) => this.#encode(w, version), MAX_SETUP_SIZE); + return Message.encode(w, (w) => this.#encode(w, version), { max: MAX_SETUP_SIZE }); } /** Decode a SETUP message with its size prefix. Throws on pre-lite-05 versions. */ 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.test.ts b/js/net/src/lite/subscriber.test.ts index ac308cda3a..148133462f 100644 --- a/js/net/src/lite/subscriber.test.ts +++ b/js/net/src/lite/subscriber.test.ts @@ -1,5 +1,6 @@ import { expect, jest, 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 * as Epoch from "../epoch.ts"; import { error, fromTransport, reason, StreamCode, StreamError } from "../error.ts"; @@ -1067,6 +1068,42 @@ test("a fetch started after the subscriber closes rejects without opening a stre 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, + 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, which ends the TRACK exchange too. + await drainUntil(() => streams.length === 1); + await streams[0].reading; + grant.set(scoped("other")); + + 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(); +}); + test("an already-aborted fetch rejects without opening a stream", async () => { const { quic, streams } = fakeSession(); const subscriber = new Subscriber(quic, Version.DRAFT_05, HopSchema.parse(1n)); diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index cb6b9c603a..5b4b5704a7 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -1,12 +1,23 @@ -import { type Dispose, type GetPromise, race, Signal } from "@moq/signals"; +import { type Dispose, type GetPromise, 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 type { Drain } from "../connection/goaway.ts"; import type { Probe as ProbeStats } from "../connection/stats.ts"; import { BroadcastCache } from "../consume.ts"; import * as DatagramStream from "../datagram_stream.ts"; import type * as Epoch from "../epoch.ts"; -import { controlTimeout, error, ProtocolViolation, reason, StreamCode, StreamError, sessionCause } from "../error.ts"; +import { + closeReason, + controlTimeout, + error, + ProtocolViolation, + reason, + StreamCode, + StreamError, + sessionCause, + unauthorized, +} 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, hiddenBelow, scopeCaptures, scopeHead, scopeOverlaps } from "../internal.ts"; @@ -79,6 +90,18 @@ 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; + track?: Stream; + producer?: track.Producer; + // Ends a setup still running once the deadline passed or the grant was revoked. + cancel: AbortController; + // Set once our grant stops covering the broadcast. + revoked?: Error; +} + /** * What a consumed broadcast captured when it was opened: the epoch it asks for, and its own id. * Fetches are shared only within one consume, so a fresh consume for a replaced instance never @@ -128,20 +151,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; @@ -183,6 +192,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 @@ -191,6 +205,7 @@ export class Subscriber { * @param probe - Optional sink for the peer's PROBE estimates * @param peerSetup - Optional peer SETUP slot for capability gating (lite-05+) * @param goaway - Settles when the peer sends GOAWAY + * @param grant - The union of our tokens' grants, which bounds what we subscribe to * * @internal */ @@ -201,6 +216,7 @@ export class Subscriber { probe?: Signal, peerSetup?: Signal, goaway?: GetPromise, + grant?: Getter, ) { this.#quic = quic; this.version = version; @@ -208,6 +224,7 @@ export class Subscriber { this.#probe = probe; this.#peerSetup = peerSetup; this.#goaway = goaway; + this.#grant = grant; } // Whether the peer has sent GOAWAY. Requests keep opening here until a replacement @@ -222,6 +239,12 @@ export class Subscriber { return this.#goingAway() ? DRAIN_COST : (cost ?? Cost.zero); } + // 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); + } + /** * Subscribe to broadcast announcements matching `scope`. Paths are relative * to the session, not the scope. @@ -599,6 +622,37 @@ export class Subscriber { request.reject(new Error(EMPTY_RANGE)); return; } + const refused = unauthorized(broadcast); + // 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 = { cancel: new AbortController() }; + 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.cancel.abort(refused); + state.producer?.close(refused); + state.stream?.abort(refused); + }); + try { + if (this.#denied(broadcast)) { + request.reject(refused); + return; + } + await this.#serveSubscribe(id, broadcast, epoch, request, state); + } finally { + disposeGrant?.(); + } + } + + async #serveSubscribe( + id: bigint, + broadcast: Path.Valid, + epoch: Epoch.Valid | undefined, + 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. @@ -622,9 +676,6 @@ export class Subscriber { // so the timeout path can close them if they finish opening after the deadline, // and `cancel` ends a setup still running once the deadline passed, resetting // its TRACK stream so a peer that never answers can't hold one per attempt. - const state: { stream?: Stream; track?: Stream; cancel: AbortController } = { - cancel: new AbortController(), - }; const setup = this.#openSubscribe(state, msg, request, id, timescale); let opened: { stream: Stream; entry: SubscribeEntry }; @@ -637,8 +688,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)); state.cancel.abort(e); request.reject(e); this.#subscribes.delete(id); @@ -729,7 +782,7 @@ export class Subscriber { // SUBSCRIBE_OK). Older drafts carry no per-track properties, so they resolve to // defaults and just drain SUBSCRIBE_OK. async #openSubscribe( - state: { stream?: Stream; track?: Stream; cancel: AbortController }, + state: SubscribeSetup, msg: Subscribe, request: track.Request, id: bigint, @@ -771,6 +824,7 @@ export class Subscriber { held: state.track, }; this.#subscribes.set(id, entry); + state.producer = producer; state.stream = await Stream.open(this.#quic, { version: this.version }); // The deadline passed while the open waited: the late-setup handler resets the stream, so diff --git a/js/net/src/lite/version.ts b/js/net/src/lite/version.ts index e608e7de42..2d492e38f1 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 and the publisher epoch. + /// Adds the ANNOUNCE_REQUEST hidden opt-in, the publisher epoch, and the Auth Stream. DRAFT_07: 0xff0dad07, } as const; @@ -65,6 +65,25 @@ export function hasDatagrams(version: Version): boolean { } } +/** + * Whether either endpoint may open an Auth Stream (0x7) to present a token and learn its + * grant. Added in lite-07. + */ +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: + case Version.DRAFT_06: + 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/js/pattern/src/index.ts b/js/pattern/src/index.ts index f5e367902c..a3c31cde8b 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 fa41d74eaf..c867be64a1 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -71,7 +71,9 @@ Decisions settled while planning, recorded so review does not relitigate them: token like any client; AUTH reports the grant actually admitted in each direction. The next mTLS scope quest can restrict or refuse it. A v1 endpoint must explicitly grant `**` for unrestricted access; AUTH does not widen a - scoped grant because the caller is another relay. + scoped grant because the caller is another relay. Mesh dials use the default + version set, which leaves out `moq-lite-07-wip`, so this applies once lite-07 + is cut or a cluster opts in. - **Client API.** Tokens live on `moq_tokio::connect::Config`, the dial-side config. `Connection::auth()` is a handle the connection owns: it keeps every added token, presents them on each session as it reconnects, @@ -89,8 +91,10 @@ Decisions settled while planning, recorded so review does not relitigate them: Everything here is additive: `Session::auth()` is new, and the relay derives the grant from the origin handles it already scopes. Decided 2026-10-05: wire work targets the wip lite version until the maintainer cuts it, never a -published version in place, so AUTH and its stream code land in -`moq-lite-07-wip` (or whichever version is wip then), not lite-06. +published version in place, so AUTH lands in `moq-lite-07-wip` (or whichever +version is wip then), not lite-06. The UNAUTHORIZED stream code stays on lite-06 +(#5004), since a relay can narrow a session with no Auth Stream; it is 0x3B, +because `main` assigned 0x3A to NOT_FETCHABLE first. ## Required @@ -101,6 +105,8 @@ published version in place, so AUTH and its stream code land in 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-token decode](/quest/m1/auth/request-token-decode.md) - an 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/quest/m1/auth/request-gate.md b/quest/m1/auth/request-gate.md new file mode 100644 index 0000000000..d03bc56f96 --- /dev/null +++ b/quest/m1/auth/request-gate.md @@ -0,0 +1,37 @@ +# [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, 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 + +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 carries its subscription's namespace and holds its own gate, +since the cache it answers from outlives the subscription. + +## 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 diff --git a/quest/m1/quest-flat-lines.md b/quest/m1/quest-flat-lines.md index 7ae9d002e5..3a983933d7 100644 --- a/quest/m1/quest-flat-lines.md +++ b/quest/m1/quest-flat-lines.md @@ -59,11 +59,8 @@ Remaining: 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, #4653 test-flakes-2, #4079 cpp, - and #4519 ffi-shape. As of the 2026-10-10 audit, only #4039 auth remains - open. 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. Reconcile its remaining code and quests with - `main` before landing it. + #4519 ffi-shape, and #4039 auth (UNAUTHORIZED moved to 0x3B). No umbrella + PR remains open. - #4133 qos closed without landing (2026-10-09). Its egress `lag` histogram is owned by [Viewer lag histogram](/quest/m1/qos/lag-histogram.md), which ports it once stats-split lands. Do not reopen the umbrella PR. diff --git a/rs/moq-c/src/error.rs b/rs/moq-c/src/error.rs index c0c0f6b10c..165a2dde75 100644 --- a/rs/moq-c/src/error.rs +++ b/rs/moq-c/src/error.rs @@ -401,6 +401,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/moq-c/src/test.rs b/rs/moq-c/src/test.rs index b9068fce03..032e59845c 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -367,6 +367,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, + 0x3b, + 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 58f857282d..3602c0c4da 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 0x3B. Unauthorized, /// The peer broke a protocol rule; the session is unusable. ProtocolViolation, @@ -144,6 +144,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, @@ -475,6 +476,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, 0x3b); + 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/benches/origin.rs b/rs/moq-net/benches/origin.rs index 82c75de8f5..63cbffd81d 100644 --- a/rs/moq-net/benches/origin.rs +++ b/rs/moq-net/benches/origin.rs @@ -9,6 +9,11 @@ //! down to its prefix and beneath it. Competing routes at that prefix are //! ranked once per change, then each cursor selects its first visible entry. //! +//! `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. +//! //! An equal-cost pool is swept the same way, over its members and the paths it //! already serves. //! @@ -17,12 +22,16 @@ //! //! Run with `cargo bench -p moq-net --bench origin`. +#[path = "../tests/support/mod.rs"] +mod support; + use std::task::Poll; use std::time::Duration; use criterion::{BatchSize, BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; use futures::FutureExt; -use moq_net::{Epoch, Hop, Hops, Pattern, Patterns, Timestamp, announce, broadcast, kio, origin, track}; +use moq_net::{Epoch, 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)]; @@ -610,6 +619,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())); + support::harness::spawn(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().authorize(&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().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"); + } + elapsed + }); + }); + } + group.finish(); +} + /// `(tracks, readers per track)` shapes for [`bench_relay`]. const RELAY: [(usize, usize); 4] = [(1, 1), (1, 100), (100, 1), (10, 100)]; @@ -882,6 +1037,8 @@ criterion_group!( bench_retire, bench_pool_churn, bench_handoff, + bench_narrow_delivery, + bench_narrow, bench_relay, bench_parked, bench_copy_walk diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs new file mode 100644 index 0000000000..db5028d076 --- /dev/null +++ b/rs/moq-net/src/auth.rs @@ -0,0 +1,1100 @@ +//! In-band authorization: present tokens to the peer and learn what they grant. +//! +//! 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 +//! 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. Either way, +//! [`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 +//! AUTH exchange: there the grant stays `None` and [`add`](Handle::add) fails with +//! [`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}, + 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, + } + } + + /// 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 = 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), + } +} + +/// 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, + /// The most this side lets the peer do, in the peer's terms: `None` (only the + /// 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. + 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 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 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.limit.as_ref().map(|limit| &limit.subscribe), + ), + Direction::Subscribe => ( + self.union.as_ref().map(|union| &union.subscribe), + self.limit.as_ref().map(|limit| &limit.publish), + ), + } + } + + /// What `direction` allows right now. + fn permit(&self, direction: Direction) -> Permit { + let (granted, limit) = self.parts(direction); + Permit { + granted: granted.cloned(), + limit: limit.cloned(), + } + } + + /// Whether `direction` allows `path` right now, without copying the patterns. + fn allows(&self, direction: Direction, path: &str) -> bool { + 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 limit this side set on the peer, each `None` +/// while unrestricted. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct Permit { + granted: 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_limit(path) + } + + /// Whether the limit alone allows `path`: for what the peer offers us, which our + /// own grant does not decide. + 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` + /// 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.limit, &other.limit) + } +} + +/// 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) { + // 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; + }; + 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(()) + } + + /// 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 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 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 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 authorize(&self, grant: &Grant) { + let mut state = self.state.lock(); + if state.limit.as_ref() != Some(grant) { + state.limit = Some(grant.clone()); + 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 state = ready_or!(self.poll_epoch(*epoch, waiter)); + *epoch = state.epoch; + Poll::Ready(state.union.clone()) + } + + /// 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 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.limit.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 + /// limit both. + pub(crate) fn allows(&self, direction: Direction, path: &str) -> bool { + self.state.read().allows(direction, path) + } + + /// 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_limit(&self, direction: Direction, path: &str) -> bool { + let state = self.state.read(); + 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 + /// 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) { + 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, +} + +/// 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); + } +} + +/// 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(); + 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. +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 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 limit's `subscribe`. + Publish, + /// The peer sends to this side: our grant's `subscribe`, and the limit's `publish`. + Subscribe, +} + +/// Watches whether the session still allows one path, for a request that must end +/// once it does not. +/// +/// 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 limit 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 session no longer allows the path. A union that is still + /// `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 { + 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, within the limit as it changes. +pub(crate) struct DefaultGrant { + handle: Handle, + base: Grant, + epoch: u64, + 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 limit = handle.state.read().limit.clone(); + Self { + handle, + base, + epoch: 0, + limit, + sent: None, + } + } + + /// 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(limit) = self.handle.poll_limit(&mut self.epoch, waiter) { + self.limit = limit; + } + let grant = match &self.limit { + Some(limit) => self.base.intersect(limit), + 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 { + 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(event) = ready_or!(announced.poll_next(waiter)) else { + return Poll::Ready(None); + }; + match event { + crate::announce::Event::Start(update) | crate::announce::Event::Update(update) + 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::Event::End(update) => { + self.live.remove(&update.prefix); + } + _ => {} + } + } + } +} + +#[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 limit replaces the last one, narrower or wider, and applies to a version + /// without AUTH too. + #[test] + fn a_limit_replaces_the_last() { + let handle = Handle::new(false); + assert!(handle.allows(Direction::Publish, "room/alice/audio")); + + 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.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.authorize(&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 limit does. + #[test] + 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_limit(Direction::Subscribe, "room/bob/cam")); + + 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); + } + + /// 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_limit() { + 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.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.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.authorize(&grant(&[""], &[""])); + assert!(default.poll(&waiter).is_pending()); + } +} diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index 4a511e7345..d13d97f3c4 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -200,6 +200,7 @@ impl Client { let start = lite::start(lite::Config { runtime: runtime.clone(), + client: true, limits: self.limits, session: session.clone(), setup_stream: None, @@ -209,6 +210,7 @@ impl Client { version, our_setup, peer_setup: None, + auth: crate::auth::Handle::new(version.has_auth()), })?; Ok(Session::new( @@ -217,8 +219,11 @@ impl Client { version.into(), start.recv_bandwidth, crate::driver::Protocol::Lite(Box::new(start.driver)), - start.goaway, - start.setup, + crate::session::Handles { + goaway: start.goaway, + auth: start.auth, + setup: start.setup, + }, )) } @@ -276,6 +281,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, setup) = ietf::start(ietf::Config { runtime: runtime.clone(), limits: self.limits, @@ -292,6 +299,7 @@ impl Client { authority: self.setup_authority.clone(), peer_setup_stream: None, peer_declared: None, + auth: auth.clone(), early_unis: Vec::new(), })?; @@ -302,8 +310,7 @@ impl Client { v, None, crate::driver::Protocol::Ietf(protocol), - goaway, - setup, + crate::session::Handles { goaway, auth, setup }, )); } Some(ALPN_16) => { @@ -394,11 +401,12 @@ impl Client { .copied() .ok_or(Error::Version)?; - let (recv_bw, protocol, goaway, setup) = match version { + let (recv_bw, protocol, goaway, auth, setup) = match version { Version::Lite(v) => { let stream = stream.with_version(v); let start = lite::start(lite::Config { runtime: runtime.clone(), + client: true, limits: self.limits, session: session.clone(), setup_stream: Some(stream), @@ -410,12 +418,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, start.setup, ) } @@ -434,6 +445,8 @@ impl Client { }; let stream = stream.with_version(v); + // 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, setup) = ietf::start(ietf::Config { runtime: runtime.clone(), @@ -451,14 +464,20 @@ impl Client { authority: None, peer_setup_stream: None, peer_declared: Some(peer_declared), + auth: auth.clone(), early_unis: Vec::new(), })?; - (None, crate::driver::Protocol::Ietf(protocol), goaway, setup) + (None, crate::driver::Protocol::Ietf(protocol), goaway, auth, setup) } }; Ok(Session::new( - runtime, session, version, recv_bw, protocol, goaway, setup, + runtime, + session, + version, + recv_bw, + protocol, + crate::session::Handles { goaway, auth, setup }, )) } } diff --git a/rs/moq-net/src/coding/reader.rs b/rs/moq-net/src/coding/reader.rs index 0f9b03a98f..5c3bf08ec8 100644 --- a/rs/moq-net/src/coding/reader.rs +++ b/rs/moq-net/src/coding/reader.rs @@ -368,7 +368,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; @@ -376,10 +376,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 d9b38c9eae..edc758b9ef 100644 --- a/rs/moq-net/src/error.rs +++ b/rs/moq-net/src/error.rs @@ -189,6 +189,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), @@ -221,6 +226,7 @@ impl StreamError { Self::FrameTooLarge => 0x38, Self::TimestampMismatch => 0x39, Self::NotFetchable => 0x3a, + Self::Unauthorized => 0x3b, Self::App(app) => *app as u32 + 64, Self::Unknown(code) => *code, } @@ -253,6 +259,7 @@ impl StreamError { 0x38 => Self::FrameTooLarge, 0x39 => Self::TimestampMismatch, 0x3a => Self::NotFetchable, + 0x3b => Self::Unauthorized, code @ 64.. => match u16::try_from(code - 64) { Ok(app) => Self::App(app), Err(_) => Self::Unknown(code), @@ -598,6 +605,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. @@ -612,8 +621,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 @@ -704,6 +712,7 @@ mod tests { StreamError::FrameTooLarge, StreamError::TimestampMismatch, StreamError::NotFetchable, + StreamError::Unauthorized, StreamError::App(7), ]; for err in registered { @@ -722,6 +731,7 @@ mod tests { (StreamError::FrameTooLarge, 0x38), (StreamError::TimestampMismatch, 0x39), (StreamError::NotFetchable, 0x3a), + (StreamError::Unauthorized, 0x3b), ] { assert_eq!(err.to_code(), code, "{err:?} moved off its assigned code"); } @@ -761,7 +771,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, 0x3a, + 0x0, 0x1, 0x2, 0x4, 0x5, 0x12, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, ] { 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/auth.rs b/rs/moq-net/src/ietf/auth.rs new file mode 100644 index 0000000000..8f5fc3dc17 --- /dev/null +++ b/rs/moq-net/src/ietf/auth.rs @@ -0,0 +1,722 @@ +//! The MoQ Auth extension (draft-lcurley-moq-auth-00). +//! +//! 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. +//! +//! 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, Serving}; +use crate::coding::{Decode, DecodeError, Decoder, Encode, EncodeError, Encoder, 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 Encoder<'_>, version: Version) -> Result<(), EncodeError> { + check_version(version).map_err(|_| EncodeError::Version)?; + self.request_id.encode(w, version)?; + w.bytes(&self.token) + } + + fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { + check_version(version)?; + Ok(Self { + request_id: RequestId::decode(r, version)?, + token: Bytes::copy_from_slice(r.bytes()?), + }) + } +} + +/// 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 Encoder<'_>) -> Result<(), EncodeError> { + w.varint(patterns.len() as u64)?; + for pattern in patterns { + let prefix = pattern.as_prefix().ok_or(EncodeError::Unsupported)?; + encode_namespace(w, &Path::new(prefix))?; + } + Ok(()) +} + +fn decode_prefixes(r: &mut Decoder<'_>) -> Result { + let count = r.varint()?; + 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)?; + 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 Encoder<'_>, version: Version) -> Result<(), EncodeError> { + check_version(version).map_err(|_| EncodeError::Version)?; + encode_prefixes(&self.publish, w)?; + encode_prefixes(&self.subscribe, w)?; + // 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), + }; + w.varint(expires) + } + + fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { + check_version(version)?; + let publish = decode_prefixes(r)?; + let subscribe = decode_prefixes(r)?; + let expires = match r.varint()? { + 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 and rejected from the +/// length prefix alone. +const MAX_REASON: usize = 8192; + +impl Message for AuthError { + const ID: u64 = 0x40B63; + + fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { + check_version(version).map_err(|_| EncodeError::Version)?; + if self.reason.len() > MAX_REASON { + return Err(EncodeError::TooLarge); + } + w.varint(self.code)?; + w.string(&self.reason) + } + + fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { + check_version(version)?; + let code = r.varint()?; + 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 }) + } +} + +/// 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, body) = 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: AuthOk = match decode_reply(&body, self.version) { + Ok(ok) => ok, + Err(err) => return err, + }; + let now = self.runtime.now(); + // 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: AuthError = match decode_reply(&body, self.version) { + Ok(refused) => refused, + Err(err) => return err, + }; + 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, + } + } + } +} + +/// 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, +) -> Result, Error> { + let Some(id) = stream.reader.varint_maybe().await? else { + return Ok(None); + }; + let body: super::Body = stream.reader.decode().await?; + Ok(Some((id, body))) +} + +/// 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 serving = Serving::new(self.handle.clone()); + let issue = &serving.issue; + let mut default = None; + 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; + } + // Re-sent whenever a new limit changes it. + None => default = Some(crate::auth::DefaultGrant::new(self.handle.clone(), self.peer_grant)), + } + + let err = match serve_issue(&self.runtime, issue, default.as_mut(), &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 + } + }; + serving.end(err); + } +} + +/// 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> { + 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)); + } + 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(()), + }) { + 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. 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 = runtime.now(); + let ok = AuthOk { + publish: grant.publish, + subscribe: grant.subscribe, + expires: grant.expires.map(|at| at.saturating_duration_since(now)), + }; + // 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 Encoder::new(&mut Vec::new(), version.into()), 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 cannot be encoded as one AUTH_OK".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 encode_msg(msg: &T, version: Version) -> Result, EncodeError> { + let mut buf = Vec::new(); + msg.encode_msg(&mut Encoder::new(&mut buf, version.into()), version)?; + Ok(buf) + } + + fn round_trip(msg: &T) -> T { + let buf = encode_msg(msg, VERSION).unwrap(); + let mut r = Decoder::new(&buf, VERSION.into()); + let got = T::decode_msg(&mut r, VERSION).unwrap(); + assert!(r.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 buf = encode_msg(&msg, 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); + } + + /// 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] + 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, + }; + assert!( + matches!(encode_msg(&msg, VERSION), Err(EncodeError::Unsupported)), + "{union:?} encoded" + ); + } + } + + #[test] + fn older_drafts_have_no_auth() { + for version in [Version::Draft14, Version::Draft15, Version::Draft16] { + let msg = Auth { + request_id: RequestId(0), + token: Bytes::new(), + }; + assert!(matches!(encode_msg(&msg, version), Err(EncodeError::Version))); + assert!(matches!( + Auth::decode_msg(&mut Decoder::new(&[0, 0], version.into()), version), + Err(DecodeError::Version) + )); + } + } + + /// 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() { + 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/error.rs b/rs/moq-net/src/ietf/error.rs index 44b1010059..9b9b4b77d2 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; } @@ -381,6 +383,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 @@ -719,6 +735,7 @@ mod tests { StreamError::FrameTooLarge, StreamError::GroupTooLarge, StreamError::TimestampMismatch, + StreamError::Unauthorized, StreamError::ControlTimeout, StreamError::App(7), StreamError::Unknown(0x1234), @@ -746,6 +763,7 @@ mod tests { 0x38, 0x39, 0x3a, + 0x3b, 64 + 7, ] { assert_eq!(from_stream_code(code, Version::Draft20), StreamError::Unknown(code)); @@ -758,7 +776,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; 19] = [ + const EVERY_ERROR: [StreamError; 20] = [ StreamError::Session(SessionError::Cancel), StreamError::Internal, StreamError::Cancel, @@ -776,6 +794,7 @@ mod tests { StreamError::FrameTooLarge, StreamError::GroupTooLarge, StreamError::TimestampMismatch, + StreamError::Unauthorized, StreamError::App(7), StreamError::Unknown(0x22), ]; diff --git a/rs/moq-net/src/ietf/mod.rs b/rs/moq-net/src/ietf/mod.rs index bc2ef60538..9ed57e1918 100644 --- a/rs/moq-net/src/ietf/mod.rs +++ b/rs/moq-net/src/ietf/mod.rs @@ -8,6 +8,7 @@ mod parameters; pub mod active_count; mod adapter; +pub(crate) mod auth; pub mod cluster; mod control; mod datagram; diff --git a/rs/moq-net/src/ietf/parameters.rs b/rs/moq-net/src/ietf/parameters.rs index 3a73d2e51f..6b2005b485 100644 --- a/rs/moq-net/src/ietf/parameters.rs +++ b/rs/moq-net/src/ietf/parameters.rs @@ -25,6 +25,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, /// ACTIVE_COUNT, from the MoQ Active Count extension. ActiveCount = super::active_count::ACTIVE_COUNT, #[num_enum(catch_all)] diff --git a/rs/moq-net/src/ietf/peer.rs b/rs/moq-net/src/ietf/peer.rs index 9d28ba8df9..c4efdd09c4 100644 --- a/rs/moq-net/src/ietf/peer.rs +++ b/rs/moq-net/src/ietf/peer.rs @@ -20,6 +20,9 @@ pub(crate) struct Peer { /// SUBSCRIBE_NAMESPACE, so we may send it. pub hidden: bool, + /// MoQ Auth: whether both sides negotiated the Auth request streams. + pub auth: bool, + /// MoQ Active Count: whether the REQUEST_OK answering SUBSCRIBE_NAMESPACE counts the /// NAMESPACE messages before the subscription is caught up, in both directions. pub active_count: bool, @@ -92,6 +95,7 @@ mod tests { }, solicit: None, hidden: false, + auth: false, active_count: false, }; @@ -104,6 +108,7 @@ mod tests { }, solicit: Some(true), hidden: true, + auth: true, active_count: true, }); diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index e08570034c..8fb4fff3b0 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -309,6 +309,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 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, } impl Namespaces { @@ -318,8 +322,15 @@ impl Namespaces { target, watched: HashMap::new(), requests: HashMap::new(), + permit: Default::default(), + epoch: 0, } } + + /// Whether we may advertise `path`. An unknown grant allows everything. + fn permitted(&self, path: &crate::Path) -> bool { + self.permit.matches(path.as_str()) + } } /// What woke an announce-forwarding loop. @@ -331,6 +342,8 @@ enum NamespaceEvent { Update(Option<(crate::announce::Announce, bool, bool)>), /// The retry sleep fired: re-offer whatever the peer should be holding and isn't. Retry, + /// Our grant (MoQ Auth) or the ceiling changed: re-check every namespace against it. + Regrant(crate::auth::Permit), } #[derive(Clone)] @@ -356,6 +369,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, // Dispatched finite serves, including those not yet polled. pub(super) owed: Arc, // Subscriptions the peer may hold at once (`session::Limits::subscriptions`). @@ -378,7 +394,12 @@ enum Joined { /// No objects existed when the subscription started. Empty, /// The prefix ending immediately after the saved Largest Object. - Group { end: Location, cache: track::Consumer }, + Group { + end: Location, + cache: track::Consumer, + /// The subscription's namespace, which the fetch is held to the grant on. + namespace: crate::PathOwned, + }, } /// One subscription's entry in [`Publisher::joins`], removed when the subscription ends. @@ -420,11 +441,18 @@ where peer_setup, joins: Default::default(), version, + auth: crate::auth::Handle::new(false), owed: Default::default(), subscriptions: Default::default(), } } + /// 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. /// @@ -631,6 +659,21 @@ 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; + } // Legal requests we can't honor are refused one at a time, never by closing the // session. A subscription that forwards nothing is only useful to a subscriber // that later turns forwarding on, and serving a Range Filter unfiltered would @@ -738,6 +781,7 @@ where object: largest.object + 1, }, cache: cache.clone(), + namespace: msg.track_namespace.to_owned(), }, (Filter::NextObject, None) => Joined::Empty, _ => Joined::Unsupported, @@ -782,9 +826,22 @@ where .await; } }; - let served = self - .run_subscription(&mut stream, &mut track_serve, &mut request_finished, fill) - .await; + // Ends the subscription once our grant (MoQ Auth) stops covering it. + let served = { + let serve = self.run_subscription(&mut stream, &mut track_serve, &mut request_finished, fill); + let mut serve = std::pin::pin!(serve); + kio::wait(|waiter| { + if let Poll::Ready(served) = waiter.poll_future(serve.as_mut()) { + return Poll::Ready(served); + } + 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))); + } + Poll::Pending + }) + .await + }; let completed = served.is_some(); let res = served.unwrap_or(Ok(())); @@ -827,6 +884,7 @@ where // Send PublishDone let (status, reason) = match &res { Ok(()) => (ietf::PublishDoneStatus::TrackEnded, "track ended"), + Err(Error::Unauthorized) => (ietf::PublishDoneStatus::Unauthorized, "not granted"), Err(Error::Unsupported) => (ietf::PublishDoneStatus::UpdateFailed, "update failed"), Err(_) => (ietf::PublishDoneStatus::InternalError, "internal error"), }; @@ -1413,13 +1471,24 @@ where other => other, }; - let (track, start, end, 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, joined, mut gate) = match fetch_type { FetchType::Standalone { namespace, track, 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; + } + 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 { 0 => end.group.checked_add(1).map(|group| Location { group, object: 0 }), @@ -1442,7 +1511,7 @@ where Err(err) => return self.reject_fetch(stream, msg.request_id, &err, &err.to_string()).await, }; - (track, start, end, false) + (track, start, end, false, gate) } FetchType::RelativeJoining { subscriber_request_id, .. @@ -1450,10 +1519,13 @@ where | FetchType::AbsoluteJoining { subscriber_request_id, .. } => { - let (end, cache) = match self.joined(&mut stream, subscriber_request_id).await? { + let (end, cache, 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, @@ -1476,6 +1548,7 @@ where }, end, true, + gate, ) } // Rewritten as standalone or refused above. @@ -1514,6 +1587,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))); + } waiter.poll_future(read.as_mut()).map(Some) }) .await @@ -1607,52 +1683,73 @@ 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: track_properties(&group.info, msg.properties_wanted), - }) - .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: track_properties(&group.info, msg.properties_wanted), + }) + .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 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. 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); } - 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 @@ -1668,7 +1765,7 @@ where &mut self, stream: &mut Stream, subscribe_id: RequestId, - ) -> Result, Error> { + ) -> Result, 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 = { @@ -1705,7 +1802,7 @@ where let refusal = match joined { Err(Error::Timeout) => (Error::Timeout, "subscription not ready"), Err(err) => return Err(err), - Ok(Some(Joined::Group { end, cache })) => return Ok(Ok((end, cache))), + Ok(Some(Joined::Group { end, cache, namespace })) => return Ok(Ok((end, cache, namespace))), Ok(None) => ( match self.version { Version::Draft14 @@ -1745,6 +1842,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 @@ -1768,6 +1880,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 @@ -1778,18 +1893,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. @@ -1930,17 +2062,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(); @@ -2412,12 +2550,18 @@ where let mut announced = origin.announced(); // MoQ Active Count: take what is advertised now, so the OK can say how many - // NAMESPACE messages carry it. They go out first, ahead of any change. + // NAMESPACE messages carry it. They go out first, ahead of any change, so with + // MoQ Auth the count waits for our grant and leaves out what it does not cover. let (initial, active) = match declared.active_count { true => { + if declared.auth { + kio::wait(|waiter| self.auth.poll_setup_answered(waiter)).await; + } + let (permit, _) = self.permit_now(); let initial = Self::snapshot(&mut announced); let count = initial .iter() + .filter(|update| permit.matches(prefix.join(&update.prefix).as_str())) .filter(|update| self.select(&update.route, &peer).wanted()) .count(); (initial, Some(count as u64)) @@ -2469,6 +2613,18 @@ where self.run_namespaces(announced, prefix, ns, initial).await } + /// Our publish permit as it stands now, with the auth epoch it was read at. + fn permit_now(&self) -> (crate::auth::Permit, u64) { + let mut epoch = 0; + match self + .auth + .poll_permit(crate::auth::Direction::Publish, &mut epoch, &kio::Waiter::noop()) + { + Poll::Ready(permit) => (permit, epoch), + Poll::Pending => (Default::default(), 0), + } + } + /// The routes an announce cursor holds right now, without waiting for more. /// /// A route announced and retracted within the snapshot is left out, and a repeat @@ -2507,6 +2663,13 @@ where if self.withdrawal.poll(&kio::Waiter::noop()).is_ready() { return Ok(()); } + // With MoQ Auth, wait for the answer to the credential we presented at setup, so + // the first advertisement, the initial set included, is already checked against + // our grant. + if self.peer_setup.get().await.auth { + kio::wait(|waiter| self.auth.poll_setup_answered(waiter)).await; + } + (ns.permit, ns.epoch) = self.permit_now(); for update in initial { self.apply_update(&mut ns, &prefix, update, true).await?; } @@ -2533,7 +2696,7 @@ where retry.set(retry_at); let event = { - let Namespaces { target, .. } = &mut ns; + let Namespaces { target, epoch, .. } = &mut ns; kio::wait(|waiter| { if self.withdrawal.poll(waiter).is_ready() { return Poll::Ready(NamespaceEvent::Update(None)); @@ -2542,6 +2705,11 @@ where if let Poll::Ready(res) = target.poll_closed(&mut finished, self.version, &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(permit) = self.auth.poll_permit(crate::auth::Direction::Publish, epoch, waiter) { + return Poll::Ready(NamespaceEvent::Regrant(permit)); + } if let Poll::Ready(next) = announced.poll_next(waiter) { return Poll::Ready(NamespaceEvent::Update(next.map(|event| match event { crate::announce::Event::Start(update) | crate::announce::Event::Update(update) => { @@ -2580,6 +2748,14 @@ where self.sync_namespace(&mut ns, &suffix, &path).await?; } } + NamespaceEvent::Regrant(permit) => { + ns.permit = permit; + 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. @@ -4688,6 +4864,166 @@ 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"); + } + } + + /// 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:?}"); + // 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" + ); + 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(); + 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() { @@ -6154,6 +6490,265 @@ mod serve_tests { } } + /// 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" + ); + } + } + + /// 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); + } + } + + /// 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] @@ -7661,6 +8256,7 @@ mod tests { }, solicit, hidden: false, + auth: false, active_count: false, }); slot diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index b8c2cded2e..e5f9dfb6a9 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -10,7 +10,7 @@ use crate::{ use super::{ Control, Message, Publisher, Subscriber, Version, active_count, adapter::ControlStreamAdapter, - cluster, hidden, peer, solicit, + auth, cluster, hidden, peer, solicit, subscriber::{is_protocol_violation, subscribe_prefixes}, }; @@ -72,6 +72,11 @@ pub struct Config { /// 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, + /// Uni streams that arrived before that pre-read SETUP (see [`PeerSetup::early`]), /// classified by the session before it accepts any more. pub early_unis: Vec>, @@ -119,6 +124,7 @@ where authority, peer_setup_stream, peer_declared, + auth, early_unis, } = config; @@ -128,6 +134,19 @@ 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, + }; + // One SUBSCRIBE_NAMESPACE per permitted prefix, like `lite::Subscriber`: the // scope is what we may ask for, and it is not the origin's root. let namespaces = subscribe.as_ref().map(subscribe_prefixes).unwrap_or_default(); @@ -157,6 +176,13 @@ where let setup_seen = crate::session::Setup::Ietf(peer_setup.clone()); 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 @@ -184,6 +210,7 @@ where let control = Control::new(request_id_max, client).with_window(limits.requests(), client); let adapter = ControlStreamAdapter::new(session.clone(), control.clone(), version); + // No AUTH on these drafts, but a limit still reaches both halves. let mut publisher = Publisher::new( runtime.clone(), adapter.clone(), @@ -192,7 +219,8 @@ where peer_hop, peer_setup.clone(), version, - ); + ) + .with_auth(auth.clone()); let (tasks, mut task_set) = TaskSet::new(); publisher.withdrawal = withdrawing.clone(); publisher.owed = serving.clone(); @@ -210,7 +238,8 @@ where version, tasks.clone(), goaway.going_away.clone(), - ); + ) + .with_auth(auth.clone()); subscriber.announces = announces; // GOAWAY send task: draft-14-16 carry GOAWAY on the shared control @@ -263,6 +292,8 @@ where dispatch_session, publisher.clone(), subscriber.clone(), + peer_setup.clone(), + None, version ))); let mut datagrams = std::pin::pin!(err_only(run_datagrams(adapter.clone(), subscriber.clone()))); @@ -354,6 +385,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 mut publisher = Publisher::new( runtime.clone(), session.clone(), @@ -362,7 +407,8 @@ where peer_hop, peer_setup.clone(), version, - ); + ) + .with_auth(auth.clone()); let (tasks, mut task_set) = TaskSet::new(); publisher.withdrawal = withdrawing.clone(); publisher.owed = serving.clone(); @@ -372,7 +418,7 @@ where runtime.clone(), session.clone(), subscribe, - control, + control.clone(), peer_hop, peer_setup.clone(), self_origin, @@ -380,7 +426,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, + }; subscriber.announces = announces; let sub_ns_session = session.clone(); @@ -418,10 +481,14 @@ where session.clone(), publisher.clone(), subscriber.clone(), + peer_setup.clone(), + Some(serve), version ))); let mut datagrams = std::pin::pin!(err_only(run_datagrams(session.clone(), subscriber.clone()))); 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`. @@ -465,6 +532,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(())); } @@ -480,6 +552,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 closing.load(std::sync::atomic::Ordering::Relaxed) { subscriber.close(); } else if let Err(err) = &res { @@ -490,6 +568,11 @@ where } }; + auth.close(match &res { + Ok(()) => Error::Cancel, + Err(err) => err.clone(), + }); + match &res { Err(err @ Error::Transport(_)) => { tracing::info!(%err, "session terminated"); @@ -621,6 +704,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); active_count::into_setup(&mut parameters, version); let parameters = parameters.encode_bytes(version)?; @@ -1011,6 +1096,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 @@ -1022,82 +1110,120 @@ 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; let mut tasks = TaskSet::owned(); - let mut accept = session.clone(); - loop { - let mut stream = tasks - .drive(|waiter| { - let mut cx = waiter.context(); - 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 header = tasks - .drive(|waiter| { - let mut cx = waiter.context(); - let id = match hdr_id { - Some(id) => id, - None => *hdr_id.insert(std::task::ready!(stream.reader.poll_varint(&mut cx))?), - }; - let body = std::task::ready!(stream.reader.poll_decode::(&mut cx))?; - std::task::Poll::Ready(Ok::<_, Error>((id, body))) - }) - .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) if died_before_header(&err) => { - tracing::debug!(%err, "dropping bidi stream that died before its header"); - continue; - } - Err(err) => return Err(err), - }; + // 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 = waiter.context(); + 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 header = tasks + .drive(|waiter| { + let mut cx = waiter.context(); + let id = match hdr_id { + Some(id) => id, + None => *hdr_id.insert(std::task::ready!(stream.reader.poll_varint(&mut cx))?), + }; + let body = std::task::ready!(stream.reader.poll_decode::(&mut cx))?; + std::task::Poll::Ready(Ok::<_, Error>((id, body))) + }) + .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) if died_before_header(&err) => { + tracing::debug!(%err, "dropping bidi stream that died before its header"); + continue; + } + Err(err) => return Err(err), + }; - match id { - // Draft-16 moved SUBSCRIBE_NAMESPACE to its own stream, past the control stream - // that admits every other request, but it still takes a request ID from the - // MAX_REQUEST_ID window. Held until the request ends, like the rest. - ietf::SubscribeNamespaceLegacy::ID if version == Version::Draft16 => { - let request_id = RequestId::decode(&mut crate::coding::Decoder::new(&data.0, version.into()), version)?; - let permit = publisher.control.accept(request_id)?; - let task = publisher.handle_stream(id, data, stream)?; - tasks.push( - async move { - let _permit = permit; - task.await + match id { + // Draft-16 moved SUBSCRIBE_NAMESPACE to its own stream, past the control stream + // that admits every other request, but it still takes a request ID from the + // MAX_REQUEST_ID window. Held until the request ends, like the rest. + ietf::SubscribeNamespaceLegacy::ID if version == Version::Draft16 => { + let request_id = + RequestId::decode(&mut crate::coding::Decoder::new(&data.0, version.into()), version)?; + let permit = publisher.control.accept(request_id)?; + let task = publisher.handle_stream(id, data, stream)?; + tasks.push( + async move { + let _permit = permit; + task.await + } + .maybe_boxed(), + ); + } + // Publisher handles: Subscribe, Fetch, SubscribeNamespace (0x50 modern / + // 0x11 legacy), SubscribeTracks, TrackStatus + ietf::Subscribe::ID + | ietf::Fetch::ID + | ietf::SubscribeNamespace::ID + | ietf::SubscribeNamespaceLegacy::ID + | ietf::SUBSCRIBE_TRACKS_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.decoder(version); + let msg = auth::Auth::decode_msg(&mut data, version)?; + if !data.is_empty() { + return Err(Error::WrongSize); } - .maybe_boxed(), - ); - } - // Publisher handles: Subscribe, Fetch, SubscribeNamespace (0x50 modern / - // 0x11 legacy), SubscribeTracks, TrackStatus - ietf::Subscribe::ID - | ietf::Fetch::ID - | ietf::SubscribeNamespace::ID - | ietf::SubscribeNamespaceLegacy::ID - | ietf::SUBSCRIBE_TRACKS_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)?); - } - _ => { - 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 @@ -1161,6 +1287,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::*; @@ -1240,6 +1398,7 @@ mod tests { }, ..Default::default() }), + auth: crate::auth::Handle::new(false), early_unis: Vec::new(), }) .expect("start the session"); @@ -1294,6 +1453,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), early_unis: Vec::new(), }) .expect("start the session"); @@ -1367,6 +1527,7 @@ mod tests { solicit, ..Default::default() }), + auth: crate::auth::Handle::new(false), early_unis: Vec::new(), }) .expect("start the session"); @@ -1450,6 +1611,7 @@ mod tests { authority: None, peer_setup_stream: None, peer_declared, + auth: crate::auth::Handle::new(false), early_unis: Vec::new(), }) .expect("start the session"); @@ -1505,6 +1667,113 @@ 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(); + crate::coding::Encoder::new(&mut buf, Version::Draft18.into()) + .varint(auth::Auth::ID) + .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: moq_net_sim::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::sim(), + session, + setup: None, + request_id_max: None, + limits: Default::default(), + client: true, + publish: Some(origin.consume()), + subscribe: None, + peer_hop: None, + cost: None, + version: Version::Draft18, + path: None, + authority: None, + peer_setup_stream: None, + peer_declared: Some(peer::Peer { + auth, + ..Default::default() + }), + auth: handle.clone(), + early_unis: Vec::new(), + }) + .expect("start the session"); + AuthSession { + handle, + log, + _origin: origin, + _cam: cam, + _gate: gate, + _goaway: goaway, + _driver: moq_net_sim::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. + #[moq_net_sim::test] + async fn a_peer_without_auth_sees_no_auth_request() { + let session = auth_session(false); + let (handle, log) = (&session.handle, &session.log); + + let err = moq_net_sim::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; + } + moq_net_sim::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. + #[moq_net_sim::test] + 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; + } + moq_net_sim::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 @@ -1561,6 +1830,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), early_unis: Vec::new(), }) .expect("start the session"); @@ -1600,6 +1870,7 @@ mod tests { authority: Some(String::from_utf8(AUTHORITY.to_vec()).unwrap()), peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), + auth: crate::auth::Handle::new(false), early_unis: Vec::new(), }) .expect("start the session"); @@ -1910,6 +2181,7 @@ mod tests { authority: None, peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), + auth: crate::auth::Handle::new(false), early_unis: Vec::new(), }) .unwrap(); @@ -1975,6 +2247,7 @@ mod tests { authority: None, peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), + auth: crate::auth::Handle::new(false), early_unis: Vec::new(), limits: Default::default(), }) @@ -2075,6 +2348,7 @@ mod tests { authority: None, peer_setup_stream: None, peer_declared: None, + auth: crate::auth::Handle::new(false), early_unis: Vec::new(), }) .expect("start the session"); diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 9632e158c2..8e74b90173 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -546,8 +546,13 @@ 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` 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, @@ -600,6 +605,8 @@ pub(super) struct Subscriber { // deliberately past draft-19 section 10.4's SHOULD NOT: refusing them would // fail requests that land before the replacement is up. going_away: crate::goaway::GoingAway, + // Our grant (MoQ Auth): a subscription it stops covering is cancelled. + auth: crate::auth::Handle, // What this session may allocate up front for objects still arriving. frames: frame::Budget, // Namespaces the peer may have announced at once (`session::Limits::announces`). @@ -684,11 +691,71 @@ where tasks, version, going_away, + auth: crate::auth::Handle::new(false), frames: Default::default(), announces: Default::default(), } } + /// 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); @@ -1557,7 +1624,9 @@ 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) => { @@ -1567,26 +1636,34 @@ where .clone() .close(crate::SessionError::from(err).to_code(), "too many announcements"); })?; + // 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, + live: Default::default(), + _slot: slot, + }); + return Ok(()); + } // 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), + generation: 0, count: 1, live: Default::default(), _slot: slot, }); 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(()) } @@ -1633,9 +1710,10 @@ 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; loop { let next = broadcasts .drive(|waiter| { @@ -1650,11 +1728,33 @@ where if self.going_away.poll(waiter).is_ready() { self.drain_route(&path); } + // 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 excluded = false; + while let Poll::Ready(permit) = + self.auth + .poll_permit(crate::auth::Direction::Subscribe, &mut epoch, waiter) + { + 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. 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 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; + // Dropping the namespace's token closes every source minted under it. + entry.live = Default::default(); + } + match &mut entry.dynamic { + Some(dynamic) => dynamic.poll_requested_broadcast(waiter).map(Some), None => Poll::Ready(None), } }) @@ -1716,7 +1816,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()); + } } /// Serve one minted source's track requests, taken through its `broadcast` handler, @@ -1791,6 +1893,21 @@ where ) { // Data streams wait on the alias bound by SUBSCRIBE_OK, so leave the model request // pending until its immutable track metadata is known. + // 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 track_name = request.name().to_owned(); // Group FETCHes for cache misses: standalone, so they outlive each subscription. let mut group_fetches = TaskSet::owned(); @@ -1805,7 +1922,7 @@ where return; }; let Some(next) = self - .linger(&broadcast_path, &track_name, idle, &mut group_fetches) + .linger(&broadcast_path, &track_name, idle, &mut group_fetches, &mut gate) .await else { return; @@ -1816,13 +1933,13 @@ where }; loop { let Some(idle) = self - .subscribe_once(&broadcast_path, &track_name, target, &mut group_fetches) + .subscribe_once(&broadcast_path, &track_name, target, &mut group_fetches, &mut gate) .await else { return; }; let Some(next) = self - .linger(&broadcast_path, &track_name, idle, &mut group_fetches) + .linger(&broadcast_path, &track_name, idle, &mut group_fetches, &mut gate) .await else { return; @@ -1840,6 +1957,7 @@ where track_name: &str, target: Target, group_fetches: &mut TaskSet, + gate: &mut crate::auth::Gate, ) -> Option { let subscription = target.subscription(); let start = subscription.as_ref().and_then(|s| s.start); @@ -2149,11 +2267,14 @@ where // does not disturb subscriptions already in flight. enum End { Idle, + Revoked, Done(Result), Fetch(group::Request), } let mut fetch_done = fetching.is_none(); + // Our grant stopped covering the track, which is aborted rather than kept lingering. + let mut revoked = false; let demand = track.demand(); // Nobody subscribing at all (only fetches asked) needs no subscription. let mut subscribed = track.subscription().is_some(); @@ -2167,6 +2288,9 @@ where { fetch_done = true; } + if gate.poll_denied(waiter).is_ready() { + return Poll::Ready(End::Revoked); + } // An error is the track closing, which the arms below report. if let Poll::Ready(Ok(request)) = dynamic.poll_requested_group(waiter) { return Poll::Ready(End::Fetch(request)); @@ -2194,6 +2318,12 @@ where .await; match end { + End::Revoked => { + tracing::info!(broadcast = %self.origin.absolute(broadcast_path), track = %track_name, "subscription no longer authorized"); + let _ = track.clone().abort(Error::Unauthorized); + revoked = true; + break true; + } End::Fetch(request) => { let fetch = self.clone().run_group_fetch( broadcast_path.to_owned(), @@ -2267,12 +2397,12 @@ where // What the copy cached goes stale from here. Marked before the cancel, which waits // on the publisher: it stops serving as soon as the cancel lands, and a reader // returning in between must not take the cache as the live edge. - if !aborted { + if !aborted && !revoked { track.set_idle(); } self.cancel_subscribe(stream, request_id).await; - // A session abort took the copy too. - if aborted { + // A session abort took the copy too, and a revoked grant ended it. + if aborted || revoked { return None; } Some(Idle { @@ -2292,6 +2422,7 @@ where track_name: &str, mut idle: Idle, group_fetches: &mut TaskSet, + gate: &mut crate::auth::Gate, ) -> Option { // Registered so a session abort ends the copy with its error. let id = { @@ -2307,6 +2438,7 @@ where Fetch(group::Request), Subscribe, Expired, + Revoked, Closed, } let resume = loop { @@ -2314,6 +2446,9 @@ where if idle.track.poll_closed(waiter).is_ready() { return Poll::Ready(Step::Closed); } + if gate.poll_denied(waiter).is_ready() { + return Poll::Ready(Step::Revoked); + } if let Poll::Ready(Ok(request)) = idle.dynamic.poll_requested_group(waiter) { return Poll::Ready(Step::Fetch(request)); } @@ -2363,6 +2498,11 @@ where linger.set(None); } }, + Step::Revoked => { + tracing::info!(broadcast = %self.origin.absolute(broadcast_path), track = %track_name, "lingering track no longer authorized"); + let _ = idle.track.clone().abort(Error::Unauthorized); + break false; + } Step::Closed => break false, } }; diff --git a/rs/moq-net/src/lib.rs b/rs/moq-net/src/lib.rs index 2a99af0764..31e6cf337c 100644 --- a/rs/moq-net/src/lib.rs +++ b/rs/moq-net/src/lib.rs @@ -73,6 +73,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..cd83283c91 --- /dev/null +++ b/rs/moq-net/src/lite/auth.rs @@ -0,0 +1,336 @@ +use std::time::Duration; + +use bytes::Bytes; + +use crate::coding::*; +use crate::{Pattern, Patterns}; + +use super::{Message, Version}; + +/// 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. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Auth { + pub token: Bytes, +} + +impl Message for Auth { + fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { + if !version.has_auth() { + return Err(DecodeError::Version); + } + Ok(Self { + token: Bytes::copy_from_slice(r.bytes()?), + }) + } + + fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { + if !version.has_auth() { + return Err(EncodeError::Version); + } + w.bytes(&self.token) + } +} + +/// 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 their canonical text. +fn encode_patterns(patterns: &Patterns, w: &mut Encoder<'_>) -> Result<(), EncodeError> { + w.varint(patterns.len() as u64)?; + for pattern in patterns { + w.string(pattern.as_str())?; + } + Ok(()) +} + +fn decode_patterns(r: &mut Decoder<'_>) -> Result { + let count = r.varint()?; + let mut patterns = Patterns::new(); + // No preallocation: the count is peer-controlled, and the message size limit + // is what bounds how many patterns actually fit. + for _ in 0..count { + let text = r.string()?; + 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) +} + +impl Message for AuthOk { + fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { + if !version.has_auth() { + return Err(DecodeError::Version); + } + let publish = decode_patterns(r)?; + let subscribe = decode_patterns(r)?; + let expires = match r.varint()? { + 0 => None, + ms => Some(Duration::from_millis(ms)), + }; + Ok(Self { + publish, + subscribe, + expires, + }) + } + + fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { + if !version.has_auth() { + return Err(EncodeError::Version); + } + encode_patterns(&self.publish, w)?; + encode_patterns(&self.subscribe, w)?; + // 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), + }; + w.varint(expires) + } +} + +/// 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 Decoder<'_>, version: Version) -> Result { + if !version.has_auth() { + return Err(DecodeError::Version); + } + let code = r.varint()?; + 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 }) + } + + fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { + if !version.has_auth() { + return Err(EncodeError::Version); + } + if self.reason.len() > MAX_REASON { + return Err(EncodeError::TooLarge); + } + w.varint(self.code)?; + w.string(&self.reason) + } +} + +/// 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 Encoder<'_>, typ: u64, msg: &M, version: Version) -> Result<(), EncodeError> { + w.varint(typ)?; + msg.encode(w, version) +} + +impl Encode for AuthReply { + fn encode(&self, w: &mut Encoder<'_>, 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(r: &mut Decoder<'_>, version: Version) -> Result { + match r.varint()? { + AUTH_OK => Ok(Self::Ok(AuthOk::decode(r, version)?)), + AUTH_ERROR => Ok(Self::Error(AuthError::decode(r, version)?)), + typ => Err(DecodeError::InvalidMessage(typ)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn patterns(texts: &[&str]) -> Patterns { + texts.iter().map(|text| Pattern::try_from(*text).unwrap()).collect() + } + + fn encode>(msg: &T, version: Version) -> Result, EncodeError> { + let mut buf = Vec::new(); + msg.encode(&mut Encoder::new(&mut buf, version.into()), version)?; + Ok(buf) + } + + fn round_trip + Decode>(msg: &T) -> T { + let buf = encode(msg, Version::Lite07).unwrap(); + let mut r = Decoder::new(&buf, Version::Lite07.into()); + let got = T::decode(&mut r, Version::Lite07).unwrap(); + assert!(r.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); + } + } + + /// `**` 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(&["room/**"]), None), + ( + patterns(&["room/alice", "room/*/cam", "**/demo.hang"]), + patterns(&["room/cam-*.hang", "lobby/**"]), + Some(Duration::from_secs(60)), + ), + ] { + let msg = AuthReply::Ok(AuthOk { + publish, + subscribe, + expires, + }); + assert_eq!(round_trip(&msg), msg); + } + } + + /// 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 buf = encode(&msg, Version::Lite07).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 + 0x83, 0xe8, // expires: 1000ms, as a leading-ones varint + ]; + 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 = Vec::new(); + let mut w = Encoder::new(&mut buf, Version::Lite07.into()); + w.varint(AUTH_OK).unwrap(); + let prefix = w.prefix_varint(); + w.varint(1).unwrap(); + w.string(text).unwrap(); + w.varint(0).unwrap(); + w.varint(0).unwrap(); + w.fill(prefix).unwrap(); + assert!( + matches!( + AuthReply::decode(&mut Decoder::new(&buf, Version::Lite07.into()), Version::Lite07), + Err(DecodeError::InvalidValue) + ), + "{text} decoded" + ); + } + } + + #[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))); + } + + /// 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!(encode(&msg, Version::Lite07), Err(EncodeError::TooLarge))); + } + + #[test] + fn older_versions_have_no_auth() { + for version in [ + Version::Lite01, + Version::Lite02, + Version::Lite03, + Version::Lite04, + Version::Lite05, + Version::Lite06, + ] { + assert!(matches!( + encode(&Auth { token: Bytes::new() }, version), + Err(EncodeError::Version) + )); + assert!(matches!( + Auth::decode_msg(&mut Decoder::new(&[0], version.into()), version), + Err(DecodeError::Version) + )); + } + } +} diff --git a/rs/moq-net/src/lite/mod.rs b/rs/moq-net/src/lite/mod.rs index cff9345d99..aa9c3ec0b3 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 compress; mod datagram; mod epoch; @@ -28,6 +29,7 @@ mod track; mod version; pub use announce::*; +pub use auth::*; pub(crate) use compress::*; #[allow(unused_imports)] pub use datagram::*; diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 33d4a58872..2fc9d48cf2 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -43,6 +43,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, /// Subscriptions the peer may hold at once (`session::Limits::subscriptions`). pub subscriptions: crate::session::Slots, } @@ -68,6 +75,8 @@ struct Shared { priority: PriorityQueue, version: Version, goaway: crate::goaway::Protocol, + auth: crate::auth::Handle, + peer_grant: crate::auth::Grant, // Control streams still serving the peer data, which a draining close waits for. owed: AtomicUsize, interests: Interests, @@ -118,6 +127,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 @@ -155,6 +170,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 { @@ -164,7 +181,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 { withdrawal: Default::default(), session: config.session, @@ -176,6 +195,8 @@ impl Publisher { priority: Default::default(), version: config.version, goaway: config.goaway, + auth: config.auth, + peer_grant: config.peer_grant, owed: AtomicUsize::new(0), interests: Interests::new(config.subscriptions), runtime: config.runtime.clone(), @@ -192,6 +213,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 = waiter.context(); @@ -234,8 +262,10 @@ impl Publisher { announced: &mut announce::Consumer, self_origin: Hop, version: Version, + auth: Option, ) -> Result<(), Error> { - let mut run = AnnounceRun::new(self_origin, version); + let mut run = AnnounceRun::new(crate::PathOwned::default(), self_origin, version); + run.auth = auth; kio::wait(|waiter| run.poll(stream, origin, announced, waiter)).await } } @@ -261,6 +291,7 @@ enum ControlState { Fetch(RequestServe), TrackInfo(RequestServe), Probe(ProbeServe), + Auth(AuthServe), /// Decoding the peer's GOAWAY, surfaced through [`crate::Session::draining`]. Goaway { stream: Stream, @@ -310,6 +341,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)), }; @@ -319,6 +351,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. @@ -352,6 +385,204 @@ 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. + serving: Option, + /// The default acceptor's grant for the connection's credential, re-sent as a + /// new limit changes it. `None` when the app answers. + default: 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-07+ only. + if !shared.version.has_auth() { + return Err(Error::UnexpectedStream); + } + Ok(Self { + shared, + stream: Some(stream), + serving: None, + default: 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(serving) = &self.serving { + serving.end(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.serving { + Some(serving) => serving.issue.clone(), + None => { + let msg = ready!(stream.reader.poll_decode::(&mut cx))?; + 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 + // 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 => { + self.default = Some(crate::auth::DefaultGrant::new( + self.shared.auth.clone(), + self.shared.peer_grant.clone(), + )); + } + } + self.serving = Some(serving); + 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)), + } + // 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; + } + + 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(()), + }) { + Poll::Ready(state) => state, + Poll::Pending => return Poll::Pending, + }; + match state.outbox.pop_front() { + Some(reply) => { + drop(state); + Self::write(&self.shared, 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, stream: &mut Stream, reply: crate::auth::Reply) -> Result<(), Error> { + let msg = match reply { + crate::auth::Reply::Grant(grant) => { + let now = shared.runtime.now(); + 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, + }), + }; + // A grant that cannot be encoded is withheld, never trimmed. Nothing is buffered, and + // 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 + }) + } +} + +/// 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. See +/// [`crate::auth::Enforce`]. +#[derive(Default)] +struct Enforce { + check: crate::auth::Enforce, + announced: Option, +} + +impl Enforce { + fn poll( + &mut self, + shared: &Shared, + waiter: &kio::Waiter, + ) -> Poll> { + let announced = match &mut self.announced { + Some(announced) => announced, + None => { + let origin = ready!(shared.poll_serving_origin(waiter)); + self.announced.insert(origin.announced()) + } + }; + 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)) + } +} + /// Serves one PROBE stream: periodic bandwidth estimates until the peer closes /// its side. struct ProbeServe { @@ -610,7 +841,8 @@ impl AnnounceServe { false => origin, }; let announced = origin.announced(); - let run = AnnounceRun::new(self.shared.self_origin, self.shared.version); + let mut run = AnnounceRun::new(prefix, self.shared.self_origin, self.shared.version); + run.auth = Some(self.shared.auth.clone()); self.state = AnnounceState::Run { origin, announced, run }; } } @@ -618,6 +850,9 @@ impl AnnounceServe { /// The announce loop's state, minus the handles it borrows per poll so the test /// shim can supply its own. struct AnnounceRun { + // The requested prefix. Updates arrive named relative to it, but the grant + // matches paths relative to the origin's root. + prefix: crate::PathOwned, self_origin: Hop, version: Version, // Lite06+: announce ids. Every `active` we send implicitly assigns the next @@ -629,6 +864,12 @@ struct AnnounceRun { // prefix. live: HashMap, phase: AnnouncePhase, + // 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 we may announce right now. + permit: crate::auth::Permit, } /// What the peer holds for one advertised suffix. @@ -651,14 +892,125 @@ enum AnnouncePhase { } impl AnnounceRun { - fn new(self_origin: Hop, version: Version) -> Self { + fn new(prefix: crate::PathOwned, self_origin: Hop, version: Version) -> Self { Self { + prefix, self_origin, version, encoder: lite::AnnounceEncoder::new(version), live: HashMap::new(), phase: AnnouncePhase::Init, + auth: None, + epoch: 0, + permit: Default::default(), + } + } + + /// Whether we may announce `suffix` (relative to the requested prefix). + fn permitted(&self, suffix: &crate::Path) -> bool { + self.permit.matches(self.prefix.join(suffix).as_str()) + } + + /// 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, + 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 { + return Ok(()); + } + + // 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(); + while let Some(event) = snapshot.try_next() { + match event { + announce::Event::Start(update) | announce::Event::Update(update) | announce::Event::Restart(update) => { + current.insert(update.prefix, update.route); + } + announce::Event::End(update) => { + current.remove(&update.prefix); + } + } + } + + for (suffix, route) in current { + if self.live.contains_key(&suffix) || !self.permitted(&suffix) { + continue; + } + let absolute = origin.absolute(&suffix); + if let Some((hops, cost)) = self.outgoing(&route, &absolute) { + self.advertise(stream, suffix, hops, cost, route.epoch, &absolute)?; + } + } + Ok(()) + } + + /// Put `suffix` on the wire with this chain and cost: start it, restart it in place + /// when its metadata changed, or leave it alone when the peer already holds it. + fn advertise( + &mut self, + stream: &mut Stream, + suffix: crate::PathOwned, + hops: Hops, + cost: crate::origin::Cost, + epoch: Option, + absolute: &crate::Path, + ) -> Result<(), Error> { + match self.live.get_mut(&suffix) { + // The peer would decode what it already holds. + Some(advertised) if advertised.hops == hops && advertised.cost == cost => {} + // A metadata update on a live advertisement: restart it in + // place (lite-05 restarts via a duplicate ANNOUNCE). + Some(advertised) if lite::update_supported(self.version) => { + tracing::debug!(route = %absolute, "reannounce"); + advertised.hops = hops.clone(); + advertised.cost = cost; + match advertised.id { + Some(id) => { + let hops = self.encoder.update(id, hops); + stream + .writer + .buffer(&lite::AnnounceBroadcast::Update { id, hops, cost })? + } + // lite-05: a duplicate ANNOUNCE, which assigns no id. + None => stream.writer.buffer(&lite::AnnounceBroadcast::Active { + epoch: None, + suffix: lite::PathRef::literal(suffix), + hops: lite::HopsRef::literal(hops), + cost, + })?, + } + } + // Pre-restart versions have no way to update a live + // advertisement; the peer keeps the original chain. + Some(_) => {} + None => { + tracing::debug!(route = %absolute, "announce"); + self.start(stream, suffix, hops, cost, epoch)?; + } } + Ok(()) } /// The chain and cost to put on the wire for `route`, or `None` when it must @@ -798,11 +1150,14 @@ impl AnnounceRun { let suffix = update.prefix; if active { + init.retain(|(s, ..)| s != &suffix); + if !self.permitted(&suffix) { + continue; + } let Some((hops, cost)) = self.outgoing(&update.route, &absolute) else { continue; }; tracing::debug!(route = %absolute, "announce"); - init.retain(|(s, ..)| s != &suffix); init.push((suffix, hops, cost)); } else { // A potential race: a just-announced route already retracted. @@ -813,7 +1168,7 @@ impl AnnounceRun { let suffixes = init.iter().map(|(suffix, ..)| suffix.clone()).collect(); stream.writer.buffer(&lite::AnnounceInit { suffixes })?; - // Record the initial set so a later end reaches the peer. + // The peer holds these now, so a later retraction or a narrower grant ends them. for (suffix, hops, cost) in init { self.live.insert(suffix, Advertised { id: None, hops, cost }); } @@ -835,11 +1190,14 @@ impl AnnounceRun { let suffix = update.prefix; if active { + initial.retain(|(s, ..)| s != &suffix); + if !self.permitted(&suffix) { + 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, update.route.epoch)); } else { // A potential race: a just-announced route already retracted. @@ -899,6 +1257,15 @@ impl AnnounceRun { let mut cx = waiter.context(); if matches!(self.phase, AnnouncePhase::Init) { + // 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(permit) = auth.poll_permit(crate::auth::Direction::Publish, &mut self.epoch, waiter) + { + self.permit = permit; + } + } self.init(stream, origin, announced)?; self.phase = AnnouncePhase::Running; } @@ -916,6 +1283,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(permit) = auth.poll_permit(crate::auth::Direction::Publish, &mut self.epoch, waiter) + && self.permit != permit + { + self.regrant(stream, origin, permit)?; + continue; + } + if let Poll::Ready(res) = stream.reader.poll_closed(&mut cx) { return Poll::Ready(res); } @@ -939,7 +1316,7 @@ impl AnnounceRun { let absolute = origin.absolute(&update.prefix); let suffix = update.prefix; - if !active { + if !active || !self.permitted(&suffix) { self.retract(stream, suffix, &absolute)?; continue; } @@ -948,39 +1325,9 @@ impl AnnounceRun { Some((hops, cost)) if restart && self.live.contains_key(&suffix) => { self.restart(stream, suffix, hops, cost, update.route.epoch, &absolute)?; } - Some((hops, cost)) => match self.live.get_mut(&suffix) { - // The peer would decode what it already holds. - Some(advertised) if advertised.hops == hops && advertised.cost == cost => {} - // A metadata update on a live advertisement: restart it in - // place (lite-05 restarts via a duplicate ANNOUNCE). - Some(advertised) if lite::update_supported(self.version) => { - tracing::debug!(route = %absolute, "reannounce"); - advertised.hops = hops.clone(); - advertised.cost = cost; - match advertised.id { - Some(id) => { - let hops = self.encoder.update(id, hops); - stream - .writer - .buffer(&lite::AnnounceBroadcast::Update { id, hops, cost })? - } - // lite-05: a duplicate ANNOUNCE, which assigns no id. - None => stream.writer.buffer(&lite::AnnounceBroadcast::Active { - epoch: None, - suffix: lite::PathRef::literal(suffix), - hops: lite::HopsRef::literal(hops), - cost, - })?, - } - } - // Pre-restart versions have no way to update a live - // advertisement; the peer keeps the original chain. - Some(_) => {} - None => { - tracing::debug!(route = %absolute, "announce"); - self.start(stream, suffix, hops, cost, update.route.epoch.clone())?; - } - }, + 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)?, @@ -1068,6 +1415,8 @@ struct RequestServe> { _charged: Option, // The latest update to arrive before the reply started. Each one replaces the last. update: Option, + /// Ends the request, even mid-reply, once the session stops allowing its broadcast. + gate: Option, // Log context, filled in after the decode. absolute: crate::PathOwned, track: String, @@ -1107,6 +1456,7 @@ impl> RequestServe { owing: true, _charged: None, update: None, + gate: None, absolute: Default::default(), track: Default::default(), budget: kio::coop::Budget::new(32), @@ -1130,9 +1480,14 @@ impl> RequestServe { // decode is answered on the stream instead. Err(err) if matches!(self.state, RequestState::Decode) => Poll::Ready(Err(err)), Err(err) => { - match is_cancel(&err) { - true => tracing::info!(broadcast = %self.absolute, track = %self.track, "{} cancelled", R::KIND), - false => { + match &err { + err if is_cancel(err) => { + tracing::info!(broadcast = %self.absolute, track = %self.track, "{} cancelled", R::KIND) + } + Error::Unauthorized | Error::Stream(crate::StreamError::Unauthorized) => { + tracing::info!(broadcast = %self.absolute, track = %self.track, "{} unauthorized", R::KIND) + } + err => { tracing::warn!(broadcast = %self.absolute, track = %self.track, %err, "{} error", R::KIND) } } @@ -1143,6 +1498,11 @@ impl> RequestServe { } 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)); + } let mut cx = waiter.context(); loop { ready!(self.budget.poll_yield(waiter)); @@ -1180,7 +1540,15 @@ impl> RequestServe { } } tracing::info!(broadcast = %self.absolute, track = %self.track, request = ?msg, "{} started", R::KIND); + // 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(R::target(&msg).0); + let denied = gate.poll_denied(waiter).is_ready(); + self.gate = Some(gate); self.state = RequestState::Resolve { msg, requesting: None }; + if denied { + return Poll::Ready(Err(Error::Unauthorized)); + } } RequestState::Resolve { msg, requesting } => { if requesting.is_none() { @@ -2095,6 +2463,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( @@ -2113,7 +2486,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; @@ -2275,6 +2648,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] @@ -2317,7 +2730,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; @@ -4449,6 +4862,9 @@ mod tests { peer_setup, goaway, peer_hop: Some(assigned), + auth: crate::auth::Handle::new(false), + peer_grant: Default::default(), + client: false, subscriptions: Default::default(), }); @@ -4509,6 +4925,7 @@ mod tests { &mut announced, self_origin, Version::Lite01, + None, )); assert!(futures::poll!(run.as_mut()).is_pending()); @@ -4543,6 +4960,7 @@ mod tests { &mut announced, self_origin, Version::Lite01, + None, )); assert!(futures::poll!(run.as_mut()).is_pending()); drop(cam); @@ -4571,7 +4989,7 @@ mod tests { let consumer = origin.consume(); let mut announced = consumer.announced(); - let mut run = AnnounceRun::new(self_origin, Version::Lite01); + let mut run = AnnounceRun::new(crate::PathOwned::default(), self_origin, Version::Lite01); run.withdraw(&mut stream, &consumer, &mut announced).unwrap(); let _ = kio::wait(|waiter| Poll::Ready(run.poll(&mut stream, &consumer, &mut announced, waiter))).await; @@ -4615,6 +5033,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, subscriptions: Default::default(), }); ProbeServe::new(publisher.shared.clone(), publisher.runtime.clone(), stream) @@ -4788,6 +5209,9 @@ mod tests { peer_setup, goaway, peer_hop: None, + auth: crate::auth::Handle::new(false), + peer_grant: Default::default(), + client: false, subscriptions: Default::default(), }); @@ -4917,6 +5341,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(); @@ -4992,6 +5419,9 @@ mod tests { peer_setup, goaway, peer_hop: None, + auth: crate::auth::Handle::new(false), + peer_grant: Default::default(), + client: false, subscriptions: Default::default(), }); @@ -5079,6 +5509,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(); @@ -5227,6 +5660,9 @@ mod tests { goaway, peer_hop: None, subscriptions: crate::session::Slots::new(0), + auth: crate::auth::Handle::new(false), + peer_grant: Default::default(), + client: false, }); let _ = publisher.poll(&kio::Waiter::noop()); diff --git a/rs/moq-net/src/lite/session.rs b/rs/moq-net/src/lite/session.rs index fdb059dda9..a40940f483 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, /// Whether the peer's SETUP arrived, read by [`crate::Session::setup`]. pub setup: crate::session::Setup, } @@ -139,6 +141,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, @@ -172,6 +179,10 @@ pub struct Config { /// 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, /// What the peer may make this session hold. pub limits: crate::session::Limits, } @@ -185,6 +196,7 @@ where { let Config { runtime, + client, session, setup_stream, publish, @@ -193,6 +205,7 @@ where version, mut our_setup, peer_setup, + auth, limits, } = config; @@ -222,6 +235,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 @@ -270,6 +292,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(), @@ -278,6 +307,9 @@ where peer_setup: peer_setup.clone(), goaway: goaway.clone(), peer_hop, + auth: auth.clone(), + peer_grant, + client, subscriptions, }); let mut subscriber = Subscriber::new(SubscriberConfig { @@ -293,10 +325,22 @@ where // for its own egress. cost: our_cost, going_away: goaway.going_away.clone(), + auth: auth.clone(), }); subscriber.announces = announces; 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(), + violation: Default::default(), + started: false, + _setup: setup_token, + }, local_close: Default::default(), setup: version .has_setup_stream() @@ -312,6 +356,7 @@ where recv_bandwidth: recv_bw_consumer, driver, goaway: goaway_handle, + auth, setup, }) } @@ -319,6 +364,8 @@ where /// 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, pub(crate) local_close: std::sync::Arc, /// Advertising our capabilities, or `None` once sent (or on a version with no /// Setup Stream). @@ -341,6 +388,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(), + }); if self.local_close.load(std::sync::atomic::Ordering::Relaxed) { self.subscriber.close(); } else if let Err(err) = &res { @@ -378,6 +429,11 @@ where fn poll_protocol(&mut self, waiter: &kio::Waiter) -> Poll> { let mut cx = waiter.context(); + // 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 && setup.poll(&mut cx).is_ready() @@ -405,6 +461,190 @@ 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>, + /// 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. + _setup: Option, +} + +impl Present { + /// 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. + 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(), + 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, + } + } +} + +/// 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, + violation: kio::Shared>, + id: u64, + state: PresentState, +} + +enum PresentState { + Open { token: bytes::Bytes }, + Run { stream: Stream, answered: bool }, + Done, +} + +impl kio::Task for PresentToken { + type Output = (); + + 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"), + } + if let Error::ProtocolViolation = err { + self.violation.lock().get_or_insert(err.clone()); + } + 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 = self.runtime.now(); + // 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) => { + // 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); + *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 @@ -643,6 +883,53 @@ impl SendGoaway { 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. + #[moq_net_sim::test] + 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 crate::coding::Encoder::new(&mut script, Version::Lite07.into()), + 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(); + + let start = start(Config { + runtime: crate::time::Clock::sim(), + client: true, + limits: Default::default(), + session: transport, + setup_stream: None, + publish: None, + subscribe: None, + peer_hop: None, + version: Version::Lite07, + our_setup: Setup::default(), + peer_setup: None, + auth: crate::auth::Handle::new(true), + }) + .unwrap(); + let mut driver = start.driver; + + let res = moq_net_sim::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()); + } #[moq_net_sim::test] async fn accept_setup_does_not_wait_on_an_early_stream_header() { @@ -697,6 +984,8 @@ mod tests { version, our_setup: Setup::default(), peer_setup: None, + client: false, + auth: crate::auth::Handle::new(version.has_auth()), limits: Default::default(), }) .unwrap(); @@ -736,6 +1025,8 @@ mod tests { version, our_setup: Setup::default(), peer_setup: None, + client: false, + auth: crate::auth::Handle::new(version.has_auth()), limits: Default::default(), }) .unwrap(); diff --git a/rs/moq-net/src/lite/stream.rs b/rs/moq-net/src/lite/stream.rs index 9de36c9594..4ccadbe414 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 747cf8385b..83ba112e3c 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; this session's routes then cost /// [`crate::origin::Cost::DRAIN`], so a replacement session outranks it. pub going_away: crate::goaway::GoingAway, + /// Our tokens' grants: a subscription the union stops covering is cancelled. + pub auth: crate::auth::Handle, } #[derive(Clone)] @@ -81,6 +83,7 @@ pub(super) struct Subscriber { /// [`SourceServe`] machines. sources: kio::Queue, going_away: crate::goaway::GoingAway, + auth: crate::auth::Handle, /// What this session may allocate up front for frames still arriving. frames: frame::Budget, /// Broadcasts the peer may have announced at once (`session::Limits::announces`). @@ -119,6 +122,7 @@ impl Subscriber { cost: config.cost, sources: kio::Queue::new(), going_away: config.going_away, + auth: config.auth, frames: Default::default(), announces: Default::default(), } @@ -340,13 +344,7 @@ impl Subscriber { // outside our scope, so don't serve it. Reflections are already // filtered above. let route = self.announced_route(&path, hops, cost, link_cost, responder_origin, announced); - let Ok(dynamic) = self.origin.dynamic(&path, route.clone()) else { - return Ok(false); - }; - - announced.attach(path, route, dynamic); - - Ok(true) + Ok(announced.offer(self, path, route)) } /// The route to announce for a prefix this peer advertised, charging our @@ -432,13 +430,7 @@ impl Subscriber { return Ok(true); } - let Ok(dynamic) = self.origin.dynamic(&path, metadata.clone()) else { - announced.declined(path); - return Ok(false); - }; - announced.attach(path, metadata, dynamic); - - Ok(true) + Ok(announced.offer(self, path, metadata)) } /// Handle an ANNOUNCE_RESTART: another publisher instance replaces a live @@ -468,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 @@ -1320,6 +1307,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 new limit holds back or brings back routes. + auth_epoch: u64, } impl AnnouncePrefix { @@ -1408,6 +1397,7 @@ impl AnnouncePrefix { link_cost, announced: Announced::new(self.subscriber.announces.clone()), decoder: lite::AnnounceDecoder::default(), + auth_epoch: 0, }; // Lite01/02 send the initial set as one ANNOUNCE_INIT message, so they @@ -1449,6 +1439,16 @@ impl AnnouncePrefix { if self.subscriber.going_away.poll(waiter).is_ready() { run.announced.drain(); } + // 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.auth_epoch, waiter) + { + run.announced.limit(&permit, &self.subscriber); + } loop { match stream.reader.poll_decode_maybe::(&mut cx) { Poll::Ready(Ok(Some(announce))) => { @@ -1693,6 +1693,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), + auth: crate::auth::Handle::new(false), }); let track = track::Producer::new(std::sync::Arc::new(crate::broadcast::Info::default()), "video", None); let mut consumer = track.consume().subscribe(None).await.unwrap(); @@ -1743,6 +1744,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), + auth: crate::auth::Handle::new(false), }); let mut track = track::Producer::new(std::sync::Arc::new(crate::broadcast::Info::default()), "video", None); @@ -1808,6 +1810,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), + auth: crate::auth::Handle::new(false), }); let serve = TrackServe { subscriber, @@ -1828,9 +1831,11 @@ mod tests { .subscribe(None) .await .unwrap(); + let gate = serve.gate(); let mut running = TrackServeRun { serve, state: TrackRunState::Serve(serving), + gate, }; kio::wait(|waiter| kio::Task::poll(&mut running, waiter)).await; @@ -1861,6 +1866,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), + auth: crate::auth::Handle::new(false), }); let serve = TrackServe { subscriber, @@ -1878,9 +1884,11 @@ mod tests { .subscribe(None) .await .unwrap(); + let gate = serve.gate(); let mut running = TrackServeRun { serve, state: TrackRunState::Serve(serving), + gate, }; assert!( kio::Task::poll(&mut running, &kio::Waiter::noop()).is_ready(), @@ -1955,6 +1963,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(); @@ -2018,6 +2027,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(); @@ -2075,6 +2085,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(); @@ -2135,6 +2146,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 { @@ -2182,6 +2194,64 @@ mod tests { assert!(wire.is_empty(), "a second SUBSCRIBE trailed the first"); } + /// A narrower limit landing while the SUBSCRIBE is still opening resets it with + /// UNAUTHORIZED, like a live one, and releases its id. + #[moq_net_sim::test] + 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()); + 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::sim(), + 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(), + epoch: None, + 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.authorize(&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, @@ -2209,6 +2279,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(); @@ -2638,6 +2709,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(); @@ -2692,6 +2764,7 @@ mod tests { cost: None, peer_hop: Some(crate::Hop::new(777).unwrap()), going_away: Default::default(), + auth: crate::auth::Handle::new(false), }); let mut announced = Announced::default(); @@ -2745,6 +2818,7 @@ mod tests { cost: None, peer_hop: Some(crate::Hop::new(777).unwrap()), going_away: Default::default(), + auth: crate::auth::Handle::new(false), }); let path = Path::new("pool").to_owned(); let mut announced = Announced::default(); @@ -2813,6 +2887,7 @@ mod tests { cost: None, peer_hop: Some(crate::Hop::new(777).unwrap()), going_away: Default::default(), + auth: crate::auth::Handle::new(false), }); let mut announced = Announced::default(); subscriber @@ -2868,6 +2943,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(); @@ -2926,6 +3002,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(); @@ -2984,6 +3061,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. @@ -3050,6 +3128,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. @@ -3109,6 +3188,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 @@ -3155,6 +3235,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(); @@ -3234,6 +3315,7 @@ mod tests { cost: None, peer_hop: None, going_away: Default::default(), + auth: crate::auth::Handle::new(false), }); (subscriber, consumer) } @@ -3276,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. @@ -3297,6 +3422,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(); @@ -3380,6 +3506,7 @@ mod tests { cost: Some(1), peer_hop: None, going_away: Default::default(), + auth: crate::auth::Handle::new(false), }); let mut prefix = AnnouncePrefix::new(subscriber, Path::new("").to_owned()); let mut cursor = consumer.announced(); @@ -3427,6 +3554,7 @@ mod tests { cost: Some(1), peer_hop: None, going_away: Default::default(), + auth: crate::auth::Handle::new(false), }); let slots = crate::session::Slots::new(2); subscriber.announces = slots.clone(); @@ -3538,7 +3666,7 @@ enum Sub { /// A declined advertisement remains present with no route because the peer still /// owns its path and announce id until it retracts or restarts it. struct Announced { - routes: HashMap>, + routes: HashMap, /// The session's announce cap, and one slot taken from it per entry in `routes`. slots: crate::session::Slots, held: Vec, @@ -3551,6 +3679,17 @@ struct 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(Box), +} + #[cfg(test)] impl Default for Announced { fn default() -> Self { @@ -3582,12 +3721,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(Box::new(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. @@ -3607,7 +3776,7 @@ impl Announced { if let Some(epoch) = epoch { self.epochs.insert(path.clone(), epoch); } - self.routes.insert(path, None); + self.routes.insert(path, Held::Declined); Ok(()) } @@ -3625,18 +3794,21 @@ impl Announced { } 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.as_mut()), + _ => None, + } } /// 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) { self.epochs.remove(path); - let Some(entry) = self.routes.remove(path) else { + let Some(held) = self.routes.remove(path) else { return; }; self.held.pop(); - if let Some(entry) = entry { + if let Held::Attached(entry) = held { entry.dynamic.withdrawn(); } } @@ -3648,7 +3820,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. @@ -3681,10 +3853,44 @@ impl Announced { } } + /// 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(); + } } } } @@ -3788,6 +3994,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, + ) + } + /// The mid-group start a peer without frame bounds can't be asked for, recorded so /// the frames below it are dropped when its whole group arrives. fn widen_frame_bounds(&self, subscription: &mut Subscription) { @@ -4137,6 +4353,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, @@ -4157,6 +4381,8 @@ impl Establish { struct TrackServeRun { serve: TrackServe, state: TrackRunState, + /// 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 @@ -4189,7 +4415,35 @@ impl TrackServeRun { let info = track::Info::default().with_timescale(None); TrackRunState::Serve(ServeLoop::new(&serve, request, info, None)) }; - Self { serve, state } + let gate = serve.gate(); + Self { serve, state, gate } + } + + /// 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) { + TrackRunState::Info { request, .. } => { + if let Some(request) = request { + request.reject(Error::Unauthorized); + } + } + 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 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::Finish(_) | TrackRunState::Done => {} + } } } @@ -4197,6 +4451,11 @@ impl kio::Task for TrackServeRun { type Output = (); fn poll(&mut self, waiter: &kio::Waiter) -> Poll<()> { + // A finished track only waits for its FIN to be acknowledged: nothing is left to revoke. + if !matches!(self.state, TrackRunState::Finish(_)) && self.gate.poll_denied(waiter).is_ready() { + self.revoke(); + return Poll::Ready(()); + } loop { match &mut self.state { TrackRunState::Info { request, info } => { @@ -4798,6 +5057,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 { type Output = (); diff --git a/rs/moq-net/src/lite/test_transport.rs b/rs/moq-net/src/lite/test_transport.rs index be8f698891..bf6fc1bf45 100644 --- a/rs/moq-net/src/lite/test_transport.rs +++ b/rs/moq-net/src/lite/test_transport.rs @@ -459,10 +459,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 } } diff --git a/rs/moq-net/src/lite/version.rs b/rs/moq-net/src/lite/version.rs index 97b648d873..bc41a7b11e 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, } @@ -124,6 +124,17 @@ impl Version { } } + /// Whether either endpoint may open an Auth Stream (0x7) to present a token and + /// 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 (AGENTS.md convention). + match self { + Self::Lite01 | Self::Lite02 | Self::Lite03 | Self::Lite04 | Self::Lite05 | Self::Lite06 => 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 637d416157..95d2651ab2 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -94,6 +94,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, @@ -118,6 +119,7 @@ impl Server { let start = lite::start(lite::Config { runtime: runtime.clone(), + client: false, limits: self.limits, session: session.clone(), setup_stream: None, @@ -127,6 +129,7 @@ impl Server { version, our_setup, peer_setup: client_setup, + auth, })?; Ok(Session::new( @@ -135,8 +138,11 @@ impl Server { version.into(), start.recv_bandwidth, crate::driver::Protocol::Lite(Box::new(start.driver)), - start.goaway, - start.setup, + crate::session::Handles { + goaway: start.goaway, + auth: start.auth, + setup: start.setup, + }, )) } @@ -230,6 +236,12 @@ 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, @@ -237,6 +249,7 @@ impl Server { // moq-lite carries no SETUP token. token: None, assigned_hop: crate::Hop::random(), + auth, inner: Some(RequestInner { server: self.clone(), runtime, @@ -387,6 +400,8 @@ impl Server { origin: None, token, 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, @@ -423,6 +438,8 @@ impl Server { origin: peer_setup.declared.cluster.hop.filter(|h| *h != crate::Hop::UNKNOWN), token: peer_setup.token.clone(), assigned_hop: crate::Hop::random(), + // 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, @@ -452,6 +469,9 @@ pub struct Handshake { /// peer declares none on the wire; it never enters a hop chain. 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>, } @@ -495,7 +515,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); @@ -516,7 +542,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 { @@ -545,6 +577,7 @@ where authority: None, peer_setup_stream: Some(peer_setup.stream), peer_declared: Some(peer_setup.declared), + auth: auth.clone(), early_unis: peer_setup.early, })?; tracing::debug!(?version, "connected"); @@ -554,8 +587,7 @@ where version.into(), None, crate::driver::Protocol::Ietf(protocol), - goaway, - setup, + crate::session::Handles { goaway, auth, setup }, )) } .maybe_boxed() @@ -584,7 +616,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 { @@ -620,12 +658,13 @@ where }; stream.writer.encode(&server_setup).await?; - let (recv_bw, protocol, goaway, setup) = match version { + let (recv_bw, protocol, goaway, auth, setup) = 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, limits: server.limits, session: session.clone(), setup_stream: Some(stream), @@ -635,11 +674,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, start.setup, ) } @@ -662,14 +703,20 @@ where authority: None, peer_setup_stream: None, peer_declared: Some(peer_declared), + auth: auth.clone(), early_unis: Vec::new(), })?; - (None, crate::driver::Protocol::Ietf(protocol), goaway, setup) + (None, crate::driver::Protocol::Ietf(protocol), goaway, auth, setup) } }; Ok(Session::new( - runtime, session, version, recv_bw, protocol, goaway, setup, + runtime, + session, + version, + recv_bw, + protocol, + crate::session::Handles { goaway, auth, setup }, )) } .maybe_boxed() @@ -718,6 +765,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() + } + /// The credential the client presented in its SETUP's `AUTHORIZATION TOKEN` option. /// /// Only moq-transport carries one, so moq-lite sessions return `None`. The transport @@ -772,6 +827,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, @@ -780,14 +836,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, } } @@ -1274,6 +1330,7 @@ mod tests { origin: None, token: None, assigned_hop: Hop::random(), + auth: crate::auth::Handle::new(false), inner: Some(RequestInner { server: Server::new().with_publisher(&origin), runtime: Clock::new(moq_net_sim::now()), diff --git a/rs/moq-net/src/session.rs b/rs/moq-net/src/session.rs index a072ca3352..4303a0baf5 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 crate::transport::Stats as _; -use crate::{Error, SessionError, Version, bandwidth, goaway}; +use crate::{Error, SessionError, Version, auth, bandwidth, goaway}; /// How long [`Session::close`] waits for queued data before closing anyway. const CLOSE_TIMEOUT: Duration = Duration::from_secs(1); @@ -126,6 +126,7 @@ pub struct Session { send_bandwidth: Option, recv_bandwidth: Option, goaway: Arc, + auth: auth::Handle, setup: Setup, } @@ -300,6 +301,23 @@ 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-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 { + self.auth.clone() + } +} + +/// 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 { @@ -309,12 +327,12 @@ impl Session { version: Version, recv_bandwidth: Option, protocol: crate::driver::Protocol, - goaway: goaway::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. @@ -354,6 +372,7 @@ impl Session { send_bandwidth, recv_bandwidth, goaway: Arc::new(goaway), + auth, setup, }; let driver = crate::Driver::new( diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs new file mode 100644 index 0000000000..e402c0b7d5 --- /dev/null +++ b/rs/moq-net/tests/auth.rs @@ -0,0 +1,1417 @@ +//! 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-07-wip and on moq-transport with the MoQ Auth extension. + +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 support::harness::{now, spawn}; +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"; + +/// Run each case on every version that exchanges AUTH. +macro_rules! cases { + ($($case:ident),* $(,)?) => { + mod lite_07 { + $(#[moq_net_sim::test] async fn $case() { super::$case(super::LITE_07).await })* + } + mod moqt_17 { + $(#[moq_net_sim::test] async fn $case() { super::$case(super::MOQT_17).await })* + } + mod moqt_22 { + $(#[moq_net_sim::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, + 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, +); + +/// Run each case on moq-transport alone, whose namespace prefixes cannot carry every +/// pattern. +macro_rules! prefix_cases { + ($($case:ident),* $(,)?) => { + mod moqt_17_prefixes { + $(#[moq_net_sim::test] async fn $case() { super::$case(super::MOQT_17).await })* + } + mod moqt_22_prefixes { + $(#[moq_net_sim::test] async fn $case() { super::$case(super::MOQT_22).await })* + } + }; +} + +prefix_cases!( + an_unrepresentable_grant_is_unsupported, + an_unrepresentable_update_revokes_only_its_token, + a_grant_too_large_for_one_message_is_unsupported, +); + +#[moq_net_sim::test] +async fn lite_07_carries_pattern_grants() { + pattern_grants_arrive_exactly(LITE_07).await +} + +#[moq_net_sim::test] +async fn lite_07_enforces_a_wildcard_grant() { + a_wildcard_grant_is_enforced(LITE_07).await +} + +#[moq_net_sim::test] +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. +#[moq_net_sim::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"); +} + +#[moq_net_sim::test] +async fn lite_05_has_no_grant() { + older_versions_have_no_grant("moq-lite-05").await +} + +#[moq_net_sim::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 { + let (producer, driver) = origin::Producer::new(origin::Config::new(Hop::new(hop).unwrap())); + spawn(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(); + let apply = |live: &mut std::collections::HashSet, event: moq_net::announce::Event| match event { + moq_net::announce::Event::Start(update) + | moq_net::announce::Event::Update(update) + | moq_net::announce::Event::Restart(update) => { + live.insert(update.prefix.to_string()); + } + moq_net::announce::Event::End(update) => { + 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")); + } +} + +#[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, + server_transport: MockSession, + requests: Option, + /// Aborting it drops the server's driver without letting it finish. + server_driver: moq_net_sim::JoinHandle, +} + +async fn connect(opts: Options) -> Pair { + 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()); + 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 observe_server = server_transport.clone(); + let client_fut = async { + let (session, driver) = client.connect(now(), client_transport).await.expect("client handshake"); + spawn(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, mut driver) = handshake.ok().await.expect("server accept"); + let driver = moq_net_sim::spawn(moq_net_sim::drive(move |now, waiter| { + moq_net::time::Driver::poll(&mut driver, now, waiter) + })); + (session, requests, driver) + }; + let (client, (server, requests, server_driver)) = futures::join!(client_fut, server_fut); + + Pair { + client, + server, + client_transport: observe, + server_transport: observe_server, + requests, + server_driver, + } +} + +/// 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 + 'static, +) -> futures::channel::mpsc::UnboundedReceiver<(Vec, auth::Issued)> { + let (tx, rx) = futures::channel::mpsc::unbounded(); + moq_net_sim::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.unbounded_send((token, request.accept(grant))); + } + None => unanswered.push(request), + } + } + }); + rx +} + +fn within(f: F) -> impl std::future::Future> { + moq_net_sim::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. +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()), + 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. +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() + }) + .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. +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() + }) + .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. +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() + }) + .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. +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, + ..Default::default() + }) + .await; + let _issued = serve(pair.requests.take().unwrap(), |token| { + token.is_empty().then(|| grant(&["baz"], &[])) + }); + + let auth = pair.client.auth(); + let pending = moq_net_sim::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. +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, + ..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.next().await.unwrap(); + + let t1 = pair.client.auth().add("t1").await.expect("t1 granted"); + let (_, t1_issued) = issued.next().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. +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, + ..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.next().await.unwrap(); + let t1 = pair.client.auth().add("t1").await.unwrap(); + let (_, t1_issued) = issued.next().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. +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, + ..Default::default() + }) + .await; + let mut issued = serve(pair.requests.take().unwrap(), |token| match token { + b"" | b"again" => Some(grant(&["a"], &[])), + _ => None, + }); + let (_, setup) = issued.next().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. +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() + }) + .await; + let mut requests = pair.requests.take().unwrap(); + moq_net_sim::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. +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() + }) + .await; + let mut requests = pair.requests.take().unwrap(); + moq_net_sim::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. +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() + }) + .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 = moq_net_sim::spawn(async move { auth.add("queued").await.map(drop) }); + // Give the second token time to reach the queue before dropping it. + moq_net_sim::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. +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() + }) + .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 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. +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() + }) + .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"); +} + +/// 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, _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) { + within(async { + let ts = |ms| moq_net::Timestamp::from_millis(ms).unwrap(); + let prefs = || moq_net::track::Subscription::default().with_max_delay(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 { + 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, + }) + .await; + let mut issued = serve(pair.requests.take().unwrap(), |token| { + token.is_empty().then(|| grant(&["up"], &["room"])) + }); + let (_, setup) = issued.next().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"); + 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. + let err = loop { + match up_sub.recv_group().await { + Ok(Some(_)) => continue, + Ok(None) => panic!("served subscription finished instead of ending"), + 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_07 { + 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_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?}"); + } + }) + .await + .expect("timed out"); +} + +/// 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 { + 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.next().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 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 { + 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.next().await.unwrap(); + let t1 = pair.client.auth().add("t1").await.unwrap(); + let (_, t1_issued) = issued.next().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, + }); + 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"); +} + +/// 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.next().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() +} + +/// 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.next().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. +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 = moq_net_sim::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"); +} + +/// 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 limit_lite_05 { + $(#[moq_net_sim::test] async fn $case() { super::$case("moq-lite-05").await })* + } + mod limit_lite_06 { + $(#[moq_net_sim::test] async fn $case() { super::$case(super::LITE_06).await })* + } + mod limit_lite_07 { + $(#[moq_net_sim::test] async fn $case() { super::$case(super::LITE_07).await })* + } + mod limit_moqt_16 { + $(#[moq_net_sim::test] async fn $case() { super::$case("moq-transport-16").await })* + } + mod limit_moqt_17 { + $(#[moq_net_sim::test] async fn $case() { super::$case(super::MOQT_17).await })* + } + }; +} + +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, +); + +#[moq_net_sim::test] +async fn lite_05_narrowing_resets_a_fetch_in_flight() { + a_narrowing_resets_a_fetch_in_flight("moq-lite-05").await +} + +#[moq_net_sim::test] +async fn lite_06_narrowing_resets_a_fetch_in_flight() { + a_narrowing_resets_a_fetch_in_flight(LITE_06).await +} + +#[moq_net_sim::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_07 | 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_delay(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().authorize(&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, 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?}"); + } + 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_delay(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().authorize(&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().authorize(&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, + } + }; + // The narrowing also retracts the route, and a fetch-only track whose route + // leaves ends `Dropped` locally, so the reader may see either. The wire carries + // the revocation regardless. + assert!(unauthorized(&err) || matches!(err, moq_net::Error::Dropped), "{err:?}"); + let resets = pair.server_transport.resets(); + assert!(resets.contains(&StreamError::Unauthorized.to_code()), "{resets:x?}"); + drop(group); + }) + .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_delay(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().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().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(); + 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_delay(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().authorize(&grant(&["room/bob/cam"], &[])); + wait_announced(&relay.consume(), "room/bob/mic", false).await; + + 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(); + 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-net/tests/support/mock.rs b/rs/moq-net/tests/support/mock.rs index c8ec82bef5..deeebe2744 100644 --- a/rs/moq-net/tests/support/mock.rs +++ b/rs/moq-net/tests/support/mock.rs @@ -130,6 +130,8 @@ type Flight = (std::time::Instant, StreamChunk); /// 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, /// Acknowledge the FIN as soon as it is sent, for a stream the peer's transport holds @@ -138,6 +140,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, } @@ -159,6 +164,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()), @@ -187,6 +197,7 @@ impl poll::SendStream for MockSendStream { fn reset(&mut self, code: u32) { if self.tx.is_some() { + self.resets.lock().unwrap().push(code); let _ = self.push(StreamChunk::Reset(code)); self.tx = None; } @@ -365,17 +376,25 @@ impl Drop for MockRecvStream { // ── Stream pair constructor ───────────────────────────────────────── -/// Create a linked (send, recv) stream pair. -fn new_stream_pair(conn: &Arc) -> (MockSendStream, 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(); let closed = Arc::new(ClosedSignal::default()); let send = MockSendStream { tx: Some(queue.clone()), + resets: resets.clone(), closed: closed.clone(), park: kio::Park::default(), ack_fin: false, withhold_fin: Arc::default(), + first_byte: None, conn: conn.clone(), }; let recv = MockRecvStream { @@ -444,6 +463,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, /// Uni streams this side opened that the peer has not accepted yet, while held. held: Mutex>>, /// Bidi streams this side opened that the peer has not accepted yet, while held. @@ -457,6 +479,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. @@ -511,9 +535,10 @@ impl poll::Session for MockSession { _cx: &mut Context<'_>, ) -> Poll> { // Create two stream pairs: one for each direction. - let (mut our_send, peer_recv) = new_stream_pair(&self.side.conn); + 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(); - let (peer_send, our_recv) = new_stream_pair(&self.side.conn); + 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); if let Some(held) = self.side.held_bidis.lock().unwrap().as_mut() { held.push((peer_send, peer_recv)); @@ -535,7 +560,7 @@ impl poll::Session for MockSession { return Poll::Ready(Err(self.close_error())); } - let (mut our_send, mut peer_recv) = new_stream_pair(&self.side.conn); + let (mut our_send, mut peer_recv) = new_stream_pair(&self.side.conn, &self.side.resets); peer_recv.split = self.side.split.lock().unwrap().clone(); if let Some(held) = self.side.held.lock().unwrap().as_mut() { @@ -712,6 +737,28 @@ impl 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.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)] + pub fn resets(&self) -> Vec { + self.side.resets.lock().unwrap().clone() + } + fn close_error(&self) -> MockError { self.side.conn.error().unwrap_or_else(MockError::closed) } @@ -735,6 +782,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(), @@ -745,12 +794,15 @@ 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(), held: Mutex::default(), held_bidis: Mutex::default(), withheld: Mutex::default(), split: Mutex::default(), lossy: Mutex::default(), withhold_bidi_fins: Arc::default(), + bidi_types: StreamTypes::default(), }); let server_side = Arc::new(SessionSide { @@ -762,12 +814,15 @@ pub fn create_mock_session_pair(protocol: Option<&'static str>) -> (MockSession, peer_datagrams: s2c_datagrams, protocol, conn, + resets: server_resets, + peer_resets: client_resets, held: Mutex::default(), held_bidis: Mutex::default(), withheld: Mutex::default(), split: Mutex::default(), lossy: Mutex::default(), withhold_bidi_fins: Arc::default(), + bidi_types: StreamTypes::default(), }); let new = |side| MockSession { diff --git a/rs/moq-pattern/src/pattern.rs b/rs/moq-pattern/src/pattern.rs index 49dca7f8c2..c4b0978d15 100644 --- a/rs/moq-pattern/src/pattern.rs +++ b/rs/moq-pattern/src/pattern.rs @@ -822,6 +822,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 ae8b85bdab..8b5905c391 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/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index 778bd502f2..3185823e56 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -419,8 +419,7 @@ 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. pub(crate) fn covered_by(&self, other: &Self) -> bool { self.root == other.root && self.mounts == other.mounts @@ -439,6 +438,8 @@ impl Token { pub struct Lease { consumer: lease::Consumer, token: Token, + /// 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 /// clock when the grant arrives, so re-polling [`ended`](Self::ended) never @@ -457,17 +458,26 @@ impl Lease { token: Token::new(path, &grant), expires: grant.deadline(), consumer, + session: None, stats: Default::default(), } } + /// 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 authorizing(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, with the patterns of + /// the latest re-check that resized it. pub fn token(&self) -> &Token { &self.token } @@ -480,12 +490,14 @@ 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` or `upstream` ends it too, since the routes it already announced - /// would be misreported as entering here or from a peer, or offered to the - /// wrong links. A changed tier keeps the - /// session and moves its [stats](Self::with_stats) to the new tier. + /// 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. + /// A changed root or mounts ends it, and so does a flipped `peer` or `upstream`, + /// since the routes it already announced would be misreported as entering here or + /// from a peer, or offered to the wrong links. 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 { @@ -512,8 +524,25 @@ impl Lease { if fresh.upstream != self.token.upstream { return "upstream changed".into(); } - if !self.token.covered_by(&fresh) { - return lease::Reason::Narrowed; + 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; re-authorizing the session", + ); + session.authorize(&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 lease::Reason::Narrowed, + None => {} + } } if fresh.tier != self.token.tier { tracing::info!(from = %self.token.tier, to = %fresh.tier, "tier changed"); @@ -537,6 +566,15 @@ impl Lease { } } +/// Patterns as one comma-separated line, for logs. +fn join(patterns: &Patterns) -> String { + patterns + .iter() + .map(|pattern| pattern.as_str()) + .collect::>() + .join(",") +} + /// Run gateway work while its admission lease still covers the session. /// /// Work is dropped when the lease expires, narrows, or is revoked. A completed diff --git a/rs/moq-relay/src/connection.rs b/rs/moq-relay/src/connection.rs index 11bedb2803..a28cd7e909 100644 --- a/rs/moq-relay/src/connection.rs +++ b/rs/moq-relay/src/connection.rs @@ -186,20 +186,22 @@ impl Connection { /// 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, and does not exit before this returns or the drain deadline. +/// 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 +/// shutdown drains the session with a GOAWAY instead of cutting it off, and does not +/// exit before this returns or the drain deadline. /// /// 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.authorizing(&session); let _serving = shutdown.serve(); loop { let nudged = async { diff --git a/rs/moq-relay/src/websocket.rs b/rs/moq-relay/src/websocket.rs index e90799856d..bb8d922c67 100644 --- a/rs/moq-relay/src/websocket.rs +++ b/rs/moq-relay/src/websocket.rs @@ -137,7 +137,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 @@ -218,6 +218,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.authorizing(&session); let _serving = shutdown.serve(); loop { diff --git a/rs/moq-relay/tests/auth_lifetime.rs b/rs/moq-relay/tests/auth_lifetime.rs index e69d104cfc..ec66957d67 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, -//! and every close reports `end` with what it moved. +//! the tier retags the live session's stats, a changed grant re-authorizes it in +//! place, a moved root or a refusal closes it, 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, //! including an outage that keeps it until `expires`, on Tokio's paused clock. @@ -567,9 +568,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 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_closes_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); @@ -577,18 +580,154 @@ 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, sub) in subs.iter_mut().enumerate() { + send(index, 0); + tokio::time::timeout(TIMEOUT, sub.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}"); + + // 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 resize 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] diff --git a/test/interop/README.md b/test/interop/README.md index 9e7ed9b0fa..53eb46eb3d 100644 --- a/test/interop/README.md +++ b/test/interop/README.md @@ -81,6 +81,37 @@ 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. 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 +`**/name` (a leading `**` matching zero segments), and the refused publisher +`interop-allowed-*.hang`. + ## Running locally You need the workspace toolchain on `PATH` (cargo, ffmpeg, bun, uv, go, @@ -137,6 +168,8 @@ The session lanes take the moq-lite drafts from each CLI's `--connect-version` choices. IETF drafts are left out on purpose: our clients and relays always prefer moq-lite with each other, and `just test interop` covers IETF. The relay offers only the cell's version, and JavaScript checks the negotiated version. +The relay is anonymous here, since released binaries may predate AUTH; the +default matrix covers tokens and grants. Current Rust media publishers feed released Rust and JS readers, then released publishers feed current readers, through both relay sources. Rust exports must decode to a video frame through ffmpeg. The existing JS subscriber @@ -243,7 +276,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) bare-fin.ts the JS side of `just test bare-fin`, driven by moq-net's tests varint.ts the JS side of the varint check, driven by moq-net's tests clients/ diff --git a/test/interop/clients/js-native/subscribe.ts b/test/interop/clients/js-native/subscribe.ts index bf7fe40a5e..b07c9fe051 100644 --- a/test/interop/clients/js-native/subscribe.ts +++ b/test/interop/clients/js-native/subscribe.ts @@ -67,10 +67,32 @@ async function video(bc: Moq.Broadcast.Consumer): Promise<[string, Catalog.Video async function run(): Promise { const origin = new Moq.Origin.Producer(); - const connect = process.env.INTEROP_COMPAT_TRANSPORT - ? (await import(process.env.INTEROP_COMPAT_TRANSPORT)).connect - : Moq.Connection.connect; - const connection = await connect({ url: new URL(url as string), consume: origin }); + // A released-compat run pins its version through its own transport and runs anonymous. + // Otherwise 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 compat = process.env.INTEROP_COMPAT_TRANSPORT; + const connection: Moq.Connection.Established = compat + ? await (await import(compat)).connect({ url: new URL(url as string), consume: origin }) + : 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) => { + if (!grant) return; + const publish = JSON.stringify(grant.publish); + const subscribe = JSON.stringify(grant.subscribe); + console.error(`auth granted publish=${publish} subscribe=${subscribe}`); + }; + let unwatch = () => {}; + if (!compat) { + printGrant(connection.auth.grant.peek()); + unwatch = connection.auth.grant.subscribe(printGrant); + } let requested: Moq.Origin.Requesting | undefined; try { const path = Moq.Path.from(broadcast as string); @@ -122,6 +144,7 @@ async function run(): Promise { } throw new Error("no frame data received"); } finally { + unwatch(); requested?.close(); // Wait for the transport to report the close too, so it reaches the relay before // process.exit; otherwise the relay times the connection out. diff --git a/test/interop/interop.sh b/test/interop/interop.sh index 27886383ab..936e06a7a6 100755 --- a/test/interop/interop.sh +++ b/test/interop/interop.sh @@ -14,6 +14,14 @@ # Every publisher but the Rust CLI also carries audio. The browser subscriber # verifies rendered WebCodecs output, player pause/resume, and that audio, then # that a session the relay refuses hands Chromium the close code and reason. +# +# 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. 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) @@ -44,6 +52,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 @@ -465,17 +474,53 @@ if harness_probe "$URL/certificate.sha256"; then exit 1 fi +# Released-wire compatibility (compat.sh) pins one version and may run released +# binaries that predate AUTH, so it runs anonymous: it compares the wire, and the +# default matrix covers auth. +COMPAT=0 +[[ -n "${INTEROP_VERSION:-}" ]] && COMPAT=1 + +# 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. +if [[ "$COMPAT" -eq 0 ]]; then + 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 +fi + 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" -if [[ -n "${INTEROP_VERSION:-}" ]]; then - # Version values come from the executable's advertised CLI choices. +if [[ "$COMPAT" -eq 1 ]]; then + # Anonymous, offering only the pinned version, which comes from the executable's + # advertised CLI choices. + sed -e "s|:4443\"|:${PORT}\"|g" -e '/^version = \[/,/^\]/d' -e 's|^url = "http://127.0.0.1:4440/"|public = "**"|' \ + "$INTEROP_DIR/interop.toml" >"$HARNESS_RUN/relay.toml" sed -i "/\[listen\]/a version = [\"${INTEROP_VERSION}\"]" "$HARNESS_RUN/relay.toml" - [[ "$(grep -c '^version = ' "$HARNESS_RUN/relay.toml")" == 1 ]] || { - echo "relay.toml needs exactly one pinned [listen] version" >&2 + [[ "$(grep -c '^version = ' "$HARNESS_RUN/relay.toml")" == 1 && "$(grep -c '^public = ' "$HARNESS_RUN/relay.toml")" == 1 ]] || { + echo "relay.toml needs exactly one pinned [listen] version and anonymous access" >&2 exit 1 } +else + sed -e "s|:4443\"|:${PORT}\"|g" -e "s|http://127.0.0.1:4440/|${AUTH_URL}|" \ + "$INTEROP_DIR/interop.toml" >"$HARNESS_RUN/relay.toml" fi harness_spawn relay "$HARNESS_RUN/relay.log" "$RELAY" "$HARNESS_RUN/relay.toml" if ! harness_ready "$URL/certificate.sha256" 30 "$HARNESS_PID"; then @@ -485,6 +530,63 @@ 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`). Compat runs anonymous, so it prints the bare URL. +token_url() { + if [[ "$COMPAT" -eq 1 ]]; then + printf '%s' "$URL" + return + fi + 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 lite version with AUTH, which no client offers by default. The clients the +# harness reads a grant or a refusal from dial it. Compat dials its pinned version. +AUTH_VERSION="${INTEROP_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. +prints_grant() { + [[ "$COMPAT" -eq 0 ]] || return 1 + 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 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\"}" + 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. @@ -504,31 +606,32 @@ 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" ${INTEROP_VERSION:+--connect-version "$INTEROP_VERSION"} --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" \ - 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" ;; cpp) - ffmpeg_h264 | "$CPP_INTEROP" publish --url "$URL" --broadcast "$broadcast" + ffmpeg_h264 | "$CPP_INTEROP" publish --url "$url" --broadcast "$broadcast" ;; js-native) # exec, so the client leads the group and `stop_publisher` waits for its own close. - exec bun "$INTEROP_JS_PUBLISH_CLIENT/client.ts" publish "$URL" "$broadcast" + exec bun "$INTEROP_JS_PUBLISH_CLIENT/client.ts" publish "$url" "$broadcast" ;; js) # Headless Chromium encodes its own H.264 from a fake camera via # WebCodecs (lazily, once a subscriber creates demand). exec, so the # driver leads the group and `stop_publisher` waits for its own close. cd "$CLIENTS/js" && exec bun driver.ts publish \ - --url "$URL" --broadcast "$broadcast" + --url "$url" --broadcast "$broadcast" ;; *) echo "unknown publisher: $lang" >&2 @@ -537,11 +640,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" } @@ -648,7 +752,7 @@ observe_group() { # 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 @@ -703,22 +807,22 @@ PYCODE return fi local n - n=$(timeout -k 3 "$TIMEOUT" "${INTEROP_SUB_MOQ:-$MOQ}" --connect "$URL" --broadcast "$broadcast" \ - export fmp4 | head -c 1 | wc -c | tr -d ' ' || true) + n=$(RUST_LOG="$CLI_LOG" timeout -k 3 "$TIMEOUT" "${INTEROP_SUB_MOQ:-$MOQ}" --connect "$url" \ + --connect-version "$AUTH_VERSION" --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" ;; 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" + "$C_INTEROP" subscribe --url "$url" --broadcast "$broadcast" --timeout "$TIMEOUT" ;; gst) # moqsrc exposes each rendition as a Sometimes pad (video_%u / audio_%u), @@ -738,7 +842,7 @@ PYCODE trap '' PIPE 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 ) @@ -751,21 +855,21 @@ PYCODE # 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 @@ -786,63 +890,72 @@ 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). 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, and is the relay log +# line the round's connections start on. run_round() { - local pub="$1" broadcast="$2" pub_pid="$3" from="$4" - local pids=() names=() i sub + local pub="$1" broadcast="$2" pub_pid="$3" want_pass="$4" from="$5" + 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 # `stop_publisher` reaps it, retiring the entry, so teardown never signals this # now-reaped (possibly recycled) PID again. if [[ -n "$pub_pid" ]]; then - stop_publisher "$pub_pid" "$pub" + # A round is named after its publisher's language, plus `-denied` for the refused one. + stop_publisher "$pub_pid" "${pub%-denied}" fi - # The negative control's subscribers end by timing out, which is its point. - [[ "$NEGATIVE" -eq 1 || "$IDLE_CHECK" -eq 0 ]] || check_idle_outs "$pub" "$from" + # A round that expects no data ends its subscribers by timing out, which is its point. + [[ "$want_pass" -eq 0 || "$IDLE_CHECK" -eq 0 ]] || check_idle_outs "$pub" "$from" return 0 } @@ -853,7 +966,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 @@ -866,7 +979,27 @@ run_media() { fi } +# 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() { + [[ "$COMPAT" -eq 0 ]] || return 1 + case "$1" in + rust) 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. @@ -886,7 +1019,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" "" "$(relay_next_line)" + run_round "none" "interop-missing-$$-$RANDOM.hang" "" 0 "$(relay_next_line)" else for pub in "${PUB_LIST[@]}"; do broadcast="interop-${pub}-$$-${RANDOM}.hang" @@ -898,9 +1031,39 @@ else overall=1 continue fi + # The exact broadcast, not its subtree. from=$(relay_next_line) - start_publisher "$pub" "$broadcast" - run_round "$pub" "$broadcast" "$PUB_PID" "$from" + start_publisher "$pub" "$pub" "$broadcast" "$(token_url --publish "$broadcast")" + run_round "$pub" "$broadcast" "$PUB_PID" 1 "$from" + 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-*.hang" + echo "=== publisher: $pub broadcast: $broadcast (token grants only $allowed) ===" + from=$(relay_next_line) + start_publisher "$pub-denied" "$pub" "$broadcast" "$(token_url --publish "$allowed")" + run_round "$pub-denied" "$broadcast" "$PUB_PID" 0 "$from" + 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 # The relay refuses a token on its public rules, and Chromium has to read the close code and diff --git a/test/interop/interop.toml b/test/interop/interop.toml index 083145e27e..b3802beb34 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" @@ -8,11 +8,32 @@ 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. 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/" 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 12cb5a79c7..024b01c27a 100755 --- a/test/ts/run.sh +++ b/test/ts/run.sh @@ -309,8 +309,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