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..ad23f04621 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. @@ -108,6 +113,16 @@ TOKEN` option with Token Type 0. HMAC, RSA, ECDSA, and EdDSA keys all work. A key can itself be **scoped** at generation (`--root`, `--publish`, `--subscribe`), after which it can never sign a broader token. +### On a request + +A moq-transport client may also put the `AUTHORIZATION TOKEN` on a SUBSCRIBE or +PUBLISH\_NAMESPACE, and renew it in place with an update. The session's +credential is checked first, so a request it covers is served whatever the +request carries. moq-relay does not verify request tokens yet: one on a request +the session's credential does not cover is refused `NOT_SUPPORTED`. An +application built on moq-net can verify them itself with +`auth::Handle::requests()`. + ### Claims | Claim | Meaning | diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index a99b376019..a754080fb1 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -138,6 +138,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..6b4f73d9a6 100644 --- a/doc/concept/standard.md +++ b/doc/concept/standard.md @@ -29,8 +29,8 @@ subset drops. What a peer actually observes against this implementation: - **History is one group.** A `FETCH` returns one group from the cache, or on drafts 14 through 19 the saved prefix of the group a new subscription just joined. A range of groups is refused, as is a draft 20+ `LOCATION_FILTER` that ends at Largest Object rather than an absolute End Location. On draft 20 and later, `FETCH_OK`'s End Location is the requested end, capped at Largest Object: a range holding no objects gets an empty fetch stream, and only a start past Largest Object is refused, with `INVALID_RANGE`. A relay's copy with no upstream subscription can't know Largest Object short of the track's end, so it echoes the requested end instead. JavaScript publishing refuses every `FETCH`. Datagrams are never fetchable. A Rust reader that only fetches learns the track from `TRACK_STATUS` instead of subscribing, so a finished track stays fetchable; draft 17 still subscribes, since its `TRACK_STATUS` answer cannot say whether the track is timed. - **Timing.** A track whose `SUBSCRIBE_OK` declares no `TIMESCALE` is untimed, as is every track on drafts 14 through 16. A Rust reader that never subscribed takes the units from `TRACK_STATUS` on draft 18 and later. - **Strict SETUP and parameters.** Repeated unknown `SETUP` options, GREASE included, are accepted if well-formed; a repeated known option is rejected. On draft 17 and later, a `GROUP_ORDER` other than Ascending (1) or Descending (2) closes the session. Message parameters follow the negotiated draft's lists: drafts 14 and 15 ignore an unknown or misplaced parameter, draft 16 ignores one defined only for another message but closes the session on an unknown one, and draft 17 and later close it on both. -- **One credential per session**, carried in `SETUP` and forwarded to the [auth server](/bin/relay/auth#the-contract) unverified. A token attached to an individual request is ignored. An alias reference or a second token closes the session. -- **Refused, not fatal.** A legal request this stack does not serve is rejected on its own and the session stays up: `FORWARD=0`, range filters, `TRACK_STATUS` to a JavaScript publisher, `SUBSCRIBE_TRACKS`, and the fetch forms above. On draft 19 and later, and in Rust on drafts 14 through 16, a subscription update may change only priority; any other update ends that subscription. +- **One credential per session**, carried in `SETUP` and forwarded to the [auth server](/bin/relay/auth#the-contract) unverified. A second token closes the session. We advertise no token cache, so an alias reference closes it with `UNKNOWN_AUTH_TOKEN_ALIAS`, and a registration on a request with `AUTH_TOKEN_CACHE_OVERFLOW`. A token on a `SUBSCRIBE` or `PUBLISH_NAMESPACE` authorizes that one request when the session's grant does not cover it and the application verifies request tokens; an update renews it, and a refused renewal ends only that request. A token on any other request is decoded and ignored. +- **Refused, not fatal.** A legal request this stack does not serve is rejected on its own and the session stays up: `FORWARD=0`, range filters, `TRACK_STATUS` to a JavaScript publisher, `SUBSCRIBE_TRACKS`, and the fetch forms above. On draft 19 and later, and in Rust on drafts 14 through 16, a subscription update may change only priority or renew a request token; any other update ends that subscription. - **Datagrams** are a single normal object at object 0, forwarded without renumbering. Anything else is dropped, and a malformed one closes the session. Rust and JavaScript both carry them on every draft. - **Priority.** Higher is served first. The IETF default of 128 is this stack's 127, and a track that never sets one is 127. - **Size.** An object extension block larger than 64 KiB ends that subgroup stream. The session stays up. This cap is ours, not the draft's. @@ -39,16 +39,19 @@ 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. +Both libraries offer solicit and auth unless a side's `extensions` setting +declines one, which connects it as a peer that does not speak that extension. [moq-e2ee](/draft/moq-e2ee) is not a transport extension. It encrypts application payloads, so relays still forward named tracks they cannot read. See [Encryption](/concept/hang#encryption). ### Deliberate deviations -These three answers differ from the draft on purpose. They are the +These four answers differ from the draft on purpose. They are the product's model, not bugs, and the relay does not change them. - **One publisher per path.** A broadcast path names one piece of content, so a @@ -72,6 +75,11 @@ product's model, not bugs, and the relay does not change them. requires an established upstream subscription before `SUBSCRIBE_OK`. From moq-lite 05 the track stream answers first, and a missing track is refused before `SUBSCRIBE_OK`. +- **An unknown token alias closes the session.** No alias is ever registered, + so a `DELETE` or `USE_ALIAS` token names one that does not exist. + [Draft 21 §8.9](https://www.ietf.org/archive/id/draft-ietf-moq-transport-21.html#section-8.9) + rejects the message, but `UNKNOWN_AUTH_TOKEN_ALIAS` exists only as a session + termination code, so we close the session with it. ## MSF diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index 2294fb07be..38ef4cf374 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -42,6 +42,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 7b36df4d72..514bf12e2b 100644 --- a/doc/lib/rs/moq-net.md +++ b/doc/lib/rs/moq-net.md @@ -21,6 +21,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 0ad17479a7..ff410731e6 100644 --- a/drafts/draft-lcurley-moq-lite.md +++ b/drafts/draft-lcurley-moq-lite.md @@ -292,8 +292,14 @@ Sent when terminating the session, via the transport's session close. | ------- | ------------- | ----------- | | 0x11 | CONTROL_MESSAGE_TIMEOUT | The peer took too long to respond to a control message. | | ------- | ------------- | ----------- | +| 0x13 | AUTH_TOKEN_CACHE_OVERFLOW | A token registration would exceed the advertised token cache size. | +| ------- | ------------- | ----------- | | 0x15 | VERSION_NEGOTIATION_FAILED | No version could be negotiated. | | ------- | ------------- | ----------- | +| 0x17 | UNKNOWN_AUTH_TOKEN_ALIAS | A token named an alias that was never registered. | +| ------- | ------------- | ----------- | +| 0x1B | TOO_MANY_REQUEST_UPDATES | The peer left more request updates unanswered than the endpoint allows. | +| ------- | ------------- | ----------- | ### Stream Error Codes Sent when resetting a stream (RESET_STREAM), or when refusing to receive one (STOP_SENDING). @@ -334,6 +340,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 +372,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. @@ -520,6 +530,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. @@ -1330,6 +1359,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. @@ -1410,6 +1517,8 @@ 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_TOKEN_CACHE_OVERFLOW (0x13), UNKNOWN_AUTH_TOKEN_ALIAS (0x17), and TOO_MANY_REQUEST_UPDATES (0x1B) session codes, with moq-transport's meaning. +- 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 @@ -1425,6 +1534,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. @@ -1577,6 +1687,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..bf7df5be70 100644 --- a/js/net/src/connection/accept.ts +++ b/js/net/src/connection/accept.ts @@ -3,6 +3,7 @@ import * as Lite from "../lite/index.ts"; import type { Consumer as OriginConsumer, Producer as OriginProducer } from "../origin.ts"; import { Stream } from "../stream.ts"; import type { Established } from "./established.ts"; +import * as Extensions from "./extensions.ts"; import { forwardAnnounced } from "./forward.ts"; import { exchangeSetup } from "./handshake.ts"; @@ -33,12 +34,19 @@ export interface AcceptProps { * nothing. The entries retract when the session dies; see the `consume` connect option. */ consume?: OriginProducer; + + /** The moq-transport extensions to offer, each on unless set to `false`. */ + extensions?: Extensions.Extensions; } /** The per-session wiring shared by every negotiated protocol path. */ 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; + /** The moq-transport extensions this side offers. */ + extensions: Extensions.Offered; }; /** @@ -67,6 +75,8 @@ async function acceptInner( const wiring: SessionProps = { discovery: props.discovery ?? true, publish: props.publish, + client: false, + extensions: Extensions.offered(props.extensions), }; if (protocol === Ietf.ALPN.DRAFT_22) { @@ -115,7 +125,12 @@ 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", + wiring.extensions, + ); return new Ietf.Connection({ ...wiring, @@ -127,6 +142,7 @@ async function acceptAlpn( solicit, hidden, cluster, + auth, // v17+ uses NativeSession which manages its own request IDs; maxRequestId is unused. maxRequestId: 0n, version, @@ -160,7 +176,7 @@ async function acceptSetup( const params = new Ietf.SetupOptions(); params.setVarint(Ietf.SetupOption.MaxRequestId, Ietf.initialMaxRequestId(true)); params.setBytes(Ietf.SetupOption.Implementation, encoder.encode("moq-lite-js")); - Ietf.solicitIntoSetup(params); + Extensions.intoSetup(params, wiring.extensions, version); Ietf.hiddenIntoSetup(params); const server = new Ietf.ServerSetup({ version, parameters: params }); @@ -220,7 +236,7 @@ async function acceptNegotiated( const params = new Ietf.SetupOptions(); params.setVarint(Ietf.SetupOption.MaxRequestId, Ietf.initialMaxRequestId(true)); params.setBytes(Ietf.SetupOption.Implementation, encoder.encode("moq-lite-js")); - Ietf.solicitIntoSetup(params); + Extensions.intoSetup(params, wiring.extensions, setupVersion); Ietf.hiddenIntoSetup(params); const server = new Ietf.ServerSetup({ version: selectedVersion, parameters: params }); diff --git a/js/net/src/connection/connect.ts b/js/net/src/connection/connect.ts index 980fbf40de..a482e1f699 100644 --- a/js/net/src/connection/connect.ts +++ b/js/net/src/connection/connect.ts @@ -9,6 +9,7 @@ import * as Hex from "../util/hex.ts"; import { redact } from "../util/log.ts"; import { isWebTransportSupported } from "./browser.ts"; import type { Established } from "./established.ts"; +import * as Extensions from "./extensions.ts"; import { forwardAnnounced } from "./forward.ts"; import { exchangeSetup } from "./handshake.ts"; @@ -110,6 +111,9 @@ export interface ConnectProps { */ consume?: OriginProducer; + /** The moq-transport extensions to offer, each on unless set to `false`. */ + extensions?: Extensions.Extensions; + /** * Aborts the connection attempt with the signal's reason. An already-aborted * signal rejects before anything opens, and aborting after the connection is @@ -122,6 +126,10 @@ 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; + /** The moq-transport extensions this side offers. */ + extensions: Extensions.Offered; }; // Save if WebSocket won the last race, so we won't give QUIC a head start next time. @@ -167,6 +175,8 @@ async function connectInner(url: URL, props: Omit, abort: P const wiring: SessionProps = { discovery: props.discovery ?? true, publish: props.publish, + client: true, + extensions: Extensions.offered(props.extensions), }; if (props.transport) { @@ -304,7 +314,7 @@ async function negotiate(url: URL, session: WebTransport, wiring: SessionProps): const params = new Ietf.SetupOptions(); params.setVarint(Ietf.SetupOption.MaxRequestId, Ietf.initialMaxRequestId(false)); params.setBytes(Ietf.SetupOption.Implementation, encoder.encode("moq-lite-js")); - Ietf.solicitIntoSetup(params); + Extensions.intoSetup(params, wiring.extensions, setupVersion); Ietf.hiddenIntoSetup(params); const client = new Ietf.ClientSetup({ @@ -361,7 +371,12 @@ 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", + wiring.extensions, + ); return new Ietf.Connection({ ...wiring, @@ -373,6 +388,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..5c7db2aa2d 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-06, and on moq-transport draft-17+ when both sides negotiate + * MoQ Auth, each side presents its connection's credential right after setup. + * Otherwise the grant stays undefined. + */ + readonly auth: Auth.Auth; + /** * 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/extensions.test.ts b/js/net/src/connection/extensions.test.ts new file mode 100644 index 0000000000..60f19e8982 --- /dev/null +++ b/js/net/src/connection/extensions.test.ts @@ -0,0 +1,105 @@ +import { expect, spyOn, test } from "bun:test"; +import { SessionCode } from "../error.ts"; +import { AuthMessage, AuthOk } from "../ietf/auth.ts"; +import { PublishNamespace } from "../ietf/publish_namespace.ts"; +import { RequestOk } from "../ietf/request.ts"; +import { ALPN, Version } from "../ietf/version.ts"; +import { createMockTransportPair } from "../mock.ts"; +import * as Path from "../path.ts"; +import { Stream } from "../stream.ts"; +import { accept } from "./accept.ts"; +import { type Extensions, offered } from "./extensions.ts"; +import { exchangeSetup } from "./handshake.ts"; + +const VERSION = Version.DRAFT_18; +const URL_ = new URL("https://example.com"); + +/** What each side learns from the other's SETUP when each offers `client` and `server`. */ +async function handshake(client: Extensions, server: Extensions) { + const pair = createMockTransportPair(ALPN.DRAFT_18); + const [dialed, accepted] = await Promise.all([ + exchangeSetup(pair.client, VERSION, "test", offered(client)), + exchangeSetup(pair.server, VERSION, "test", offered(server)), + ]); + return { dialed, accepted }; +} + +/** Accept a draft-18 session offering `extensions`, against a peer offering every extension. */ +async function acceptWith(extensions: Extensions) { + const pair = createMockTransportPair(ALPN.DRAFT_18); + const [peer, connection] = await Promise.all([ + exchangeSetup(pair.client, VERSION, "test", offered()), + accept({ transport: pair.server, url: URL_, extensions }), + ]); + return { pair, peer, connection }; +} + +test("every extension is offered by default", () => { + expect(offered()).toEqual({ auth: true, solicit: true }); + expect(offered({ solicit: false })).toEqual({ auth: true, solicit: false }); +}); + +test("a declined extension stays out of SETUP", async () => { + // Control: both sides offer both. + const all = await handshake({}, {}); + expect(all.accepted.solicit).toBe(true); + expect(all.accepted.auth).toBe(true); + expect(all.dialed.auth).toBe(true); + + const none = await handshake({ auth: false, solicit: false }, {}); + expect(none.accepted.solicit).toBeUndefined(); + expect(none.accepted.auth).toBe(false); + // Negotiated only when both offer it, so the declining side does not speak it either. + expect(none.dialed.auth).toBe(false); + expect(none.dialed.solicit).toBe(true); +}); + +test("an AUTH to a side that declined it closes the session", async () => { + // Control: offered by both, the connection credential is granted. + const offering = await acceptWith({}); + const granted = await Stream.open(offering.pair.client, { version: VERSION }); + await new AuthMessage(0n, new Uint8Array()).encode(granted.writer, VERSION); + expect(await granted.reader.u53()).toBe(AuthOk.id); + offering.connection.close(); + + const logged = spyOn(console, "error").mockImplementation(() => void 0); + try { + const declined = await acceptWith({ auth: false }); + const refused = await Stream.open(declined.pair.client, { version: VERSION }); + // The session closes on the message type alone, so the rest of the write may fail. + await new AuthMessage(0n, new Uint8Array()).encode(refused.writer, VERSION).catch(() => undefined); + const info = await declined.pair.client.closed; + expect(info.closeCode).toBe(SessionCode.ProtocolViolation); + } finally { + logged.mockRestore(); + } +}); + +test("a side that declined solicit takes an unsolicited announce", async () => { + const announce = async (session: Awaited>) => { + const stream = await Stream.open(session.pair.client, { version: VERSION }); + await stream.writer.u53(PublishNamespace.id); + await new PublishNamespace({ + requestId: 0n, + trackNamespace: Path.from("room"), + cluster: { hops: [session.peer.cluster.self], cost: 0n }, + }).encode(stream.writer, VERSION); + return stream; + }; + + const declined = await acceptWith({ solicit: false }); + const taken = await announce(declined); + expect(await taken.reader.u53()).toBe(RequestOk.id); + declined.connection.close(); + + // Control: our SETUP declared it, so the peer that implements it broke it. + const logged = spyOn(console, "error").mockImplementation(() => void 0); + try { + const declaring = await acceptWith({}); + await announce(declaring); + await declaring.pair.client.closed; + expect(logged).toHaveBeenCalled(); + } finally { + logged.mockRestore(); + } +}); diff --git a/js/net/src/connection/extensions.ts b/js/net/src/connection/extensions.ts new file mode 100644 index 0000000000..e029f37b47 --- /dev/null +++ b/js/net/src/connection/extensions.ts @@ -0,0 +1,27 @@ +import * as Ietf from "../ietf/index.ts"; + +/** + * The moq-transport extensions a session offers in its SETUP, each on unless set to `false`. + * Turning one off connects as a peer that does not speak it. moq-lite carries both in its + * core, so this only changes moq-transport sessions. + */ +export interface Extensions { + /** The MoQ Auth extension: tokens presented and granted on their own stream (draft-17+). */ + auth?: boolean; + /** The MoQ Solicit extension: the peer answers our SUBSCRIBE_NAMESPACE rather than announcing unasked. */ + solicit?: boolean; +} + +/** Every extension resolved to whether it is offered. */ +export type Offered = Required; + +/** Resolve each unset extension to offered. */ +export function offered(extensions?: Extensions): Offered { + return { auth: extensions?.auth ?? true, solicit: extensions?.solicit ?? true }; +} + +/** Write the options for the offered extensions into a SETUP. */ +export function intoSetup(params: Ietf.SetupOptions, extensions: Offered, version: Ietf.IetfVersion) { + if (extensions.solicit) Ietf.solicitIntoSetup(params); + if (extensions.auth) Ietf.Auth.intoSetup(params, version); +} diff --git a/js/net/src/connection/handshake.ts b/js/net/src/connection/handshake.ts index 81bb2052a9..2903de9da6 100644 --- a/js/net/src/connection/handshake.ts +++ b/js/net/src/connection/handshake.ts @@ -2,6 +2,7 @@ import { StreamError } from "../error.ts"; import { type Hop, randomHop } from "../hop.ts"; import * as Ietf from "../ietf/index.ts"; import { Reader, Stream, UnexpectedEnd, Writer } from "../stream.ts"; +import * as Extensions from "./extensions.ts"; /** * Draft-17+ SETUP exchange. Each side opens a uni stream, writes its Setup @@ -12,26 +13,29 @@ 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 - * 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. + * extension), its Hop ID (see the MoQ Cluster extension), and whether MoQ Auth is negotiated. + * We declare our Hop ID on every session, and Solicit and Auth unless `extensions` declines + * them: 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. */ export async function exchangeSetup( transport: WebTransport, version: Ietf.IetfVersion, implementation: string, + extensions: Extensions.Offered, ): Promise<{ control: Stream; early: Reader[]; 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); + Extensions.intoSetup(params, extensions, version); Ietf.hiddenIntoSetup(params); // One id per session, like the moq-lite connection: nothing in this process forwards @@ -52,6 +56,8 @@ export async function exchangeSetup( solicit: received.solicit, hidden: received.hidden, cluster: { self, peer: received.cluster }, + // Negotiated only when both sides offer it. + auth: received.auth && extensions.auth, }; } @@ -79,6 +85,7 @@ async function receiveSetup( solicit: boolean | undefined; hidden: boolean; cluster: Hop | undefined; + auth: boolean; }> { const uniReader = transport.incomingUnidirectionalStreams.getReader() as ReadableStreamDefaultReader< ReadableStream @@ -120,5 +127,6 @@ async function receiveSetup( solicit: Ietf.solicitFromSetup(setup.parameters), hidden: Ietf.hiddenFromSetup(setup.parameters), cluster: Ietf.Cluster.fromSetup(setup.parameters, version), + auth: Ietf.Auth.fromSetup(setup.parameters, version) === true, }; } diff --git a/js/net/src/connection/index.ts b/js/net/src/connection/index.ts index 9989292eb6..1cbbb3762c 100644 --- a/js/net/src/connection/index.ts +++ b/js/net/src/connection/index.ts @@ -14,6 +14,7 @@ export { type WebTransportProps, } from "./connect.ts"; export type { Established } from "./established.ts"; +export type { Extensions } from "./extensions.ts"; export { Connection, type ConnectionProps } from "./pool.ts"; export type { Probe, Stats } from "./stats.ts"; export type { Transport } from "./transport.ts"; diff --git a/js/net/src/connection/pool.ts b/js/net/src/connection/pool.ts index c2e939d7fa..21a8613bf4 100644 --- a/js/net/src/connection/pool.ts +++ b/js/net/src/connection/pool.ts @@ -20,6 +20,7 @@ import { type WebTransportProps as WebTransportPropsType, } from "./connect.ts"; import type { Established as EstablishedType } from "./established.ts"; +import type { Extensions as ExtensionsType } from "./extensions.ts"; import type { GoawayProps, Redirect as RedirectType } from "./goaway.ts"; import { Reload, type ReloadDelay, type ReloadStatus } from "./reload.ts"; import type { Probe as ProbeType, Stats as StatsType } from "./stats.ts"; @@ -372,6 +373,8 @@ export namespace Connection { export type CertificateHash = CertificateHashType; /** An established one-shot session. */ export type Established = EstablishedType; + /** The moq-transport extensions a one-shot session offers. */ + export type Extensions = ExtensionsType; } /** Throw if `props` cannot be honored, rather than silently dropping them. */ diff --git a/js/net/src/error.test.ts b/js/net/src/error.test.ts index 210d39efa7..45b3566829 100644 --- a/js/net/src/error.test.ts +++ b/js/net/src/error.test.ts @@ -209,7 +209,10 @@ test("the code tables match the spec", () => { expect(Number(SessionCode.Cancel)).toBe(0x0); expect(Number(SessionCode.Unauthorized)).toBe(0x2); expect(Number(SessionCode.GoawayTimeout)).toBe(0x10); + expect(Number(SessionCode.AuthTokenCacheOverflow)).toBe(0x13); expect(Number(SessionCode.Version)).toBe(0x15); + expect(Number(SessionCode.UnknownAuthTokenAlias)).toBe(0x17); + expect(Number(SessionCode.TooManyRequestUpdates)).toBe(0x1b); expect(Number(StreamCode.Cancel)).toBe(0x1); expect(Number(StreamCode.SessionClosed)).toBe(0x3); expect(Number(StreamCode.TooFarBehind)).toBe(0x5); @@ -230,6 +233,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 +252,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..d1dccb1a9f 100644 --- a/js/net/src/error.ts +++ b/js/net/src/error.ts @@ -44,8 +44,14 @@ export const SessionCode = Object.freeze( GoawayTimeout: 0x10 as SessionCode, /** A control message took too long. */ Timeout: 0x11 as SessionCode, + /** A token registration would exceed the advertised token cache size, which is 0 when unadvertised. */ + AuthTokenCacheOverflow: 0x13 as SessionCode, /** No version could be negotiated. */ Version: 0x15 as SessionCode, + /** A token named an alias that was never registered. */ + UnknownAuthTokenAlias: 0x17 as SessionCode, + /** The peer left more request updates unanswered than MAX_REQUEST_UPDATES allows. */ + TooManyRequestUpdates: 0x1b as SessionCode, } as const), ); @@ -96,6 +102,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. */ @@ -294,9 +302,13 @@ export class RefusedRedirect extends Error { * @public */ export class ProtocolViolation extends Error { - constructor(message: string, options?: { cause?: unknown }) { + /** The session code to close with: `ProtocolViolation` unless the draft names a more specific one. */ + readonly code: SessionCode; + + constructor(message: string, options?: { cause?: unknown; code?: SessionCode }) { super(message, options); this.name = "ProtocolViolation"; + this.code = options?.code ?? SessionCode.ProtocolViolation; } } @@ -370,6 +382,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 +485,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/cluster.test.ts b/js/net/src/ietf/cluster.test.ts index a4c2626c46..377384e527 100644 --- a/js/net/src/ietf/cluster.test.ts +++ b/js/net/src/ietf/cluster.test.ts @@ -241,7 +241,7 @@ test("Cluster: a negotiated PUBLISH_NAMESPACE may carry AUTHORIZATION_TOKEN", as await wr.u62(1n); await Namespace.encode(wr, Path.from("alice.hang")); const params = Cluster.intoParams(advert); - params.bytes.set(0x03n, new Uint8Array([1, 2, 3])); // AUTHORIZATION_TOKEN + params.bytes.set(0x03n, new Uint8Array([0x03, 0x00, 0xaa])); // AUTHORIZATION_TOKEN: USE_VALUE, type 0 await params.encode(wr, version); }); }, diff --git a/js/net/src/ietf/cluster.ts b/js/net/src/ietf/cluster.ts index 0af006eb97..21ed9b7480 100644 --- a/js/net/src/ietf/cluster.ts +++ b/js/net/src/ietf/cluster.ts @@ -222,6 +222,8 @@ export async function decodeParams( try { params = await Parameters.decode(r, version, message); } catch (err) { + // Keep a violation's own code, such as a request token's. + if (err instanceof ProtocolViolation) throw err; throw new ProtocolViolation(reason(err), { cause: err }); } diff --git a/js/net/src/ietf/connection.test.ts b/js/net/src/ietf/connection.test.ts index 306b9f7b2a..3eedb234e9 100644 --- a/js/net/src/ietf/connection.test.ts +++ b/js/net/src/ietf/connection.test.ts @@ -1,4 +1,5 @@ import { expect, spyOn, test } from "bun:test"; +import { offered } from "../connection/extensions.ts"; import { exchangeSetup } from "../connection/handshake.ts"; import { SessionCode } from "../error.ts"; import { createMockTransportPair } from "../mock.ts"; @@ -298,7 +299,7 @@ test("uni streams before SETUP are held until it lands", async () => { parameters.setBytes(SetupOption.Implementation, new TextEncoder().encode("test")); await new Setup({ parameters }).encode(setup, version); - const { control, early, solicit, hidden, cluster } = await exchangeSetup(pair.server, version, "test"); + const { control, early, solicit, hidden, cluster } = await exchangeSetup(pair.server, version, "test", offered()); expect(early.length).toBe(2); let closed = false; @@ -348,7 +349,7 @@ test("malformed uni stream before SETUP fails the handshake", async () => { parameters.setBytes(SetupOption.Implementation, new TextEncoder().encode("test")); await new Setup({ parameters }).encode(setup, version); - await expect(exchangeSetup(pair.server, version, "test")).rejects.toThrow("leading-ones varint"); + await expect(exchangeSetup(pair.server, version, "test", offered())).rejects.toThrow("leading-ones varint"); }); /** An early stream that ends partway through its type died; it is skipped like an empty one. */ @@ -367,7 +368,7 @@ test("truncated uni stream before SETUP is skipped", async () => { parameters.setBytes(SetupOption.Implementation, new TextEncoder().encode("test")); await new Setup({ parameters }).encode(setup, version); - const { early } = await exchangeSetup(pair.server, version, "test"); + const { early } = await exchangeSetup(pair.server, version, "test", offered()); expect(early.length).toBe(0); }); @@ -497,3 +498,60 @@ for (const [version, alpn] of [ }); } } + +/** A SUBSCRIBE for `room/video` whose only parameter is an AUTHORIZATION TOKEN holding `token`. */ +function subscribeWithToken(version: IetfVersion, token: readonly number[]): Uint8Array { + const ascii = (text: string) => [...new TextEncoder().encode(text)]; + // Request ID, then the namespace and track name. + const head = [0, 1, 4, ...ascii("room"), 5, ...ascii("video")]; + // Draft-14 carries priority, group order, forward, and a Largest Object filter inline. + const fields = version === Version.DRAFT_14 ? [0x80, 0x02, 0x01, 0x02] : []; + const body = [...head, ...fields, 1, 0x03, token.length, ...token]; + return new Uint8Array([0x03, 0, body.length, ...body]); +} + +/** + * A request's AUTHORIZATION TOKEN decodes by the SETUP option's rules, so one this endpoint + * cannot use closes the session with the draft's code. Draft-14 carries the request on the + * control stream, a strict draft on its own stream. + */ +for (const [version, alpn] of [ + [Version.DRAFT_14, ALPN.DRAFT_14], + [Version.DRAFT_22, ALPN.DRAFT_22], +] as const) { + for (const [name, token, code] of [ + ["REGISTER", [0x01, 0x07, 0x00], SessionCode.AuthTokenCacheOverflow], + ["DELETE", [0x00, 0x07], SessionCode.UnknownAuthTokenAlias], + ["USE_ALIAS", [0x02, 0x07], SessionCode.UnknownAuthTokenAlias], + ["USE_VALUE without a type", [0x03], SessionCode.KeyValueFormatting], + ["an unknown alias type", [0x04, 0x00], SessionCode.KeyValueFormatting], + ] as const) { + test(`a request token with ${name} closes ${alpn}`, async () => { + const logged = spyOn(console, "error").mockImplementation(() => void 0); + const pair = createMockTransportPair(alpn); + const control = await Stream.open(pair.server, { version }); + const connection = new Connection({ + url: new URL("https://example.com"), + quic: pair.server, + control, + maxRequestId: 100n, + version, + client: false, + }); + try { + const stream = + version === Version.DRAFT_14 + ? await Stream.accept(pair.client, version) + : await Stream.open(pair.client, { version }); + if (!stream) throw new Error("no control stream"); + await stream.writer.write(subscribeWithToken(version, token)); + + const info = await pair.client.closed; + expect(info.closeCode).toBe(code); + } finally { + logged.mockRestore(); + connection.abort(); + } + }); + } +} diff --git a/js/net/src/ietf/connection.ts b/js/net/src/ietf/connection.ts index 6cf0994973..48ccfea5a4 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 { error, fromClose, ProtocolViolation, StreamCode, StreamError } from "../error.ts"; import type { Consumer as OriginConsumer } from "../origin.ts"; -import type * as Path from "../path.ts"; +import * as Path from "../path.ts"; import { type Reader, Readers, type Stream } from "../stream.ts"; import { 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. @@ -71,6 +83,8 @@ export class Connection implements Established { // What the peer declared about being solicited; see {@link Ietf.solicitFromSetup}. #solicit: boolean | undefined; + // Whether our SETUP declared MoQ Solicit, which is what makes an unasked announce a fault. + #declaredSolicit: boolean; // The Hop IDs this session declared; see {@link Cluster}. #cluster?: Cluster.Hops; @@ -106,8 +120,10 @@ export class Connection implements Established { solicit, hidden = false, cluster, + auth = false, early = [], requestWindow, + extensions, }: { url: URL; quic: WebTransport; @@ -131,10 +147,14 @@ export class Connection implements Established { * cannot negotiate the extension, as is a `peer` the peer never declared. */ cluster?: Cluster.Hops; + /** Whether MoQ Auth is negotiated (draft-17+): offered by both SETUPs. */ + 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`). */ requestWindow?: bigint; + /** What our own SETUP offered; every extension when omitted. */ + extensions?: { solicit: boolean }; }) { this.url = url; this.discovery = discovery; @@ -165,6 +185,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,8 +211,11 @@ export class Connection implements Established { requiresSolicitation: solicit ?? false, hidden, cluster, + grant: this.#auth.grant, + ready: this.#auth.setupAnswered(), }); this.#solicit = solicit; + this.#declaredSolicit = extensions?.solicit ?? true; this.#cluster = cluster; this.#subscriber = new Subscriber({ session: this.#session, @@ -182,6 +224,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 +258,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); @@ -228,7 +273,7 @@ export class Connection implements Established { // The peer broke the protocol, so losing the stream is not enough: nothing stops it // repeating the violation on the next one. #violated(err: ProtocolViolation) { - this.#close({ closeCode: SessionCode.ProtocolViolation, reason: err.message }); + this.#close({ closeCode: err.code, reason: err.message }); } async #run(early: Reader[]): Promise { @@ -237,12 +282,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); @@ -362,18 +411,19 @@ export class Connection implements Established { Cluster.negotiated(this.#cluster), ); - // We always declare that advertisements to us must be solicited (MoQ - // Solicit), and writing the option at all proves the peer implements the - // extension, whichever value it chose. It also cannot have advertised - // before reading our SETUP, since our SETUP is what says whether + // Unless we declined it, we declare that advertisements to us must be + // solicited (MoQ Solicit), and writing the option at all proves the peer + // implements the extension, whichever value it chose. It also cannot have + // advertised before reading our SETUP, since our SETUP is what says whether // advertising unasked is allowed. So this is a bug in the peer, and a - // silent one on both sides if we tolerate it. + // silent one on both sides if we tolerate it. Without our declaration an + // unasked announce is what we invited. // // Draft-14/15 are exempt: they have no inline NAMESPACE, so a // PUBLISH_NAMESPACE request is also how a peer answers our // SUBSCRIBE_NAMESPACE there, and the message alone does not say which. const legacy = this.#session.version === Version.DRAFT_14 || this.#session.version === Version.DRAFT_15; - if (this.#solicit !== undefined && !legacy) { + if (this.#declaredSolicit && this.#solicit !== undefined && !legacy) { console.error( `unsolicited publish_namespace from a peer that implements MoQ Solicit: broadcast=${msg.trackNamespace}`, ); @@ -384,6 +434,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..87e0af8d42 100644 --- a/js/net/src/ietf/parameters.ts +++ b/js/net/src/ietf/parameters.ts @@ -2,13 +2,14 @@ import { ProtocolViolation } from "../error.ts"; import type { Reader, Writer } from "../stream.ts"; import * as Varint from "../varint.ts"; import * as Filter from "./filter.ts"; +import { AUTHORIZATION_TOKEN, tokenFromRequest } from "./token.ts"; import { type IetfVersion, Version } from "./version.ts"; /// Setup Option key constants (separate namespace from Message Parameters). export const SetupOption = { Path: 1n, MaxRequestId: 2n, - AuthorizationToken: 3n, + AuthorizationToken: AUTHORIZATION_TOKEN, MaxAuthTokenCacheSize: 4n, Authority: 5n, Implementation: 7n, @@ -20,6 +21,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). @@ -219,8 +222,8 @@ const MSG_PARAM_ROUTE_COST = 0x40b58n; const MSG_PARAM_HIDDEN = 0x40b5en; // Bytes parameter IDs (odd) -/// AUTHORIZATION TOKEN. Ignored: the session's grant is what authorizes a request. -const MSG_PARAM_AUTHORIZATION_TOKEN = 0x03n; +/// AUTHORIZATION TOKEN. Decoded, but grants nothing: the session's grant is what authorizes a request. +const MSG_PARAM_AUTHORIZATION_TOKEN = AUTHORIZATION_TOKEN; const MSG_PARAM_LARGEST_OBJECT = 0x09n; const MSG_PARAM_SUBSCRIPTION_FILTER = 0x21n; /// FILL_PARAMETERS, draft-20's request for a backfill. @@ -252,6 +255,7 @@ function draft20(version: IetfVersion): boolean { /** True when this draft's definition of `message` includes `id`. */ function allows(message: ControlMessage, version: IetfVersion, id: bigint): boolean { + const d14 = version === Version.DRAFT_14; const d15 = version === Version.DRAFT_15; const d16 = version === Version.DRAFT_16; const d17 = version === Version.DRAFT_17; @@ -292,6 +296,8 @@ function allows(message: ControlMessage, version: IetfVersion, id: bigint): bool include ); case "subscribe-ok": + // Draft-14 defines only MAX_CACHE_DURATION here. + if (d14) return id === MSG_PARAM_MAX_CACHE_DURATION; return (id === MSG_PARAM_MAX_CACHE_DURATION && d15) || expires || largest || (order && d15); case "subscribe-update": case "request-update": @@ -438,7 +444,9 @@ export class Parameters { this.#repeated = new Map(); } - #repeat(id: bigint, value: Uint8Array) { + #repeat(id: bigint, value: Uint8Array, version: IetfVersion) { + // Every token decodes, so one this endpoint cannot use closes the session. + if (id === MSG_PARAM_AUTHORIZATION_TOKEN) tokenFromRequest(value, version); const values = this.#repeated.get(id) ?? []; values.push(value); this.#repeated.set(id, values); @@ -717,7 +725,9 @@ export class Parameters { * draft defines for a different message is ignored through draft-16 and closes the * session from draft-17 on. An id the draft does not define at all closes from * draft-16 on. Draft-14 and draft-15 ignore anything the message does not list. - * Omitting `message` keeps the previous decode, which stores every id. + * Omitting `message`, as a draft-14 request does, stores every id. + * + * Only a request may carry an AUTHORIZATION TOKEN, and every one decodes. */ static async decode(r: Reader, version: IetfVersion, message?: ControlMessage): Promise { const count = await r.u53(); @@ -764,7 +774,7 @@ export class Parameters { const size = await r.u53(); const bytes = await r.read(size); if (MSG_PARAM_REPEATABLE.includes(id)) { - params.#repeat(id, bytes); + params.#repeat(id, bytes, version); } else if (id === MSG_PARAM_LARGEST_OBJECT) { if (params.#locations.has(id)) { throw new Error(`duplicate message parameter id: ${id.toString()}`); @@ -788,7 +798,7 @@ export class Parameters { if (MSG_PARAM_REPEATABLE.includes(id)) { const size = await r.u53(); - params.#repeat(id, await r.read(size)); + params.#repeat(id, await r.read(size), version); continue; } 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.test.ts b/js/net/src/ietf/publisher.test.ts index 80ae59bae2..ed56d21d90 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -1,5 +1,6 @@ import { expect, jest, mock, spyOn, test } from "bun:test"; import { Signal } from "@moq/signals"; +import type { Grant } from "../auth.ts"; import type { Producer as BroadcastProducer } from "../broadcast.ts"; import * as Epoch from "../epoch.ts"; import { error } from "../error.ts"; @@ -14,6 +15,7 @@ import type { Producer as TrackProducer } from "../track.ts"; import { wireOf } from "../wire.ts"; import { ControlStreamAdapter, NativeSession, type Session } from "./adapter.ts"; import type * as Cluster from "./cluster.ts"; +import { toRequestCode } from "./error.ts"; import { Fetch, FetchHeader } from "./fetch.ts"; import { Frame, Group as GroupMessage } from "./object.ts"; import { PublishDone } from "./publish.ts"; @@ -115,7 +117,13 @@ function publisher( requiresSolicitation = false, session, cluster, - }: { requiresSolicitation?: boolean; session?: Session; cluster?: Cluster.Hops } = {}, + grant, + }: { + requiresSolicitation?: boolean; + session?: Session; + cluster?: Cluster.Hops; + grant?: Signal; + } = {}, ): { pub: Publisher; origin: OriginProducer } { const origin = new OriginProducer(); const inner = session ?? new NativeSession(transport, VERSION, true); @@ -126,6 +134,7 @@ function publisher( publish: origin.consume(), requiresSolicitation, cluster, + grant, }), origin, }; @@ -220,6 +229,49 @@ test("FETCH and a non-forwarding SUBSCRIBE get NOT_SUPPORTED", async () => { } }); +/** A request's USE_VALUE token decodes but grants nothing yet, so a SUBSCRIBE outside the grant is still refused. */ +test("a request token does not widen the grant", async () => { + for (const version of [Version.DRAFT_14, Version.DRAFT_22] as const) { + const pair = createMockTransportPair(ALPNS[version]); + const session = new NativeSession(pair.server, version, true); + const nothing = new Signal({ + publish: new Path.Patterns([]), + subscribe: new Path.Patterns([]), + }); + const { pub, origin } = publisher(pair.server, { session, grant: nothing }); + publish(origin, Path.from("room")); + + const ascii = (text: string) => [...new TextEncoder().encode(text)]; + // Draft-14 carries priority, group order, forward, and a Largest Object filter inline. + const fields = version === Version.DRAFT_14 ? [0x80, 0x02, 0x01, 0x02] : []; + const token = [0x03, 0x00, 0xaa]; // USE_VALUE, type 0 + const body = [0, 1, 4, ...ascii("room"), 5, ...ascii("video"), ...fields, 1, 0x03, token.length, ...token]; + const msg = await Subscribe.decode( + new Reader(undefined, new Uint8Array([0, body.length, ...body]), version), + version, + ); + + const written: Uint8Array[] = []; + const stream = new Stream({ + readable: new ReadableStream(), + writable: new WritableStream({ + write: (chunk) => { + written.push(new Uint8Array(chunk)); + }, + }), + version, + }); + await pub.runSubscribe(msg, stream); + await stream.writer.closed; + origin.close(); + + // SUBSCRIBE_ERROR on draft-14, REQUEST_ERROR after; the code follows the Length and any Request ID. + const wire = written.flatMap((chunk) => Array.from(chunk)); + expect(wire[0]).toBe(0x05); + expect(wire[version === Version.DRAFT_14 ? 4 : 3]).toBe(toRequestCode("unauthorized", "subscribe", version)); + } +}); + // The header is part of the group's lifetime too. If it blocks on flow control, advancing // the live edge must reset the stream without waiting for that write to finish. test("a blocked group header is reset when the group expires", async () => { diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index 78723708fe..f111baef4c 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/solicit.test.ts b/js/net/src/ietf/solicit.test.ts index be31565f46..c7937498b3 100644 --- a/js/net/src/ietf/solicit.test.ts +++ b/js/net/src/ietf/solicit.test.ts @@ -3,7 +3,7 @@ import { SetupOption, SetupOptions } from "./parameters.ts"; import { solicitFromSetup, solicitIntoSetup } from "./solicit.ts"; /** - * We declare it on every session, so a peer honoring it never sends us an unsolicited + * We declare it by default, so a peer honoring it never sends us an unsolicited * PUBLISH_NAMESPACE. The literal 1 is the wire contract, not an implementation detail. */ test("we declare that advertisements must be solicited", () => { diff --git a/js/net/src/ietf/solicit.ts b/js/net/src/ietf/solicit.ts index a01089dd4e..9ee96c3494 100644 --- a/js/net/src/ietf/solicit.ts +++ b/js/net/src/ietf/solicit.ts @@ -37,9 +37,9 @@ export function solicitFromSetup(params: SetupOptions): boolean | undefined { /** * Declare that advertisements to us must be solicited. * - * Unconditional, and true of every session: we send SUBSCRIBE_NAMESPACE for each prefix - * we want, so there is nothing an unsolicited PUBLISH_NAMESPACE could tell us that we - * will not have asked for. + * Declared unless the session's `extensions` declines it: we send SUBSCRIBE_NAMESPACE for + * each prefix we want, so there is nothing an unsolicited PUBLISH_NAMESPACE could tell us + * that we will not have asked for. * * @internal */ diff --git a/js/net/src/ietf/subscribe.ts b/js/net/src/ietf/subscribe.ts index bc9d341e0c..1eed75c3ea 100644 --- a/js/net/src/ietf/subscribe.ts +++ b/js/net/src/ietf/subscribe.ts @@ -300,7 +300,7 @@ export class SubscribeOk { largest = { groupId, objectId }; } - properties.maxCacheDuration = (await Parameters.decode(r, version)).maxCacheDuration; + properties.maxCacheDuration = (await Parameters.decode(r, version, "subscribe-ok")).maxCacheDuration; } else { // v15+: parameters followed by Track Properties (draft-17+). LARGEST_OBJECT is // required on every draft once the track has content, so rejecting it would tear 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/ietf/token.test.ts b/js/net/src/ietf/token.test.ts index 5d4b4137d9..991230aefb 100644 --- a/js/net/src/ietf/token.test.ts +++ b/js/net/src/ietf/token.test.ts @@ -1,9 +1,9 @@ import { expect, test } from "bun:test"; -import { SessionCode, SessionError } from "../error.ts"; +import { ProtocolViolation, SessionCode } from "../error.ts"; import { Reader, Writer } from "../stream.ts"; import * as Varint from "../varint.ts"; import { SetupOption, SetupOptions } from "./parameters.ts"; -import { TOKEN_OUT_OF_BAND, type Token, tokenFromSetup, tokenIntoSetup } from "./token.ts"; +import { TOKEN_OUT_OF_BAND, type Token, tokenFromRequest, tokenFromSetup, tokenIntoSetup } from "./token.ts"; import { type IetfVersion, Version } from "./version.ts"; const VERSIONS: IetfVersion[] = [ @@ -54,16 +54,21 @@ async function received(params: SetupOptions, version: IetfVersion): Promise varint(field, version)), value]; - const raw = new Uint8Array(parts.reduce((total, part) => total + part.length, 0)); + const out = new Uint8Array(parts.reduce((total, part) => total + part.length, 0)); let offset = 0; for (const part of parts) { - raw.set(part, offset); + out.set(part, offset); offset += part.length; } + return out; +} + +/** The Token structure as a SETUP option. */ +function structure(version: IetfVersion, fields: bigint[], value: Uint8Array = new Uint8Array()): SetupOptions { const params = new SetupOptions(); - params.setBytes(SetupOption.AuthorizationToken, raw); + params.setBytes(SetupOption.AuthorizationToken, raw(version, fields, value)); return params; } @@ -71,7 +76,7 @@ function sessionCode(fn: () => unknown): SessionCode | undefined { try { fn(); } catch (err) { - if (err instanceof SessionError) return err.code; + if (err instanceof ProtocolViolation) return err.code; throw err; } return undefined; @@ -119,11 +124,12 @@ test("REGISTER is a value", () => { } }); -test("an alias reference is a protocol violation", () => { +/** A cache size of 0 means no alias was ever registered. */ +test("an alias reference is an unknown alias", () => { for (const version of VERSIONS) { for (const aliasType of [0x0n, 0x2n]) { const params = structure(version, [aliasType, 7n]); - expect(sessionCode(() => tokenFromSetup(params, version))).toBe(SessionCode.ProtocolViolation); + expect(sessionCode(() => tokenFromSetup(params, version))).toBe(SessionCode.UnknownAuthTokenAlias); } } }); @@ -153,3 +159,35 @@ test("two tokens are refused", async () => { ); } }); + +test("a request token by value decodes", () => { + for (const version of VERSIONS) { + expect(tokenFromRequest(raw(version, [0x3n, TOKEN.kind], TOKEN.value), version)).toEqual(TOKEN); + } +}); + +/** Only SETUP falls back to a value: on a request, a registration overflows the cache. */ +test("a request registration overflows the cache", () => { + for (const version of VERSIONS) { + const token = raw(version, [0x1n, 7n, TOKEN.kind], TOKEN.value); + expect(sessionCode(() => tokenFromRequest(token, version))).toBe(SessionCode.AuthTokenCacheOverflow); + } +}); + +test("a request alias reference is an unknown alias", () => { + for (const version of VERSIONS) { + for (const aliasType of [0x0n, 0x2n]) { + const token = raw(version, [aliasType, 7n]); + expect(sessionCode(() => tokenFromRequest(token, version))).toBe(SessionCode.UnknownAuthTokenAlias); + } + } +}); + +test("an undecodable request token is a formatting error", () => { + for (const version of VERSIONS) { + for (const fields of [[], [0x3n], [0x4n, 0n]]) { + const token = raw(version, fields); + expect(sessionCode(() => tokenFromRequest(token, version))).toBe(SessionCode.KeyValueFormatting); + } + } +}); diff --git a/js/net/src/ietf/token.ts b/js/net/src/ietf/token.ts index 818466696f..17327bbbcc 100644 --- a/js/net/src/ietf/token.ts +++ b/js/net/src/ietf/token.ts @@ -1,10 +1,11 @@ -import { SessionCode, SessionError } from "../error.ts"; +import { ProtocolViolation, SessionCode } from "../error.ts"; import * as Varint from "../varint.ts"; -import { SetupOption, type SetupOptions } from "./parameters.ts"; +import type { SetupOptions } from "./parameters.ts"; import { type IetfVersion, Version } from "./version.ts"; /** - * The `AUTHORIZATION TOKEN` Setup Option (draft-ietf-moq-transport-21 section 9.1.4). + * The `AUTHORIZATION TOKEN` Setup Option (draft-ietf-moq-transport-21 section 9.1.4) and + * Message Parameter. * * The value is the Token structure of section 8.9: an Alias Type, then fields that type * selects. We advertise no `MAX_AUTH_TOKEN_CACHE_SIZE`, so its default of 0 means no alias @@ -16,6 +17,13 @@ import { type IetfVersion, Version } from "./version.ts"; * @internal */ +/** + * The `AUTHORIZATION TOKEN` key, the same as a Setup Option and as a Message Parameter. + * + * @internal + */ +export const AUTHORIZATION_TOKEN = 0x03n; + /** Retire a registered alias. */ const DELETE = 0x0n; /** Register an alias for this type and value, then use them. */ @@ -26,7 +34,7 @@ const USE_ALIAS = 0x2n; const USE_VALUE = 0x3n; /** - * A credential presented in a SETUP's `AUTHORIZATION TOKEN` option. + * A credential presented in an `AUTHORIZATION TOKEN` option or parameter. * * @internal */ @@ -62,17 +70,37 @@ function leadingOnes(version: IetfVersion): boolean { * A second token is already refused as a duplicate option by {@link SetupOptions}: one * credential per connection. * - * @throws {SessionError} `ProtocolViolation` for an alias reference, which nothing before - * SETUP could have registered, or `KeyValueFormatting` for a structure that cannot be decoded. + * @throws {ProtocolViolation} carrying the session code to close with. * @internal */ export function tokenFromSetup(params: SetupOptions, version: IetfVersion): Token | undefined { - const raw = params.getBytes(SetupOption.AuthorizationToken); + const raw = params.getBytes(AUTHORIZATION_TOKEN); if (raw === undefined) return undefined; + return decode(raw, version, true); +} +/** + * Decode an `AUTHORIZATION TOKEN` message parameter on a request, by the SETUP option's rules + * except that a registration overflows the cache instead of falling back to a value. + * + * @throws {ProtocolViolation} carrying the session code to close with. + * @internal + */ +export function tokenFromRequest(raw: Uint8Array, version: IetfVersion): Token { + return decode(raw, version, false); +} + +/** + * Decode a Token structure, refusing what this endpoint cannot accept. + * + * @throws {ProtocolViolation} `AuthTokenCacheOverflow` for a registration outside SETUP, + * `UnknownAuthTokenAlias` for an alias reference, or `KeyValueFormatting` for a structure that + * cannot be decoded. + */ +function decode(raw: Uint8Array, version: IetfVersion, setup: boolean): Token { // Section 8.9: a structure that cannot be decoded closes with KEY_VALUE_FORMATTING_ERROR. const malformed = (cause?: unknown) => - new SessionError(SessionCode.KeyValueFormatting, { cause, reason: "malformed AUTHORIZATION TOKEN" }); + new ProtocolViolation("malformed AUTHORIZATION TOKEN", { cause, code: SessionCode.KeyValueFormatting }); const unvarint = (buf: Uint8Array): [bigint, Uint8Array] => { try { return leadingOnes(version) ? Varint.decodeLeadingOnes(buf) : Varint.decodeBigInt(buf); @@ -85,14 +113,25 @@ export function tokenFromSetup(params: SetupOptions, version: IetfVersion): Toke switch (aliasType) { case USE_VALUE: break; - // With no cache, section 9.1.4 treats a registration as a value; the alias is unused. case REGISTER: + // Section 8.9: a registration past the cache size of 0 closes the session. + if (!setup) { + throw new ProtocolViolation("AUTHORIZATION TOKEN registration", { + code: SessionCode.AuthTokenCacheOverflow, + }); + } + // With no cache, section 9.1.4 treats a registration in SETUP as a value; the alias + // is unused. [, rest] = unvarint(rest); break; - // Section 9.1.4: nothing can have been registered before SETUP. + // Section 9.1.3: a cache size of 0 prohibits aliases, so none was ever registered. + // Section 8.9 rejects the message, but 0x17 is only a session code (section 12.2), so + // the session closes; doc/concept/standard.md records the deviation. case DELETE: case USE_ALIAS: - throw new SessionError(SessionCode.ProtocolViolation, { reason: "AUTHORIZATION TOKEN alias in SETUP" }); + throw new ProtocolViolation("unknown AUTHORIZATION TOKEN alias", { + code: SessionCode.UnknownAuthTokenAlias, + }); default: throw malformed(); } @@ -115,5 +154,5 @@ export function tokenIntoSetup(params: SetupOptions, token: Token, version: Ietf out.set(aliasType, 0); out.set(kind, aliasType.length); out.set(token.value, aliasType.length + kind.length); - params.setBytes(SetupOption.AuthorizationToken, out); + params.setBytes(AUTHORIZATION_TOKEN, out); } 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 c0e818d5b4..5932b45164 100644 --- a/js/net/src/lite/announce.ts +++ b/js/net/src/lite/announce.ts @@ -488,7 +488,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..78891d1bf3 100644 --- a/js/net/src/lite/connection.ts +++ b/js/net/src/lite/connection.ts @@ -1,17 +1,20 @@ 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 { closeError, error, fromClose, ProtocolViolation, SessionCode, StreamCode, sessionCause } from "../error.ts"; +import { closeError, error, fromClose, ProtocolViolation, 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. @@ -323,7 +365,7 @@ export class Connection implements Established { .catch((err: unknown) => { stream.stop(err); if (err instanceof ProtocolViolation) { - this.#quic.close({ closeCode: SessionCode.ProtocolViolation, reason: err.message }); + this.#quic.close({ closeCode: err.code, reason: err.message }); } }); } 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 e6643f8803..24612b667c 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) { @@ -1643,6 +1647,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 93f7a48348..afeb353c7a 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"; @@ -392,6 +394,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 @@ -406,11 +417,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); @@ -501,24 +522,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); } @@ -550,22 +603,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, snap] of active) { const cur = updated.get(suffix); @@ -602,6 +650,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 = @@ -612,6 +704,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}`)); @@ -627,6 +721,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 } }); @@ -726,6 +821,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 { @@ -742,6 +865,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. @@ -763,6 +888,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, @@ -1013,6 +1144,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( @@ -1031,6 +1174,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(); @@ -1039,9 +1183,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 @@ -1369,6 +1521,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 0caaeb51be..0d9bfeaa61 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"; @@ -990,6 +991,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 726c93f037..478109f275 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"; @@ -78,6 +89,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; +} + interface SubscribeEntry { // The write side: incoming GROUP streams are routed here. The application reads // the matching track.Subscriber it got from broadcast.Consumer.subscribe. @@ -117,20 +140,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; @@ -170,6 +179,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 @@ -178,6 +192,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 */ @@ -188,6 +203,7 @@ export class Subscriber { probe?: Signal, peerSetup?: Signal, goaway?: GetPromise, + grant?: Getter, ) { this.#quic = quic; this.version = version; @@ -195,6 +211,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 @@ -209,6 +226,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. @@ -560,6 +583,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. @@ -583,9 +637,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 }; @@ -598,8 +649,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); @@ -690,7 +743,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, @@ -732,6 +785,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 55ae963947..e56e299b61 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 d9be181e76..ad1270707a 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 tokens](/quest/m1/auth/request-token.md) - an `AUTHORIZATION diff --git a/quest/m1/auth/request-gate.md b/quest/m1/auth/request-gate.md new file mode 100644 index 0000000000..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/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..fe99890a0b --- /dev/null +++ b/rs/moq-net/src/auth.rs @@ -0,0 +1,1791 @@ +//! 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, + /// The session speaks moq-lite, whose requests carry no token. + lite: 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 `AUTHORIZATION TOKEN` this side presents on its own SUBSCRIBE and PUBLISH_NAMESPACE +/// requests (MoQ request-token), shared between a session's [`Handle`] and its driver. +/// +/// The session reads the current value when it first sends a request, and re-presents a +/// changed one on each live request as a REQUEST_UPDATE. The default presents no token, +/// which is byte-identical to a session that never sets one. +#[derive(Clone, Default)] +pub(crate) struct RequestToken { + token: kio::Shared>, +} + +impl RequestToken { + /// A credential presenting `token` (or none) until replaced. + #[cfg(test)] + pub(crate) fn new(token: Option) -> Self { + Self { + token: kio::Shared::new(token), + } + } + + /// Replace the token presented on this session's requests. A live request re-presents + /// it as a REQUEST_UPDATE on its next turn; setting the same value again is a no-op. + pub(crate) fn set(&self, token: Option) { + *self.token.lock() = token; + } + + /// The token to present right now, read when a request is first sent. + pub(crate) fn peek(&self) -> Option { + self.token.read().clone() + } + + /// Ready with the current token once it differs from `last`, registering `waiter` + /// otherwise. The send loops park here to re-present a replaced token on their live + /// requests; it reads without advancing `last`, so a poll that loses its turn to + /// another arm is re-offered the change rather than dropping it. + pub(crate) fn poll_changed( + &self, + last: &Option, + waiter: &kio::Waiter, + ) -> Poll> { + let guard = ready_or!(self.token.poll(waiter, |cur| match **cur == *last { + true => Poll::Pending, + false => Poll::Ready(()), + })); + Poll::Ready((*guard).clone()) + } +} + +/// The session's auth handle, returned by [`Session::auth`](crate::Session::auth). +/// +/// Cheap to clone; every clone shares the session's tokens. +#[derive(Clone)] +pub struct Handle { + state: kio::Shared, + request_token: RequestToken, +} + +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() + }), + request_token: RequestToken::default(), + } + } + + /// Present `token` as the `AUTHORIZATION TOKEN` on this side's own requests (MoQ + /// request-token), as distinct from a connection credential, which [`add`](Self::add) + /// presents for the whole session. + /// + /// Set it before running the session's driver to present it on the first request. + /// Replacing it later re-presents the new token on every live request as a + /// REQUEST_UPDATE, renewing a token-authorized request in place; setting the same value + /// again sends nothing. Independent of the MoQ Auth extension. + /// + /// A moq-lite session has no request token to carry it, so it refuses with + /// [`Error::Unsupported`] rather than drop it. + pub fn set_request_token(&self, token: crate::setup::Token) -> Result<()> { + if self.state.read().lite { + return Err(Error::Unsupported); + } + self.request_token.set(Some(token)); + Ok(()) + } + + /// The session speaks moq-lite, so [`set_request_token`](Self::set_request_token) refuses. + pub(crate) fn lite(&self) { + self.state.lock().lite = true; + } + + /// The request-token cell the session's publisher and subscriber present from. + pub(crate) fn request_token(&self) -> RequestToken { + self.request_token.clone() + } + + /// Whether this side speaks AUTH: false on a version without it, on a side that does + /// not offer it, and once the peer turns out not to. + pub(crate) fn supported(&self) -> bool { + self.state.lock().supported + } + + /// 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 request-borne token rides the request message itself, not the AUTH stream, so the + // acceptor answers request tokens even on a session that never negotiated the MoQ Auth + // extension. The queue therefore stays live regardless of `supported`; only session + // token presentation ([`add`](Self::add)) stays [`Error::Unsupported`] without it. + state.acceptor = Acceptor::App(queue.clone()); + Ok(Requests { queue }) + } + + /// Verify a token that rode on one request, scoped to that request alone. + /// + /// The token reaches the same acceptor a session token does, tagged with the + /// request's path and kind. The grant the acceptor answers covers only this request, + /// never joins the session union, and ends when the returned [`RequestVerdict`] is + /// dropped, so it lives exactly as long as the request. With no [`requests`] consumer + /// the token cannot be verified, so the verdict is [`Error::Unsupported`] and the + /// caller refuses the request. + pub(crate) fn verify_request( + &self, + token: crate::setup::Token, + path: crate::PathOwned, + kind: RequestKind, + ) -> RequestVerdict { + match self.acceptor() { + Some(queue) => { + let issue = kio::Shared::::default(); + // A closed queue (the app dropped its Requests) hands the request back, and + // dropping it refuses the token with Unauthorized. + let _ = queue.try_push(Request::new_request(token, path, kind, issue.clone())); + RequestVerdict { issue: Some(issue) } + } + // No app verifier took the requests: a request token cannot be checked in band. + None => RequestVerdict { issue: None }, + } + } + + /// Admit a request on the token it carries, scoped to that request alone. + /// + /// The acceptor's grant must cover the request, and is then held for the request's life. + /// The error is what the request is refused with: the acceptor's refusal, UNAUTHORIZED for + /// a grant that does not cover it, or NOT_SUPPORTED with no [`requests`](Self::requests) + /// consumer. + pub(crate) async fn admit_request( + &self, + clock: &crate::time::Clock, + token: crate::setup::Token, + path: crate::PathOwned, + kind: RequestKind, + ) -> Result { + let verdict = self.verify_request(token, path.clone(), kind); + let grant = verdict.grant().await?; + if !kind.covers(&grant, path.as_str()) { + return Err(Error::Unauthorized); + } + Ok(RequestGrant::new(clock, verdict, grant, path, kind)) + } + + /// 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)) + } + + /// Whether the union positively covers `path` right now. Unlike [`allows`](Self::allows), a + /// union that is still `None` (no answer yet, or a version without AUTH) does NOT cover: a + /// request presenting a token is verified by it rather than admitted by the permissive + /// default. Only the token-bearing path uses this; the token-less path keeps `allows`. + pub(crate) fn covers(&self, direction: Direction, path: &str) -> bool { + let state = self.state.read(); + state.union.as_ref().is_some_and(|union| match direction { + Direction::Publish => union.publish.matches(path), + Direction::Subscribe => union.subscribe.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); + // The request queue stays live: a request-borne token does not ride the AUTH stream, so + // the acceptor keeps answering request tokens without the extension. Only the session + // tokens above end as unsupported. + } + + /// 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); + } +} + +/// Which request a token rode on. A token on a request authorizes that one request, so +/// the acceptor and the session scope its grant to the request's path and kind rather +/// than to the whole session. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum RequestKind { + /// A SUBSCRIBE: the subscriber reads the named track. + Subscribe, + /// A PUBLISH_NAMESPACE: the peer announces a namespace it will publish under. + PublishNamespace, +} + +impl RequestKind { + /// Whether `grant`, issued to the peer that presented the token, covers this request at + /// `path`. A grant names what its holder may do, so a request to read is covered by its + /// `subscribe` patterns and a request to announce by its `publish` patterns. + pub(crate) fn covers(self, grant: &Grant, path: &str) -> bool { + match self { + Self::Subscribe => grant.subscribe.matches(path), + Self::PublishNamespace => grant.publish.matches(path), + } + } +} + +/// The request a token rode on, which its grant is scoped to. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub struct Scope { + /// The request's path: the track's namespace, or the namespace announced. + pub path: crate::PathOwned, + /// Which request it is. + pub kind: RequestKind, + /// The token's Token Type (moq-transport section 8.9), naming the verifier it is for. + pub token_kind: u64, +} + +/// A token the peer presented, waiting for an answer. +/// +/// A token with no [`scope`](Request::scope) is the connection's own credential (or an +/// AUTH-stream token), granting the whole session. A token that rode on a request is scoped +/// to it: its grant covers only that request and never joins the session union. +/// +/// Dropping it unanswered refuses the token with [`SessionError::Unauthorized`]. +pub struct Request { + token: Bytes, + issue: Option>, + scope: Option, +} + +impl Request { + pub(crate) fn new(token: Bytes, issue: kio::Shared) -> Self { + Self { + token, + issue: Some(issue), + scope: None, + } + } + + /// A token that rode on one request, carrying the credential value, its structure + /// type, and the request it belongs to. + pub(crate) fn new_request( + token: crate::setup::Token, + path: crate::PathOwned, + kind: RequestKind, + issue: kio::Shared, + ) -> Self { + Self { + token: Bytes::from(token.value), + issue: Some(issue), + scope: Some(Scope { + path, + kind, + token_kind: token.kind, + }), + } + } + + /// The token the peer presented. Empty means the credential its connection + /// already carried, or none. For a request token this is the structure's value. + pub fn token(&self) -> &Bytes { + &self.token + } + + /// The request this token rode on, or `None` for the connection's own credential. + pub fn scope(&self) -> Option<&Scope> { + self.scope.as_ref() + } + + /// 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; + } +} + +/// The verdict on a request-borne token, from [`Handle::verify_request`]. +/// +/// Holding it keeps the request's grant alive; dropping it tells the acceptor the request +/// is over, so the grant lives exactly as long as the request and never outlives it. +pub(crate) struct RequestVerdict { + issue: Option>, +} + +impl RequestVerdict { + /// Wait for the acceptor's first answer: the grant it issued, or a refusal as the + /// [`Error`] whose request code the caller sends the peer. No consumer is + /// [`Error::Unsupported`]; an unanswered (dropped) request is + /// [`SessionError::Unauthorized`]. + pub(crate) async fn grant(&self) -> Result { + kio::wait(|waiter| self.poll_grant(waiter)).await + } + + /// Poll for the acceptor's first answer, so the caller can race the verify against the + /// live grant's deadline and serving rather than blocking on a bare await. + pub(crate) fn poll_grant(&self, waiter: &kio::Waiter) -> Poll> { + let Some(issue) = &self.issue else { + return Poll::Ready(Err(Error::Unsupported)); + }; + let mut guard = ready_or!( + issue.poll(waiter, |issue| match issue.outbox.is_empty() && !issue.done { + true => Poll::Pending, + false => Poll::Ready(()), + }) + ); + Poll::Ready(match guard.outbox.pop_front() { + Some(Reply::Grant(grant)) => Ok(grant), + Some(Reply::Refuse { code, .. }) => Err(Error::Session(code)), + // Done with nothing written: the acceptor dropped the request unanswered. + None => Err(Error::Session(SessionError::Unauthorized)), + }) + } + + /// After the first grant, poll for the acceptor's next action on this token: a + /// replacement grant (an update, such as a lowered expiry), a refusal (revoke), or the + /// issued grant being dropped. `Pending` while the grant still stands. + pub(crate) fn poll_reply(&self, waiter: &kio::Waiter) -> Poll { + let Some(issue) = &self.issue else { + return Poll::Ready(Reply::Refuse { + code: SessionError::Unauthorized, + reason: "no verifier".to_string(), + }); + }; + let mut guard = ready_or!( + issue.poll(waiter, |issue| match issue.outbox.is_empty() && !issue.done { + true => Poll::Pending, + false => Poll::Ready(()), + }) + ); + Poll::Ready(match guard.outbox.pop_front() { + Some(reply) => reply, + // Done with nothing more to read: the acceptor dropped the issued grant, ending + // the request. + None => Reply::Refuse { + code: SessionError::Unauthorized, + reason: "grant dropped".to_string(), + }, + }) + } +} + +impl Drop for RequestVerdict { + fn drop(&mut self) { + if let Some(issue) = &self.issue { + // Tell the acceptor's Issued the request is over, so it stops revalidating this + // request's grant. An unread grant left in the outbox does not matter: the + // request is ending regardless. + issue.lock().peer.get_or_insert(Error::Cancel); + } + } +} + +/// A request-borne grant, held for the life of the request it authorized (a SUBSCRIBE or a +/// PUBLISH_NAMESPACE). It carries the acceptor's grant and a deadline armed at the grant's +/// expiry, and ends the request when the deadline lapses or the acceptor revokes or drops the +/// grant. A REQUEST_UPDATE the acceptor accepts [`renew`](Self::renew)s it with a fresh grant +/// and expiry; one it refuses is not applied here, and the serve loop ends the request. +/// +/// Ending a request grant never touches the session: only that one request ends. +pub(crate) struct RequestGrant { + verdict: RequestVerdict, + grant: Grant, + deadline: crate::time::Deadline, + /// The request this grant must keep covering: a replacement that no longer does ends it. + path: crate::PathOwned, + kind: RequestKind, +} + +impl RequestGrant { + /// Hold `grant` (the acceptor's first answer, already awaited) for the request's life, + /// armed to lapse at its expiry. + pub(crate) fn new( + clock: &crate::time::Clock, + verdict: RequestVerdict, + grant: Grant, + path: crate::PathOwned, + kind: RequestKind, + ) -> Self { + let mut deadline = crate::time::Deadline::new(clock); + deadline.set(grant.expires); + Self { + verdict, + grant, + deadline, + path, + kind, + } + } + + /// Whether `grant` covers the request this guard holds. + fn covers(&self, grant: &Grant) -> bool { + self.kind.covers(grant, self.path.as_str()) + } + + /// The grant in force right now. Observed by the lifecycle tests. + #[cfg(test)] + pub(crate) fn grant(&self) -> &Grant { + &self.grant + } + + /// Apply a renewal's verdict, returning whether it renewed the request. + /// + /// A grant that still covers the request replaces this one: the new verdict is adopted + /// (dropping the old, which ends the old token) and the deadline re-armed at the new + /// expiry. A refusal, or a grant that no longer covers the request, changes nothing here; + /// the caller ends only that request. + pub(crate) fn renew(&mut self, verdict: RequestVerdict, res: Result) -> bool { + let grant = match res { + Ok(grant) if self.covers(&grant) => grant, + _ => return false, + }; + self.verdict = verdict; + self.deadline.set(grant.expires); + self.grant = grant; + true + } + + /// Resolve with the error that ends the request: the deadline lapsing + /// ([`Error::Unauthorized`]; [`Error::Expired`] once the code split lands), or the + /// acceptor revoking or dropping the grant (its code), or an acceptor-side update (a + /// replacement grant on the same token) that no longer covers the request + /// ([`Error::Unauthorized`]). A covering update, such as a lowered expiry, is folded in and + /// polling continues. Never resolves while the grant still stands. + pub(crate) fn poll_ended(&mut self, waiter: &kio::Waiter) -> Poll { + loop { + if self.deadline.poll(waiter).is_ready() { + return Poll::Ready(Error::Unauthorized); + } + match self.verdict.poll_reply(waiter) { + Poll::Ready(Reply::Grant(grant)) => { + if !self.covers(&grant) { + return Poll::Ready(Error::Unauthorized); + } + self.deadline.set(grant.expires); + self.grant = grant; + // Re-poll: the new deadline may already have lapsed, or another reply may + // be waiting. + continue; + } + Poll::Ready(Reply::Refuse { code, .. }) => return Poll::Ready(Error::Session(code)), + Poll::Pending => return Poll::Pending, + } + } + } +} + +/// 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, + /// Watch the limit alone, for a request a token authorized in place of the union. + limit_only: bool, +} + +impl Gate { + pub(crate) fn new(handle: Handle, path: crate::PathOwned, direction: Direction) -> Self { + Self { + handle, + path, + direction, + epoch: 0, + limit_only: false, + } + } + + /// A gate on the limit alone: a request token stands in for the union, never for the + /// ceiling this side set on the peer, so a later narrowing still ends the request. + pub(crate) fn limit(handle: Handle, path: crate::PathOwned, direction: Direction) -> Self { + Self { + limit_only: true, + ..Self::new(handle, path, direction) + } + } + + /// 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)); + let allowed = match self.limit_only { + true => permit.within_limit(self.path.as_str()), + false => permit.matches(self.path.as_str()), + }; + if !allowed { + 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()); + } +} + +#[cfg(test)] +mod request_token_tests { + use super::*; + + fn token(value: &[u8], kind: u64) -> crate::setup::Token { + crate::setup::Token { + kind, + value: value.to_vec(), + } + } + + fn subscribe_path() -> crate::PathOwned { + crate::Path::new("room/alice").to_owned() + } + + /// A request token reaches the app's acceptor tagged with the request it rode on, and + /// the grant the app answers is what the verdict resolves to. + #[moq_net_sim::test] + async fn a_request_token_reaches_the_acceptor_with_its_context() { + let handle = Handle::new(true); + let mut requests = handle.requests().expect("take the requests"); + let verdict = handle.verify_request(token(b"jwt", 7), subscribe_path(), RequestKind::Subscribe); + + let request = requests.next().await.expect("a request"); + assert_eq!(request.token(), &Bytes::from_static(b"jwt")); + assert_eq!( + request.scope(), + Some(&Scope { + path: subscribe_path(), + kind: RequestKind::Subscribe, + token_kind: 7, + }) + ); + let _issued = request.accept(Grant::all()); + + let grant = verdict.grant().await.expect("granted"); + assert!(grant.publish.matches("room/alice")); + } + + /// A request-borne token is verified even without the MoQ Auth extension: it rides the + /// request message, not the AUTH stream, so `requests()` hands a live queue on a session + /// whose `supported` is false and the acceptor admits it. This is the shape a standard + /// moq-transport peer (an encoder or CDN) presents when it does not negotiate the moq-dev + /// AUTH extension. + #[moq_net_sim::test] + async fn a_request_token_is_verified_without_the_auth_extension() { + let handle = Handle::new(false); + let mut requests = handle.requests().expect("take the requests"); + for kind in [RequestKind::Subscribe, RequestKind::PublishNamespace] { + let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), kind); + let request = requests.next().await.expect("a request reaches the acceptor"); + assert_eq!(request.scope().map(|scope| scope.kind), Some(kind)); + let _issued = request.accept(Grant::all()); + assert!( + verdict.grant().await.expect("granted").publish.matches("room/alice"), + "{kind:?} admitted without the AUTH extension" + ); + } + } + + /// With no `requests()` consumer, a request token cannot be verified in band, so the /// verdict is Unsupported and the caller refuses the request NOT_SUPPORTED. + #[moq_net_sim::test] + async fn no_consumer_refuses_a_request_token_as_unsupported() { + let handle = Handle::new(true); + let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe); + let err = verdict.grant().await.expect_err("no verifier"); + assert!(matches!(err, Error::Unsupported), "{err:?}"); + } + + /// A refused request token resolves to the refusal, which the caller sends the peer as + /// UNAUTHORIZED. + #[moq_net_sim::test] + async fn a_refused_request_token_is_unauthorized() { + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe); + requests.next().await.unwrap().reject(SessionError::Unauthorized, "no"); + let err = verdict.grant().await.expect_err("refused"); + assert!(matches!(err, Error::Session(SessionError::Unauthorized)), "{err:?}"); + } + + /// The grant lives exactly as long as the request: dropping the verdict ends the + /// acceptor's issued grant, so it never outlives the request nor joins the union. + #[moq_net_sim::test] + async fn dropping_the_verdict_ends_the_grant() { + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe); + let issued = requests.next().await.unwrap().accept(Grant::all()); + verdict.grant().await.expect("granted"); + drop(verdict); + let err = issued.closed().await; + assert!(matches!(err, Error::Cancel), "{err:?}"); + } + + /// A request token never joins the session union: the union stays what the connection + /// credential earned, not what a request token granted. + #[moq_net_sim::test] + async fn a_request_token_never_joins_the_union() { + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe); + let _issued = requests.next().await.unwrap().accept(Grant::all()); + verdict.grant().await.expect("granted"); + // The union only reflects tokens presented on this side (none here), never a + // request token the acceptor answered. + assert_eq!(handle.grant().peek(), None, "a request token must not widen the union"); + } + + /// A session token (the connection credential) carries no request context, so the + /// acceptor can tell it apart from a request token. + #[test] + fn a_session_token_has_no_request_context() { + let request = Request::new(Bytes::new(), kio::Shared::::default()); + assert_eq!(request.scope(), None); + } + + use std::time::Duration; + + fn grant_in(runtime: &crate::time::Clock, secs: u64) -> Grant { + Grant { + publish: Patterns::new(), + subscribe: crate::Pattern::all().into(), + expires: runtime.now().checked_add(Duration::from_secs(secs)), + } + } + + /// The request grant carries the acceptor's expiry, which is the deadline it lapses at. + #[moq_net_sim::test] + async fn a_request_grant_has_the_acceptors_expiry() { + let runtime = crate::time::Clock::sim(); + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe); + let grant = grant_in(&runtime, 60); + let expires = grant.expires; + let _issued = requests.next().await.unwrap().accept(grant); + let answered = verdict.grant().await.unwrap(); + let request_grant = RequestGrant::new(&runtime, verdict, answered, subscribe_path(), RequestKind::Subscribe); + assert_eq!(request_grant.grant().expires, expires); + assert!(expires.is_some()); + } + + /// An accepted REQUEST_UPDATE replaces the grant and its expiry. + #[moq_net_sim::test] + async fn a_renewal_token_replaces_the_grant_and_expiry() { + let runtime = crate::time::Clock::sim(); + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + + let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe); + let first = grant_in(&runtime, 60); + let first_expires = first.expires; + let _issued = requests.next().await.unwrap().accept(first); + let answered = verdict.grant().await.unwrap(); + let mut request_grant = + RequestGrant::new(&runtime, verdict, answered, subscribe_path(), RequestKind::Subscribe); + + // A REQUEST_UPDATE carrying a fresh token the acceptor accepts with a later expiry. + let renewal = handle.verify_request(token(b"jwt2", 0), subscribe_path(), RequestKind::Subscribe); + let second = grant_in(&runtime, 600); + let second_expires = second.expires; + let _issued2 = requests.next().await.unwrap().accept(second); + let renewed = renewal.grant().await; + assert!(request_grant.renew(renewal, renewed), "a covering renewal renews"); + + assert_eq!(request_grant.grant().expires, second_expires); + assert_ne!(second_expires, first_expires); + } + + /// With no accepted renewal, the deadline ends the request UNAUTHORIZED, and the + /// session is not closed by it. + #[moq_net_sim::test] + async fn the_deadline_ends_the_request_unauthorized_without_renewal() { + let runtime = crate::time::Clock::sim(); + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe); + // Held for the request's life, so only the deadline (not a drop) ends it. + let _issued = requests.next().await.unwrap().accept(grant_in(&runtime, 60)); + let answered = verdict.grant().await.unwrap(); + let mut request_grant = + RequestGrant::new(&runtime, verdict, answered, subscribe_path(), RequestKind::Subscribe); + + let err = kio::wait(|waiter| request_grant.poll_ended(waiter)).await; + assert!(matches!(err, Error::Unauthorized), "{err:?}"); + assert_eq!(handle.grant().peek(), None, "the session grant is untouched"); + } + + /// A refused renewal verdict is not applied: the request grant it already holds stands + /// untouched and ends only when its own deadline lapses. The serve loops end the request on + /// a refusal separately (a refused renewal ends only that request); this covers only that + /// the grant object is not mutated. + #[moq_net_sim::test] + async fn a_refused_renewal_leaves_the_grant() { + let runtime = crate::time::Clock::sim(); + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe); + let first = grant_in(&runtime, 60); + let first_expires = first.expires; + let _issued = requests.next().await.unwrap().accept(first); + let answered = verdict.grant().await.unwrap(); + let mut request_grant = + RequestGrant::new(&runtime, verdict, answered, subscribe_path(), RequestKind::Subscribe); + + // The renewal token is refused, so it renews nothing and the old grant is untouched. + let renewal = handle.verify_request(token(b"bad", 0), subscribe_path(), RequestKind::Subscribe); + requests + .next() + .await + .unwrap() + .reject(SessionError::Unauthorized, "bad token"); + let refused = renewal.grant().await; + assert!(matches!(refused, Err(Error::Session(SessionError::Unauthorized)))); + assert!(!request_grant.renew(renewal, refused), "a refusal does not renew"); + + // The old grant still stands with its original expiry, and the request ends only + // when that lapses. + assert_eq!(request_grant.grant().expires, first_expires); + let err = kio::wait(|waiter| request_grant.poll_ended(waiter)).await; + assert!(matches!(err, Error::Unauthorized), "{err:?}"); + } + + /// An acceptor-side update that no longer covers the request ends it, even when the + /// replacement has no expiry to lapse at; one that still covers it is folded in. + #[moq_net_sim::test] + async fn an_update_that_stops_covering_ends_the_request() { + let runtime = crate::time::Clock::sim(); + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe); + let issued = requests.next().await.unwrap().accept(Grant::all()); + let answered = verdict.grant().await.unwrap(); + let mut request_grant = + RequestGrant::new(&runtime, verdict, answered, subscribe_path(), RequestKind::Subscribe); + + // A covering update (everything, still unexpiring) leaves the request standing. + issued.update(Grant::all()); + let pending = kio::wait(|waiter| Poll::Ready(request_grant.poll_ended(waiter).is_pending())).await; + assert!(pending, "a covering update keeps the request"); + + issued.update(Grant::default()); + let err = moq_net_sim::timeout( + Duration::from_secs(1), + kio::wait(|waiter| request_grant.poll_ended(waiter)), + ) + .await + .expect("an uncovering update must end the request"); + assert!(matches!(err, Error::Unauthorized), "{err:?}"); + } + + /// The grant is the presenting peer's: a read is covered by `subscribe`, an announce by + /// `publish`, never the other way around. + #[test] + fn request_coverage_is_from_the_presenters_side() { + let read_only = Grant { + publish: Patterns::new(), + subscribe: crate::Pattern::all().into(), + expires: None, + }; + let write_only = Grant { + publish: crate::Pattern::all().into(), + subscribe: Patterns::new(), + expires: None, + }; + assert!(RequestKind::Subscribe.covers(&read_only, "room")); + assert!(!RequestKind::Subscribe.covers(&write_only, "room")); + assert!(RequestKind::PublishNamespace.covers(&write_only, "room")); + assert!(!RequestKind::PublishNamespace.covers(&read_only, "room")); + } + + /// An acceptor-side revoke ends the request before the deadline, and does not close the + /// session. + #[moq_net_sim::test] + async fn an_acceptor_revoke_ends_the_request() { + let runtime = crate::time::Clock::sim(); + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + let verdict = handle.verify_request(token(b"jwt", 0), subscribe_path(), RequestKind::Subscribe); + // A grant that never expires, so only the revoke can end the request. + let issued = requests.next().await.unwrap().accept(Grant::all()); + let answered = verdict.grant().await.unwrap(); + let mut request_grant = + RequestGrant::new(&runtime, verdict, answered, subscribe_path(), RequestKind::Subscribe); + + issued.revoke(SessionError::Unauthorized, "revoked"); + let err = kio::wait(|waiter| request_grant.poll_ended(waiter)).await; + assert!(matches!(err, Error::Session(SessionError::Unauthorized)), "{err:?}"); + assert_eq!(handle.grant().peek(), None, "the session grant is untouched"); + } +} diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index 4a511e7345..3f7e00141c 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -19,6 +19,7 @@ pub struct Client { cost: Option, peer_hop: Option, limits: crate::session::Limits, + extensions: setup::Extensions, } impl Client { @@ -94,6 +95,12 @@ impl Client { self } + /// Choose which moq-transport extensions to offer in SETUP. Defaults to all of them. + pub fn with_extensions(mut self, extensions: setup::Extensions) -> Self { + self.extensions = extensions; + self + } + /// Price this link, in the units the rest of the mesh uses (moq-lite-06+, and /// `moqt-17`+ via the MoQ Cluster extension). /// @@ -200,6 +207,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 +217,7 @@ impl Client { version, our_setup, peer_setup: None, + auth: crate::auth::Handle::new(version.has_auth()), })?; Ok(Session::new( @@ -217,8 +226,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 +288,9 @@ 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. A handle that does not + // offer it keeps the option out of our SETUP and presents no credential. + let auth = crate::auth::Handle::new(self.extensions.auth); let (protocol, goaway, setup) = ietf::start(ietf::Config { runtime: runtime.clone(), limits: self.limits, @@ -292,7 +307,9 @@ impl Client { authority: self.setup_authority.clone(), peer_setup_stream: None, peer_declared: None, + auth: auth.clone(), early_unis: Vec::new(), + extensions: self.extensions, })?; tracing::debug!(version = ?v, "connected"); @@ -302,8 +319,7 @@ impl Client { v, None, crate::driver::Protocol::Ietf(protocol), - goaway, - setup, + crate::session::Handles { goaway, auth, setup }, )); } Some(ALPN_16) => { @@ -374,7 +390,9 @@ impl Client { if let Some(authority) = &self.setup_authority { parameters.set_bytes(ietf::ParameterBytes::Authority, authority.clone().into_bytes()); } - ietf::solicit::into_setup(&mut parameters, ietf_encoding); + if self.extensions.solicit { + ietf::solicit::into_setup(&mut parameters, ietf_encoding); + } ietf::hidden::into_setup(&mut parameters, ietf_encoding); ietf::active_count::into_setup(&mut parameters, ietf_encoding); let parameters = parameters.encode_bytes(ietf_encoding)?; @@ -394,11 +412,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 +429,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 +456,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 +475,21 @@ impl Client { authority: None, peer_setup_stream: None, peer_declared: Some(peer_declared), + auth: auth.clone(), early_unis: Vec::new(), + extensions: self.extensions, })?; - (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/decode.rs b/rs/moq-net/src/coding/decode.rs index 4be934995f..adc04e7f84 100644 --- a/rs/moq-net/src/coding/decode.rs +++ b/rs/moq-net/src/coding/decode.rs @@ -93,6 +93,11 @@ pub enum DecodeError { /// The field does not exist in the negotiated protocol version. #[error("unsupported version")] Version, + + /// The value is one the draft answers by closing the session with this code, such as an + /// `AUTHORIZATION TOKEN` naming an alias. + #[error(transparent)] + Session(crate::SessionError), } impl DecodeError { 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..3e22889e72 100644 --- a/rs/moq-net/src/error.rs +++ b/rs/moq-net/src/error.rs @@ -46,10 +46,24 @@ pub enum SessionError { #[error("control message timeout")] Timeout, + /// A token registration would exceed the advertised `MAX_AUTH_TOKEN_CACHE_SIZE`, which is + /// 0 when unadvertised. + #[error("auth token cache overflow")] + AuthTokenCacheOverflow, + /// No version could be negotiated. #[error("version negotiation failed")] Version, + /// A token named an alias that was never registered. + #[error("unknown auth token alias")] + UnknownAuthTokenAlias, + + /// The peer left more unacknowledged REQUEST_UPDATEs outstanding on one request stream + /// than the MAX_REQUEST_UPDATES it was advertised (draft-19 section 10.3.1.7). + #[error("too many request updates")] + TooManyRequestUpdates, + /// An application-chosen code, offset into the 64+ range on the wire. #[error("app code={0}")] App(u16), @@ -71,7 +85,10 @@ impl SessionError { Self::TooManyRequests => 0x7, Self::GoawayTimeout => 0x10, Self::Timeout => 0x11, + Self::AuthTokenCacheOverflow => 0x13, Self::Version => 0x15, + Self::UnknownAuthTokenAlias => 0x17, + Self::TooManyRequestUpdates => 0x1B, Self::App(app) => *app as u32 + 64, Self::Unknown(code) => *code, } @@ -92,7 +109,10 @@ impl SessionError { 0x7 => Self::TooManyRequests, 0x10 => Self::GoawayTimeout, 0x11 => Self::Timeout, + 0x13 => Self::AuthTokenCacheOverflow, 0x15 => Self::Version, + 0x17 => Self::UnknownAuthTokenAlias, + 0x1B => Self::TooManyRequestUpdates, code @ 64.. => match u16::try_from(code - 64) { Ok(app) => Self::App(app), Err(_) => Self::Unknown(code), @@ -189,6 +209,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 +246,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 +279,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), @@ -542,7 +569,7 @@ impl From for Error { impl From<&Error> for SessionError { fn from(err: &Error) -> Self { match err { - Error::Session(err) => err.clone(), + Error::Session(err) | Error::Decode(coding::DecodeError::Session(err)) => err.clone(), // App codes share the 64+ range in both registries. Error::Stream(StreamError::App(app)) => Self::App(*app), // A stream-scoped code has no meaning in this registry; don't forward the number. @@ -588,6 +615,7 @@ impl From<&Error> for StreamError { Error::Session(_) => Self::Internal, Error::Cancel | Error::Closed => Self::Cancel, Error::SessionClosed => Self::Session(SessionError::Cancel), + Error::Decode(coding::DecodeError::Session(err)) => Self::Session(err.clone()), Error::Old => Self::Old, Error::Evicted => Self::Evicted, Error::Lagged => Self::TooFarBehind, @@ -598,6 +626,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 +642,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 @@ -651,7 +680,10 @@ mod tests { SessionError::TooManyRequests, SessionError::GoawayTimeout, SessionError::Timeout, + SessionError::AuthTokenCacheOverflow, SessionError::Version, + SessionError::UnknownAuthTokenAlias, + SessionError::TooManyRequestUpdates, SessionError::App(0), SessionError::App(404), ]; @@ -667,7 +699,10 @@ mod tests { assert_eq!(SessionError::Unauthorized.to_code(), 0x2); assert_eq!(SessionError::TooManyRequests.to_code(), 0x7); assert_eq!(SessionError::GoawayTimeout.to_code(), 0x10); + assert_eq!(SessionError::AuthTokenCacheOverflow.to_code(), 0x13); assert_eq!(SessionError::Version.to_code(), 0x15); + assert_eq!(SessionError::UnknownAuthTokenAlias.to_code(), 0x17); + assert_eq!(SessionError::TooManyRequestUpdates.to_code(), 0x1B); // The reserved 32-47 range, and anything else unregistered, keeps its value instead // of being given a meaning. A peer on the old placeholders (0x20-0x22) lands here. @@ -704,6 +739,7 @@ mod tests { StreamError::FrameTooLarge, StreamError::TimestampMismatch, StreamError::NotFetchable, + StreamError::Unauthorized, StreamError::App(7), ]; for err in registered { @@ -722,6 +758,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 +798,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/fuzz.rs b/rs/moq-net/src/fuzz.rs index 0ec56ed368..0b9d2f500d 100644 --- a/rs/moq-net/src/fuzz.rs +++ b/rs/moq-net/src/fuzz.rs @@ -567,6 +567,7 @@ impl Default for Messages { properties_wanted: false, forward: true, range_filters: false, + authorization_token: None, }, // Draft-17+ carries the request id in the control message framing instead. ietf_ok: ietf::SubscribeOk { diff --git a/rs/moq-net/src/ietf/adapter.rs b/rs/moq-net/src/ietf/adapter.rs index 7734c6b83d..d9c4c96640 100644 --- a/rs/moq-net/src/ietf/adapter.rs +++ b/rs/moq-net/src/ietf/adapter.rs @@ -853,7 +853,18 @@ impl ControlStreamAdapter { let raw = encode_raw(type_id, &body, self.version); // Classify and route - match classify(type_id, &body, self.version, &self.shared.namespaces)? { + let classified = classify(type_id, &body, self.version, &self.shared.namespaces)?; + // An answer to one of our updates names the update, which has no stream of its own, + // so it goes to the request the update targets. Only REQUEST_OK and REQUEST_ERROR + // answer an update. + if matches!(type_id, ietf::RequestOk::ID | ietf::RequestError::ID) + && let Route::Response(id) | Route::CloseStream(id) = classified + && let Some(target) = self.control.follow_up_target(id) + { + self.shared.push(target, raw); + continue; + } + match classified { Route::NewRequest(request_id) => { let permit = self.control.accept(request_id)?; self.shared.open_incoming(request_id, raw, permit)? @@ -1344,8 +1355,10 @@ mod tests { subscription_request_id: Some(target_id), start_location: ietf::Location { group: 1, object: 2 }, end_group: 100, - subscriber_priority: 200, - forward: false, + subscriber_priority: Some(200), + forward: Some(false), + filter: Some(ietf::Filter::NextObject), + authorization_token: None, }; let body = encode_body(&update, version); let raw = encode_raw(ietf::SubscribeUpdate::ID, &body, version); @@ -1516,6 +1529,7 @@ mod tests { request_id, track_namespace: crate::Path::new(namespace), cluster: None, + authorization_token: None, } } @@ -1877,8 +1891,10 @@ mod tests { subscription_request_id: Some(RequestId(0)), start_location: ietf::Location { group: 0, object: 0 }, end_group: 0, - subscriber_priority: 1, - forward: true, + subscriber_priority: Some(1), + forward: Some(true), + filter: Some(ietf::Filter::NextObject), + authorization_token: None, }; let update = encode_raw(ietf::SubscribeUpdate::ID, &encode_body(&update, VERSION), VERSION); // The target is in the window and the update's own ID is past it. diff --git a/rs/moq-net/src/ietf/auth.rs b/rs/moq-net/src/ietf/auth.rs new file mode 100644 index 0000000000..98664fed46 --- /dev/null +++ b/rs/moq-net/src/ietf/auth.rs @@ -0,0 +1,685 @@ +//! 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, + }; + + let mut data = body.decoder(self.version); + match id { + AuthOk::ID => { + let ok = match AuthOk::decode_msg(&mut data, self.version) { + Ok(ok) => ok, + Err(err) => return err.into(), + }; + 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 = match AuthError::decode_msg(&mut data, self.version) { + Ok(refused) => refused, + Err(err) => return err.into(), + }; + let err = from_code(refused.code); + tracing::warn!(%err, code = refused.code, reason = %refused.reason, "auth token refused"); + // A grant the acceptor could not tell is unknown, not empty, so only a + // real refusal settles the union. + if !matches!(err, Error::Unsupported) { + self.handle.refused(self.id); + } + return err; + } + _ => return Error::UnexpectedMessage, + } + } + } +} + +/// Read one `[type][size][body]` message, or `None` once the peer finished the stream. +async fn read_message( + stream: &mut Stream, +) -> Result, Error> { + let Some(id) = stream.reader.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) + )); + } + } + + /// 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/control.rs b/rs/moq-net/src/ietf/control.rs index 3ba504d7e2..e791e45a4a 100644 --- a/rs/moq-net/src/ietf/control.rs +++ b/rs/moq-net/src/ietf/control.rs @@ -16,6 +16,8 @@ struct ControlState { request_id_max: Option, /// The peer's requests, flow controlled on drafts 14 to 16 only. incoming: Option, + /// Our outstanding updates, by their own Request ID, to the request each updates. + follow_ups: std::collections::HashMap, } /// The MAX_REQUEST_ID window we grant the peer. @@ -44,6 +46,7 @@ impl Control { request_id_next: if client { RequestId(0) } else { RequestId(1) }, request_id_max, incoming: None, + follow_ups: Default::default(), }), } } @@ -149,6 +152,38 @@ impl Control { } } +impl Control { + /// Route the answer to our update `update` back to the request `target` it updates, until + /// the returned guard drops. + /// + /// Drafts 15 and 16 name an update's own Request ID in its answer, and an update has no + /// stream of its own on the control stream. + pub fn follow_up(&self, update: RequestId, target: RequestId) -> FollowUp { + self.state.lock().follow_ups.insert(update, target); + FollowUp { + control: self.clone(), + update, + } + } + + /// The request an answer naming `id` belongs to, when `id` is an update of ours. + pub fn follow_up_target(&self, id: RequestId) -> Option { + self.state.lock().follow_ups.remove(&id) + } +} + +/// An update of ours awaiting its answer, routed back to the request it updates until dropped. +pub(super) struct FollowUp { + control: Control, + update: RequestId, +} + +impl Drop for FollowUp { + fn drop(&mut self) { + self.control.state.lock().follow_ups.remove(&self.update); + } +} + /// One request the peer holds open, retired (and eventually granted back) on drop. pub(super) struct RequestPermit(Control); @@ -162,6 +197,21 @@ impl Drop for RequestPermit { mod tests { use super::*; + /// An answer to our update maps to the request it updates once, and not after the + /// update stops waiting on it. + #[test] + fn an_update_answer_follows_its_request() { + let control = Control::new(None, true); + let answered = control.follow_up(RequestId(8), RequestId(2)); + assert_eq!(control.follow_up_target(RequestId(8)), Some(RequestId(2))); + assert_eq!(control.follow_up_target(RequestId(8)), None, "routed once"); + drop(answered); + + let abandoned = control.follow_up(RequestId(10), RequestId(2)); + drop(abandoned); + assert_eq!(control.follow_up_target(RequestId(10)), None, "dropped with its update"); + } + fn granted(control: &Control) -> Option { match control.poll_grant(&kio::Waiter::noop()) { Poll::Ready(max) => Some(max.0), 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/fetch.rs b/rs/moq-net/src/ietf/fetch.rs index 15ca89921a..60cc0a755a 100644 --- a/rs/moq-net/src/ietf/fetch.rs +++ b/rs/moq-net/src/ietf/fetch.rs @@ -196,14 +196,14 @@ impl Message for Fetch<'_> { let _required_request_id_delta = buf.varint()?; } - // The token is ignored: the session's grant is what authorizes the request. + // The token decodes but grants nothing: the session's grant is what authorizes the request. let (fetch_type, subscriber_priority, group_order, range_filters, fill_timeout, properties_wanted) = match version { Version::Draft14 => { let subscriber_priority = buf.u8()?; let group_order = GroupOrder::decode(buf, version)?; let fetch_type = FetchType::decode(buf, version)?; - Parameters::skip(buf)?; + Parameters::request_tokens(buf)?; ( fetch_type, Some(subscriber_priority), @@ -216,7 +216,7 @@ impl Message for Fetch<'_> { Version::Draft15 | Version::Draft16 | Version::Draft17 | Version::Draft18 | Version::Draft19 => { let fetch_type = FetchType::decode(buf, version)?; decode_params!(buf, version, - 0x03 => _authorization_token: Vec, + 0x03 => _authorization_token: Vec, 0x0A => fill_timeout: Option where !matches!(version, Version::Draft15 | Version::Draft16 | Version::Draft17), 0x20 => subscriber_priority: Option, 0x22 => group_order: Option, @@ -248,7 +248,7 @@ impl Message for Fetch<'_> { let namespace = decode_namespace(buf)?; let track = Cow::Owned(buf.string()?); decode_params!(buf, version, - 0x03 => _authorization_token: Vec, + 0x03 => _authorization_token: Vec, 0x0A => fill_timeout: Option, 0x20 => subscriber_priority: Option, 0x21 => filter: Option, @@ -354,7 +354,7 @@ impl Message for FetchOk { let end_of_track = buf.bool()?; let end_location = Location::decode(buf, version)?; let properties = super::Properties { - max_cache_duration: Parameters::skip(buf)?.map(std::time::Duration::from_millis), + max_cache_duration: Parameters::skip_response(buf)?.map(std::time::Duration::from_millis), ..Default::default() }; Ok(Self { diff --git a/rs/moq-net/src/ietf/mod.rs b/rs/moq-net/src/ietf/mod.rs index bc2ef60538..4f75cf41ff 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; @@ -28,6 +29,7 @@ mod publish_namespace; mod publisher; mod request; mod request_stream; +mod request_update; mod session; pub mod solicit; mod subscribe; diff --git a/rs/moq-net/src/ietf/parameters.rs b/rs/moq-net/src/ietf/parameters.rs index 3a73d2e51f..a8ed4a970d 100644 --- a/rs/moq-net/src/ietf/parameters.rs +++ b/rs/moq-net/src/ietf/parameters.rs @@ -17,6 +17,8 @@ pub enum ParameterVarInt { /// Removed in draft-17; only used in draft-14/15/16. MaxRequestId = 2, MaxAuthTokenCacheSize = 4, + /// MAX_REQUEST_UPDATES, added in draft-19. + MaxRequestUpdates = super::request_update::OPTION, /// HOP_ID, from the MoQ Cluster extension. HopId = super::cluster::HOP_ID, /// RELAY_COST, from the MoQ Cluster extension. @@ -25,6 +27,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)] @@ -165,19 +169,30 @@ impl Encode for Parameters { } impl Parameters { - /// Consume a draft-14 message parameter block, returning its first MAX_CACHE_DURATION - /// (0x04), the one parameter we act on. - /// + /// Consume a draft-14 request's parameter block, returning every AUTHORIZATION TOKEN + /// (0x03), each decoded by [`super::token::RequestToken`]'s rules. + pub fn request_tokens(r: &mut Decoder<'_>) -> Result, DecodeError> { + Ok(Self::skip(r, true)?.1) + } + + /// Consume a draft-14 response's parameter block, returning its first + /// MAX_CACHE_DURATION (0x04), the one parameter we act on. + pub fn skip_response(r: &mut Decoder<'_>) -> Result, DecodeError> { + Ok(Self::skip(r, false)?.0) + } + /// Draft-14 section 9.2 has a receiver ignore unrecognized parameters and allow their /// duplicates, and lets AUTHORIZATION TOKEN repeat, so unlike [`Parameters::decode`], - /// which SETUP uses, a repeat is not refused. - pub fn skip(r: &mut Decoder<'_>) -> Result, DecodeError> { + /// which SETUP uses, a repeat is not refused. A token on a message that does not list it, + /// a response, is ignored like any other parameter. + fn skip(r: &mut Decoder<'_>, request: bool) -> Result<(Option, Vec), DecodeError> { let count = r.varint()?; if count > MAX_PARAMS { return Err(DecodeError::TooMany); } let mut cache_duration = None; + let mut tokens = Vec::new(); for _ in 0..count { // Parity frames a Key-Value-Pair: even is one varint, odd is length prefixed. let kind = r.varint()?; @@ -188,6 +203,9 @@ impl Parameters { cache_duration.get_or_insert(value); } } + _ if request && kind == u64::from(ParameterBytes::AuthorizationToken) => { + tokens.push(super::token::RequestToken::param_decode(r, Version::Draft14)?); + } _ => { let len = usize::try_from(r.varint()?).map_err(|_| DecodeError::BoundsExceeded)?; if len > MAX_KVP_VALUE_LEN { @@ -198,7 +216,7 @@ impl Parameters { } } - Ok(cache_duration) + Ok((cache_duration, tokens)) } pub fn get_varint(&self, kind: ParameterVarInt) -> Option { @@ -1209,24 +1227,33 @@ mod tests { /// instance instead of failing the message as a duplicate. #[test] fn test_param_repeat_allowed() { + use super::super::token::RequestToken; + use crate::setup::Token; + + let token = |kind| { + RequestToken(Token { + kind, + value: vec![0xAA], + }) + }; for version in [Version::Draft15, Version::Draft16, Version::Draft17, Version::Draft20] { let mut buf = Vec::new(); let mut w = Encoder::new(&mut buf, version.into()); w.varint(2).unwrap(); w.varint(0x03).unwrap(); - Opaque(vec![0xAA]).param_encode(&mut w, version).unwrap(); + token(0).param_encode(&mut w, version).unwrap(); // The second key: absolute before draft-16, a zero delta after. let second: u64 = if version == Version::Draft15 { 0x03 } else { 0 }; w.varint(second).unwrap(); - Opaque(vec![0xBB]).param_encode(&mut w, version).unwrap(); + token(1).param_encode(&mut w, version).unwrap(); let mut r = Decoder::new(&buf, version.into()); - let tokens = (|| -> Result, DecodeError> { - decode_params!(&mut r, version, 0x03 => tokens: Vec); + let tokens = (|| -> Result, DecodeError> { + decode_params!(&mut r, version, 0x03 => tokens: Vec); Ok(tokens) })() .unwrap_or_else(|e| panic!("{version}: {e}")); - assert_eq!(tokens, vec![Opaque(vec![0xAA]), Opaque(vec![0xBB])], "{version}"); + assert_eq!(tokens, vec![token(0), token(1)], "{version}"); assert!(r.is_empty(), "{version}"); } } @@ -1238,13 +1265,37 @@ mod tests { #[rustfmt::skip] let block = [ 0x04, // Number of Parameters - 0x03, 0x01, 0xAA, // AUTHORIZATION TOKEN - 0x03, 0x01, 0xBB, // and again + 0x03, 0x03, 0x03, 0x00, 0xAA, // AUTHORIZATION TOKEN: USE_VALUE, type 0 + 0x03, 0x03, 0x03, 0x01, 0xBB, // and again, type 1 0x3E, 0x05, // an unknown varint parameter 0x3E, 0x06, // and again ]; let mut r = Decoder::new(&block, Version::Draft14.into()); - Parameters::skip(&mut r).unwrap(); + let tokens = Parameters::request_tokens(&mut r).unwrap(); + assert_eq!(tokens.len(), 2); + assert_eq!(tokens[1].0.kind, 1); + assert!(r.is_empty()); + } + + /// A draft-14 request decodes every token; a response ignores one, as section 9.2 has + /// for a parameter on a message that does not list it. + #[test] + fn test_skip_decodes_draft14_request_tokens() { + #[rustfmt::skip] + let block = [ + 0x02, // Number of Parameters + 0x03, 0x03, 0x03, 0x00, 0xAA, // AUTHORIZATION TOKEN: USE_VALUE, type 0 + 0x03, 0x02, 0x02, 0x07, // and a USE_ALIAS, never registered + ]; + let mut r = Decoder::new(&block, Version::Draft14.into()); + let err = Parameters::request_tokens(&mut r).unwrap_err(); + assert!( + matches!(err, DecodeError::Session(crate::SessionError::UnknownAuthTokenAlias)), + "{err:?}" + ); + + let mut r = Decoder::new(&block, Version::Draft14.into()); + assert_eq!(Parameters::skip_response(&mut r).unwrap(), None); assert!(r.is_empty()); } } 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/publish.rs b/rs/moq-net/src/ietf/publish.rs index 843ddfedf4..ce625e2386 100644 --- a/rs/moq-net/src/ietf/publish.rs +++ b/rs/moq-net/src/ietf/publish.rs @@ -314,7 +314,7 @@ impl Message for Publish<'_> { }; let forward = r.bool()?; // parameters - Parameters::skip(r)?; + Parameters::request_tokens(r)?; Ok(Self { request_id, @@ -340,7 +340,7 @@ impl Message for Publish<'_> { // OBJECT_DELIVERY_TIMEOUT is also legal in draft-15. The values are dropped. decode_params!(r, version, 0x02 => _object_delivery_timeout: Option where version == Version::Draft15 || Filter::is_draft20(version), - 0x03 => _authorization_token: Vec, + 0x03 => _authorization_token: Vec, 0x06 => _subgroup_delivery_timeout: Option where Filter::is_draft20(version), 0x08 => _expires: Option, 0x09 => largest_location: Option, @@ -440,7 +440,7 @@ impl Message for PublishOk { let filter = Filter::decode(r, version)?; // no parameters - Parameters::skip(r)?; + Parameters::skip_response(r)?; Ok(Self { request_id, diff --git a/rs/moq-net/src/ietf/publish_namespace.rs b/rs/moq-net/src/ietf/publish_namespace.rs index d3713f9b47..b30e6a3987 100644 --- a/rs/moq-net/src/ietf/publish_namespace.rs +++ b/rs/moq-net/src/ietf/publish_namespace.rs @@ -7,6 +7,7 @@ use crate::{Path, coding::*, ietf::RequestId}; use super::Message; use super::cluster; use super::namespace::{decode_namespace, encode_namespace}; +use super::token::RequestToken; use super::Version; @@ -21,6 +22,10 @@ pub struct PublishNamespace<'a> { /// negotiated the extension and `None` on one that did not, which is what decides /// whether they appear on the wire at all. pub cluster: Option, + + /// The `AUTHORIZATION TOKEN` (0x03) this request presents. The parameter may repeat; the + /// first instance is the request's credential, and every instance has been decoded. + pub authorization_token: Option, } impl PublishNamespace<'_> { @@ -37,9 +42,8 @@ impl PublishNamespace<'_> { } let track_namespace = decode_namespace(r)?; - // The token is ignored: the session's grant is what authorizes the request. decode_params!(r, version, - 0x03 => _authorization_token: Vec, + 0x03 => authorization_token: Vec, cluster::HOP_PATH => hops: Option, cluster::ROUTE_COST => cost: Option, ); @@ -59,6 +63,7 @@ impl PublishNamespace<'_> { request_id, track_namespace, cluster, + authorization_token: authorization_token.into_iter().next(), }) } } @@ -72,7 +77,18 @@ impl Message for PublishNamespace<'_> { w.varint(0)?; // required_request_id_delta = 0 (draft-17 only, removed in draft-18 per #1615) } encode_namespace(w, &self.track_namespace)?; - encode_cluster_params(w, version, self.cluster.as_ref()) + // ROUTE_COST is optional and absent means 0, so a free path sends nothing. + let hops = self.cluster.as_ref().map(|advert| advert.hops.clone()); + let cost = self + .cluster + .as_ref() + .and_then(|advert| (advert.cost != 0).then_some(advert.cost)); + encode_params!(w, version, + 0x03 => self.authorization_token.clone(), + cluster::HOP_PATH => hops, + cluster::ROUTE_COST => cost, + ); + Ok(()) } fn decode_msg(r: &mut Decoder<'_>, version: Version) -> Result { @@ -94,6 +110,9 @@ pub struct PublishNamespaceUpdate { pub hops: Option, /// ROUTE_COST, when the cost changed. pub cost: Option, + /// The `AUTHORIZATION TOKEN` (0x03) renewing the advertisement's credential, if any. The + /// parameter may repeat; the first instance is kept, and every instance has been decoded. + pub authorization_token: Option, } impl PublishNamespaceUpdate { @@ -103,6 +122,7 @@ impl PublishNamespaceUpdate { request_id, hops: (held.hops != next.hops).then(|| next.hops.clone()), cost: (held.cost != next.cost).then_some(next.cost), + authorization_token: None, } } @@ -128,6 +148,7 @@ impl Message for PublishNamespaceUpdate { _ => self.request_id.encode(w, version)?, } encode_params!(w, version, + 0x03 => self.authorization_token.clone(), cluster::HOP_PATH => self.hops, cluster::ROUTE_COST => self.cost, ); @@ -144,13 +165,17 @@ impl Message for PublishNamespaceUpdate { } _ => RequestId::decode(r, version)?, }; - // The token is ignored: the session's grant is what authorizes the request. decode_params!(r, version, - 0x03 => _authorization_token: Vec, + 0x03 => authorization_token: Vec, cluster::HOP_PATH => hops: Option, cluster::ROUTE_COST => cost: Option, ); - Ok(Self { request_id, hops, cost }) + Ok(Self { + request_id, + hops, + cost, + authorization_token: authorization_token.into_iter().next(), + }) } } @@ -367,6 +392,7 @@ mod tests { request_id: RequestId(1), track_namespace: Path::new("test/broadcast"), cluster: None, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft14); @@ -456,6 +482,7 @@ mod tests { request_id: RequestId(5), track_namespace: Path::new("v17/broadcast"), cluster: None, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft17); @@ -471,6 +498,7 @@ mod tests { request_id: RequestId(5), track_namespace: Path::new("v18/broadcast"), cluster: None, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft18); @@ -572,6 +600,7 @@ mod tests { request_id: RequestId(2), hops: None, cost: Some(0), + authorization_token: None, }; let mut buf = Vec::new(); assert!( diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index db8ea33d87..60cd563191 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -20,7 +20,7 @@ use crate::{ util::{MaybeBoxedExt, MaybeSendBox}, }; -use super::{Message, Version, cluster, error::request, peer}; +use super::{Message, Version, cluster, error::request, peer, request_update}; /// Largest millisecond duration every implementation can carry losslessly. const MAX_SAFE_DELAY_MS: u64 = (1_u64 << 53) - 1; @@ -304,6 +304,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 { @@ -313,6 +317,38 @@ 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()) + } +} + +/// How a subscription's serve loop ended, short of the peer cancelling it. +#[derive(Debug)] +enum Served { + /// The track and any fill finished, or serving ended with this error. + Track(Result<(), Error>), + /// An update could not be applied, its parameters unsupported or its renewal refused + /// (drafts 16 section 9.11.1, 18 section 10.9.1). The update was already answered, and + /// PUBLISH_DONE says UPDATE_FAILED. + UpdateFailed(Error), +} + +impl Served { + /// The PUBLISH_DONE status and reason this end reports. + fn status(&self) -> (ietf::PublishDoneStatus, &'static str) { + match self { + Self::Track(Ok(())) => (ietf::PublishDoneStatus::TrackEnded, "track ended"), + Self::Track(Err(Error::Unauthorized | Error::Session(crate::SessionError::Unauthorized))) => { + (ietf::PublishDoneStatus::Unauthorized, "not granted") + } + Self::Track(Err(_)) => (ietf::PublishDoneStatus::InternalError, "internal error"), + Self::UpdateFailed(_) => (ietf::PublishDoneStatus::UpdateFailed, "update failed"), } } } @@ -326,6 +362,10 @@ enum NamespaceEvent { Update(Option<(crate::announce::Announce, 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), + /// The client replaced its request token: re-present it on every live announce. + TokenRefresh(Option), } #[derive(Clone)] @@ -351,10 +391,17 @@ 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`). pub(super) subscriptions: crate::session::Slots, + // The AUTHORIZATION TOKEN this side presents on its PUBLISH_NAMESPACE requests and their + // REQUEST_UPDATEs (MoQ request-token). A shared handle so a client can replace it while the + // session runs; the default presents none. A client credential. + request_token: crate::RequestToken, } struct Serve(Arc); @@ -377,6 +424,8 @@ enum Joined { end: Location, cache: track::Consumer, timescale: Option, + /// The subscription's namespace, which the fetch is held to the grant on. + namespace: crate::PathOwned, }, } @@ -419,11 +468,28 @@ where peer_setup, joins: Default::default(), version, + auth: crate::auth::Handle::new(false), owed: Default::default(), subscriptions: Default::default(), + request_token: crate::RequestToken::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 + } + + /// Present this request token (the AUTHORIZATION TOKEN parameter value) on the + /// PUBLISH_NAMESPACE requests this side sends, and on their REQUEST_UPDATEs, so a client + /// authorizes its announces the standard draft-17+ way (MoQ request-token). A shared handle, + /// so a replaced token is re-presented on each live announce (draft-17+). + pub fn with_request_token(mut self, token: crate::RequestToken) -> Self { + self.request_token = token; + self + } + /// What the peer declared in its SETUP, or the default (extension off) on a version /// that cannot negotiate it. /// @@ -630,6 +696,68 @@ 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. The session grant is checked first; a request it does not cover falls + // back to an AUTHORIZATION TOKEN carried on the SUBSCRIBE itself (MoQ + // request-token), verified by the app's acceptor and scoped to this one request. + // Every admitted subscription is gated so it ends if the session narrows: a + // union-authorized one on the union and the limit, a token-authorized one on the + // limit alone (the union never covered it; its grant ends with the request). + let mut request_grant = None; + // A request presenting a token is authorized by it whenever the session union does + // not positively cover the path. `covers` treats a `None` union (no answer yet, or a + // session without the AUTH extension) as NOT covering, so a standard peer's token is + // verified rather than admitted by the permissive default. A token-less request keeps + // the permissive `allows` default unchanged. + let mut gate = if let Some(token) = &msg.authorization_token + && !self + .auth + .covers(crate::auth::Direction::Publish, msg.track_namespace.as_str()) + { + // The token stands in for the union, never for the limit this side set on the + // peer: a request outside that ceiling is refused whatever the token grants. + if !self + .auth + .within_limit(crate::auth::Direction::Publish, msg.track_namespace.as_str()) + { + let err = Error::Unauthorized; + return self.reject_subscribe(stream, request_id, &err, "not granted").await; + } + // Held for the subscription's life: the request ends when this grant lapses, is + // revoked, or stops covering it, or when the limit narrows past it (the gate), + // never the session. It authorizes nothing else and never joins the session union. + let admitted = self + .auth + .admit_request( + &self.runtime, + token.0.clone(), + msg.track_namespace.to_owned(), + crate::auth::RequestKind::Subscribe, + ) + .await; + match admitted { + Ok(grant) => request_grant = Some(grant), + Err(err) => return self.reject_subscribe(stream, request_id, &err, &err.to_string()).await, + } + crate::auth::Gate::limit( + self.auth.clone(), + msg.track_namespace.to_owned(), + crate::auth::Direction::Publish, + ) + } else if self + .auth + .allows(crate::auth::Direction::Publish, msg.track_namespace.as_str()) + { + crate::auth::Gate::new( + self.auth.clone(), + msg.track_namespace.to_owned(), + crate::auth::Direction::Publish, + ) + } else { + 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 +866,7 @@ where }, cache: cache.clone(), timescale, + namespace: msg.track_namespace.to_owned(), }, (Filter::NextObject, None) => Joined::Empty, _ => Joined::Unsupported, @@ -782,12 +911,37 @@ 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, + request_grant, + msg.track_namespace.to_owned(), + ); + 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(Served::Track(Err(Error::Unauthorized)))); + } + Poll::Pending + }) + .await + }; let completed = served.is_some(); - let res = served.unwrap_or(Ok(())); + let served = served.unwrap_or(Served::Track(Ok(()))); + let (status, reason) = served.status(); + let res = match served { + Served::Track(res) => res, + Served::UpdateFailed(err) => Err(err), + }; // Draft-14 on carries no end location in PUBLISH_DONE: an END_OF_TRACK object is // what tells the subscriber where the track ended. A cancelled subscription is @@ -825,11 +979,6 @@ where let streams = track_serve.opened(); // Send PublishDone - let (status, reason) = match &res { - Ok(()) => (ietf::PublishDoneStatus::TrackEnded, "track ended"), - Err(Error::Unsupported) => (ietf::PublishDoneStatus::UpdateFailed, "update failed"), - Err(_) => (ietf::PublishDoneStatus::InternalError, "internal error"), - }; let _ = stream.writer.varint(ietf::PublishDone::ID).await; let _ = stream .writer @@ -857,32 +1006,82 @@ where serve: &mut TrackServe, finished: &mut bool, fill: impl std::future::Future, - ) -> Option> { + mut request_grant: Option, + namespace: crate::PathOwned, + ) -> Option { let mut session = self.session.clone(); let mut fill = std::pin::pin!(fill); let mut fill_done = false; let mut served = None; + + // Renewal state carried across turns of the loop. After SUBSCRIBE_OK the peer may send a + // REQUEST_UPDATE refreshing the request's token. A token-bearing update's verify is raced + // against serving and the old grant's deadline (never a bare await), so media keeps + // flowing and the old deadline still fires when the acceptor is slow. While a verdict is + // pending the loop keeps reading so a cancellation still ends the request, stashing every + // further update, token or not, to handle once the verdict resolves: draft-17+ answers + // carry no Request ID, so they must go out in the order the updates arrived. The stash is + // a FIFO bounded by MAX_REQUEST_UPDATES (counting the one being verified). + let mut renewal = request_update::Renewal::::default(); + + // What one turn of the loop resolved to. + enum Turn { + // The track and any fill finished, or the stream failed. + Served(Result<(), Error>), + // The peer cancelled, or the session ended. + Cancelled, + // The request grant ended the subscription early. + Ended(Error), + // A REQUEST_UPDATE, already decoded. + Update(super::request_stream::Update), + // A pending renewal's verdict resolved, for the update with this request id. + Renewal(request_update::Verdict), + } + loop { - let event = kio::wait(|waiter| { + // Handle one queued update before reading the next, so answers stay in order. + if let Some(update) = renewal.next() { + if let Err(served) = self + .apply_subscription_update(stream, serve, &namespace, &request_grant, &mut renewal, update) + .await + { + return Some(served); + } + continue; + } + + let turn = kio::wait(|waiter| { let mut cx = waiter.context(); if session.poll_closed(&mut cx).is_ready() { - return Poll::Ready(Err(Error::Cancel)); + return Poll::Ready(Turn::Cancelled); } if !matches!(self.version, Version::Draft14 | Version::Draft15 | Version::Draft16) && stream.writer.poll_closed(&mut cx).is_ready() { - return Poll::Ready(Err(Error::Cancel)); + return Poll::Ready(Turn::Cancelled); + } + if let Some(rg) = request_grant.as_mut() + && let Poll::Ready(err) = rg.poll_ended(waiter) + { + tracing::info!(broadcast = %namespace, %err, "request token grant ended"); + return Poll::Ready(Turn::Ended(err)); + } + if let Poll::Ready(renewed) = renewal.poll(waiter) { + return Poll::Ready(Turn::Renewal(renewed)); } if !*finished { if matches!(self.version, Version::Draft14 | Version::Draft15 | Version::Draft16) { use super::request_stream::{FollowUp, Update}; match stream.reader.poll_decode_maybe::(&mut cx) { Poll::Ready(Ok(Some(FollowUp::Update(body)))) => { - return Poll::Ready(Update::decode_legacy(&body, self.version).map(Some)); + return Poll::Ready(match Update::decode_legacy(&body, self.version) { + Ok(update) => Turn::Update(update), + Err(err) => Turn::Served(Err(err)), + }); } // UNSUBSCRIBE, or the control stream going away. - Poll::Ready(Ok(Some(FollowUp::End) | None)) => return Poll::Ready(Err(Error::Cancel)), - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(Some(FollowUp::End) | None)) => return Poll::Ready(Turn::Cancelled), + Poll::Ready(Err(err)) => return Poll::Ready(Turn::Served(Err(err))), Poll::Pending => {} } } else { @@ -890,12 +1089,12 @@ where .reader .poll_decode_maybe::(&mut cx) { - Poll::Ready(Ok(Some(update))) => return Poll::Ready(Ok(Some(update))), + Poll::Ready(Ok(Some(update))) => return Poll::Ready(Turn::Update(update)), Poll::Ready(Ok(None)) if !super::request_stream::fin_cancels(self.version) => { *finished = true } - Poll::Ready(Ok(None)) => return Poll::Ready(Err(Error::Cancel)), - Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(None)) => return Poll::Ready(Turn::Cancelled), + Poll::Ready(Err(err)) => return Poll::Ready(Turn::Served(Err(err))), Poll::Pending => {} } } @@ -909,63 +1108,144 @@ where served = Some(done); } match (&served, fill_done) { - (Some(Ok(())), true) => Poll::Ready(Ok(None)), - (Some(Err(err)), true) => Poll::Ready(Err(err.clone())), + (Some(Ok(())), true) => Poll::Ready(Turn::Served(Ok(()))), + (Some(Err(err)), true) => Poll::Ready(Turn::Served(Err(err.clone()))), _ => Poll::Pending, } }) .await; - match event { - Ok(None) => return Some(Ok(())), - Err(Error::Cancel) => return None, - Err(err) => return Some(Err(err)), - Ok(Some(update)) => { - // Draft 14 answers no update. Drafts 15 and 16 answer on the control - // stream, naming the update's own Request ID; later drafts name none. - let answer = self.version != Version::Draft14; - let answer_id = - matches!(self.version, Version::Draft15 | Version::Draft16).then_some(update.request_id); - if update.unsupported { - let result = if answer { - self.write_subscribe_error( - &mut stream.writer, - update.request_id, - &Error::Unsupported, - "REQUEST_UPDATE parameters not supported", - ) - .await - } else { - Ok(()) - }; - return Some(result.and(Err(Error::Unsupported))); - } - if let Some(priority) = update.priority { - let mut subscription = serve.track.subscription(); - subscription.priority = super::priority::from_wire(priority); - if let Err(err) = serve.track.update(subscription) { - return Some(Err(err)); + + // Draft 14 answers no update. Drafts 15 and 16 answer on the control stream, + // naming the update's own Request ID; later drafts name none. + let answer = self.version != Version::Draft14; + + match turn { + Turn::Served(res) => return Some(Served::Track(res)), + Turn::Cancelled => return None, + Turn::Ended(err) => return Some(Served::Track(Err(err))), + Turn::Renewal(request_update::Verdict { + verdict, + grant: res, + request_id: rid, + }) => { + // Only a token-authorized request starts a renewal. + let rg = request_grant.as_mut().expect("a renewal without a request grant"); + // On accept the old grant is dropped (ending the old token) and the deadline + // re-armed at the new expiry. + if rg.renew(verdict, res) { + if answer && let Err(err) = self.write_request_ok(&mut stream.writer, rid).await { + return Some(Served::Track(Err(err))); } + continue; } + // Per drafts 16 section 9.11.1 / 18 section 10.9.1, a refused or uncovered + // renewal ends only this subscription: REQUEST_ERROR, then PUBLISH_DONE + // UPDATE_FAILED. The session stays up and the old grant does not survive. if answer - && let Err(err) = async { - stream.writer.varint(ietf::RequestOk::ID).await?; - stream - .writer - .encode(&ietf::RequestOk { - request_id: answer_id, - active: None, - }) - .await - } + && let Err(err) = self + .write_subscribe_error(&mut stream.writer, rid, &Error::Unauthorized, "renewal not granted") + .await + { + return Some(Served::Track(Err(err))); + } + return Some(Served::UpdateFailed(Error::Unauthorized)); + } + // An update behind a pending verdict waits its turn, so its answer follows the + // verdict's. + Turn::Update(update) if renewal.pending() => match renewal.queue(update, self.version) { + Ok(()) => {} + Err(request_update::Overflow::Session) => { + return Some(Served::Track(Err(request_update::too_many(&mut self.session.clone())))); + } + // Below draft 19 no limit is negotiated, so this is a local memory guard: end + // this request, never the session. + Err(request_update::Overflow::Request) => { + return Some(Served::Track(Err(Error::ProtocolViolation))); + } + }, + Turn::Update(update) => { + if let Err(served) = self + .apply_subscription_update(stream, serve, &namespace, &request_grant, &mut renewal, update) .await { - return Some(Err(err)); + return Some(served); } } } } } + /// Apply one REQUEST_UPDATE on an in-flight subscription, with no verdict pending: refuse + /// unsupported parameters, apply a priority, then start verifying a renewal's token, or + /// answer at once. A token-less update, or any update on a union-authorized request (which + /// holds no grant to renew), owes one answer so the peer's MAX_REQUEST_UPDATES credit is + /// restored. + /// + /// An error is how the request ends. + async fn apply_subscription_update( + &self, + stream: &mut Stream, + serve: &mut TrackServe, + namespace: &crate::PathOwned, + request_grant: &Option, + renewal: &mut request_update::Renewal, + update: super::request_stream::Update, + ) -> Result<(), Served> { + // Draft 14 answers no update. Drafts 15 and 16 answer on the control stream, naming the + // update's own Request ID; later drafts name none. + let answer = self.version != Version::Draft14; + let failed = |err| Served::Track(Err(err)); + if update.unsupported { + if answer { + self.write_subscribe_error( + &mut stream.writer, + update.request_id, + &Error::Unsupported, + "REQUEST_UPDATE parameters not supported", + ) + .await + .map_err(failed)?; + } + return Err(Served::UpdateFailed(Error::Unsupported)); + } + if let Some(priority) = update.priority { + let mut subscription = serve.track.subscription(); + subscription.priority = super::priority::from_wire(priority); + serve.track.update(subscription).map_err(failed)?; + } + if let (Some(token), true) = (update.authorization_token, request_grant.is_some()) { + let verdict = self + .auth + .verify_request(token.0, namespace.clone(), crate::auth::RequestKind::Subscribe); + renewal.verify(verdict, update.request_id); + return Ok(()); + } + if answer { + self.write_request_ok(&mut stream.writer, update.request_id) + .await + .map_err(failed)?; + } + Ok(()) + } + + /// Answer a REQUEST_UPDATE with REQUEST_OK. + /// + /// Drafts 15 and 16 answer on the control stream, naming the update's own Request ID; + /// later drafts name none. Draft-14 answers nothing and never reaches here. + async fn write_request_ok( + &self, + writer: &mut Writer, + request_id: RequestId, + ) -> Result<(), Error> { + writer.varint(ietf::RequestOk::ID).await?; + writer + .encode(&ietf::RequestOk { + request_id: matches!(self.version, Version::Draft15 | Version::Draft16).then_some(request_id), + active: None, + }) + .await + } + /// Reject a SUBSCRIBE, ending the request stream. /// /// Takes the whole stream because delivering the error is the other half of the job: @@ -1412,13 +1692,24 @@ where other => other, }; - let (track, start, end, timescale, joined) = match fetch_type { + // Every FETCH is held to the grant while its group loads and its response is written. + // A standalone one is also checked here, before it reaches the origin; a joining one + // was checked when its subscription was. + let (track, start, end, timescale, joined, mut gate) = match fetch_type { FetchType::Standalone { namespace, track, 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 +1733,7 @@ where }; // The track's timescale is only known once its group is read, below. - (track, start, end, None, false) + (track, start, end, None, false, gate) } FetchType::RelativeJoining { subscriber_request_id, .. @@ -1450,10 +1741,13 @@ where | FetchType::AbsoluteJoining { subscriber_request_id, .. } => { - let (end, cache, timescale) = match self.joined(&mut stream, subscriber_request_id).await? { + let (end, cache, timescale, namespace) = match self.joined(&mut stream, subscriber_request_id).await? { Ok(joined) => joined, Err((err, reason)) => return self.reject_fetch(stream, msg.request_id, &err, reason).await, }; + // The cache outlives the subscription, so the fetch holds its own gate rather + // than trusting the subscription's to stop it. + let gate = crate::auth::Gate::new(self.auth.clone(), namespace, crate::auth::Direction::Publish); let start = match fetch_type { FetchType::RelativeJoining { group_offset, .. } => end.group.saturating_sub(group_offset), FetchType::AbsoluteJoining { group_id, .. } if group_id <= end.group => group_id, @@ -1477,6 +1771,7 @@ where end, timescale, true, + gate, ) } // Rewritten as standalone or refused above. @@ -1515,6 +1810,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 @@ -1610,52 +1908,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: Default::default(), - }) - .await?; + // The response, raced against the grant: a standalone FETCH whose grant narrows while it + // waits on stream credit stops there instead of sending what it no longer may. + let respond = async { + // FETCH_OK on every draft, never REQUEST_OK: section 5.2 allows exactly one FETCH_OK or + // REQUEST_ERROR in answer to a FETCH, and REQUEST_OK's own definition lists the other + // requests it answers without ever naming this one. + stream.writer.varint(ietf::FetchOk::ID).await?; + stream + .writer + .encode(&ietf::FetchOk { + request_id: match self.version { + Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(msg.request_id), + _ => None, + }, + // Only draft-14 encodes it, and only as the publisher restating the order. + group_order: match msg.group_order { + GroupOrder::Descending => GroupOrder::Descending, + _ => GroupOrder::Ascending, + }, + end_of_track, + end_location, + properties: Default::default(), + }) + .await?; - let uni = self.session.open_uni().await.map_err(Error::from_transport)?; - let mut writer = Writer::new(uni, self.version); - writer.set_priority(priority); - writer.varint(FetchHeader::TYPE).await?; - writer - .encode(&FetchHeader { - request_id: msg.request_id, + let uni = self.session.open_uni().await.map_err(Error::from_transport)?; + let mut writer = Writer::new(uni, self.version); + writer.set_priority(priority); + writer.varint(FetchHeader::TYPE).await?; + writer + .encode(&FetchHeader { + request_id: msg.request_id, + }) + .await?; + let mut first = true; + for (object, frame) in (group.first..).zip(group.frames) { + Self::write_fetch_object( + &mut writer, + group.sequence, + object, + FetchPrior::next(&mut first), + frame.timestamp, + timescale, + self.version, + ) + .await?; + Self::write_fetch_payload(&mut writer, frame.payload, self.version).await?; + } + writer.close().await?; + Ok::<(), Error>(()) + }; + let res = { + let mut respond = std::pin::pin!(respond); + kio::wait(|waiter| { + if 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 @@ -1671,7 +1990,8 @@ where &mut self, stream: &mut Stream, subscribe_id: RequestId, - ) -> Result), (Error, &'static str)>, Error> { + ) -> Result, crate::PathOwned), (Error, &'static str)>, Error> + { // Request streams can arrive out of order. Wait on registration, while bounding // the lifetime of a request whose subscription never arrives or resolves. let joined = { @@ -1708,7 +2028,12 @@ where let refusal = match joined { Err(Error::Timeout) => (Error::Timeout, "subscription not ready"), Err(err) => return Err(err), - Ok(Some(Joined::Group { end, cache, timescale })) => return Ok(Ok((end, cache, timescale))), + Ok(Some(Joined::Group { + end, + cache, + timescale, + namespace, + })) => return Ok(Ok((end, cache, timescale, namespace))), Ok(None) => ( match self.version { Version::Draft14 @@ -1748,6 +2073,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 @@ -1771,6 +2111,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 @@ -1781,18 +2124,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. @@ -1933,17 +2293,32 @@ where suffix: &crate::PathOwned, path: &crate::PathOwned, ) -> Result<(), Error> { + // A request token authorizes an announce the connection grant does not cover, but only + // when the announce carries it: a PUBLISH_NAMESPACE request does, an inline NAMESPACE + // entry has no slot for one. So the grant still filters inline entries, and stands down + // only for requests, where the server's covers-gate refuses a bad token per request. + // The token never stands in for the limit this side set on the peer. + let carries_token = matches!(ns.target, Target::Requests(_)) && self.request_token.peek().is_some(); + let permitted = match carries_token { + true => ns.permit.within_limit(path.as_str()), + false => 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(); @@ -2069,6 +2444,7 @@ where request_id, track_namespace: path.as_path(), cluster, + authorization_token: self.request_token.peek().map(crate::ietf::token::RequestToken), }) .await?; @@ -2306,6 +2682,86 @@ where Ok(()) } + /// Re-present the client's request token on every live announce as a token-only + /// REQUEST_UPDATE (MoQ request-token renewal), so a refreshed credential reaches the peer + /// before the old grant lapses. + /// + /// Draft-17+ only: earlier drafts have no PUBLISH_NAMESPACE_UPDATE, so a token set on them + /// rides the initial advertisement and is not renewed in place. Clearing the token (`None`) + /// is not a renewal and sends nothing. A refusal withdraws the announce, since the receiver + /// closes its stream on a renewal it cannot grant; the retry re-offers it carrying the new + /// token, honoring any retry interval the refusal named. + async fn refresh_request_token( + &self, + ns: &mut Namespaces, + token: Option, + ) -> Result<(), Error> { + let Some(token) = token else { + return Ok(()); + }; + if matches!(self.version, Version::Draft14 | Version::Draft15 | Version::Draft16) { + return Ok(()); + } + + let suffixes: Vec = ns.requests.keys().cloned().collect(); + for suffix in suffixes { + let request_id = self.control.next_request_id(&self.runtime).await?; + let Some(request) = ns.requests.get_mut(&suffix) else { + continue; + }; + let update = ietf::PublishNamespaceUpdate { + request_id, + hops: None, + cost: None, + authorization_token: Some(crate::ietf::token::RequestToken(token.clone())), + }; + request.stream.writer.encode_message(&update).await?; + + let absolute = self.origin.absolute(&request.path).to_owned(); + let Some((type_id, body)) = self.read_response(&mut request.stream).await? else { + tracing::debug!(broadcast = %absolute, "no answer to the token refresh"); + // The peer never answered: the stream is gone, so drop the advertisement. + ns.requests.remove(&suffix); + // Forget what was sent so reconcile re-offers it with the new token, and re-arm + // the deferred-only retry sweep if an earlier refusal still holds it back. + if let Some(watch) = ns.watched.get_mut(&suffix) { + watch.sent = Advert::None; + watch.deferred = watch.refused.pending(); + } + continue; + }; + let mut data = body.decoder(self.version); + match type_id { + ietf::RequestOk::ID => { + let _ = ietf::RequestOk::decode_msg(&mut data, self.version)?; + tracing::debug!(broadcast = %absolute, "request token refreshed"); + } + ietf::RequestError::ID => { + let msg = ietf::RequestError::decode_msg(&mut data, self.version)?; + // A refused renewal means the receiver closed its stream, so the announce + // cannot stand on the old grant: withdraw it and honor the refusal's retry + // interval, exactly as a repriced update does. The retry re-offers it fresh, + // carrying the new token. + tracing::warn!(broadcast = %absolute, message = ?msg, "request token refresh refused"); + let refused = self.refusal(msg.retry_interval); + let deferred = refused.pending(); + self.withdraw_namespace(&mut ns.target, &mut ns.requests, suffix.clone()) + .await?; + if let Some(watch) = ns.watched.get_mut(&suffix) { + watch.refused = refused; + watch.sent = Advert::None; + // Re-arm the retry sweep (which only revisits deferred namespaces) so a + // bounded refusal re-offers this fresh with the new token once its interval + // passes; a permanent refusal (Never) is not pending and stays withdrawn. + watch.deferred = deferred; + } + } + _ => return Err(Error::UnexpectedMessage), + } + } + Ok(()) + } + /// Close out every open PUBLISH_NAMESPACE request. A no-op for a loop whose entries /// ride the SUBSCRIBE_NAMESPACE stream itself, which retracts them by ending. async fn withdraw_requests( @@ -2415,12 +2871,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)) @@ -2472,6 +2934,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 @@ -2508,6 +2982,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?; } @@ -2519,6 +3000,10 @@ where let mut retry_at: Option = None; let mut retry_delay = RETRY_BASE; + // The request token last presented on these announces (their initial value). A client + // replacing it wakes the loop, which re-presents the new one on each live announce. + let mut last_token = self.request_token.peek(); + // Stream updates (origin route (un)announces), bailing if the peer closes // its side first. let res = loop { @@ -2534,7 +3019,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)); @@ -2543,6 +3028,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) => { @@ -2551,6 +3041,11 @@ where crate::announce::Event::End(update) => (update, false), }))); } + // A replaced request token is re-presented on each live announce, below the + // origin updates so a busy loop still makes progress on both. + if let Poll::Ready(token) = self.request_token.poll_changed(&last_token, waiter) { + return Poll::Ready(NamespaceEvent::TokenRefresh(token)); + } if retry.poll(waiter).is_ready() { return Poll::Ready(NamespaceEvent::Retry); } @@ -2561,6 +3056,10 @@ where match event { NamespaceEvent::Closed(res) => break res, + NamespaceEvent::TokenRefresh(token) => { + last_token = token.clone(); + self.refresh_request_token(&mut ns, token).await?; + } NamespaceEvent::Retry => { retry_at = None; retry_delay = (retry_delay * 2).min(RETRY_MAX); @@ -2580,6 +3079,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. @@ -3656,11 +4163,14 @@ mod serve_tests { None, ); let mut finished = false; - let mut run = - std::pin::pin!( - h.publisher - .run_subscription(&mut stream, &mut serving, &mut finished, async {}) - ); + let mut run = std::pin::pin!(h.publisher.run_subscription( + &mut stream, + &mut serving, + &mut finished, + async {}, + None, + crate::Path::new("room").to_owned(), + )); assert_eq!( futures::poll!(run.as_mut()).is_ready(), super::super::request_stream::fin_cancels(version), @@ -3754,10 +4264,14 @@ mod serve_tests { None, ); let mut finished = false; - let mut run = std::pin::pin!( - h.publisher - .run_subscription(&mut stream, &mut serving, &mut finished, async {}) - ); + let mut run = std::pin::pin!(h.publisher.run_subscription( + &mut stream, + &mut serving, + &mut finished, + async {}, + None, + crate::Path::new("room").to_owned(), + )); assert!(futures::poll!(run.as_mut()).is_pending()); assert_eq!(h.track.subscription().unwrap().priority, 245); assert_eq!(occurrences(&session.log, &[0x07, 0, 1, 0]), 2); @@ -3781,9 +4295,19 @@ mod serve_tests { let mut finished = false; let served = h .publisher - .run_subscription(&mut stream, &mut serving, &mut finished, async {}) + .run_subscription( + &mut stream, + &mut serving, + &mut finished, + async {}, + None, + crate::Path::new("room").to_owned(), + ) .await; - assert!(matches!(served, Some(Err(Error::Unsupported))), "{served:?}"); + assert!( + matches!(served, Some(Served::UpdateFailed(Error::Unsupported))), + "{served:?}" + ); } /// The request stream's control messages and every data stream's first FIN or reset, @@ -3984,12 +4508,51 @@ mod serve_tests { wire.assert_streams_closed_before_publish_done(); } + /// A track that ends with an Unsupported error (a relay refusing a copy whose metadata + /// changed does) ends its subscription with INTERNAL_ERROR: UPDATE_FAILED is only for an + /// update the publisher could not apply. + #[moq_net_sim::test] + async fn an_unsupported_track_error_is_not_a_failed_update() { + let version = Version::Draft18; + let h = serve(version); + let mut session = ScriptedSession::per_stream(vec![vec![]]); + let mut stream = Stream::open(&mut session, version).await.unwrap(); + let mut serving = TrackServe::new( + h.session.clone(), + h.track.subscribe(None), + RequestId(0), + version, + ServeRange::default(), + None, + ); + h.track.clone().abort(Error::Unsupported).unwrap(); + let mut finished = false; + let served = h + .publisher + .run_subscription( + &mut stream, + &mut serving, + &mut finished, + async {}, + None, + crate::Path::new("room").to_owned(), + ) + .await + .expect("the track's error ends the subscription"); + assert!(matches!(served, Served::Track(Err(Error::Unsupported))), "{served:?}"); + assert_eq!(served.status().0, ietf::PublishDoneStatus::InternalError); + assert_eq!( + Served::UpdateFailed(Error::Unsupported).status().0, + ietf::PublishDoneStatus::UpdateFailed + ); + } + /// Draft-21 §9.5: a failed REQUEST_UPDATE is answered with REQUEST_ERROR, then the /// subscription ends with PUBLISH_DONE(UPDATE_FAILED). /// - /// The update carries an authorization token this publisher cannot verify. It - /// arrives while object 0 of the current group is half written, so the fill stream - /// is stalled inside it and the group stream waits for object 1. + /// The update asks for FORWARD=0, which this publisher does not serve. It arrives + /// while object 0 of the current group is half written, so the fill stream is stalled + /// inside it and the group stream waits for object 1. async fn fail_update_mid_flight(version: Version) -> Wire { let h = serve(version); let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); @@ -4016,8 +4579,8 @@ mod serve_tests { ) .await; - // REQUEST_UPDATE, Request ID 2, one AUTHORIZATION TOKEN (0x03) parameter. - request.push(&[0x02, 0, 5, 2, 1, 0x03, 1, 0x00]); + // REQUEST_UPDATE, Request ID 2, one FORWARD (0x10) of 0. + request.push(&[0x02, 0, 4, 2, 1, 0x10, 0x00]); moq_net_sim::timeout(std::time::Duration::from_secs(10), serve) .await .expect("the failed update ends the subscription") @@ -4059,8 +4622,8 @@ mod serve_tests { /// A draft 14-16 update framed as the adapter routes it onto its subscription. /// - /// Drafts 15 and 16 carry only SUBSCRIBER_PRIORITY and FORWARD here, since the codec's - /// own encoding always adds a Subscription Filter, which this publisher refuses. + /// Drafts 15 and 16 carry only SUBSCRIBER_PRIORITY and FORWARD here, written by hand so + /// the bytes are pinned. fn legacy_update(version: Version, priority: u8, forward: bool) -> Vec { const UPDATE_ID: u8 = 10; let body = match version { @@ -4071,8 +4634,10 @@ mod serve_tests { subscription_request_id: Some(RequestId(0)), start_location: Location { group: 0, object: 0 }, end_group: 0, - subscriber_priority: priority, - forward, + subscriber_priority: Some(priority), + forward: Some(forward), + filter: None, + authorization_token: None, } .encode_msg(&mut Encoder::new(&mut body, version.into()), version) .unwrap(); @@ -4105,11 +4670,14 @@ mod serve_tests { None, ); let mut finished = false; - let mut run = - std::pin::pin!( - h.publisher - .run_subscription(&mut stream, &mut serving, &mut finished, async {}) - ); + let mut run = std::pin::pin!(h.publisher.run_subscription( + &mut stream, + &mut serving, + &mut finished, + async {}, + None, + crate::Path::new("room").to_owned(), + )); assert!(futures::poll!(run.as_mut()).is_pending(), "{version}: update ended it"); assert_eq!(h.track.subscription().unwrap().priority, 245, "{version}"); @@ -4150,9 +4718,19 @@ mod serve_tests { let mut finished = false; let served = h .publisher - .run_subscription(&mut stream, &mut serving, &mut finished, async {}) + .run_subscription( + &mut stream, + &mut serving, + &mut finished, + async {}, + None, + crate::Path::new("room").to_owned(), + ) .await; - assert!(matches!(served, Some(Err(Error::Unsupported))), "{version}: {served:?}"); + assert!( + matches!(served, Some(Served::UpdateFailed(Error::Unsupported))), + "{version}: {served:?}" + ); // The answer is all that was written: [type, length (2), Request ID, ...]. let written = session.log.writes.lock().unwrap().clone(); match version { @@ -4202,6 +4780,7 @@ mod serve_tests { properties_wanted: true, forward: true, range_filters: false, + authorization_token: None, } } @@ -4389,22 +4968,207 @@ mod serve_tests { } } - /// A TRACK_STATUS for a broadcast we do not serve is refused as a SUBSCRIBE would be. + /// 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_for_a_missing_broadcast_is_refused() { + 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 code = refusal(version, ietf::TrackStatus::ID, track_status("absent", true, version)).await; - let expected = match version { - Version::Draft14 => 0x4, - _ => 0x10, - }; - assert_eq!(code, expected, "{version}"); + 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" + ); } - } - /// Opting out of SUBSCRIBE_OK's properties does not strip the objects' timestamps: - /// their units come from TRACK_STATUS instead. - #[moq_net_sim::test] + 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"); + } + } + + /// A request's USE_VALUE token decodes but grants nothing yet, so a TRACK_STATUS outside + /// the session grant is still refused. + #[moq_net_sim::test] + async fn a_request_token_does_not_widen_the_grant() { + let nothing = crate::auth::Grant { + publish: Default::default(), + subscribe: Default::default(), + expires: None, + }; + for version in [Version::Draft14, Version::Draft22] { + let mut h = serve(version); + let auth = crate::auth::Handle::new(false); + auth.authorize(¬hing); + h.publisher = h.publisher.clone().with_auth(auth); + + let mut body = track_status("room", true, version); + // Replace the empty parameter block with an AUTHORIZATION TOKEN: USE_VALUE, type 0. + assert_eq!(body.pop(), Some(0), "{version}"); + body.extend([0x01, 0x03, 0x03, 0x03, 0x00, 0xAA]); + + let unauthorized = request::to_code(&Error::Unauthorized, request::Kind::TrackStatus, version); + assert_eq!(answer_code(&h, body, version).await, Some(unauthorized), "{version}"); + } + } + + /// 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() { + for version in ALL_VERSIONS { + let code = refusal(version, ietf::TrackStatus::ID, track_status("absent", true, version)).await; + let expected = match version { + Version::Draft14 => 0x4, + _ => 0x10, + }; + assert_eq!(code, expected, "{version}"); + } + } + + /// Opting out of SUBSCRIBE_OK's properties does not strip the objects' timestamps: + /// their units come from TRACK_STATUS instead. + #[moq_net_sim::test] async fn the_properties_opt_out_keeps_object_timestamps() { let version = Version::Draft20; let stamp = crate::Timestamp::from_millis(123_456).unwrap(); @@ -5782,6 +6546,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] @@ -6078,1033 +7101,2962 @@ mod serve_tests { assert!(!serve(Some(0)).await.flags.has_end, "capped at object 0 of 3"); assert!(serve(None).await.flags.has_end, "the whole group"); } -} -#[cfg(test)] -mod tests { - use super::*; - use crate::coding::Decode; - use crate::lite::test_transport::SinkSession; - use crate::model::ProduceTest; - use futures::FutureExt; + /// Like [`serve`], but with an app auth acceptor wired in and the subscribe stream's + /// reader scripted with `first_script` (a REQUEST_UPDATE, for the renewal tests). + fn serve_with_auth(version: Version, auth: crate::auth::Handle, first_script: Vec) -> Serve { + serve_on(version, auth, ScriptedSession::per_stream(vec![first_script])) + } - async fn settle() { - moq_net_sim::sleep(Duration::from_millis(1)).await; + /// [`serve_with_auth`] over a given session, such as one that finishes the stream after + /// its script. + fn serve_on(version: Version, auth: crate::auth::Handle, session: ScriptedSession) -> Serve { + 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 log = session.log.clone(); + + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + + let publisher = Publisher::new( + crate::time::Clock::sim(), + session.clone(), + origin.consume(), + Control::new(None, false), + None, + peer_setup, + version, + ) + .with_auth(auth); + + Serve { + publisher, + session, + log, + track, + _origin: origin, + _broadcast: broadcast, + } } - fn occurrences(log: &crate::lite::test_transport::Log, needle: &[u8]) -> usize { - let writes = log.writes.lock().unwrap(); - writes.windows(needle.len()).filter(|window| *window == needle).count() + /// A grant to subscribe to everything, lapsing in `secs` (or never), on the publisher's clock. + fn grant_all_expiring(runtime: &crate::time::Clock, secs: Option) -> crate::auth::Grant { + crate::auth::Grant { + publish: crate::Patterns::new(), + subscribe: crate::Pattern::all().into(), + expires: secs.map(|s| runtime.now().checked_add(Duration::from_secs(s)).unwrap()), + } } - /// NAMESPACE and NAMESPACE_DONE suffixes on a SUBSCRIBE_NAMESPACE stream, in order. - fn namespace_events(bytes: &[u8], version: Version) -> Vec<(u64, String)> { - let mut dec = crate::coding::Decoder::new(bytes, version.into()); - let mut out = Vec::new(); - while !dec.is_empty() { - let id = dec.varint().expect("message type"); - match id { - ietf::RequestOk::ID => { - ietf::RequestOk::decode(&mut dec, version).expect("request ok"); - } - ietf::Namespace::ID => { - let msg = ietf::Namespace::decode(&mut dec, version).expect("namespace"); - out.push((id, msg.suffix.as_str().to_owned())); - } - ietf::NamespaceDone::ID => { - let msg = ietf::NamespaceDone::decode(&mut dec, version).expect("namespace done"); - out.push((id, msg.suffix.as_str().to_owned())); - } - other => panic!("unexpected message {other:#x}"), - } - } - out - } - - /// A SETUP slot already filled with what the peer declared. The announce loops block - /// on it, so a test that leaves it empty is a test that never advertises. - fn declared(solicit: Option) -> peer::PeerSetup { - let slot = peer::PeerSetup::default(); - slot.set(peer::Peer { - solicit, - ..Default::default() - }); - slot + /// An auth handle whose session grant covers only `other`, so a SUBSCRIBE for `room` + /// falls to its request token. The presented credential is kept alive by the returned + /// [`crate::auth::Token`]. + fn auth_covering_other() -> (crate::auth::Handle, crate::auth::Requests, crate::auth::Token) { + let auth = crate::auth::Handle::new(true); + let requests = auth.requests().unwrap(); + let cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); + auth.granted( + 0, + crate::auth::Grant { + publish: crate::Pattern::subtree("other").unwrap().into(), + subscribe: crate::Patterns::new(), + expires: None, + }, + ); + assert!( + !auth.allows(crate::auth::Direction::Publish, "room"), + "the session grant must not cover the request path" + ); + (auth, requests, cred) } - /// moq-transport cannot carry the receiver's max delay budget, so the serving - /// subscription must preserve everything the producer still retains. - #[test] - fn serving_subscription_keeps_retained_backlog() { - let producer = track::Producer::new(std::sync::Arc::new(crate::broadcast::Info::default()), "video", None); - for millis in [0, 1000] { - let mut group = producer.append_group().unwrap(); - group - .write_frame(crate::Timestamp::from_millis(millis).unwrap(), b"frame".as_slice()) - .unwrap(); - group.finish().unwrap(); - } - - let subscription = serving_subscription(128); - assert_eq!(subscription.max_delay.as_millis(), MAX_SAFE_DELAY_MS as u128); - let mut subscriber = producer.subscribe(subscription); - for sequence in [0, 1] { - let group = subscriber - .recv_group() - .now_or_never() - .expect("retained group should be ready") - .unwrap() - .expect("track should remain open"); - assert_eq!(group.sequence, sequence); + /// A token-bearing client does not self-censor on its connection grant: the + /// publisher advertises a namespace its connection grant does not cover, carrying the token, + /// and the server's covers-gate is left to be the authority. A token-less client with the same + /// grant filters that announce, as before. + #[moq_net_sim::test] + async fn a_token_bearing_client_announces_outside_its_connection_grant() { + const VERSION: Version = Version::Draft18; + let token = bytes::Bytes::from_static(&[0x03, 0x00, b'o', b'k']); + + // A connection grant of "other", which does not cover "cam". The presented credential and + // its grant are held for the run's lifetime, else the union reverts to permissive. + fn seed_auth() -> (crate::auth::Handle, crate::auth::Token) { + let auth = crate::auth::Handle::new(true); + let cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); + auth.granted( + 0, + crate::auth::Grant { + publish: crate::Pattern::subtree("other").unwrap().into(), + subscribe: crate::Patterns::new(), + expires: None, + }, + ); + (auth, cred) } - } - - /// A peer that requires solicitation, which is what hands the advertisements to the - /// SUBSCRIBE_NAMESPACE stream. - fn requires_solicitation() -> peer::PeerSetup { - declared(Some(true)) - } - /// A publisher for a peer assigned `assigned`, over an origin holding two - /// broadcasts: `from/peer`, whose only route flows through `assigned`, and - /// `from/us`, which does not. The producers are returned so the routes outlive - /// the assertions. - async fn echo_harness( - assigned: crate::Hop, - ) -> ( - Publisher, - origin::Consumer, - Vec, - ) { - let other = crate::Hop::new(778).unwrap(); + // With a token, "cam" is advertised despite the grant not covering it (the token rides it). let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let consumer = origin.consume(); - - let session = crate::lite::test_transport::SinkSession::new(Default::default()); + let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![Vec::new()]); + let log = session.log.clone(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + let (auth, _cred) = seed_auth(); let publisher = Publisher::new( crate::time::Clock::sim(), session, origin.consume(), Control::new(None, false), - Some(assigned), - peer::PeerSetup::default(), - Version::Draft16, + None, + peer_setup, + VERSION, + ) + .with_auth(auth) + .with_request_token(crate::RequestToken::new(Some(typed(&token)))); + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + let mut advertised = false; + for _ in 0..200 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, &token) >= 1 { + advertised = true; + break; + } + settle().await; + } + assert!( + advertised, + "a token-bearing client must announce outside its connection grant" ); - let mut echoed_hops = crate::Hops::new(); - echoed_hops.push(crate::Hop::UNKNOWN).unwrap(); - let echoed = origin - .announce( - "from/peer", - crate::origin::Route::default() - .with_hops(echoed_hops) - .with_via(assigned), - ) - .unwrap(); - - let mut local_hops = crate::Hops::new(); - local_hops.push(other).unwrap(); - let local = origin - .announce("from/us", crate::origin::Route::default().with_hops(local_hops)) - .unwrap(); - - (publisher, consumer, vec![echoed, local]) + // Without a token, the same grant filters "cam": nothing is advertised. + let origin2 = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let _cam2 = origin2.announce("cam", crate::origin::Route::default()).unwrap(); + let session2 = crate::lite::test_transport::ScriptedSession::per_stream(vec![Vec::new()]); + let log2 = session2.log.clone(); + let peer_setup2 = peer::PeerSetup::default(); + peer_setup2.set(peer::Peer::default()); + let (auth2, _cred2) = seed_auth(); + let publisher2 = Publisher::new( + crate::time::Clock::sim(), + session2, + origin2.consume(), + Control::new(None, false), + None, + peer_setup2, + VERSION, + ) + .with_auth(auth2); + let mut run2 = std::pin::pin!(publisher2.run_publish_namespaces()); + for _ in 0..80 { + assert!(futures::poll!(run2.as_mut()).is_pending()); + settle().await; + } + assert_eq!( + occurrences(&log2, b"cam"), + 0, + "a token-less client self-censors on its grant" + ); } - /// A broadcast whose every route flows through the peer's assigned identity - /// (`Client::with_peer_hop`) is never advertised to that peer; it would only - /// echo the peer's own content back at it. A broadcast with an independent - /// route still is. + /// A request token stands in for the connection grant, never for the limit this side set + /// on the peer: a token-bearing client still withholds an announce outside that limit. #[moq_net_sim::test] - async fn assigned_peer_hop_filters_echoed_announces() { - let assigned = crate::Hop::new(777).unwrap(); - let (publisher, consumer, _routes) = echo_harness(assigned).await; - - let peer = cluster::Peer::default(); + async fn a_request_token_does_not_lift_the_local_limit_on_announces() { + const VERSION: Version = Version::Draft18; + let token = bytes::Bytes::from_static(&[0x03, 0x00, b'o', b'k']); - // The cursor is what filters: an excluded consumer never sees the echoed route. - let mut announced = consumer.excluding(assigned).announced(); - let local = announced.assert_next_active("from/us"); - announced.assert_next_wait(); - - assert_eq!(publisher.select(&local, &peer), Advert::Plain); + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![Vec::new()]); + let log = session.log.clone(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + let auth = crate::auth::Handle::new(false); + auth.authorize(&crate::auth::Grant::default()); + let publisher = Publisher::new( + crate::time::Clock::sim(), + session, + origin.consume(), + Control::new(None, false), + None, + peer_setup, + VERSION, + ) + .with_auth(auth) + .with_request_token(crate::RequestToken::new(Some(typed(&token)))); + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + settle().await; + } + assert_eq!(occurrences(&log, b"cam"), 0, "the limit still withholds the announce"); } - /// An anonymous chain received from an identified peer keeps the 0 on the wire - /// and is never advertised back to that session: split-horizon matches `via` - /// as well as the chain. + /// A peer that requires solicitation gets inline NAMESPACE entries, which have no slot for a + /// request token, so the token cannot authorize them: the connection grant still filters the + /// answer to its SUBSCRIBE_NAMESPACE. #[moq_net_sim::test] - async fn anonymous_chain_is_forwarded_with_zero_and_not_echoed() { - let assigned = crate::Hop::new(777).unwrap(); - let r1 = crate::Hop::new(9).unwrap(); + async fn a_request_token_does_not_lift_the_grant_on_inline_namespaces() { + const VERSION: Version = Version::Draft18; + let token = bytes::Bytes::from_static(&[0x03, 0x00, b'o', b'k']); + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let consumer = origin.consume(); + let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); + let _other = origin.announce("other/mic", crate::origin::Route::default()).unwrap(); + settle().await; + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![Vec::new()]); + let log = session.log.clone(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer { + solicit: Some(true), + ..Default::default() + }); + let auth = crate::auth::Handle::new(true); + let _cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); + auth.granted( + 0, + crate::auth::Grant { + publish: crate::Pattern::subtree("other").unwrap().into(), + subscribe: crate::Patterns::new(), + expires: None, + }, + ); let publisher = Publisher::new( crate::time::Clock::sim(), - crate::lite::test_transport::SinkSession::new(Default::default()), + session.clone(), origin.consume(), Control::new(None, false), - Some(assigned), - peer::PeerSetup::default(), - Version::Draft19, - ); - - let mut hops = crate::Hops::new(); - hops.push(crate::Hop::UNKNOWN).unwrap(); - hops.push(r1).unwrap(); - let _echoed = origin - .announce( - "from/peer", - crate::origin::Route::default().with_hops(hops.clone()).with_via(r1), - ) - .unwrap(); + None, + peer_setup, + VERSION, + ) + .with_auth(auth) + .with_request_token(crate::RequestToken::new(Some(typed(&token)))); - let peer = cluster::Peer { - hop: Some(r1), - cost: None, + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + let msg = ietf::SubscribeNamespace { + request_id: RequestId(1), + namespace: crate::Path::new(""), + hidden: false, }; - let mut announced = consumer.excluding(publisher.exclude(&peer)).announced(); - announced.assert_next_wait(); - - let forwarded = cluster::Advert::forward(&hops, 0, crate::Hop::new(1).unwrap()).unwrap(); - let ids: Vec<_> = forwarded.hops.hops().iter().map(|h| h.id()).collect(); - assert_eq!(ids, vec![0, 9, 1]); + let mut run = std::pin::pin!(publisher.run_subscribe_namespace_stream(stream, msg)); + let mut covered = false; + for _ in 0..200 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, b"mic") >= 1 { + covered = true; + break; + } + settle().await; + } + assert!(covered, "a namespace the grant covers is advertised inline"); + for _ in 0..50 { + assert!(futures::poll!(run.as_mut()).is_pending()); + settle().await; + } + assert_eq!(occurrences(&log, b"cam"), 0, "the grant still filters inline entries"); + assert_eq!(occurrences(&log, &token), 0, "an inline entry carries no token"); } - /// Declaring the reserved 0 turns the extension on while naming nobody, so the - /// identity we assigned stands in, exactly as for a peer that never negotiated. - /// Asserted on the resolution itself rather than through an advertisement: a - /// negotiated peer always sends its own HOP_PATH, so a route attributed to the - /// assigned identity is a state this peer class cannot reach; see - /// [`a_declared_zero_chain_is_not_advertised_back`] for what it gets instead. - #[moq_net_sim::test] - async fn withheld_peer_hop_falls_back_to_assigned() { - let assigned = crate::Hop::new(777).unwrap(); - let declared = crate::Hop::new(9).unwrap(); - let (publisher, _consumer, _routes) = echo_harness(assigned).await; + /// A request token whose Token Type takes two varint bytes. + fn request_token() -> crate::ietf::token::RequestToken { + crate::ietf::token::RequestToken(crate::setup::Token { + kind: 300, + value: vec![0x00, 0xff], + }) + } - let withheld = cluster::Peer { - hop: Some(crate::Hop::UNKNOWN), - cost: None, + /// One REQUEST_UPDATE carrying a fresh AUTHORIZATION TOKEN, framed as the peer sends it. + async fn subscribe_update_with_token(version: Version) -> Vec { + let log = crate::lite::test_transport::Log::default(); + let mut writer = crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); + let subscription_request_id = match version { + Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(RequestId(REQUEST_ID)), + _ => None, }; - assert!(withheld.negotiated(), "the extension is on"); - assert_eq!(publisher.exclude(&withheld), assigned, "0 names nobody, so we do"); - - let absent = cluster::Peer::default(); - assert_eq!(publisher.exclude(&absent), assigned, "so does declaring nothing"); - - let named = cluster::Peer { - hop: Some(declared), - cost: None, + let msg = ietf::SubscribeUpdate { + request_id: RequestId(0x40), + subscription_request_id, + start_location: Location { group: 0, object: 0 }, + end_group: 0, + subscriber_priority: Some(128), + forward: Some(true), + filter: None, + authorization_token: Some(request_token()), }; - assert_eq!(publisher.exclude(&named), declared, "a declared identity wins"); + writer.varint(ietf::SubscribeUpdate::ID).await.unwrap(); + writer.encode(&msg).await.unwrap(); + log.writes.lock().unwrap().clone() } - /// A peer that negotiated the extension MUST send a HOP_PATH on every advertisement, - /// and one that declared 0 names itself 0 there. An arriving chain is not rewritten, - /// so the route carries 0; the assigned identity stays on `via` and split-horizon - /// matches it, so the peer is not advertised its own route back. - #[moq_net_sim::test] - async fn a_declared_zero_chain_is_not_advertised_back() { - let assigned = crate::Hop::new(777).unwrap(); - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let consumer = origin.consume(); - - let publisher = Publisher::new( - crate::time::Clock::sim(), - crate::lite::test_transport::SinkSession::new(Default::default()), - origin.consume(), - Control::new(None, false), - Some(assigned), - peer::PeerSetup::default(), - Version::Draft16, - ); - - // The chain as ingress stores it: the peer named itself 0, and `via` is the - // identity we assigned that session. - let mut hops = crate::Hops::new(); - hops.push(crate::Hop::UNKNOWN).unwrap(); - let _echoed = origin - .announce( - "from/peer", - crate::origin::Route::default().with_hops(hops).with_via(assigned), - ) - .unwrap(); - - let peer = cluster::Peer { - hop: Some(crate::Hop::UNKNOWN), - cost: None, - }; - let mut announced = consumer.excluding(publisher.exclude(&peer)).announced(); - announced.assert_next_wait(); - } - - /// MoQ Active Count: the OK counts exactly the NAMESPACE messages that follow it, - /// so a route this peer is never told about is not counted either. Counting one would - /// leave the peer waiting on a message that never comes. - #[moq_net_sim::test] - async fn the_ok_counts_only_what_is_advertised() { - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - - let session = SinkSession::gated_bi(kio::Producer::new(true).consume()); - let log = session.log.clone(); - let setup = peer::PeerSetup::default(); - setup.set(peer::Peer { - cluster: cluster::Peer { - hop: Some(crate::Hop::new(9).unwrap()), - cost: None, - }, - solicit: Some(true), - active_count: true, - ..Default::default() - }); - let publisher = Publisher::new( - crate::time::Clock::sim(), - session.clone(), - origin.consume(), - Control::new(None, false), - None, - setup, - Version::Draft17, - ); - - let mut clean = crate::Hops::new(); - clean.push(crate::Hop::new(778).unwrap()).unwrap(); - let _clean = origin - .announce("cam-clean", crate::origin::Route::default().with_hops(clean)) - .unwrap(); - // A chain with no room left for our own hop cannot be forwarded. - let mut full = crate::Hops::new(); - while full.push(crate::Hop::new(100 + full.len() as u64).unwrap()).is_ok() {} - let _full = origin - .announce("cam-full", crate::origin::Route::default().with_hops(full)) - .unwrap(); - settle().await; - - let stream = Stream::open(&mut session.clone(), Version::Draft17).await.unwrap(); - let msg = ietf::SubscribeNamespace { - request_id: RequestId(1), - namespace: crate::Path::new(""), - hidden: false, - }; - let mut run = std::pin::pin!(publisher.run_subscribe_namespace_stream(stream, msg)); - assert!(futures::poll!(run.as_mut()).is_pending()); - - assert_eq!(occurrences(&log, b"cam-clean"), 1); - assert_eq!(occurrences(&log, b"cam-full"), 0); - - let expected = crate::lite::test_transport::Log::default(); - let mut writer = crate::coding::Writer::new( - crate::lite::test_transport::SinkSend::new(expected.clone()), - Version::Draft17, - ); + /// The wire bytes of one REQUEST_OK answering `request_id`: keyed to it on draft-15/16, + /// unkeyed on draft-17+. + async fn request_ok_bytes(version: Version, request_id: RequestId) -> Vec { + let log = crate::lite::test_transport::Log::default(); + let mut writer = crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); writer.varint(ietf::RequestOk::ID).await.unwrap(); writer .encode(&ietf::RequestOk { - request_id: None, - active: Some(1), + request_id: matches!(version, Version::Draft15 | Version::Draft16).then_some(request_id), + active: None, }) .await .unwrap(); - let expected = expected.writes.lock().unwrap().clone(); - assert!( - log.writes.lock().unwrap().starts_with(&expected), - "the OK counts one NAMESPACE" - ); - } - - /// A same-path source can splice into (or detach from) an existing broadcast - /// without an origin-level (un)announce, silently flipping `advertisable`. - /// Namespace forwarding must follow: advertise when a clean route appears, - /// withdraw when the last one detaches. - #[moq_net_sim::test] - async fn namespace_follows_route_eligibility_changes() { - let assigned = crate::Hop::new(777).unwrap(); - let clean_publisher = crate::Hop::new(778).unwrap(); - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - - let gate = kio::Producer::new(true); - let session = SinkSession::gated_bi(gate.consume()); - let log = session.log.clone(); - let publisher = Publisher::new( - crate::time::Clock::sim(), - session.clone(), - origin.consume(), - Control::new(None, false), - Some(assigned), - requires_solicitation(), - Version::Draft16, - ); - - // The prefix starts with only a route from the assigned peer: hop 0 on the - // chain, identity on `via`. - let mut tainted_hops = crate::Hops::new(); - tainted_hops.push(crate::Hop::UNKNOWN).unwrap(); - let _tainted = origin - .announce( - "route-flip-cam", - crate::origin::Route::default() - .with_hops(tainted_hops) - .with_via(assigned), - ) - .unwrap(); - settle().await; - - let stream = Stream::open(&mut session.clone(), Version::Draft16).await.unwrap(); - let msg = ietf::SubscribeNamespace { - request_id: RequestId(1), - namespace: crate::Path::new(""), - hidden: false, - }; - let mut run = std::pin::pin!(publisher.run_subscribe_namespace_stream(stream, msg)); - - // Initial set: the tainted-only broadcast is filtered, nothing but the OK - // response on the wire. - assert!(futures::poll!(run.as_mut()).is_pending()); - assert_eq!(occurrences(&log, b"route-flip-cam"), 0); - - // A clean route joins the same prefix: the excluded cursor now has a best - // visible route, so the namespace must be advertised. - let mut clean_hops = crate::Hops::new(); - clean_hops.push(clean_publisher).unwrap(); - let clean = origin - .announce("route-flip-cam", crate::origin::Route::default().with_hops(clean_hops)) - .unwrap(); - settle().await; - assert!(futures::poll!(run.as_mut()).is_pending()); - assert_eq!( - occurrences(&log, b"route-flip-cam"), - 1, - "NAMESPACE after a clean route joins" - ); - - // The clean route retracts, leaving only the tainted one: withdrawn. - drop(clean); - settle().await; - assert!(futures::poll!(run.as_mut()).is_pending()); - assert_eq!( - occurrences(&log, b"route-flip-cam"), - 2, - "NAMESPACE_DONE after the last clean route detaches" - ); + log.writes.lock().unwrap().clone() } - /// The peer's OK to a PUBLISH_NAMESPACE, framed exactly as the announce path - /// reads it -- built with the crate's own writer so the framing can't drift - /// from the encoder under test. - /// A REQUEST_ERROR declining an advertisement, with the retry interval the peer asked - /// for in milliseconds. Zero means it does not want the namespace offered again. - async fn publish_namespace_error(version: Version, retry_interval: u64) -> Vec { + /// The REQUEST_ERROR a refused renewal writes before the subscription ends: keyed to the + /// update's Request ID on draft-15/16, unkeyed on draft-17+, matching + /// `write_subscribe_error(.., &Error::Unauthorized, reason)`. + async fn request_error_bytes(version: Version, request_id: RequestId, reason: &str) -> Vec { let log = crate::lite::test_transport::Log::default(); let mut writer = crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); - + let request_id = matches!(version, Version::Draft15 | Version::Draft16).then_some(request_id); writer.varint(ietf::RequestError::ID).await.unwrap(); writer .encode(&ietf::RequestError { - request_id: matches!(version, Version::Draft15 | Version::Draft16).then_some(RequestId(1)), - // UNINTERESTED, draft-17 section 14.5.2. - error_code: 0x20, - reason_phrase: "no".into(), - retry_interval, + request_id, + error_code: request::to_code(&Error::Unauthorized, request::Kind::Subscribe, version), + reason_phrase: reason.into(), + retry_interval: 0, }) .await .unwrap(); - log.writes.lock().unwrap().clone() } - /// A peer that refuses an advertisement with a retry interval of 0 is asking not to be - /// offered it again. Coming back anyway turns a permanent refusal (unauthorized, - /// uninterested) into a request every few seconds for the life of the session. + /// A live SUBSCRIBE for `room/video` presenting a request token, with one published + /// group so the subscription parks at the live edge rather than ending. + fn token_subscribe() -> ietf::Subscribe<'static> { + let mut msg = subscribe(Filter::NextObject, None); + msg.request_id = RequestId(REQUEST_ID); + msg.authorization_token = Some(request_token()); + msg + } + + /// A REQUEST_UPDATE whose token the acceptor renews keeps a token-authorized + /// subscription alive past the old grant's expiry: the reader re-verifies the token off + /// the subscribe stream and re-arms the deadline. Proven against the deadline the + /// original grant would otherwise have lapsed at. #[moq_net_sim::test] - async fn a_refusal_that_forbids_retrying_is_not_retried() { - const VERSION: Version = Version::Draft17; + async fn a_request_update_renews_a_token_subscription_past_the_old_expiry() { + const VERSION: Version = Version::Draft18; - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let _cam = origin.announce("lonely-cam", crate::origin::Route::default()).unwrap(); - settle().await; + let (auth, mut requests, _cred) = auth_covering_other(); + let h = serve_with_auth(VERSION, auth, subscribe_update_with_token(VERSION).await); + let rt = h.publisher.runtime.clone(); - // Every stream is answered with the same refusal, so a retry would show up as a - // second occurrence on the wire. - let refusal = publish_namespace_error(VERSION, 0).await; - let session = - crate::lite::test_transport::ScriptedSession::per_stream(vec![refusal.clone(), refusal.clone(), refusal]); - let log = session.log.clone(); + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; - let publisher = Publisher::new( - crate::time::Clock::sim(), - session, - origin.consume(), - Control::new(None, false), - None, - declared(Some(false)), - VERSION, - ); + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + + let answered = std::sync::Arc::new(AtomicU64::new(0)); + let acceptor = { + let answered = answered.clone(); + async move { + let mut held = Vec::new(); + // The first grant lapses in 60s; the renewal never expires. + let first = requests.next().await.unwrap(); + held.push(first.accept(grant_all_expiring(&rt, Some(60)))); + answered.fetch_add(1, Ordering::Relaxed); + let renewal = requests.next().await.unwrap(); + held.push(renewal.accept(grant_all_expiring(&rt, None))); + answered.fetch_add(1, Ordering::Relaxed); + std::future::pending::<()>().await + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); - let mut run = std::pin::pin!(publisher.run_publish_namespaces()); - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"lonely-cam") > 0 { + // Drive until the acceptor has answered the initial token and the renewal. + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(serving.as_mut()).is_pending(), + "subscription ended during setup" + ); + if answered.load(Ordering::Relaxed) >= 2 { break; } settle().await; } - assert_eq!(occurrences(&log, b"lonely-cam"), 1, "the advertisement never went out"); + assert_eq!( + answered.load(Ordering::Relaxed), + 2, + "acceptor never answered both tokens" + ); - // Well past every retry the loop would otherwise take. - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - tick().await; + // Let the serve loop apply the renewal it read off the stream. + for _ in 0..20 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(serving.as_mut()).is_pending()); + settle().await; } - assert_eq!( - occurrences(&log, b"lonely-cam"), - 1, - "re-offered a namespace the peer asked not to be offered again" - ); + // Past the original 60s expiry: the renewal re-armed the deadline, so the + // subscription lives on. + moq_net_sim::advance(Duration::from_secs(120)).await; + for _ in 0..50 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(serving.as_mut()).is_pending(), + "the renewal did not extend the subscription past the old expiry" + ); + settle().await; + } } - async fn publish_namespace_ok(version: Version) -> Vec { - let log = crate::lite::test_transport::Log::default(); - let mut writer = crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); - - match version { - Version::Draft14 => { - writer.varint(ietf::PublishNamespaceOk::ID).await.unwrap(); - writer - .encode(&ietf::PublishNamespaceOk { - request_id: RequestId(1), - }) - .await - .unwrap(); - } - Version::Draft15 | Version::Draft16 => { - writer.varint(ietf::RequestOk::ID).await.unwrap(); - writer - .encode(&ietf::RequestOk { - request_id: Some(RequestId(1)), - active: None, - }) - .await - .unwrap(); - } - // Draft-17+ dropped the request id: the response rides the request's stream. - _ => { - writer.varint(ietf::RequestOk::ID).await.unwrap(); - writer - .encode(&ietf::RequestOk { - request_id: None, - active: None, - }) - .await - .unwrap(); - } + /// The pre-draft-17 renewal: a SUBSCRIBE_UPDATE carrying a fresh token renews a + /// token-authorized subscription past the old grant's expiry at draft-14/15/16, where the + /// update rides the control-stream adapter. Draft-14 writes no answer (it defines none, and + /// its control stream would read a 0x05 as ending the subscription); draft-15 and draft-16 + /// (section 9.11) write exactly one REQUEST_OK, keyed to the update's own Request ID so the + /// peer matches it to the REQUEST_UPDATE it sent. The adapter's follow-up routing of the + /// update to the subscription's stream is proven by + /// `super::super::adapter::tests::test_classify_subscribe_update_followup`. + #[moq_net_sim::test] + async fn a_subscribe_update_renewal_is_answered_from_draft_15() { + for version in [Version::Draft14, Version::Draft15, Version::Draft16] { + renews(version, true).await; } - - let writes = log.writes.lock().unwrap(); - writes.clone() } - /// Draft-14/15 predate the NAMESPACE message, so a SUBSCRIBE_NAMESPACE is - /// answered with one PUBLISH_NAMESPACE request per matching namespace over the - /// control stream, and PUBLISH_NAMESPACE_DONE withdraws it. The state is local - /// to the subscription's task, mirroring lite's announce handling. + /// A refused renewal ends only its subscription at once (PUBLISH_DONE UPDATE_FAILED), never + /// the session. The update is answered first: nothing on draft-14, a keyed REQUEST_ERROR to + /// the update's Request ID on draft-15/16. #[moq_net_sim::test] - async fn v14_subscribe_namespace_is_answered_with_publish_namespace() { - const VERSION: Version = Version::Draft14; + async fn a_refused_subscribe_update_renewal_ends_the_subscription_from_draft_14() { + for version in [Version::Draft14, Version::Draft15, Version::Draft16] { + renews(version, false).await; + } + } - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let consumer = origin.consume(); + async fn renews(version: Version, accept: bool) { + let (auth, mut requests, _cred) = auth_covering_other(); + let h = serve_with_auth(version, auth, subscribe_update_with_token(version).await); + let rt = h.publisher.runtime.clone(); - // Announced before the peer subscribes: it must only hit the wire after. - let early = origin.announce("early-cam", crate::origin::Route::default()).unwrap(); + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); settle().await; - // Stream 1 is the peer's SUBSCRIBE_NAMESPACE (the peer stays quiet after); - // streams 2 and 3 answer our two PUBLISH_NAMESPACE requests. - let ok = publish_namespace_ok(VERSION).await; - let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![Vec::new(), ok.clone(), ok]); - let log = session.log.clone(); - - let publisher = Publisher::new( - crate::time::Clock::sim(), - session.clone(), - consumer, - Control::new(None, false), - None, - requires_solicitation(), - VERSION, - ); + let stream = Stream::open(&mut h.session.clone(), version).await.unwrap(); - let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); - let msg = ietf::SubscribeNamespace { - request_id: RequestId(1), - namespace: crate::Path::new(""), - hidden: false, + // The renewal is held until the setup writes have settled, so anything written after + // it is released is the renewal's answer. + let release = std::sync::Arc::new(Release::default()); + let answered = std::sync::Arc::new(AtomicU64::new(0)); + let acceptor = { + let answered = answered.clone(); + let release = release.clone(); + async move { + let mut held = Vec::new(); + let first = requests.next().await.unwrap(); + held.push(first.accept(grant_all_expiring(&rt, Some(60)))); + answered.fetch_add(1, Ordering::Relaxed); + let renewal = requests.next().await.unwrap(); + release.notified().await; + if accept { + held.push(renewal.accept(grant_all_expiring(&rt, None))); + } else { + renewal.reject(crate::SessionError::Unauthorized, "no"); + } + answered.fetch_add(1, Ordering::Relaxed); + std::future::pending::<()>().await + } }; - let mut run = std::pin::pin!(publisher.run_subscribe_namespace_stream(stream, msg)); + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); - // The subscription solicits the already-announced namespace. - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"early-cam") >= 1 { - break; - } + for _ in 0..200 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(serving.as_mut()).is_pending(), + "{version:?}: ended during setup" + ); settle().await; } assert_eq!( - occurrences(&log, b"early-cam"), + answered.load(Ordering::Relaxed), 1, - "PUBLISH_NAMESPACE after subscribing" + "{version:?}: the first token was answered" ); + let before = h.log.writes.lock().unwrap().len(); - // A later announce reaches the same subscription. - let _late = origin.announce("late-cam", crate::origin::Route::default()).unwrap(); - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"late-cam") >= 1 { + release.notify_one(); + + if accept { + for _ in 0..200 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(serving.as_mut()).is_pending(), + "{version:?}: an accepted renewal keeps serving" + ); + settle().await; + } + assert_eq!( + answered.load(Ordering::Relaxed), + 2, + "{version:?}: the renewal was answered" + ); + // Draft-14 writes nothing; draft-15/16 write exactly one keyed REQUEST_OK to the + // update's own Request ID (0x40), the id the renewing peer waits on. + let written = h.log.writes.lock().unwrap()[before..].to_vec(); + match version { + Version::Draft14 => assert!(written.is_empty(), "{version:?}: a renewal writes no answer"), + _ => assert_eq!( + written, + request_ok_bytes(version, RequestId(0x40)).await, + "{version:?}: one keyed REQUEST_OK to the update's request id" + ), + } + // The re-armed deadline keeps the subscription past the old 60s expiry. + moq_net_sim::advance(Duration::from_secs(120)).await; + let mut ended = false; + for _ in 0..50 { + let _ = futures::poll!(acceptor.as_mut()); + if futures::poll!(serving.as_mut()).is_ready() { + ended = true; + break; + } + settle().await; + } + assert!(!ended, "{version:?}: an accepted renewal keeps the subscription"); + } else { + // Per drafts 16 section 9.11.1 / 18 section 10.9.1, a refused renewal ends only + // this subscription, promptly and without the old 60s grant having to lapse, with + // PUBLISH_DONE UPDATE_FAILED. The update is answered first: nothing on draft-14, + // exactly one keyed REQUEST_ERROR to the update's Request ID (0x40) on draft-15/16. + // The session stays up. + let mut result = None; + for _ in 0..200 { + let _ = futures::poll!(acceptor.as_mut()); + if let Poll::Ready(res) = futures::poll!(serving.as_mut()) { + result = Some(res); + break; + } + settle().await; + } + assert_eq!( + answered.load(Ordering::Relaxed), + 2, + "{version:?}: the renewal was answered" + ); + assert!( + matches!(result, Some(Err(Error::Unauthorized))), + "{version:?}: a refused renewal ends the subscription at once, got {result:?}" + ); + let (status, _) = Wire::read(&h.log, version).publish_done.expect("PUBLISH_DONE was sent"); + assert_eq!( + status, + ietf::PublishDoneStatus::UpdateFailed.code(version), + "{version:?}: UPDATE_FAILED" + ); + let written = h.log.writes.lock().unwrap()[before..].to_vec(); + match version { + // draft-14 has no renewal reply: the refusal is signalled only by the subscription + // ending (SUBSCRIBE_DONE UPDATE_FAILED, written by the caller), with no REQUEST_ERROR. + Version::Draft14 => {} + // draft-15/16 answer with one keyed REQUEST_ERROR before the subscription ends. + _ => { + let error = request_error_bytes(version, RequestId(0x40), "renewal not granted").await; + assert!( + written.starts_with(&error), + "{version:?}: a keyed REQUEST_ERROR is written before the subscription ends" + ); + } + } + assert!(h.log.closes().is_empty(), "{version:?}: the session must stay up"); + } + } + + /// One REQUEST_UPDATE carrying a fresh token, keyed to its own Request ID so several can be + /// told apart on the wire. + async fn subscribe_update_token_rid(version: Version, rid: u64) -> Vec { + let log = crate::lite::test_transport::Log::default(); + let mut writer = crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); + let subscription_request_id = match version { + Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(RequestId(REQUEST_ID)), + _ => None, + }; + let msg = ietf::SubscribeUpdate { + request_id: RequestId(rid), + subscription_request_id, + start_location: Location { group: 0, object: 0 }, + end_group: 0, + subscriber_priority: Some(128), + forward: Some(true), + filter: None, + authorization_token: Some(request_token()), + }; + writer.varint(ietf::SubscribeUpdate::ID).await.unwrap(); + writer.encode(&msg).await.unwrap(); + log.writes.lock().unwrap().clone() + } + + /// One token-less REQUEST_UPDATE (a priority or forward change) keyed to `rid`, framed as the + /// peer sends it. It carries no AUTHORIZATION TOKEN, so it starts no verify and is answered at + /// once rather than held. + async fn subscribe_update_bare_rid(version: Version, rid: u64) -> Vec { + let log = crate::lite::test_transport::Log::default(); + let mut writer = crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); + let subscription_request_id = match version { + Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(RequestId(REQUEST_ID)), + _ => None, + }; + let msg = ietf::SubscribeUpdate { + request_id: RequestId(rid), + subscription_request_id, + start_location: Location { group: 0, object: 0 }, + end_group: 0, + subscriber_priority: Some(128), + forward: Some(true), + filter: None, + authorization_token: None, + }; + writer.varint(ietf::SubscribeUpdate::ID).await.unwrap(); + writer.encode(&msg).await.unwrap(); + log.writes.lock().unwrap().clone() + } + + /// Two token renewals that arrive while a first renewal's verdict is still pending are each + /// verified and answered, not collapsed. The loop keeps per-update state while a verify is + /// pending (draft-18 section 10.9.1 answers each update); the single slot it replaced + /// dropped the middle renewal (0x41) entirely, so its token was never verified. + #[moq_net_sim::test] + async fn two_renewals_buffered_behind_a_pending_verdict_each_get_a_verdict() { + const VERSION: Version = Version::Draft18; + + let (auth, mut requests, _cred) = auth_covering_other(); + let mut script = subscribe_update_token_rid(VERSION, 0x40).await; + script.extend(subscribe_update_token_rid(VERSION, 0x41).await); + script.extend(subscribe_update_token_rid(VERSION, 0x42).await); + let h = serve_with_auth(VERSION, auth, script); + let rt = h.publisher.runtime.clone(); + + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + + // Hold the first renewal (0x40) so the next two (0x41, 0x42) are read and buffered while + // its verdict is pending: the exact window the single slot used to collapse. + let release = std::sync::Arc::new(Release::default()); + let answered = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); + let acceptor = { + let answered = answered.clone(); + let release = release.clone(); + async move { + let first = requests.next().await.unwrap(); + let mut held = vec![first.accept(grant_all_expiring(&rt, None))]; + answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let a = requests.next().await.unwrap(); + release.notified().await; + held.push(a.accept(grant_all_expiring(&rt, None))); + answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + // 0x41 then 0x42: reached only if neither was dropped from the buffer. + loop { + let renewal = requests.next().await.unwrap(); + held.push(renewal.accept(grant_all_expiring(&rt, None))); + answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + + // Let 0x41 and 0x42 buffer behind the held 0x40. + for _ in 0..200 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(serving.as_mut()).is_pending(), "ended during setup"); + settle().await; + } + release.notify_one(); + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(serving.as_mut()).is_pending()); + if answered.load(std::sync::atomic::Ordering::Relaxed) >= 4 { break; } settle().await; } assert_eq!( - occurrences(&log, b"late-cam"), - 1, - "PUBLISH_NAMESPACE for a live announce" + answered.load(std::sync::atomic::Ordering::Relaxed), + 4, + "the initial token and all three renewals must each be verified; the single slot dropped 0x41" ); + } - // An unannounce closes out its own request with PUBLISH_NAMESPACE_DONE. - drop(early); - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"early-cam") >= 2 { + /// Draft-19 advertises MAX_REQUEST_UPDATES, so a peer that leaves more outstanding than that + /// broke the negotiated limit: draft-19 section 10.3.1.7 closes the session with + /// TOO_MANY_REQUEST_UPDATES. + #[moq_net_sim::test] + async fn renewals_past_the_advertised_limit_close_the_session() { + const VERSION: Version = Version::Draft19; + + let (auth, mut requests, _cred) = auth_covering_other(); + // Exactly one past the limit: the held renewal plus MAX_REQUEST_UPDATES queued behind it + // is the (limit + 1)th outstanding, the one that must close the session. + let last = 0x40 + request_update::MAX_REQUEST_UPDATES; + let mut script = Vec::new(); + for rid in 0x40..=last { + script.extend(subscribe_update_token_rid(VERSION, rid).await); + } + let h = serve_with_auth(VERSION, auth, script); + let rt = h.publisher.runtime.clone(); + + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + + // Accept the initial token, then never answer a renewal, so the buffer only grows. + let acceptor = async move { + let first = requests.next().await.unwrap(); + let _held = first.accept(grant_all_expiring(&rt, None)); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + + let mut ended = false; + for _ in 0..2000 { + let _ = futures::poll!(acceptor.as_mut()); + if futures::poll!(serving.as_mut()).is_ready() { + ended = true; break; } settle().await; } - assert_eq!( - occurrences(&log, b"early-cam"), - 2, - "PUBLISH_NAMESPACE_DONE on unannounce" + assert!(ended, "flooding past the advertised limit must end the request"); + assert!( + h.log + .closes() + .iter() + .any(|close| close.0 == crate::SessionError::TooManyRequestUpdates.to_code()), + "the flood must close the session with TOO_MANY_REQUEST_UPDATES: {:?}", + h.log.closes() ); - - // One stream for the subscription itself, one per PUBLISH_NAMESPACE: the - // withdrawal rode the announce's own request, not a new stream. - assert_eq!(log.bi_opens(), 3, "no extra stream for the withdrawal"); } - /// A peer that declared nothing is told without being asked. Relays that never send - /// SUBSCRIBE_NAMESPACE hear nothing otherwise, and every third-party one behaves - /// that way: a publisher is expected to announce itself. + /// Exactly MAX_REQUEST_UPDATES outstanding (the one being verified plus the queue) is within + /// the advertised limit, so a draft-19 peer holding that many is not faulted: the request + /// keeps running and the session stays up. #[moq_net_sim::test] - async fn a_peer_that_declared_nothing_is_told_unsolicited() { - const VERSION: Version = Version::Draft17; + async fn renewals_at_the_advertised_limit_keep_the_request() { + const VERSION: Version = Version::Draft19; - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let _local = origin.announce("local-cam", crate::origin::Route::default()).unwrap(); - settle().await; + let (auth, mut requests, _cred) = auth_covering_other(); + // The held renewal plus MAX_REQUEST_UPDATES - 1 queued behind it is exactly the limit. + let last = 0x40 + request_update::MAX_REQUEST_UPDATES - 1; + let mut script = Vec::new(); + for rid in 0x40..=last { + script.extend(subscribe_update_token_rid(VERSION, rid).await); + } + let h = serve_with_auth(VERSION, auth, script); + let rt = h.publisher.runtime.clone(); - // The only stream is the PUBLISH_NAMESPACE request we open ourselves. - let session = - crate::lite::test_transport::ScriptedSession::per_stream(vec![publish_namespace_ok(VERSION).await]); - let log = session.log.clone(); + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; - let peer_setup = peer::PeerSetup::default(); - peer_setup.set(peer::Peer::default()); + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); - let publisher = Publisher::new( - crate::time::Clock::sim(), - session, - origin.consume(), - Control::new(None, false), - None, - peer_setup, - VERSION, - ); + let acceptor = async move { + let first = requests.next().await.unwrap(); + let _held = first.accept(grant_all_expiring(&rt, None)); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); - let mut run = std::pin::pin!(publisher.run_publish_namespaces()); - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"local-cam") >= 1 { - break; - } + for _ in 0..2000 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(serving.as_mut()).is_pending(), + "the request must stay up at exactly the advertised limit" + ); settle().await; } - - assert_eq!( - occurrences(&log, b"local-cam"), - 1, - "PUBLISH_NAMESPACE without a SUBSCRIBE_NAMESPACE" + assert!( + h.log.closes().is_empty(), + "no session close at the limit: {:?}", + h.log.closes() ); - assert_eq!(log.bi_opens(), 1, "one request stream"); } - /// ACTIVE_COUNT only answers SUBSCRIBE_NAMESPACE (MoQ Active Count), so one on the OK - /// to a PUBLISH_NAMESPACE is the peer breaking the extension, negotiated or not. + /// Drafts below 19 carry no MAX_REQUEST_UPDATES option, so a peer there agreed to no ceiling: + /// the same flood that closes a draft-19 session must neither end the request nor close the + /// session on draft-18, because a conforming peer must not be stranded for a limit it never saw. #[moq_net_sim::test] - async fn a_counted_publish_namespace_ok_is_a_violation() { - const VERSION: Version = Version::Draft17; + async fn older_drafts_do_not_strand_a_renewal_flood() { + const VERSION: Version = Version::Draft18; - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let _local = origin.announce("local-cam", crate::origin::Route::default()).unwrap(); + let (auth, mut requests, _cred) = auth_covering_other(); + let mut script = Vec::new(); + for rid in 0x40..=0x60u64 { + script.extend(subscribe_update_token_rid(VERSION, rid).await); + } + let h = serve_with_auth(VERSION, auth, script); + let rt = h.publisher.runtime.clone(); + + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); settle().await; - let ok = crate::lite::test_transport::Log::default(); - let mut writer = crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(ok.clone()), VERSION); - writer.varint(ietf::RequestOk::ID).await.unwrap(); - writer - .encode(&ietf::RequestOk { - request_id: None, - active: Some(0), - }) - .await - .unwrap(); - let ok = ok.writes.lock().unwrap().clone(); - let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![ok]); - let log = session.log.clone(); - - let peer_setup = peer::PeerSetup::default(); - peer_setup.set(peer::Peer { - active_count: true, - ..Default::default() - }); - let publisher = Publisher::new( - crate::time::Clock::sim(), - session, - origin.consume(), - Control::new(None, false), - None, - peer_setup, - VERSION, - ); + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); - let res = moq_net_sim::timeout(Duration::from_secs(5), publisher.run_publish_namespaces()) - .await - .expect("the violation ends the loop"); - assert!(matches!(res, Err(Error::ProtocolViolation)), "{res:?}"); - // Closed at the source, since the solicited path's request task only logs errors. - let code = crate::SessionError::ProtocolViolation.to_code(); - assert!(log.closes().iter().any(|(c, _)| *c == code), "{:?}", log.closes()); - } + let acceptor = async move { + let first = requests.next().await.unwrap(); + let _held = first.accept(grant_all_expiring(&rt, None)); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); - /// Drive both announce loops at once against a peer that declared `solicit`, - /// returning how many times the namespace hit the wire and how many bidi streams - /// were opened. One stream means the entry rode the subscription inline; two means - /// it went out as its own PUBLISH_NAMESPACE request. - async fn advertise_both_ways(solicit: Option) -> (usize, usize) { - let log = advertise_with_hidden(Discovery { - solicit, - ..Discovery::default() - }) - .await; - (occurrences(&log, b"cam"), log.bi_opens()) + for _ in 0..2000 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(serving.as_mut()).is_pending(), + "draft-18 negotiates no limit, so the flood must not strand the request" + ); + settle().await; + } + assert!(h.log.closes().is_empty(), "draft-18 flood must not close the session"); } - /// [`advertise_both_ways`] with a hidden `.stats/node` beside `cam`, and the peer's - /// SUBSCRIBE_NAMESPACE for `prefix` opting in to hidden namespaces or not. - struct Discovery<'a> { - version: Version, - declared: bool, - solicit: Option, - prefix: &'a str, - hidden: bool, - scoped: bool, - } + /// Every REQUEST_UPDATE owes one answer, token or not (moq-transport section 10.9), and that + /// answer restores one MAX_REQUEST_UPDATES credit. A peer that only sends token-less priority + /// updates must keep its credit so it can still renew, so each gets a REQUEST_OK and the + /// session stays up well past the advertised limit. + #[moq_net_sim::test] + async fn token_less_updates_are_each_acknowledged() { + const VERSION: Version = Version::Draft19; - impl Default for Discovery<'_> { - fn default() -> Self { - Self { - version: Version::Draft17, - declared: true, - solicit: Some(true), - prefix: "", - hidden: false, - scoped: false, - } + let (auth, mut requests, _cred) = auth_covering_other(); + // More than the advertised limit, none carrying a token: none starts a verify, so none is + // ever outstanding. + let count = request_update::MAX_REQUEST_UPDATES + 4; + let mut script = Vec::new(); + for rid in 0x40..0x40 + count { + script.extend(subscribe_update_bare_rid(VERSION, rid).await); } - } + let h = serve_with_auth(VERSION, auth, script); + let rt = h.publisher.runtime.clone(); - async fn advertise_with_hidden(case: Discovery<'_>) -> crate::lite::test_transport::Log { - let Discovery { - version, - declared: hidden_declared, - solicit, - prefix, - hidden, - scoped, - } = case; - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); - let _stats = origin.announce(".stats/node", crate::origin::Route::default()).unwrap(); + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); settle().await; - // Stream 1 is the peer's SUBSCRIBE_NAMESPACE; stream 2, if opened at all, is our - // PUBLISH_NAMESPACE request. - let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![ - Vec::new(), - publish_namespace_ok(version).await, - ]); - let log = session.log.clone(); - - let setup = peer::PeerSetup::default(); - setup.set(peer::Peer { - solicit, - hidden: hidden_declared, - ..Default::default() - }); - let consume = match scoped { - true => origin - .consume() - .scope("", &crate::Pattern::subtree(".stats").unwrap().into()) - .unwrap(), - false => origin.consume(), - }; - let publisher = Publisher::new( - crate::time::Clock::sim(), - session.clone(), - consume, - Control::new(None, false), - None, - setup, - version, - ); + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); - let stream = Stream::open(&mut session.clone(), version).await.unwrap(); - let msg = ietf::SubscribeNamespace { - request_id: RequestId(1), - namespace: crate::Path::new(prefix), - hidden, + let acceptor = async move { + let first = requests.next().await.unwrap(); + let _held = first.accept(grant_all_expiring(&rt, None)); + std::future::pending::<()>().await }; - let mut solicited = std::pin::pin!(publisher.clone().run_subscribe_namespace_stream(stream, msg)); - let mut unsolicited = std::pin::pin!(publisher.run_publish_namespaces()); + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); - // Poll well past the first advertisement, so a second one from the other loop - // would show up rather than being missed by an early break. The unsolicited loop - // finishes immediately when the peer requires solicitation, and a completed - // future must not be polled again. - let mut quiet = false; - for _ in 0..100 { - assert!(futures::poll!(solicited.as_mut()).is_pending()); - if !quiet { - quiet = futures::poll!(unsolicited.as_mut()).is_ready(); - } + for _ in 0..2000 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(serving.as_mut()).is_pending(), + "token-less updates must not strand the request" + ); settle().await; } - log + // Draft-19 answers each with a REQUEST_OK, request id omitted. + let one_ok = { + let log = crate::lite::test_transport::Log::default(); + let mut writer = + crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), VERSION); + writer.varint(ietf::RequestOk::ID).await.unwrap(); + writer + .encode(&ietf::RequestOk { + request_id: None, + active: None, + }) + .await + .unwrap(); + log.writes.lock().unwrap().clone() + }; + let written = h.log.writes.lock().unwrap().clone(); + let acks = written + .windows(one_ok.len()) + .filter(|w| *w == one_ok.as_slice()) + .count(); + assert_eq!( + acks, count as usize, + "each token-less update must get exactly one REQUEST_OK" + ); + assert!( + h.log.closes().is_empty(), + "token-less updates must not close the session" + ); } - /// A hidden namespace reaches only a subscription that opted in or named its dot - /// segment. On draft-16 and later the subscription also repeats every visible - /// namespace the unsolicited loop already sent. A NAMESPACE is discovery, not - /// a second route. + /// An update that arrives while a renewal's verdict is pending is answered after the + /// renewal, never before it: from draft 17 an answer names no Request ID, so its order is + /// all that ties it to its update. The renewal is refused, so its REQUEST_ERROR is the first + /// answer either way, and the token-less update behind it is never answered ahead of it. #[moq_net_sim::test] - async fn hidden_namespaces_need_an_opt_in() { - for (solicit, prefix, hidden, cam, stats) in [ - (Some(false), "", false, 2, 0), - (Some(false), "", true, 2, 1), - (Some(false), ".stats", false, 1, 1), - (Some(true), "", false, 1, 0), - (Some(true), "", true, 1, 1), - (Some(true), ".stats", false, 0, 1), - ] { - let log = advertise_with_hidden(Discovery { - solicit, - prefix, - hidden, - ..Discovery::default() - }) - .await; - let case = format!("solicit {solicit:?}, prefix {prefix:?}, hidden {hidden}"); - assert_eq!(occurrences(&log, b"cam"), cam, "{case}"); - // An inline entry names its suffix, so count the leaf. - assert_eq!(occurrences(&log, b"node"), stats, "{case}"); + async fn an_update_behind_a_pending_renewal_is_answered_in_order() { + for version in [Version::Draft16, Version::Draft18] { + let (auth, mut requests, _cred) = auth_covering_other(); + let mut script = subscribe_update_token_rid(version, 0x40).await; + script.extend(subscribe_update_bare_rid(version, 0x41).await); + let h = serve_with_auth(version, auth, script); + let rt = h.publisher.runtime.clone(); + + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), version).await.unwrap(); + let release = Release::default(); + let acceptor = { + let release = release.clone(); + async move { + let first = requests.next().await.unwrap(); + let _held = first.accept(grant_all_expiring(&rt, None)); + let renewal = requests.next().await.unwrap(); + release.notified().await; + renewal.reject(crate::SessionError::Unauthorized, "no"); + std::future::pending::<()>().await + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + + // Both updates are read while the renewal is held. + for _ in 0..200 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(serving.as_mut()).is_pending(), "{version}: ended early"); + settle().await; + } + let ok = request_ok_bytes(version, RequestId(0x41)).await; + let held = h.log.writes.lock().unwrap().clone(); + assert!( + !held.windows(ok.len()).any(|window| window == ok.as_slice()), + "{version}: the update behind the pending renewal was answered first" + ); + let before = held.len(); + + release.notify_one(); + for _ in 0..200 { + let _ = futures::poll!(acceptor.as_mut()); + if futures::poll!(serving.as_mut()).is_ready() { + break; + } + settle().await; + } + let written = h.log.writes.lock().unwrap()[before..].to_vec(); + let error = request_error_bytes(version, RequestId(0x40), "renewal not granted").await; + assert!( + written.starts_with(&error), + "{version}: the renewal's REQUEST_ERROR is the first answer" + ); } } + /// Before draft-17 a token-less REQUEST_UPDATE is answered keyed to its own request id + /// (draft-15/16), or not at all (draft-14, which defines no response). This pins the keyed and + /// silent branches of the acknowledgement so a wrong key cannot misroute the peer's answer. #[moq_net_sim::test] - async fn hidden_discovery_obeys_peer_setup_and_requested_prefix() { - for version in [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft19, - Version::Draft22, - ] { - for declared in [false, true] { - for solicit in [Some(false), Some(true)] { - for scoped in [false, true] { - for (prefix, hidden) in [("", false), ("", true), (".stats", false)] { - let log = advertise_with_hidden(Discovery { - version, - declared, - solicit, - prefix, - hidden, - scoped, - }) - .await; - // Visible on the subscription: not filtered, opted in, or the prefix - // names the dot segment. The unsolicited loop sends a dot namespace - // only when the peer did not declare MoQ Hidden. Draft-14/15 keep - // the two paths disjoint; draft-16 and later fill the stream too. - let on_stream = !declared || hidden || prefix == ".stats"; - let unsolicited = solicit != Some(true) && !declared; - let legacy = matches!(version, Version::Draft14 | Version::Draft15); - let inline = if legacy { - (solicit == Some(true) && on_stream) - || (solicit != Some(true) && declared && (hidden || prefix == ".stats")) - } else { - on_stream - }; - let expected = usize::from(inline) + usize::from(unsolicited); - assert_eq!( - occurrences(&log, b"node"), - expected, - "{version:?}: declared={declared}, solicit={solicit:?}, scoped={scoped}, prefix={prefix}, hidden={hidden}" - ); - } - } + async fn token_less_updates_are_keyed_before_draft_17() { + for version in [Version::Draft14, Version::Draft15, Version::Draft16] { + let (auth, mut requests, _cred) = auth_covering_other(); + let rids = [0x41u64, 0x42, 0x43]; + let mut script = Vec::new(); + for rid in rids { + script.extend(subscribe_update_bare_rid(version, rid).await); + } + let h = serve_with_auth(version, auth, script); + let rt = h.publisher.runtime.clone(); + + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), version).await.unwrap(); + let acceptor = async move { + let first = requests.next().await.unwrap(); + let _held = first.accept(grant_all_expiring(&rt, None)); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(serving.as_mut()).is_pending(), + "{version}: token-less updates must not end the request" + ); + settle().await; + } + + // Draft-14 answers nothing; draft-15/16 answer each update keyed to its own request id. + let written = h.log.writes.lock().unwrap().clone(); + if !matches!(version, Version::Draft14) { + for rid in rids { + let one = request_ok_bytes(version, RequestId(rid)).await; + let n = written.windows(one.len()).filter(|w| *w == one.as_slice()).count(); + assert_eq!(n, 1, "{version}: update {rid:#x} gets exactly one keyed REQUEST_OK"); } } + assert!( + h.log.closes().is_empty(), + "{version}: token-less updates must not close the session" + ); } } - /// On draft-16 and later a peer that did not ask to be solicited hears each - /// namespace twice: an unsolicited PUBLISH_NAMESPACE and a NAMESPACE on the - /// SUBSCRIBE_NAMESPACE stream. Those are two discoveries of one namespace, not - /// two routes. A peer that asked to be solicited hears it only on the stream. - /// Draft-14 and 15 still answer a non-solicit peer only with PUBLISH_NAMESPACE. + /// On drafts without the option the guard is a memory backstop, not a protocol limit: a flood + /// past it ends the one request through PUBLISH_DONE, never the session. #[moq_net_sim::test] - async fn a_non_solicit_peer_hears_a_namespace_both_ways() { - let (times, streams) = advertise_both_ways(Some(false)).await; - assert_eq!(times, 2, "PUBLISH_NAMESPACE and NAMESPACE"); - assert_eq!(streams, 2, "the subscription plus one PUBLISH_NAMESPACE request"); + async fn an_older_draft_flood_past_the_guard_ends_the_request() { + const VERSION: Version = Version::Draft18; - let (once, streams) = advertise_both_ways(Some(true)).await; - assert_eq!(once, 1, "a peer that asked to be told on request is told once"); - assert_eq!(streams, 1, "inline on the SUBSCRIBE_NAMESPACE stream it asked on"); + let (auth, mut requests, _cred) = auth_covering_other(); + let last = 0x40 + request_update::UNNEGOTIATED_GUARD as u64 + 1; + let mut script = Vec::new(); + for rid in 0x40..=last { + script.extend(subscribe_update_token_rid(VERSION, rid).await); + } + let h = serve_with_auth(VERSION, auth, script); + let rt = h.publisher.runtime.clone(); - for version in [Version::Draft14, Version::Draft15] { - let log = advertise_with_hidden(Discovery { - version, - solicit: Some(false), - ..Discovery::default() - }) - .await; - assert_eq!( - occurrences(&log, b"cam"), - 1, - "{version:?} still answers only with PUBLISH_NAMESPACE" - ); + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + + let acceptor = async move { + let first = requests.next().await.unwrap(); + let _held = first.accept(grant_all_expiring(&rt, None)); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + + let mut ended = false; + for _ in 0..4000 { + let _ = futures::poll!(acceptor.as_mut()); + if futures::poll!(serving.as_mut()).is_ready() { + ended = true; + break; + } + settle().await; } + assert!(ended, "a flood past the guard must end the request"); + assert!(h.log.closes().is_empty(), "the guard ends the request, not the session"); } - /// A peer that did not send SOLICIT still gets NAMESPACE on its SUBSCRIBE_NAMESPACE - /// stream on draft-16 and later: one for a match that already exists, one announced - /// after, then NAMESPACE_DONE when that announcement ends. + /// A peer that ends the subscription while a renewal is still being verified ends it here + /// too: with a grant that never expires and an acceptor that never answers the renewal, + /// only the stream closing can end the request, and it must. #[moq_net_sim::test] - async fn a_non_solicit_subscribe_namespace_carries_namespace() { - for version in [Version::Draft16, Version::Draft18] { - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let early = origin.announce("early-cam", crate::origin::Route::default()).unwrap(); - settle().await; + async fn a_closed_subscription_ends_while_a_renewal_is_pending() { + const VERSION: Version = Version::Draft18; - let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![Vec::new()]); - let log = session.log.clone(); + let (auth, mut requests, _cred) = auth_covering_other(); + let session = ScriptedSession::per_stream_eof(vec![subscribe_update_with_token(VERSION).await]); + let h = serve_on(VERSION, auth, session); + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; - let peer_setup = peer::PeerSetup::default(); - peer_setup.set(peer::Peer::default()); + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + let popped = std::sync::Arc::new(AtomicU64::new(0)); + let acceptor = { + let popped = popped.clone(); + async move { + let first = requests.next().await.expect("a request"); + let _issued = first.accept(crate::auth::Grant::all()); + popped.fetch_add(1, Ordering::Relaxed); + let _never_answered = requests.next().await.expect("a renewal"); + popped.fetch_add(1, Ordering::Relaxed); + std::future::pending::<()>().await + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + let mut ended = false; + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + if futures::poll!(serving.as_mut()).is_ready() { + ended = true; + break; + } + settle().await; + } + assert!( + popped.load(Ordering::Relaxed) >= 1, + "the subscription was token-authorized" + ); + assert!( + ended, + "closing the stream ends the subscription despite the pending renewal" + ); + } - let publisher = Publisher::new( - crate::time::Clock::sim(), - session.clone(), - origin.consume(), - Control::new(None, false), - None, - peer_setup, - version, - ); + /// One UNSUBSCRIBE keyed to the subscription, the draft-14/15/16 cancellation message. + async fn unsubscribe_bytes(version: Version) -> Vec { + let log = crate::lite::test_transport::Log::default(); + let mut writer = crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); + writer.varint(ietf::Unsubscribe::ID).await.unwrap(); + writer + .encode(&ietf::Unsubscribe { + request_id: RequestId(REQUEST_ID), + }) + .await + .unwrap(); + log.writes.lock().unwrap().clone() + } - let stream = Stream::open(&mut session.clone(), version).await.unwrap(); - let msg = ietf::SubscribeNamespace { - request_id: RequestId(1), - namespace: crate::Path::new(""), - hidden: false, - }; - let mut run = std::pin::pin!(publisher.run_subscribe_namespace_stream(stream, msg)); + /// Before draft-17 a cancellation is an UNSUBSCRIBE message, not a FIN. It must end the + /// request even while a renewal verdict is pending: with a non-expiring grant and an + /// acceptor that never answers the renewal, the UNSUBSCRIBE is the only thing that can. + #[moq_net_sim::test] + async fn a_pending_renewal_ends_on_an_unsubscribe_before_draft_17() { + for version in [Version::Draft14, Version::Draft15, Version::Draft16] { + let (auth, mut requests, _cred) = auth_covering_other(); + // The stream carries the renewal then the UNSUBSCRIBE and never FINs, so only the + // message can end the request. + let script = [ + subscribe_update_with_token(version).await, + unsubscribe_bytes(version).await, + ] + .concat(); + let h = serve_with_auth(version, auth, script); + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"early-cam") >= 1 { + let stream = Stream::open(&mut h.session.clone(), version).await.unwrap(); + let acceptor = async move { + let first = requests.next().await.expect("a request"); + let _issued = first.accept(crate::auth::Grant::all()); + let _never_answered = requests.next().await.expect("a renewal"); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + let mut ended = false; + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + if futures::poll!(serving.as_mut()).is_ready() { + ended = true; break; } settle().await; } - let bytes = log.writes.lock().unwrap().clone(); - assert_eq!( - namespace_events(&bytes, version), - vec![(ietf::Namespace::ID, "early-cam".to_owned())], - "{version:?}: existing match", + assert!( + ended, + "{version:?}: an UNSUBSCRIBE ends the subscription despite the pending renewal" ); + } + } - let _late = origin.announce("late-cam", crate::origin::Route::default()).unwrap(); - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"late-cam") >= 1 { - break; + /// A REQUEST_UPDATE the acceptor refuses ends only that subscription, at once, through the + /// PUBLISH_DONE UPDATE_FAILED path (drafts 16 section 9.11.1 / 18 section 10.9.1). The old + /// grant does not keep it alive to its 60s expiry, and the session stays up. + #[moq_net_sim::test] + async fn a_refused_request_update_ends_the_subscription() { + const VERSION: Version = Version::Draft18; + + let (auth, mut requests, _cred) = auth_covering_other(); + let h = serve_with_auth(VERSION, auth, subscribe_update_with_token(VERSION).await); + let rt = h.publisher.runtime.clone(); + + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + + let answered = std::sync::Arc::new(AtomicU64::new(0)); + let acceptor = { + let answered = answered.clone(); + async move { + // The first grant lasts 60s; the renewal is refused. + let first = requests.next().await.unwrap(); + let _issued = first.accept(grant_all_expiring(&rt, Some(60))); + answered.fetch_add(1, Ordering::Relaxed); + let renewal = requests.next().await.unwrap(); + renewal.reject(crate::SessionError::Unauthorized, "no"); + answered.fetch_add(1, Ordering::Relaxed); + std::future::pending::<()>().await + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + + // No time is advanced: the refusal must end the subscription on its own, not by letting + // the 60s grant lapse. + let mut result = None; + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + if let Poll::Ready(res) = futures::poll!(serving.as_mut()) { + result = Some(res); + break; + } + settle().await; + } + assert_eq!( + answered.load(Ordering::Relaxed), + 2, + "acceptor never answered both tokens" + ); + assert!( + matches!(result, Some(Err(Error::Unauthorized))), + "a refused renewal ends the subscription at once, got {result:?}" + ); + let (status, _) = Wire::read(&h.log, VERSION).publish_done.expect("PUBLISH_DONE was sent"); + assert_eq!( + status, + ietf::PublishDoneStatus::UpdateFailed.code(VERSION), + "UPDATE_FAILED" + ); + // Draft-17+ answers the refused update with exactly one unkeyed REQUEST_ERROR before the + // subscription ends. + let written = h.log.writes.lock().unwrap().clone(); + let error = request_error_bytes(VERSION, RequestId(0), "renewal not granted").await; + assert_eq!( + written.windows(error.len()).filter(|w| *w == error.as_slice()).count(), + 1, + "exactly one unkeyed REQUEST_ERROR before the subscription ends" + ); + assert!( + h.log.closes().is_empty(), + "the session must stay up: {:?}", + h.log.closes() + ); + } + + /// A slow acceptor answering a renewal must not stall serving nor let the request outlive + /// its grant: the renewal verify is raced against the old deadline, which still fires. + #[moq_net_sim::test] + async fn a_slow_renewal_does_not_stall_serving() { + const VERSION: Version = Version::Draft18; + let (auth, mut requests, _cred) = auth_covering_other(); + let h = serve_with_auth(VERSION, auth, subscribe_update_with_token(VERSION).await); + let rt = h.publisher.runtime.clone(); + + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + + let popped = std::sync::Arc::new(AtomicU64::new(0)); + let acceptor = { + let popped = popped.clone(); + async move { + let mut held = Vec::new(); + // The first grant lapses in 60s. + let first = requests.next().await.expect("a request"); + held.push(first.accept(grant_all_expiring(&rt, Some(60)))); + popped.fetch_add(1, Ordering::Relaxed); + // The renewal is popped but never answered: a slow or hung acceptor. + let _slow = requests.next().await.expect("a renewal"); + popped.fetch_add(1, Ordering::Relaxed); + std::future::pending::<()>().await + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + + // Let the loop read the REQUEST_UPDATE and the acceptor pop both requests. + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(serving.as_mut()).is_pending(), + "the subscription ended during setup" + ); + if popped.load(Ordering::Relaxed) >= 2 { + break; + } + settle().await; + } + assert_eq!(popped.load(Ordering::Relaxed), 2, "the acceptor never saw the renewal"); + + // The acceptor is stuck. Past the old 60s expiry the deadline must still fire and end + // the request UNAUTHORIZED, rather than the pending renewal stalling serving. + moq_net_sim::advance(Duration::from_secs(120)).await; + let mut ended = None; + for _ in 0..50 { + let _ = futures::poll!(acceptor.as_mut()); + if let Poll::Ready(res) = futures::poll!(serving.as_mut()) { + ended = Some(res); + break; + } + settle().await; + } + let res = ended.expect("serving must end at the old deadline, not stall on the slow renewal"); + assert!(matches!(res, Err(Error::Unauthorized)), "{res:?}"); + } + + /// Publisher / SUBSCRIBE side: on a session without the AUTH extension the union is + /// `None` forever, so `allows` is permissive; a token-bearing SUBSCRIBE must still be + /// verified via `covers`, not admitted by that default. The acceptor is consulted; with the + /// bug the token path was bypassed and the token silently ignored. + #[moq_net_sim::test] + async fn a_request_token_on_a_no_auth_session_is_verified() { + const VERSION: Version = Version::Draft18; + let auth = crate::auth::Handle::new(false); + let mut requests = auth.requests().unwrap(); + assert!( + auth.allows(crate::auth::Direction::Publish, "room"), + "None union is permissive" + ); + assert!( + !auth.covers(crate::auth::Direction::Publish, "room"), + "None union does not cover" + ); + + let h = serve_with_auth(VERSION, auth, Vec::new()); + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + let consulted = std::sync::Arc::new(AtomicU64::new(0)); + let acceptor = { + let consulted = consulted.clone(); + async move { + let mut held = Vec::new(); + loop { + let request = requests.next().await.expect("a request"); + consulted.fetch_add(1, Ordering::Relaxed); + held.push(request.accept(crate::auth::Grant::all())); } - settle().await; } - let bytes = log.writes.lock().unwrap().clone(); - assert_eq!( - namespace_events(&bytes, version), - vec![ - (ietf::Namespace::ID, "early-cam".to_owned()), - (ietf::Namespace::ID, "late-cam".to_owned()), - ], - "{version:?}: announced later", + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(serving.as_mut()).is_pending(), + "the subscription ended early" ); + if consulted.load(Ordering::Relaxed) >= 1 { + break; + } + settle().await; + } + assert!( + consulted.load(Ordering::Relaxed) >= 1, + "the token must be verified by the acceptor, not admitted by the permissive default" + ); + } - drop(early); - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"early-cam") >= 2 { - break; + /// A token-bearing SUBSCRIBE being served on a no-AUTH session whose acceptor answers every + /// token with `grant`, after `limit` (if any) narrowed what the peer may do. + type Serving = std::pin::Pin>>>; + + async fn serve_token_subscribe( + grant: crate::auth::Grant, + limit: Option, + ) -> (crate::auth::Handle, Serve, Serving) { + const VERSION: Version = Version::Draft18; + let auth = crate::auth::Handle::new(false); + let mut requests = auth.requests().unwrap(); + if let Some(limit) = &limit { + auth.authorize(limit); + } + let h = serve_with_auth(VERSION, auth.clone(), Vec::new()); + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + let serving = h.publisher.clone().run_subscribe_stream(stream, token_subscribe()); + let serving: Serving = Box::pin(async move { + let acceptor = async move { + let mut held = Vec::new(); + while let Some(request) = requests.next().await { + held.push(request.accept(grant.clone())); } - settle().await; + std::future::pending::>().await + }; + let acceptor = std::pin::pin!(acceptor); + let serving = std::pin::pin!(serving); + match futures::future::select(acceptor, serving).await { + futures::future::Either::Left((res, _)) | futures::future::Either::Right((res, _)) => res, + } + }); + (auth, h, serving) + } + + /// Whether `serving` is still running after the acceptor and the serve loop have had their + /// turns. + async fn still_serving(serving: &mut Serving) -> bool { + for _ in 0..300 { + if futures::poll!(serving.as_mut()).is_ready() { + return false; + } + settle().await; + } + true + } + + /// A subscriber's token grant is checked on its `subscribe` patterns: a write-only grant + /// does not admit a SUBSCRIBE, a read-only one does. + #[moq_net_sim::test] + async fn a_subscribe_token_needs_a_subscribe_grant() { + let read_only = crate::auth::Grant { + publish: crate::Patterns::new(), + subscribe: crate::Pattern::all().into(), + expires: None, + }; + let write_only = crate::auth::Grant { + publish: crate::Pattern::all().into(), + subscribe: crate::Patterns::new(), + expires: None, + }; + let (_auth, _h, mut serving) = serve_token_subscribe(read_only, None).await; + assert!(still_serving(&mut serving).await, "a read grant admits it"); + let (_auth, _h, mut serving) = serve_token_subscribe(write_only, None).await; + assert!(!still_serving(&mut serving).await, "a write grant refuses it"); + } + + /// A request token stands in for the union, never for the limit this side set on the + /// peer: a SUBSCRIBE outside that ceiling is refused whatever the token grants, and a + /// later narrowing ends one the token admitted. + #[moq_net_sim::test] + async fn a_request_token_cannot_exceed_the_local_limit() { + let nothing = Some(crate::auth::Grant::default()); + let (_auth, _h, mut serving) = serve_token_subscribe(crate::auth::Grant::all(), nothing).await; + assert!( + !still_serving(&mut serving).await, + "a limit of nothing refuses an all-covering token" + ); + + let (auth, _h, mut serving) = serve_token_subscribe(crate::auth::Grant::all(), None).await; + assert!( + still_serving(&mut serving).await, + "an unlimited session admits the token" + ); + auth.authorize(&crate::auth::Grant::default()); + assert!( + !still_serving(&mut serving).await, + "narrowing the limit ends a token-authorized subscription" + ); + } + + /// Holds an acceptor until the test releases it. + #[derive(Clone, Default)] + struct Release(kio::Shared); + + impl Release { + fn notify_one(&self) { + *self.0.lock() = true; + } + + async fn notified(&self) { + kio::wait(|waiter| { + self.0 + .poll(waiter, |released| match **released { + true => Poll::Ready(()), + false => Poll::Pending, + }) + .map(|_| ()) + }) + .await + } + } + + /// The typed form of a raw USE_VALUE Token structure with a one-byte Token Type. + fn typed(raw: &[u8]) -> crate::setup::Token { + assert_eq!(raw[0], 0x03, "USE_VALUE"); + crate::setup::Token { + kind: raw[1].into(), + value: raw[2..].to_vec(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::coding::Decode; + use crate::lite::test_transport::SinkSession; + use crate::model::ProduceTest; + use futures::FutureExt; + + async fn settle() { + moq_net_sim::sleep(Duration::from_millis(1)).await; + } + + fn occurrences(log: &crate::lite::test_transport::Log, needle: &[u8]) -> usize { + let writes = log.writes.lock().unwrap(); + writes.windows(needle.len()).filter(|window| *window == needle).count() + } + + /// NAMESPACE and NAMESPACE_DONE suffixes on a SUBSCRIBE_NAMESPACE stream, in order. + fn namespace_events(bytes: &[u8], version: Version) -> Vec<(u64, String)> { + let mut dec = crate::coding::Decoder::new(bytes, version.into()); + let mut out = Vec::new(); + while !dec.is_empty() { + let id = dec.varint().expect("message type"); + match id { + ietf::RequestOk::ID => { + ietf::RequestOk::decode(&mut dec, version).expect("request ok"); + } + ietf::Namespace::ID => { + let msg = ietf::Namespace::decode(&mut dec, version).expect("namespace"); + out.push((id, msg.suffix.as_str().to_owned())); + } + ietf::NamespaceDone::ID => { + let msg = ietf::NamespaceDone::decode(&mut dec, version).expect("namespace done"); + out.push((id, msg.suffix.as_str().to_owned())); + } + other => panic!("unexpected message {other:#x}"), + } + } + out + } + + /// A SETUP slot already filled with what the peer declared. The announce loops block + /// on it, so a test that leaves it empty is a test that never advertises. + fn declared(solicit: Option) -> peer::PeerSetup { + let slot = peer::PeerSetup::default(); + slot.set(peer::Peer { + solicit, + ..Default::default() + }); + slot + } + + /// moq-transport cannot carry the receiver's max delay budget, so the serving + /// subscription must preserve everything the producer still retains. + #[test] + fn serving_subscription_keeps_retained_backlog() { + let producer = track::Producer::new(std::sync::Arc::new(crate::broadcast::Info::default()), "video", None); + for millis in [0, 1000] { + let mut group = producer.append_group().unwrap(); + group + .write_frame(crate::Timestamp::from_millis(millis).unwrap(), b"frame".as_slice()) + .unwrap(); + group.finish().unwrap(); + } + + let subscription = serving_subscription(128); + assert_eq!(subscription.max_delay.as_millis(), MAX_SAFE_DELAY_MS as u128); + let mut subscriber = producer.subscribe(subscription); + for sequence in [0, 1] { + let group = subscriber + .recv_group() + .now_or_never() + .expect("retained group should be ready") + .unwrap() + .expect("track should remain open"); + assert_eq!(group.sequence, sequence); + } + } + + /// A peer that requires solicitation, which is what hands the advertisements to the + /// SUBSCRIBE_NAMESPACE stream. + fn requires_solicitation() -> peer::PeerSetup { + declared(Some(true)) + } + + /// A publisher for a peer assigned `assigned`, over an origin holding two + /// broadcasts: `from/peer`, whose only route flows through `assigned`, and + /// `from/us`, which does not. The producers are returned so the routes outlive + /// the assertions. + async fn echo_harness( + assigned: crate::Hop, + ) -> ( + Publisher, + origin::Consumer, + Vec, + ) { + let other = crate::Hop::new(778).unwrap(); + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let consumer = origin.consume(); + + let session = crate::lite::test_transport::SinkSession::new(Default::default()); + let publisher = Publisher::new( + crate::time::Clock::sim(), + session, + origin.consume(), + Control::new(None, false), + Some(assigned), + peer::PeerSetup::default(), + Version::Draft16, + ); + + let mut echoed_hops = crate::Hops::new(); + echoed_hops.push(crate::Hop::UNKNOWN).unwrap(); + let echoed = origin + .announce( + "from/peer", + crate::origin::Route::default() + .with_hops(echoed_hops) + .with_via(assigned), + ) + .unwrap(); + + let mut local_hops = crate::Hops::new(); + local_hops.push(other).unwrap(); + let local = origin + .announce("from/us", crate::origin::Route::default().with_hops(local_hops)) + .unwrap(); + + (publisher, consumer, vec![echoed, local]) + } + + /// A broadcast whose every route flows through the peer's assigned identity + /// (`Client::with_peer_hop`) is never advertised to that peer; it would only + /// echo the peer's own content back at it. A broadcast with an independent + /// route still is. + #[moq_net_sim::test] + async fn assigned_peer_hop_filters_echoed_announces() { + let assigned = crate::Hop::new(777).unwrap(); + let (publisher, consumer, _routes) = echo_harness(assigned).await; + + let peer = cluster::Peer::default(); + + // The cursor is what filters: an excluded consumer never sees the echoed route. + let mut announced = consumer.excluding(assigned).announced(); + let local = announced.assert_next_active("from/us"); + announced.assert_next_wait(); + + assert_eq!(publisher.select(&local, &peer), Advert::Plain); + } + + /// An anonymous chain received from an identified peer keeps the 0 on the wire + /// and is never advertised back to that session: split-horizon matches `via` + /// as well as the chain. + #[moq_net_sim::test] + async fn anonymous_chain_is_forwarded_with_zero_and_not_echoed() { + let assigned = crate::Hop::new(777).unwrap(); + let r1 = crate::Hop::new(9).unwrap(); + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let consumer = origin.consume(); + let publisher = Publisher::new( + crate::time::Clock::sim(), + crate::lite::test_transport::SinkSession::new(Default::default()), + origin.consume(), + Control::new(None, false), + Some(assigned), + peer::PeerSetup::default(), + Version::Draft19, + ); + + let mut hops = crate::Hops::new(); + hops.push(crate::Hop::UNKNOWN).unwrap(); + hops.push(r1).unwrap(); + let _echoed = origin + .announce( + "from/peer", + crate::origin::Route::default().with_hops(hops.clone()).with_via(r1), + ) + .unwrap(); + + let peer = cluster::Peer { + hop: Some(r1), + cost: None, + }; + let mut announced = consumer.excluding(publisher.exclude(&peer)).announced(); + announced.assert_next_wait(); + + let forwarded = cluster::Advert::forward(&hops, 0, crate::Hop::new(1).unwrap()).unwrap(); + let ids: Vec<_> = forwarded.hops.hops().iter().map(|h| h.id()).collect(); + assert_eq!(ids, vec![0, 9, 1]); + } + + /// Declaring the reserved 0 turns the extension on while naming nobody, so the + /// identity we assigned stands in, exactly as for a peer that never negotiated. + /// Asserted on the resolution itself rather than through an advertisement: a + /// negotiated peer always sends its own HOP_PATH, so a route attributed to the + /// assigned identity is a state this peer class cannot reach; see + /// [`a_declared_zero_chain_is_not_advertised_back`] for what it gets instead. + #[moq_net_sim::test] + async fn withheld_peer_hop_falls_back_to_assigned() { + let assigned = crate::Hop::new(777).unwrap(); + let declared = crate::Hop::new(9).unwrap(); + let (publisher, _consumer, _routes) = echo_harness(assigned).await; + + let withheld = cluster::Peer { + hop: Some(crate::Hop::UNKNOWN), + cost: None, + }; + assert!(withheld.negotiated(), "the extension is on"); + assert_eq!(publisher.exclude(&withheld), assigned, "0 names nobody, so we do"); + + let absent = cluster::Peer::default(); + assert_eq!(publisher.exclude(&absent), assigned, "so does declaring nothing"); + + let named = cluster::Peer { + hop: Some(declared), + cost: None, + }; + assert_eq!(publisher.exclude(&named), declared, "a declared identity wins"); + } + + /// A peer that negotiated the extension MUST send a HOP_PATH on every advertisement, + /// and one that declared 0 names itself 0 there. An arriving chain is not rewritten, + /// so the route carries 0; the assigned identity stays on `via` and split-horizon + /// matches it, so the peer is not advertised its own route back. + #[moq_net_sim::test] + async fn a_declared_zero_chain_is_not_advertised_back() { + let assigned = crate::Hop::new(777).unwrap(); + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let consumer = origin.consume(); + + let publisher = Publisher::new( + crate::time::Clock::sim(), + crate::lite::test_transport::SinkSession::new(Default::default()), + origin.consume(), + Control::new(None, false), + Some(assigned), + peer::PeerSetup::default(), + Version::Draft16, + ); + + // The chain as ingress stores it: the peer named itself 0, and `via` is the + // identity we assigned that session. + let mut hops = crate::Hops::new(); + hops.push(crate::Hop::UNKNOWN).unwrap(); + let _echoed = origin + .announce( + "from/peer", + crate::origin::Route::default().with_hops(hops).with_via(assigned), + ) + .unwrap(); + + let peer = cluster::Peer { + hop: Some(crate::Hop::UNKNOWN), + cost: None, + }; + let mut announced = consumer.excluding(publisher.exclude(&peer)).announced(); + announced.assert_next_wait(); + } + + /// MoQ Active Count: the OK counts exactly the NAMESPACE messages that follow it, + /// so a route this peer is never told about is not counted either. Counting one would + /// leave the peer waiting on a message that never comes. + #[moq_net_sim::test] + async fn the_ok_counts_only_what_is_advertised() { + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + + let session = SinkSession::gated_bi(kio::Producer::new(true).consume()); + let log = session.log.clone(); + let setup = peer::PeerSetup::default(); + setup.set(peer::Peer { + cluster: cluster::Peer { + hop: Some(crate::Hop::new(9).unwrap()), + cost: None, + }, + solicit: Some(true), + active_count: true, + ..Default::default() + }); + let publisher = Publisher::new( + crate::time::Clock::sim(), + session.clone(), + origin.consume(), + Control::new(None, false), + None, + setup, + Version::Draft17, + ); + + let mut clean = crate::Hops::new(); + clean.push(crate::Hop::new(778).unwrap()).unwrap(); + let _clean = origin + .announce("cam-clean", crate::origin::Route::default().with_hops(clean)) + .unwrap(); + // A chain with no room left for our own hop cannot be forwarded. + let mut full = crate::Hops::new(); + while full.push(crate::Hop::new(100 + full.len() as u64).unwrap()).is_ok() {} + let _full = origin + .announce("cam-full", crate::origin::Route::default().with_hops(full)) + .unwrap(); + settle().await; + + let stream = Stream::open(&mut session.clone(), Version::Draft17).await.unwrap(); + let msg = ietf::SubscribeNamespace { + request_id: RequestId(1), + namespace: crate::Path::new(""), + hidden: false, + }; + let mut run = std::pin::pin!(publisher.run_subscribe_namespace_stream(stream, msg)); + assert!(futures::poll!(run.as_mut()).is_pending()); + + assert_eq!(occurrences(&log, b"cam-clean"), 1); + assert_eq!(occurrences(&log, b"cam-full"), 0); + + let expected = crate::lite::test_transport::Log::default(); + let mut writer = crate::coding::Writer::new( + crate::lite::test_transport::SinkSend::new(expected.clone()), + Version::Draft17, + ); + writer.varint(ietf::RequestOk::ID).await.unwrap(); + writer + .encode(&ietf::RequestOk { + request_id: None, + active: Some(1), + }) + .await + .unwrap(); + let expected = expected.writes.lock().unwrap().clone(); + assert!( + log.writes.lock().unwrap().starts_with(&expected), + "the OK counts one NAMESPACE" + ); + } + + /// A same-path source can splice into (or detach from) an existing broadcast + /// without an origin-level (un)announce, silently flipping `advertisable`. + /// Namespace forwarding must follow: advertise when a clean route appears, + /// withdraw when the last one detaches. + #[moq_net_sim::test] + async fn namespace_follows_route_eligibility_changes() { + let assigned = crate::Hop::new(777).unwrap(); + let clean_publisher = crate::Hop::new(778).unwrap(); + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + + let gate = kio::Producer::new(true); + let session = SinkSession::gated_bi(gate.consume()); + let log = session.log.clone(); + let publisher = Publisher::new( + crate::time::Clock::sim(), + session.clone(), + origin.consume(), + Control::new(None, false), + Some(assigned), + requires_solicitation(), + Version::Draft16, + ); + + // The prefix starts with only a route from the assigned peer: hop 0 on the + // chain, identity on `via`. + let mut tainted_hops = crate::Hops::new(); + tainted_hops.push(crate::Hop::UNKNOWN).unwrap(); + let _tainted = origin + .announce( + "route-flip-cam", + crate::origin::Route::default() + .with_hops(tainted_hops) + .with_via(assigned), + ) + .unwrap(); + settle().await; + + let stream = Stream::open(&mut session.clone(), Version::Draft16).await.unwrap(); + let msg = ietf::SubscribeNamespace { + request_id: RequestId(1), + namespace: crate::Path::new(""), + hidden: false, + }; + let mut run = std::pin::pin!(publisher.run_subscribe_namespace_stream(stream, msg)); + + // Initial set: the tainted-only broadcast is filtered, nothing but the OK + // response on the wire. + assert!(futures::poll!(run.as_mut()).is_pending()); + assert_eq!(occurrences(&log, b"route-flip-cam"), 0); + + // A clean route joins the same prefix: the excluded cursor now has a best + // visible route, so the namespace must be advertised. + let mut clean_hops = crate::Hops::new(); + clean_hops.push(clean_publisher).unwrap(); + let clean = origin + .announce("route-flip-cam", crate::origin::Route::default().with_hops(clean_hops)) + .unwrap(); + settle().await; + assert!(futures::poll!(run.as_mut()).is_pending()); + assert_eq!( + occurrences(&log, b"route-flip-cam"), + 1, + "NAMESPACE after a clean route joins" + ); + + // The clean route retracts, leaving only the tainted one: withdrawn. + drop(clean); + settle().await; + assert!(futures::poll!(run.as_mut()).is_pending()); + assert_eq!( + occurrences(&log, b"route-flip-cam"), + 2, + "NAMESPACE_DONE after the last clean route detaches" + ); + } + + /// The peer's OK to a PUBLISH_NAMESPACE, framed exactly as the announce path + /// reads it -- built with the crate's own writer so the framing can't drift + /// from the encoder under test. + /// A REQUEST_ERROR declining an advertisement, with the retry interval the peer asked + /// for in milliseconds. Zero means it does not want the namespace offered again. + async fn publish_namespace_error(version: Version, retry_interval: u64) -> Vec { + let log = crate::lite::test_transport::Log::default(); + let mut writer = crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); + + writer.varint(ietf::RequestError::ID).await.unwrap(); + writer + .encode(&ietf::RequestError { + request_id: matches!(version, Version::Draft15 | Version::Draft16).then_some(RequestId(1)), + // UNINTERESTED, draft-17 section 14.5.2. + error_code: 0x20, + reason_phrase: "no".into(), + retry_interval, + }) + .await + .unwrap(); + + log.writes.lock().unwrap().clone() + } + + /// A peer that refuses an advertisement with a retry interval of 0 is asking not to be + /// offered it again. Coming back anyway turns a permanent refusal (unauthorized, + /// uninterested) into a request every few seconds for the life of the session. + #[moq_net_sim::test] + async fn a_refusal_that_forbids_retrying_is_not_retried() { + const VERSION: Version = Version::Draft17; + + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let _cam = origin.announce("lonely-cam", crate::origin::Route::default()).unwrap(); + settle().await; + + // Every stream is answered with the same refusal, so a retry would show up as a + // second occurrence on the wire. + let refusal = publish_namespace_error(VERSION, 0).await; + let session = + crate::lite::test_transport::ScriptedSession::per_stream(vec![refusal.clone(), refusal.clone(), refusal]); + let log = session.log.clone(); + + let publisher = Publisher::new( + crate::time::Clock::sim(), + session, + origin.consume(), + Control::new(None, false), + None, + declared(Some(false)), + VERSION, + ); + + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, b"lonely-cam") > 0 { + break; + } + settle().await; + } + assert_eq!(occurrences(&log, b"lonely-cam"), 1, "the advertisement never went out"); + + // Well past every retry the loop would otherwise take. + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + tick().await; + } + + assert_eq!( + occurrences(&log, b"lonely-cam"), + 1, + "re-offered a namespace the peer asked not to be offered again" + ); + } + + async fn publish_namespace_ok(version: Version) -> Vec { + let log = crate::lite::test_transport::Log::default(); + let mut writer = crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); + + match version { + Version::Draft14 => { + writer.varint(ietf::PublishNamespaceOk::ID).await.unwrap(); + writer + .encode(&ietf::PublishNamespaceOk { + request_id: RequestId(1), + }) + .await + .unwrap(); + } + Version::Draft15 | Version::Draft16 => { + writer.varint(ietf::RequestOk::ID).await.unwrap(); + writer + .encode(&ietf::RequestOk { + request_id: Some(RequestId(1)), + active: None, + }) + .await + .unwrap(); + } + // Draft-17+ dropped the request id: the response rides the request's stream. + _ => { + writer.varint(ietf::RequestOk::ID).await.unwrap(); + writer + .encode(&ietf::RequestOk { + request_id: None, + active: None, + }) + .await + .unwrap(); + } + } + + let writes = log.writes.lock().unwrap(); + writes.clone() + } + + /// Draft-14/15 predate the NAMESPACE message, so a SUBSCRIBE_NAMESPACE is + /// answered with one PUBLISH_NAMESPACE request per matching namespace over the + /// control stream, and PUBLISH_NAMESPACE_DONE withdraws it. The state is local + /// to the subscription's task, mirroring lite's announce handling. + #[moq_net_sim::test] + async fn v14_subscribe_namespace_is_answered_with_publish_namespace() { + const VERSION: Version = Version::Draft14; + + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let consumer = origin.consume(); + + // Announced before the peer subscribes: it must only hit the wire after. + let early = origin.announce("early-cam", crate::origin::Route::default()).unwrap(); + settle().await; + + // Stream 1 is the peer's SUBSCRIBE_NAMESPACE (the peer stays quiet after); + // streams 2 and 3 answer our two PUBLISH_NAMESPACE requests. + let ok = publish_namespace_ok(VERSION).await; + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![Vec::new(), ok.clone(), ok]); + let log = session.log.clone(); + + let publisher = Publisher::new( + crate::time::Clock::sim(), + session.clone(), + consumer, + Control::new(None, false), + None, + requires_solicitation(), + VERSION, + ); + + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + let msg = ietf::SubscribeNamespace { + request_id: RequestId(1), + namespace: crate::Path::new(""), + hidden: false, + }; + let mut run = std::pin::pin!(publisher.run_subscribe_namespace_stream(stream, msg)); + + // The subscription solicits the already-announced namespace. + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, b"early-cam") >= 1 { + break; + } + settle().await; + } + assert_eq!( + occurrences(&log, b"early-cam"), + 1, + "PUBLISH_NAMESPACE after subscribing" + ); + + // A later announce reaches the same subscription. + let _late = origin.announce("late-cam", crate::origin::Route::default()).unwrap(); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, b"late-cam") >= 1 { + break; + } + settle().await; + } + assert_eq!( + occurrences(&log, b"late-cam"), + 1, + "PUBLISH_NAMESPACE for a live announce" + ); + + // An unannounce closes out its own request with PUBLISH_NAMESPACE_DONE. + drop(early); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, b"early-cam") >= 2 { + break; + } + settle().await; + } + assert_eq!( + occurrences(&log, b"early-cam"), + 2, + "PUBLISH_NAMESPACE_DONE on unannounce" + ); + + // One stream for the subscription itself, one per PUBLISH_NAMESPACE: the + // withdrawal rode the announce's own request, not a new stream. + assert_eq!(log.bi_opens(), 3, "no extra stream for the withdrawal"); + } + + /// A peer that declared nothing is told without being asked. Relays that never send + /// SUBSCRIBE_NAMESPACE hear nothing otherwise, and every third-party one behaves + /// that way: a publisher is expected to announce itself. + #[moq_net_sim::test] + async fn a_peer_that_declared_nothing_is_told_unsolicited() { + const VERSION: Version = Version::Draft17; + + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let _local = origin.announce("local-cam", crate::origin::Route::default()).unwrap(); + settle().await; + + // The only stream is the PUBLISH_NAMESPACE request we open ourselves. + let session = + crate::lite::test_transport::ScriptedSession::per_stream(vec![publish_namespace_ok(VERSION).await]); + let log = session.log.clone(); + + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + + let publisher = Publisher::new( + crate::time::Clock::sim(), + session, + origin.consume(), + Control::new(None, false), + None, + peer_setup, + VERSION, + ); + + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, b"local-cam") >= 1 { + break; + } + settle().await; + } + + assert_eq!( + occurrences(&log, b"local-cam"), + 1, + "PUBLISH_NAMESPACE without a SUBSCRIBE_NAMESPACE" + ); + assert_eq!(log.bi_opens(), 1, "one request stream"); + } + + /// ACTIVE_COUNT only answers SUBSCRIBE_NAMESPACE (MoQ Active Count), so one on the OK + /// to a PUBLISH_NAMESPACE is the peer breaking the extension, negotiated or not. + #[moq_net_sim::test] + async fn a_counted_publish_namespace_ok_is_a_violation() { + const VERSION: Version = Version::Draft17; + + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let _local = origin.announce("local-cam", crate::origin::Route::default()).unwrap(); + settle().await; + + let ok = crate::lite::test_transport::Log::default(); + let mut writer = crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(ok.clone()), VERSION); + writer.varint(ietf::RequestOk::ID).await.unwrap(); + writer + .encode(&ietf::RequestOk { + request_id: None, + active: Some(0), + }) + .await + .unwrap(); + let ok = ok.writes.lock().unwrap().clone(); + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![ok]); + let log = session.log.clone(); + + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer { + active_count: true, + ..Default::default() + }); + let publisher = Publisher::new( + crate::time::Clock::sim(), + session, + origin.consume(), + Control::new(None, false), + None, + peer_setup, + VERSION, + ); + + let res = moq_net_sim::timeout(Duration::from_secs(5), publisher.run_publish_namespaces()) + .await + .expect("the violation ends the loop"); + assert!(matches!(res, Err(Error::ProtocolViolation)), "{res:?}"); + // Closed at the source, since the solicited path's request task only logs errors. + let code = crate::SessionError::ProtocolViolation.to_code(); + assert!(log.closes().iter().any(|(c, _)| *c == code), "{:?}", log.closes()); + } + + /// Drive both announce loops at once against a peer that declared `solicit`, + /// returning how many times the namespace hit the wire and how many bidi streams + /// were opened. One stream means the entry rode the subscription inline; two means + /// it went out as its own PUBLISH_NAMESPACE request. + async fn advertise_both_ways(solicit: Option) -> (usize, usize) { + let log = advertise_with_hidden(Discovery { + solicit, + ..Discovery::default() + }) + .await; + (occurrences(&log, b"cam"), log.bi_opens()) + } + + /// [`advertise_both_ways`] with a hidden `.stats/node` beside `cam`, and the peer's + /// SUBSCRIBE_NAMESPACE for `prefix` opting in to hidden namespaces or not. + struct Discovery<'a> { + version: Version, + declared: bool, + solicit: Option, + prefix: &'a str, + hidden: bool, + scoped: bool, + } + + impl Default for Discovery<'_> { + fn default() -> Self { + Self { + version: Version::Draft17, + declared: true, + solicit: Some(true), + prefix: "", + hidden: false, + scoped: false, + } + } + } + + async fn advertise_with_hidden(case: Discovery<'_>) -> crate::lite::test_transport::Log { + let Discovery { + version, + declared: hidden_declared, + solicit, + prefix, + hidden, + scoped, + } = case; + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); + let _stats = origin.announce(".stats/node", crate::origin::Route::default()).unwrap(); + settle().await; + + // Stream 1 is the peer's SUBSCRIBE_NAMESPACE; stream 2, if opened at all, is our + // PUBLISH_NAMESPACE request. + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![ + Vec::new(), + publish_namespace_ok(version).await, + ]); + let log = session.log.clone(); + + let setup = peer::PeerSetup::default(); + setup.set(peer::Peer { + solicit, + hidden: hidden_declared, + ..Default::default() + }); + let consume = match scoped { + true => origin + .consume() + .scope("", &crate::Pattern::subtree(".stats").unwrap().into()) + .unwrap(), + false => origin.consume(), + }; + let publisher = Publisher::new( + crate::time::Clock::sim(), + session.clone(), + consume, + Control::new(None, false), + None, + setup, + version, + ); + + let stream = Stream::open(&mut session.clone(), version).await.unwrap(); + let msg = ietf::SubscribeNamespace { + request_id: RequestId(1), + namespace: crate::Path::new(prefix), + hidden, + }; + let mut solicited = std::pin::pin!(publisher.clone().run_subscribe_namespace_stream(stream, msg)); + let mut unsolicited = std::pin::pin!(publisher.run_publish_namespaces()); + + // Poll well past the first advertisement, so a second one from the other loop + // would show up rather than being missed by an early break. The unsolicited loop + // finishes immediately when the peer requires solicitation, and a completed + // future must not be polled again. + let mut quiet = false; + for _ in 0..100 { + assert!(futures::poll!(solicited.as_mut()).is_pending()); + if !quiet { + quiet = futures::poll!(unsolicited.as_mut()).is_ready(); + } + settle().await; + } + + log + } + + /// A hidden namespace reaches only a subscription that opted in or named its dot + /// segment. On draft-16 and later the subscription also repeats every visible + /// namespace the unsolicited loop already sent. A NAMESPACE is discovery, not + /// a second route. + #[moq_net_sim::test] + async fn hidden_namespaces_need_an_opt_in() { + for (solicit, prefix, hidden, cam, stats) in [ + (Some(false), "", false, 2, 0), + (Some(false), "", true, 2, 1), + (Some(false), ".stats", false, 1, 1), + (Some(true), "", false, 1, 0), + (Some(true), "", true, 1, 1), + (Some(true), ".stats", false, 0, 1), + ] { + let log = advertise_with_hidden(Discovery { + solicit, + prefix, + hidden, + ..Discovery::default() + }) + .await; + let case = format!("solicit {solicit:?}, prefix {prefix:?}, hidden {hidden}"); + assert_eq!(occurrences(&log, b"cam"), cam, "{case}"); + // An inline entry names its suffix, so count the leaf. + assert_eq!(occurrences(&log, b"node"), stats, "{case}"); + } + } + + #[moq_net_sim::test] + async fn hidden_discovery_obeys_peer_setup_and_requested_prefix() { + for version in [ + Version::Draft14, + Version::Draft15, + Version::Draft16, + Version::Draft19, + Version::Draft22, + ] { + for declared in [false, true] { + for solicit in [Some(false), Some(true)] { + for scoped in [false, true] { + for (prefix, hidden) in [("", false), ("", true), (".stats", false)] { + let log = advertise_with_hidden(Discovery { + version, + declared, + solicit, + prefix, + hidden, + scoped, + }) + .await; + // Visible on the subscription: not filtered, opted in, or the prefix + // names the dot segment. The unsolicited loop sends a dot namespace + // only when the peer did not declare MoQ Hidden. Draft-14/15 keep + // the two paths disjoint; draft-16 and later fill the stream too. + let on_stream = !declared || hidden || prefix == ".stats"; + let unsolicited = solicit != Some(true) && !declared; + let legacy = matches!(version, Version::Draft14 | Version::Draft15); + let inline = if legacy { + (solicit == Some(true) && on_stream) + || (solicit != Some(true) && declared && (hidden || prefix == ".stats")) + } else { + on_stream + }; + let expected = usize::from(inline) + usize::from(unsolicited); + assert_eq!( + occurrences(&log, b"node"), + expected, + "{version:?}: declared={declared}, solicit={solicit:?}, scoped={scoped}, prefix={prefix}, hidden={hidden}" + ); + } + } + } + } + } + } + + /// On draft-16 and later a peer that did not ask to be solicited hears each + /// namespace twice: an unsolicited PUBLISH_NAMESPACE and a NAMESPACE on the + /// SUBSCRIBE_NAMESPACE stream. Those are two discoveries of one namespace, not + /// two routes. A peer that asked to be solicited hears it only on the stream. + /// Draft-14 and 15 still answer a non-solicit peer only with PUBLISH_NAMESPACE. + #[moq_net_sim::test] + async fn a_non_solicit_peer_hears_a_namespace_both_ways() { + let (times, streams) = advertise_both_ways(Some(false)).await; + assert_eq!(times, 2, "PUBLISH_NAMESPACE and NAMESPACE"); + assert_eq!(streams, 2, "the subscription plus one PUBLISH_NAMESPACE request"); + + let (once, streams) = advertise_both_ways(Some(true)).await; + assert_eq!(once, 1, "a peer that asked to be told on request is told once"); + assert_eq!(streams, 1, "inline on the SUBSCRIBE_NAMESPACE stream it asked on"); + + for version in [Version::Draft14, Version::Draft15] { + let log = advertise_with_hidden(Discovery { + version, + solicit: Some(false), + ..Discovery::default() + }) + .await; + assert_eq!( + occurrences(&log, b"cam"), + 1, + "{version:?} still answers only with PUBLISH_NAMESPACE" + ); + } + } + + /// A peer that did not send SOLICIT still gets NAMESPACE on its SUBSCRIBE_NAMESPACE + /// stream on draft-16 and later: one for a match that already exists, one announced + /// after, then NAMESPACE_DONE when that announcement ends. + #[moq_net_sim::test] + async fn a_non_solicit_subscribe_namespace_carries_namespace() { + for version in [Version::Draft16, Version::Draft18] { + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let early = origin.announce("early-cam", crate::origin::Route::default()).unwrap(); + settle().await; + + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![Vec::new()]); + let log = session.log.clone(); + + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + + let publisher = Publisher::new( + crate::time::Clock::sim(), + session.clone(), + origin.consume(), + Control::new(None, false), + None, + peer_setup, + version, + ); + + let stream = Stream::open(&mut session.clone(), version).await.unwrap(); + let msg = ietf::SubscribeNamespace { + request_id: RequestId(1), + namespace: crate::Path::new(""), + hidden: false, + }; + let mut run = std::pin::pin!(publisher.run_subscribe_namespace_stream(stream, msg)); + + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, b"early-cam") >= 1 { + break; + } + settle().await; + } + let bytes = log.writes.lock().unwrap().clone(); + assert_eq!( + namespace_events(&bytes, version), + vec![(ietf::Namespace::ID, "early-cam".to_owned())], + "{version:?}: existing match", + ); + + let _late = origin.announce("late-cam", crate::origin::Route::default()).unwrap(); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, b"late-cam") >= 1 { + break; + } + settle().await; + } + let bytes = log.writes.lock().unwrap().clone(); + assert_eq!( + namespace_events(&bytes, version), + vec![ + (ietf::Namespace::ID, "early-cam".to_owned()), + (ietf::Namespace::ID, "late-cam".to_owned()), + ], + "{version:?}: announced later", + ); + + drop(early); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, b"early-cam") >= 2 { + break; + } + settle().await; + } + let bytes = log.writes.lock().unwrap().clone(); + assert_eq!( + namespace_events(&bytes, version), + vec![ + (ietf::Namespace::ID, "early-cam".to_owned()), + (ietf::Namespace::ID, "late-cam".to_owned()), + (ietf::NamespaceDone::ID, "early-cam".to_owned()), + ], + "{version:?}: NAMESPACE_DONE when it ends", + ); + } + } + + /// A peer out of stream credit parks the open. That must not wedge the loop, because + /// the withdrawals queued behind it are the only thing that frees a slot: an open that + /// never gives up is a deadlock, not a delay. + /// + /// Draft-14 so the withdrawal names its namespace on the wire, which is what makes the + /// loop's progress visible while every open is blocked. + #[moq_net_sim::test] + async fn a_parked_open_still_lets_a_namespace_be_withdrawn() { + const VERSION: Version = Version::Draft14; + + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let first = origin.announce("first-cam", crate::origin::Route::default()).unwrap(); + settle().await; + + // Open: the peer still has credit for the first advertisement, and answers it. + let gate = kio::Producer::new(true); + let ok = publish_namespace_ok(VERSION).await; + let session = crate::lite::test_transport::ScriptedSession::gated_open(vec![ok.clone(), ok], gate.consume()); + let log = session.log.clone(); + + let publisher = Publisher::new( + crate::time::Clock::sim(), + session, + origin.consume(), + Control::new(None, false), + None, + declared(Some(false)), + VERSION, + ); + + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, b"first-cam") > 0 { + break; + } + settle().await; + } + assert_eq!( + occurrences(&log, b"first-cam"), + 1, + "the first advertisement never went out" + ); + + // Credit runs out, and a second namespace wants a stream we cannot get. + set_gate(&gate, false); + let _second = origin.announce("second-cam", crate::origin::Route::default()).unwrap(); + settle().await; + + // Retiring the first frees a slot and needs no new stream, so the loop has to reach + // it despite the open above. + drop(first); + + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, b"first-cam") >= 2 { + break; + } + tick().await; + } + assert_eq!( + occurrences(&log, b"first-cam"), + 2, + "PUBLISH_NAMESPACE_DONE never sent: the open wedged the loop" + ); + + // Credit returns, and nothing else about the origin changes. + set_gate(&gate, true); + + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, b"second-cam") > 0 { + break; + } + tick().await; + } + assert_eq!( + occurrences(&log, b"second-cam"), + 1, + "never retried once credit returned" + ); + } + + /// A namespace nobody can advertise any more is not pending, whatever happened before. + /// `deferred` outliving the want would arm the retry timer forever for a wire message + /// that can never happen: not a spin, but a session that never sleeps. + #[moq_net_sim::test] + async fn a_namespace_that_stops_being_advertisable_stops_being_deferred() { + let assigned = crate::Hop::new(777).unwrap(); + + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + + // A route that already passed through us must never be forwarded: `select` + // wants nothing, which is what the peer already holds. + let mut hops = crate::Hops::new(); + hops.push(crate::Hop::new(1).unwrap()).unwrap(); + + let session = crate::lite::test_transport::SinkSession::new(Default::default()); + let publisher = Publisher::new( + crate::time::Clock::sim(), + session, + origin.consume(), + Control::new(None, false), + Some(assigned), + declared(Some(false)), + Version::Draft17, + ); + + // The state a refused or failed offer leaves behind: the peer holds nothing, and + // the loop is coming back to it on a timer. + let suffix: crate::PathOwned = crate::Path::new("from/peer").to_owned(); + let mut watch = Watched::new(crate::origin::Route::default().with_hops(hops)); + watch.deferred = true; + + let mut ns = Namespaces::new(cluster::Peer::default(), Target::Requests(None)); + ns.watched.insert(suffix.clone(), watch); + + publisher.sync_namespace(&mut ns, &suffix, &suffix).await.unwrap(); + + assert!( + !ns.watched[&suffix].deferred, + "the retry timer stays armed for a namespace that can never be advertised" + ); + } + + /// A minimum wait binds every path back to the namespace, not just the retry sweep. + /// A route change re-prices the advertisement; it does not excuse us from the wait the + /// peer asked for. + #[moq_net_sim::test] + async fn a_route_change_still_waits_out_a_refusal() { + const VERSION: Version = Version::Draft17; + + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let cam = origin.announce("solo-cam", crate::origin::Route::default()).unwrap(); + settle().await; + + // Refused with a wait far longer than any backoff the loop would take on its own. + let refusal = publish_namespace_error(VERSION, 600_000).await; + let session = + crate::lite::test_transport::ScriptedSession::per_stream(vec![refusal.clone(), refusal.clone(), refusal]); + let log = session.log.clone(); + + let publisher = Publisher::new( + crate::time::Clock::sim(), + session, + origin.consume(), + Control::new(None, false), + None, + declared(Some(false)), + VERSION, + ); + + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, b"solo-cam") > 0 { + break; + } + settle().await; + } + assert_eq!(occurrences(&log, b"solo-cam"), 1, "the advertisement never went out"); + + // A cheaper second route makes the advertisement worth re-pricing, which is a + // path back into the reconciliation that does not go through the retry timer. + let _standby = origin + .announce("solo-cam", crate::origin::Route::default().with_cost(0)) + .unwrap(); + + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + tick().await; + } + + assert_eq!( + occurrences(&log, b"solo-cam"), + 1, + "re-offered inside the wait the peer asked for" + ); + + drop(cam); + } + + /// A SETUP slot for a peer that negotiated the cluster extension, so an + /// advertisement carries a path and a cost worth repricing. + fn clustered(solicit: Option) -> peer::PeerSetup { + let slot = peer::PeerSetup::default(); + slot.set(peer::Peer { + cluster: cluster::Peer { + hop: Some(crate::Hop::new(9).unwrap()), + cost: None, + }, + solicit, + hidden: false, + auth: false, + active_count: false, + }); + slot + } + + /// The bytes of one REQUEST_UPDATE, framed as the publisher writes it. + async fn request_update(version: Version, msg: &ietf::PublishNamespaceUpdate) -> Vec { + let log = crate::lite::test_transport::Log::default(); + let mut writer = crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); + writer.varint(ietf::PublishNamespaceUpdate::ID).await.unwrap(); + writer.encode(msg).await.unwrap(); + log.writes.lock().unwrap().clone() + } + + /// A relay that starts carrying a namespace reprices it with REQUEST_UPDATE on the + /// request that already carries it, sending the changed parameter only. The new cost + /// is 0, which has to be explicit: REQUEST_UPDATE keeps an omitted parameter, so + /// leaving it out would keep the old price. + #[moq_net_sim::test] + async fn a_repricing_is_a_request_update() { + const VERSION: Version = Version::Draft19; + + // Forward the update at once: the hold is not what this checks. + let origin = crate::origin::Config { + update_hold: Duration::ZERO, + ..crate::origin::Config::new(crate::Hop::new(1).unwrap()) + } + .produce(); + let _cold = origin + .announce("cam", crate::origin::Route::default().with_cost(4)) + .unwrap(); + settle().await; + + // One stream: the PUBLISH_NAMESPACE request and its update, each answered OK. + let ok = publish_namespace_ok(VERSION).await; + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![[ok.clone(), ok].concat()]); + let log = session.log.clone(); + + let publisher = Publisher::new( + crate::time::Clock::sim(), + session, + origin.consume(), + Control::new(None, false), + None, + clustered(Some(false)), + VERSION, + ); + + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, b"cam") >= 1 { + break; + } + settle().await; + } + assert_eq!(occurrences(&log, b"cam"), 1, "the advertisement never went out"); + + // We start carrying it: a free route outranks the cold one. + let _warm = origin + .announce("cam", crate::origin::Route::default().with_cost(0)) + .unwrap(); + + // The request consumed id 1, so the update takes the next of our parity. The + // path is unchanged and omitted; the cost is an explicit 0. + let expected = request_update( + VERSION, + &ietf::PublishNamespaceUpdate { + request_id: RequestId(3), + hops: None, + cost: Some(0), + authorization_token: None, + }, + ) + .await; + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, &expected) >= 1 { + break; + } + settle().await; + } + + assert_eq!(occurrences(&log, &expected), 1, "REQUEST_UPDATE with an explicit 0"); + assert_eq!(occurrences(&log, b"cam"), 1, "PUBLISH_NAMESPACE was not repeated"); + assert_eq!(log.bi_opens(), 1, "the update rode the request's own stream"); + } + + /// ACTIVE_COUNT only answers SUBSCRIBE_NAMESPACE (MoQ Active Count), so one on the OK + /// to a REQUEST_UPDATE is the peer breaking the extension too. + #[moq_net_sim::test] + async fn a_counted_update_ok_is_a_violation() { + const VERSION: Version = Version::Draft19; + + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let _cold = origin + .announce("cam", crate::origin::Route::default().with_cost(4)) + .unwrap(); + settle().await; + + // The PUBLISH_NAMESPACE is answered cleanly; its update's OK carries a count. + let counted = crate::lite::test_transport::Log::default(); + let mut writer = + crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(counted.clone()), VERSION); + writer.varint(ietf::RequestOk::ID).await.unwrap(); + writer + .encode(&ietf::RequestOk { + request_id: None, + active: Some(0), + }) + .await + .unwrap(); + let counted = counted.writes.lock().unwrap().clone(); + let ok = publish_namespace_ok(VERSION).await; + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![[ok, counted].concat()]); + let log = session.log.clone(); + + let publisher = Publisher::new( + crate::time::Clock::sim(), + session, + origin.consume(), + Control::new(None, false), + None, + clustered(Some(false)), + VERSION, + ); + + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, b"cam") >= 1 { + break; + } + settle().await; + } + assert_eq!(occurrences(&log, b"cam"), 1, "the advertisement never went out"); + + // Repricing sends the REQUEST_UPDATE whose OK is counted. + let _warm = origin + .announce("cam", crate::origin::Route::default().with_cost(0)) + .unwrap(); + + let res = moq_net_sim::timeout(Duration::from_secs(5), run) + .await + .expect("the violation ends the loop"); + assert!(matches!(res, Err(Error::ProtocolViolation)), "{res:?}"); + // Closed at the source, since the solicited path's request task only logs errors. + let code = crate::SessionError::ProtocolViolation.to_code(); + assert!(log.closes().iter().any(|(c, _)| *c == code), "{:?}", log.closes()); + } + + /// A route from a different original publisher updates the advertisement in place, + /// like any other change: withdrawing it would make the namespace briefly vanish + /// downstream just because its publisher moved. + #[moq_net_sim::test] + async fn a_publisher_change_is_a_request_update() { + const VERSION: Version = Version::Draft19; + + // Forward the update at once: the hold is not what this checks. + let origin = crate::origin::Config { + update_hold: Duration::ZERO, + ..crate::origin::Config::new(crate::Hop::new(1).unwrap()) + } + .produce(); + let publisher_a = crate::Hops::try_from(vec![crate::Hop::new(7).unwrap()]).unwrap(); + let publisher_b = crate::Hops::try_from(vec![crate::Hop::new(8).unwrap()]).unwrap(); + let _from_a = origin + .announce( + "cam", + crate::origin::Route::default().with_hops(publisher_a).with_cost(4), + ) + .unwrap(); + settle().await; + + // One stream: the advertisement from A and its update to B, each answered OK. + let ok = publish_namespace_ok(VERSION).await; + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![[ok.clone(), ok].concat()]); + let log = session.log.clone(); + + let publisher = Publisher::new( + crate::time::Clock::sim(), + session, + origin.consume(), + Control::new(None, false), + None, + clustered(Some(false)), + VERSION, + ); + + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, b"cam") >= 1 { + break; + } + settle().await; + } + assert_eq!(occurrences(&log, b"cam"), 1, "the advertisement never went out"); + + // A cheaper route from B wins the selection. + let _from_b = origin + .announce( + "cam", + crate::origin::Route::default().with_hops(publisher_b).with_cost(0), + ) + .unwrap(); + let update = request_update( + VERSION, + &ietf::PublishNamespaceUpdate { + request_id: RequestId(3), + hops: Some(cluster::HopPath::new( + crate::Hops::try_from(vec![crate::Hop::new(8).unwrap(), crate::Hop::new(1).unwrap()]).unwrap(), + )), + cost: Some(0), + authorization_token: None, + }, + ) + .await; + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, &update) >= 1 { + break; } - let bytes = log.writes.lock().unwrap().clone(); - assert_eq!( - namespace_events(&bytes, version), - vec![ - (ietf::Namespace::ID, "early-cam".to_owned()), - (ietf::Namespace::ID, "late-cam".to_owned()), - (ietf::NamespaceDone::ID, "early-cam".to_owned()), - ], - "{version:?}: NAMESPACE_DONE when it ends", - ); + settle().await; } + + assert_eq!(occurrences(&log, &update), 1, "REQUEST_UPDATE carrying B's path"); + assert_eq!(occurrences(&log, b"cam"), 1, "PUBLISH_NAMESPACE was not repeated"); + assert_eq!(log.bi_opens(), 1, "no withdrawal and no second stream"); } - /// A peer out of stream credit parks the open. That must not wedge the loop, because - /// the withdrawals queued behind it are the only thing that frees a slot: an open that - /// never gives up is a deadlock, not a delay. - /// - /// Draft-14 so the withdrawal names its namespace on the wire, which is what makes the - /// loop's progress visible while every open is blocked. + /// A peer that refuses an update closes the stream, which withdraws the + /// advertisement. The namespace is then not held at all, so it comes back as a fresh + /// PUBLISH_NAMESPACE once the refusal's wait is out, not as another update on a + /// stream the peer already ended. #[moq_net_sim::test] - async fn a_parked_open_still_lets_a_namespace_be_withdrawn() { - const VERSION: Version = Version::Draft14; + async fn a_refused_update_is_re_advertised_fresh() { + const VERSION: Version = Version::Draft19; let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let first = origin.announce("first-cam", crate::origin::Route::default()).unwrap(); + let _cold = origin + .announce("cam", crate::origin::Route::default().with_cost(4)) + .unwrap(); settle().await; - // Open: the peer still has credit for the first advertisement, and answers it. - let gate = kio::Producer::new(true); + // Stream 1 accepts the advertisement and refuses its update, with a wait shorter + // than the retry sweep; stream 2 accepts the fresh advertisement. let ok = publish_namespace_ok(VERSION).await; - let session = crate::lite::test_transport::ScriptedSession::gated_open(vec![ok.clone(), ok], gate.consume()); + let refusal = publish_namespace_error(VERSION, 50).await; + let session = + crate::lite::test_transport::ScriptedSession::per_stream(vec![[ok.clone(), refusal].concat(), ok]); let log = session.log.clone(); let publisher = Publisher::new( @@ -7113,209 +10065,205 @@ mod tests { origin.consume(), Control::new(None, false), None, - declared(Some(false)), + clustered(Some(false)), VERSION, ); let mut run = std::pin::pin!(publisher.run_publish_namespaces()); for _ in 0..100 { assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"first-cam") > 0 { + if occurrences(&log, b"cam") >= 1 { break; } settle().await; } - assert_eq!( - occurrences(&log, b"first-cam"), - 1, - "the first advertisement never went out" - ); - - // Credit runs out, and a second namespace wants a stream we cannot get. - set_gate(&gate, false); - let _second = origin.announce("second-cam", crate::origin::Route::default()).unwrap(); - settle().await; + assert_eq!(occurrences(&log, b"cam"), 1, "the advertisement never went out"); - // Retiring the first frees a slot and needs no new stream, so the loop has to reach - // it despite the open above. - drop(first); + let _warm = origin + .announce("cam", crate::origin::Route::default().with_cost(0)) + .unwrap(); for _ in 0..100 { assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"first-cam") >= 2 { + if occurrences(&log, b"cam") >= 2 { break; } tick().await; } - assert_eq!( - occurrences(&log, b"first-cam"), - 2, - "PUBLISH_NAMESPACE_DONE never sent: the open wedged the loop" - ); - - // Credit returns, and nothing else about the origin changes. - set_gate(&gate, true); - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"second-cam") > 0 { - break; - } - tick().await; - } - assert_eq!( - occurrences(&log, b"second-cam"), - 1, - "never retried once credit returned" - ); + assert_eq!(occurrences(&log, b"cam"), 2, "re-advertised after the refusal"); + assert_eq!(log.bi_opens(), 2, "on a fresh request stream"); + let update = request_update( + VERSION, + &ietf::PublishNamespaceUpdate { + request_id: RequestId(3), + hops: None, + cost: Some(0), + authorization_token: None, + }, + ) + .await; + assert_eq!(occurrences(&log, &update), 1, "only the one update was attempted"); } - /// A namespace nobody can advertise any more is not pending, whatever happened before. - /// `deferred` outliving the want would arm the retry timer forever for a wire message - /// that can never happen: not a spin, but a session that never sleeps. + /// Draft-17+ has no PUBLISH_NAMESPACE_DONE, so a withdrawal there is the FIN and + /// nothing else. Writing the message anyway puts its type on the wire before the body + /// fails to encode, which the receiver can only read as a protocol violation, so every + /// unannounce would kill an otherwise healthy session. + /// + /// Only reachable through the unsolicited loop, which is what this branch made the + /// default: the solicited path answers inline and never opens a request per namespace. #[moq_net_sim::test] - async fn a_namespace_that_stops_being_advertisable_stops_being_deferred() { - let assigned = crate::Hop::new(777).unwrap(); + async fn a_modern_withdrawal_is_the_fin_alone() { + const VERSION: Version = Version::Draft17; let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let cam = origin.announce("solo-cam", crate::origin::Route::default()).unwrap(); + settle().await; - // A route that already passed through us must never be forwarded: `select` - // wants nothing, which is what the peer already holds. - let mut hops = crate::Hops::new(); - hops.push(crate::Hop::new(1).unwrap()).unwrap(); + let session = + crate::lite::test_transport::ScriptedSession::per_stream(vec![publish_namespace_ok(VERSION).await]); + let log = session.log.clone(); - let session = crate::lite::test_transport::SinkSession::new(Default::default()); let publisher = Publisher::new( crate::time::Clock::sim(), session, origin.consume(), Control::new(None, false), - Some(assigned), - declared(Some(false)), - Version::Draft17, + None, + declared(None), + VERSION, ); - // The state a refused or failed offer leaves behind: the peer holds nothing, and - // the loop is coming back to it on a timer. - let suffix: crate::PathOwned = crate::Path::new("from/peer").to_owned(); - let mut watch = Watched::new(crate::origin::Route::default().with_hops(hops)); - watch.deferred = true; + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, b"solo-cam") > 0 { + break; + } + settle().await; + } + assert_eq!(occurrences(&log, b"solo-cam"), 1, "the advertisement never went out"); - let mut ns = Namespaces::new(cluster::Peer::default(), Target::Requests(None)); - ns.watched.insert(suffix.clone(), watch); + let advertised = log.writes.lock().unwrap().len(); - publisher.sync_namespace(&mut ns, &suffix, &suffix).await.unwrap(); + // Unannounce, which retires the request the advertisement opened. + drop(cam); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + settle().await; + } - assert!( - !ns.watched[&suffix].deferred, - "the retry timer stays armed for a namespace that can never be advertised" + assert_eq!( + log.writes.lock().unwrap().len(), + advertised, + "a draft-17+ withdrawal wrote a message; the FIN alone retracts" ); } - /// A minimum wait binds every path back to the namespace, not just the retry sweep. - /// A route change re-prices the advertisement; it does not excuse us from the wait the - /// peer asked for. #[moq_net_sim::test] - async fn a_route_change_still_waits_out_a_refusal() { - const VERSION: Version = Version::Draft17; - + async fn close_withdraws_legacy_namespaces_without_closing_the_origin() { + const VERSION: Version = Version::Draft14; let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let cam = origin.announce("solo-cam", crate::origin::Route::default()).unwrap(); + let _cam = origin.announce("closing-cam", crate::origin::Route::default()).unwrap(); settle().await; - - // Refused with a wait far longer than any backoff the loop would take on its own. - let refusal = publish_namespace_error(VERSION, 600_000).await; let session = - crate::lite::test_transport::ScriptedSession::per_stream(vec![refusal.clone(), refusal.clone(), refusal]); + crate::lite::test_transport::ScriptedSession::per_stream(vec![publish_namespace_ok(VERSION).await]); let log = session.log.clone(); - let publisher = Publisher::new( crate::time::Clock::sim(), session, origin.consume(), Control::new(None, false), None, - declared(Some(false)), + declared(None), VERSION, ); - - let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + let mut run = std::pin::pin!(publisher.clone().run_publish_namespaces()); for _ in 0..100 { assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"solo-cam") > 0 { + if occurrences(&log, b"closing-cam") > 0 { break; } settle().await; } - assert_eq!(occurrences(&log, b"solo-cam"), 1, "the advertisement never went out"); + assert_eq!(occurrences(&log, b"closing-cam"), 1); + assert!(!publisher.withdrawal.drained()); + publisher.withdrawal.begin(); + run.await.unwrap(); + assert!(publisher.withdrawal.drained()); + assert_eq!( + occurrences(&log, b"closing-cam"), + 2, + "PUBLISH_NAMESPACE_DONE names the withdrawn namespace" + ); + } - // A cheaper second route makes the advertisement worth re-pricing, which is a - // path back into the reconciliation that does not go through the retry timer. - let _standby = origin - .announce("solo-cam", crate::origin::Route::default().with_cost(0)) - .unwrap(); + /// The peer granting a stream is only half the exchange. One it accepts and then never + /// answers on wedges the loop exactly as a parked open does, so the response is bounded + /// too: everything queued behind it is otherwise stranded for the session. + /// + /// Draft-14 so each advertisement names its namespace on the wire. + #[moq_net_sim::test] + async fn a_silent_answer_still_lets_the_next_namespace_be_advertised() { + const VERSION: Version = Version::Draft14; - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - tick().await; - } + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let _first = origin.announce("first-cam", crate::origin::Route::default()).unwrap(); + let _second = origin.announce("second-cam", crate::origin::Route::default()).unwrap(); + settle().await; - assert_eq!( - occurrences(&log, b"solo-cam"), - 1, - "re-offered inside the wait the peer asked for" - ); + // Every stream opens and then goes silent: an exhausted script parks rather than + // reporting EOF, which is the peer that takes the request and answers nothing. + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![Vec::new(), Vec::new()]); + let log = session.log.clone(); - drop(cam); - } + let publisher = Publisher::new( + crate::time::Clock::sim(), + session, + origin.consume(), + Control::new(None, false), + None, + declared(Some(false)), + VERSION, + ); - /// A SETUP slot for a peer that negotiated the cluster extension, so an - /// advertisement carries a path and a cost worth repricing. - fn clustered(solicit: Option) -> peer::PeerSetup { - let slot = peer::PeerSetup::default(); - slot.set(peer::Peer { - cluster: cluster::Peer { - hop: Some(crate::Hop::new(9).unwrap()), - cost: None, - }, - solicit, - hidden: false, - active_count: false, - }); - slot - } + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + for _ in 0..200 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, b"first-cam") > 0 && occurrences(&log, b"second-cam") > 0 { + break; + } + tick().await; + } - /// The bytes of one REQUEST_UPDATE, framed as the publisher writes it. - async fn request_update(version: Version, msg: &ietf::PublishNamespaceUpdate) -> Vec { - let log = crate::lite::test_transport::Log::default(); - let mut writer = crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); - writer.varint(ietf::PublishNamespaceUpdate::ID).await.unwrap(); - writer.encode(msg).await.unwrap(); - log.writes.lock().unwrap().clone() + // Whichever went first is the one that stalled, so both having reached the wire is + // the proof: the loop gave up on the answer and carried on. + assert!( + occurrences(&log, b"first-cam") > 0, + "the first advertisement never went out" + ); + assert!( + occurrences(&log, b"second-cam") > 0, + "the silent answer wedged the loop: the second namespace never went out" + ); } - /// A relay that starts carrying a namespace reprices it with REQUEST_UPDATE on the - /// request that already carries it, sending the changed parameter only. The new cost - /// is 0, which has to be explicit: REQUEST_UPDATE keeps an omitted parameter, so - /// leaving it out would keep the old price. + /// Credit returning raises no signal of its own: no announce, no route change, nothing + /// the loop is watching. Only a retry brings the namespace back, and without one it + /// stays undiscoverable for the life of the session. #[moq_net_sim::test] - async fn a_repricing_is_a_request_update() { - const VERSION: Version = Version::Draft19; + async fn a_namespace_refused_a_stream_is_retried_on_its_own() { + const VERSION: Version = Version::Draft14; - // Forward the update at once: the hold is not what this checks. - let origin = crate::origin::Config { - update_hold: Duration::ZERO, - ..crate::origin::Config::new(crate::Hop::new(1).unwrap()) - } - .produce(); - let _cold = origin - .announce("cam", crate::origin::Route::default().with_cost(4)) - .unwrap(); + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let _cam = origin.announce("lonely-cam", crate::origin::Route::default()).unwrap(); settle().await; - // One stream: the PUBLISH_NAMESPACE request and its update, each answered OK. + // Closed from the start: the peer has granted nothing. + let gate = kio::Producer::new(false); let ok = publish_namespace_ok(VERSION).await; - let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![[ok.clone(), ok].concat()]); + let session = crate::lite::test_transport::ScriptedSession::gated_open(vec![ok], gate.consume()); let log = session.log.clone(); let publisher = Publisher::new( @@ -7324,617 +10272,606 @@ mod tests { origin.consume(), Control::new(None, false), None, - clustered(Some(false)), + declared(Some(false)), VERSION, ); let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + + // Well past the point where the open gives up. for _ in 0..100 { assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"cam") >= 1 { - break; - } - settle().await; + tick().await; } - assert_eq!(occurrences(&log, b"cam"), 1, "the advertisement never went out"); + assert_eq!(occurrences(&log, b"lonely-cam"), 0, "advertised without a stream"); - // We start carrying it: a free route outranks the cold one. - let _warm = origin - .announce("cam", crate::origin::Route::default().with_cost(0)) - .unwrap(); + // Credit returns. Nothing else changes: no publish, no unannounce, no route move. + set_gate(&gate, true); - // The request consumed id 1, so the update takes the next of our parity. The - // path is unchanged and omitted; the cost is an explicit 0. - let expected = request_update( - VERSION, - &ietf::PublishNamespaceUpdate { - request_id: RequestId(3), - hops: None, - cost: Some(0), - }, - ) - .await; for _ in 0..100 { assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, &expected) >= 1 { + if occurrences(&log, b"lonely-cam") > 0 { break; } - settle().await; + tick().await; } + assert_eq!(occurrences(&log, b"lonely-cam"), 1, "never came back on its own"); + } - assert_eq!(occurrences(&log, &expected), 1, "REQUEST_UPDATE with an explicit 0"); - assert_eq!(occurrences(&log, b"cam"), 1, "PUBLISH_NAMESPACE was not repeated"); - assert_eq!(log.bi_opens(), 1, "the update rode the request's own stream"); + /// Advance far enough that a parked open gives up and its retry comes due, without + /// making the test wait: time is paused, so this only moves the clock the loop reads. + async fn tick() { + moq_net_sim::advance(Duration::from_millis(200)).await; } - /// ACTIVE_COUNT only answers SUBSCRIBE_NAMESPACE (MoQ Active Count), so one on the OK - /// to a REQUEST_UPDATE is the peer breaking the extension too. - #[moq_net_sim::test] - async fn a_counted_update_ok_is_a_violation() { - const VERSION: Version = Version::Draft19; + fn set_gate(gate: &kio::Producer, open: bool) { + let Ok(mut gate) = gate.write() else { + panic!("gate closed") + }; + *gate = open; + } - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let _cold = origin - .announce("cam", crate::origin::Route::default().with_cost(4)) - .unwrap(); - settle().await; + /// A publisher talking to a scripted peer that never answers, over one bidi stream. + struct Harness { + publisher: Publisher, + session: crate::lite::test_transport::ScriptedSession, + log: crate::lite::test_transport::Log, + /// Keeps the origin alive; the publisher only holds a consumer. + _origin: origin::Producer, + } - // The PUBLISH_NAMESPACE is answered cleanly; its update's OK carries a count. - let counted = crate::lite::test_transport::Log::default(); - let mut writer = - crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(counted.clone()), VERSION); - writer.varint(ietf::RequestOk::ID).await.unwrap(); - writer - .encode(&ietf::RequestOk { - request_id: None, - active: Some(0), - }) - .await - .unwrap(); - let counted = counted.writes.lock().unwrap().clone(); - let ok = publish_namespace_ok(VERSION).await; - let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![[ok, counted].concat()]); + fn harness(version: Version) -> Harness { + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![Vec::new()]); let log = session.log.clone(); + // Serving a request blocks on the peer's SETUP, which no scripted peer sends here. + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + let publisher = Publisher::new( crate::time::Clock::sim(), - session, + session.clone(), origin.consume(), Control::new(None, false), None, - clustered(Some(false)), - VERSION, + peer_setup, + version, ); - let mut run = std::pin::pin!(publisher.run_publish_namespaces()); - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"cam") >= 1 { - break; + Harness { + publisher, + session, + log, + _origin: origin, + } + } + + /// Subscribe to a path nothing publishes, returning what the peer would read off the + /// request stream plus the reset codes the stream recorded. + async fn subscribe_missing(version: Version) -> (Vec, Vec) { + let h = harness(version); + + let stream = Stream::open(&mut h.session.clone(), version).await.unwrap(); + h.publisher + .clone() + .run_subscribe_stream( + stream, + ietf::Subscribe { + request_id: RequestId(1), + track_namespace: crate::Path::new("nothing/here"), + track_name: "video".into(), + subscriber_priority: 128, + group_order: GroupOrder::Descending, + filter: Filter::NextObject, + fill: None, + properties_wanted: true, + forward: true, + range_filters: false, + authorization_token: None, + }, + ) + .await + .unwrap(); + + let writes = h.log.writes.lock().unwrap().clone(); + (writes, h.log.resets()) + } + + /// Send a FETCH we refuse, returning the same pair. + async fn fetch_refused(version: Version, fetch_type: FetchType<'_>) -> (Vec, Vec) { + let h = harness(version); + + let stream = Stream::open(&mut h.session.clone(), version).await.unwrap(); + h.publisher + .clone() + .run_fetch_stream( + stream, + ietf::Fetch { + request_id: RequestId(1), + subscriber_priority: 128, + group_order: GroupOrder::Descending, + fetch_type, + range_filters: false, + fill_timeout: false, + properties_wanted: true, + }, + ) + .await + .unwrap(); + + let writes = h.log.writes.lock().unwrap().clone(); + (writes, h.log.resets()) + } + + /// A SUBSCRIBE for a path with no publisher is refused with REQUEST_ERROR, and the refusal + /// has to survive the trip. `Writer` resets the stream on drop, and a reset that races the + /// write discards the bytes the peer has not read yet, which leaves the subscriber waiting + /// on a request we already refused. Finishing first makes the drop-time reset a no-op. + #[moq_net_sim::test] + async fn missing_broadcast_is_refused_without_resetting_the_stream() { + for version in [Version::Draft17, Version::Draft18, Version::Draft19, Version::Draft20] { + let (writes, resets) = subscribe_missing(version).await; + + assert!(!writes.is_empty(), "{version}: nothing was sent"); + assert_eq!( + writes[0], + ietf::RequestError::ID as u8, + "{version}: not a REQUEST_ERROR" + ); + assert!(resets.is_empty(), "{version}: stream reset, discarding the error"); + } + } + + /// Every FETCH we refuse goes out through its own error encoder, so it needs the same + /// finish: a reset there loses the rejection the same way. + #[moq_net_sim::test] + async fn a_refused_fetch_does_not_reset_the_stream() { + let refused = || { + [ + ( + "standalone", + FetchType::Standalone { + namespace: crate::Path::new("nothing/here"), + track: "video".into(), + start: Location { group: 0, object: 0 }, + end: Location { group: 1, object: 0 }, + }, + ), + ( + "empty range", + FetchType::Standalone { + namespace: crate::Path::new("nothing/here"), + track: "video".into(), + start: Location { group: 2, object: 0 }, + end: Location { group: 1, object: 0 }, + }, + ), + ] + }; + + for version in [Version::Draft17, Version::Draft18, Version::Draft19, Version::Draft20] { + for (label, fetch_type) in refused() { + let (writes, resets) = fetch_refused(version, fetch_type).await; + + assert!(!writes.is_empty(), "{version} {label}: nothing was sent"); + assert_eq!( + writes[0], + ietf::RequestError::ID as u8, + "{version} {label}: not a REQUEST_ERROR" + ); + assert!( + resets.is_empty(), + "{version} {label}: stream reset, discarding the error" + ); } - settle().await; } - assert_eq!(occurrences(&log, b"cam"), 1, "the advertisement never went out"); - - // Repricing sends the REQUEST_UPDATE whose OK is counted. - let _warm = origin - .announce("cam", crate::origin::Route::default().with_cost(0)) - .unwrap(); - - let res = moq_net_sim::timeout(Duration::from_secs(5), run) - .await - .expect("the violation ends the loop"); - assert!(matches!(res, Err(Error::ProtocolViolation)), "{res:?}"); - // Closed at the source, since the solicited path's request task only logs errors. - let code = crate::SessionError::ProtocolViolation.to_code(); - assert!(log.closes().iter().any(|(c, _)| *c == code), "{:?}", log.closes()); } - /// A route from a different original publisher updates the advertisement in place, - /// like any other change: withdrawing it would make the namespace briefly vanish - /// downstream just because its publisher moved. + /// A client configured with a request token puts it on the PUBLISH_NAMESPACE it sends, on + /// a legacy draft (draft-14 trailing block) and a strict one (draft-18 message + /// parameters). Without `with_request_token` the token bytes never reach the wire. #[moq_net_sim::test] - async fn a_publisher_change_is_a_request_update() { - const VERSION: Version = Version::Draft19; - - // Forward the update at once: the hold is not what this checks. - let origin = crate::origin::Config { - update_hold: Duration::ZERO, - ..crate::origin::Config::new(crate::Hop::new(1).unwrap()) - } - .produce(); - let publisher_a = crate::Hops::try_from(vec![crate::Hop::new(7).unwrap()]).unwrap(); - let publisher_b = crate::Hops::try_from(vec![crate::Hop::new(8).unwrap()]).unwrap(); - let _from_a = origin - .announce( - "cam", - crate::origin::Route::default().with_hops(publisher_a).with_cost(4), - ) - .unwrap(); - settle().await; - - // One stream: the advertisement from A and its update to B, each answered OK. - let ok = publish_namespace_ok(VERSION).await; - let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![[ok.clone(), ok].concat()]); - let log = session.log.clone(); - - let publisher = Publisher::new( - crate::time::Clock::sim(), - session, - origin.consume(), - Control::new(None, false), - None, - clustered(Some(false)), - VERSION, - ); - - let mut run = std::pin::pin!(publisher.run_publish_namespaces()); - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"cam") >= 1 { - break; - } + async fn a_configured_request_token_rides_the_publish_namespace() { + let token = bytes::Bytes::from_static(&[0x03, 0x00, 0xfe, 0xed]); + for version in [ + Version::Draft14, + Version::Draft15, + Version::Draft16, + Version::Draft17, + Version::Draft18, + ] { + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); settle().await; - } - assert_eq!(occurrences(&log, b"cam"), 1, "the advertisement never went out"); - // A cheaper route from B wins the selection. - let _from_b = origin - .announce( - "cam", - crate::origin::Route::default().with_hops(publisher_b).with_cost(0), + let session = + crate::lite::test_transport::ScriptedSession::per_stream(vec![publish_namespace_ok(version).await]); + let log = session.log.clone(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + + let publisher = Publisher::new( + crate::time::Clock::sim(), + session, + origin.consume(), + Control::new(None, false), + None, + peer_setup, + version, ) - .unwrap(); - let update = request_update( - VERSION, - &ietf::PublishNamespaceUpdate { - request_id: RequestId(3), - hops: Some(cluster::HopPath::new( - crate::Hops::try_from(vec![crate::Hop::new(8).unwrap(), crate::Hop::new(1).unwrap()]).unwrap(), - )), - cost: Some(0), - }, - ) - .await; - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, &update) >= 1 { - break; + .with_request_token(crate::RequestToken::new(Some(typed(&token)))); + + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + let mut sent = false; + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, &token) >= 1 { + sent = true; + break; + } + settle().await; } - settle().await; + assert!(sent, "{version}: the request token must ride the PUBLISH_NAMESPACE"); } - - assert_eq!(occurrences(&log, &update), 1, "REQUEST_UPDATE carrying B's path"); - assert_eq!(occurrences(&log, b"cam"), 1, "PUBLISH_NAMESPACE was not repeated"); - assert_eq!(log.bi_opens(), 1, "no withdrawal and no second stream"); } - /// A peer that refuses an update closes the stream, which withdraws the - /// advertisement. The namespace is then not held at all, so it comes back as a fresh - /// PUBLISH_NAMESPACE once the refusal's wait is out, not as another update on a - /// stream the peer already ended. + /// Replacing the request token re-presents it on a live announce as a token-only + /// PUBLISH_NAMESPACE_UPDATE (MoQ request-token renewal), on the same stream, without a + /// reprice. Draft-17+, since earlier drafts have no PUBLISH_NAMESPACE_UPDATE. #[moq_net_sim::test] - async fn a_refused_update_is_re_advertised_fresh() { - const VERSION: Version = Version::Draft19; + async fn setting_a_new_request_token_re_presents_it_on_a_live_announce() { + const VERSION: Version = Version::Draft18; + let first = bytes::Bytes::from_static(&[0x03, 0x00, b'a', b'a']); + let second = bytes::Bytes::from_static(&[0x03, 0x00, b'b', b'b']); let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let _cold = origin - .announce("cam", crate::origin::Route::default().with_cost(4)) - .unwrap(); + let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); settle().await; - // Stream 1 accepts the advertisement and refuses its update, with a wait shorter - // than the retry sweep; stream 2 accepts the fresh advertisement. - let ok = publish_namespace_ok(VERSION).await; - let refusal = publish_namespace_error(VERSION, 50).await; - let session = - crate::lite::test_transport::ScriptedSession::per_stream(vec![[ok.clone(), refusal].concat(), ok]); + // One stream carries the announce: RequestOk answers the PUBLISH_NAMESPACE, then + // RequestOk answers the token-only REQUEST_UPDATE. + let mut script = publish_namespace_ok(VERSION).await; + script.extend(publish_namespace_ok(VERSION).await); + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![script]); let log = session.log.clone(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + let token = crate::RequestToken::new(Some(typed(&first))); let publisher = Publisher::new( crate::time::Clock::sim(), session, origin.consume(), Control::new(None, false), None, - clustered(Some(false)), + peer_setup, VERSION, - ); + ) + .with_request_token(token.clone()); let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + // The initial token rides the PUBLISH_NAMESPACE. + let mut initial = false; for _ in 0..100 { assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"cam") >= 1 { + if occurrences(&log, &first) >= 1 { + initial = true; break; } settle().await; } - assert_eq!(occurrences(&log, b"cam"), 1, "the advertisement never went out"); - - let _warm = origin - .announce("cam", crate::origin::Route::default().with_cost(0)) - .unwrap(); + assert!(initial, "the initial token must ride the PUBLISH_NAMESPACE"); + // Replacing it re-presents the new token on the live announce. + token.set(Some(typed(&second))); + let mut renewed = false; for _ in 0..100 { assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"cam") >= 2 { + if occurrences(&log, &second) >= 1 { + renewed = true; break; } - tick().await; + settle().await; } - - assert_eq!(occurrences(&log, b"cam"), 2, "re-advertised after the refusal"); - assert_eq!(log.bi_opens(), 2, "on a fresh request stream"); - let update = request_update( - VERSION, - &ietf::PublishNamespaceUpdate { - request_id: RequestId(3), - hops: None, - cost: Some(0), - }, - ) - .await; - assert_eq!(occurrences(&log, &update), 1, "only the one update was attempted"); + assert!(renewed, "a replaced token must be re-presented on the live announce"); } - /// Draft-17+ has no PUBLISH_NAMESPACE_DONE, so a withdrawal there is the FIN and - /// nothing else. Writing the message anyway puts its type on the wire before the body - /// fails to encode, which the receiver can only read as a protocol violation, so every - /// unannounce would kill an otherwise healthy session. - /// - /// Only reachable through the unsolicited loop, which is what this branch made the - /// default: the solicited path answers inline and never opens a request per namespace. + /// Setting the same token again is a no-op: no second REQUEST_UPDATE is sent, so the token + /// bytes appear once (the initial PUBLISH_NAMESPACE) and no more. #[moq_net_sim::test] - async fn a_modern_withdrawal_is_the_fin_alone() { - const VERSION: Version = Version::Draft17; + async fn an_unchanged_token_is_not_re_presented() { + const VERSION: Version = Version::Draft18; + let token_bytes = bytes::Bytes::from_static(&[0x03, 0x00, b'a', b'a']); let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let cam = origin.announce("solo-cam", crate::origin::Route::default()).unwrap(); + let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); settle().await; - let session = - crate::lite::test_transport::ScriptedSession::per_stream(vec![publish_namespace_ok(VERSION).await]); + let mut script = publish_namespace_ok(VERSION).await; + script.extend(publish_namespace_ok(VERSION).await); + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![script]); let log = session.log.clone(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + let token = crate::RequestToken::new(Some(typed(&token_bytes))); let publisher = Publisher::new( crate::time::Clock::sim(), session, origin.consume(), Control::new(None, false), None, - declared(None), + peer_setup, VERSION, - ); + ) + .with_request_token(token.clone()); let mut run = std::pin::pin!(publisher.run_publish_namespaces()); for _ in 0..100 { assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"solo-cam") > 0 { + if occurrences(&log, &token_bytes) >= 1 { break; } settle().await; } - assert_eq!(occurrences(&log, b"solo-cam"), 1, "the advertisement never went out"); - - let advertised = log.writes.lock().unwrap().len(); - - // Unannounce, which retires the request the advertisement opened. - drop(cam); - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - settle().await; - } - assert_eq!( - log.writes.lock().unwrap().len(), - advertised, - "a draft-17+ withdrawal wrote a message; the FIN alone retracts" + occurrences(&log, &token_bytes), + 1, + "the initial token rode the announce once" ); - } - #[moq_net_sim::test] - async fn close_withdraws_legacy_namespaces_without_closing_the_origin() { - const VERSION: Version = Version::Draft14; - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let _cam = origin.announce("closing-cam", crate::origin::Route::default()).unwrap(); - settle().await; - let session = - crate::lite::test_transport::ScriptedSession::per_stream(vec![publish_namespace_ok(VERSION).await]); - let log = session.log.clone(); - let publisher = Publisher::new( - crate::time::Clock::sim(), - session, - origin.consume(), - Control::new(None, false), - None, - declared(None), - VERSION, - ); - let mut run = std::pin::pin!(publisher.clone().run_publish_namespaces()); - for _ in 0..100 { + // Setting the same value again wakes the loop but changes nothing, so no REQUEST_UPDATE. + token.set(Some(typed(&token_bytes))); + for _ in 0..50 { assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"closing-cam") > 0 { - break; - } settle().await; } - assert_eq!(occurrences(&log, b"closing-cam"), 1); - assert!(!publisher.withdrawal.drained()); - publisher.withdrawal.begin(); - run.await.unwrap(); - assert!(publisher.withdrawal.drained()); assert_eq!( - occurrences(&log, b"closing-cam"), - 2, - "PUBLISH_NAMESPACE_DONE names the withdrawn namespace" + occurrences(&log, &token_bytes), + 1, + "an unchanged token must not be re-presented" ); } - /// The peer granting a stream is only half the exchange. One it accepts and then never - /// answers on wedges the loop exactly as a parked open does, so the response is bounded - /// too: everything queued behind it is otherwise stranded for the session. - /// - /// Draft-14 so each advertisement names its namespace on the wire. + /// A refused token refresh (REQUEST_ERROR with a retry interval) withdraws the announce and + /// re-offers it fresh, carrying the new token, once the interval passes. Without re-arming the + /// retry sweep (which only revisits deferred namespaces) the withdrawn namespace would never + /// come back. Draft-17+, since earlier drafts have no PUBLISH_NAMESPACE_UPDATE. #[moq_net_sim::test] - async fn a_silent_answer_still_lets_the_next_namespace_be_advertised() { - const VERSION: Version = Version::Draft14; + async fn a_refused_token_refresh_re_offers_the_namespace_with_the_new_token() { + const VERSION: Version = Version::Draft18; + let first = bytes::Bytes::from_static(&[0x03, 0x00, b'a', b'a']); + let second = bytes::Bytes::from_static(&[0x03, 0x00, b'b', b'b']); let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let _first = origin.announce("first-cam", crate::origin::Route::default()).unwrap(); - let _second = origin.announce("second-cam", crate::origin::Route::default()).unwrap(); + let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); settle().await; - // Every stream opens and then goes silent: an exhausted script parks rather than - // reporting EOF, which is the peer that takes the request and answers nothing. - let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![Vec::new(), Vec::new()]); + // Stream 1: the PUBLISH_NAMESPACE is accepted, then the token-refresh REQUEST_UPDATE is + // refused with a 1s retry interval. Stream 2: the re-offer is accepted. + let mut stream1 = publish_namespace_ok(VERSION).await; + stream1.extend(publish_namespace_error(VERSION, 1000).await); + let stream2 = publish_namespace_ok(VERSION).await; + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![stream1, stream2]); let log = session.log.clone(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + let token = crate::RequestToken::new(Some(typed(&first))); let publisher = Publisher::new( crate::time::Clock::sim(), session, origin.consume(), Control::new(None, false), None, - declared(Some(false)), + peer_setup, VERSION, - ); + ) + .with_request_token(token.clone()); let mut run = std::pin::pin!(publisher.run_publish_namespaces()); - for _ in 0..200 { + for _ in 0..100 { assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"first-cam") > 0 && occurrences(&log, b"second-cam") > 0 { + if occurrences(&log, &first) >= 1 { break; } - tick().await; + settle().await; } + assert_eq!(occurrences(&log, &first), 1, "the initial token rode the announce"); - // Whichever went first is the one that stalled, so both having reached the wire is - // the proof: the loop gave up on the answer and carried on. + // Replace the token: the refresh REQUEST_UPDATE carries it and the peer refuses it, + // closing the stream. + token.set(Some(typed(&second))); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, &second) >= 1 { + break; + } + settle().await; + } + assert_eq!( + occurrences(&log, &second), + 1, + "the refused refresh carried the new token once" + ); + + // Past the 1s retry interval, the withdrawn namespace is re-offered on a fresh stream, + // carrying the new token. + moq_net_sim::advance(Duration::from_secs(2)).await; + let mut reoffered = false; + for _ in 0..300 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, &second) >= 2 { + reoffered = true; + break; + } + settle().await; + } assert!( - occurrences(&log, b"first-cam") > 0, - "the first advertisement never went out" + reoffered, + "a refused refresh must re-offer the namespace with the new token after the interval" ); assert!( - occurrences(&log, b"second-cam") > 0, - "the silent answer wedged the loop: the second namespace never went out" + log.bi_opens() >= 2, + "the re-offer opened a fresh PUBLISH_NAMESPACE stream" ); } - /// Credit returning raises no signal of its own: no announce, no route change, nothing - /// the loop is watching. Only a retry brings the namespace back, and without one it - /// stays undiscoverable for the life of the session. + /// A token refresh the peer never answers drops the announce's stream and re-offers the + /// namespace fresh, carrying the new token. Without forgetting the sent advertisement, + /// reconcile would see it unchanged and the namespace would stay withdrawn. Draft-17+, + /// since earlier drafts have no PUBLISH_NAMESPACE_UPDATE. #[moq_net_sim::test] - async fn a_namespace_refused_a_stream_is_retried_on_its_own() { - const VERSION: Version = Version::Draft14; + async fn an_unanswered_token_refresh_re_offers_the_namespace_with_the_new_token() { + const VERSION: Version = Version::Draft18; + let first = bytes::Bytes::from_static(&[0x03, 0x00, b'a', b'a']); + let second = bytes::Bytes::from_static(&[0x03, 0x00, b'b', b'b']); let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let _cam = origin.announce("lonely-cam", crate::origin::Route::default()).unwrap(); + let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); settle().await; - // Closed from the start: the peer has granted nothing. - let gate = kio::Producer::new(false); - let ok = publish_namespace_ok(VERSION).await; - let session = crate::lite::test_transport::ScriptedSession::gated_open(vec![ok], gate.consume()); + // Stream 1: the PUBLISH_NAMESPACE is accepted, then the token-refresh REQUEST_UPDATE is + // never answered. Stream 2: the re-offer is accepted. + let stream1 = publish_namespace_ok(VERSION).await; + let stream2 = publish_namespace_ok(VERSION).await; + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![stream1, stream2]); let log = session.log.clone(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + let token = crate::RequestToken::new(Some(typed(&first))); let publisher = Publisher::new( crate::time::Clock::sim(), session, origin.consume(), Control::new(None, false), None, - declared(Some(false)), + peer_setup, VERSION, - ); + ) + .with_request_token(token.clone()); let mut run = std::pin::pin!(publisher.run_publish_namespaces()); - - // Well past the point where the open gives up. for _ in 0..100 { assert!(futures::poll!(run.as_mut()).is_pending()); - tick().await; + if occurrences(&log, &first) >= 1 { + break; + } + settle().await; } - assert_eq!(occurrences(&log, b"lonely-cam"), 0, "advertised without a stream"); - - // Credit returns. Nothing else changes: no publish, no unannounce, no route move. - set_gate(&gate, true); + assert_eq!(occurrences(&log, &first), 1, "the initial token rode the announce"); + token.set(Some(typed(&second))); for _ in 0..100 { assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"lonely-cam") > 0 { + if occurrences(&log, &second) >= 1 { break; } - tick().await; + settle().await; } - assert_eq!(occurrences(&log, b"lonely-cam"), 1, "never came back on its own"); - } + assert_eq!( + occurrences(&log, &second), + 1, + "the unanswered refresh carried the new token once" + ); - /// Advance far enough that a parked open gives up and its retry comes due, without - /// making the test wait: time is paused, so this only moves the clock the loop reads. - async fn tick() { - moq_net_sim::advance(Duration::from_millis(200)).await; + // Past the answer timeout, the namespace is re-offered on a fresh stream with the new token. + moq_net_sim::advance(ADVERTISE_TIMEOUT + Duration::from_secs(1)).await; + let mut reoffered = false; + for _ in 0..300 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, &second) >= 2 { + reoffered = true; + break; + } + settle().await; + } + assert!( + reoffered, + "an unanswered refresh must re-offer the namespace with the new token" + ); + assert!( + log.bi_opens() >= 2, + "the re-offer opened a fresh PUBLISH_NAMESPACE stream" + ); } - fn set_gate(gate: &kio::Producer, open: bool) { - let Ok(mut gate) = gate.write() else { - panic!("gate closed") - }; - *gate = open; - } + /// A token-bearing client's reprice carries only the cluster change, never the unchanged + /// token: a receiver renewing a token-authorized announce answers a token-bearing update as + /// a renewal, which would drop the HOP_PATH / ROUTE_COST riding it. + #[moq_net_sim::test] + async fn a_reprice_does_not_carry_the_request_token() { + const VERSION: Version = Version::Draft19; + let token = bytes::Bytes::from_static(&[0x03, 0x00, b'o', b'k']); - /// A publisher talking to a scripted peer that never answers, over one bidi stream. - struct Harness { - publisher: Publisher, - session: crate::lite::test_transport::ScriptedSession, - log: crate::lite::test_transport::Log, - /// Keeps the origin alive; the publisher only holds a consumer. - _origin: origin::Producer, - } + // Forward the reprice at once: the update hold is not what this checks. + let origin = crate::origin::Config { + update_hold: Duration::ZERO, + ..crate::origin::Config::new(crate::Hop::new(1).unwrap()) + } + .produce(); + let _cold = origin + .announce("cam", crate::origin::Route::default().with_cost(4)) + .unwrap(); + settle().await; - fn harness(version: Version) -> Harness { - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![Vec::new()]); + let ok = publish_namespace_ok(VERSION).await; + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![[ok.clone(), ok].concat()]); let log = session.log.clone(); - // Serving a request blocks on the peer's SETUP, which no scripted peer sends here. - let peer_setup = peer::PeerSetup::default(); - peer_setup.set(peer::Peer::default()); - let publisher = Publisher::new( crate::time::Clock::sim(), - session.clone(), + session, origin.consume(), Control::new(None, false), None, - peer_setup, - version, - ); + clustered(Some(false)), + VERSION, + ) + .with_request_token(crate::RequestToken::new(Some(typed(&token)))); - Harness { - publisher, - session, - log, - _origin: origin, + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, &token) >= 1 { + break; + } + settle().await; } - } - - /// Subscribe to a path nothing publishes, returning what the peer would read off the - /// request stream plus the reset codes the stream recorded. - async fn subscribe_missing(version: Version) -> (Vec, Vec) { - let h = harness(version); - - let stream = Stream::open(&mut h.session.clone(), version).await.unwrap(); - h.publisher - .clone() - .run_subscribe_stream( - stream, - ietf::Subscribe { - request_id: RequestId(1), - track_namespace: crate::Path::new("nothing/here"), - track_name: "video".into(), - subscriber_priority: 128, - group_order: GroupOrder::Descending, - filter: Filter::NextObject, - fill: None, - properties_wanted: true, - forward: true, - range_filters: false, - }, - ) - .await - .unwrap(); - - let writes = h.log.writes.lock().unwrap().clone(); - (writes, h.log.resets()) - } - - /// Send a FETCH we refuse, returning the same pair. - async fn fetch_refused(version: Version, fetch_type: FetchType<'_>) -> (Vec, Vec) { - let h = harness(version); + assert_eq!(occurrences(&log, &token), 1, "the token rides the PUBLISH_NAMESPACE"); - let stream = Stream::open(&mut h.session.clone(), version).await.unwrap(); - h.publisher - .clone() - .run_fetch_stream( - stream, - ietf::Fetch { - request_id: RequestId(1), - subscriber_priority: 128, - group_order: GroupOrder::Descending, - fetch_type, - range_filters: false, - fill_timeout: false, - properties_wanted: true, - }, - ) - .await + // The announce has gone out carrying the token exactly once; a reprice must then emit a + // PUBLISH_NAMESPACE_UPDATE that does NOT re-send the token. + let after_announce = log.writes.lock().unwrap().len(); + let _warm = origin + .announce("cam", crate::origin::Route::default().with_cost(0)) .unwrap(); - - let writes = h.log.writes.lock().unwrap().clone(); - (writes, h.log.resets()) - } - - /// A SUBSCRIBE for a path with no publisher is refused with REQUEST_ERROR, and the refusal - /// has to survive the trip. `Writer` resets the stream on drop, and a reset that races the - /// write discards the bytes the peer has not read yet, which leaves the subscriber waiting - /// on a request we already refused. Finishing first makes the drop-time reset a no-op. - #[moq_net_sim::test] - async fn missing_broadcast_is_refused_without_resetting_the_stream() { - for version in [Version::Draft17, Version::Draft18, Version::Draft19, Version::Draft20] { - let (writes, resets) = subscribe_missing(version).await; - - assert!(!writes.is_empty(), "{version}: nothing was sent"); - assert_eq!( - writes[0], - ietf::RequestError::ID as u8, - "{version}: not a REQUEST_ERROR" - ); - assert!(resets.is_empty(), "{version}: stream reset, discarding the error"); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if log.writes.lock().unwrap().len() > after_announce { + break; + } + settle().await; } + assert!( + log.writes.lock().unwrap().len() > after_announce, + "the reprice update was never sent" + ); + assert_eq!( + occurrences(&log, &token), + 1, + "the reprice must not carry the request token: it rides only the initial announce" + ); } - /// Every FETCH we refuse goes out through its own error encoder, so it needs the same - /// finish: a reset there loses the rejection the same way. - #[moq_net_sim::test] - async fn a_refused_fetch_does_not_reset_the_stream() { - let refused = || { - [ - ( - "standalone", - FetchType::Standalone { - namespace: crate::Path::new("nothing/here"), - track: "video".into(), - start: Location { group: 0, object: 0 }, - end: Location { group: 1, object: 0 }, - }, - ), - ( - "empty range", - FetchType::Standalone { - namespace: crate::Path::new("nothing/here"), - track: "video".into(), - start: Location { group: 2, object: 0 }, - end: Location { group: 1, object: 0 }, - }, - ), - ] - }; - - for version in [Version::Draft17, Version::Draft18, Version::Draft19, Version::Draft20] { - for (label, fetch_type) in refused() { - let (writes, resets) = fetch_refused(version, fetch_type).await; - - assert!(!writes.is_empty(), "{version} {label}: nothing was sent"); - assert_eq!( - writes[0], - ietf::RequestError::ID as u8, - "{version} {label}: not a REQUEST_ERROR" - ); - assert!( - resets.is_empty(), - "{version} {label}: stream reset, discarding the error" - ); - } + /// The typed form of a raw USE_VALUE Token structure with a one-byte Token Type. + fn typed(raw: &[u8]) -> crate::setup::Token { + assert_eq!(raw[0], 0x03, "USE_VALUE"); + crate::setup::Token { + kind: raw[1].into(), + value: raw[2..].to_vec(), } } } @@ -8192,6 +11129,7 @@ mod range_tests { properties_wanted: true, forward: true, range_filters: false, + authorization_token: None, } } diff --git a/rs/moq-net/src/ietf/request_stream.rs b/rs/moq-net/src/ietf/request_stream.rs index de82091117..9d2f9224ca 100644 --- a/rs/moq-net/src/ietf/request_stream.rs +++ b/rs/moq-net/src/ietf/request_stream.rs @@ -78,12 +78,26 @@ impl Decode for FollowUp { } } +/// One whole `[type][length][body]` message off a request stream, decoded at once so a read +/// dropped part-way consumes nothing. +#[derive(Debug)] +pub(super) struct Framed(pub(super) u64, pub(super) super::Body); + +impl Decode for Framed { + fn decode(r: &mut Decoder<'_>, version: Version) -> Result { + let id = r.varint()?; + Ok(Self(id, super::Body::decode(r, version)?)) + } +} + /// A framed subscription update, preserving omitted preferences. #[derive(Debug)] pub(super) struct Update { /// The first Request ID: the update's own on drafts 14-16, which their answer names. pub(super) request_id: super::RequestId, pub(super) priority: Option, + /// The AUTHORIZATION TOKEN renewing the request's credential, the first if it repeats. + pub(super) authorization_token: Option, pub(super) unsupported: bool, } @@ -112,13 +126,14 @@ impl Update { let _end_group = data.varint()?; let priority = data.u8()?; let forward = data.bool()?; - Parameters::skip(data)?; + let authorization_token = Parameters::request_tokens(data)?.into_iter().next(); if !data.is_empty() { return Err(DecodeError::InvalidValue); } return Ok(Self { request_id, priority: Some(priority), + authorization_token, unsupported: !forward, }); } @@ -133,7 +148,7 @@ impl Update { } decode_params!(data, version, 0x02 => object_timeout: Option, - 0x03 => token: Vec, + 0x03 => token: Vec, 0x06 => subgroup_timeout: Option where !matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16 | Version::Draft17), 0x10 => forward: Option, 0x20 => priority: Option, @@ -152,12 +167,12 @@ impl Update { Ok(Self { request_id, priority, + authorization_token: token.into_iter().next(), unsupported: forward == Some(false) || filter.is_some() || fill.is_some() || object_timeout.is_some() || subgroup_timeout.is_some() - || !token.is_empty() || !subgroup_filter.is_empty() || !object_filter.is_empty() || !priority_filter.is_empty() diff --git a/rs/moq-net/src/ietf/request_update.rs b/rs/moq-net/src/ietf/request_update.rs new file mode 100644 index 0000000000..4a7b96cdcb --- /dev/null +++ b/rs/moq-net/src/ietf/request_update.rs @@ -0,0 +1,186 @@ +//! The MAX_REQUEST_UPDATES Setup Option (draft-ietf-moq-transport-19 section 10.3.1.7). +//! +//! A request stream (SUBSCRIBE, PUBLISH_NAMESPACE, ...) can be refreshed in place with +//! REQUEST_UPDATE messages. Each is outstanding from when the peer sends it until its +//! REQUEST_OK or REQUEST_ERROR, and MAX_REQUEST_UPDATES caps how many a peer may leave +//! outstanding on one stream at once. We verify a request token asynchronously, so a slow +//! acceptor holds one renewal unanswered while more pile up behind it; this bound is what +//! stops that queue from growing without limit. +//! +//! The option is draft-19+ (Option Type 0x08). Advertising it lets a conforming peer +//! self-limit, so a peer that exceeds the advertised value broke a negotiated rule, which +//! the draft answers with a session close ([`SessionError::TooManyRequestUpdates`]). Drafts +//! below 19 carry no such option, so a peer there agreed to no ceiling: the receiver keeps +//! a generous local guard that ends only the one request rather than fault a peer at +//! session scope for a limit it never saw. +//! +//! [`SessionError::TooManyRequestUpdates`]: crate::SessionError::TooManyRequestUpdates + +use std::{collections::VecDeque, task::Poll}; + +use super::{RequestId, Version}; +use crate::{Error, SessionError, auth}; + +/// MAX_REQUEST_UPDATES Setup Option (Option Type 0x08). Even, so the value is a bare varint. +pub const OPTION: u64 = 0x08; + +/// The credit we advertise and enforce on drafts that define the option: the most +/// unacknowledged REQUEST_UPDATEs we accept on one request stream, counting the one being +/// verified. One constant drives both the advertisement and the enforcement so the value we +/// promise and the value we hold a peer to cannot drift. +pub const MAX_REQUEST_UPDATES: u64 = 16; + +/// Local resource guard on drafts without the option. Generous relative to the negotiated +/// credit, since those drafts negotiate no limit and a conforming peer must never reach it, but +/// still bounded: a control message carries up to a 16-bit length, so this caps the buffered +/// bytes per request stream rather than letting a flood grow without end. Exceeding it ends the +/// one request, never the session. +pub const UNNEGOTIATED_GUARD: usize = 64; + +/// Whether this version defines MAX_REQUEST_UPDATES. Draft-19 section 10.3.1.7 added it; the +/// drafts below carry no such Setup Option. +pub fn supported(version: Version) -> bool { + !matches!( + version, + Version::Draft14 | Version::Draft15 | Version::Draft16 | Version::Draft17 | Version::Draft18 + ) +} + +/// A request's in-band renewal: the verdict on the token being verified, and the updates that +/// arrived behind it. +/// +/// From draft 17 an answer names no Request ID, so updates are answered in arrival order: one +/// behind a pending verdict waits here until the verdict ahead of it resolves. The queue is +/// bounded by MAX_REQUEST_UPDATES, counting the one being verified. +pub(super) struct Renewal { + pending: Option<(auth::RequestVerdict, RequestId)>, + queued: VecDeque, +} + +impl Default for Renewal { + fn default() -> Self { + Self { + pending: None, + queued: VecDeque::new(), + } + } +} + +/// An update past the bound on outstanding updates. +pub(super) enum Overflow { + /// Past the MAX_REQUEST_UPDATES we advertised (draft 19+): the session closes. + Session, + /// Past the local guard on a draft without the option: only the request ends. + Request, +} + +impl Renewal { + /// Whether a verdict is pending, so a new update must wait behind it. + pub fn pending(&self) -> bool { + self.pending.is_some() + } + + /// Wait on `verdict`, the renewal the update with `request_id` carried. + pub fn verify(&mut self, verdict: auth::RequestVerdict, request_id: RequestId) { + debug_assert!(self.pending.is_none(), "one renewal is verified at a time"); + self.pending = Some((verdict, request_id)); + } + + /// Queue `update` behind the pending verdict, counting it against the bound. + pub fn queue(&mut self, update: T, version: Version) -> Result<(), Overflow> { + // Outstanding counts the one being verified plus those already queued. + let outstanding = self.queued.len() as u64 + 1; + match supported(version) { + // We advertised the credit, so a peer with that many outstanding sending another + // broke the negotiated limit (draft-19 section 10.3.1.7). + true if outstanding >= MAX_REQUEST_UPDATES => return Err(Overflow::Session), + false if self.queued.len() >= UNNEGOTIATED_GUARD => return Err(Overflow::Request), + _ => {} + } + self.queued.push_back(update); + Ok(()) + } + + /// The next queued update, once no verdict is pending. + pub fn next(&mut self) -> Option { + match self.pending { + Some(_) => None, + None => self.queued.pop_front(), + } + } + + /// `Ready` with the pending verdict once it resolves. + pub fn poll(&mut self, waiter: &kio::Waiter) -> Poll { + let Some((verdict, _)) = &self.pending else { + return Poll::Pending; + }; + let grant = std::task::ready!(verdict.poll_grant(waiter)); + let (verdict, request_id) = self.pending.take().expect("a pending renewal"); + Poll::Ready(Verdict { + verdict, + grant, + request_id, + }) + } +} + +/// A renewal's resolved verdict. +pub(super) struct Verdict { + /// What renews the request grant, if the grant covers it. + pub verdict: auth::RequestVerdict, + /// The acceptor's grant, or its refusal. + pub grant: crate::Result, + /// The update that carried the renewal, which the answer names before draft 17. + pub request_id: RequestId, +} + +/// Close `session` for a peer past the MAX_REQUEST_UPDATES we advertised, returning the error +/// that ends the request. +pub(super) fn too_many(session: &mut S) -> Error { + let err = SessionError::TooManyRequestUpdates; + session.close(err.to_code(), "too many request updates"); + Error::Session(err) +} + +/// Advertise our MAX_REQUEST_UPDATES credit, on versions that define the option. +pub fn into_setup(params: &mut super::Parameters, version: Version) { + if supported(version) { + params.set_varint(super::ParameterVarInt::MaxRequestUpdates, MAX_REQUEST_UPDATES); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The option rides SETUP only on the drafts that define it; an older peer sees nothing, + /// which is what keeps the bound a negotiated contract rather than a surprise. + #[test] + fn advertised_only_on_draft_19_plus() { + for version in [Version::Draft19, Version::Draft20, Version::Draft21, Version::Draft22] { + let mut params = super::super::Parameters::default(); + into_setup(&mut params, version); + assert_eq!( + params.get_varint(super::super::ParameterVarInt::MaxRequestUpdates), + Some(MAX_REQUEST_UPDATES), + "{version} must advertise MAX_REQUEST_UPDATES" + ); + } + + for version in [ + Version::Draft14, + Version::Draft15, + Version::Draft16, + Version::Draft17, + Version::Draft18, + ] { + let mut params = super::super::Parameters::default(); + into_setup(&mut params, version); + assert_eq!( + params.get_varint(super::super::ParameterVarInt::MaxRequestUpdates), + None, + "{version} has no MAX_REQUEST_UPDATES option to advertise" + ); + } + } +} diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index b8c2cded2e..dd0d149e5d 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, request_update, solicit, subscriber::{is_protocol_violation, subscribe_prefixes}, }; @@ -72,9 +72,19 @@ 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 and this side offers 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>, + + /// The extensions our SETUP offers. Drafts 14-16 already sent it, so there this only + /// records what it said. MoQ Auth is offered only while [`Self::auth`] supports it too. + pub extensions: crate::setup::Extensions, } pub(crate) struct Driver { @@ -119,8 +129,11 @@ where authority, peer_setup_stream, peer_declared, + auth, early_unis, + extensions, } = config; + let request_token = auth.request_token(); // GOAWAY wiring: the public Session holds one half (drain trigger, received // signal), the protocol tasks below hold the other. @@ -128,6 +141,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 +183,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 +217,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 +226,9 @@ where peer_hop, peer_setup.clone(), version, - ); + ) + .with_auth(auth.clone()) + .with_request_token(request_token.clone()); let (tasks, mut task_set) = TaskSet::new(); publisher.withdrawal = withdrawing.clone(); publisher.owed = serving.clone(); @@ -210,7 +246,10 @@ where version, tasks.clone(), goaway.going_away.clone(), - ); + ) + .with_auth(auth.clone()) + .with_request_token(request_token.clone()) + .with_solicit(extensions.solicit); subscriber.announces = announces; // GOAWAY send task: draft-14-16 carry GOAWAY on the shared control @@ -263,6 +302,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()))); @@ -343,9 +384,23 @@ where let runtime = runtime.clone(); let session = session.clone(); let goaway = goaway.clone(); + let extensions = crate::setup::Extensions { + auth: extensions.auth && auth.supported(), + ..extensions + }; async move { - if let Err(err) = - run_setup(runtime, session, version, path, authority, self_origin, cost, goaway).await + if let Err(err) = run_setup( + runtime, + session, + version, + path, + authority, + self_origin, + cost, + extensions, + goaway, + ) + .await { tracing::warn!(%err, "setup send error"); } @@ -354,6 +409,22 @@ 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(); + let request_token = request_token.clone(); + let peer_setup = peer_setup.clone(); + async move { + match client { + true => enforce_grant(auth, origin, session, request_token, peer_setup, version).await, + false => std::future::pending().await, + } + } + }; let mut publisher = Publisher::new( runtime.clone(), session.clone(), @@ -362,7 +433,9 @@ where peer_hop, peer_setup.clone(), version, - ); + ) + .with_auth(auth.clone()) + .with_request_token(request_token.clone()); let (tasks, mut task_set) = TaskSet::new(); publisher.withdrawal = withdrawing.clone(); publisher.owed = serving.clone(); @@ -372,7 +445,7 @@ where runtime.clone(), session.clone(), subscribe, - control, + control.clone(), peer_hop, peer_setup.clone(), self_origin, @@ -380,7 +453,28 @@ where version, tasks, goaway.going_away.clone(), + ) + .with_auth(auth.clone()) + .with_request_token(request_token.clone()) + .with_solicit(extensions.solicit); + + // 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(), ); + // Only a side that offered MoQ Auth answers the peer's Auth requests: one that + // arrives otherwise falls through to the protocol violation in `run_dispatch`. + let serve = auth.supported().then(|| auth::Serve { + runtime: runtime.clone(), + handle: auth.clone(), + peer_grant, + }); subscriber.announces = announces; let sub_ns_session = session.clone(); @@ -418,10 +512,14 @@ where session.clone(), publisher.clone(), subscriber.clone(), + peer_setup.clone(), + 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 +563,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 +583,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 +599,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 +735,7 @@ fn peer_from_params(params: &ietf::Parameters, version: Version) -> Result Result( runtime: crate::time::Clock, @@ -641,6 +756,7 @@ async fn run_setup( authority: Option, self_origin: Hop, cost: Option, + extensions: crate::setup::Extensions, goaway: crate::goaway::Protocol, ) -> Result<(), Error> { let outer_version = crate::Version::Ietf(version); @@ -657,9 +773,15 @@ async fn run_setup( parameters.set_bytes(ietf::ParameterBytes::Authority, authority.into_bytes()); } cluster::peer_into_setup(&mut parameters, self_origin, cost, version); - solicit::into_setup(&mut parameters, version); + if extensions.solicit { + solicit::into_setup(&mut parameters, version); + } hidden::into_setup(&mut parameters, version); + if extensions.auth { + auth::into_setup(&mut parameters, version); + } active_count::into_setup(&mut parameters, version); + request_update::into_setup(&mut parameters, version); let parameters = parameters.encode_bytes(version)?; writer.encode(&setup::Setup { parameters }).await?; @@ -1011,6 +1133,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 +1147,120 @@ where // costs a handshake round rather than blocking. let peer = subscriber.peer().await; + // An AUTH without MoQ Auth negotiated, offered by both sides, 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 +1324,59 @@ 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, + request_token: crate::RequestToken, + peer_setup: peer::PeerSetup, + version: Version, +) -> Result<(), Error> { + // A request token authorizes an announce outside the connection grant only when the + // announce carries it, which is when it rides its own PUBLISH_NAMESPACE request: always on + // draft-14/15, and on later drafts unless the peer requires solicitation, which turns every + // advertisement into an inline NAMESPACE entry with no token slot. Then the grant still + // bounds what we publish. + if request_token.peek().is_some() && announces_carry_token(&peer_setup, version).await { + return Ok(()); + } + 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) +} + +/// Whether our announces will ride their own PUBLISH_NAMESPACE requests, the only form that +/// carries a request token, rather than inline NAMESPACE entries. Draft-14/15 predate +/// NAMESPACE; later drafts send requests unless the peer requires solicitation. +async fn announces_carry_token(peer_setup: &peer::PeerSetup, version: Version) -> bool { + match version { + Version::Draft14 | Version::Draft15 => true, + _ => !peer_setup.get().await.solicit.unwrap_or(false), + } +} + #[cfg(test)] mod tests { use super::*; @@ -1240,7 +1456,9 @@ mod tests { }, ..Default::default() }), + auth: crate::auth::Handle::new(false), early_unis: Vec::new(), + extensions: Default::default(), }) .expect("start the session"); @@ -1294,7 +1512,9 @@ 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(), + extensions: Default::default(), }) .expect("start the session"); let _driver = moq_net_sim::spawn(driver); @@ -1367,7 +1587,9 @@ mod tests { solicit, ..Default::default() }), + auth: crate::auth::Handle::new(false), early_unis: Vec::new(), + extensions: Default::default(), }) .expect("start the session"); let driver = moq_net_sim::spawn(driver); @@ -1420,6 +1642,91 @@ mod tests { /// parks again; a busy machine cannot turn a slow announce into a passing silence. const ANNOUNCE_TURNS: usize = 100; + /// A client that presents a request token authorizes each announce at the server when the + /// announce carries the token, so dialing-side grant enforcement stands down for a peer + /// that takes unsolicited PUBLISH_NAMESPACE requests: enforcing it would close the client + /// for announcing outside the connection grant the token was meant to extend. + #[moq_net_sim::test] + async fn a_client_may_send_a_token_bearing_request_its_connection_grant_does_not_cover() { + let auth = crate::auth::Handle::new(true); + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let _cam = origin.announce("room/alice", crate::origin::Route::default()).unwrap(); + let session = crate::lite::test_transport::SinkSession::new(Default::default()); + let log = session.log.clone(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer { + solicit: Some(false), + ..Default::default() + }); + + let result = enforce_grant( + auth, + origin.consume(), + session, + crate::RequestToken::new(Some(crate::setup::Token { + kind: 0, + value: b"jwt".to_vec(), + })), + peer_setup, + Version::Draft18, + ) + .await; + + assert!( + result.is_ok(), + "a token-bearing client must not be closed by grant enforcement" + ); + assert!( + log.closes().is_empty(), + "the session must stay open for a token-bearing client" + ); + } + + /// When the peer requires solicitation, every advertisement is an inline NAMESPACE entry, + /// which has no slot for a request token. A token set for SUBSCRIBE must not let the client + /// advertise outside its connection grant there: the grant is still enforced. + #[moq_net_sim::test] + async fn a_request_token_does_not_lift_the_grant_when_announces_are_inline() { + let auth = crate::auth::Handle::new(true); + let setup = auth.present(bytes::Bytes::new(), true).unwrap(); + auth.granted( + 0, + crate::auth::Grant { + publish: crate::Pattern::subtree("room/alice").unwrap().into(), + subscribe: crate::Patterns::new(), + expires: None, + }, + ); + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let _cam = origin + .announce("room/bob/cam", crate::origin::Route::default()) + .unwrap(); + let session = crate::lite::test_transport::SinkSession::new(Default::default()); + let log = session.log.clone(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer { + solicit: Some(true), + ..Default::default() + }); + + let result = enforce_grant( + auth, + origin.consume(), + session, + crate::RequestToken::new(Some(crate::setup::Token { + kind: 0, + value: b"jwt".to_vec(), + })), + peer_setup, + Version::Draft18, + ) + .await; + + assert!(matches!(result, Err(Error::Unauthorized)), "{result:?}"); + assert_eq!(log.closes().len(), 1, "the session closes on the uncovered announce"); + drop(setup); + } + /// Run a publish-only session against a peer that declared `peer_declared`, returning /// how many times the namespace reached the wire. /// @@ -1450,7 +1757,9 @@ mod tests { authority: None, peer_setup_stream: None, peer_declared, + auth: crate::auth::Handle::new(false), early_unis: Vec::new(), + extensions: Default::default(), }) .expect("start the session"); let _driver = moq_net_sim::spawn(driver); @@ -1505,6 +1814,267 @@ 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(), + extensions: Default::default(), + }) + .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"); + } + + /// What the draft-18 SETUP that `start` writes says about MoQ Auth and MoQ Solicit. + async fn our_setup(client: bool, auth: crate::auth::Handle, extensions: crate::setup::Extensions) -> (bool, bool) { + const VERSION: Version = Version::Draft18; + + let session = crate::lite::test_transport::ScriptedSession::new(Vec::new()); + let log = session.log.clone(); + let (driver, _goaway, _) = start(Config { + runtime: crate::time::Clock::sim(), + session, + setup: None, + request_id_max: None, + limits: Default::default(), + client, + publish: None, + subscribe: None, + peer_hop: None, + cost: None, + version: VERSION, + path: None, + authority: None, + peer_setup_stream: None, + peer_declared: Some(peer::Peer::default()), + auth, + early_unis: Vec::new(), + extensions, + }) + .expect("start the session"); + + // The driver parks once SETUP is out. + moq_net_sim::timeout(std::time::Duration::from_secs(10), driver) + .await + .expect_err("the session ended instead of parking after SETUP"); + + let writes = log.writes.lock().unwrap().clone(); + let (setup, _) = setup::Setup::decode_slice(&writes, crate::Version::Ietf(VERSION)).expect("our SETUP"); + let (params, _) = ietf::Parameters::decode_slice(&setup.parameters, VERSION).expect("its options"); + ( + auth::from_setup(¶ms, VERSION) == Some(true), + solicit::from_setup(¶ms, VERSION).unwrap() == Some(true), + ) + } + + /// Each extension reaches our SETUP only while this side offers it. + #[moq_net_sim::test] + async fn a_declined_extension_stays_out_of_setup() { + let all = crate::setup::Extensions::default(); + let mut no_auth = all; + no_auth.auth = false; + let mut no_solicit = all; + no_solicit.solicit = false; + + // A client's handle supports AUTH exactly when it offers it. + assert_eq!(our_setup(true, crate::auth::Handle::new(true), all).await, (true, true)); + assert_eq!( + our_setup(true, crate::auth::Handle::new(false), no_auth).await, + (false, true) + ); + assert_eq!( + our_setup(true, crate::auth::Handle::new(true), no_solicit).await, + (true, false) + ); + } + + /// A server's handle supports AUTH only when the client offered it too, so a server + /// answering a client that did not offer it leaves the option out, whatever it offers. + #[moq_net_sim::test] + async fn a_server_offers_auth_only_to_a_client_that_did() { + let all = crate::setup::Extensions::default(); + assert_eq!( + our_setup(false, crate::auth::Handle::new(true), all).await, + (true, true) + ); + assert_eq!( + our_setup(false, crate::auth::Handle::new(false), all).await, + (false, true) + ); + } + + /// The bytes of a peer's Auth request presenting its connection credential. + async fn auth_request(version: Version) -> Vec { + let log = crate::lite::test_transport::Log::default(); + let mut writer = crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); + writer + .encode_message(&auth::Auth { + request_id: RequestId(0), + token: bytes::Bytes::new(), + }) + .await + .unwrap(); + let writes = log.writes.lock().unwrap(); + writes.clone() + } + + /// Run a draft-18 server whose client offered MoQ Auth and then sent an Auth request. + /// `offered` is whether the server offered it too. Returns how the session ended, if it + /// did, and what closed it. + async fn serve_auth_request(offered: bool) -> (Option>, Vec<(u32, String)>) { + const VERSION: Version = Version::Draft18; + + let session = crate::lite::test_transport::ScriptedSession::new(Vec::new()) + .with_incoming_bidis(vec![auth_request(VERSION).await]); + let log = session.log.clone(); + let (driver, _goaway, _) = start(Config { + runtime: crate::time::Clock::sim(), + session, + setup: None, + request_id_max: None, + limits: Default::default(), + client: false, + publish: None, + subscribe: None, + peer_hop: None, + cost: None, + version: VERSION, + path: None, + authority: None, + peer_setup_stream: None, + peer_declared: Some(peer::Peer { + auth: true, + ..Default::default() + }), + // As `Server` builds it: offered by the client, so supported iff we offer it. + auth: crate::auth::Handle::new(offered), + early_unis: Vec::new(), + extensions: crate::setup::Extensions::default(), + }) + .expect("start the session"); + + let res = moq_net_sim::timeout(std::time::Duration::from_secs(10), driver) + .await + .ok(); + (res, log.closes()) + } + + /// The Auth draft negotiates the extension only when both sides offer it, so an Auth + /// request reaching a side that declined it is a protocol violation, not a token to grant. + #[moq_net_sim::test] + async fn an_auth_request_to_a_side_that_declined_it_is_refused() { + let (res, closes) = serve_auth_request(true).await; + assert!(res.is_none(), "both offered: the request is served, {res:?}"); + assert_eq!(closes, vec![]); + + let (res, closes) = serve_auth_request(false).await; + let Some(Err(err)) = res else { + panic!("declined: the Auth request did not end the session: {res:?}"); + }; + assert_eq!(SessionError::from(&err), SessionError::ProtocolViolation); + assert_eq!( + closes, + vec![(SessionError::ProtocolViolation.to_code(), err.to_string())] + ); + } + /// 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,7 +2131,9 @@ 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(), + extensions: Default::default(), }) .expect("start the session"); @@ -1600,7 +2172,9 @@ 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(), + extensions: Default::default(), }) .expect("start the session"); @@ -1910,7 +2484,9 @@ mod tests { authority: None, peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), + auth: crate::auth::Handle::new(false), early_unis: Vec::new(), + extensions: Default::default(), }) .unwrap(); @@ -1975,7 +2551,9 @@ mod tests { authority: None, peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), + auth: crate::auth::Handle::new(false), early_unis: Vec::new(), + extensions: Default::default(), limits: Default::default(), }) .unwrap(); @@ -2004,6 +2582,7 @@ mod tests { request_id: RequestId(1), track_namespace: crate::Path::new("room/host"), cluster: None, + authorization_token: None, }) .await .unwrap(); @@ -2075,7 +2654,9 @@ mod tests { authority: None, peer_setup_stream: None, peer_declared: None, + auth: crate::auth::Handle::new(false), early_unis: Vec::new(), + extensions: Default::default(), }) .expect("start the session"); let driver = moq_net_sim::spawn(driver); @@ -2096,4 +2677,118 @@ mod tests { assert!(!driver.is_finished(), "the repeated withdrawal ended the session"); assert!(log.closes().is_empty(), "closed the session: {:?}", log.closes()); } + + /// A SUBSCRIBE for `room/video` whose parameters are one AUTHORIZATION TOKEN holding + /// `token`, framed as a control message. + fn subscribe_with_token(version: Version, token: &[u8]) -> Vec { + let msg = ietf::Subscribe { + request_id: RequestId(0), + track_namespace: crate::Path::new("room"), + track_name: "video".into(), + subscriber_priority: 128, + group_order: ietf::GroupOrder::Descending, + filter: ietf::Filter::NextObject, + fill: None, + properties_wanted: true, + forward: true, + range_filters: false, + authorization_token: None, + }; + let mut body = Vec::new(); + let mut w = crate::coding::Encoder::new(&mut body, version.into()); + match version { + Version::Draft14 => { + msg.encode_msg(&mut w, version).unwrap(); + // Replace the empty parameter block with the token. + assert_eq!(body.pop(), Some(0)); + } + // The request ID, namespace and name; every parameter but the token defaults. + _ => w.slice(&[0, 1, 4, b'r', b'o', b'o', b'm', 5, b'v', b'i', b'd', b'e', b'o']), + } + let mut w = crate::coding::Encoder::new(&mut body, version.into()); + w.varint(1).unwrap(); + w.varint(u64::from(ietf::ParameterBytes::AuthorizationToken)).unwrap(); + w.bytes(token).unwrap(); + + let mut payload = Vec::new(); + let mut w = crate::coding::Encoder::new(&mut payload, version.into()); + w.varint(ietf::Subscribe::ID).unwrap(); + w.u16(body.len() as u16); + w.slice(&body); + payload + } + + /// A request's AUTHORIZATION TOKEN decodes by the SETUP option's rules, so one this + /// endpoint cannot use closes the session with the draft's code. Draft-14 carries the + /// request on the control stream, a strict draft on its own stream. + #[moq_net_sim::test] + async fn a_request_token_that_cannot_be_used_closes_the_session() { + for version in [Version::Draft14, Version::Draft22] { + for (token, expected, why) in [ + ( + &[0x01, 0x07, 0x00][..], + SessionError::AuthTokenCacheOverflow, + "REGISTER", + ), + (&[0x00, 0x07][..], SessionError::UnknownAuthTokenAlias, "DELETE"), + (&[0x02, 0x07][..], SessionError::UnknownAuthTokenAlias, "USE_ALIAS"), + ( + &[0x03][..], + SessionError::KeyValueFormatting, + "USE_VALUE without a type", + ), + ( + &[0x04, 0x00][..], + SessionError::KeyValueFormatting, + "an unknown alias type", + ), + ] { + let payload = subscribe_with_token(version, token); + let mut session = match version { + Version::Draft14 => crate::lite::test_transport::ScriptedSession::new(payload), + _ => { + crate::lite::test_transport::ScriptedSession::new(Vec::new()).with_incoming_bidis(vec![payload]) + } + }; + let log = session.log.clone(); + let setup = match version { + Version::Draft14 => Some(Stream::open(&mut session, version).await.unwrap()), + _ => None, + }; + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let (driver, _goaway, _) = start(Config { + runtime: crate::time::Clock::sim(), + session, + setup, + request_id_max: None, + limits: Default::default(), + client: false, + publish: Some(origin.consume()), + subscribe: None, + peer_hop: None, + cost: None, + version, + path: None, + authority: None, + peer_setup_stream: None, + peer_declared: Some(peer::Peer::default()), + auth: crate::auth::Handle::new(false), + extensions: Default::default(), + early_unis: Vec::new(), + }) + .unwrap(); + + let err = moq_net_sim::timeout(std::time::Duration::from_secs(10), driver) + .await + .unwrap_or_else(|_| panic!("{version:?} {why}: the session stayed up")) + .expect_err("the token must fail the session"); + assert_eq!(SessionError::from(&err), expected, "{version:?} {why}"); + assert_eq!( + log.closes(), + vec![(expected.to_code(), err.to_string())], + "{version:?} {why}" + ); + } + } + } } diff --git a/rs/moq-net/src/ietf/solicit.rs b/rs/moq-net/src/ietf/solicit.rs index e02e826d05..470577094e 100644 --- a/rs/moq-net/src/ietf/solicit.rs +++ b/rs/moq-net/src/ietf/solicit.rs @@ -39,7 +39,7 @@ pub fn from_setup(params: &super::Parameters, _version: Version) -> Result { /// MAX_FILTER_RANGES, so the request is refused rather than served unfiltered. /// Never encoded; we send no range filters. pub range_filters: bool, + /// The `AUTHORIZATION TOKEN` (0x03) this request presents. The parameter may repeat; the + /// first instance is the request's credential, and every instance has been decoded. + pub authorization_token: Option, } /// NEW_GROUP_REQUEST (0x32) arrived in draft-16. @@ -69,7 +74,7 @@ impl Message for Subscribe<'_> { let forward = r.bool()?; let filter = Filter::decode(r, version)?; - Parameters::skip(r)?; + let authorization_token = Parameters::request_tokens(r)?.into_iter().next(); Ok(Self { request_id, @@ -82,15 +87,15 @@ impl Message for Subscribe<'_> { properties_wanted: true, forward, range_filters: false, + authorization_token, }) } _ => { - // The token is ignored: the session's grant is what authorizes the request. - // NEW_GROUP_REQUEST is ignored too, as the draft lets a publisher without - // dynamic groups do. + // NEW_GROUP_REQUEST is ignored, as the draft lets a publisher without dynamic + // groups do. decode_params!(r, version, 0x02 => _object_delivery_timeout: Option, - 0x03 => _authorization_token: Vec, + 0x03 => authorization_token: Vec, // 0x04 is MAX_CACHE_DURATION in draft-15 (a publisher parameter) and not a // message parameter at all in draft-16. RENDEZVOUS_TIMEOUT arrives in draft-17. 0x04 => rendezvous_timeout: Option where !matches!(version, Version::Draft15 | Version::Draft16), @@ -142,6 +147,7 @@ impl Message for Subscribe<'_> { // Absent means 1. forward: forward.unwrap_or(true), range_filters, + authorization_token: authorization_token.into_iter().next(), }) } } @@ -162,7 +168,9 @@ impl Message for Subscribe<'_> { w.bool(self.forward); self.filter.encode(w, version)?; - w.u8(0); // no parameters + encode_params!(w, version, + 0x03 => self.authorization_token.clone(), + ); } _ => { // FILL_PARAMETERS arrived in draft-20. Sending it to an older peer would be an @@ -176,6 +184,7 @@ impl Message for Subscribe<'_> { let include_properties = (!self.properties_wanted && Filter::is_draft20(version)).then_some(false); encode_params!(w, version, + 0x03 => self.authorization_token.clone(), 0x10 => self.forward, 0x20 => self.subscriber_priority, 0x21 => self.filter, @@ -276,7 +285,7 @@ impl Message for SubscribeOk { largest = Some(Location::decode(r, version)?); } - properties.max_cache_duration = Parameters::skip(r)?.map(std::time::Duration::from_millis); + properties.max_cache_duration = Parameters::skip_response(r)?.map(std::time::Duration::from_millis); } _ => { // GROUP_ORDER and MAX_CACHE_DURATION are legal here only in draft-15. Draft-16 @@ -370,10 +379,20 @@ impl Message for Unsubscribe { pub struct SubscribeUpdate { pub request_id: RequestId, pub subscription_request_id: Option, + /// Draft-14's fixed start, which a peer must not see decrease; unused on later drafts. pub start_location: Location, + /// Draft-14's fixed end group (0 for open-ended); unused on later drafts. pub end_group: u64, - pub subscriber_priority: u8, - pub forward: bool, + /// `None` leaves the priority as it is. Draft-14 cannot omit it, so `None` sends the + /// default there. + pub subscriber_priority: Option, + /// `None` leaves forwarding as it is, as for the priority. + pub forward: Option, + /// The new LOCATION_FILTER (draft-15+); `None` keeps the subscription's own range. + pub filter: Option, + /// The `AUTHORIZATION TOKEN` (0x03) renewing the request's credential, if any. The + /// parameter may repeat; the first instance is kept, and every instance has been decoded. + pub authorization_token: Option, } impl Message for SubscribeUpdate { @@ -388,9 +407,11 @@ impl Message for SubscribeUpdate { .encode(w, version)?; self.start_location.encode(w, version)?; w.varint(self.end_group)?; - w.u8(self.subscriber_priority); - w.bool(self.forward); - w.u8(0); // no parameters + w.u8(self.subscriber_priority.unwrap_or(128)); + w.bool(self.forward.unwrap_or(true)); + encode_params!(w, version, + 0x03 => self.authorization_token.clone(), + ); } Version::Draft15 | Version::Draft16 => { self.request_id.encode(w, version)?; @@ -398,9 +419,10 @@ impl Message for SubscribeUpdate { .expect("subscription_request_id required for draft15-16") .encode(w, version)?; encode_params!(w, version, + 0x03 => self.authorization_token.clone(), 0x10 => self.forward, 0x20 => self.subscriber_priority, - 0x21 => Filter::NextObject, + 0x21 => self.filter, ); } _ => { @@ -414,9 +436,10 @@ impl Message for SubscribeUpdate { w.varint(0)?; // required_request_id_delta = 0 (draft-17 only, removed in draft-18 per #1615) } encode_params!(w, version, + 0x03 => self.authorization_token.clone(), 0x10 => self.forward, 0x20 => self.subscriber_priority, - 0x21 => Filter::NextObject, + 0x21 => self.filter, ); } } @@ -431,9 +454,9 @@ impl Message for SubscribeUpdate { let subscription_request_id = Some(RequestId::decode(r, version)?); let start_location = Location::decode(r, version)?; let end_group = r.varint()?; - let subscriber_priority = r.u8()?; - let forward = r.bool()?; - Parameters::skip(r)?; + let subscriber_priority = Some(r.u8()?); + let forward = Some(r.bool()?); + let authorization_token = Parameters::request_tokens(r)?.into_iter().next(); Ok(Self { request_id, @@ -442,6 +465,8 @@ impl Message for SubscribeUpdate { end_group, subscriber_priority, forward, + filter: None, + authorization_token, }) } Version::Draft15 | Version::Draft16 => { @@ -449,16 +474,13 @@ impl Message for SubscribeUpdate { let subscription_request_id = Some(RequestId::decode(r, version)?); decode_params!(r, version, 0x02 => _object_delivery_timeout: Option, - 0x03 => _authorization_token: Vec, + 0x03 => authorization_token: Vec, 0x10 => forward: Option, 0x20 => subscriber_priority: Option, - 0x21 => _filter: Option, + 0x21 => filter: Option, 0x32 => _new_group_request: Option where has_new_group_request(version), ); - let subscriber_priority = subscriber_priority.unwrap_or(128); - let forward = forward.unwrap_or(true); - Ok(Self { request_id, subscription_request_id, @@ -466,6 +488,8 @@ impl Message for SubscribeUpdate { end_group: 0, subscriber_priority, forward, + filter, + authorization_token: authorization_token.into_iter().next(), }) } _ => { @@ -478,11 +502,11 @@ impl Message for SubscribeUpdate { // consumed so a legal update does not fail the session. decode_params!(r, version, 0x02 => _object_delivery_timeout: Option, - 0x03 => _authorization_token: Vec, + 0x03 => authorization_token: Vec, 0x06 => _subgroup_delivery_timeout: Option where !matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16 | Version::Draft17), 0x10 => forward: Option, 0x20 => subscriber_priority: Option, - 0x21 => _filter: Option, + 0x21 => filter: Option, 0x23 => _fill: Option where Filter::is_draft20(version), 0x25 => _subgroup_filter: Vec where has_range_filters(version), 0x26 => _object_id_filter: Vec where has_range_filters(version), @@ -492,9 +516,6 @@ impl Message for SubscribeUpdate { 0x32 => _new_group_request: Option, ); - let subscriber_priority = subscriber_priority.unwrap_or(128); - let forward = forward.unwrap_or(true); - Ok(Self { request_id, subscription_request_id: None, @@ -502,6 +523,8 @@ impl Message for SubscribeUpdate { end_group: 0, subscriber_priority, forward, + filter, + authorization_token: authorization_token.into_iter().next(), }) } } @@ -537,6 +560,7 @@ mod tests { properties_wanted: true, forward: true, range_filters: false, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft14); @@ -561,6 +585,7 @@ mod tests { properties_wanted: true, forward: true, range_filters: false, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft15); @@ -655,6 +680,7 @@ mod tests { properties_wanted: true, forward: true, range_filters: false, + authorization_token: None, }; for version in [Version::Draft17, Version::Draft18, Version::Draft19, Version::Draft20] { @@ -686,6 +712,7 @@ mod tests { properties_wanted: true, forward: true, range_filters: false, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft14); @@ -872,6 +899,7 @@ mod tests { properties_wanted: wanted, forward: true, range_filters: false, + authorization_token: None, }; let encoded = encode_message(&msg, version); @@ -906,8 +934,10 @@ mod tests { subscription_request_id: Some(RequestId(5)), start_location: Location { group: 0, object: 0 }, end_group: 0, - subscriber_priority: 200, - forward: true, + subscriber_priority: Some(200), + forward: Some(true), + filter: Some(Filter::NextObject), + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft15); @@ -915,8 +945,8 @@ mod tests { assert_eq!(decoded.request_id, RequestId(10)); assert_eq!(decoded.subscription_request_id, Some(RequestId(5))); - assert_eq!(decoded.subscriber_priority, 200); - assert!(decoded.forward); + assert_eq!(decoded.subscriber_priority, Some(200)); + assert_eq!(decoded.forward, Some(true)); } #[test] @@ -926,8 +956,10 @@ mod tests { subscription_request_id: Some(RequestId(5)), start_location: Location { group: 1, object: 2 }, end_group: 100, - subscriber_priority: 200, - forward: true, + subscriber_priority: Some(200), + forward: Some(true), + filter: Some(Filter::NextObject), + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft14); @@ -937,8 +969,8 @@ mod tests { assert_eq!(decoded.subscription_request_id, Some(RequestId(5))); assert_eq!(decoded.start_location, Location { group: 1, object: 2 }); assert_eq!(decoded.end_group, 100); - assert_eq!(decoded.subscriber_priority, 200); - assert!(decoded.forward); + assert_eq!(decoded.subscriber_priority, Some(200)); + assert_eq!(decoded.forward, Some(true)); } #[test] @@ -996,6 +1028,7 @@ mod tests { properties_wanted: true, forward: true, range_filters: false, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft17); @@ -1030,8 +1063,10 @@ mod tests { subscription_request_id: None, start_location: Location { group: 0, object: 0 }, end_group: 0, - subscriber_priority: 200, - forward: true, + subscriber_priority: Some(200), + forward: Some(true), + filter: Some(Filter::NextObject), + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft17); @@ -1039,8 +1074,8 @@ mod tests { assert_eq!(decoded.request_id, RequestId(10)); assert_eq!(decoded.subscription_request_id, None); - assert_eq!(decoded.subscriber_priority, 200); - assert!(decoded.forward); + assert_eq!(decoded.subscriber_priority, Some(200)); + assert_eq!(decoded.forward, Some(true)); } #[test] @@ -1056,6 +1091,7 @@ mod tests { properties_wanted: true, forward: true, range_filters: false, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft18); @@ -1232,8 +1268,10 @@ mod tests { subscription_request_id: None, start_location: Location { group: 0, object: 0 }, end_group: 0, - subscriber_priority: 200, - forward: true, + subscriber_priority: Some(200), + forward: Some(true), + filter: Some(Filter::NextObject), + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft18); @@ -1241,8 +1279,8 @@ mod tests { assert_eq!(decoded.request_id, RequestId(10)); assert_eq!(decoded.subscription_request_id, None); - assert_eq!(decoded.subscriber_priority, 200); - assert!(decoded.forward); + assert_eq!(decoded.subscriber_priority, Some(200)); + assert_eq!(decoded.forward, Some(true)); } /// Cross-check: draft-17 emits an extra 0-byte (required_request_id_delta) that @@ -1255,8 +1293,10 @@ mod tests { subscription_request_id: None, start_location: Location { group: 0, object: 0 }, end_group: 0, - subscriber_priority: 200, - forward: true, + subscriber_priority: Some(200), + forward: Some(true), + filter: Some(Filter::NextObject), + authorization_token: None, }; let v18_msg = SubscribeUpdate { ..v17_msg.clone() }; @@ -1317,6 +1357,7 @@ mod tests { properties_wanted: false, forward: true, range_filters: false, + authorization_token: None, }; #[rustfmt::skip] @@ -1378,6 +1419,7 @@ mod tests { properties_wanted: true, forward: false, range_filters: false, + authorization_token: None, }; let decoded: Subscribe = decode_message(&encode_message(&msg, version), version).unwrap(); assert!(!decoded.forward, "{version}"); diff --git a/rs/moq-net/src/ietf/subscribe_namespace.rs b/rs/moq-net/src/ietf/subscribe_namespace.rs index 6bf2ff68a5..9c02fa0853 100644 --- a/rs/moq-net/src/ietf/subscribe_namespace.rs +++ b/rs/moq-net/src/ietf/subscribe_namespace.rs @@ -74,9 +74,9 @@ impl Message for SubscribeNamespace<'_> { } let request_id = RequestId::decode(r, version)?; let namespace = decode_namespace(r)?; - // The token is ignored: the session's grant is what authorizes the request. + // The token decodes but grants nothing: the session's grant is what authorizes the request. decode_params!(r, version, - 0x03 => _authorization_token: Vec, + 0x03 => _authorization_token: Vec, HIDDEN_PARAM => hidden: Option, ); @@ -170,13 +170,13 @@ impl Message for SubscribeNamespaceLegacy<'_> { _ => SubscribeOptions::Namespace, }; - // The token is ignored: the session's grant is what authorizes the request. + // The token decodes but grants nothing: the session's grant is what authorizes the request. // FORWARD is legal here in draft-15 through draft-17. The value is checked (only // 0 or 1) and then dropped: a namespace subscription has no objects to forward. // Draft-14 has no such parameter, so an unlisted id is ignored. Draft-18 moved // FORWARD onto SUBSCRIBE_TRACKS and closes if it shows up on the new message. decode_params!(r, version, - 0x03 => _authorization_token: Vec, + 0x03 => _authorization_token: Vec, 0x10 => _forward: Option where matches!(version, Version::Draft15 | Version::Draft16 | Version::Draft17), HIDDEN_PARAM => hidden: Option, ); @@ -463,6 +463,7 @@ mod tests { hops: hop_path(&[7]), cost: 0, }), + authorization_token: None, }; let mut buf = Vec::new(); diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 5ed8e6dd67..980c9a8457 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -13,7 +13,7 @@ use crate::{ util::{MaybeBoxedExt, MaybeSendBox, TaskSet, Tasks}, }; -use super::{Message, Version, cluster, error::request, group::ObjectExtensionsLength, peer}; +use super::{Message, Version, cluster, error::request, group::ObjectExtensionsLength, peer, request_update}; use crate::tail::{Reading, Settle, Tail}; use kio::Lock; @@ -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, @@ -589,6 +594,9 @@ pub(super) struct Subscriber { self_origin: crate::Hop, // What the peer declared in its SETUP. peer_setup: peer::PeerSetup, + // Whether our SETUP declared MoQ Solicit, which is what makes an unsolicited + // PUBLISH_NAMESPACE the peer's fault. + solicit: bool, // Local policy for what pulling from this peer costs, overriding whatever it // declared. See `cluster::link_cost`. cost: Option, @@ -600,10 +608,16 @@ 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`). pub(super) announces: crate::session::Slots, + // The AUTHORIZATION TOKEN this side presents on its SUBSCRIBE requests and their + // REQUEST_UPDATEs (MoQ request-token). A shared handle so a client can replace it while the + // session runs; the default presents none. A client credential. + request_token: crate::RequestToken, } /// The prefixes to issue SUBSCRIBE_NAMESPACE for: `origin`'s permitted scope, @@ -679,16 +693,94 @@ where session_origin: peer_hop.unwrap_or(crate::Hop::UNKNOWN), self_origin, peer_setup, + solicit: true, cost, state: Default::default(), tasks, version, going_away, + auth: crate::auth::Handle::new(false), frames: Default::default(), announces: Default::default(), + request_token: crate::RequestToken::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 + } + + /// Whether our SETUP declared MoQ Solicit. Without it an unsolicited PUBLISH_NAMESPACE + /// is what we invited, even from a peer that implements the extension. + pub fn with_solicit(mut self, solicit: bool) -> Self { + self.solicit = solicit; + self + } + + /// Present this request token (the AUTHORIZATION TOKEN parameter value) on the SUBSCRIBE + /// requests this side sends, so a client authorizes its subscribes the standard draft-17+ + /// way (MoQ request-token). A shared handle, so a replaced token is re-presented on each + /// live subscription as a REQUEST_UPDATE. + pub fn with_request_token(mut self, token: crate::RequestToken) -> Self { + self.request_token = token; + 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); @@ -1118,16 +1210,20 @@ where /// Whether an incoming PUBLISH_NAMESPACE means the peer ignored our SETUP. /// - /// We always declare that advertisements to us must be solicited (MoQ Solicit), and a - /// peer that wrote the option at all proves it implements the extension, whichever - /// value it chose. It also cannot have advertised before reading our SETUP, since our - /// SETUP is what says whether advertising unasked is allowed. So this is a bug in the - /// peer, and a silent one on both sides if we tolerate it. + /// Unless [`with_solicit`](Self::with_solicit) turned it off, we declare that + /// advertisements to us must be solicited (MoQ Solicit), and a peer that wrote the + /// option at all proves it implements the extension, whichever value it chose. It also + /// cannot have advertised before reading our SETUP, since our SETUP is what says whether + /// advertising unasked is allowed. So this is a bug in the peer, and a silent one on + /// both sides if we tolerate it. /// /// Draft-14/15 are exempt: they have no inline NAMESPACE, so a PUBLISH_NAMESPACE /// request is also how a peer answers our SUBSCRIBE_NAMESPACE there, and the message /// alone does not say which it is. fn unsolicited_is_a_violation(&self, declared: Option) -> bool { + if !self.solicit { + return false; + } match self.version { Version::Draft14 | Version::Draft15 => false, _ => declared.is_some(), @@ -1165,6 +1261,37 @@ where return Ok(()); }; + // A request token on the PUBLISH_NAMESPACE authorizes the announce when the session + // grant does not already cover it (MoQ request-token, draft-17 section 9.3.2 / + // draft-18+ section 10.2.2). Purely additive: with no token, or a grant that covers + // the path, everything below is unchanged and the origin model decides scope as it + // did before. + let mut token_grant = None; + if let Some(token) = &msg.authorization_token + && !self.auth.covers(crate::auth::Direction::Subscribe, path.as_str()) + { + // The token's grant must cover this announce; it authorizes nothing else and never + // joins the session union. + let admitted = self + .auth + .admit_request( + &self.runtime, + token.0.clone(), + path.clone(), + crate::auth::RequestKind::PublishNamespace, + ) + .await; + match admitted { + Ok(grant) => token_grant = Some(grant), + Err(err) => { + self.write_error(&mut stream, request_id, &err, &err.to_string()) + .await?; + let _ = stream.writer.close().await; + return Ok(()); + } + } + } + match self.start_announce(path.clone(), advert) { Ok(_) => { if let Err(err) = self.write_ok(&mut stream, request_id).await { @@ -1189,7 +1316,7 @@ where // update) is not released twice here. let mut attached = true; let res = self - .run_publish_namespace_updates(&mut stream, &path, msg.cluster, peer, &mut attached) + .run_publish_namespace_updates(&mut stream, &path, msg.cluster, peer, &mut attached, token_grant) .await; if attached { @@ -1229,35 +1356,148 @@ where mut held: Option, peer: cluster::Peer, attached: &mut bool, + mut token_grant: Option, ) -> Result<(), Error> { + // A REQUEST_UPDATE carrying a fresh token refreshes the announce's request grant (MoQ + // request-token); its verify is raced against the stream and the old grant's deadline in + // the loop's single wait (never a bare await), so the old deadline still fires while a + // slow acceptor decides and a peer ending the announce ends it whatever the acceptor + // does. Messages behind a pending verdict wait in `renewal`, handled in order once it + // resolves. + let mut renewal = request_update::Renewal::<(u64, ietf::Body)>::default(); + // Draft-19+ only: the peer finished its send direction, which does not withdraw the + // advertisement there, so stop reading and keep watching the writer. + let mut finished = false; + + // What one turn of the loop resolved to. + enum Turn { + // A whole message off the stream, or one drained from the queue. + Message(u64, ietf::Body), + // The peer finished its send direction. + Fin, + // Draft-17+: our send direction closed (the peer stopped reading or reset it). + WriterClosed(Result<(), Error>), + // The read failed. + Failed(Error), + // The request grant lapsed or was revoked: the announce ends, never the session. + Ended(Error), + // A pending renewal's verdict resolved, for the update with this request id. + Renewal(request_update::Verdict), + } + loop { - let next = kio::wait(|waiter| { - let mut cx = waiter.context(); - if !matches!(self.version, Version::Draft14 | Version::Draft15 | Version::Draft16) - && let Poll::Ready(result) = stream.writer.poll_closed(&mut cx) - { - stream.reader.abort(&Error::Cancel); - return Poll::Ready(result.map(|()| None)); + // Handle one buffered update before reading the next, so answers stay in order. + let turn = if let Some((id, body)) = renewal.next() { + Turn::Message(id, body) + } else { + let version = self.version; + kio::wait(|waiter| { + let mut cx = waiter.context(); + if !matches!(version, Version::Draft14 | Version::Draft15 | Version::Draft16) + && let Poll::Ready(result) = stream.writer.poll_closed(&mut cx) + { + return Poll::Ready(Turn::WriterClosed(result)); + } + if let Some(rg) = token_grant.as_mut() + && let Poll::Ready(err) = rg.poll_ended(waiter) + { + return Poll::Ready(Turn::Ended(err)); + } + if let Poll::Ready(renewed) = renewal.poll(waiter) { + return Poll::Ready(Turn::Renewal(renewed)); + } + // Keep reading while a verdict is pending, so a withdrawal still ends the + // announce: a buffered update must not mask a later terminal. + if !finished { + return match stream + .reader + .poll_decode_maybe::(&mut cx) + { + Poll::Ready(Ok(Some(super::request_stream::Framed(id, body)))) => { + Poll::Ready(Turn::Message(id, body)) + } + Poll::Ready(Ok(None)) => Poll::Ready(Turn::Fin), + Poll::Ready(Err(err)) => Poll::Ready(Turn::Failed(err)), + Poll::Pending => Poll::Pending, + }; + } + Poll::Pending + }) + .await + }; + + let (type_id, body) = match turn { + // A non-terminal message read while a verdict is pending waits for it. + Turn::Message(id, body) if renewal.pending() && !self.terminal_publish_namespace(id) => { + match renewal.queue((id, body), self.version) { + Ok(()) => continue, + // The dispatcher closes only on is_protocol_violation, which excludes + // Error::Session, so the session is closed here. + Err(request_update::Overflow::Session) => { + return Err(request_update::too_many(&mut self.session)); + } + // Drafts below 19 negotiate no limit, so this is a local memory guard, not a + // protocol fault: end this announce, never the session. + Err(request_update::Overflow::Request) => { + if stream.writer.finish().is_ok() { + let _ = stream.writer.closed().await; + } + return Ok(()); + } + } } - stream.reader.poll_varint_maybe(&mut cx) - }) - .await?; - let type_id: u64 = match next { - Some(id) => id, - None => { + // A terminal read while a verdict is pending is handled now, ahead of any queued + // update: nothing more is owed once the peer retracts. + Turn::Message(id, body) => (id, body), + Turn::Fin => { if super::request_stream::fin_cancels(self.version) { return Ok(()); } // The advertisement outlives the peer's send direction. + finished = true; + continue; + } + // Already finished: the advertisement outlived the peer's send direction until now. + Turn::WriterClosed(result) if finished => return result, + Turn::WriterClosed(result) => { + stream.reader.abort(&Error::Cancel); + result?; + if super::request_stream::fin_cancels(self.version) { + return Ok(()); + } return stream.writer.closed().await; } + Turn::Failed(err) => return Err(err), + Turn::Ended(err) => return Err(err), + Turn::Renewal(request_update::Verdict { + verdict, + grant: res, + request_id: rid, + }) => { + // Only a token-authorized announce starts a renewal. + let rg = token_grant.as_mut().expect("a renewal without a request grant"); + // On accept the old grant is dropped and the deadline re-armed (REQUEST_OK). + if rg.renew(verdict, res) { + self.write_ok(stream, rid).await?; + continue; + } + // Per drafts 16 section 9.11.1 / 18 section 10.9.1, a refused or uncovered + // renewal ends only this announce. Answer REQUEST_ERROR, then close the stream; + // the session stays up and the old grant does not survive. + self.write_error(stream, rid, &Error::Unauthorized, "renewal not granted") + .await?; + if stream.writer.finish().is_ok() { + let _ = stream.writer.closed().await; + } + return Ok(()); + } }; + // Drafts 14-16 carry no advertisement parameters to update, so an update the // adapter routed here changes nothing. if type_id == ietf::SubscribeUpdate::ID && matches!(self.version, Version::Draft14 | Version::Draft15 | Version::Draft16) { - stream.reader.decode::().await?; continue; } let terminal = self.terminal_publish_namespace(type_id); @@ -1268,7 +1508,6 @@ where return Err(Error::UnexpectedMessage); } - let body: ietf::Body = stream.reader.decode().await?; let mut data = body.decoder(self.version); if terminal { @@ -1287,54 +1526,76 @@ where return Err(Error::WrongSize); } - // An omitted parameter keeps its value, so the update lands on what the peer - // already advertised. The parameters exist only on a session that negotiated - // the extension; anywhere else they are the peer's violation. - // A different original publisher applies in place too: the origin drains what - // the old one already serves and never splices the two. + // Cluster parameters and a token renewal can ride the same update, so apply the + // routing first, for every update that carries it, before dealing with the token. + // A different original publisher applies in place too: the origin drains what the + // old one already serves and never splices the two. The parameters exist only on a + // session that negotiated the extension; anywhere else they are the peer's violation. + let carries_cluster = msg.hops.is_some() || msg.cost.is_some(); held = match &held { Some(current) => Some(msg.apply(current)), - None if msg.hops.is_some() || msg.cost.is_some() => { + None if carries_cluster => { tracing::warn!(%path, "cluster parameters on a session that negotiated none"); return Err(Error::ProtocolViolation); } None => None, }; - // A path that now runs through us is unusable, so detach rather than keep - // serving it. The update itself is accepted, and reading continues: this - // stream is the only channel the advertisement has, so a later clean path - // arrives here or nowhere. Ending the stream is also not ours to do, since a - // peer MAY legitimately send a path carrying our Hop ID when a redundant - // sibling shares it. - let Some(advert) = self.route(held.as_ref(), &peer) else { - if std::mem::take(attached) { - tracing::debug!(%path, "publish_namespace now loops back; detaching"); - let _ = self.stop_announce(path.clone()); - } - self.write_ok(stream, msg.request_id).await?; - continue; - }; - - tracing::debug!(%path, hops = advert.route.hops.len(), cost = ?advert.route.cost, "publish_namespace update"); - let applied = match *attached { - true => self.update_announce(path.clone(), advert), - // Re-attach: a clean path replaced the reflected one we detached from. - false => self.start_announce(path.clone(), advert).map(|()| *attached = true), + // Re-route only when the update actually changes the route (an omitted parameter + // keeps its value, so a token-only update leaves it untouched). A path that now runs + // through us is unusable, so detach rather than keep serving it; reading continues, + // since this stream is the advertisement's only channel and a later clean path + // arrives here or nowhere. Ending the stream is not ours to do: a peer MAY + // legitimately send a path carrying our Hop ID when a redundant sibling shares it. + let applied: Result<(), Error> = match carries_cluster { + true => match self.route(held.as_ref(), &peer) { + None => { + if std::mem::take(attached) { + tracing::debug!(%path, "publish_namespace now loops back; detaching"); + let _ = self.stop_announce(path.clone()); + } + Ok(()) + } + Some(advert) => { + tracing::debug!(%path, hops = advert.route.hops.len(), cost = ?advert.route.cost, "publish_namespace update"); + match *attached { + true => self.update_announce(path.clone(), advert), + // Re-attach: a clean path replaced the reflected one we detached from. + false => self.start_announce(path.clone(), advert).map(|()| *attached = true), + } + } + }, + false => Ok(()), }; - match applied { - Ok(()) => self.write_ok(stream, msg.request_id).await?, - Err(err) => { - tracing::warn!(%path, %err, "publish_namespace update refused"); - self.write_error(stream, msg.request_id, &err, &err.to_string()).await?; - // The close is the withdrawal; the caller releases what was attached. - if stream.writer.finish().is_ok() { - let _ = stream.writer.closed().await; - } - return Ok(()); + // An unroutable apply withdraws the announce whether or not a token also rides it. + if let Err(err) = &applied { + tracing::warn!(%path, %err, "publish_namespace update refused"); + self.write_error(stream, msg.request_id, err, &err.to_string()).await?; + // The close is the withdrawal; the caller releases what was attached. + if stream.writer.finish().is_ok() { + let _ = stream.writer.closed().await; } + return Ok(()); + } + + // A REQUEST_UPDATE carrying a fresh token refreshes the announce's request grant + // (MoQ request-token), when the announce is token-authorized. The verify is not + // awaited here: it becomes the pending renewal raced against the deadline above. + // The routing above is already applied, so the renewal's answer (written when its + // verdict resolves) is this update's single response, cluster parameters included. + if let Some(token) = msg.authorization_token + && token_grant.is_some() + { + let verdict = + self.auth + .verify_request(token.0, path.clone(), crate::auth::RequestKind::PublishNamespace); + renewal.verify(verdict, msg.request_id); + continue; } + + // No token rides this update: acknowledge it now. + self.write_ok(stream, msg.request_id).await?; } } @@ -1557,7 +1818,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 +1830,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 +1904,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 +1922,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 +2010,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 +2087,35 @@ 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. A SUBSCRIBE always carries the token, so + // a token-bearing client does not self-censor on its connection grant: the token + // authorizes what that grant does not cover, and the server's covers-gate is the + // authority. The token stands in for the grant only, never for the limit this side set + // on the peer, which still filters and, narrowing, revokes. + let token_authorized = self.request_token.peek().is_some(); + let allowed = match token_authorized { + true => self + .auth + .within_limit(crate::auth::Direction::Subscribe, broadcast_path.as_str()), + false => self + .auth + .allows(crate::auth::Direction::Subscribe, broadcast_path.as_str()), + }; + if !allowed { + request.reject(Error::Unauthorized); + return; + } + let gate_for = match token_authorized { + true => crate::auth::Gate::limit, + false => crate::auth::Gate::new, + }; + let mut gate = gate_for( + 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 +2130,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 +2141,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 +2165,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); @@ -1900,6 +2226,8 @@ where // Write Subscribe message. The aggregate is read now: a subscriber can join while // the request ID and stream were awaited, and nothing updates the priority after. let priority = target.subscription().map(|s| s.priority).unwrap_or(0); + // Re-sent unchanged on a draft-14 request-token renewal, whose fields are all mandatory. + let subscriber_priority = super::priority::to_wire(priority); let written = async { stream.writer.varint(ietf::Subscribe::ID).await?; stream @@ -1908,7 +2236,7 @@ where request_id, track_namespace: broadcast_path.to_owned(), track_name: track_name.into(), - subscriber_priority: super::priority::to_wire(priority), + subscriber_priority, group_order: GroupOrder::Descending, filter: join.filter, fill: join.fill, @@ -1916,6 +2244,7 @@ where properties_wanted: matches!(target, Target::Request(_)), forward: true, range_filters: false, + authorization_token: self.request_token.peek().map(crate::ietf::token::RequestToken), }) .await } @@ -2066,6 +2395,12 @@ where priority, largest, } = accepted; + // Where this subscription began: the object after the Largest Object SUBSCRIBE_OK + // named, which every draft before 20 subscribes at. Draft-14's renewal restates it. + let renewal_start = largest.map_or(ietf::Location { group: 0, object: 0 }, |largest| ietf::Location { + group: largest.group, + object: largest.object.saturating_add(1), + }); // A resumed copy opts out of the properties from draft-20, so it keeps the units it // learned. Before that the answer declares them again. let timescale = timescale.or(resumed.as_ref().and_then(|idle| idle.timescale)); @@ -2149,16 +2484,41 @@ where // does not disturb subscriptions already in flight. enum End { Idle, + Revoked, + /// The client replaced its request token: re-present it on this live subscription. + Renew(Option), + /// The publisher answered the renewal in flight (REQUEST_OK / REQUEST_ERROR), so + /// a replacement coalesced behind it may now be sent. + Answered, 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(); + // The request token last presented on this subscription (its initial value). + let mut last_token = self.request_token.peek(); + // At most one renewal (SUBSCRIBE_UPDATE) is left unanswered at a time: a replacement + // that arrives while one is in flight is coalesced into `last_token` and sent once + // the outstanding one is answered, so the sender never outruns the receiver's + // MAX_REQUEST_UPDATES credit (draft-19 section 10.3.1.7) whatever its value, without + // reading it. `in_flight` is the token value awaiting an answer, `None` when nothing + // is outstanding. Draft-14 answers no accepted renewal, so it cannot pace on answers + // and re-presents each change directly; it also advertises no credit to exceed. + let throttle = !matches!(self.version, Version::Draft14); + // The token awaiting an answer, and on drafts 15 and 16 the routing that brings the + // answer back here. + let mut in_flight: Option<(crate::setup::Token, Option)> = None; + // Bumped by `read_publish_done` on each renewal answer; the loop releases the + // coalesced replacement when it advances past `seen_answers`. + let answers = kio::Shared::new(0u64); + let mut seen_answers = 0u64; let idle = { - let mut done = std::pin::pin!(Self::read_publish_done(&mut stream.reader, self.version)); + let mut done = std::pin::pin!(Self::read_publish_done(&mut stream.reader, self.version, &answers)); loop { let end = kio::wait(|waiter| { if !fetch_done @@ -2167,6 +2527,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)); @@ -2189,11 +2552,86 @@ where if !subscribed || demand.poll_unused(waiter).is_ready() { return Poll::Ready(End::Idle); } + if let Poll::Ready(token) = self.request_token.poll_changed(&last_token, waiter) { + return Poll::Ready(End::Renew(token)); + } + // Only wait on an answer while a renewal is actually outstanding. + if throttle + && in_flight.is_some() + && let Poll::Ready(count) = answers.poll(waiter, |count| match **count != seen_answers { + true => Poll::Ready(()), + false => Poll::Pending, + }) { + seen_answers = *count; + return Poll::Ready(End::Answered); + } waiter.poll_future(done.as_mut()).map(End::Done) }) .await; match end { + // A replaced token is re-presented as a token-only REQUEST_UPDATE; the read + // future above consumes the answer. The subscribe stream's writer is a + // disjoint borrow from its reader, so writing here does not disturb the read. + End::Renew(token) => { + last_token = token.clone(); + // Send only when no renewal is outstanding; otherwise coalesce, leaving the + // newest in `last_token` to go out on End::Answered. A cleared token (`None`) + // is not a renewal and sends nothing. + let send_now = !throttle || in_flight.is_none(); + if send_now && let Some(token) = token { + match self + .send_request_token_update( + &mut stream.writer, + request_id, + subscriber_priority, + renewal_start, + token.clone(), + ) + .await + { + Ok(follow_up) if throttle => { + in_flight = Some((token, follow_up)); + // Only an answer that arrives after this send releases the + // coalesced follow-up, so a stray earlier answer cannot. + seen_answers = *answers.lock(); + } + Ok(_) => {} + // A failed send does not end the subscription: it continues on the old + // grant until that lapses, and the next change re-presents the token. + Err(err) => tracing::debug!(%err, "failed to re-present the request token"), + } + } + } + End::Answered => { + // The outstanding renewal was answered. If the credential changed while it + // was in flight, present the newest now (a cleared token sends nothing); + // otherwise the publisher already holds the latest. + let was = in_flight.take().map(|(token, _)| token); + if last_token != was + && let Some(token) = last_token.clone() + { + match self + .send_request_token_update( + &mut stream.writer, + request_id, + subscriber_priority, + renewal_start, + token.clone(), + ) + .await + { + Ok(follow_up) => in_flight = Some((token, follow_up)), + Err(err) => tracing::debug!(%err, "failed to re-present the request token"), + } + } + } + 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 +2705,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 +2730,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 +2746,7 @@ where Fetch(group::Request), Subscribe, Expired, + Revoked, Closed, } let resume = loop { @@ -2314,6 +2754,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 +2806,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, } }; @@ -2376,16 +2824,96 @@ where /// /// The publisher must send it before its FIN (draft-19 section 3.3.2), so a FIN /// without one is a failed request, not a clean end. - async fn read_publish_done(reader: &mut Reader, version: Version) -> Result { - match reader.varint_maybe().await? { - Some(ietf::PublishDone::ID) => {} - Some(_) => return Err(Error::UnexpectedMessage), - None => return Err(Error::ProtocolViolation), + /// + /// A request-token renewal we sent (SUBSCRIBE_UPDATE) is answered on this same stream + /// (REQUEST_OK / REQUEST_ERROR on draft-15+; draft-14 is silent on an accepted renewal and + /// answers a refused one with SUBSCRIBE_ERROR). Each answer bumps `answers` so the send + /// loop can release the renewal it coalesced behind the one in flight; the read continues + /// regardless, since the subscription ends on its PUBLISH_DONE, which the publisher sends + /// right after a refused renewal's REQUEST_ERROR, not on the answer itself. + async fn read_publish_done( + reader: &mut Reader, + version: Version, + answers: &kio::Shared, + ) -> Result { + loop { + match reader.varint_maybe().await? { + Some(ietf::PublishDone::ID) => { + let msg: ietf::PublishDone = reader.decode().await?; + tracing::debug!(message = ?msg, "received publish done"); + msg.end(version)?; + return Ok(msg.stream_count); + } + Some(ietf::RequestOk::ID) => { + let msg: ietf::RequestOk = reader.decode().await?; + tracing::debug!(message = ?msg, "request token renewal accepted"); + *answers.lock() += 1; + } + Some(ietf::RequestError::ID) => { + // Draft-15+ generalized SUBSCRIBE_ERROR into REQUEST_ERROR at the same id; + // draft-14 still frames it as SUBSCRIBE_ERROR. Either way it refuses the + // renewal; the subscription then ends on the PUBLISH_DONE that follows, not + // on this answer. + match version { + Version::Draft14 => { + let msg: ietf::SubscribeError = reader.decode().await?; + tracing::warn!(message = ?msg, "request token renewal refused"); + } + _ => { + let msg: ietf::RequestError = reader.decode().await?; + tracing::warn!(message = ?msg, "request token renewal refused"); + } + } + *answers.lock() += 1; + } + Some(_) => return Err(Error::UnexpectedMessage), + None => return Err(Error::ProtocolViolation), + } } - let msg: ietf::PublishDone = reader.decode().await?; - tracing::debug!(message = ?msg, "received publish done"); - msg.end(version)?; - Ok(msg.stream_count) + } + + /// Re-present the client's request token on a live subscription as a token-only + /// REQUEST_UPDATE (SUBSCRIBE_UPDATE), so a refreshed credential reaches the publisher + /// before the old grant lapses (MoQ request-token renewal). + /// + /// Token-only: draft-15+ omits the range, priority and forward flag, which an update keeps + /// as they are. Draft-14's fields are fixed, so it restates the subscription's own: + /// `start` is where it began (the Largest Object after SUBSCRIBE_OK), which the peer + /// must not see decrease. The answer, if the version sends one, is read on the + /// subscription stream by [`read_publish_done`](Self::read_publish_done); on drafts 15 + /// and 16 the returned guard routes it there until dropped. + async fn send_request_token_update( + &self, + writer: &mut crate::coding::Writer, + subscription_id: RequestId, + subscriber_priority: u8, + start: ietf::Location, + token: crate::setup::Token, + ) -> Result, Error> { + let request_id = self.control.next_request_id(&self.runtime).await?; + // Drafts 15 and 16 answer naming the update itself; draft 14 answers no accepted + // update, and later drafts answer on the subscription's own stream. + let follow_up = matches!(self.version, Version::Draft15 | Version::Draft16) + .then(|| self.control.follow_up(request_id, subscription_id)); + // Draft-14/15/16 name the subscription being updated; draft-17+ identifies it by stream. + let subscription_request_id = match self.version { + Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(subscription_id), + _ => None, + }; + let draft14 = self.version == Version::Draft14; + writer + .encode_message(&ietf::SubscribeUpdate { + request_id, + subscription_request_id, + start_location: start, + end_group: 0, + subscriber_priority: draft14.then_some(subscriber_priority), + forward: draft14.then_some(true), + filter: None, + authorization_token: Some(crate::ietf::token::RequestToken(token)), + }) + .await?; + Ok(follow_up) } /// Tell the publisher to stop serving a subscription we are walking away from. @@ -4316,7 +4844,8 @@ mod tests { let mut session = ScriptedSession::eof(responses); let (_, recv) = session.open_bi().await.unwrap(); let mut reader = Reader::new(recv, Version::Draft19); - let result = Subscriber::::read_publish_done(&mut reader, Version::Draft19).await; + let result = + Subscriber::::read_publish_done(&mut reader, Version::Draft19, &kio::Shared::new(0)).await; if clean { assert_eq!(result.unwrap(), 0); } else { @@ -4467,8 +4996,13 @@ mod tests { } /// What an unsolicited advertisement means to a subscriber on `version` whose peer - /// declared `solicit`. + /// declared `solicit`, when we declared MoQ Solicit ourselves. fn unsolicited_is_a_violation(solicit: Option, version: Version) -> bool { + unsolicited_is_a_violation_to(true, solicit, version) + } + + /// The same, for a subscriber whose own SETUP declared MoQ Solicit only if `ours`. + fn unsolicited_is_a_violation_to(ours: bool, solicit: Option, version: Version) -> bool { let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let session = crate::lite::test_transport::SinkSession::new(Default::default()); let peer_setup = peer::PeerSetup::default(); @@ -4491,10 +5025,23 @@ mod tests { tasks, Default::default(), ) + .with_solicit(ours) .unsolicited_is_a_violation(solicit) } - /// We always declare that advertisements to us must be solicited, so a peer that + /// A side that declined MoQ Solicit invited unsolicited advertisements, so one is + /// expected even from a peer that implements the extension. + #[moq_net_sim::test] + async fn an_announce_to_a_side_that_declined_solicit_is_fine() { + for solicit in [Some(true), Some(false), None] { + assert!( + !unsolicited_is_a_violation_to(false, solicit, Version::Draft17), + "{solicit:?}" + ); + } + } + + /// By default we declare that advertisements to us must be solicited, so a peer that /// implements the extension and announces anyway has a bug. Tolerating it is what /// keeps that bug invisible on both sides, so the session goes. /// @@ -6232,6 +6779,7 @@ mod tests { request_id: RequestId(0), track_namespace: crate::Path::new("room/host"), cluster: None, + authorization_token: None, }; let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( stream, @@ -6294,6 +6842,7 @@ mod tests { request_id: RequestId(0), track_namespace: path.borrow(), cluster: None, + authorization_token: None, }; subscriber .run_publish_namespace_stream(stream, msg, cluster::Peer::default(), None) @@ -6350,6 +6899,7 @@ mod tests { request_id: RequestId(0), track_namespace: path.borrow(), cluster: None, + authorization_token: None, }; subscriber .run_publish_namespace_stream(stream, msg, cluster::Peer::default(), None) @@ -6660,6 +7210,7 @@ mod tests { request_id: RequestId(3 + 2 * i as u64), hops: Some(advert.hops.clone()), cost: Some(advert.cost), + authorization_token: None, }) .await .unwrap(); @@ -6797,6 +7348,7 @@ mod tests { Some(clean.clone()), peer, &mut attached, + None, )); for _ in 0..100 { @@ -6843,6 +7395,7 @@ mod tests { Some(clean.clone()), peer, &mut attached, + None, )); // Both updates apply, then the loop parks on the exhausted script. The @@ -6889,6 +7442,7 @@ mod tests { request_id: RequestId(3), hops: None, cost: Some(0), + authorization_token: None, }) .await .unwrap(); @@ -6910,6 +7464,7 @@ mod tests { Some(held.clone()), peer, &mut attached, + None, )); for _ in 0..20 { assert!(futures::poll!(run.as_mut()).is_pending(), "the stream stays open"); @@ -6958,6 +7513,7 @@ mod tests { Some(clean.clone()), peer, &mut attached, + None, )); for _ in 0..20 { if let std::task::Poll::Ready(res) = futures::poll!(run.as_mut()) { @@ -7004,6 +7560,7 @@ mod tests { Some(clean.clone()), peer, &mut attached, + None, )); for _ in 0..20 { assert!( @@ -7044,6 +7601,7 @@ mod tests { cost: 0, ..clean.clone() }), + authorization_token: None, }) .await .unwrap(); @@ -7062,6 +7620,7 @@ mod tests { Some(clean.clone()), peer, &mut attached, + None, )); let mut result = None; for _ in 0..20 { @@ -7201,6 +7760,1231 @@ mod tests { ); } } + + /// A SUBSCRIBE_OK for the subscribe stream, framed as the peer sends it, with a Largest so + /// the subscription reaches Established. The scripted session parks after it, keeping the + /// subscription live so the steady-state loop runs. + async fn subscribe_ok_bytes(version: Version) -> Vec { + let log = crate::lite::test_transport::Log::default(); + let mut writer = crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); + writer.varint(ietf::SubscribeOk::ID).await.unwrap(); + writer + .encode(&ietf::SubscribeOk { + request_id: match version { + Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(RequestId(1)), + _ => None, + }, + track_alias: 7, + largest: Some(ietf::Location { group: 0, object: 0 }), + properties: Default::default(), + }) + .await + .unwrap(); + log.writes.lock().unwrap().clone() + } + + /// Replacing the client's request token re-presents it on a live subscription as a token-only + /// SUBSCRIBE_UPDATE (MoQ request-token renewal), on a legacy draft (draft-14 trailing block) + /// and a strict one (draft-18 message parameters). SUBSCRIBE_UPDATE carries the token from + /// draft-14 on, unlike PUBLISH_NAMESPACE_UPDATE (draft-17+ only). + #[moq_net_sim::test] + async fn setting_a_new_request_token_re_presents_it_on_a_live_subscription() { + for version in [Version::Draft14, Version::Draft18] { + let first = bytes::Bytes::from_static(&[0x03, 0x00, b'a', b'a']); + let second = bytes::Bytes::from_static(&[0x03, 0x00, b'b', b'b']); + + let session = crate::lite::test_transport::ScriptedSession::new(subscribe_ok_bytes(version).await); + let log = session.log.clone(); + let (tasks, _task_set) = crate::util::TaskSet::new(); + let token = crate::RequestToken::new(Some(typed(&first))); + let mut subscriber = Subscriber::new( + crate::time::Clock::sim(), + session, + crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), + Control::new(None, false), + None, + peer::PeerSetup::default(), + crate::Hop::new(1).unwrap(), + None, + version, + tasks, + Default::default(), + ) + .with_request_token(token.clone()); + + let producer = crate::broadcast::Info::default().produce(); + let mut dynamic = producer.dynamic(); + let consumer = producer.consume(); + let track = consumer.track("video").unwrap(); + // Held for the test so the track never reads as unused (which would cancel it). + let subscription = track.subscribe(None); + let request = dynamic.requested_track().await.expect("no track requested"); + + let serving = moq_net_sim::spawn(async move { + subscriber.run_subscribe(Path::new("broadcast"), dynamic, request).await; + }); + + // The initial token rides the SUBSCRIBE. + let mut initial = false; + for _ in 0..200 { + if occurrences(&log, &first) >= 1 { + initial = true; + break; + } + settle().await; + } + assert!(initial, "{version}: the initial token must ride the SUBSCRIBE"); + + // Replacing it re-presents the new token on the live subscription as a SUBSCRIBE_UPDATE. + token.set(Some(typed(&second))); + let mut renewed = false; + for _ in 0..200 { + if occurrences(&log, &second) >= 1 { + renewed = true; + break; + } + settle().await; + } + assert!( + renewed, + "{version}: a replaced token must be re-presented on the live subscription" + ); + + // The renewal leaves the subscription's range alone: draft-14 restates where it + // began (the object after SUBSCRIBE_OK's Largest Object, {0, 0} here), and draft-15+ + // omits the filter, priority and forward flag so they keep their values. + let draft14 = version == Version::Draft14; + let mut expected = Vec::new(); + for request_id in 0..16 { + let log = crate::lite::test_transport::Log::default(); + let mut writer = + crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); + writer.varint(ietf::SubscribeUpdate::ID).await.unwrap(); + writer + .encode(&ietf::SubscribeUpdate { + request_id: RequestId(request_id), + subscription_request_id: draft14.then_some(RequestId(1)), + start_location: ietf::Location { group: 0, object: 1 }, + end_group: 0, + // The SUBSCRIBE went out at the lowest wire priority; the renewal restates it. + subscriber_priority: draft14.then_some(0xff), + forward: draft14.then_some(true), + filter: None, + authorization_token: Some(crate::ietf::token::RequestToken(typed(&second))), + }) + .await + .unwrap(); + expected.push(log.writes.lock().unwrap().clone()); + } + assert!( + expected.iter().any(|bytes| occurrences(&log, bytes) == 1), + "{version}: the renewal must keep the subscription's range" + ); + + drop(subscription); + drop(track); + drop(consumer); + serving.abort(); + } + } + + /// A token-bearing client does not self-censor on its connection grant: the + /// subscriber sends a SUBSCRIBE for a path its connection grant does not cover, carrying the + /// token; a token-less client with the same grant rejects it locally, as before. + #[moq_net_sim::test] + async fn a_token_bearing_client_subscribes_outside_its_connection_grant() { + const VERSION: Version = Version::Draft18; + let token = bytes::Bytes::from_static(&[0x03, 0x00, b'o', b'k']); + + // A connection grant of "other", which does not cover "room/x". Held for the run. + fn seed_auth() -> (crate::auth::Handle, crate::auth::Token) { + let auth = crate::auth::Handle::new(true); + let cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); + auth.granted( + 0, + crate::auth::Grant { + publish: crate::Patterns::new(), + subscribe: crate::Pattern::subtree("other").unwrap().into(), + expires: None, + }, + ); + (auth, cred) + } + assert!( + !seed_auth().0.allows(crate::auth::Direction::Subscribe, "room/x"), + "the connection grant must not cover the subscribed path" + ); + + // With a token, the SUBSCRIBE for room/x reaches the wire carrying the token. + let session = crate::lite::test_transport::ScriptedSession::new(subscribe_ok_bytes(VERSION).await); + let log = session.log.clone(); + let (tasks, _task_set) = crate::util::TaskSet::new(); + let (auth, _cred) = seed_auth(); + let mut subscriber = Subscriber::new( + crate::time::Clock::sim(), + session, + crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), + Control::new(None, false), + None, + peer::PeerSetup::default(), + crate::Hop::new(1).unwrap(), + None, + VERSION, + tasks, + Default::default(), + ) + .with_auth(auth) + .with_request_token(crate::RequestToken::new(Some(typed(&token)))); + let producer = crate::broadcast::Info::default().produce(); + let mut dynamic = producer.dynamic(); + let consumer = producer.consume(); + let track = consumer.track("video").unwrap(); + let subscription = track.subscribe(None); + let request = dynamic.requested_track().await.expect("no track requested"); + let serving = moq_net_sim::spawn(async move { + subscriber.run_subscribe(Path::new("room/x"), dynamic, request).await; + }); + let mut sent = false; + for _ in 0..200 { + if occurrences(&log, &token) >= 1 { + sent = true; + break; + } + settle().await; + } + assert!( + sent, + "a token-bearing client must subscribe outside its connection grant" + ); + drop(subscription); + drop(track); + drop(consumer); + serving.abort(); + + // Without a token, the same grant rejects the subscribe locally: no SUBSCRIBE is sent. + let session2 = crate::lite::test_transport::ScriptedSession::new(subscribe_ok_bytes(VERSION).await); + let log2 = session2.log.clone(); + let (tasks2, _task_set2) = crate::util::TaskSet::new(); + let (auth2, _cred2) = seed_auth(); + let mut subscriber2 = Subscriber::new( + crate::time::Clock::sim(), + session2, + crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), + Control::new(None, false), + None, + peer::PeerSetup::default(), + crate::Hop::new(1).unwrap(), + None, + VERSION, + tasks2, + Default::default(), + ) + .with_auth(auth2); + let producer2 = crate::broadcast::Info::default().produce(); + let mut dynamic2 = producer2.dynamic(); + let consumer2 = producer2.consume(); + let track2 = consumer2.track("video").unwrap(); + let subscription2 = track2.subscribe(None); + let request2 = dynamic2.requested_track().await.expect("no track requested"); + let serving2 = moq_net_sim::spawn(async move { + subscriber2.run_subscribe(Path::new("room/x"), dynamic2, request2).await; + }); + for _ in 0..80 { + settle().await; + } + assert!( + !control_message_types(&log2, VERSION).contains(&ietf::Subscribe::ID), + "a token-less client rejects a subscribe outside its grant locally" + ); + drop(subscription2); + drop(track2); + drop(consumer2); + serving2.abort(); + } + + /// A subscriber whose session grant covers only `other`, with an app auth acceptor + /// wired in, so a PUBLISH_NAMESPACE for `room/alice` falls to its request token. The + /// subscribe stream's reader is scripted with `first_script` (a REQUEST_UPDATE, for the + /// renewal test). Returns the presented credential to keep it alive. + fn auth_announce_harness( + version: Version, + first_script: Vec, + ) -> ( + Subscriber, + crate::auth::Requests, + crate::auth::Token, + origin::Consumer, + crate::lite::test_transport::ScriptedSession, + ) { + auth_announce_harness_on( + version, + crate::lite::test_transport::ScriptedSession::per_stream(vec![first_script]), + ) + } + + /// [`auth_announce_harness`] over a given session, such as one that finishes the stream + /// after its script. + fn auth_announce_harness_on( + version: Version, + session: crate::lite::test_transport::ScriptedSession, + ) -> ( + Subscriber, + crate::auth::Requests, + crate::auth::Token, + origin::Consumer, + crate::lite::test_transport::ScriptedSession, + ) { + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let consumer = origin.consume(); + let (tasks, task_set) = crate::util::TaskSet::new(); + std::mem::forget(task_set); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + + let auth = crate::auth::Handle::new(true); + let requests = auth.requests().unwrap(); + let cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); + auth.granted( + 0, + crate::auth::Grant { + publish: crate::Patterns::new(), + subscribe: crate::Pattern::subtree("other").unwrap().into(), + expires: None, + }, + ); + assert!( + !auth.allows(crate::auth::Direction::Subscribe, "room/alice"), + "the session grant must not cover the announced path" + ); + + let subscriber = Subscriber::new( + crate::time::Clock::sim(), + session.clone(), + origin, + Control::new(None, false), + None, + peer_setup, + crate::Hop::new(1).unwrap(), + None, + version, + tasks, + Default::default(), + ) + .with_auth(auth); + + (subscriber, requests, cred, consumer, session) + } + + /// A grant to publish everything, lapsing in `secs` (or never), on the subscriber's clock: + /// what an announcing peer's token must carry. + fn publish_grant_expiring(runtime: &crate::time::Clock, secs: Option) -> crate::auth::Grant { + crate::auth::Grant { + publish: crate::Pattern::all().into(), + subscribe: crate::Patterns::new(), + expires: secs.map(|s| runtime.now().checked_add(std::time::Duration::from_secs(s)).unwrap()), + } + } + + /// A request token whose Token Type takes two varint bytes. + fn announce_token() -> crate::ietf::token::RequestToken { + crate::ietf::token::RequestToken(crate::setup::Token { + kind: 300, + value: vec![0x00, 0xff], + }) + } + + fn token_publish_namespace() -> ietf::PublishNamespace<'static> { + ietf::PublishNamespace { + request_id: RequestId(1), + track_namespace: crate::Path::new("room/alice"), + cluster: None, + authorization_token: Some(announce_token()), + } + } + + /// One REQUEST_UPDATE on the announce stream carrying a fresh token, framed as the peer + /// sends it. + async fn publish_namespace_update_with_token(version: Version) -> Vec { + let log = crate::lite::test_transport::Log::default(); + let mut writer = crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); + let msg = ietf::PublishNamespaceUpdate { + request_id: RequestId(3), + hops: None, + cost: None, + authorization_token: Some(announce_token()), + }; + writer.varint(ietf::PublishNamespaceUpdate::ID).await.unwrap(); + writer.encode(&msg).await.unwrap(); + log.writes.lock().unwrap().clone() + } + + /// One REQUEST_UPDATE on the announce stream carrying both a fresh token and new cluster + /// parameters (HOP_PATH/ROUTE_COST), framed as the peer sends it. + async fn publish_namespace_update_with_token_and_cluster(version: Version) -> Vec { + let log = crate::lite::test_transport::Log::default(); + let mut writer = crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); + let msg = ietf::PublishNamespaceUpdate { + request_id: RequestId(3), + hops: Some(hop_path(&[7, 9])), + cost: Some(0), + authorization_token: Some(announce_token()), + }; + writer.varint(ietf::PublishNamespaceUpdate::ID).await.unwrap(); + writer.encode(&msg).await.unwrap(); + log.writes.lock().unwrap().clone() + } + + /// A request token on a PUBLISH_NAMESPACE the session grant does not cover authorizes + /// the announce: the subscriber verifies it through the acceptor and attaches the route. + #[moq_net_sim::test] + async fn a_publish_namespace_token_authorizes_an_uncovered_announce() { + const VERSION: Version = Version::Draft18; + let (mut subscriber, mut requests, _cred, consumer, session) = auth_announce_harness(VERSION, Vec::new()); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + + let acceptor = async { + let mut held = Vec::new(); + loop { + let request = requests.next().await.expect("a request"); + held.push(request.accept(crate::auth::Grant::all())); + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + + let mut announced = false; + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(run.as_mut()).is_pending(), "the announce ended early"); + if routed_now(&consumer, "room/alice").is_some() { + announced = true; + break; + } + settle().await; + } + assert!(announced, "a valid request token must authorize the announce"); + } + + /// A refused request token is answered UNAUTHORIZED and the announce is not attached; the + /// session is untouched. + #[moq_net_sim::test] + async fn a_refused_publish_namespace_token_is_not_announced() { + const VERSION: Version = Version::Draft18; + let (mut subscriber, mut requests, _cred, consumer, session) = auth_announce_harness(VERSION, Vec::new()); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + + let acceptor = async { + let request = requests.next().await.expect("a request"); + request.reject(crate::SessionError::Unauthorized, "no"); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + + let mut ended = false; + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + if futures::poll!(run.as_mut()).is_ready() { + ended = true; + break; + } + settle().await; + } + assert!(ended, "a refused announce ends its own stream"); + assert!( + routed_now(&consumer, "room/alice").is_none(), + "a refused token must not attach the route" + ); + } + + /// A REQUEST_UPDATE the acceptor renews keeps a token-authorized announce alive past the + /// old grant's expiry: the subscriber re-verifies the token off the announce stream and + /// re-arms the deadline. + #[moq_net_sim::test] + async fn a_publish_namespace_renewal_extends_past_the_old_expiry() { + const VERSION: Version = Version::Draft18; + let (mut subscriber, mut requests, _cred, consumer, session) = + auth_announce_harness(VERSION, publish_namespace_update_with_token(VERSION).await); + let rt = subscriber.runtime.clone(); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + + let answered = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); + let acceptor = { + let answered = answered.clone(); + async move { + let mut held = Vec::new(); + // The first grant lapses in 60s; the renewal never expires. + let first = requests.next().await.expect("a request"); + held.push(first.accept(publish_grant_expiring(&rt, Some(60)))); + answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let renewal = requests.next().await.expect("a renewal"); + held.push(renewal.accept(publish_grant_expiring(&rt, None))); + answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + std::future::pending::<()>().await + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(run.as_mut()).is_pending(), + "the announce ended during setup" + ); + if answered.load(std::sync::atomic::Ordering::Relaxed) >= 2 { + break; + } + settle().await; + } + assert_eq!( + answered.load(std::sync::atomic::Ordering::Relaxed), + 2, + "acceptor never answered both tokens" + ); + // Let the loop apply the renewal it read. + for _ in 0..20 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(run.as_mut()).is_pending()); + settle().await; + } + + // Past the original 60s expiry: the renewal re-armed the deadline, so the announce + // stays attached. + moq_net_sim::advance(std::time::Duration::from_secs(120)).await; + for _ in 0..50 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(run.as_mut()).is_pending(), + "renewal did not extend the announce" + ); + settle().await; + } + assert!( + routed_now(&consumer, "room/alice").is_some(), + "the renewed announce must still be attached" + ); + } + + /// Per drafts 16 section 9.11.1 / 18 section 10.9.1, a refused PUBLISH_NAMESPACE + /// renewal ends only that announce at once, by closing its stream, not by waiting for the old + /// 60s grant to lapse. The session stays up. + #[moq_net_sim::test] + async fn a_refused_publish_namespace_renewal_ends_the_announce() { + const VERSION: Version = Version::Draft18; + let (mut subscriber, mut requests, _cred, _consumer, session) = + auth_announce_harness(VERSION, publish_namespace_update_with_token(VERSION).await); + let rt = subscriber.runtime.clone(); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + + let answered = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); + let acceptor = { + let answered = answered.clone(); + async move { + // The first grant lasts 60s; the renewal is refused. + let first = requests.next().await.expect("a request"); + let _issued = first.accept(publish_grant_expiring(&rt, Some(60))); + answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let renewal = requests.next().await.expect("a renewal"); + renewal.reject(crate::SessionError::Unauthorized, "no"); + answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + std::future::pending::<()>().await + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + + // No time is advanced: the refusal must end the announce on its own, not by letting the + // 60s grant lapse. + let mut result = None; + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + if let std::task::Poll::Ready(res) = futures::poll!(run.as_mut()) { + result = Some(res); + break; + } + settle().await; + } + assert_eq!( + answered.load(std::sync::atomic::Ordering::Relaxed), + 2, + "acceptor never answered both tokens" + ); + assert!( + matches!(result, Some(Ok(()))), + "a refused renewal ends the announce cleanly by closing the stream, got {result:?}" + ); + assert!( + session.log.closes().is_empty(), + "the session must stay up: {:?}", + session.log.closes() + ); + } + /// A REQUEST_UPDATE carrying both a fresh token and cluster parameters applies both: the + /// renewal re-arms the grant and the HOP_PATH/ROUTE_COST re-route the advertisement, + /// answered with the renewal's single response. Before the fix the token branch + /// short-circuited the loop and the cluster parameters on the same update were dropped. + #[moq_net_sim::test] + async fn an_update_applies_both_a_renewal_and_cluster_params() { + const VERSION: Version = Version::Draft18; + let (mut subscriber, mut requests, _cred, consumer, session) = + auth_announce_harness(VERSION, publish_namespace_update_with_token_and_cluster(VERSION).await); + let rt = subscriber.runtime.clone(); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + + // A negotiated peer over a free link, so the advertised cost is the route's warm cost. + let peer = cluster::Peer { + hop: Some(crate::Hop::new(9).unwrap()), + cost: Some(0), + }; + // The announce arrives already routed at cost 4; the update re-routes it to cost 0. + let mut initial = token_publish_namespace(); + initial.cluster = Some(cluster::Advert { + hops: hop_path(&[7, 9]), + cost: 4, + }); + + let acceptor = { + let rt = rt.clone(); + async move { + let mut held = Vec::new(); + let first = requests.next().await.expect("a request"); + held.push(first.accept(publish_grant_expiring(&rt, Some(60)))); + let renewal = requests.next().await.expect("a renewal"); + held.push(renewal.accept(publish_grant_expiring(&rt, None))); + std::future::pending::<()>().await + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream(stream, initial, peer, None)); + + let mut rerouted = false; + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(run.as_mut()).is_pending(), "the announce ended early"); + if routed_now(&consumer, "room/alice").is_some_and(|route| route.cost.value() == 0) { + rerouted = true; + break; + } + settle().await; + } + assert!( + rerouted, + "the update's cluster parameters must re-route the announce (cost 4 to 0), not be dropped by the renewal" + ); + } + + /// One PUBLISH_NAMESPACE REQUEST_UPDATE carrying a fresh token, keyed to its own Request ID, + /// optionally changing the HOP_PATH and/or ROUTE_COST, framed as the peer sends it. + async fn publish_namespace_update_token_rid( + version: Version, + rid: u64, + hops: Option, + cost: Option, + ) -> Vec { + let log = crate::lite::test_transport::Log::default(); + let mut writer = crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); + let msg = ietf::PublishNamespaceUpdate { + request_id: RequestId(rid), + hops, + cost, + authorization_token: Some(announce_token()), + }; + writer.varint(ietf::PublishNamespaceUpdate::ID).await.unwrap(); + writer.encode(&msg).await.unwrap(); + log.writes.lock().unwrap().clone() + } + + /// Two announce renewals that arrive while a first renewal's verdict is still pending are + /// each verified, and each one's cluster delta is applied, not collapsed. The middle update + /// (0x04) changes only ROUTE_COST (4 to 1) and the last (0x05) only HOP_PATH, so the final + /// cost shows the middle delta survived; the single slot this replaced dropped 0x04, + /// leaving the cost at the initial 4 and never verifying its token. + #[moq_net_sim::test] + async fn two_announce_renewals_buffered_behind_a_pending_verdict_each_apply_and_answer() { + const VERSION: Version = Version::Draft18; + + let mut script = publish_namespace_update_token_rid(VERSION, 0x03, None, None).await; + script.extend(publish_namespace_update_token_rid(VERSION, 0x04, None, Some(1)).await); + script.extend(publish_namespace_update_token_rid(VERSION, 0x05, Some(hop_path(&[7, 11])), None).await); + let (mut subscriber, mut requests, _cred, consumer, session) = auth_announce_harness(VERSION, script); + let rt = subscriber.runtime.clone(); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + + // A negotiated peer over a free link, so the advertised cost is the route's warm cost. + let peer = cluster::Peer { + hop: Some(crate::Hop::new(9).unwrap()), + cost: Some(0), + }; + // The announce arrives routed at cost 4; the buffered updates re-route it. + let mut initial = token_publish_namespace(); + initial.cluster = Some(cluster::Advert { + hops: hop_path(&[7, 9]), + cost: 4, + }); + + // Hold the first renewal (0x03) so the next two (0x04, 0x05) are read and buffered while + // its verdict is pending: the window the single slot used to collapse. + let release = std::sync::Arc::new(Release::default()); + let answered = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); + let acceptor = { + let rt = rt.clone(); + let answered = answered.clone(); + let release = release.clone(); + async move { + let first = requests.next().await.expect("a request"); + let mut held = vec![first.accept(publish_grant_expiring(&rt, None))]; + answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let a = requests.next().await.expect("a renewal"); + release.notified().await; + held.push(a.accept(publish_grant_expiring(&rt, None))); + answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + loop { + let renewal = requests.next().await.expect("a renewal"); + held.push(renewal.accept(publish_grant_expiring(&rt, None))); + answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream(stream, initial, peer, None)); + + for _ in 0..200 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(run.as_mut()).is_pending(), "the announce ended early"); + settle().await; + } + release.notify_one(); + let mut both = false; + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(run.as_mut()).is_pending(), "the announce ended early"); + if answered.load(std::sync::atomic::Ordering::Relaxed) >= 4 + && routed_now(&consumer, "room/alice").is_some_and(|route| route.cost.value() == 1) + { + both = true; + break; + } + settle().await; + } + assert!( + both, + "both buffered updates must each be verified (4 total) and each delta applied: the \ + middle update's cost (1), not the single-slot survivor's initial 4" + ); + } + + /// Draft-19 advertises MAX_REQUEST_UPDATES, so a peer that leaves more outstanding than that on + /// an announce stream broke the negotiated limit: draft-19 section 10.3.1.7 closes the session + /// with TOO_MANY_REQUEST_UPDATES. run_publish_namespace_updates must close explicitly (the + /// dispatcher does not close on an Error::Session), and surface that error too. + #[moq_net_sim::test] + async fn announce_renewals_past_the_advertised_limit_close_the_session() { + const VERSION: Version = Version::Draft19; + + // Exactly one past the limit: the held renewal plus MAX_REQUEST_UPDATES queued behind it. + let last = 0x40 + request_update::MAX_REQUEST_UPDATES; + let mut script = Vec::new(); + for rid in 0x40..=last { + script.extend(publish_namespace_update_token_rid(VERSION, rid, None, None).await); + } + let (mut subscriber, mut requests, _cred, _consumer, session) = auth_announce_harness(VERSION, script); + let rt = subscriber.runtime.clone(); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + + // Accept the initial token, then never answer a renewal, so the buffer only grows. + let acceptor = async move { + let first = requests.next().await.expect("a request"); + let _held = first.accept(publish_grant_expiring(&rt, None)); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + + let mut result = None; + for _ in 0..2000 { + let _ = futures::poll!(acceptor.as_mut()); + if let std::task::Poll::Ready(r) = futures::poll!(run.as_mut()) { + result = Some(r); + break; + } + settle().await; + } + let Some(Err(err)) = result else { + panic!("flooding past the advertised limit must end the announce with an error: {result:?}"); + }; + assert_eq!( + SessionError::from(&err), + SessionError::TooManyRequestUpdates, + "the flood must surface TOO_MANY_REQUEST_UPDATES" + ); + assert!( + session + .log + .closes() + .iter() + .any(|close| close.0 == SessionError::TooManyRequestUpdates.to_code()), + "the flood must close the session with TOO_MANY_REQUEST_UPDATES: {:?}", + session.log.closes() + ); + } + + /// Exactly MAX_REQUEST_UPDATES outstanding (the one being verified plus the queue) is within + /// the advertised limit, so a draft-19 peer holding that many is not faulted: the announce + /// keeps running and the session stays up. + #[moq_net_sim::test] + async fn announce_renewals_at_the_advertised_limit_keep_the_announce() { + const VERSION: Version = Version::Draft19; + + let last = 0x40 + request_update::MAX_REQUEST_UPDATES - 1; + let mut script = Vec::new(); + for rid in 0x40..=last { + script.extend(publish_namespace_update_token_rid(VERSION, rid, None, None).await); + } + let (mut subscriber, mut requests, _cred, _consumer, session) = auth_announce_harness(VERSION, script); + let rt = subscriber.runtime.clone(); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + + let acceptor = async move { + let first = requests.next().await.expect("a request"); + let _held = first.accept(publish_grant_expiring(&rt, None)); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + + for _ in 0..2000 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(run.as_mut()).is_pending(), + "the announce must stay up at exactly the advertised limit" + ); + settle().await; + } + assert!( + session.log.closes().is_empty(), + "no session close at the limit: {:?}", + session.log.closes() + ); + } + + /// Drafts below 19 carry no MAX_REQUEST_UPDATES option, so a peer there agreed to no ceiling: + /// the same flood that closes a draft-19 session must neither end the announce nor close the + /// session on draft-18, because a conforming peer must not be stranded for a limit it never saw. + #[moq_net_sim::test] + async fn older_drafts_do_not_strand_an_announce_flood() { + const VERSION: Version = Version::Draft18; + + let mut script = Vec::new(); + for rid in 0x40..=0x60u64 { + script.extend(publish_namespace_update_token_rid(VERSION, rid, None, None).await); + } + let (mut subscriber, mut requests, _cred, _consumer, session) = auth_announce_harness(VERSION, script); + let rt = subscriber.runtime.clone(); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + + let acceptor = async move { + let first = requests.next().await.expect("a request"); + let _held = first.accept(publish_grant_expiring(&rt, None)); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + + for _ in 0..2000 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(run.as_mut()).is_pending(), + "draft-18 negotiates no limit, so the flood must not strand the announce" + ); + settle().await; + } + assert!( + session.log.closes().is_empty(), + "draft-18 flood must not close the session" + ); + } + + /// On drafts without the option the guard is a memory backstop, not a protocol limit: a flood + /// past it ends the one announce by finishing the stream, never the session. + #[moq_net_sim::test] + async fn an_older_draft_announce_flood_past_the_guard_ends_the_announce() { + const VERSION: Version = Version::Draft18; + + let last = 0x40 + request_update::UNNEGOTIATED_GUARD as u64 + 1; + let mut script = Vec::new(); + for rid in 0x40..=last { + script.extend(publish_namespace_update_token_rid(VERSION, rid, None, None).await); + } + let (mut subscriber, mut requests, _cred, _consumer, session) = auth_announce_harness(VERSION, script); + let rt = subscriber.runtime.clone(); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + + let acceptor = async move { + let first = requests.next().await.expect("a request"); + let _held = first.accept(publish_grant_expiring(&rt, None)); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + + let mut result = None; + for _ in 0..4000 { + let _ = futures::poll!(acceptor.as_mut()); + if let std::task::Poll::Ready(r) = futures::poll!(run.as_mut()) { + result = Some(r); + break; + } + settle().await; + } + assert!( + matches!(result, Some(Ok(()))), + "a flood past the guard ends the announce with Ok: {result:?}" + ); + assert!( + session.log.closes().is_empty(), + "the guard ends the announce, not the session" + ); + } + + /// Like [`auth_announce_harness`] but the session never negotiated the MoQ Auth extension, + /// so its union is `None` forever: `allows` is permissive, `covers` is not. No credential + /// is presented; the acceptor answers request tokens regardless. + fn auth_announce_harness_no_ext( + version: Version, + ) -> ( + Subscriber, + crate::auth::Requests, + origin::Consumer, + crate::lite::test_transport::ScriptedSession, + ) { + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let consumer = origin.consume(); + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![Vec::new()]); + let (tasks, task_set) = crate::util::TaskSet::new(); + std::mem::forget(task_set); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + + let auth = crate::auth::Handle::new(false); + let requests = auth.requests().unwrap(); + assert!( + auth.allows(crate::auth::Direction::Subscribe, "room/alice"), + "a None union is permissive for allows" + ); + assert!( + !auth.covers(crate::auth::Direction::Subscribe, "room/alice"), + "a None union does not cover" + ); + + let subscriber = Subscriber::new( + crate::time::Clock::sim(), + session.clone(), + origin, + Control::new(None, false), + None, + peer_setup, + crate::Hop::new(1).unwrap(), + None, + version, + tasks, + Default::default(), + ) + .with_auth(auth); + + (subscriber, requests, consumer, session) + } + + /// On a session without the AUTH extension the union is `None` forever, so `allows` is + /// permissive; a token-bearing PUBLISH_NAMESPACE must still be verified (`covers`), not + /// admitted by that default. Admitted on a covering grant; refused UNAUTHORIZED (not + /// admitted) on refusal. This is the standard moq-transport peer shape the quest targets. + #[moq_net_sim::test] + async fn a_request_token_on_a_no_auth_session_is_verified() { + const VERSION: Version = Version::Draft18; + + // Accepted: the acceptor is consulted (proving the token path, not the permissive + // default, which the origin model would also route) and its grant admits the announce. + { + let (mut subscriber, mut requests, consumer, session) = auth_announce_harness_no_ext(VERSION); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + let consulted = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); + let acceptor = { + let consulted = consulted.clone(); + async move { + let mut held = Vec::new(); + loop { + let request = requests.next().await.expect("a request reaches the acceptor"); + consulted.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + held.push(request.accept(crate::auth::Grant::all())); + } + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + let mut routed = false; + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(run.as_mut()).is_pending(), + "the announce ended during setup" + ); + if routed_now(&consumer, "room/alice").is_some() { + routed = true; + break; + } + settle().await; + } + assert!( + routed, + "a token on a no-auth session must be verified and admitted, not ignored" + ); + assert!( + consulted.load(std::sync::atomic::Ordering::Relaxed) >= 1, + "the token must reach the acceptor, not be admitted by the permissive default" + ); + } + + // Refused: not admitted by the permissive default. + { + let (mut subscriber, mut requests, consumer, session) = auth_announce_harness_no_ext(VERSION); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + let acceptor = async move { + let request = requests.next().await.expect("a request reaches the acceptor"); + request.reject(crate::SessionError::Unauthorized, "no"); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + let mut ended = false; + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + if futures::poll!(run.as_mut()).is_ready() { + ended = true; + break; + } + settle().await; + } + assert!(ended, "a refused token ends the announce"); + assert!( + routed_now(&consumer, "room/alice").is_none(), + "a refused token must not be admitted by the permissive default" + ); + } + } + + /// A peer that ends the announce while a renewal is still being verified ends it here too: + /// with a grant that never expires and an acceptor that never answers the renewal, only + /// the stream closing can end the request, and it must. + #[moq_net_sim::test] + async fn a_closed_announce_ends_while_a_renewal_is_pending() { + const VERSION: Version = Version::Draft18; + let session = crate::lite::test_transport::ScriptedSession::per_stream_eof(vec![ + publish_namespace_update_with_token(VERSION).await, + ]); + let (mut subscriber, mut requests, _cred, consumer, session) = auth_announce_harness_on(VERSION, session); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + let popped = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); + let acceptor = { + let popped = popped.clone(); + async move { + let first = requests.next().await.expect("a request"); + let _issued = first.accept(crate::auth::Grant::all()); + popped.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let _never_answered = requests.next().await.expect("a renewal"); + popped.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + std::future::pending::<()>().await + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + let mut ended = false; + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + if futures::poll!(run.as_mut()).is_ready() { + ended = true; + break; + } + settle().await; + } + assert!( + popped.load(std::sync::atomic::Ordering::Relaxed) >= 1, + "the announce was token-authorized" + ); + assert!( + ended, + "closing the stream ends the announce despite the pending renewal" + ); + assert!( + routed_now(&consumer, "room/alice").is_none(), + "the announce is withdrawn" + ); + } + + /// An announcing peer's token grant is checked on its `publish` patterns: a read-only grant + /// does not admit a PUBLISH_NAMESPACE, a write-only one does. + #[moq_net_sim::test] + async fn an_announce_token_needs_a_publish_grant() { + const VERSION: Version = Version::Draft18; + let read_only = crate::auth::Grant { + publish: crate::Patterns::new(), + subscribe: crate::Pattern::all().into(), + expires: None, + }; + let write_only = crate::auth::Grant { + publish: crate::Pattern::all().into(), + subscribe: crate::Patterns::new(), + expires: None, + }; + for (grant, admitted) in [(read_only, false), (write_only, true)] { + let (mut subscriber, mut requests, consumer, session) = auth_announce_harness_no_ext(VERSION); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + let acceptor = async move { + let request = requests.next().await.expect("a request reaches the acceptor"); + let _issued = request.accept(grant); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + let mut ended = false; + for _ in 0..300 { + let _ = futures::poll!(acceptor.as_mut()); + if futures::poll!(run.as_mut()).is_ready() { + ended = true; + break; + } + if admitted && routed_now(&consumer, "room/alice").is_some() { + break; + } + settle().await; + } + assert_eq!( + routed_now(&consumer, "room/alice").is_some(), + admitted, + "admitted={admitted}" + ); + assert_eq!(ended, !admitted, "a grant that does not cover the announce refuses it"); + } + } + + /// The additive constraint: a token-LESS PUBLISH_NAMESPACE on a no-auth session (None union) + /// is admitted by the origin model exactly as before; the token path is never entered. + #[moq_net_sim::test] + async fn a_token_less_request_on_a_no_auth_session_is_unchanged() { + const VERSION: Version = Version::Draft18; + let (mut subscriber, _requests, consumer, session) = auth_announce_harness_no_ext(VERSION); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + + let mut msg = token_publish_namespace(); + msg.authorization_token = None; + + let mut run = + std::pin::pin!(subscriber.run_publish_namespace_stream(stream, msg, cluster::Peer::default(), None,)); + let mut routed = false; + for _ in 0..500 { + assert!(futures::poll!(run.as_mut()).is_pending(), "the announce ended early"); + if routed_now(&consumer, "room/alice").is_some() { + routed = true; + break; + } + settle().await; + } + assert!( + routed, + "a token-less announce is admitted by the origin model as before" + ); + } + + /// Holds an acceptor until the test releases it. + #[derive(Clone, Default)] + struct Release(kio::Shared); + + impl Release { + fn notify_one(&self) { + *self.0.lock() = true; + } + + async fn notified(&self) { + kio::wait(|waiter| { + self.0 + .poll(waiter, |released| match **released { + true => Poll::Ready(()), + false => Poll::Pending, + }) + .map(|_| ()) + }) + .await + } + } + + /// The typed form of a raw USE_VALUE Token structure with a one-byte Token Type. + fn typed(raw: &[u8]) -> crate::setup::Token { + assert_eq!(raw[0], 0x03, "USE_VALUE"); + crate::setup::Token { + kind: raw[1].into(), + value: raw[2..].to_vec(), + } + } } /// What a SUBSCRIBE asks for: the range it delivers, and the backfill covering a head that diff --git a/rs/moq-net/src/ietf/token.rs b/rs/moq-net/src/ietf/token.rs index e5e1c3558d..3b796fd5aa 100644 --- a/rs/moq-net/src/ietf/token.rs +++ b/rs/moq-net/src/ietf/token.rs @@ -1,16 +1,17 @@ -//! The `AUTHORIZATION TOKEN` Setup Option (draft-ietf-moq-transport-21 section 9.1.4). +//! The `AUTHORIZATION TOKEN` Setup Option (draft-ietf-moq-transport-21 section 9.1.4) and +//! Message Parameter. //! //! The value is the Token structure of section 8.9: an Alias Type, then fields that //! type selects. We advertise no `MAX_AUTH_TOKEN_CACHE_SIZE`, so its default of 0 means //! no alias is ever registered and every token arrives by value. use crate::{ - Error, SessionError, + DecodeError, Error, SessionError, coding::{Decoder, EncodeError, Encoder}, setup::Token, }; -use super::{ParameterBytes, Parameters, Version}; +use super::{Param, ParameterBytes, Parameters, Version}; /// Retire a registered alias. const DELETE: u64 = 0x0; @@ -28,37 +29,72 @@ const USE_VALUE: u64 = 0x3; pub fn from_setup(params: &Parameters, version: Version) -> Result, Error> { params .get_bytes(ParameterBytes::AuthorizationToken) - .map(|value| decode(value, version)) + .map(|value| decode(value, version, true).map_err(Error::Session)) .transpose() } /// Present `token` in our SETUP, by value. #[cfg_attr(not(test), expect(dead_code))] pub fn into_setup(params: &mut Parameters, token: &Token, version: Version) -> Result<(), EncodeError> { + params.set_bytes(ParameterBytes::AuthorizationToken, encode(token, version)?); + Ok(()) +} + +/// An `AUTHORIZATION TOKEN` parameter on a request message, decoded by the SETUP option's +/// rules except that a registration overflows the cache instead of falling back to a value. +/// +/// The parameter may repeat, so a request decodes it as a `Vec` and every instance is checked. +#[derive(Clone, PartialEq, Eq)] +pub struct RequestToken(pub Token); + +// A request message is logged with `Debug`, so the credential shows only its type and length. +impl std::fmt::Debug for RequestToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "RequestToken(kind={}, <{} bytes>)", self.0.kind, self.0.value.len()) + } +} + +impl Param for RequestToken { + fn param_encode(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { + w.bytes(&encode(&self.0, version)?) + } + + fn param_decode(r: &mut Decoder<'_>, version: Version) -> Result { + let value = r.bytes()?; + Ok(Self(decode(value, version, false).map_err(DecodeError::Session)?)) + } +} + +/// Encode `token` by value. +fn encode(token: &Token, version: Version) -> Result, EncodeError> { let mut value = Vec::new(); let mut w = Encoder::new(&mut value, version.into()); w.varint(USE_VALUE)?; w.varint(token.kind)?; w.slice(&token.value); - params.set_bytes(ParameterBytes::AuthorizationToken, value); - Ok(()) + Ok(value) } -/// Decode a Token structure, refusing what a SETUP cannot carry. -fn decode(buf: &[u8], version: Version) -> Result { +/// Decode a Token structure, refusing what this endpoint cannot accept. +fn decode(buf: &[u8], version: Version, setup: bool) -> Result { // Section 8.9: a structure that cannot be decoded closes with KEY_VALUE_FORMATTING_ERROR. - let malformed = |_| Error::Session(SessionError::KeyValueFormatting); + let malformed = |_| SessionError::KeyValueFormatting; let mut r = Decoder::new(buf, version.into()); match r.varint().map_err(malformed)? { USE_VALUE => {} - // With no cache, section 9.1.4 treats a registration as a value; the alias is unused. - REGISTER => { + // With no cache, section 9.1.4 treats a registration in SETUP as a value; the alias is + // unused. + REGISTER if setup => { r.varint().map_err(malformed)?; } - // Section 9.1.4: nothing can have been registered before SETUP. - DELETE | USE_ALIAS => return Err(Error::ProtocolViolation), - _ => return Err(Error::Session(SessionError::KeyValueFormatting)), + // Section 8.9: a registration past the cache size of 0 closes the session. + REGISTER => return Err(SessionError::AuthTokenCacheOverflow), + // Section 9.1.3: a cache size of 0 prohibits aliases, so none was ever registered. + // Section 8.9 rejects the message, but 0x17 is only a session code (section 12.2), so + // the session closes; doc/concept/standard.md records the deviation. + DELETE | USE_ALIAS => return Err(SessionError::UnknownAuthTokenAlias), + _ => return Err(SessionError::KeyValueFormatting), } let kind = r.varint().map_err(malformed)?; @@ -156,6 +192,21 @@ mod tests { } } + /// A request's credential shows only its type and length in a message's `Debug`. + #[test] + fn a_request_token_debug_hides_the_value() { + let debug = format!( + "{:?}", + RequestToken(Token { + kind: 0, + value: b"s3cr3t-jwt".to_vec(), + }) + ); + assert!(!debug.contains("s3cr3t"), "{debug}"); + assert!(!debug.contains("115"), "{debug}"); + assert!(debug.contains("<10 bytes>"), "{debug}"); + } + #[test] fn absent_is_none() { for version in VERSIONS { @@ -203,20 +254,96 @@ mod tests { } } + /// A cache size of 0 means no alias was ever registered. #[test] - fn an_alias_reference_is_a_protocol_violation() { + fn an_alias_reference_is_an_unknown_alias() { for version in VERSIONS { for alias_type in [DELETE, USE_ALIAS] { let params = structure(version, &[alias_type, 7], &[]); let err = from_setup(¶ms, version).unwrap_err(); assert!( - matches!(err, Error::ProtocolViolation), + matches!(err, Error::Session(SessionError::UnknownAuthTokenAlias)), + "{version:?} {alias_type}: {err:?}" + ); + } + } + } + + /// A request's token as a message parameter value: the length-prefixed structure. + fn request(version: Version, fields: &[u64], value: &[u8]) -> Result { + let mut raw = Vec::new(); + let mut w = Encoder::new(&mut raw, version.into()); + for field in fields { + w.varint(*field).unwrap(); + } + w.slice(value); + let mut param = Vec::new(); + Encoder::new(&mut param, version.into()).bytes(&raw).unwrap(); + + let mut r = Decoder::new(¶m, version.into()); + let token = RequestToken::param_decode(&mut r, version)?; + assert!(r.is_empty(), "{version:?}"); + Ok(token) + } + + #[test] + fn a_request_token_round_trips_on_every_draft() { + for version in VERSIONS { + let mut param = Vec::new(); + RequestToken(token()) + .param_encode(&mut Encoder::new(&mut param, version.into()), version) + .unwrap(); + let mut r = Decoder::new(¶m, version.into()); + assert_eq!( + RequestToken::param_decode(&mut r, version).unwrap(), + RequestToken(token()), + "{version:?}" + ); + } + } + + /// Only SETUP falls back to a value: on a request, a registration overflows the cache. + #[test] + fn a_request_registration_overflows_the_cache() { + for version in VERSIONS { + let err = request(version, &[REGISTER, 7, token().kind], &token().value).unwrap_err(); + assert!( + matches!(err, DecodeError::Session(SessionError::AuthTokenCacheOverflow)), + "{version:?}: {err:?}" + ); + } + } + + #[test] + fn a_request_alias_reference_is_an_unknown_alias() { + for version in VERSIONS { + for alias_type in [DELETE, USE_ALIAS] { + let err = request(version, &[alias_type, 7], &[]).unwrap_err(); + assert!( + matches!(err, DecodeError::Session(SessionError::UnknownAuthTokenAlias)), "{version:?} {alias_type}: {err:?}" ); } } } + #[test] + fn an_undecodable_request_token_is_a_formatting_error() { + for version in VERSIONS { + for (fields, why) in [ + (&[][..], "no alias type"), + (&[USE_VALUE][..], "no token type"), + (&[0x4, 0][..], "an unknown alias type"), + ] { + let err = request(version, fields, &[]).unwrap_err(); + assert!( + matches!(err, DecodeError::Session(SessionError::KeyValueFormatting)), + "{version:?} {why}: {err:?}" + ); + } + } + } + #[test] fn an_undecodable_structure_is_a_formatting_error() { for version in VERSIONS { diff --git a/rs/moq-net/src/ietf/version.rs b/rs/moq-net/src/ietf/version.rs index 70ed605f71..3ac1d465a3 100644 --- a/rs/moq-net/src/ietf/version.rs +++ b/rs/moq-net/src/ietf/version.rs @@ -100,6 +100,7 @@ mod tests { properties_wanted: false, forward: true, range_filters: false, + authorization_token: None, }; let subscribe_ok = SubscribeOk { @@ -171,6 +172,7 @@ mod tests { properties_wanted: true, forward: true, range_filters: false, + authorization_token: None, }; // Draft-20 spells Next Object as a Length of 2 and two zero fields; draft-22 as type 0x05. diff --git a/rs/moq-net/src/lib.rs b/rs/moq-net/src/lib.rs index 2a99af0764..4b2d1b90be 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; @@ -102,6 +103,7 @@ pub mod stats; pub mod time; pub mod transport; +pub(crate) use auth::RequestToken; pub use client::*; pub use coding::{BoundsExceeded, DecodeError, EncodeError, varint}; pub use driver::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 d777c68176..dfcc4a753d 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -39,6 +39,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, } @@ -64,6 +71,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, @@ -114,6 +123,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 @@ -151,6 +166,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 { @@ -160,7 +177,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, @@ -172,6 +191,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(), @@ -188,6 +209,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(); @@ -231,7 +259,7 @@ impl Publisher { self_origin: Hop, version: Version, ) -> Result<(), Error> { - let mut run = AnnounceRun::new(self_origin, version); + let mut run = AnnounceRun::new(crate::PathOwned::default(), self_origin, version); kio::wait(|waiter| run.poll(stream, origin, announced, waiter)).await } } @@ -257,6 +285,7 @@ enum ControlState { Fetch(RequestServe), TrackInfo(RequestServe), Probe(ProbeServe), + Auth(AuthServe), /// Decoding the peer's GOAWAY, surfaced through [`crate::Session::draining`]. Goaway { stream: Stream, @@ -306,6 +335,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)), }; @@ -315,6 +345,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. @@ -348,6 +379,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 { @@ -606,7 +835,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 }; } } @@ -614,6 +844,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 @@ -625,6 +858,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. @@ -647,14 +886,138 @@ 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, + announced: &mut announce::Consumer, + permit: crate::auth::Permit, + ) -> Result<(), Error> { + let grew = !self.permit.covers(&permit); + self.permit = permit; + + if !grew { + let revoked: Vec<_> = self + .live + .keys() + .filter(|suffix| !self.permitted(suffix)) + .cloned() + .collect(); + for suffix in revoked { + let absolute = origin.absolute(&suffix); + tracing::debug!(route = %absolute, "announce no longer authorized"); + self.retract(stream, suffix, &absolute)?; + } + return Ok(()); + } + + // A route withheld earlier left no trace, so take a fresh cursor: its initial + // burst is the origin's current state, which is then diffed against what the + // peer holds. + *announced = origin.announced(); + let mut current: HashMap = HashMap::new(); + while let Some(event) = announced.try_next() { + match event { + announce::Event::Start(update) | announce::Event::Update(update) => { + current.insert(update.prefix, update.route); + } + announce::Event::End(update) => { + current.remove(&update.prefix); + } + } + } + + let stale: Vec<_> = self + .live + .keys() + .filter(|suffix| !current.contains_key(*suffix) || !self.permitted(suffix)) + .cloned() + .collect(); + for suffix in stale { + let absolute = origin.absolute(&suffix); + self.retract(stream, suffix, &absolute)?; + } + + for (suffix, route) in current { + if !self.permitted(&suffix) { + continue; + } + let absolute = origin.absolute(&suffix); + // Still held: bring its metadata up to date in case the old cursor had an + // update pending. + if let Some((hops, cost)) = self.outgoing(&route, &absolute) { + self.advertise(stream, suffix, hops, cost, route.epoch.clone(), &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::restart_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::Restart { 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 @@ -757,11 +1120,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. @@ -772,7 +1138,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 }); } @@ -792,11 +1158,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. @@ -856,6 +1225,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; } @@ -873,6 +1251,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, announced, permit)?; + continue; + } + if let Poll::Ready(res) = stream.reader.poll_closed(&mut cx) { return Poll::Ready(res); } @@ -895,45 +1283,15 @@ 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; } match self.outgoing(&update.route, &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::restart_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::Restart { 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)?, @@ -1021,6 +1379,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, @@ -1058,6 +1418,7 @@ impl> RequestServe { owing: true, _charged: None, update: None, + gate: None, absolute: Default::default(), track: Default::default(), } @@ -1080,9 +1441,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) } } @@ -1093,6 +1459,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 { // Once answered, the requester's FIN is the normal end, not a cancel. @@ -1129,7 +1500,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() { @@ -3956,6 +4335,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(), }); @@ -4078,7 +4460,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; @@ -4122,6 +4504,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) @@ -4295,6 +4680,9 @@ mod tests { peer_setup, goaway, peer_hop: None, + auth: crate::auth::Handle::new(false), + peer_grant: Default::default(), + client: false, subscriptions: Default::default(), }); @@ -4424,6 +4812,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(); @@ -4499,6 +4890,9 @@ mod tests { peer_setup, goaway, peer_hop: None, + auth: crate::auth::Handle::new(false), + peer_grant: Default::default(), + client: false, subscriptions: Default::default(), }); @@ -4611,6 +5005,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 f7c8ed0470..184f5d9b6f 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(), } @@ -326,13 +330,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)) } /// Remove a subscription, releasing the session's handle on its producer. @@ -1255,6 +1247,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 { @@ -1343,6 +1337,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 @@ -1384,6 +1379,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))) => { @@ -1628,6 +1633,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(); @@ -1678,6 +1684,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); @@ -1743,6 +1750,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), + auth: crate::auth::Handle::new(false), }); let serve = TrackServe { subscriber, @@ -1763,9 +1771,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; @@ -1796,6 +1806,7 @@ mod tests { peer_hop: None, cost: None, going_away: Default::default(), + auth: crate::auth::Handle::new(false), }); let serve = TrackServe { subscriber, @@ -1813,9 +1824,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(), @@ -1890,6 +1903,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(); @@ -1953,6 +1967,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(); @@ -2010,6 +2025,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(); @@ -2070,6 +2086,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 { @@ -2117,6 +2134,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, @@ -2144,6 +2219,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(); @@ -2573,6 +2649,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(); @@ -2627,6 +2704,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(); @@ -2680,6 +2758,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(); @@ -2748,6 +2827,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 @@ -2803,6 +2883,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(); @@ -2861,6 +2942,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(); @@ -2919,6 +3001,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. @@ -2985,6 +3068,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. @@ -3044,6 +3128,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 @@ -3090,6 +3175,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(); @@ -3169,6 +3255,7 @@ mod tests { cost: None, peer_hop: None, going_away: Default::default(), + auth: crate::auth::Handle::new(false), }); (subscriber, consumer) } @@ -3232,6 +3319,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(); @@ -3315,6 +3403,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(); @@ -3362,6 +3451,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(); @@ -3473,7 +3563,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, @@ -3486,6 +3576,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 { @@ -3517,12 +3618,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. @@ -3542,7 +3673,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(()) } @@ -3552,18 +3683,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(); } } @@ -3575,7 +3709,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. @@ -3608,10 +3742,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(); + } } } } @@ -3715,6 +3883,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) { @@ -4064,6 +4242,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, @@ -4084,6 +4270,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 @@ -4116,7 +4304,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 => {} + } } } @@ -4124,6 +4340,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 } => { @@ -4724,6 +4945,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 fe1c15b04a..f838fe2bf5 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..ed629cb5db 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -21,6 +21,7 @@ pub struct Server { stats: stats::Session, versions: Versions, limits: crate::session::Limits, + extensions: setup::Extensions, } impl Server { @@ -72,6 +73,12 @@ impl Server { self } + /// Choose which moq-transport extensions to offer in SETUP. Defaults to all of them. + pub fn with_extensions(mut self, extensions: setup::Extensions) -> Self { + self.extensions = extensions; + self + } + /// The configured origin pair, each tagged with the stats context so the /// model attributes reads (egress) and writes (ingress) for this session. /// One shared context across both halves keeps presence and viewer counts @@ -94,6 +101,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 +126,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 +136,7 @@ impl Server { version, our_setup, peer_setup: client_setup, + auth, })?; Ok(Session::new( @@ -135,8 +145,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 +243,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 +256,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 +407,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 +445,9 @@ 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 it offered MoQ Auth, so it is + // negotiated exactly when we offer it too. + auth: crate::auth::Handle::new(peer_setup.declared.auth && self.extensions.auth), inner: Some(RequestInner { server: self.clone(), runtime, @@ -452,6 +477,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 +523,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 +550,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,7 +585,9 @@ where authority: None, peer_setup_stream: Some(peer_setup.stream), peer_declared: Some(peer_setup.declared), + auth: auth.clone(), early_unis: peer_setup.early, + extensions: server.extensions, })?; tracing::debug!(?version, "connected"); Ok(Session::new( @@ -554,8 +596,7 @@ where version.into(), None, crate::driver::Protocol::Ietf(protocol), - goaway, - setup, + crate::session::Handles { goaway, auth, setup }, )) } .maybe_boxed() @@ -584,7 +625,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 { @@ -606,7 +653,9 @@ where ietf::initial_max_request_id(server.limits.requests(), true), ); parameters.set_bytes(ietf::ParameterBytes::Implementation, b"moq-lite-rs".to_vec()); - ietf::solicit::into_setup(&mut parameters, v); + if server.extensions.solicit { + ietf::solicit::into_setup(&mut parameters, v); + } ietf::hidden::into_setup(&mut parameters, v); ietf::active_count::into_setup(&mut parameters, v); parameters.encode_bytes(v)? @@ -620,12 +669,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 +685,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 +714,21 @@ where authority: None, peer_setup_stream: None, peer_declared: Some(peer_declared), + auth: auth.clone(), early_unis: Vec::new(), + extensions: server.extensions, })?; - (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 +777,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 +839,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 +848,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, } } @@ -1084,6 +1152,56 @@ mod tests { params } + /// An acceptor taken with `Request::auth().requests()` BEFORE `ok()` must govern the + /// session the driver then runs, through the REAL accept path (`accept_request` -> `ok`). + /// The session's own `auth()` (the handle the driver polls) routes a request token to that + /// pre-ok consumer instead of falling to the `Unsupported` default, and granting admits. + /// Both a no-AUTH-extension legacy session (draft-14, `Handle::new(false)`) and the modern + /// uni-SETUP path (draft-18); a QUIC server has no other point to install the acceptor. + #[moq_net_sim::test] + async fn a_pre_ok_requests_consumer_governs_the_session_acceptor() { + let cases = [ + ( + FakeSession::new(ALPN_14, []) + .with_bi(legacy_setup(ietf::Version::Draft14, ietf::Parameters::default())), + ietf::Version::Draft14, + ), + ( + FakeSession::new(ALPN_18, [ietf_setup(ietf::Version::Draft18, Some("room/alice"))]), + ietf::Version::Draft18, + ), + ]; + + for (session, version) in cases { + let request = Server::new() + .accept_request(moq_net_sim::now(), session) + .await + .unwrap_or_else(|e| panic!("accept at {version:?}: {e}")); + + // Install the acceptor before ok(), as a QUIC server must. + let mut requests = request.auth().requests().expect("requests() pre-ok"); + let (net_session, _driver) = request.ok().await.unwrap_or_else(|e| panic!("ok at {version:?}: {e}")); + + // The session's own handle is the one the driver polls. Verifying a request token on + // it must reach the pre-ok consumer; if the handle were not shared it would fall to + // the Unsupported default and the consumer would never see this request. + let verdict = net_session.auth().verify_request( + crate::setup::Token { + kind: 1, + value: b"jwt".to_vec(), + }, + crate::PathOwned::from("room/alice".to_string()), + crate::auth::RequestKind::PublishNamespace, + ); + let received = requests.next().await.expect("the pre-ok consumer receives the request"); + let scope = received.scope().expect("a request token"); + assert_eq!(scope.path.as_str(), "room/alice", "{version:?}"); + assert_eq!(scope.kind, crate::auth::RequestKind::PublishNamespace, "{version:?}"); + let _issued = received.accept(crate::auth::Grant::all()); + assert!(verdict.grant().await.is_ok(), "granting admits at {version:?}"); + } + } + #[moq_net_sim::test] async fn accept_request_exposes_the_setup_token() { let modern = FakeSession::new( @@ -1120,7 +1238,7 @@ mod tests { let delete = [0x0, 0x7]; // DELETE alias 7 let truncated = [0x3]; // USE_VALUE with no Token Type for (raw, code) in [ - (&delete[..], SessionError::ProtocolViolation), + (&delete[..], SessionError::UnknownAuthTokenAlias), (&truncated[..], SessionError::KeyValueFormatting), ] { let mut params = ietf::Parameters::default(); @@ -1274,6 +1392,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..c2a1084722 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,15 @@ 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; + if version.is_lite() { + auth.lite(); + } let sample = snapshot(&session); // Send bandwidth is version-agnostic: it depends on QUIC backend support. @@ -354,6 +375,7 @@ impl Session { send_bandwidth, recv_bandwidth, goaway: Arc::new(goaway), + auth, setup, }; let driver = crate::Driver::new( diff --git a/rs/moq-net/src/setup.rs b/rs/moq-net/src/setup.rs index f8a12ef862..a84b5ee043 100644 --- a/rs/moq-net/src/setup.rs +++ b/rs/moq-net/src/setup.rs @@ -1,6 +1,8 @@ -//! The SETUP exchange, and the credential a peer may present in it. +//! The SETUP exchange, the extensions a side offers in it, and the credential a peer may +//! present in it. //! -//! Only [`Token`] is public; the SETUP messages themselves are wire internals. +//! Only [`Extensions`] and [`Token`] are public; the SETUP messages themselves are wire +//! internals. use bytes::Bytes; @@ -19,6 +21,30 @@ const SERVER_SETUP: u8 = 0x21; /// Draft-17 unified SETUP message type (varint 0x2F00) pub(crate) const SETUP_V17: u64 = 0x2F00; +/// The moq-transport Setup Options this side offers, each on by default. +/// +/// Turning one off connects as a peer that does not speak that extension. moq-lite carries +/// both in its core, so this only changes moq-transport sessions. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(default, deny_unknown_fields)] +#[non_exhaustive] +pub struct Extensions { + /// The MoQ Auth extension: tokens presented and granted on their own stream (draft-17+). + pub auth: bool, + /// The MoQ Solicit extension: the peer answers our SUBSCRIBE_NAMESPACE rather than + /// announcing unasked. + pub solicit: bool, +} + +impl Default for Extensions { + fn default() -> Self { + Self { + auth: true, + solicit: true, + } + } +} + /// A credential a moq-transport peer presented in its SETUP's `AUTHORIZATION TOKEN` option. /// /// The transport never reads the bytes; verifying them is the application's job. diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs new file mode 100644 index 0000000000..b9cfe55ff2 --- /dev/null +++ b/rs/moq-net/tests/auth.rs @@ -0,0 +1,1950 @@ +//! 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"; +/// A draft-18 session, the shape a standard request-token peer uses. +const MOQT_15: &str = "moq-transport-15"; +const MOQT_16: &str = "moq-transport-16"; +const MOQT_18: &str = "moq-transport-18"; +const MOQT_19: &str = "moq-transport-19"; +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) => { + 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")); + } +} + +/// A request token of kind 0 carrying `value`. +fn request_token(value: &[u8]) -> moq_net::setup::Token { + moq_net::setup::Token { + kind: 0, + value: value.to_vec(), + } +} + +#[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, + /// A request token the client attaches to its outgoing PUBLISH_NAMESPACE / SUBSCRIBE. + client_request_token: Option, + /// The client does not offer the MoQ Auth extension (`Extensions::auth` off). + client_decline_auth: bool, + /// The server does not offer the MoQ Solicit extension (`Extensions::solicit` off). + server_decline_solicit: bool, + /// The server does not offer the MoQ Auth extension (`Extensions::auth` off). + server_decline_auth: 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); + } + if opts.client_decline_auth { + let mut extensions = moq_net::setup::Extensions::default(); + extensions.auth = false; + client = client.with_extensions(extensions); + } + + let mut server = Server::new().with_versions(version.into()); + if opts.server_decline_solicit || opts.server_decline_auth { + let mut extensions = moq_net::setup::Extensions::default(); + extensions.solicit = !opts.server_decline_solicit; + extensions.auth = !opts.server_decline_auth; + server = server.with_extensions(extensions); + } + 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_token = opts.client_request_token; + let client_fut = async { + let (session, driver) = client.connect(now(), client_transport).await.expect("client handshake"); + // Set before the driver runs, so the first request already carries it. + if let Some(token) = client_token { + session + .auth() + .set_request_token(token) + .expect("a moq-transport session carries a request token"); + } + 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 request token on a PUBLISH_NAMESPACE authorizes the announce on a session that never +/// negotiated the MoQ Auth extension (draft-14), the standard moq-transport peer shape, when the +/// token reaches the acceptor THROUGH THE DRIVER rather than an inline `verify_request`. +/// +/// This exercises `ietf::start`'s legacy (draft 14-16) branch. That branch built its +/// Subscriber without `.with_auth(auth)`, so the driver's subscriber consulted a fresh +/// default `Handle` instead of the session handle the `requests()` acceptor was installed on: +/// `verify_request` found no `App` acceptor and refused the announce `NOT_SUPPORTED`, and the +/// announce never reached the server origin. The modern (17+) branch already wired +/// `.with_auth`, so only the legacy / no-extension path was affected, and the unit tests +/// missed it by constructing the Subscriber with `.with_auth` by hand. +#[moq_net_sim::test] +async fn a_request_token_authorizes_a_legacy_announce_through_the_driver() { + within(async { + let publisher = produce_origin(2); + let relay = produce_origin(1); + let mut pair = connect(Options { + version: Some("moq-transport-14"), + client_publish: Some(publisher.clone()), + // A request token the acceptor answers unconditionally below. + client_request_token: Some(request_token(b"ok")), + server_subscribe: Some(relay.scope("", &patterns(&["room/alice"])).unwrap()), + server_requests: true, + ..Default::default() + }) + .await; + + // The server answers the request token from its acceptor with a grant covering the + // announced path. Held for the test by the returned receiver. + let requests = pair.requests.take().expect("server took its requests pre-ok"); + let mut answered = serve(requests, |_token| Some(grant(&["room/alice"], &["room/alice"]))); + + // The client announces under the token. On the legacy path there is no session grant, + // so the token is the only authorization for the announce. + let bc = publisher.create_broadcast("room/alice").unwrap(); + bc.announce(Default::default()).unwrap(); + + // Mechanism: the token reached the acceptor over the driver (not admitted by a + // permissive default, and not refused NOT_SUPPORTED by a disconnected handle). + let (_token, _issued) = answered.recv().await.expect("the token reached the acceptor"); + + // End to end: the verified announce reached the server's subscribe origin. + wait_announced(&relay.consume(), "room/alice", true).await; + }) + .await + .expect("timed out"); +} + +/// A client may decline the MoQ Auth extension, connecting at draft-18 as a peer that +/// does not negotiate it (a non-moq-dev encoder or CDN). Its SETUP omits the option, so +/// the server sees `declared.auth == false` and the session carries no connection grant +/// (`None` union). A request-borne `AUTHORIZATION TOKEN` is then the authorizing artifact +/// and reaches the server's acceptor, where a normal draft-18 client's connection grant +/// would cover the request and skip the token. A token-less request on such a +/// session is admitted by the permissive default of the ungranted session. +/// +/// The token is exercised on a SUBSCRIBE, which is always a request and carries the token +/// on every draft. A request token on a PUBLISH_NAMESPACE does NOT reach a moq-net peer at +/// draft-16+ regardless of this option: moq-net declares MoQ Solicit unconditionally, so +/// the announce answers the peer's SUBSCRIBE_NAMESPACE inline via `ietf::Namespace`, which +/// carries no token, and the token-bearing unsolicited PUBLISH_NAMESPACE loop is disabled. +#[moq_net_sim::test] +async fn a_client_may_decline_the_auth_extension() { + 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 server_origin = produce_origin(1); + let down = server_origin.create_broadcast("room/alice").unwrap(); + let down_track = down.create_track("video", None).unwrap(); + down.announce(Default::default()).unwrap(); + + let received = produce_origin(3); + let mut pair = connect(Options { + version: Some(MOQT_18), + client_subscribe: Some(received.clone()), + client_request_token: Some(request_token(b"ok")), + client_decline_auth: true, + server_publish: Some(server_origin.clone()), + server_requests: true, + ..Default::default() + }) + .await; + + // declared.auth == false: the declining client speaks no AUTH, so it holds no + // session grant and cannot present a session token (unlike a normal draft-18 peer). + assert_eq!(pair.client.auth().grant().peek(), None); + assert!(matches!(pair.client.auth().add("x").await, Err(Error::Unsupported))); + + // The client's token-bearing SUBSCRIBE reaches the acceptor: on the `None`-union + // session the covers-gate does not short-circuit, so the token is verified rather + // than admitted by a covering connection grant. + let requests = pair.requests.take().expect("server took its requests pre-ok"); + let mut answered = serve(requests, |_token| Some(grant(&["room/alice"], &["room/alice"]))); + + let remote = received.consume().routed_broadcast("room/alice").await.unwrap(); + let mut sub = remote.track("video").unwrap().subscribe(prefs()).await.unwrap(); + let mut group = down_track.append_group().unwrap(); + group.write_frame(ts(0), b"down".as_ref()).unwrap(); + + let (_token, _issued) = answered.recv().await.expect("the token reached the acceptor"); + sub.recv_group().await.unwrap().unwrap(); + + // Token-less: a declining client with no token is admitted by the permissive default. + let bare_publisher = produce_origin(4); + let bare_relay = produce_origin(5); + let bare = connect(Options { + version: Some(MOQT_18), + client_publish: Some(bare_publisher.clone()), + client_decline_auth: true, + server_subscribe: Some(bare_relay.clone()), + ..Default::default() + }) + .await; + let bc = bare_publisher.create_broadcast("room/bob").unwrap(); + bc.announce(Default::default()).unwrap(); + wait_announced(&bare_relay.consume(), "room/bob", true).await; + assert_eq!(bare.client_transport.close_reason(), None); + }) + .await + .expect("timed out"); +} + +/// moq-lite requests carry no token, so a moq-lite session refuses one rather than drop it, +/// and a moq-transport session takes it. +#[moq_net_sim::test] +async fn a_moq_lite_session_refuses_a_request_token() { + within(async { + let lite = connect(Options::default()).await; + let refused = lite.client.auth().set_request_token(request_token(b"ok")); + assert!(matches!(refused, Err(Error::Unsupported)), "{refused:?}"); + + let ietf = connect(Options { + version: Some(MOQT_18), + ..Default::default() + }) + .await; + ietf.client + .auth() + .set_request_token(request_token(b"ok")) + .expect("a moq-transport session takes a request token"); + }) + .await + .expect("timed out"); +} + +/// Draft-15/16 answer an update naming the update's own Request ID, which has no stream of +/// its own on the control stream. The answer must still reach the subscription it renews: +/// the client keeps one renewal in flight, so a second one goes out only once the first is +/// answered there. +#[moq_net_sim::test] +async fn a_legacy_renewal_answer_reaches_its_subscription() { + for version in [MOQT_15, MOQT_16] { + 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 server_origin = produce_origin(1); + let down = server_origin.create_broadcast("room/alice").unwrap(); + let down_track = down.create_track("video", None).unwrap(); + down.announce(Default::default()).unwrap(); + + let received = produce_origin(3); + let mut pair = connect(Options { + version: Some(version), + client_subscribe: Some(received.clone()), + client_request_token: Some(request_token(b"aa")), + server_publish: Some(server_origin.clone()), + server_requests: true, + ..Default::default() + }) + .await; + let requests = pair.requests.take().expect("server took its requests pre-ok"); + let mut answered = serve(requests, |_token| Some(grant(&[], &["room/alice"]))); + + let remote = received.consume().routed_broadcast("room/alice").await.unwrap(); + let mut sub = remote.track("video").unwrap().subscribe(prefs()).await.unwrap(); + let mut group = down_track.append_group().unwrap(); + group.write_frame(ts(0), b"one".as_ref()).unwrap(); + group.finish().unwrap(); + let (token, _first) = answered.next().await.expect("the first token reached the acceptor"); + assert_eq!(token, b"aa", "{version}"); + sub.recv_group().await.unwrap().unwrap(); + + pair.client.auth().set_request_token(request_token(b"bb")).unwrap(); + let (token, _second) = answered.next().await.expect("the first renewal reached the acceptor"); + assert_eq!(token, b"bb", "{version}"); + + // Sent only once the first renewal's answer came back to the subscription. + moq_net_sim::sleep(Duration::from_millis(100)).await; + pair.client.auth().set_request_token(request_token(b"cc")).unwrap(); + let (token, _third) = answered.next().await.expect("the second renewal reached the acceptor"); + assert_eq!(token, b"cc", "{version}"); + }) + .await + .unwrap_or_else(|_| panic!("{version}: timed out")); + } +} + +/// A request token renews over the wire: replacing it on the client's `Session::auth()` +/// re-presents it on the live SUBSCRIBE as a REQUEST_UPDATE, the server's driver routes it to +/// the acceptor, and the new grant keeps the subscription alive past the old one's expiry. +/// Runs through both drivers at draft-18, in the shape a base moq-transport peer produces +/// (no MoQ Auth, so the token is what authorizes). +#[moq_net_sim::test] +async fn a_request_token_renews_a_subscription_through_the_driver() { + 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 server_origin = produce_origin(1); + let down = server_origin.create_broadcast("room/alice").unwrap(); + let down_track = down.create_track("video", None).unwrap(); + down.announce(Default::default()).unwrap(); + + let first = request_token(b"aa"); + let second = request_token(b"bb"); + let received = produce_origin(3); + let mut pair = connect(Options { + version: Some(MOQT_18), + client_subscribe: Some(received.clone()), + client_request_token: Some(first.clone()), + client_decline_auth: true, + server_publish: Some(server_origin.clone()), + server_requests: true, + ..Default::default() + }) + .await; + + // The first token lapses in a second; the renewal never does. + let expires = Some(now() + Duration::from_secs(1)); + // The acceptor sees the token's value. + let renewal = second.value.clone(); + let requests = pair.requests.take().expect("server took its requests pre-ok"); + let mut answered = serve(requests, move |token| { + let mut granted = grant(&[], &["room/alice"]); + if token != renewal.as_slice() { + granted.expires = expires; + } + Some(granted) + }); + + let remote = received.consume().routed_broadcast("room/alice").await.unwrap(); + let mut sub = remote.track("video").unwrap().subscribe(prefs()).await.unwrap(); + let mut group = down_track.append_group().unwrap(); + group.write_frame(ts(0), b"one".as_ref()).unwrap(); + group.finish().unwrap(); + let (token, _first_issued) = answered.recv().await.expect("the first token reached the acceptor"); + assert_eq!(token, first.value); + sub.recv_group().await.unwrap().unwrap(); + + pair.client.auth().set_request_token(second.clone()).unwrap(); + let (token, _renewed) = answered.recv().await.expect("the renewal reached the acceptor"); + assert_eq!(token, second.value, "the replaced token rides the REQUEST_UPDATE"); + + // Past the first grant's expiry the subscription still delivers. + moq_net_sim::sleep(Duration::from_millis(1500)).await; + let mut group = down_track.append_group().unwrap(); + group.write_frame(ts(2000), b"two".as_ref()).unwrap(); + group.finish().unwrap(); + sub.recv_group() + .await + .unwrap() + .expect("the renewed subscription is still live"); + assert_eq!( + pair.client_transport.close_reason(), + None, + "renewal never touches the session" + ); + // The same subscription carried on: a lapse would have ended it, and the client's + // re-subscribe would have reached the acceptor as another request. + assert!( + answered.try_recv().is_err(), + "the original subscription was renewed, not replaced" + ); + }) + .await + .expect("timed out"); +} + +/// The sender honors the receiver's MAX_REQUEST_UPDATES credit: it keeps at most one +/// renewal in flight per request and coalesces replacements that arrive while one is +/// unanswered, so a burst of token changes never outruns the credit (draft-19 section +/// 10.3.1.7). +/// +/// Two drivers over the in-process transport at draft-19, where the serving side advertises +/// and enforces a 16-update credit with a session close ([`SessionError::TooManyRequestUpdates`]). +/// The acceptor holds the first renewal's verifier, the client replaces its token well past +/// the credit, and the test asserts the connection stays up, only the held renewal reaches the +/// acceptor, and resolving it releases exactly the newest token, not any coalesced between. +/// +/// A fire-and-forget sender would put every renewal outstanding behind the held verifier and +/// the serving side would close the session, losing every request on it. +/// +/// [`SessionError::TooManyRequestUpdates`]: moq_net::SessionError::TooManyRequestUpdates +#[moq_net_sim::test] +async fn a_held_renewal_coalesces_a_burst_without_tripping_the_credit() { + 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 server_origin = produce_origin(1); + let down = server_origin.create_broadcast("room/alice").unwrap(); + let down_track = down.create_track("video", None).unwrap(); + down.announce(Default::default()).unwrap(); + + // A distinct value per credential. + let token = |n: u8| request_token(&[b't', n]); + let initial = token(0); + let received = produce_origin(3); + let mut pair = connect(Options { + version: Some(MOQT_19), + client_subscribe: Some(received.clone()), + client_request_token: Some(initial.clone()), + client_decline_auth: true, + server_publish: Some(server_origin.clone()), + server_requests: true, + ..Default::default() + }) + .await; + + // The acceptor runs concurrently with the subscribe (the initial token rides the + // SUBSCRIBE, so nothing reaches it until we subscribe). It reports every token it + // verifies and holds the first renewal's verifier until released, the way a relay with + // a slow authorizer would. + let mut requests = pair.requests.take().expect("server took its requests pre-ok"); + let (seen_tx, mut seen) = futures::channel::mpsc::unbounded::>(); + let (release, release_waiter) = futures::channel::oneshot::channel::<()>(); + let acceptor = moq_net_sim::spawn(async move { + let mut issued = Vec::new(); + let mut count = 0u64; + let mut release_waiter = Some(release_waiter); + while let Some(request) = requests.next().await { + let token = request.token().to_vec(); + count += 1; + seen_tx.unbounded_send(token).ok(); + // Request 1 is the initial SUBSCRIBE token; request 2 is the first renewal, + // held until the test releases it. + if count == 2 + && let Some(release) = release_waiter.take() + { + let _ = release.await; + } + issued.push(request.accept(grant(&[], &["room/alice"]))); + } + }); + + // Establish and deliver one group so the subscription is live. The initial token rides + // the SUBSCRIBE, which the acceptor (above) verifies concurrently. + let remote = received.consume().routed_broadcast("room/alice").await.unwrap(); + let mut sub = remote.track("video").unwrap().subscribe(prefs()).await.unwrap(); + let mut group = down_track.append_group().unwrap(); + group.write_frame(ts(0), b"one".as_ref()).unwrap(); + group.finish().unwrap(); + sub.recv_group().await.unwrap().unwrap(); + assert_eq!( + seen.next().await.expect("initial token"), + initial.value, + "the SUBSCRIBE carries the token" + ); + + // Replace the token once and let that one renewal reach the held acceptor, so the held + // renewal is deterministic regardless of scheduling. + pair.client.auth().set_request_token(token(1)).unwrap(); + assert_eq!( + seen.next().await.expect("first renewal"), + token(1).value, + "the first replacement goes out immediately" + ); + + // With that renewal held unanswered, replace the token many more times, spaced so the + // driver observes each: the scenario the credit guards. Far past any plausible credit, + // so the test keeps guarding if MAX_REQUEST_UPDATES grows. A fire-and-forget sender + // would put all of these outstanding behind the held verifier and the serving side + // would close the session with TOO_MANY_REQUEST_UPDATES, losing every request on it; + // the one-in-flight rule coalesces them behind the held one instead. + for n in 2..=64u8 { + pair.client.auth().set_request_token(token(n)).unwrap(); + moq_net_sim::sleep(Duration::from_millis(5)).await; + } + + assert_eq!( + pair.client_transport.close_reason(), + None, + "the burst never tripped the receiver's credit" + ); + assert_eq!( + pair.server_transport.close_reason(), + None, + "the server never closed the session" + ); + + // Resolve the held verifier. The sender sends the coalesced renewal carrying the newest + // token, not any of the ones replaced between. + release.send(()).unwrap(); + assert_eq!( + seen.next().await.expect("coalesced renewal"), + token(64).value, + "the newest token wins; the rest are coalesced away" + ); + + // Only the newest coalesced renewal followed the held one: the burst collapsed to one, + // not a backlog queued behind it. + assert!( + moq_net_sim::timeout(Duration::from_millis(100), seen.next()) + .await + .is_err(), + "only the newest coalesced renewal followed, not a backlog" + ); + assert_eq!( + pair.client_transport.close_reason(), + None, + "the session stayed up throughout" + ); + acceptor.abort(); + }) + .await + .expect("timed out"); +} + +/// A server may decline the MoQ Solicit extension, so a peer sends an unsolicited +/// PUBLISH_NAMESPACE (the base moq-transport behavior) instead of answering our +/// SUBSCRIBE_NAMESPACE inline. Only the unsolicited PUBLISH_NAMESPACE carries an +/// `AUTHORIZATION TOKEN`; the inline `Namespace` entry has no parameter slot for one. So a +/// request-borne token on an announce reaches the acceptor exactly when the server does not +/// solicit, which is the shape a standard moq-transport peer (an encoder or CDN) always sends. +/// +/// This runs at draft-18, with the client also declining +/// the AUTH extension, so the session's union is `None`, the covers-gate does not short-circuit +/// on a connection grant, and the request token is the authorizing artifact. The +/// control half shows the default: with Solicit declared the client answers inline, no token +/// reaches the acceptor, and the announce is admitted by the permissive default of the +/// ungranted session. +#[moq_net_sim::test] +async fn a_server_that_declines_solicit_gets_a_token_bearing_unsolicited_announce() { + within(async { + let request_token = request_token(b"ok"); + + // Server declines Solicit: the client sends an unsolicited PUBLISH_NAMESPACE carrying + // the token, which reaches the acceptor as a PublishNamespace request. + let publisher = produce_origin(1); + let relay = produce_origin(2); + let mut pair = connect(Options { + version: Some(MOQT_18), + client_publish: Some(publisher.clone()), + client_request_token: Some(request_token.clone()), + client_decline_auth: true, + server_subscribe: Some(relay.clone()), + server_decline_solicit: true, + server_requests: true, + ..Default::default() + }) + .await; + let bc = publisher.create_broadcast("room/alice").unwrap(); + bc.announce(Default::default()).unwrap(); + + let requests = pair.requests.take().expect("server took its requests pre-ok"); + let mut answered = serve(requests, |_token| Some(grant(&["room/alice"], &["room/alice"]))); + // The announce is admitted once the token is granted. + wait_announced(&relay.consume(), "room/alice", true).await; + let (tok, _issued) = answered + .recv() + .await + .expect("the unsolicited PUBLISH_NAMESPACE carried the token to the acceptor"); + assert_eq!(tok, b"ok", "the acceptor saw the request token's decoded value"); + + // Control: with Solicit declared (the default) the same client answers our + // SUBSCRIBE_NAMESPACE inline with a Namespace, which carries no token, so nothing + // reaches the acceptor. The announce is still admitted by the permissive default. + let publisher = produce_origin(3); + let relay = produce_origin(4); + let mut pair = connect(Options { + version: Some(MOQT_18), + client_publish: Some(publisher.clone()), + client_request_token: Some(request_token.clone()), + client_decline_auth: true, + server_subscribe: Some(relay.clone()), + // server_decline_solicit defaults false: the server declares Solicit. + server_requests: true, + ..Default::default() + }) + .await; + let bc = publisher.create_broadcast("room/carol").unwrap(); + bc.announce(Default::default()).unwrap(); + + let requests = pair.requests.take().expect("server took its requests pre-ok"); + let mut answered = serve(requests, |_token| Some(grant(&["room/carol"], &[]))); + wait_announced(&relay.consume(), "room/carol", true).await; + assert!( + answered.try_recv().is_err(), + "a solicited (inline) announce carries no token, so the acceptor is never consulted" + ); + }) + .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, + client_request_token: None, + ..Default::default() + }) + .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/extensions.rs b/rs/moq-net/tests/extensions.rs new file mode 100644 index 0000000000..932a5b6b4d --- /dev/null +++ b/rs/moq-net/tests/extensions.rs @@ -0,0 +1,145 @@ +//! A side may decline a moq-transport extension with `setup::Extensions`, and then +//! connects as a peer that does not speak it. +//! +//! Deterministic: paused time over the mock transport. + +mod support; + +use std::time::Duration; + +use moq_net::{Error, Hop, Version, origin, setup::Extensions}; +use support::harness::{MockConnectOptions, MockPair, connect_mock}; + +/// Long enough, in virtual time, for anything in flight to reach the far side. +const SETTLE: Duration = Duration::from_secs(1); + +/// Every moq-transport draft, each of which carries the SOLICIT option. +const MOQT: [&str; 9] = [ + "moq-transport-14", + "moq-transport-15", + "moq-transport-16", + "moq-transport-17", + "moq-transport-18", + "moq-transport-19", + "moq-transport-20", + "moq-transport-21", + "moq-transport-22", +]; + +/// The first draft that negotiates MoQ Auth, and the newest. +const AUTH: [&str; 2] = ["moq-transport-17", "moq-transport-22"]; + +fn produce_origin(hop: u64) -> origin::Producer { + let (producer, driver) = origin::Producer::new(origin::Config::new(Hop::new(hop).unwrap())); + support::harness::spawn(driver); + producer +} + +fn declined(f: impl FnOnce(&mut Extensions)) -> Extensions { + let mut extensions = Extensions::default(); + f(&mut extensions); + extensions +} + +fn assert_open(pair: &MockPair, context: &str) { + assert_eq!(pair.client_transport.close_reason(), None, "{context}: client closed"); + assert_eq!(pair.server_transport.close_reason(), None, "{context}: server closed"); +} + +/// Whether `path` is announced in `origin` right now. +fn announced(origin: &origin::Producer, path: &str) -> bool { + let mut announced = origin.consume().announced(); + let mut live = false; + while let Some(event) = announced.try_next() { + match event { + moq_net::announce::Event::Start(update) | moq_net::announce::Event::Update(update) => { + live |= update.prefix.as_str() == path; + } + moq_net::announce::Event::End(update) => { + live &= update.prefix.as_str() != path; + } + } + } + live +} + +/// A side that declines MoQ Solicit invites unsolicited advertisements, so its peer +/// announces unasked and that is not held against the peer, even though the peer +/// implements the extension itself. +#[moq_net_sim::test] +async fn a_side_that_declines_solicit_takes_unsolicited_announces() { + for name in MOQT { + let version: Version = name.parse().unwrap(); + for server_declines in [true, false] { + let context = format!("{name}, server declines: {server_declines}"); + let publisher = produce_origin(1); + let subscriber = produce_origin(2); + let no_solicit = declined(|e| e.solicit = false); + + let mut options = MockConnectOptions::new(version); + match server_declines { + true => { + options.client_publish = Some(publisher.consume()); + options.server_subscribe = Some(subscriber.clone()); + options.server_extensions = no_solicit; + } + false => { + options.server_publish = Some(publisher.consume()); + options.client_subscribe = Some(subscriber.clone()); + options.client_extensions = no_solicit; + } + } + let pair = connect_mock(options).await; + + let broadcast = publisher.create_broadcast("room/cam").unwrap(); + broadcast.announce(Default::default()).unwrap(); + moq_net_sim::sleep(SETTLE).await; + + assert!(announced(&subscriber, "room/cam"), "{context}: never announced"); + assert_open(&pair, &context); + } + } +} + +/// A side that declines MoQ Auth leaves it un-negotiated whichever side declines: +/// neither side holds a grant, and a token is unsupported rather than presented. +#[moq_net_sim::test] +async fn a_declined_auth_extension_is_not_negotiated() { + for name in AUTH { + let version: Version = name.parse().unwrap(); + + // Control: offered by both, the client's connection credential earns a grant. + let pair = connect_mock(MockConnectOptions::new(version)).await; + let mut grant = pair.client.auth().grant(); + moq_net_sim::timeout(SETTLE, async { + while grant.peek().is_none() { + grant.changed().await.expect("grant watch ended"); + } + }) + .await + .unwrap_or_else(|_| panic!("{name}: no grant with both offering")); + + for server_declines in [true, false] { + let context = format!("{name}, server declines: {server_declines}"); + let no_auth = declined(|e| e.auth = false); + let mut options = MockConnectOptions::new(version); + match server_declines { + true => options.server_extensions = no_auth, + false => options.client_extensions = no_auth, + } + let pair = connect_mock(options).await; + moq_net_sim::sleep(SETTLE).await; + + for session in [&pair.client, &pair.server] { + assert_eq!(session.auth().grant().peek(), None, "{context}"); + let added = moq_net_sim::timeout(SETTLE, session.auth().add("token")).await; + assert!( + matches!(added, Ok(Err(Error::Unsupported))), + "{context}: {:?}", + added.map(|res| res.map(|_| ())) + ); + } + assert_open(&pair, &context); + } + } +} diff --git a/rs/moq-net/tests/support/harness.rs b/rs/moq-net/tests/support/harness.rs index 7ea37f3d39..ee96cf25f9 100644 --- a/rs/moq-net/tests/support/harness.rs +++ b/rs/moq-net/tests/support/harness.rs @@ -53,6 +53,10 @@ pub struct MockConnectOptions { pub latency: std::time::Duration, /// What the client may make the server's session hold, when not the default. pub server_limits: Option, + /// The extensions the client offers. + pub client_extensions: moq_net::setup::Extensions, + /// The extensions the server offers. + pub server_extensions: moq_net::setup::Extensions, } impl MockConnectOptions { @@ -66,6 +70,8 @@ impl MockConnectOptions { server_subscribe: None, latency: std::time::Duration::ZERO, server_limits: None, + client_extensions: Default::default(), + server_extensions: Default::default(), } } } @@ -96,7 +102,9 @@ pub async fn connect_mock(opts: MockConnectOptions) -> MockPair { client_transport.set_latency(opts.latency); let transports = (client_transport.clone(), server_transport.clone()); - let mut client = Client::new().with_versions(opts.version.into()); + let mut client = Client::new() + .with_versions(opts.version.into()) + .with_extensions(opts.client_extensions); if let Some(publish) = &opts.client_publish { client = client.with_publisher(publish); } @@ -104,7 +112,9 @@ pub async fn connect_mock(opts: MockConnectOptions) -> MockPair { client = client.with_subscriber(subscribe); } - let mut server = Server::new().with_versions(opts.version.into()); + let mut server = Server::new() + .with_versions(opts.version.into()) + .with_extensions(opts.server_extensions); if let Some(publish) = &opts.server_publish { server = server.with_publisher(publish); } 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 556f50fdc6..1e225666da 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/rs/moq-tokio/src/client.rs b/rs/moq-tokio/src/client.rs index 6ccede73f0..5836d4526d 100644 --- a/rs/moq-tokio/src/client.rs +++ b/rs/moq-tokio/src/client.rs @@ -4,7 +4,7 @@ //! QUIC settings ([`crate::quic::Config`]) a binary shares with its accept half. The //! accept side is [`crate::server`]. -use crate::connection::Goaway; +use crate::connection::{Goaway, RequestToken}; use crate::{Addrs, Backoff, Connection, Error}; use futures::future::BoxFuture; #[cfg(all(feature = "websocket", feature = "noq"))] @@ -44,6 +44,10 @@ impl Config { #[derive(Clone)] pub struct Client { moq: moq_net::Client, + /// The request token seeded from [`crate::connect::Config::with_request_token`], read once + /// by [`crate::Connection`] to seed its own request-token handle (the single source of truth + /// across reconnects). + request_token: Option, /// The single resolved set of protocol versions, used to advertise moq ALPNs across /// every transport (passed into the QUIC backend's `connect` and used directly for /// raw TCP/UDS qmux and WebSocket). Resolved once in [`Client::new`] so the ALPN list @@ -143,7 +147,10 @@ impl Client { let timeout = resolved.timeout; Ok(Self { - moq: moq_net::Client::new().with_versions(versions.clone()), + moq: moq_net::Client::new() + .with_versions(versions.clone()) + .with_extensions(config.extensions), + request_token: config.request_token.clone().map(|token| token.0), #[cfg(any( feature = "noq", feature = "iroh", @@ -305,6 +312,14 @@ impl Client { Some(self.with_subscriber(origin).connect(url)) } + /// The request token seeded from [`crate::connect::Config::with_request_token`], if any. + /// + /// Read by [`Connection`] to initialize its own request-token handle, which is the single + /// home for the token thereafter. + pub(crate) fn request_token(&self) -> Option { + self.request_token.clone() + } + /// Dial the given URL and complete the MoQ handshake. /// /// Errors if no transport feature is compiled in. @@ -315,7 +330,7 @@ impl Client { feature = "tcp", feature = "uds" )))] - pub(crate) async fn dial(&self, _addr: crate::connect::Addr) -> crate::Result { + pub(crate) async fn dial(&self, _addr: crate::connect::Addr, _token: &RequestToken) -> crate::Result { Err(Error::NoBackend( "no backend compiled; enable noq, iroh, websocket, tcp, or uds feature", )) @@ -335,10 +350,10 @@ impl Client { feature = "tcp", feature = "uds" ))] - pub(crate) async fn dial(&self, addr: crate::connect::Addr) -> crate::Result { + pub(crate) async fn dial(&self, addr: crate::connect::Addr, token: &RequestToken) -> crate::Result { // Each compiled backend adds state to this dispatch future. Keep it off the // caller's stack so all-feature builds remain safe on standard 2 MiB threads. - let attempt = Box::pin(self.connect_inner(addr)); + let attempt = Box::pin(self.connect_inner(addr, token)); // The deadline covers the dial AND the handshake, for every transport: it is the // only bound some of them have. Dropping `attempt` on expiry cancels whichever @@ -384,7 +399,7 @@ impl Client { feature = "tcp", feature = "uds" ))] - async fn connect_inner(&self, addr: crate::connect::Addr) -> crate::Result { + async fn connect_inner(&self, addr: crate::connect::Addr, token: &RequestToken) -> crate::Result { let url = addr.url().clone(); // Transports with no request URI of their own advertise the request target in the // SETUP instead; `setup_path` returns `None` for the ones that carry a URI, where @@ -403,7 +418,7 @@ impl Client { if url.scheme() == "tcp" { let session = crate::tcp::connect(url, &self.versions.alpns(), self.failover_delay, self.resolution_delay).await?; - let session = connect_session(&moq, crate::transport::Session::new(session)).await?; + let session = connect_session(&moq, crate::transport::Session::new(session), token).await?; return Ok(Dialed::new(session, crate::Transport::Tcp)); } @@ -418,7 +433,7 @@ impl Client { self.resolution_delay, ) .await?; - let session = connect_session(&moq, crate::transport::Session::new(session)).await?; + let session = connect_session(&moq, crate::transport::Session::new(session), token).await?; return Ok(Dialed::new(session, crate::Transport::Tcp)); } @@ -427,7 +442,7 @@ impl Client { #[cfg(all(feature = "uds", unix))] if url.scheme() == "unix" { let session = crate::unix::connect(url, &self.versions.alpns()).await?; - let session = connect_session(&moq, crate::transport::Session::new(session)).await?; + let session = connect_session(&moq, crate::transport::Session::new(session), token).await?; return Ok(Dialed::new(session, crate::Transport::Unix)); } @@ -435,7 +450,7 @@ impl Client { // nothing to race and the fallback's answer is the connect's verdict. #[cfg(feature = "websocket")] if matches!(url.scheme(), "ws" | "wss") { - return self.connect_websocket(addr).await; + return self.connect_websocket(addr, token).await; } // iroh offers the moq ALPNs ahead of H3, so two moq endpoints normally land on raw @@ -454,7 +469,7 @@ impl Client { crate::iroh::Binding::H3 => self.moq.clone(), }; - let session = connect_session(&moq, crate::transport::Session::new(session)).await?; + let session = connect_session(&moq, crate::transport::Session::new(session), token).await?; return Ok(Dialed::new(session, crate::Transport::Iroh)); } @@ -474,19 +489,19 @@ impl Client { #[cfg(feature = "websocket")] { - return self.race_moq_connect(&moq, addr, quic_handle).await; + return self.race_moq_connect(&moq, addr, quic_handle, token).await; } #[cfg(not(feature = "websocket"))] { let session = quic_handle.await?; - let session = connect_session(&moq, session).await?; + let session = connect_session(&moq, session, token).await?; return Ok(Dialed::new(session, quic_transport(&url))); } } #[cfg(feature = "websocket")] - return self.connect_websocket(addr).await; + return self.connect_websocket(addr, token).await; #[cfg(not(feature = "websocket"))] return Err(Error::NoBackend("no QUIC backend matched; this should not happen")); @@ -495,11 +510,11 @@ impl Client { /// Connect over WebSocket alone. qmux over WebSocket carries the path in its request /// URI, so the plain builder is used: repeating it in the SETUP is a protocol violation. #[cfg(feature = "websocket")] - async fn connect_websocket(&self, addr: crate::connect::Addr) -> crate::Result { + async fn connect_websocket(&self, addr: crate::connect::Addr, token: &RequestToken) -> crate::Result { let alpns = self.versions.alpns(); let session = crate::websocket::connect(&self.websocket, &self.tls, self.tls_host_name.as_deref(), addr, &alpns).await?; - let session = connect_session(&self.moq, crate::transport::Session::new(session)).await?; + let session = connect_session(&self.moq, crate::transport::Session::new(session), token).await?; Ok(Dialed::new(session, crate::Transport::WebSocket)) } @@ -521,6 +536,7 @@ impl Client { moq: &moq_net::Client, addr: crate::connect::Addr, quic: Q, + token: &RequestToken, ) -> crate::Result where Q: Future> + Unpin + Send + 'static, @@ -539,9 +555,9 @@ impl Client { }; match race_transport_connect(quic, websocket).await? { - TransportRace::Quic(quic) => Ok(Dialed::new(connect_session(moq, quic).await?, transport)), + TransportRace::Quic(quic) => Ok(Dialed::new(connect_session(moq, quic, token).await?, transport)), TransportRace::WebSocket { session, quic } => { - let session = match connect_session(&self.moq, crate::transport::Session::new(session)).await { + let session = match connect_session(&self.moq, crate::transport::Session::new(session), token).await { Ok(session) => session, Err(err) => { // The fallback got through but its MoQ handshake did not. A QUIC dial still @@ -561,7 +577,7 @@ impl Client { // UDP gets through after all, so the next dial gives QUIC its head start. crate::websocket::forget(&url); // Both handshakes failing is still a two-arm loss: a mixed auth pair stays retryable. - let session = connect_session(moq, quic) + let session = connect_session(moq, quic, token) .await .map_err(|quic| race_error(quic.into(), err))?; return Ok(Dialed::new(session, transport)); @@ -570,10 +586,11 @@ impl Client { let mut dialed = Dialed::new(session, crate::Transport::WebSocket); dialed.upgrade = quic.ok().map(|quic| { let moq = moq.clone(); + let token = token.clone(); let dial = Box::pin(async move { let quic = quic.await?; Ok(Box::pin(async move { - let session = connect_session(&moq, quic).await?; + let session = connect_session(&moq, quic, &token).await?; // Wait for the peer's SETUP before draining WebSocket. This crate's servers // send SETUP after admission; other servers may still refuse afterward. // A refusal, or a version with no SETUP to wait on, keeps WebSocket. @@ -878,10 +895,18 @@ fn race_error(quic: Error, websocket: Error) -> Error { async fn connect_session( client: &moq_net::Client, transport: S, + token: &RequestToken, ) -> Result { let (session, driver) = client .connect(tokio::time::Instant::now().into_std(), transport) .await?; + // Attach before the driver is spawned: `enforce_grant` checks for a token when the driver + // starts, and the first request carries the token current now (one renewed while the + // handshake was pending included). Every later renewal then reaches this session. On + // draft-14/15/16 the first PUBLISH_NAMESPACE carries the token inline and cannot renew in + // place, so a stale seed there would stick. A session that cannot carry the token set + // (moq-lite) fails the dial rather than drop it. + token.attach(session.auth())?; use tracing::Instrument; tokio::spawn(moq_net::time::run(driver).instrument(tracing::Span::current())); Ok(session) @@ -1597,7 +1622,7 @@ mod tests { let dialed = tokio::time::timeout( std::time::Duration::from_secs(10), - client.race_moq_connect(&client.moq, ws_addr, quic), + client.race_moq_connect(&client.moq, ws_addr, quic, &RequestToken::default()), ) .await .expect("client connect timed out") @@ -1685,7 +1710,7 @@ mod tests { Ok::<_, crate::Error>(crate::transport::Session::new(session)) }); let cc = client - .race_moq_connect(&client.moq, ws_addr, quic) + .race_moq_connect(&client.moq, ws_addr, quic, &RequestToken::default()) .await .expect("client connect failed"); @@ -1853,7 +1878,8 @@ mod tests { // `dial` rather than `connect`: this is about one attempt's deadline, and // `connect` now hands back a reconnect loop that would redial past it. - let mut attempt = Box::pin(client.dial(url.into())); + let token = RequestToken::default(); + let mut attempt = Box::pin(client.dial(url.into(), &token)); let _silent = tokio::select! { res = &mut attempt => match res { Err(err) => panic!("connect failed before the silent peer accepted it: {err}"), diff --git a/rs/moq-tokio/src/connect.rs b/rs/moq-tokio/src/connect.rs index 6589087eb8..07d4afaae8 100644 --- a/rs/moq-tokio/src/connect.rs +++ b/rs/moq-tokio/src/connect.rs @@ -361,6 +361,35 @@ impl ConnectError { mod tests { use super::*; + /// A request token seeded on the dial config is kept out of its `Debug` and never + /// serialized, since a config is routinely logged and written back. + #[test] + fn a_request_token_stays_out_of_debug_and_serde() { + let config = Config::default().with_request_token(moq_net::setup::Token { + kind: 0, + value: b"s3cr3t".to_vec(), + }); + assert!(config.request_token.is_some()); + let debug = format!("{config:?}"); + assert!(!debug.contains("s3cr3t") && debug.contains("<6 bytes>"), "{debug}"); + assert!(!toml::to_string(&config).expect("serialize").contains("request_token")); + } + + /// The dial config offers every extension unless told otherwise, and leaves the + /// default out when serialized. + #[test] + fn extensions_default_on_and_parse_off() { + let config: Config = toml::from_str("[extensions]\nsolicit = false\n").expect("parse"); + assert!(!config.extensions.solicit); + assert!(config.extensions.auth); + assert!(toml::to_string(&config).expect("serialize").contains("solicit = false")); + assert!( + !toml::to_string(&Config::default()) + .expect("serialize") + .contains("extensions") + ); + } + #[test] fn auth_statuses_are_terminal() { assert_eq!(ConnectError::from_status_u16(401), Some(ConnectError::Unauthorized)); @@ -464,6 +493,21 @@ failover_delay = "1s" } } +/// A request token held by a [`Config`], shown by type and length only so it stays out of logs. +#[derive(Clone, PartialEq, Eq)] +pub(crate) struct RequestToken(pub moq_net::setup::Token); + +impl std::fmt::Debug for RequestToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "kind={} <{} bytes>", self.0.kind, self.0.value.len()) + } +} + +/// Whether `extensions` offers every extension, the default a config leaves out. +pub(crate) fn all_extensions(extensions: &moq_net::setup::Extensions) -> bool { + *extensions == moq_net::setup::Extensions::default() +} + /// The dial side of an endpoint: where to connect and how to get there. /// /// Derives [`usage::Args`], so flatten it into a binary's own parser with @@ -609,6 +653,17 @@ pub struct Config { )] pub version: Vec, + /// The moq-transport extensions to offer in SETUP, all by default. + #[serde(default, skip_serializing_if = "crate::connect::all_extensions")] + #[usage(skip)] + pub extensions: moq_net::setup::Extensions, + + /// The `AUTHORIZATION TOKEN` every session presents on its own requests; see + /// [`with_request_token`](Self::with_request_token). + #[serde(skip)] + #[usage(skip)] + pub(crate) request_token: Option, + /// TLS trust and client-certificate settings (`--connect-tls-*`). #[usage(flatten)] #[serde(default)] @@ -687,6 +742,8 @@ impl Default for Config { timeout: DEFAULT_TIMEOUT, timeout_arg: None, version: Vec::new(), + extensions: Default::default(), + request_token: None, tls: Default::default(), once: None, reconnect: None, @@ -701,6 +758,15 @@ impl Default for Config { } impl Config { + /// Present `token` as the `AUTHORIZATION TOKEN` on every session's own requests (MoQ + /// request-token). It seeds the owning [`crate::Connection`]'s request-token handle, the + /// single source of truth across reconnects, so renewing it on a live connection is + /// [`crate::Connection::auth`] with [`Auth::set_request_token`](crate::Auth::set_request_token). + pub fn with_request_token(mut self, token: moq_net::setup::Token) -> Self { + self.request_token = Some(RequestToken(token)); + self + } + /// Every released spelling this config was parsed from, across this section and /// the TLS and WebSocket ones it owns, each paired with what replaced it. /// diff --git a/rs/moq-tokio/src/connection.rs b/rs/moq-tokio/src/connection.rs index 5c5d8f555c..90f8ae021f 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -484,6 +484,8 @@ struct Shared { send_bw: BandwidthProducer, recv_bw: BandwidthProducer, closed: CloseGuard, + /// Attached to each session as its handshake completes, detached once nothing is live. + request_token: RequestToken, } impl Shared { @@ -521,6 +523,12 @@ impl Shared { /// serving throughout, so only the status moves back. fn stayed(&self) { self.status(Status::Connected); + // The failed upgrade attached its own session before its handshake failed, so renewals + // go back to the session that kept serving. + if let Some(session) = &self.state.read().session { + // It carried the token when it attached, so it still can. + let _ = self.request_token.attach(session.auth()); + } } fn status(&self, status: Status) { @@ -530,7 +538,7 @@ impl Shared { } /// Nothing is live. Clears the session so a handle can't be handed a closed one, - /// and drops the estimates that belonged to it. + /// and drops the estimates and the request-token attachment that belonged to it. fn disconnected(&self) { if let Ok(mut state) = self.state.write() { // Count one close per live session; a second call without an @@ -546,6 +554,7 @@ impl Shared { } let _ = self.send_bw.set(None); let _ = self.recv_bw.set(None); + self.request_token.detach(); } /// Record the error the loop gave up with, for [`Connection::closed`]. @@ -634,6 +643,131 @@ pub struct Snapshot { pub version: Version, } +/// The request token a [`Connection`] presents on its own requests and the session it +/// presents it on, shared across its sessions and shown by length only so it stays out of logs. +/// +/// The single source of truth across reconnects. Seeding a new session +/// ([`attach`](Self::attach)) and renewing the token ([`set`](Self::set)) each read and +/// write under one lock, so neither can leave a session on an older token than the one set +/// last, and a token set while a session is still handshaking is the one it starts with. +/// +/// A plain mutex, held only across synchronous work: presenting stores the token in the +/// session's own cell and wakes its driver, which runs elsewhere and never takes this lock. +#[derive(Clone, Default)] +pub(crate) struct RequestToken(std::sync::Arc>); + +#[derive(Default)] +struct Presenting { + token: Option, + /// The newest session, which a renewal reaches; `None` while nothing is live. + session: Option>, +} + +/// Where a request token is presented: a session's auth handle. +pub(crate) trait Present: Send { + /// Present `token` on this session's requests from now on, or refuse it when the session + /// cannot carry one (moq-lite). + fn present(&self, token: moq_net::setup::Token) -> Result<(), moq_net::Error>; +} + +impl Present for moq_net::auth::Handle { + fn present(&self, token: moq_net::setup::Token) -> Result<(), moq_net::Error> { + self.set_request_token(token) + } +} + +impl RequestToken { + fn new(token: Option) -> Self { + Self(std::sync::Arc::new(std::sync::Mutex::new(Presenting { + token, + session: None, + }))) + } + + /// The token to present right now, or `None` when none is set. + #[cfg(test)] + fn get(&self) -> Option { + self.0.lock().unwrap().token.clone() + } + + /// Replace the token, on the newest session too, and seed every later session with it. + /// + /// Errors when the newest session cannot carry it; later sessions are still seeded. + fn set(&self, token: moq_net::setup::Token) -> Result<(), moq_net::Error> { + let mut presenting = self.0.lock().unwrap(); + presenting.token = Some(token.clone()); + match &presenting.session { + Some(session) => session.present(token), + None => Ok(()), + } + } + + /// Seed `session` with the current token and make it the session later renewals reach. + /// + /// Called once the handshake completes and before the session's driver starts, so its + /// first request carries the newest token. Errors when the session cannot carry the token + /// set, which leaves it unattached. + pub(crate) fn attach(&self, session: impl Present + 'static) -> Result<(), moq_net::Error> { + let mut presenting = self.0.lock().unwrap(); + if let Some(token) = &presenting.token { + session.present(token.clone())?; + } + presenting.session = Some(Box::new(session)); + Ok(()) + } + + /// Stop reaching the session once nothing is live; renewals then only update the seed. + fn detach(&self) { + // Also runs from `Shared`'s drop, so take a poisoned lock rather than panic while unwinding. + self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).session = None; + } +} + +impl std::fmt::Debug for RequestToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.0.lock().unwrap().token { + Some(token) => write!(f, "kind={} <{} bytes>", token.kind, token.value.len()), + None => write!(f, ""), + } + } +} + +/// Renews the request token a [`Connection`] presents, independent of any one session. +/// +/// Returned by [`Connection::auth`]. The connection owns the token, so one set here survives +/// reconnects: the loop seeds every new session from it. Setting a new token on a live +/// connection also re-presents it on the current session's live requests as a REQUEST_UPDATE, +/// renewing a token-authorized request in place. +/// +/// This is the request-token slice of the connection-owned auth handle. Session credentials +/// and grant union (the relay-token work) are not built here. +#[derive(Clone)] +pub struct Auth { + token: RequestToken, +} + +impl std::fmt::Debug for Auth { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Auth").field("request_token", &self.token).finish() + } +} + +impl Auth { + /// Present `token` as the `AUTHORIZATION TOKEN` on this connection's own requests (MoQ + /// request-token). + /// + /// Updates the connection's source of truth, so every later session is seeded with it, and + /// reaches the live session (if any) so a renewal re-presents on its live requests as a + /// REQUEST_UPDATE. A session still handshaking starts with it. Replaces whatever + /// [`crate::connect::Config::with_request_token`] seeded. + /// + /// Errors with [`moq_net::Error::Unsupported`] when the live session speaks moq-lite, whose + /// requests carry no token; later sessions are still seeded with it. + pub fn set_request_token(&self, token: moq_net::setup::Token) -> Result<(), moq_net::Error> { + self.token.set(token) + } +} + /// Handle to a connection maintained by a background task. /// /// The task connects, waits for the session to end, then (unless reconnecting is @@ -659,6 +793,9 @@ pub struct Connection { /// The last status returned by [`status`](Self::status), for change detection. /// Per-clone: a clone starts from its parent's cursor and diverges from there. last_reported: Option, + /// The request token presented across reconnects, the single source of truth the loop + /// seeds each session from. Shared with every [`Auth`] handle from [`auth`](Self::auth). + request_token: RequestToken, } /// The connection loop, shared by every [`Connection`] clone and aborted when the @@ -693,6 +830,11 @@ impl Connection { let closed: CloseGuard = Default::default(); let task_closed = closed.clone(); + // The connection owns the request token (seeded from the dial config), so a renewal + // outlives any one session and the loop attaches each new session to the same cell. + let request_token = RequestToken::new(client.request_token()); + let loop_token = request_token.clone(); + let task = tokio::spawn(async move { let reconnect = client.reconnect; let shared = Shared { @@ -700,6 +842,7 @@ impl Connection { send_bw, recv_bw, closed: task_closed, + request_token: loop_token, }; if let Err(err) = Self::run(&shared, client, addrs).await { // In one-shot mode the session ending is the expected lifecycle, and @@ -721,6 +864,7 @@ impl Connection { send_bandwidth, recv_bandwidth, last_reported: None, + request_token, } } @@ -1051,7 +1195,7 @@ impl Connection { tracing::info!(peer = %Endpoint(url), "connecting"); - let mut dial = std::pin::pin!(client.dial(addr.clone())); + let mut dial = std::pin::pin!(client.dial(addr.clone(), &shared.request_token)); let dialed = kio::wait(|waiter| { if poll_draining(draining, waiter) { shared.disconnected(); @@ -1246,6 +1390,19 @@ impl Connection { last_presence: moq_net::stats::Presence::default(), } } + + /// Renew the request token this connection presents, across reconnects. + /// + /// Returns an [`Auth`] handle the connection owns rather than the current session: a token + /// set through it is the single source of truth the reconnect loop seeds every new session + /// from, and setting one on a live connection also re-presents it on the current session as + /// a REQUEST_UPDATE. The connection starts from + /// [`crate::connect::Config::with_request_token`], and this replaces it. + pub fn auth(&self) -> Auth { + Auth { + token: self.request_token.clone(), + } + } } /// The reconnect loop's backoff window across a run of failed attempts. @@ -1532,6 +1689,372 @@ fn terminal(state: &State) -> Error { mod tests { use super::*; + /// A request token of kind 0 carrying `value`. + fn tok(value: &[u8]) -> moq_net::setup::Token { + moq_net::setup::Token { + kind: 0, + value: value.to_vec(), + } + } + + /// The request token is shown by length only, never printed, in both the cell and the + /// [`Auth`] handle that wraps it. + #[test] + fn request_token_debug_is_redacted() { + let token = RequestToken::new(Some(tok(b"s3cr3t"))); + assert_eq!(format!("{token:?}"), "kind=0 <6 bytes>"); + assert_eq!(format!("{:?}", RequestToken::default()), ""); + + let auth = Auth { token }; + let debug = format!("{auth:?}"); + assert!(!debug.contains("s3cr3t"), "the token leaked into Debug: {debug}"); + assert!(debug.contains("<6 bytes>"), "{debug}"); + } + + /// Renewing through the handle updates the connection's source of truth even with no live + /// session, so the reconnect loop seeds the next session with the latest token. The live + /// session path is covered end to end by + /// [`the_first_request_on_a_connected_session_carries_the_token`]. + #[test] + fn renewal_updates_the_reconnect_seed() { + let token = RequestToken::new(None); + let auth = Auth { token: token.clone() }; + assert_eq!(token.get(), None); + + // No live session: the cell still updates and does not panic. This is the value a + // reconnect reads to seed its session. + auth.set_request_token(tok(b"first")).unwrap(); + assert_eq!(token.get(), Some(tok(b"first"))); + + auth.set_request_token(tok(b"second")).unwrap(); + assert_eq!( + token.get(), + Some(tok(b"second")), + "the latest renewal is what the next session is seeded with" + ); + } + + /// A session double that records every token it is presented and parks inside its first + /// [`Present::present`] until the test releases it: exactly where a seed or a renewal + /// writes the session, after reading the token it writes. + struct Parked { + presented: std::sync::Arc>>, + /// Taken by the first present, which reports that it parked and waits to be released. + park: std::sync::Mutex, std::sync::Arc)>>, + } + + /// How the write under test got as far as the test sees it. + enum Step { + /// Parked inside its first present. + Parked, + /// Returned without presenting anything. + Returned, + } + + impl Parked { + fn new(presented: &std::sync::Arc>>) -> Self { + Self { + presented: presented.clone(), + park: Default::default(), + } + } + } + + impl Present for Parked { + fn present(&self, token: moq_net::setup::Token) -> Result<(), moq_net::Error> { + let park = self.park.lock().unwrap().take(); + if let Some((parked, release)) = park { + parked.send(Step::Parked).unwrap(); + release.wait(); + } + self.presented.lock().unwrap().push(token); + Ok(()) + } + } + + /// Park the first present `write` makes on another thread, then race `renew` against it, + /// returning every token the session was presented in order. + /// + /// While `write` is parked mid-present, the cell's lock must still be held, so `renew` + /// cannot land between `write`'s read and its write and be overwritten by the older token. + fn race_renewal( + token: &RequestToken, + write: impl FnOnce(&RequestToken, Parked) + Send + 'static, + renew: moq_net::setup::Token, + ) -> Vec { + let presented = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let release = std::sync::Arc::new(std::sync::Barrier::new(2)); + let (step, steps) = std::sync::mpsc::channel(); + let parked = Parked::new(&presented); + *parked.park.lock().unwrap() = Some((step.clone(), release.clone())); + + let writer = { + let token = token.clone(); + std::thread::spawn(move || { + write(&token, parked); + let _ = step.send(Step::Returned); + }) + }; + assert!( + matches!(steps.recv(), Ok(Step::Parked)), + "the write under test never presented a token" + ); + assert!( + token.0.try_lock().is_err(), + "a renewal could land between the parked write's read and its write" + ); + + let renewer = { + let token = token.clone(); + std::thread::spawn(move || token.set(renew).unwrap()) + }; + release.wait(); + writer.join().expect("writer panicked"); + renewer.join().expect("renewer panicked"); + std::mem::take(&mut *presented.lock().unwrap()) + } + + /// A session that cannot carry a request token (moq-lite). + struct Refuses; + + impl Present for Refuses { + fn present(&self, _token: moq_net::setup::Token) -> Result<(), moq_net::Error> { + Err(moq_net::Error::Unsupported) + } + } + + /// A session that cannot carry the token refuses it loudly: attaching fails, so the dial + /// does too, and a renewal reaching it fails while still seeding later sessions. + #[test] + fn a_session_that_cannot_carry_the_token_refuses_it() { + let token = RequestToken::new(Some(tok(b"token-a"))); + assert!(matches!(token.attach(Refuses), Err(moq_net::Error::Unsupported))); + + let token = RequestToken::new(None); + token.attach(Refuses).expect("nothing to present"); + assert!(matches!(token.set(tok(b"token-b")), Err(moq_net::Error::Unsupported))); + assert_eq!(token.get(), Some(tok(b"token-b")), "later sessions are still seeded"); + } + + /// A reconnect seeding its new session cannot replay the token it read over a renewal that + /// lands meanwhile: the session ends on the renewal, as the reconnect seed does. + #[test] + fn a_renewal_racing_the_reconnect_seed_is_not_replayed() { + let a = tok(b"token-a"); + let b = tok(b"token-b"); + let token = RequestToken::new(Some(a.clone())); + + let presented = race_renewal(&token, |token, session| token.attach(session).unwrap(), b.clone()); + + assert_eq!(presented, [a, b.clone()], "seeded with A, then renewed to B"); + assert_eq!(token.get(), Some(b), "the next reconnect is seeded with B too"); + } + + /// Two renewals present in the order they land, so the session ends on the one that landed + /// last, the same token the next reconnect is seeded with. + #[test] + fn concurrent_renewals_leave_the_session_on_the_last() { + let a = tok(b"token-a"); + let b = tok(b"token-b"); + let token = RequestToken::new(None); + + let presented = race_renewal( + &token, + { + let a = a.clone(); + move |token, session| { + // Nothing to seed, so the attach presents nothing and the renewal parks. + token.attach(session).unwrap(); + token.set(a).unwrap(); + } + }, + b.clone(), + ); + + assert_eq!(presented, [a, b.clone()], "renewed to A, then to B"); + assert_eq!(token.get(), Some(b), "the next reconnect is seeded with B too"); + } + + /// Once nothing is live a renewal only updates the seed, and the next attach picks it up. + #[test] + fn a_renewal_between_sessions_seeds_the_next() { + let presented = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let session = || Parked::new(&presented); + let token = RequestToken::new(Some(tok(b"token-a"))); + + token.attach(session()).unwrap(); + token.detach(); + token.set(tok(b"token-b")).unwrap(); + assert_eq!( + presented.lock().unwrap().len(), + 1, + "a renewal reached a detached session" + ); + + token.attach(session()).unwrap(); + assert_eq!(presented.lock().unwrap().last().cloned(), Some(tok(b"token-b"))); + } + + /// The connection owns the request token across the reconnect loop: the dial config seeds + /// the cell the loop presents on each (re)connected session, and renewing through + /// [`Connection::auth`] updates that same cell, so a renewal survives into the next session. + /// + /// The live-session presentation and the REQUEST_UPDATE renewal on the wire are covered by + /// moq-net's auth suite, which drives the same `session.auth().set_request_token` seam this + /// connection calls on every connect. + #[cfg(feature = "tcp")] + #[tokio::test] + async fn the_connection_owns_the_request_token_across_the_loop() { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + + let t1 = tok(b"token-one"); + let mut config = crate::connect::Config::default(); + config.tls.insecure = Some(true); + let config = config.with_request_token(t1.clone()); + let client = config.init(Default::default()).expect("client"); + + // A dead address: the background loop never connects, but the handle and its token + // cell exist immediately, which is all this asserts. + let conn = client.connect(url("tcp://127.0.0.1:1/")); + assert_eq!( + conn.request_token.get(), + Some(t1), + "the dial config seeds the cell the loop presents on each session" + ); + + let t2 = tok(b"token-two"); + conn.auth().set_request_token(t2.clone()).unwrap(); + assert_eq!( + conn.request_token.get(), + Some(t2), + "renewing through auth() updates the source of truth the next session is seeded with" + ); + } + + /// A draft-16 server for the first-request tests: it accepts one session, reports on + /// `paused` once the client's SETUP has arrived (the client's handshake then waits on this + /// side's reply), holds its SERVER_SETUP until `release` fires, and sends the first + /// request-borne token it verifies on the returned receiver. + /// + /// It declines Solicit, so each announce rides its own PUBLISH_NAMESPACE, the request that + /// carries a token inline. + #[cfg(feature = "tcp")] + async fn first_request_token( + paused: tokio::sync::oneshot::Sender<()>, + release: tokio::sync::oneshot::Receiver<()>, + ) -> (Url, tokio::sync::oneshot::Receiver) { + let mut cfg = crate::listen::Config::default(); + cfg.tcp.bind = Some("127.0.0.1:0".parse().unwrap()); + cfg.version = vec!["moq-transport-16".parse().unwrap()]; + cfg.extensions.solicit = false; + let mut listener = cfg + .init(Default::default()) + .expect("server") + .listen() + .await + .expect("listen"); + let addr = listener.tcp_local_addr().expect("tcp listener bound"); + let url = url(&format!("tcp://{addr}/")); + let (first_tx, first_rx) = tokio::sync::oneshot::channel(); + tokio::spawn(async move { + let request = listener.accept().await.expect("the client dials"); + let _ = paused.send(()); + let _ = release.await; + + let request = request.with_subscriber(crate::origin::spawn()); + let mut requests = request.auth().requests().expect("requests are taken before ok"); + let _session = request.ok().await.expect("accept"); + let mut first_tx = Some(first_tx); + let mut held = Vec::new(); + while let Some(req) = requests.next().await { + if req.scope().is_some() + && let Some(tx) = first_tx.take() + { + let _ = tx.send(req.token().clone()); + } + held.push(req.accept(moq_net::auth::Grant::all())); + } + }); + (url, first_rx) + } + + /// A draft-16 client presenting `token`, with a broadcast announced before it connects so + /// its first PUBLISH_NAMESPACE is pending when the session's driver starts. It declines the + /// AUTH extension, so the request token is the only authorizing artifact. + #[cfg(feature = "tcp")] + fn announcing_client(token: moq_net::setup::Token, url: Url) -> (Connection, moq_net::broadcast::Producer) { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let origin = crate::origin::spawn(); + let broadcast = origin.create_broadcast("room/alice").expect("broadcast"); + broadcast.announce(Default::default()).expect("announce"); + + let mut cfg = crate::connect::Config::default(); + cfg.tls.insecure = Some(true); + cfg.version = vec!["moq-transport-16".parse().unwrap()]; + cfg.extensions.auth = false; + let client = cfg + .with_request_token(token) + .init(Default::default()) + .expect("client") + .with_publisher(origin.consume()) + .with_reconnect(false); + (client.connect(url), broadcast) + } + + /// A token of kind 0 carrying `value`, and the bytes the acceptor sees. + #[cfg(feature = "tcp")] + fn request_token(value: &'static [u8]) -> (moq_net::setup::Token, bytes::Bytes) { + (tok(value), bytes::Bytes::from_static(value)) + } + + /// The dial config's token reaches the FIRST request on a connected session: the session + /// is attached to the connection's token before its driver starts, and nothing re-sets it + /// afterwards, so a session started without it would present nothing. + #[cfg(feature = "tcp")] + #[tokio::test] + async fn the_first_request_on_a_connected_session_carries_the_token() { + let (paused_tx, _paused) = tokio::sync::oneshot::channel(); + let (release, release_rx) = tokio::sync::oneshot::channel(); + let (url, first) = first_request_token(paused_tx, release_rx).await; + release.send(()).unwrap(); + + let (token, value) = request_token(b"a"); + let (_conn, _broadcast) = announcing_client(token, url); + let first = tokio::time::timeout(Duration::from_secs(10), first) + .await + .expect("the first request never reached the server") + .expect("server ended"); + assert_eq!(first, value, "the first request carries the configured token"); + } + + /// A token set while the handshake is still pending is the one the first request carries: + /// the session is seeded when it is created, not from a snapshot taken before dialing. The + /// server holds its SERVER_SETUP until the renewal returns, and draft-16 carries the token + /// inline on the first PUBLISH_NAMESPACE, which cannot renew in place. + #[cfg(feature = "tcp")] + #[tokio::test] + async fn a_renewal_during_the_handshake_reaches_the_first_request() { + let (paused_tx, paused) = tokio::sync::oneshot::channel(); + let (release, release_rx) = tokio::sync::oneshot::channel(); + let (url, first) = first_request_token(paused_tx, release_rx).await; + + let (a, _) = request_token(b"a"); + let (b, b_value) = request_token(b"b"); + let (conn, _broadcast) = announcing_client(a, url); + paused.await.expect("the client's SETUP reached the server"); + conn.auth().set_request_token(b).unwrap(); + release.send(()).unwrap(); + + let first = tokio::time::timeout(Duration::from_secs(10), first) + .await + .expect("the first request never reached the server") + .expect("server ended"); + assert_eq!( + first, b_value, + "the first request carries the renewal, not the dial-time token" + ); + } + /// Updating with an empty CLI preserves a standing TOML value over typed defaults. #[test] fn cli_does_not_clobber_toml_backoff() { @@ -2057,6 +2580,7 @@ mod tests { send_bw: BandwidthProducer::new(), recv_bw: BandwidthProducer::new(), closed: CloseGuard::default(), + request_token: RequestToken::default(), } } diff --git a/rs/moq-tokio/src/lib.rs b/rs/moq-tokio/src/lib.rs index 9bf366fd5a..67f27e5db5 100644 --- a/rs/moq-tokio/src/lib.rs +++ b/rs/moq-tokio/src/lib.rs @@ -70,7 +70,7 @@ pub mod websocket; // new `pub` item in these modules doesn't silently join it. pub use client::Client; pub use connect::{Addrs, ConnectError}; -pub use connection::{Backoff, Connection, Redirect, Status}; +pub use connection::{Auth, Backoff, Connection, Redirect, Status}; pub use error::{Error, Result}; pub use log::{Log, RedactedUrl}; #[cfg(feature = "_transport")] diff --git a/rs/moq-tokio/src/listen.rs b/rs/moq-tokio/src/listen.rs index ddea557f9e..b60f3e5d85 100644 --- a/rs/moq-tokio/src/listen.rs +++ b/rs/moq-tokio/src/listen.rs @@ -146,6 +146,11 @@ pub struct Config { )] pub version: Vec, + /// The moq-transport extensions to offer in SETUP, all by default. + #[serde(default, skip_serializing_if = "crate::connect::all_extensions")] + #[usage(skip)] + pub extensions: moq_net::setup::Extensions, + /// Maximum time for one accepted connection to finish its handshake: the /// QUIC, WebTransport, WebSocket, or qmux one, then the MoQ SETUP, through /// [`crate::server::Request::ok`]. Defaults to 10 seconds; set to 0 to wait forever. @@ -258,6 +263,7 @@ impl Default for Config { #[cfg(all(feature = "uds", unix))] unix: Default::default(), version: Vec::new(), + extensions: Default::default(), timeout: DEFAULT_TIMEOUT, timeout_arg: None, tls: Default::default(), @@ -507,6 +513,21 @@ impl Config { #[cfg(test)] mod tests { use super::*; + + /// The listen config offers every extension unless told otherwise, and leaves the + /// default out when serialized. + #[test] + fn extensions_default_on_and_parse_off() { + let config: Config = toml::from_str("[extensions]\nauth = false\n").expect("parse"); + assert!(!config.extensions.auth); + assert!(config.extensions.solicit); + assert!(toml::to_string(&config).expect("serialize").contains("auth = false")); + assert!( + !toml::to_string(&Config::default()) + .expect("serialize") + .contains("extensions") + ); + } /// A parser wrapping the config, since it derives `Args` (see the note in /// [`crate::connect`]). #[derive(usage::Cli)] diff --git a/rs/moq-tokio/src/server.rs b/rs/moq-tokio/src/server.rs index ecb2e98ff5..56ffbbc1bf 100644 --- a/rs/moq-tokio/src/server.rs +++ b/rs/moq-tokio/src/server.rs @@ -362,7 +362,10 @@ impl Server { iroh::listen(endpoint, &versions)?; } - let mut moq = moq_net::Server::new().with_versions(versions.clone()).with_stats(stats); + let mut moq = moq_net::Server::new() + .with_versions(versions.clone()) + .with_extensions(config.extensions) + .with_stats(stats); if let Some(publisher) = publisher { moq = moq.with_publisher(publisher); } @@ -1642,6 +1645,17 @@ impl Request { request_ref!(self, r => r.token()) } + /// The session's [`auth::Handle`](moq_net::auth::Handle), for an app that verifies the + /// peer's request tokens itself (the `AUTHORIZATION TOKEN` carried on a SUBSCRIBE or + /// PUBLISH_NAMESPACE). + /// + /// Take [`requests`](moq_net::auth::Handle::requests) here and answer them before + /// [`ok`](Self::ok): the acceptor is fixed on the session driver's first poll, which `ok` + /// starts, so a consumer installed afterwards races it. + pub fn auth(&self) -> moq_net::auth::Handle { + request_ref!(self, r => r.auth()) + } + /// The client certificate chain the peer presented, if any, validated /// against a configured [`crate::tls::Listen::root`] during the handshake. /// diff --git a/test/interop/README.md b/test/interop/README.md index 3359d53444..aac50a3c6d 100644 --- a/test/interop/README.md +++ b/test/interop/README.md @@ -72,6 +72,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, @@ -128,6 +159,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 @@ -227,7 +260,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 767bca5ab8..78029a050d 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(); connection.close(); // returns void, not a promise origin.close(); diff --git a/test/interop/interop.sh b/test/interop/interop.sh index a6c0c93fde..8dae2ff9cb 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) @@ -36,6 +44,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 @@ -457,17 +466,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 @@ -477,6 +522,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. @@ -496,29 +598,30 @@ 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) - bun "$INTEROP_JS_PUBLISH_CLIENT/client.ts" publish "$URL" "$broadcast" + 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). cd "$CLIENTS/js" && bun driver.ts publish \ - --url "$URL" --broadcast "$broadcast" + --url "$url" --broadcast "$broadcast" ;; *) echo "unknown publisher: $lang" >&2 @@ -527,11 +630,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" } @@ -560,7 +664,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 @@ -615,22 +719,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), @@ -647,7 +751,7 @@ PYCODE local n n=$(GST_PLUGIN_PATH_1_0="$GST_PLUGIN_DIR" GST_REGISTRY_1_0="$HARNESS_RUN/gst-run-registry.bin" \ timeout -k 3 "$TIMEOUT" gst-launch-1.0 -q \ - moqsrc name=s url="$URL" broadcast="$broadcast" \ + moqsrc name=s url="$url" broadcast="$broadcast" \ s.video_0 ! filesink location=/dev/stdout buffer-mode=2 \ 2>/dev/null | head -c 1 | wc -c | tr -d ' ' || true) [[ "${n:-0}" -ge 1 ]] @@ -659,21 +763,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 @@ -694,53 +798,60 @@ 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. run_round() { - local pub="$1" broadcast="$2" pub_pid="$3" - local pids=() names=() i sub + local pub="$1" broadcast="$2" pub_pid="$3" want_pass="$4" + local pids=() names=() i sub sub_url sub_grant why + sub_url=$(token_url --subscribe "**/$broadcast") + sub_grant=$(grant_line "" "**/$broadcast") for sub in "${SUB_LIST[@]}"; do if is_broken "$sub"; then echo " FAIL $pub -> $sub (subscriber client unavailable)" overall=1 continue fi - harness_spawn "$pub-$sub" "$HARNESS_RUN/$pub-$sub.log" run_cell "$pub" "$sub" "$broadcast" + harness_spawn "$pub-$sub" "$HARNESS_RUN/$pub-$sub.log" run_cell "$pub" "$sub" "$broadcast" "$sub_url" pids+=("$HARNESS_PID") names+=("$sub") done # A publisher that streams forever should still be alive; if it died, the # subscriber failures below are a publisher bug, so surface its log. - if [[ -n "$pub_pid" ]] && ! kill -0 "$pub_pid" 2>/dev/null; then + if [[ "$want_pass" -eq 1 && -n "$pub_pid" ]] && ! kill -0 "$pub_pid" 2>/dev/null; then echo " WARN publisher '$pub' exited early:" sed 's/^/ /' "$HARNESS_RUN/pub-$pub.log" 2>/dev/null || true fi - local want_pass=1 got round_pass=0 elapsed - [[ "$NEGATIVE" -eq 1 ]] && want_pass=0 + local got round_pass=0 elapsed # ${arr[@]+...} guard: a round may have no live subscribers (all broken), # and bash 3.2 (macOS) errors on "${!pids[@]}" for an empty array under `set -u`. for i in ${pids[@]+"${!pids[@]}"}; do + why="" if harness_wait "${pids[$i]}"; then got=1; else got=0; fi elapsed=$(cat "$HARNESS_RUN/$pub-${names[$i]}.secs" 2>/dev/null || echo "?") - if [[ "$got" -eq "$want_pass" ]]; then + if [[ "$got" -eq "$want_pass" ]] && why=$(check_grant "${names[$i]}" "$HARNESS_RUN/$pub-${names[$i]}.log" "$sub_grant"); then echo " PASS $pub -> ${names[$i]} (${elapsed}s)" round_pass=1 else - echo " FAIL $pub -> ${names[$i]} (${elapsed}s of ${TIMEOUT}s)" + echo " FAIL $pub -> ${names[$i]} (${elapsed}s of ${TIMEOUT}s)${why:+ $why}" sed 's/^/ /' "$HARNESS_RUN/$pub-${names[$i]}.log" 2>/dev/null || true overall=1 fi done # Every leg failing points at the publisher; surface its log even when the # process is still alive (e.g. connected and announcing but producing nothing). - if [[ "$NEGATIVE" -eq 0 && "$round_pass" -eq 0 && ${#pids[@]} -gt 0 && -n "$pub_pid" ]]; then + if [[ "$want_pass" -eq 1 && "$round_pass" -eq 0 && ${#pids[@]} -gt 0 && -n "$pub_pid" ]]; then echo " INFO publisher '$pub' log:" sed 's/^/ /' "$HARNESS_RUN/pub-$pub.log" 2>/dev/null || true fi @@ -759,7 +870,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 @@ -772,7 +883,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. @@ -791,7 +922,7 @@ elif [[ "$NEGATIVE" -eq 1 ]]; then # Negative control: no publisher. Every subscriber must FAIL (time out with # no data), proving the harness can actually report failure. echo "=== negative control: subscribers expect NO data ===" - run_round "none" "interop-missing-$$-$RANDOM.hang" "" + run_round "none" "interop-missing-$$-$RANDOM.hang" "" 0 else for pub in "${PUB_LIST[@]}"; do broadcast="interop-${pub}-$$-${RANDOM}.hang" @@ -803,8 +934,37 @@ else overall=1 continue fi - start_publisher "$pub" "$broadcast" - run_round "$pub" "$broadcast" "$PUB_PID" + # The exact broadcast, not its subtree. + start_publisher "$pub" "$pub" "$broadcast" "$(token_url --publish "$broadcast")" + run_round "$pub" "$broadcast" "$PUB_PID" 1 + if why=$(check_grant "$pub" "$HARNESS_RUN/pub-$pub.log" "$(grant_line "$broadcast" "")"); then + 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) ===" + start_publisher "$pub-denied" "$pub" "$broadcast" "$(token_url --publish "$allowed")" + run_round "$pub-denied" "$broadcast" "$PUB_PID" 0 + log="$HARNESS_RUN/pub-$pub-denied.log" + if ! check_denied "$log" "$broadcast"; then + echo " FAIL $pub publisher did not fail with Unauthorized naming $broadcast:" + sed 's/^/ /' "$log" 2>/dev/null || true + overall=1 + elif ! why=$(check_grant "$pub" "$log" "$(grant_line "$allowed" "")"); then + echo " FAIL $pub $why" + overall=1 + else + echo " PASS $pub publisher refused: Unauthorized" + fi done # 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