diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index 8e84fde839..f4a4148613 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -164,6 +164,27 @@ TOKEN` option with Token Type 0; `moq auth serve` verifies it the same way. HMAC can itself be **scoped** at generation (`--root`, `--publish`, `--subscribe`), after which it can never sign a broader token. +### On a request + +The same `AUTHORIZATION TOKEN` may ride an individual request (SUBSCRIBE, +REQUEST\_UPDATE, PUBLISH\_NAMESPACE, FETCH, PUBLISH, SUBSCRIBE\_NAMESPACE, +TRACK\_STATUS), not only the SETUP. A request is authorized by the session's +grant first; when that does not cover the request's path, by the token on the +request; with neither it is refused `UNAUTHORIZED`. A request token's grant +covers only the request it rode on, never widens the session, and ends when +the request ends. A REQUEST\_UPDATE carrying a fresh token refreshes it, so a +long-lived request (an ingest PUBLISH\_NAMESPACE, a subscription) renews its +credential in place without reconnecting; a refused renewal leaves the old +grant standing until it lapses. + +Verifying a request token is a moq-net library capability: an application takes +`auth::Handle::requests()` before running the session and answers each token +tagged with the request's path and kind (`auth::Request::path()`, `::kind()`). +moq-relay does not yet opt in on the request path, so as of this release it +refuses a request token with `NOT_SUPPORTED`; wiring a per-request lease into +the `--auth-url` server and the in-process [`admissions()`](#in-process) API is +a follow-up. + ### Claims | Claim | Meaning | diff --git a/js/net/src/connection/accept.ts b/js/net/src/connection/accept.ts index 7772aa29b6..1095863a19 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,6 +34,9 @@ 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 is on unless set to `false`. */ + extensions?: Extensions.Extensions; } /** The per-session wiring shared by every negotiated protocol path. */ @@ -41,6 +45,8 @@ type SessionProps = { 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; }; /** @@ -69,6 +75,7 @@ async function acceptInner( discovery: props.discovery ?? true, publish: props.publish, client: false, + extensions: Extensions.offered(props.extensions), }; if (protocol === Ietf.ALPN.DRAFT_22) { @@ -117,7 +124,12 @@ async function acceptAlpn( version: Ietf.IetfVersion, wiring: SessionProps, ): Promise { - const { control, solicit, hidden, cluster, auth } = await exchangeSetup(transport, version, "moq-lite-js"); + const { control, solicit, hidden, cluster, auth } = await exchangeSetup( + transport, + version, + "moq-lite-js", + wiring.extensions, + ); return new Ietf.Connection({ ...wiring, @@ -162,7 +174,7 @@ async function acceptSetup( const params = new Ietf.SetupOptions(); params.setVarint(Ietf.SetupOption.MaxRequestId, 42069n); 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 }); @@ -222,7 +234,7 @@ async function acceptNegotiated( const params = new Ietf.SetupOptions(); params.setVarint(Ietf.SetupOption.MaxRequestId, 42069n); 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 15b2017e4f..e20c818e06 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 { dev, 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 is 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 @@ -124,6 +128,8 @@ type SessionProps = { 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. @@ -170,6 +176,7 @@ async function connectInner(url: URL, props: Omit, abort: P discovery: props.discovery ?? true, publish: props.publish, client: true, + extensions: Extensions.offered(props.extensions), }; if (props.transport) { @@ -308,7 +315,7 @@ async function negotiate(url: URL, session: WebTransport, wiring: SessionProps): const params = new Ietf.SetupOptions(); params.setVarint(Ietf.SetupOption.MaxRequestId, 42069n); 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({ @@ -365,7 +372,12 @@ async function handshakeAlpn( version: Ietf.IetfVersion, wiring: SessionProps, ): Promise { - const { control, solicit, hidden, cluster, auth } = await exchangeSetup(session, version, "moq-lite-js"); + const { control, solicit, hidden, cluster, auth } = await exchangeSetup( + session, + version, + "moq-lite-js", + wiring.extensions, + ); return new Ietf.Connection({ ...wiring, diff --git a/js/net/src/connection/extensions.test.ts b/js/net/src/connection/extensions.test.ts new file mode 100644 index 0000000000..4596235568 --- /dev/null +++ b/js/net/src/connection/extensions.test.ts @@ -0,0 +1,20 @@ +import { expect, test } from "bun:test"; +import * as Ietf from "../ietf/index.ts"; +import { intoSetup, offered } from "./extensions.ts"; + +test("every extension is offered by default", () => { + expect(offered()).toEqual({ auth: true, solicit: true }); + expect(offered({ solicit: false })).toEqual({ auth: true, solicit: false }); +}); + +test("only the offered extensions reach the SETUP", () => { + const all = new Ietf.SetupOptions(); + intoSetup(all, offered(), Ietf.Version.DRAFT_18); + expect(Ietf.solicitFromSetup(all)).toBe(true); + expect(Ietf.Auth.fromSetup(all, Ietf.Version.DRAFT_18)).toBe(true); + + const none = new Ietf.SetupOptions(); + intoSetup(none, offered({ auth: false, solicit: false }), Ietf.Version.DRAFT_18); + expect(Ietf.solicitFromSetup(none)).toBeUndefined(); + expect(Ietf.Auth.fromSetup(none, Ietf.Version.DRAFT_18)).not.toBe(true); +}); diff --git a/js/net/src/connection/extensions.ts b/js/net/src/connection/extensions.ts new file mode 100644 index 0000000000..279f8712cc --- /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 is 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 applies to moq-transport sessions only. + */ +export interface Extensions { + /** The MoQ Auth extension: tokens presented and granted on their own stream. */ + 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 unset extensions 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 9433eafd7e..1a6c4e40e8 100644 --- a/js/net/src/connection/handshake.ts +++ b/js/net/src/connection/handshake.ts @@ -1,6 +1,7 @@ import { type Hop, randomHop } from "../hop.ts"; import * as Ietf from "../ietf/index.ts"; import { Reader, Stream, Writer } from "../stream.ts"; +import * as Extensions from "./extensions.ts"; /** * Draft-17+ SETUP exchange. Each side opens a uni stream, writes its Setup @@ -10,16 +11,17 @@ import { Reader, Stream, Writer } from "../stream.ts"; * * Returns the control stream plus what the peer's SETUP declared: whether it requires * solicitation, which decides whether we announce namespaces unprompted (see the MoQ Solicit - * extension), its Hop ID (see the MoQ Cluster extension), and whether it offered MoQ Auth. - * We declare all three ourselves on - * every session: we send SUBSCRIBE_NAMESPACE for each prefix we want, so an unsolicited - * advertisement can tell us nothing we won't have asked for, and a peer that knows our Hop - * ID can withhold the advertisements that already flowed through us. + * 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` turns them + * off: 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; solicit: boolean | undefined; @@ -30,9 +32,8 @@ export async function exchangeSetup( 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); - Ietf.Auth.intoSetup(params, version); // One id per session, like the moq-lite connection: nothing in this process forwards // between sessions, so there is nothing for a shared id to detect. @@ -51,7 +52,8 @@ export async function exchangeSetup( solicit: received.solicit, hidden: received.hidden, cluster: { self, peer: received.cluster }, - auth: received.auth, + // Auth is negotiated only when both sides offer it. + auth: received.auth && extensions.auth, }; } 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/ietf/connection.ts b/js/net/src/ietf/connection.ts index 3ffea47dc9..625bc613ec 100644 --- a/js/net/src/ietf/connection.ts +++ b/js/net/src/ietf/connection.ts @@ -71,6 +71,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; @@ -103,6 +105,7 @@ export class Connection implements Established { hidden = false, cluster, auth = false, + extensions, }: { url: URL; quic: WebTransport; @@ -126,8 +129,10 @@ export class Connection implements Established { * cannot negotiate the extension, as is a `peer` the peer never declared. */ cluster?: Cluster.Hops; - /** Whether the peer's SETUP offered MoQ Auth (draft-17+). */ + /** Whether MoQ Auth is negotiated (draft-17+): offered by both SETUPs. */ auth?: boolean; + /** What our own SETUP offered; every extension when omitted. */ + extensions?: { solicit: boolean }; }) { this.url = url; this.discovery = discovery; @@ -180,6 +185,7 @@ export class Connection implements Established { ready: this.#auth.setupAnswered(), }); this.#solicit = solicit; + this.#declaredSolicit = extensions?.solicit ?? true; this.#cluster = cluster; this.#subscriber = new Subscriber({ session: this.#session, quic, cluster, hidden, grant: this.#auth.grant }); registerWire(this, { consume: (path) => this.#subscriber.consume(path) }); @@ -300,18 +306,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 told otherwise 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}`, ); diff --git a/js/net/src/ietf/ietf.test.ts b/js/net/src/ietf/ietf.test.ts index ec6d906c20..852e062299 100644 --- a/js/net/src/ietf/ietf.test.ts +++ b/js/net/src/ietf/ietf.test.ts @@ -90,6 +90,27 @@ async function encodeFetchFrameVersioned( return concatChunks(written); } +test("message parameters accept and drop an AUTHORIZATION TOKEN (0x03)", async () => { + // A request-token peer may present the token on SUBSCRIBE, FETCH, PUBLISH, TRACK_STATUS, + // PUBLISH_NAMESPACE or SUBSCRIBE_NAMESPACE. draft-16 tolerates unknown parameters + // generically; a draft-18 strict decoder must recognize 0x03 or it fails the whole + // message. We have no accept-side consumer, so the value is decoded and dropped. + const token = new Uint8Array([0x03, 0x81, 0x2c, 0x00, 0xff]); + for (const version of [Version.DRAFT_16, Version.DRAFT_18]) { + const params = new Parameters(); + params.bytes.set(0x03n, token); + + const { stream, written } = createTestWritableStream(); + const writer = new Writer(stream, version); + await params.encode(writer, version); + writer.close(); + await writer.closed; + + const decoded = await Parameters.decode(new Reader(undefined, concatChunks(written), version), version); + expect(decoded.bytes.get(0x03n)).toEqual(token); + } +}); + test("DEFAULT_PUBLISHER_PRIORITY has exact SUBSCRIBE_OK bytes per draft", async () => { for (const version of [ Version.DRAFT_14, diff --git a/js/net/src/ietf/parameters.ts b/js/net/src/ietf/parameters.ts index 65b1c65047..e662b54ded 100644 --- a/js/net/src/ietf/parameters.ts +++ b/js/net/src/ietf/parameters.ts @@ -211,6 +211,11 @@ const MSG_PARAM_HIDDEN = 0x40b5en; // Bytes parameter IDs (odd) const MSG_PARAM_LARGEST_OBJECT = 0x09n; +/// AUTHORIZATION TOKEN (0x03): a per-request credential (MoQ request-token). This client has +/// no accept-side consumer to verify one, so it is decoded and dropped; an uncovered request +/// is then refused by the session grant as before. Recognizing it keeps a draft-17+ peer that +/// presents a token from failing the whole message on an unknown parameter. +const MSG_PARAM_AUTHORIZATION_TOKEN = 0x03n; const MSG_PARAM_SUBSCRIPTION_FILTER = 0x21n; /// FILL_PARAMETERS, draft-20's request for a backfill. const MSG_PARAM_FILL_PARAMETERS = 0x23n; @@ -245,6 +250,7 @@ function getMessageParamKind(id: bigint): MessageParamKind { case MSG_PARAM_FILL_PARAMETERS: case MSG_PARAM_INCLUDE_PROPERTIES: case MSG_PARAM_HOP_PATH: + case MSG_PARAM_AUTHORIZATION_TOKEN: return "bytes"; default: throw new Error(`unknown message parameter id: ${id.toString()}`); diff --git a/quest/m1/auth/request-token.md b/quest/m1/auth/request-token.md index 3a9cef655a..eb4a5ea0bf 100644 --- a/quest/m1/auth/request-token.md +++ b/quest/m1/auth/request-token.md @@ -71,6 +71,23 @@ gives it meaning. Public API: additive on `moq_net::auth::Request` (the request it belongs to) and on `moq_auth::Client` (the per-request lease). Wire: none new; the parameter already exists in every supported draft. +Decided in review: + +- Client credential: the request token rides the auth handle beside + session tokens, distinguished by kind (`auth::Handle::set_request_token`), + with no `Client` methods. `Connection::auth()` is not in the tree yet, so + moq-tokio seeds it from `connect::Config` on every (re)connected session; + live renewal there arrives with `Connection::auth()`, and is available on + moq-net's `Session::auth()` until then. +- Extensions: a positive `#[non_exhaustive] setup::Extensions { auth, + solicit }`, all on by default, on moq-net's client and server, moq-tokio's + dial and listen `Config`, and JS. Later extensions join it. +- Client-side live renewal stays in this quest: setting a new request token + on the handle re-presents it on live requests. +- `EXPIRED_AUTH_TOKEN` / `MALFORMED_AUTH_TOKEN` land with + [expired-error](/quest/m1/auth/expired-error.md); this quest answers + `UNAUTHORIZED` and `NOT_SUPPORTED`. + ## Required - [Relay tokens](/quest/m1/auth/relay-refresh.md) - supplies the lease diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 3a584598ba..1d29422106 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -238,12 +238,64 @@ impl Permit { } } +/// 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, + ) -> std::task::Poll> { + use std::task::Poll; + match self.token.poll(waiter, |cur| match **cur == *last { + true => Poll::Pending, + false => Poll::Ready(()), + }) { + Poll::Ready(guard) => Poll::Ready((*guard).clone()), + Poll::Pending => Poll::Pending, + } + } +} + /// 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 { @@ -254,9 +306,35 @@ impl Handle { 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. + pub fn set_request_token(&self, token: impl Into) { + self.request_token.set(Some(token.into())); + } + + /// 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 session speaks the AUTH extension. False on a version that cannot + /// negotiate it, and on a handle whose side does not offer it (`Extensions::auth` off), + /// so the SETUP omits the option and no + /// connection credential is presented. + 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. @@ -326,15 +404,42 @@ impl Handle { return Err(Error::Duplicate); } let queue = kio::Queue::new(); - // A version without AUTH never receives a token, so its requests end with - // the session like any other. - if !state.supported { - queue.close(); - } + // 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: Bytes, + token_kind: u64, + 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, token_kind, 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 }, + } + } + /// 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> { @@ -508,6 +613,18 @@ impl Handle { 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) { @@ -520,9 +637,9 @@ impl Handle { } // Nothing will read a withdrawn slot again. state.tokens.retain(|_, slot| !slot.withdrawn); - if let Acceptor::App(queue) = &state.acceptor { - queue.close(); - } + // 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 @@ -769,12 +886,61 @@ impl Drop for Serving { } } +/// 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)] +pub enum RequestKind { + /// A SUBSCRIBE: the subscriber reads the named track. + Subscribe, + /// A FETCH: the subscriber reads a past range of the named track. + Fetch, + /// A PUBLISH: the peer offers a track to publish. + Publish, + /// A PUBLISH_NAMESPACE: the peer announces a namespace it will publish under. + PublishNamespace, + /// A SUBSCRIBE_NAMESPACE: the peer asks to be told what is published under a prefix. + SubscribeNamespace, + /// A TRACK_STATUS: the peer asks for a track's current status. + TrackStatus, +} + +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 or publish by its `publish` patterns. + pub(crate) fn covers(self, grant: &Grant, path: &str) -> bool { + match self { + Self::Subscribe | Self::Fetch | Self::SubscribeNamespace | Self::TrackStatus => { + grant.subscribe.matches(path) + } + Self::Publish | Self::PublishNamespace => grant.publish.matches(path), + } + } +} + +/// The request a token rode on, kept beside the token so the acceptor can scope its +/// grant to that one request. +struct RequestContext { + path: crate::PathOwned, + kind: RequestKind, + /// The Token structure's type (section 8.9): which verifier the token is for. + token_kind: u64, +} + /// A token the peer presented, waiting for an answer. /// +/// A token with no [`path`](Request::path) is the connection's own credential (or an +/// AUTH-stream token), granting the whole session. A token that rode on a request carries +/// that request's [`path`](Request::path) and [`kind`](Request::kind): 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>, + /// `Some` when the token rode on a request; `None` for the connection credential. + context: Option, } impl Request { @@ -782,15 +948,49 @@ impl Request { Self { token, issue: Some(issue), + context: 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: Bytes, + token_kind: u64, + path: crate::PathOwned, + kind: RequestKind, + issue: kio::Shared, + ) -> Self { + Self { + token, + issue: Some(issue), + context: Some(RequestContext { path, kind, token_kind }), } } /// The token the peer presented. Empty means the credential its connection - /// already carried, or none. + /// 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 path(&self) -> Option<&str> { + self.context.as_ref().map(|c| c.path.as_str()) + } + + /// Which request this token rode on, or `None` for the connection's own credential. + pub fn kind(&self) -> Option { + self.context.as_ref().map(|c| c.kind) + } + + /// The Token structure's type (section 8.9), for a request token: which verifier it + /// is for (a CAT reaches the CAT verifier, not the JWT one). `None` for the + /// connection's own credential. + pub fn token_kind(&self) -> Option { + self.context.as_ref().map(|c| c.token_kind) + } + /// Grant the token. The grant holds until the returned [`Issued`] is revoked /// or dropped, or the peer withdraws the token. /// @@ -824,6 +1024,169 @@ fn refuse(issue: &kio::Shared, code: SessionError, reason: String) { 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, a +/// FETCH, ...). It carries the acceptor's grant and a deadline armed at the grant's +/// expiry, and ends the request when the deadline lapses, the acceptor revokes or drops +/// the grant, or a refused renewal leaves the old grant to lapse. A REQUEST_UPDATE the +/// acceptor accepts [`renew`](Self::renew)s it with a fresh grant and expiry. +/// +/// Ending a request grant never touches the session: only that one request ends. +pub(crate) struct RequestGrant { + verdict: RequestVerdict, + grant: Grant, + deadline: crate::runtime::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( + runtime: &R, + verdict: RequestVerdict, + grant: Grant, + path: crate::PathOwned, + kind: RequestKind, + ) -> Self { + let mut deadline = crate::runtime::Deadline::new(runtime); + deadline.set(grant.expires); + Self { + verdict, + grant, + deadline, + path, + kind, + } + } + + /// Whether `grant` covers the request this guard holds. + pub(crate) 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; the reader checks a + /// renewal's coverage on the freshly awaited grant before it calls [`renew`](Self::renew). + #[cfg_attr(not(test), expect(dead_code))] + pub(crate) fn grant(&self) -> &Grant { + &self.grant + } + + /// Replace the grant after an accepted REQUEST_UPDATE: adopt the new verdict (dropping + /// the old, which ends the old token) and re-arm the deadline at the new expiry. A + /// refused renewal does NOT call this: the old grant stands until it lapses. + pub(crate) fn renew(&mut self, verdict: RequestVerdict, grant: Grant) { + self.verdict = verdict; + self.deadline.set(grant.expires); + self.grant = grant; + } + + /// 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, @@ -883,6 +1246,8 @@ pub(crate) struct Gate { 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 { @@ -892,6 +1257,16 @@ impl Gate { 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) } } @@ -901,7 +1276,11 @@ impl Gate { pub(crate) fn poll_denied(&mut self, waiter: &kio::Waiter) -> Poll<()> { loop { let permit = ready_or!(self.handle.poll_permit(self.direction, &mut self.epoch, waiter)); - if !permit.matches(self.path.as_str()) { + 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(()); } } @@ -1094,3 +1473,286 @@ mod tests { assert!(default.poll(&waiter).is_pending()); } } + +#[cfg(test)] +mod request_token_tests { + use super::*; + + 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. + #[tokio::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(Bytes::from_static(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.path(), Some("room/alice")); + assert_eq!(request.kind(), Some(RequestKind::Subscribe)); + assert_eq!(request.token_kind(), Some(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. + #[tokio::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(Bytes::from_static(b"jwt"), 0, subscribe_path(), kind); + let request = requests.next().await.expect("a request reaches the acceptor"); + assert_eq!(request.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. + #[tokio::test] + async fn no_consumer_refuses_a_request_token_as_unsupported() { + let handle = Handle::new(true); + let verdict = handle.verify_request(Bytes::from_static(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. + #[tokio::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(Bytes::from_static(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. + #[tokio::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(Bytes::from_static(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. + #[tokio::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(Bytes::from_static(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.path(), None); + assert_eq!(request.kind(), None); + assert_eq!(request.token_kind(), 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: crate::runtime::Timers::now(runtime).checked_add(Duration::from_secs(secs)), + } + } + + /// The request grant carries the acceptor's expiry, which is the deadline it lapses at. + #[tokio::test(start_paused = true)] + async fn a_request_grant_has_the_acceptors_expiry() { + let runtime = crate::time::Clock::tokio(); + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + let verdict = handle.verify_request(Bytes::from_static(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. + #[tokio::test(start_paused = true)] + async fn a_renewal_token_replaces_the_grant_and_expiry() { + let runtime = crate::time::Clock::tokio(); + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + + let verdict = handle.verify_request(Bytes::from_static(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(Bytes::from_static(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.unwrap(); + request_grant.renew(renewal, renewed); + + 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. + #[tokio::test(start_paused = true)] + async fn the_deadline_ends_the_request_unauthorized_without_renewal() { + let runtime = crate::time::Clock::tokio(); + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + let verdict = handle.verify_request(Bytes::from_static(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 does not touch the grant: the old grant stands and the request + /// ends only when that old grant lapses. + #[tokio::test(start_paused = true)] + async fn a_refused_renewal_keeps_the_old_grant_until_it_lapses() { + let runtime = crate::time::Clock::tokio(); + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + let verdict = handle.verify_request(Bytes::from_static(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: verify it resolves to a refusal, and the caller does + // NOT renew. The old grant is untouched. + let renewal = handle.verify_request(Bytes::from_static(b"bad"), 0, subscribe_path(), RequestKind::Subscribe); + requests + .next() + .await + .unwrap() + .reject(SessionError::Unauthorized, "bad token"); + assert!(matches!( + renewal.grant().await, + Err(Error::Session(SessionError::Unauthorized)) + )); + + // 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. + #[tokio::test(start_paused = true)] + async fn an_update_that_stops_covering_ends_the_request() { + let runtime = crate::time::Clock::tokio(); + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + let verdict = handle.verify_request(Bytes::from_static(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 = tokio::time::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. + #[tokio::test(start_paused = true)] + async fn an_acceptor_revoke_ends_the_request() { + let runtime = crate::time::Clock::tokio(); + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + let verdict = handle.verify_request(Bytes::from_static(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 b592d135c2..dc030341f1 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -20,6 +20,7 @@ pub struct Client { setup_authority: Option, cost: Option, peer_hop: Option, + extensions: crate::setup::Extensions, } impl Client { @@ -132,6 +133,12 @@ impl Client { self } + /// Choose which moq-transport extensions this client offers in its SETUP. Defaults to all. + pub fn with_extensions(mut self, extensions: crate::setup::Extensions) -> Self { + self.extensions = extensions; + self + } + /// The origin pair a session attaches, tagged and filtered. /// /// Reads through the publish (egress) consumer and writes through the @@ -264,8 +271,10 @@ impl Client { // Draft-17+: SETUP is exchanged by the connection driver. // We advertise the request path in our SETUP for URL-less transports. - // The peer's SETUP decides whether AUTH is negotiated. - let auth = crate::auth::Handle::new(true); + // The peer's SETUP decides whether AUTH is negotiated. A client that does not + // offer the extension builds a handle that does not speak it, so its SETUP + // omits the option and no connection credential is presented. + let auth = crate::auth::Handle::new(self.extensions.auth); let (protocol, goaway) = ietf::start(ietf::Config { runtime: runtime.clone(), session: session.clone(), @@ -282,6 +291,7 @@ impl Client { peer_setup_stream: None, peer_declared: None, auth: auth.clone(), + extensions: self.extensions, })?; tracing::debug!(version = ?v, "connected"); @@ -359,7 +369,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); let parameters = parameters.encode_bytes(ietf_encoding)?; @@ -438,6 +450,7 @@ impl Client { peer_setup_stream: None, peer_declared: Some(peer_declared), auth: auth.clone(), + extensions: self.extensions, })?; (None, crate::driver::Protocol::Ietf(protocol), goaway, auth) } diff --git a/rs/moq-net/src/error.rs b/rs/moq-net/src/error.rs index b09ef3e5fb..a8c538a4c8 100644 --- a/rs/moq-net/src/error.rs +++ b/rs/moq-net/src/error.rs @@ -28,6 +28,11 @@ pub enum SessionError { #[error("protocol violation")] ProtocolViolation, + /// 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, + /// A key-value pair was malformed or repeated more than allowed. #[error("key-value formatting error")] KeyValueFormatting, @@ -65,6 +70,7 @@ impl SessionError { Self::GoawayTimeout => 0x10, Self::Timeout => 0x11, Self::Version => 0x15, + Self::TooManyRequestUpdates => 0x1B, Self::App(app) => *app as u32 + 64, Self::Unknown(code) => *code, } @@ -85,6 +91,7 @@ impl SessionError { 0x10 => Self::GoawayTimeout, 0x11 => Self::Timeout, 0x15 => Self::Version, + 0x1B => Self::TooManyRequestUpdates, code @ 64.. => match u16::try_from(code - 64) { Ok(app) => Self::App(app), Err(_) => Self::Unknown(code), @@ -630,6 +637,7 @@ mod tests { SessionError::Internal, SessionError::Unauthorized, SessionError::ProtocolViolation, + SessionError::TooManyRequestUpdates, SessionError::KeyValueFormatting, SessionError::GoawayTimeout, SessionError::Timeout, diff --git a/rs/moq-net/src/ietf/adapter.rs b/rs/moq-net/src/ietf/adapter.rs index e2ab2f2756..a1bb10faa2 100644 --- a/rs/moq-net/src/ietf/adapter.rs +++ b/rs/moq-net/src/ietf/adapter.rs @@ -258,6 +258,10 @@ pub struct VirtualSendStream { /// Accumulates bytes until the request_id can be parsed, /// then registers the stream and flushes. pending: Option, + /// Watches the outgoing frames of an outgoing request stream for a REQUEST_UPDATE + /// renewal, so its reply (keyed to the update's own Request ID on draft-15/16) routes + /// back to the subscription. `None` for incoming streams, which never send renewals. + sniff: Option, } struct OutgoingRegistration { @@ -325,13 +329,71 @@ impl VirtualSendStream { Self { control_tx, pending: None, + sniff: None, } } fn with_registration(control_tx: Queue, pending: OutgoingRegistration) -> Self { + // Only draft-15/16 key a renewal reply to the update's own Request ID; draft-14 answers + // nothing and draft-17+ uses real bidi streams, not this adapter, so only those sniff. + let sniff = matches!(pending.version, Version::Draft15 | Version::Draft16).then(|| UpdateSniffer { + shared: Arc::clone(&pending.shared), + version: pending.version, + buf: BytesMut::new(), + }); Self { control_tx, pending: Some(pending), + sniff, + } + } +} + +/// Watches the outgoing control frames of an outgoing request stream for a REQUEST_UPDATE +/// renewal (a SUBSCRIBE_UPDATE carrying a fresh token). Draft-14/15/16 give the update its own +/// Request ID, distinct from the subscription's, and the receiver keys its REQUEST_OK / +/// REQUEST_ERROR to that update id, but the adapter only ever registered the subscription under +/// its initial id. Recording the update id against the subscription lets the reply route back +/// to the subscription stream (see [`Shared::route_update_reply`]). +/// +/// It observes a copy of the written bytes and never alters forwarding: a parse that falls +/// short only leaves a reply unrouted, which is the behavior before this existed. +/// +/// Only draft-15/16 are recorded: draft-14 answers nothing (no reply to route) and draft-17+ +/// uses real bidi streams, not this adapter. Only SUBSCRIBE_UPDATE is sniffed; a +/// PUBLISH_NAMESPACE_UPDATE carries no subscription id to route a reply to. +struct UpdateSniffer { + shared: Arc, + version: Version, + buf: BytesMut, +} + +impl UpdateSniffer { + fn observe(&mut self, chunk: &[u8]) { + self.buf.extend_from_slice(chunk); + loop { + let mut cursor = std::io::Cursor::new(&self.buf); + let Ok(type_id) = u64::decode(&mut cursor, self.version) else { + return; + }; + let Ok(size) = u16::decode(&mut cursor, self.version) else { + return; + }; + let header_len = cursor.position() as usize; + let frame_len = header_len + size as usize; + if self.buf.len() < frame_len { + return; + } + if type_id == ietf::SubscribeUpdate::ID { + let body = Bytes::copy_from_slice(&self.buf[header_len..frame_len]); + if let (Ok(update_id), Ok(subscription_id)) = ( + decode_request_id(&body, self.version), + decode_subscribe_update_request_id(&body, self.version), + ) { + self.shared.record_update(update_id, subscription_id); + } + } + let _ = self.buf.split_to(frame_len); } } } @@ -341,6 +403,11 @@ impl VirtualSendStream { /// register and flush; forward directly afterwards. Never blocks, since the /// control queue is unbounded. fn push(&mut self, chunk: Bytes) -> Result<(), crate::Error> { + // Observe the bytes for a renewal update before forwarding; this never alters what is + // forwarded, only what the reply routing later knows. + if let Some(sniff) = &mut self.sniff { + sniff.observe(&chunk); + } if let Some(pending) = &mut self.pending { pending.buf.extend_from_slice(&chunk); @@ -643,6 +710,13 @@ struct Shared { /// Namespace → request_id reverse lookup (for v14/v15 namespace-keyed messages). namespaces: Namespaces, + + /// A renewal update's own Request ID → the subscription it renews, for draft-15/16 where + /// the receiver keys the renewal's REQUEST_OK / REQUEST_ERROR to the update id rather than + /// the subscription id. [`route_update_reply`](Self::route_update_reply) consumes an entry; + /// [`forget`](Self::forget) / [`close`](Self::close) drop any left by a subscription that + /// ended before its reply arrived. + updates: Mutex>, } impl Shared { @@ -693,6 +767,10 @@ impl Shared { fn close(&self, request_id: RequestId, raw: Bytes) { let tx = self.streams.lock().unwrap().remove(&request_id); self.namespaces.forget(request_id); + self.updates + .lock() + .unwrap() + .retain(|_, subscription_id| *subscription_id != request_id); if let Some(tx) = tx { tx.push(raw); } @@ -702,6 +780,40 @@ impl Shared { fn forget(&self, request_id: RequestId) { self.streams.lock().unwrap().remove(&request_id); self.namespaces.forget(request_id); + self.updates + .lock() + .unwrap() + .retain(|_, subscription_id| *subscription_id != request_id); + } + + /// Record a renewal update's own Request ID against the subscription it renews. + fn record_update(&self, update_id: RequestId, subscription_id: RequestId) { + self.updates.lock().unwrap().insert(update_id, subscription_id); + } + + /// Route a reply to a buffered REQUEST_UPDATE back to the subscription it renews. + /// + /// Draft-15/16 key the renewal's REQUEST_OK / REQUEST_ERROR to the update's own Request ID, + /// which the adapter never registered as a stream of its own. Deliver it to the + /// subscription stream as a follow-up (data, never a close), so the client observes the + /// answer and a refused renewal leaves the subscription open, as the keep-old-grant policy + /// and draft-14/15/16 cancellation require. Returns true when it handled the message. + fn route_update_reply(&self, type_id: u64, classified: &Route, raw: &Bytes) -> bool { + // Only a renewal's REQUEST_OK / REQUEST_ERROR is rerouted. A PUBLISH_DONE, UNSUBSCRIBE, + // or FETCH_CANCEL that happens to classify as a response or close for a recorded id keeps + // its normal routing: those are not renewal replies. + if type_id != ietf::RequestOk::ID && type_id != ietf::RequestError::ID { + return false; + } + let reply_id = match classified { + Route::Response(id) | Route::CloseStream(id) => *id, + _ => return false, + }; + let Some(subscription_id) = self.updates.lock().unwrap().remove(&reply_id) else { + return false; + }; + self.push(subscription_id, raw.clone()); + true } } @@ -834,7 +946,13 @@ impl ControlStreamAdapter { let raw = encode_raw(type_id, size, &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)?; + // A reply to a buffered REQUEST_UPDATE renewal is keyed to the update's own Request + // ID, which has no stream of its own; route it to the subscription it renews. + if self.shared.route_update_reply(type_id, &classified, &raw) { + continue; + } + match classified { Route::NewRequest(request_id) => self.shared.open_incoming(request_id, raw)?, Route::Response(request_id) | Route::FollowUp(request_id) => self.shared.push(request_id, raw), Route::CloseStream(request_id) => self.shared.close(request_id, raw), @@ -981,9 +1099,10 @@ fn classify(type_id: u64, body: &Bytes, version: Version, namespaces: &Namespace _ => Err(Error::UnexpectedMessage), }, - // Follow-up messages: route to existing stream + // Follow-up messages: route to existing stream. A SUBSCRIBE_UPDATE targets the + // subscription by its Subscribe Request ID (the second field), not the update's own. ietf::SubscribeUpdate::ID => { - let id = decode_request_id(body, version)?; + let id = decode_subscribe_update_request_id(body, version)?; Ok(Route::FollowUp(id)) } @@ -1188,6 +1307,19 @@ fn decode_response_request_id(body: &Bytes, version: Version) -> Result Result { + let mut cursor = std::io::Cursor::new(body); + let _update_request_id = RequestId::decode(&mut cursor, version)?; + let subscription_request_id = RequestId::decode(&mut cursor, version)?; + Ok(subscription_request_id) +} + /// Decode the namespace from a PublishNamespace message body (after the request_id). fn decode_publish_namespace_body(body: &Bytes, version: Version) -> Result { let mut cursor = std::io::Cursor::new(body); @@ -1275,7 +1407,13 @@ mod tests { #[test] fn test_classify_subscribe_update_followup() { - let body = make_body_with_request_id(10, Version::Draft15); + use crate::coding::Encode; + // A SUBSCRIBE_UPDATE carries its own Request ID (7) first and the subscription's + // Request ID (10) second; the follow-up must route to the subscription (10). + let mut buf = BytesMut::new(); + RequestId(7).encode(&mut buf, Version::Draft15).unwrap(); + RequestId(10).encode(&mut buf, Version::Draft15).unwrap(); + let body = buf.freeze(); let route = classify_msg(Version::Draft15, ietf::SubscribeUpdate::ID, &body).unwrap(); assert!(matches!(route, Route::FollowUp(RequestId(10)))); } @@ -1420,6 +1558,7 @@ mod tests { request_id, track_namespace: crate::Path::new(namespace), cluster: None, + authorization_token: None, } } @@ -1493,6 +1632,184 @@ mod tests { (shared, ours, theirs) } + /// One SUBSCRIBE_UPDATE renewal keyed to its own `update_id`, naming the subscription it + /// renews by `subscription_id` in the second field, as a draft-15/16 client frames it. + fn subscribe_update(update_id: RequestId, subscription_id: RequestId) -> ietf::SubscribeUpdate { + ietf::SubscribeUpdate { + request_id: update_id, + subscription_request_id: Some(subscription_id), + start_location: ietf::Location { group: 0, object: 0 }, + end_group: 0, + subscriber_priority: None, + forward: None, + filter: None, + authorization_token: Some(Bytes::from_static(&[0x03, 0x81, 0x2c, 0x00, 0xff])), + } + } + + /// A renewal's reply is keyed to the update's own Request ID on draft-15/16, which owns no + /// stream of its own. It must route to the subscription the update renews, as data on that + /// stream, so the client observes REQUEST_OK / REQUEST_ERROR and a refused renewal leaves the + /// subscription open. Before the update was recorded against the subscription, the accept + /// (routed as a Response to an unknown id) was dropped and the refuse (a CloseStream to an + /// unknown id) was a no-op, so the client's renewal never resolved. + async fn renewal_reply_reaches_the_subscription(version: Version) { + let shared = Arc::new(Shared::default()); + + // Open the subscription stream on request id 4; the first write registers it. + let (mut send, mut recv) = shared.open_outgoing(version); + send.write_chunk(encode_msg(&subscribe(RequestId(4)), version)) + .await + .unwrap(); + + // The client renews twice: 0x40 to be accepted, 0x41 refused. Each names subscription 4. + send.write_chunk(encode_msg(&subscribe_update(RequestId(0x40), RequestId(4)), version)) + .await + .unwrap(); + send.write_chunk(encode_msg(&subscribe_update(RequestId(0x41), RequestId(4)), version)) + .await + .unwrap(); + + // The peer answers each on the update's own id. Route them the way the read loop does. + let ok_msg = ietf::RequestOk { + request_id: Some(RequestId(0x40)), + }; + let ok = encode_msg(&ok_msg, version); + let ok_route = classify( + ietf::RequestOk::ID, + &encode_body(&ok_msg, version), + version, + &shared.namespaces, + ) + .unwrap(); + assert!( + shared.route_update_reply(ietf::RequestOk::ID, &ok_route, &ok), + "{version:?}: an accepted renewal must route to its subscription" + ); + + let err_msg = ietf::RequestError { + request_id: Some(RequestId(0x41)), + error_code: 0, + reason_phrase: "no".into(), + retry_interval: 0, + }; + let err = encode_msg(&err_msg, version); + let err_route = classify( + ietf::RequestError::ID, + &encode_body(&err_msg, version), + version, + &shared.namespaces, + ) + .unwrap(); + assert!( + shared.route_update_reply(ietf::RequestError::ID, &err_route, &err), + "{version:?}: a refused renewal must route to its subscription" + ); + + // Both answers arrive on the subscription stream, in order, and it is still open: the + // refusal was delivered as data, not a close. + assert_eq!( + recv.read_chunk(usize::MAX).await.unwrap(), + Some(ok), + "{version:?}: the client observes the REQUEST_OK" + ); + assert_eq!( + recv.read_chunk(usize::MAX).await.unwrap(), + Some(err), + "{version:?}: the client observes the REQUEST_ERROR" + ); + // The refusal was delivered as data, not a close: the subscription is still open. + assert!( + recv.read_chunk(usize::MAX).now_or_never().is_none(), + "{version:?}: a refused renewal must not close the subscription stream" + ); + } + + #[tokio::test] + async fn a_renewal_reply_routes_back_to_its_subscription_at_draft_15_and_16() { + renewal_reply_reaches_the_subscription(Version::Draft15).await; + renewal_reply_reaches_the_subscription(Version::Draft16).await; + } + + /// A minimal SUBSCRIBE for `request_id`, enough to open and register a subscription stream. + fn subscribe(request_id: RequestId) -> ietf::Subscribe<'static> { + ietf::Subscribe { + request_id, + track_namespace: crate::Path::new("room/alice"), + track_name: "video".into(), + subscriber_priority: 0, + group_order: ietf::GroupOrder::Descending, + filter: ietf::Filter::NextObject, + fill: None, + properties_wanted: false, + authorization_token: None, + } + } + + /// A renewal is recorded only on a draft that answers one, and the mapping is released when + /// the subscription is forgotten or closed, so an outstanding renewal cannot outlive it. + #[tokio::test] + async fn a_renewal_mapping_is_pre_draft_17_only_and_released_with_its_subscription() { + // Draft-14 answers no renewal, so nothing is recorded. + let shared = Arc::new(Shared::default()); + let (mut send, _recv) = shared.open_outgoing(Version::Draft14); + send.write_chunk(encode_msg(&subscribe(RequestId(4)), Version::Draft14)) + .await + .unwrap(); + send.write_chunk(encode_msg( + &subscribe_update(RequestId(0x40), RequestId(4)), + Version::Draft14, + )) + .await + .unwrap(); + assert!( + shared.updates.lock().unwrap().is_empty(), + "draft-14 records no renewal mapping" + ); + + // Draft-15 records the mapping; forgetting the subscription releases it. + let shared = Arc::new(Shared::default()); + let (mut send, _recv) = shared.open_outgoing(Version::Draft15); + send.write_chunk(encode_msg(&subscribe(RequestId(4)), Version::Draft15)) + .await + .unwrap(); + send.write_chunk(encode_msg( + &subscribe_update(RequestId(0x40), RequestId(4)), + Version::Draft15, + )) + .await + .unwrap(); + assert_eq!( + shared.updates.lock().unwrap().len(), + 1, + "draft-15 records the renewal mapping" + ); + shared.forget(RequestId(4)); + assert!( + shared.updates.lock().unwrap().is_empty(), + "forgetting the subscription releases its renewal mapping" + ); + + // Draft-16 records; closing the subscription releases it. + let shared = Arc::new(Shared::default()); + let (mut send, _recv) = shared.open_outgoing(Version::Draft16); + send.write_chunk(encode_msg(&subscribe(RequestId(4)), Version::Draft16)) + .await + .unwrap(); + send.write_chunk(encode_msg( + &subscribe_update(RequestId(0x41), RequestId(4)), + Version::Draft16, + )) + .await + .unwrap(); + assert_eq!(shared.updates.lock().unwrap().len(), 1); + shared.close(RequestId(4), Bytes::new()); + assert!( + shared.updates.lock().unwrap().is_empty(), + "closing the subscription releases its renewal mapping" + ); + } + /// Read until the stream FINs, returning whether it did so within `limit` reads. async fn drained(stream: &mut VirtualRecvStream, limit: usize) -> bool { for _ in 0..limit { diff --git a/rs/moq-net/src/ietf/fetch.rs b/rs/moq-net/src/ietf/fetch.rs index 5987d8efbc..44a4fde264 100644 --- a/rs/moq-net/src/ietf/fetch.rs +++ b/rs/moq-net/src/ietf/fetch.rs @@ -162,6 +162,7 @@ impl Message for Fetch<'_> { _ => { let fetch_type = FetchType::decode(buf, version)?; decode_params!(buf, version, + 0x03 => _authorization_token: Option, 0x20 => subscriber_priority: Option, 0x22 => group_order: Option, ); @@ -755,6 +756,50 @@ mod tests { ]; assert_eq!(encode_message(&msg, Version::Draft18), expected); } + + /// A request-token peer may present an AUTHORIZATION TOKEN (0x03) on a FETCH. The strict + /// decoder consumes it and the legacy trailing block carries it, so the message is + /// accepted rather than failing the session; the token itself is dropped (a FETCH is not + /// authorized by a request token yet). + #[test] + fn fetch_accepts_a_dropped_authorization_token() -> Result<(), EncodeError> { + let token = &[0x03u8, 0x81, 0x2c, 0x00, 0xff]; + let standalone = || FetchType::Standalone { + namespace: Path::new("room"), + track: "video".into(), + start: Location { group: 0, object: 0 }, + end: Location { group: 1, object: 0 }, + }; + + // Strict (draft-18): the parameter is consumed by decode_params. + let version = Version::Draft18; + let mut body = BytesMut::new(); + RequestId(1).encode(&mut body, version).unwrap(); + standalone().encode(&mut body, version).unwrap(); + encode_params!(&mut body, version, 0x03 => Some(bytes::Bytes::from_static(token))); + let mut buf = body.freeze(); + assert_eq!( + Fetch::decode_msg(&mut buf, version) + .expect("strict token accepted") + .request_id, + RequestId(1) + ); + assert!(buf.is_empty()); + + // Legacy (draft-14): the token rides the trailing parameter block. + let version = Version::Draft14; + let mut params = crate::ietf::Parameters::default(); + params.set_bytes(crate::ietf::ParameterBytes::AuthorizationToken, token.to_vec()); + let mut body = BytesMut::new(); + RequestId(1).encode(&mut body, version).unwrap(); + 128u8.encode(&mut body, version).unwrap(); + GroupOrder::Descending.encode(&mut body, version).unwrap(); + standalone().encode(&mut body, version).unwrap(); + params.encode(&mut body, version).unwrap(); + let mut buf = body.freeze(); + Fetch::decode_msg(&mut buf, version).expect("legacy token accepted"); + Ok(()) + } } /// The Object serialization on a fetch stream (draft-20 section 11.4.4), which a fill's diff --git a/rs/moq-net/src/ietf/mod.rs b/rs/moq-net/src/ietf/mod.rs index 39026cef6c..22e317eb2f 100644 --- a/rs/moq-net/src/ietf/mod.rs +++ b/rs/moq-net/src/ietf/mod.rs @@ -26,6 +26,7 @@ mod publish; mod publish_namespace; mod publisher; mod request; +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 669f4f62d7..4918f7c449 100644 --- a/rs/moq-net/src/ietf/parameters.rs +++ b/rs/moq-net/src/ietf/parameters.rs @@ -20,6 +20,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. @@ -303,6 +305,20 @@ impl Param for u64 { } } +/// A length-prefixed byte-string parameter value, such as the `AUTHORIZATION TOKEN` +/// (0x03) a request carries. The odd parameter key frames the value with a Length on +/// every draft, so this is the same byte-string coding [`Vec`] uses; the bytes are +/// the Token structure of section 8.9, decoded with [`super::token::decode_value`]. +impl Param for bytes::Bytes { + fn param_encode(&self, w: &mut W, version: Version) -> Result<(), EncodeError> { + self.encode(w, version) + } + + fn param_decode(r: &mut R, version: Version) -> Result { + bytes::Bytes::decode(r, version) + } +} + /// A Location parameter value, such as LARGEST_OBJECT (0x09). /// /// Draft-16 section 9.2 serializes every Message Parameter as a Key-Value-Pair, and section @@ -648,6 +664,35 @@ mod tests { Ok(()) } + #[test] + fn test_param_bytes_round_trip() { + // A value that is not text and not a single byte, so no codec can assume UTF-8 or a + // fixed width: the AUTHORIZATION TOKEN structure a request carries. + let value = Bytes::from_static(&[0x03, 0x81, 0x2c, 0x00, 0xff]); + for version in [ + Version::Draft14, + Version::Draft15, + Version::Draft16, + Version::Draft17, + Version::Draft18, + ] { + let value = value.clone(); + let expected = value.clone(); + round_trip_params( + version, + move |w, v| { + encode_params!(w, v, 0x03 => value); + Ok(()) + }, + move |r, v| { + decode_params!(r, v, 0x03 => token: Option); + assert_eq!(token, Some(expected), "{version}"); + Ok(()) + }, + ); + } + } + #[test] fn test_param_bool_all_versions() { for version in [ diff --git a/rs/moq-net/src/ietf/peer.rs b/rs/moq-net/src/ietf/peer.rs index 336ffb755a..6f4d7adbee 100644 --- a/rs/moq-net/src/ietf/peer.rs +++ b/rs/moq-net/src/ietf/peer.rs @@ -22,6 +22,13 @@ pub(crate) struct Peer { /// MoQ Auth: whether both sides negotiated the Auth request streams. pub auth: bool, + + /// MAX_REQUEST_UPDATES: the most unanswered REQUEST_UPDATEs the peer accepts on one + /// request stream, if it set a limit (draft-19 section 10.3.1.7). `None` on a draft + /// without the option, and when the peer advertised none or `0` (both meaning no limit). + /// Recorded for a future sender that paces to this credit; the current sender keeps one + /// renewal in flight per request, which stays within any limit without reading it. + pub max_request_updates: Option, } /// Shared slot for [`Peer`], filled when the peer's SETUP is read. @@ -82,6 +89,7 @@ mod tests { solicit: None, hidden: false, auth: false, + max_request_updates: None, }; let slot = PeerSetup::default(); @@ -94,6 +102,7 @@ mod tests { solicit: Some(true), hidden: true, auth: true, + max_request_updates: Some(16), }); assert_eq!(slot.get().await, first); diff --git a/rs/moq-net/src/ietf/publish.rs b/rs/moq-net/src/ietf/publish.rs index cf93727fd1..0c81ae4c2a 100644 --- a/rs/moq-net/src/ietf/publish.rs +++ b/rs/moq-net/src/ietf/publish.rs @@ -334,6 +334,7 @@ impl Message for Publish<'_> { // letting the request reach its NOT_SUPPORTED response. decode_params!(r, version, 0x02 => object_delivery_timeout: Option, + 0x03 => _authorization_token: Option, 0x06 => subgroup_delivery_timeout: Option, 0x08 => _expires: Option, 0x09 => largest_location: Option, @@ -956,4 +957,49 @@ mod tests { assert_eq!(decoded.status_code, UNKNOWN); } + + /// A request-token peer may present an AUTHORIZATION TOKEN (0x03) on a PUBLISH. The + /// strict decoder consumes it and the legacy trailing block carries it, so the message + /// is accepted rather than failing the session; the token is dropped (PUBLISH is refused + /// NOT_SUPPORTED regardless). + #[test] + fn publish_accepts_a_dropped_authorization_token() -> Result<(), EncodeError> { + use super::super::namespace::encode_namespace; + let token = &[0x03u8, 0x81, 0x2c, 0x00, 0xff]; + + // Strict (draft-18): the parameter is consumed alongside the Track Properties block. + let version = Version::Draft18; + let mut body = BytesMut::new(); + RequestId(1).encode(&mut body, version).unwrap(); + encode_namespace(&mut body, &Path::new("room"), version).unwrap(); + "video".encode(&mut body, version).unwrap(); + 42u64.encode(&mut body, version).unwrap(); + encode_params!(&mut body, version, 0x03 => Some(bytes::Bytes::from_static(token))); + Properties::default().encode(&mut body, version).unwrap(); + let mut buf = body.freeze(); + assert_eq!( + Publish::decode_msg(&mut buf, version) + .expect("strict token accepted") + .track_alias, + 42 + ); + assert!(buf.is_empty()); + + // Legacy (draft-14): the token rides the trailing parameter block. + let version = Version::Draft14; + let mut params = crate::ietf::Parameters::default(); + params.set_bytes(crate::ietf::ParameterBytes::AuthorizationToken, token.to_vec()); + let mut body = BytesMut::new(); + RequestId(1).encode(&mut body, version).unwrap(); + encode_namespace(&mut body, &Path::new("room"), version).unwrap(); + "video".encode(&mut body, version).unwrap(); + 42u64.encode(&mut body, version).unwrap(); + GroupOrder::Descending.encode(&mut body, version).unwrap(); + false.encode(&mut body, version).unwrap(); // content exists + true.encode(&mut body, version).unwrap(); // forward + params.encode(&mut body, version).unwrap(); + let mut buf = body.freeze(); + Publish::decode_msg(&mut buf, version).expect("legacy token accepted"); + Ok(()) + } } diff --git a/rs/moq-net/src/ietf/publish_namespace.rs b/rs/moq-net/src/ietf/publish_namespace.rs index 863826dfeb..eadb66caf1 100644 --- a/rs/moq-net/src/ietf/publish_namespace.rs +++ b/rs/moq-net/src/ietf/publish_namespace.rs @@ -12,7 +12,7 @@ use super::Version; /// PublishNamespace message (0x06) /// Sent by the publisher to announce the availability of a namespace. -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct PublishNamespace<'a> { pub request_id: RequestId, pub track_namespace: Path<'a>, @@ -21,6 +21,26 @@ 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) the announcer presented on this request, if any. + /// A subscriber verifies it when its session grant does not already cover the + /// namespace (MoQ request-token); the value is the Token structure of section 8.9, + /// decoded with [`super::token::decode_value`]. + pub authorization_token: Option, +} + +impl std::fmt::Debug for PublishNamespace<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PublishNamespace") + .field("request_id", &self.request_id) + .field("track_namespace", &self.track_namespace) + .field("cluster", &self.cluster) + .field( + "authorization_token", + &super::token::Redacted(&self.authorization_token), + ) + .finish() + } } impl PublishNamespace<'_> { @@ -36,12 +56,13 @@ impl PublishNamespace<'_> { let _required_request_id_delta = u64::decode(r, version)?; } let track_namespace = decode_namespace(r, version)?; - let cluster = decode_cluster_params(r, version, negotiated)?; + let (cluster, authorization_token) = decode_request_params(r, version, negotiated)?; Ok(Self { request_id, track_namespace, cluster, + authorization_token, }) } } @@ -55,7 +76,7 @@ impl Message for PublishNamespace<'_> { 0u64.encode(w, version)?; // required_request_id_delta = 0 (draft-17 only, removed in draft-18 per #1615) } encode_namespace(w, &self.track_namespace, version)?; - encode_cluster_params(w, version, self.cluster.as_ref()) + encode_request_params(w, version, self.cluster.as_ref(), self.authorization_token.as_ref()) } fn decode_msg(r: &mut R, version: Version) -> Result { @@ -63,13 +84,70 @@ impl Message for PublishNamespace<'_> { } } +/// Write the Parameters field of a PUBLISH_NAMESPACE: the optional AUTHORIZATION TOKEN +/// (0x03) followed by the cluster parameters. The token rides the same block as the +/// cluster params but is independent of the extension, so it is handled here rather than +/// in [`encode_cluster_params`] (which also serves the NAMESPACE advertisement, where no +/// token belongs). +fn encode_request_params( + w: &mut W, + version: Version, + advert: Option<&cluster::Advert>, + token: Option<&bytes::Bytes>, +) -> Result<(), EncodeError> { + match advert { + Some(advert) => { + let cost = (advert.cost != 0).then_some(advert.cost); + encode_params!(w, version, + 0x03 => token.cloned(), + cluster::HOP_PATH => advert.hops, + cluster::ROUTE_COST => cost, + ); + } + None => encode_params!(w, version, + 0x03 => token.cloned(), + ), + } + Ok(()) +} + +/// Read the Parameters field of a PUBLISH_NAMESPACE. See [`encode_request_params`]. +fn decode_request_params( + r: &mut R, + version: Version, + negotiated: bool, +) -> Result<(Option, Option), DecodeError> { + if !negotiated { + // The cluster parameters are a violation on a non-negotiated session, so only the + // AUTHORIZATION TOKEN is allowed in the block here. + decode_params!(r, version, + 0x03 => authorization_token: Option, + ); + return Ok((None, authorization_token)); + } + + decode_params!(r, version, + 0x03 => authorization_token: Option, + cluster::HOP_PATH => hops: Option, + cluster::ROUTE_COST => cost: Option, + ); + + Ok(( + Some(cluster::Advert { + hops: hops.ok_or(DecodeError::InvalidValue)?, + cost: cost.unwrap_or(0), + }), + authorization_token, + )) +} + /// REQUEST_UPDATE (0x02) on a PUBLISH_NAMESPACE stream: the cluster parameters that /// changed (draft-lcurley-moq-cluster, Updating an Advertisement). /// /// An omitted parameter keeps its value (moq-transport Section 9.5), so a cost that /// dropped to 0 is sent as an explicit 0, unlike the advertisement itself where absent /// means 0. Draft-17+ only: the extension negotiates on nothing earlier. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq)] pub struct PublishNamespaceUpdate { /// The update's own Request ID; every REQUEST_UPDATE consumes one. pub request_id: RequestId, @@ -77,6 +155,24 @@ pub struct PublishNamespaceUpdate { pub hops: Option, /// ROUTE_COST, when the cost changed. pub cost: Option, + /// The `AUTHORIZATION TOKEN` (0x03) presented on this REQUEST_UPDATE, if any. A fresh + /// token refreshes the announce's request grant (MoQ request-token); the value is the + /// Token structure of section 8.9, decoded with [`super::token::decode_value`]. + pub authorization_token: Option, +} + +impl std::fmt::Debug for PublishNamespaceUpdate { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PublishNamespaceUpdate") + .field("request_id", &self.request_id) + .field("hops", &self.hops) + .field("cost", &self.cost) + .field( + "authorization_token", + &super::token::Redacted(&self.authorization_token), + ) + .finish() + } } impl PublishNamespaceUpdate { @@ -86,6 +182,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, } } @@ -111,6 +208,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, ); @@ -128,10 +226,16 @@ impl Message for PublishNamespaceUpdate { _ => RequestId::decode(r, version)?, }; decode_params!(r, version, + 0x03 => authorization_token: Option, cluster::HOP_PATH => hops: Option, cluster::ROUTE_COST => cost: Option, ); - Ok(Self { request_id, hops, cost }) + Ok(Self { + request_id, + hops, + cost, + authorization_token, + }) } } @@ -358,6 +462,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); @@ -366,6 +471,71 @@ mod tests { assert_eq!(decoded.track_namespace.as_str(), "test/broadcast"); } + /// A request-borne AUTHORIZATION TOKEN round-trips on a legacy draft (draft-14) and a + /// strict one (draft-18), so a non-moq-dev peer can present a credential on a + /// PUBLISH_NAMESPACE the draft-17+ standard way. + #[test] + fn authorization_token_round_trips_legacy_and_strict() { + let token = bytes::Bytes::from_static(&[0x03, 0x81, 0x2c, 0x00, 0xff]); + for version in [ + Version::Draft14, + Version::Draft15, + Version::Draft16, + Version::Draft17, + Version::Draft18, + ] { + let msg = PublishNamespace { + request_id: RequestId(1), + track_namespace: Path::new("room/alice"), + cluster: None, + authorization_token: Some(token.clone()), + }; + let encoded = encode_message(&msg, version); + let decoded: PublishNamespace = decode_message(&encoded, version).unwrap(); + assert_eq!(decoded.authorization_token, Some(token.clone()), "{version:?}"); + assert_eq!(decoded.track_namespace.as_str(), "room/alice", "{version:?}"); + } + } + + /// No token stays no token: a PUBLISH_NAMESPACE without one carries no phantom + /// parameter, on both families. + #[test] + fn absent_authorization_token_stays_none() { + for version in [Version::Draft14, Version::Draft18] { + let msg = PublishNamespace { + request_id: RequestId(2), + track_namespace: Path::new("room/bob"), + cluster: None, + authorization_token: None, + }; + let encoded = encode_message(&msg, version); + let decoded: PublishNamespace = decode_message(&encoded, version).unwrap(); + assert_eq!(decoded.authorization_token, None, "{version:?}"); + } + } + + /// On a cluster-negotiated session the token rides the same parameter block as + /// HOP_PATH and ROUTE_COST, and both survive the trip. + #[test] + fn authorization_token_rides_alongside_cluster_params() { + let version = Version::Draft19; + let token = bytes::Bytes::from_static(&[0x03, 0x81, 0x2c, 0x00, 0xff]); + let msg = PublishNamespace { + request_id: RequestId(3), + track_namespace: Path::new("room/alice"), + cluster: Some(cluster::Advert { + hops: hop_path(&[7, 9]), + cost: 5, + }), + authorization_token: Some(token.clone()), + }; + let mut buf = bytes::Bytes::from(encode_message(&msg, version)); + let decoded = PublishNamespace::decode_body(&mut buf, version, true).unwrap(); + assert!(buf.is_empty()); + assert_eq!(decoded.authorization_token, Some(token)); + assert_eq!(decoded.cluster, msg.cluster); + } + #[test] fn test_announce_error() { let msg = PublishNamespaceError { @@ -447,6 +617,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); @@ -462,6 +633,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); @@ -554,6 +726,7 @@ mod tests { request_id: RequestId(2), hops: None, cost: Some(0), + authorization_token: None, }; let mut buf = BytesMut::new(); assert!(msg.encode_msg(&mut buf, Version::Draft16).is_err()); diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 6a24d5efd0..164ce3acc1 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -15,13 +15,13 @@ use web_transport_trait::poll::SendStream as _; use crate::{ AsPath, Error, Timescale, Timestamp, - coding::{Stream, Writer}, + coding::{Reader, Stream, Writer}, ietf::{self, Control, EndLocation, FetchHeader, FetchType, Filter, GroupOrder, Location, RequestId}, track::Subscription, 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_AGE_MS: u64 = (1_u64 << 53) - 1; @@ -38,6 +38,37 @@ fn serving_subscription(subscriber_priority: u8) -> Subscription { } } +/// Read one `[type][size][body]` control message off a request stream, or `None` once +/// the peer finishes it. Mirrors [`super::auth`]'s reader, but borrows only the reader so +/// a renewal can answer on the writer once the read yields a message. Shared with the +/// subscriber's announce loop. +/// +/// Cancel-safe: the message decodes as one value, so a read dropped part-way consumes +/// nothing and the next one starts at the same message. +pub(super) async fn read_control( + reader: &mut Reader, +) -> Result, Error> { + Ok(reader + .decode_maybe::() + .await? + .map(|ControlMessage(id, data)| (id, data))) +} + +/// A whole `[type][size][body]` control message, decoded at once. +#[derive(Debug)] +struct ControlMessage(u64, bytes::Bytes); + +impl crate::coding::Decode for ControlMessage { + fn decode(buf: &mut B, version: Version) -> Result { + let id = u64::decode(buf, version)?; + let size = u16::decode(buf, version)? as usize; + if buf.remaining() < size { + return Err(crate::coding::DecodeError::Short); + } + Ok(Self(id, buf.copy_to_bytes(size))) + } +} + enum FillStep { Batch, Partial(frame::Consumer), @@ -235,6 +266,8 @@ enum NamespaceEvent { 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)] @@ -262,6 +295,10 @@ pub(super) struct Publisher { // Our grant (MoQ Auth): only what it lets us publish is advertised and served, and a // shrink withdraws what it no longer covers. auth: crate::auth::Handle, + // The 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, } /// The snapshot a joining FETCH inherits from its subscription. @@ -318,6 +355,7 @@ where joins: Default::default(), version, auth: crate::auth::Handle::new(false), + request_token: crate::RequestToken::default(), } } @@ -327,6 +365,15 @@ where 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. /// @@ -499,16 +546,98 @@ where // Serve only what our grant lets us publish (MoQ Auth), and stop once it no // longer does. Checked before resolving, so a denied request never reaches the - // origin. - let mut gate = crate::auth::Gate::new( - self.auth.clone(), - msg.track_namespace.to_owned(), - crate::auth::Direction::Publish, - ); - if !self + // 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 gate; + 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. + 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; + } + // An alias reference (DELETE/USE_ALIAS) is a connection-level protocol violation + // and closes the session exactly as on the SETUP path; a merely-undecodable + // structure is refused per request without tearing down the connection. + let structure = match crate::ietf::token::decode_value(token, self.version) { + Ok(structure) => structure, + Err(err @ Error::ProtocolViolation) => { + self.session + .clone() + .close(crate::SessionError::ProtocolViolation.to_code(), &err.to_string()); + return Err(err); + } + Err(_) => { + let err = Error::Unauthorized; + return self + .reject_subscribe(stream, request_id, &err, "malformed authorization token") + .await; + } + }; + let verdict = self.auth.verify_request( + bytes::Bytes::from(structure.value), + structure.kind, + msg.track_namespace.to_owned(), + crate::auth::RequestKind::Subscribe, + ); + match verdict.grant().await { + // The token's grant must cover this exact request; it authorizes nothing + // else and never joins the session union. + Ok(grant) if crate::auth::RequestKind::Subscribe.covers(&grant, msg.track_namespace.as_str()) => { + // Held for the subscription's life: the request ends when this grant + // lapses, is revoked, or stops covering it (RequestGrant::poll_ended + // below), or when the limit narrows past it (the gate), never the session. + request_grant = Some(crate::auth::RequestGrant::new( + &self.runtime, + verdict, + grant, + msg.track_namespace.to_owned(), + crate::auth::RequestKind::Subscribe, + )); + gate = Some(crate::auth::Gate::limit( + self.auth.clone(), + msg.track_namespace.to_owned(), + crate::auth::Direction::Publish, + )); + } + Ok(_) => { + let err = Error::Unauthorized; + return self + .reject_subscribe(stream, request_id, &err, "token does not cover this request") + .await; + } + // UNAUTHORIZED for a refusal, NOT_SUPPORTED when no consumer verifies tokens. + Err(err) => { + return self.reject_subscribe(stream, request_id, &err, &err.to_string()).await; + } + } + } else if self .auth .allows(crate::auth::Direction::Publish, msg.track_namespace.as_str()) { + gate = Some(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; } @@ -646,21 +775,265 @@ where }; let mut serve = std::pin::pin!(serve); let mut closed_session = self.session.clone(); - kio::wait(|waiter| { - if let Poll::Ready(served) = waiter.poll_future(serve.as_mut()) { - return Poll::Ready(Some(served)); - } - if gate.poll_denied(waiter).is_ready() { - tracing::info!(broadcast = %absolute, track = %track_name, "subscription no longer authorized"); - return Poll::Ready(Some((Err(Error::Unauthorized), false))); - } - let mut cx = std::task::Context::from_waker(waiter.waker()); - if stream.reader.poll_closed(&mut cx).is_ready() || closed_session.poll_closed(&mut cx).is_ready() { - return Poll::Ready(None); + + // What one turn of the serve loop resolved to. + enum Step { + // The track (and any fill) finished: the subscription's own result. + Served((Result<(), Error>, bool)), + // The union gate or the request grant ended the subscription early. + Ended((Result<(), Error>, bool)), + // The peer finished or reset the stream, or the session ended. + Closed, + // A `[type][size][body]` control message off the subscribe stream. + Message(u64, bytes::Bytes), + // A non-terminal message read while a renewal is pending: held to handle once + // the verdict resolves, so the read keeps watching for a terminal meanwhile. + Buffered(u64, bytes::Bytes), + // A pending renewal's verdict resolved, for the update with this request id. + Renewal(Result, RequestId), + } + + // After SUBSCRIBE_OK the peer may send a REQUEST_UPDATE (SUBSCRIBE_UPDATE, 0x02) + // to refresh the request's token. The loop reads one control message per turn + // while serving; a renewal's verify is raced against serving and the old grant's + // deadline (never a bare await), so media keeps flowing and the old deadline can + // still fire when the acceptor is slow. While a renewal is pending the loop keeps + // reading so a cancellation still ends it, buffering a further renewal to handle + // once the verdict resolves. The read future borrows only `stream.reader` and + // lives in an inner block, so that borrow is released before a renewal answers on + // `stream.writer`. + let mut pending: Option<(crate::auth::RequestVerdict, RequestId)> = None; + // Updates that arrived while a renewal was pending, handled in order once each + // verdict resolves. A FIFO queue, not a single slot: draft-18 section 10.9.1 + // permits coalescing the cumulative deltas but still requires an answer per + // update, so an earlier buffered renewal must not be dropped by a later one. The + // queue is bounded by MAX_REQUEST_UPDATES, counting the one being verified, so a + // peer cannot grow it without limit behind a slow verdict. + let mut stashed: std::collections::VecDeque<(u64, bytes::Bytes)> = std::collections::VecDeque::new(); + loop { + let step = if let Some((verdict, rid)) = pending.as_mut() { + let rid = *rid; + // The stream is still watched while the verdict is pending: a peer that + // ends the request then must end it here too, whatever the acceptor does. + let mut read = std::pin::pin!(read_control(&mut stream.reader)); + kio::wait(|waiter| { + if let Poll::Ready(served) = waiter.poll_future(serve.as_mut()) { + return Poll::Ready(Step::Served(served)); + } + if gate.as_mut().is_some_and(|gate| gate.poll_denied(waiter).is_ready()) { + tracing::info!(broadcast = %absolute, track = %track_name, "subscription no longer authorized"); + return Poll::Ready(Step::Ended((Err(Error::Unauthorized), false))); + } + if let Some(rg) = request_grant.as_mut() + && let Poll::Ready(err) = rg.poll_ended(waiter) + { + tracing::info!(broadcast = %absolute, track = %track_name, %err, "request token grant ended"); + return Poll::Ready(Step::Ended((Err(err), false))); + } + if let Poll::Ready(res) = verdict.poll_grant(waiter) { + return Poll::Ready(Step::Renewal(res, rid)); + } + let mut cx = std::task::Context::from_waker(waiter.waker()); + if closed_session.poll_closed(&mut cx).is_ready() { + return Poll::Ready(Step::Closed); + } + // Keep reading while the verdict is pending so a cancellation still ends + // the request: an acceptor that never answers and a non-expiring grant + // would otherwise hang on it. Draft-14/15/16 cancel with an UNSUBSCRIBE + // message (the adapter delivers it before the FIN), draft-17+ with the + // FIN or a reset. A further renewal is buffered to handle once the + // verdict resolves; breaking the wait starts a fresh read that keeps + // watching for a terminal, so a buffered update never masks one. + match waiter.poll_future(read.as_mut()) { + Poll::Ready(Ok(Some((id, _)))) if id == ietf::Unsubscribe::ID => { + Poll::Ready(Step::Closed) + } + Poll::Ready(Ok(Some((id, data)))) => Poll::Ready(Step::Buffered(id, data)), + Poll::Ready(Ok(None)) | Poll::Ready(Err(_)) => Poll::Ready(Step::Closed), + Poll::Pending => Poll::Pending, + } + }) + .await + } else if let Some((id, data)) = stashed.pop_front() { + Step::Message(id, data) + } else { + let mut read = std::pin::pin!(read_control(&mut stream.reader)); + kio::wait(|waiter| { + if let Poll::Ready(served) = waiter.poll_future(serve.as_mut()) { + return Poll::Ready(Step::Served(served)); + } + if gate.as_mut().is_some_and(|gate| gate.poll_denied(waiter).is_ready()) { + tracing::info!(broadcast = %absolute, track = %track_name, "subscription no longer authorized"); + return Poll::Ready(Step::Ended((Err(Error::Unauthorized), false))); + } + // A request-token subscription ends when its grant lapses or the + // acceptor revokes it; the session is untouched. + if let Some(rg) = request_grant.as_mut() + && let Poll::Ready(err) = rg.poll_ended(waiter) + { + tracing::info!(broadcast = %absolute, track = %track_name, %err, "request token grant ended"); + return Poll::Ready(Step::Ended((Err(err), false))); + } + let mut cx = std::task::Context::from_waker(waiter.waker()); + if closed_session.poll_closed(&mut cx).is_ready() { + return Poll::Ready(Step::Closed); + } + match waiter.poll_future(read.as_mut()) { + Poll::Ready(Ok(Some((id, data)))) => Poll::Ready(Step::Message(id, data)), + // A FIN or a read error is the peer ending the subscribe stream, + // the same end the reader's close signalled before. + Poll::Ready(Ok(None)) | Poll::Ready(Err(_)) => Poll::Ready(Step::Closed), + Poll::Pending => Poll::Pending, + } + }) + .await + }; + + match step { + Step::Served(served) | Step::Ended(served) => break Some(served), + Step::Closed => break None, + // A message arrived while a renewal was pending. Only a REQUEST_UPDATE is + // acted on, and the non-pending path ignores anything else, so drop other + // messages here rather than let a peer grow the queue with ones that will + // never be answered. Each renewal is kept, in order, so none loses its + // verdict or response. The one being verified plus those queued behind it + // are the outstanding REQUEST_UPDATEs; what happens when another would + // exceed the ceiling is version-appropriate. + Step::Buffered(id, data) => { + if id == ietf::SubscribeUpdate::ID { + // Outstanding counting the one being verified: 1 + stashed.len(). + let outstanding = stashed.len() as u64 + 1; + if request_update::supported(self.version) { + // Draft-19+: we advertised MAX_REQUEST_UPDATES, so a peer with + // that many already outstanding sending another broke the + // negotiated limit. Draft-19 section 10.3.1.7 answers that with + // a session close, TOO_MANY_REQUEST_UPDATES. A conforming peer + // self-limits and never reaches here. Closing the session makes + // the request's own PUBLISH_DONE moot, so return straight out as + // the malformed-token path does. + if outstanding >= request_update::MAX_REQUEST_UPDATES { + self.session.clone().close( + crate::SessionError::TooManyRequestUpdates.to_code(), + "too many request updates", + ); + return Err(Error::Session(crate::SessionError::TooManyRequestUpdates)); + } + } else if stashed.len() >= request_update::UNNEGOTIATED_GUARD { + // Drafts below 19 negotiate no limit, so a peer agreed to no + // ceiling: this is a local memory guard, not a protocol fault. + // End this request, never the session. + break Some((Err(Error::ProtocolViolation), false)); + } + stashed.push_back((id, data)); + } + } + Step::Renewal(res, rid) => { + // The pending verdict resolved: consume it and answer the update. + let (verdict, _) = pending.take().expect("a pending renewal"); + let Some(rg) = request_grant.as_mut() else { + continue; + }; + // Draft-15 section 9.11 and draft-16 section 9.11 require one + // REQUEST_OK or REQUEST_ERROR per update, keyed to the update's own + // Request ID so the peer matches it to the REQUEST_UPDATE it sent. + // Draft-14 section 9.10 defines no such response, and its control + // stream would read a 0x05 as ending the subscription and a 0x07 as + // PUBLISH_NAMESPACE_OK, so the renewal is decided silently there. + // Draft-17+ answers on the real subscribe stream with the id omitted. + let answer_id = match self.version { + Version::Draft15 | Version::Draft16 => Some(rid), + _ => None, + }; + let answer = !matches!(self.version, Version::Draft14); + match res { + // The renewal's grant must still cover this request. On accept the + // old grant is dropped (ending the old token) and the deadline is + // re-armed at the new expiry. + Ok(grant) if rg.covers(&grant) => { + rg.renew(verdict, grant); + if answer { + self.write_request_ok(&mut stream.writer, answer_id).await?; + } + } + // A refused or uncovered renewal keeps the old grant (per the quest, + // the request ends only when that lapses) and answers UNAUTHORIZED so + // the peer can retry before then. + _ if !answer => {} + _ => { + self.write_subscribe_error( + &mut stream.writer, + rid, + &Error::Unauthorized, + "renewal not granted", + ) + .await?; + } + } + } + Step::Message(id, mut data) => { + // Only REQUEST_UPDATE carries a renewal; any other message is ignored, + // as it was when the loop only watched for the stream closing. + if id != ietf::SubscribeUpdate::ID { + continue; + } + let update = match ietf::SubscribeUpdate::decode_msg(&mut data, self.version) { + Ok(update) => update, + // A malformed REQUEST_UPDATE body ends this subscription, never the + // session: one bad request does not tear down the connection. + Err(err) => break Some((Err(err.into()), false)), + }; + // A token-less REQUEST_UPDATE is an ordinary priority/forward change, + // which this publisher does not act on; the grant is untouched. It still + // owes one answer so the peer's MAX_REQUEST_UPDATES credit is restored. + let Some(token) = &update.authorization_token else { + self.answer_request_update_ok(&mut stream.writer, update.request_id) + .await?; + continue; + }; + // Only a token-authorized subscription holds a request grant to renew; + // a union-authorized one is already covered by the session grant. The + // update is accepted all the same, so it is acknowledged and its credit + // restored. + if request_grant.is_none() { + self.answer_request_update_ok(&mut stream.writer, update.request_id) + .await?; + continue; + } + // An alias reference (DELETE/USE_ALIAS) is a connection-level protocol + // violation and closes the session, as on the SETUP path; a + // merely-undecodable structure is refused per request, leaving the old + // grant to stand until it lapses. + let structure = match crate::ietf::token::decode_value(token, self.version) { + Ok(structure) => structure, + Err(err @ Error::ProtocolViolation) => { + self.session + .clone() + .close(crate::SessionError::ProtocolViolation.to_code(), &err.to_string()); + return Err(err); + } + Err(_) => { + self.write_subscribe_error( + &mut stream.writer, + update.request_id, + &Error::Unauthorized, + "malformed authorization token", + ) + .await?; + continue; + } + }; + // Verify the fresh token, but do not block the serve loop on it: the + // verdict is raced against serving and the old grant's deadline above. + let verdict = self.auth.verify_request( + bytes::Bytes::from(structure.value), + structure.kind, + msg.track_namespace.to_owned(), + crate::auth::RequestKind::Subscribe, + ); + pending = Some((verdict, update.request_id)); + } } - Poll::Pending - }) - .await + } }; let completed = served.is_some(); @@ -700,7 +1073,9 @@ where // Send PublishDone let (status, reason) = match &res { Ok(()) => (ietf::PublishDoneStatus::TrackEnded, "track ended"), - Err(Error::Unauthorized) => (ietf::PublishDoneStatus::Unauthorized, "not granted"), + Err(Error::Unauthorized) | Err(Error::Session(crate::SessionError::Unauthorized)) => { + (ietf::PublishDoneStatus::Unauthorized, "not granted") + } Err(_) => (ietf::PublishDoneStatus::InternalError, "internal error"), }; let _ = stream.writer.encode(&ietf::PublishDone::ID).await; @@ -792,6 +1167,44 @@ where Ok(()) } + /// Acknowledge an accepted REQUEST_UPDATE. + /// + /// Draft-15/16 carry the update's own Request ID so the peer matches the response to + /// the REQUEST_UPDATE it sent; draft-17+ answers on the real subscribe stream with the + /// id omitted. Draft-14 sends no response and never reaches here. + async fn write_request_ok( + &self, + writer: &mut Writer, + request_id: Option, + ) -> Result<(), Error> { + debug_assert_eq!( + request_id.is_some(), + matches!(self.version, Version::Draft15 | Version::Draft16), + "draft-15/16 carry the update request id; draft-17+ omit it", + ); + writer.encode(&ietf::RequestOk::ID).await?; + writer.encode(&ietf::RequestOk { request_id }).await?; + Ok(()) + } + + /// Acknowledge a REQUEST_UPDATE that this side accepts without a pending verify, keyed as the + /// version requires: draft-14 defines no response (silent), draft-15/16 key it to the update's + /// own request id, draft-17+ answer on the stream with the id omitted. Every REQUEST_UPDATE + /// owes exactly one REQUEST_OK or REQUEST_ERROR (moq-transport section 10.9), and that answer + /// restores one of the peer's MAX_REQUEST_UPDATES credits; without it an advertised limit would + /// strand a peer that only ever sends priority or forward updates. + async fn answer_request_update_ok( + &self, + writer: &mut Writer, + request_id: RequestId, + ) -> Result<(), Error> { + if matches!(self.version, Version::Draft14) { + return Ok(()); + } + let answer_id = matches!(self.version, Version::Draft15 | Version::Draft16).then_some(request_id); + self.write_request_ok(writer, answer_id).await + } + /// Serve a draft-20 fill on its own fetch stream: the requested range, read from the /// group cache, capped at the Largest Object snapshot. /// @@ -1393,7 +1806,16 @@ where suffix: &crate::PathOwned, path: &crate::PathOwned, ) -> Result<(), Error> { - let permitted = ns.permitted(path); + // 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, @@ -1535,6 +1957,7 @@ where request_id, track_namespace: path.as_path(), cluster, + authorization_token: self.request_token.peek(), }) .await?; @@ -1751,6 +2174,66 @@ 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 keeps the announce, since the receiver + /// holds the old grant until it lapses; only a dead stream drops it. + async fn refresh_request_token( + &self, + requests: &mut HashMap>, + 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 = requests.keys().cloned().collect(); + for suffix in suffixes { + let request_id = self.control.next_request_id(&self.runtime).await?; + let Some(request) = requests.get_mut(&suffix) else { + continue; + }; + let update = ietf::PublishNamespaceUpdate { + request_id, + hops: None, + cost: None, + authorization_token: Some(token.clone()), + }; + request.stream.writer.encode(&ietf::PublishNamespaceUpdate::ID).await?; + request.stream.writer.encode(&update).await?; + + let absolute = self.origin.absolute(&request.path).to_owned(); + let Some((type_id, mut data)) = 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. + requests.remove(&suffix); + continue; + }; + 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)?; + // The receiver refuses a renewal it cannot grant but keeps the announce on the + // old grant until it lapses, so the request stays; the announce ends later + // when that grant does and the stream closes. + tracing::warn!(broadcast = %absolute, message = ?msg, "request token refresh refused"); + } + _ => 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( @@ -1912,6 +2395,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 { @@ -1941,6 +2428,11 @@ where if let Poll::Ready(update) = announced.poll_next(waiter) { return Poll::Ready(NamespaceEvent::Update(update)); } + // 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); } @@ -1978,6 +2470,10 @@ where self.sync_namespace(&mut ns, &suffix, &path).await?; } } + NamespaceEvent::TokenRefresh(token) => { + last_token = token.clone(); + self.refresh_request_token(&mut ns.requests, token).await?; + } NamespaceEvent::Update(None) => { // The origin is gone: withdraw everything, then finish the // stream and wait for delivery. @@ -2859,79 +3355,1452 @@ mod serve_tests { } } - /// A distinctive request id, so `[FetchHeader::TYPE, REQUEST_ID]` is a usable needle. - const REQUEST_ID: u64 = 0x2B; + /// 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])) + } - fn subscribe(filter: Filter, fill: Option) -> ietf::Subscribe<'static> { - ietf::Subscribe { - request_id: RequestId(REQUEST_ID), - track_namespace: crate::Path::new("room"), - track_name: "video".into(), - subscriber_priority: 128, - group_order: GroupOrder::Descending, - filter, - fill, - properties_wanted: true, + /// [`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::tokio(), + session.clone(), + origin.consume(), + Control::new(None, false), + None, + peer_setup, + version, + ) + .with_auth(auth); + + Serve { + publisher, + session, + log, + track, + _origin: origin, + _broadcast: broadcast, } } - /// The bytes that begin every fill fetch stream. - const FETCH_STREAM: &[u8] = &[FetchHeader::TYPE as u8, REQUEST_ID as u8]; + /// 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| { + crate::runtime::Timers::now(runtime) + .checked_add(Duration::from_secs(s)) + .unwrap() + }), + } + } - /// Serve `msg` against the live track, then finish the track so the subscription - /// completes. Subscribing after the finish would be rejected instead of served. - async fn run_live(h: &mut Serve, msg: ietf::Subscribe<'static>) { - // `create_broadcast` registers the broadcast from a spawned task, so yield to the - // runtime before subscribing or the lookup 404s. - tokio::time::sleep(std::time::Duration::from_millis(1)).await; + /// 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) + } - let mut session = h.session.clone(); - let stream = Stream::open(&mut session, h.publisher.version).await.unwrap(); - let mut serve = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, msg)); + /// A token-bearing client does not self-censor on its connection grant (B1a, quest Goal): 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. + #[tokio::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) + } - // Everything cached serves immediately; the subscription then parks at the live - // edge, which is where the track is allowed to finish. + // 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 _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::tokio(), + session, + origin.consume(), + Control::new(None, false), + None, + peer_setup, + VERSION, + ) + .with_auth(auth) + .with_request_token(crate::RequestToken::new(Some(token.clone()))); + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + let mut advertised = false; for _ in 0..200 { - assert!( - futures::poll!(serve.as_mut()).is_pending(), - "subscription ended before the track finished" - ); + 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" + ); - h.track.finish().unwrap(); - serve.await.unwrap(); + // 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::tokio(), + 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 subscribe for a broadcast we do not serve is refused with the negotiated draft's own - /// "does not exist" value. - /// - /// Draft-14 numbers it 0x4 and draft-15 moved it to 0x10, which is draft-14's - /// MALFORMED_AUTH_TOKEN: a peer told the wrong one re-authenticates instead of waiting - /// for the announcement. The reply is encoded here rather than matched by code alone, so - /// a value slipping outside the draft's table cannot pass, and the reason phrase is the - /// origin's own, which is what carries a refusal the registry has no value for. + /// 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. #[tokio::test] - async fn a_missing_broadcast_is_refused_with_the_draft_s_code() { - for version in [ - Version::Draft14, - Version::Draft15, - Version::Draft16, - Version::Draft17, - Version::Draft18, - Version::Draft19, - Version::Draft20, - ] { - let error_code = match version { - Version::Draft14 => 0x4, - _ => 0x10, - }; - - let h = serve(version); - let mut session = h.session.clone(); - let stream = Stream::open(&mut session, version).await.unwrap(); + 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']); - let mut msg = subscribe(Filter::NextObject, None); - msg.track_namespace = crate::Path::new("absent"); + 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::tokio(), + session, + origin.consume(), + Control::new(None, false), + None, + peer_setup, + VERSION, + ) + .with_auth(auth) + .with_request_token(crate::RequestToken::new(Some(token.clone()))); + 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"); + } + + /// 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. + #[tokio::test] + 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 _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::tokio(), + session.clone(), + origin.consume(), + Control::new(None, false), + None, + peer_setup, + VERSION, + ) + .with_auth(auth) + .with_request_token(crate::RequestToken::new(Some(token.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)); + 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"); + } + + /// A Token structure value that decodes via `token::decode_value` (USE_VALUE, kind 300). + fn request_token() -> bytes::Bytes { + bytes::Bytes::from_static(&[0x03, 0x81, 0x2c, 0x00, 0xff]) + } + + /// 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, + }; + 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()), + }; + writer.encode(&ietf::SubscribeUpdate::ID).await.unwrap(); + writer.encode(&msg).await.unwrap(); + log.writes.lock().unwrap().clone() + } + + /// The wire bytes of one REQUEST_OK keyed to `request_id`, the draft-15/16 accept answer. + 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.encode(&ietf::RequestOk::ID).await.unwrap(); + writer + .encode(&ietf::RequestOk { + request_id: Some(request_id), + }) + .await + .unwrap(); + log.writes.lock().unwrap().clone() + } + + /// The wire bytes of one REQUEST_ERROR keyed to `request_id`, the draft-15/16 refuse answer, + /// matching [`Publisher::write_subscribe_error`]'s code and reason for a refused renewal. + async fn request_error_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); + let error_code = request::to_code(&Error::Unauthorized, request::Kind::Subscribe, version); + writer.encode(&ietf::RequestError::ID).await.unwrap(); + writer + .encode(&ietf::RequestError { + request_id: Some(request_id), + error_code, + reason_phrase: "renewal not granted".into(), + retry_interval: 0, + }) + .await + .unwrap(); + log.writes.lock().unwrap().clone() + } + + /// 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. + #[tokio::test(start_paused = true)] + async fn a_request_update_renews_a_token_subscription_past_the_old_expiry() { + 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 { + 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())); + + // 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!( + answered.load(Ordering::Relaxed), + 2, + "acceptor never answered both tokens" + ); + + // 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; + } + + // Past the original 60s expiry: the renewal re-armed the deadline, so the + // subscription lives on. + tokio::time::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; + } + } + + /// 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`. + #[tokio::test(start_paused = true)] + async fn a_subscribe_update_renewal_is_answered_from_draft_15() { + for version in [Version::Draft14, Version::Draft15, Version::Draft16] { + renews(version, true).await; + } + } + + /// A refused renewal before draft-17 answers exactly one REQUEST_ERROR on draft-15/16 (none + /// on draft-14) and leaves the old grant to lapse the subscription, never the session. + #[tokio::test(start_paused = true)] + async fn a_refused_subscribe_update_renewal_is_answered_from_draft_15() { + for version in [Version::Draft14, Version::Draft15, Version::Draft16] { + renews(version, false).await; + } + } + + 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(); + + 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(); + + // 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(tokio::sync::Notify::new()); + 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 acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + + 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!( + answered.load(Ordering::Relaxed), + 1, + "{version:?}: the first token was answered" + ); + let before = h.log.writes.lock().unwrap().len(); + + release.notify_one(); + for _ in 0..200 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(serving.as_mut()).is_pending()); + 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 answer to the update's + // own Request ID (0x40), the id the renewing peer waits on: REQUEST_OK on accept, + // REQUEST_ERROR on refuse. + let written = h.log.writes.lock().unwrap()[before..].to_vec(); + let expected = match accept { + true => request_ok_bytes(version, RequestId(0x40)).await, + false => request_error_bytes(version, RequestId(0x40)).await, + }; + match version { + Version::Draft14 => assert!(written.is_empty(), "{version:?}: a renewal writes no answer"), + _ => assert_eq!( + written, expected, + "{version:?}: one keyed answer to the update's request id" + ), + } + + // An accepted renewal re-armed the deadline, so the subscription lives past the old 60s + // expiry; a refused one leaves the old grant to lapse it there, never the session. + tokio::time::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_eq!( + ended, !accept, + "{version:?}: accepted renewal keeps the subscription; a refused one lets the old grant lapse it" + ); + } + + /// 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.encode(&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.encode(&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. + #[tokio::test(start_paused = true)] + 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(tokio::sync::Notify::new()); + 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!( + answered.load(std::sync::atomic::Ordering::Relaxed), + 4, + "the initial token and all three renewals must each be verified; the single slot dropped 0x41" + ); + } + + /// 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. + #[tokio::test(start_paused = true)] + 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!(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() + ); + } + + /// 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. + #[tokio::test(start_paused = true)] + async fn renewals_at_the_advertised_limit_keep_the_request() { + const VERSION: Version = Version::Draft19; + + 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(); + + 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..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!( + h.log.closes().is_empty(), + "no session close at the limit: {:?}", + h.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 request nor close the + /// session on draft-18, because a conforming peer must not be stranded for a limit it never saw. + #[tokio::test(start_paused = true)] + async fn older_drafts_do_not_strand_a_renewal_flood() { + const VERSION: Version = Version::Draft18; + + 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 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..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"); + } + + /// 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. + #[tokio::test(start_paused = true)] + async fn token_less_updates_are_each_acknowledged() { + const VERSION: Version = Version::Draft19; + + 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(); + + 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..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; + } + + // 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.encode(&ietf::RequestOk::ID).await.unwrap(); + writer.encode(&ietf::RequestOk { request_id: None }).await.unwrap(); + log.writes.lock().unwrap().clone() + }; + let written = h.log.writes.lock().unwrap().clone(); + assert!( + written.ends_with(&one_ok.repeat(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" + ); + } + + /// 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. + #[tokio::test(start_paused = true)] + 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 mut expected = Vec::new(); + if !matches!(version, Version::Draft14) { + for rid in rids { + expected.extend(request_ok_bytes(version, RequestId(rid)).await); + } + } + let written = h.log.writes.lock().unwrap().clone(); + assert!( + written.ends_with(&expected), + "{version}: each token-less update gets its keyed REQUEST_OK" + ); + assert!( + h.log.closes().is_empty(), + "{version}: token-less updates 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 request through PUBLISH_DONE, never the session. + #[tokio::test(start_paused = true)] + async fn an_older_draft_flood_past_the_guard_ends_the_request() { + const VERSION: Version = Version::Draft18; + + 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(); + + 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 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. + #[tokio::test(start_paused = true)] + async fn a_closed_subscription_ends_while_a_renewal_is_pending() { + const VERSION: Version = Version::Draft18; + + 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 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" + ); + } + + /// 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.encode(&ietf::Unsubscribe::ID).await.unwrap(); + writer + .encode(&ietf::Unsubscribe { + request_id: RequestId(REQUEST_ID), + }) + .await + .unwrap(); + log.writes.lock().unwrap().clone() + } + + /// 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. + #[tokio::test(start_paused = true)] + 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; + + 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; + } + assert!( + ended, + "{version:?}: an UNSUBSCRIBE ends the subscription despite the pending renewal" + ); + } + } + + /// A REQUEST_UPDATE the acceptor refuses does NOT extend the grant: the old grant stands + /// and the subscription ends only when it lapses (the quest's rule), never the session. + #[tokio::test(start_paused = true)] + async fn a_refused_request_update_lets_the_old_grant_lapse() { + 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 lapses in 60s; the renewal is refused, so it stands. + 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())); + + 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!( + answered.load(Ordering::Relaxed), + 2, + "acceptor never answered both tokens" + ); + + // Let the serve loop apply the refusal (which keeps the old grant). + for _ in 0..20 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(serving.as_mut()).is_pending()); + settle().await; + } + + // The old grant lapses at 60s and ends the subscription, since the refusal did not + // renew it. + tokio::time::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, "a refused renewal must let the old grant lapse the subscription"); + } + + /// An alias reference (DELETE/USE_ALIAS) on a request token is a connection-level protocol + /// violation, closing the session exactly as on the SETUP path, not a per-request refusal. + #[tokio::test] + async fn an_alias_token_on_a_request_is_a_protocol_violation() { + const VERSION: Version = Version::Draft18; + let (auth, _requests, _cred) = auth_covering_other(); + let h = serve_with_auth(VERSION, auth, Vec::new()); + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + + let mut msg = subscribe(Filter::NextObject, None); + // USE_ALIAS (0x02): nothing can be registered before SETUP, so an alias reference is a + // PROTOCOL_VIOLATION rather than a token we could verify. + msg.authorization_token = Some(bytes::Bytes::from_static(&[0x02, 0x07])); + + let err = h.publisher.clone().run_subscribe_stream(stream, msg).await.unwrap_err(); + assert!(matches!(err, Error::ProtocolViolation), "{err:?}"); + assert_eq!( + h.log.closes().first().map(|c| c.0), + Some(crate::SessionError::ProtocolViolation.to_code()), + "the session must close with PROTOCOL_VIOLATION" + ); + } + + /// 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. + #[tokio::test(start_paused = true)] + 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. + tokio::time::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. + #[tokio::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())); + } + } + }; + 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" + ); + } + + /// 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())); + } + std::future::pending::>().await + }; + tokio::select! { + res = acceptor => res, + res = serving => 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. + #[tokio::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. + #[tokio::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" + ); + } + + /// A distinctive request id, so `[FetchHeader::TYPE, REQUEST_ID]` is a usable needle. + const REQUEST_ID: u64 = 0x2B; + + fn subscribe(filter: Filter, fill: Option) -> ietf::Subscribe<'static> { + ietf::Subscribe { + request_id: RequestId(REQUEST_ID), + track_namespace: crate::Path::new("room"), + track_name: "video".into(), + subscriber_priority: 128, + group_order: GroupOrder::Descending, + filter, + fill, + properties_wanted: true, + authorization_token: None, + } + } + + /// The bytes that begin every fill fetch stream. + const FETCH_STREAM: &[u8] = &[FetchHeader::TYPE as u8, REQUEST_ID as u8]; + + /// Serve `msg` against the live track, then finish the track so the subscription + /// completes. Subscribing after the finish would be rejected instead of served. + async fn run_live(h: &mut Serve, msg: ietf::Subscribe<'static>) { + // `create_broadcast` registers the broadcast from a spawned task, so yield to the + // runtime before subscribing or the lookup 404s. + tokio::time::sleep(std::time::Duration::from_millis(1)).await; + + let mut session = h.session.clone(); + let stream = Stream::open(&mut session, h.publisher.version).await.unwrap(); + let mut serve = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, msg)); + + // Everything cached serves immediately; the subscription then parks at the live + // edge, which is where the track is allowed to finish. + for _ in 0..200 { + assert!( + futures::poll!(serve.as_mut()).is_pending(), + "subscription ended before the track finished" + ); + } + + h.track.finish().unwrap(); + serve.await.unwrap(); + } + + /// A subscribe for a broadcast we do not serve is refused with the negotiated draft's own + /// "does not exist" value. + /// + /// Draft-14 numbers it 0x4 and draft-15 moved it to 0x10, which is draft-14's + /// MALFORMED_AUTH_TOKEN: a peer told the wrong one re-authenticates instead of waiting + /// for the announcement. The reply is encoded here rather than matched by code alone, so + /// a value slipping outside the draft's table cannot pass, and the reason phrase is the + /// origin's own, which is what carries a refusal the registry has no value for. + #[tokio::test] + async fn a_missing_broadcast_is_refused_with_the_draft_s_code() { + for version in [ + Version::Draft14, + Version::Draft15, + Version::Draft16, + Version::Draft17, + Version::Draft18, + Version::Draft19, + Version::Draft20, + ] { + let error_code = match version { + Version::Draft14 => 0x4, + _ => 0x10, + }; + + let h = serve(version); + let mut session = h.session.clone(); + let stream = Stream::open(&mut session, version).await.unwrap(); + + let mut msg = subscribe(Filter::NextObject, None); + msg.track_namespace = crate::Path::new("absent"); h.publisher.clone().run_subscribe_stream(stream, msg).await.unwrap(); @@ -4270,6 +6139,172 @@ mod tests { assert_eq!(log.bi_opens(), 1, "one request stream"); } + /// 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. + #[tokio::test] + async fn a_configured_request_token_rides_the_publish_namespace() { + let token = bytes::Bytes::from_static(&[0x03, 0x81, 0x2c, 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; + + 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::tokio(), + session, + origin.consume(), + Control::new(None, false), + None, + peer_setup, + version, + ) + .with_request_token(crate::RequestToken::new(Some(token.clone()))); + + 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; + } + assert!(sent, "{version}: the request token must ride the PUBLISH_NAMESPACE"); + } + } + + /// 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. + #[tokio::test] + 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 _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); + settle().await; + + // 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(first.clone())); + let publisher = Publisher::new( + crate::time::Clock::tokio(), + session, + origin.consume(), + Control::new(None, false), + None, + 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, &first) >= 1 { + initial = true; + break; + } + settle().await; + } + assert!(initial, "the initial token must ride the PUBLISH_NAMESPACE"); + + // Replacing it re-presents the new token on the live announce. + token.set(Some(second.clone())); + let mut renewed = false; + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, &second) >= 1 { + renewed = true; + break; + } + settle().await; + } + assert!(renewed, "a replaced token must be re-presented on the live announce"); + } + + /// 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. + #[tokio::test] + 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("cam", crate::origin::Route::default()).unwrap(); + settle().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(token_bytes.clone())); + let publisher = Publisher::new( + crate::time::Clock::tokio(), + session, + origin.consume(), + Control::new(None, false), + 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, &token_bytes) >= 1 { + break; + } + settle().await; + } + assert_eq!( + occurrences(&log, &token_bytes), + 1, + "the initial token rode the announce once" + ); + + // Setting the same value again wakes the loop but changes nothing, so no REQUEST_UPDATE. + token.set(Some(token_bytes.clone())); + for _ in 0..50 { + assert!(futures::poll!(run.as_mut()).is_pending()); + settle().await; + } + assert_eq!( + occurrences(&log, &token_bytes), + 1, + "an unchanged token must not be re-presented" + ); + } + /// 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 @@ -4649,6 +6684,7 @@ mod tests { solicit, hidden: false, auth: false, + max_request_updates: None, }); slot } @@ -4714,6 +6750,7 @@ mod tests { request_id: RequestId(3), hops: None, cost: Some(0), + authorization_token: None, }, ) .await; @@ -4730,6 +6767,69 @@ mod tests { assert_eq!(log.bi_opens(), 1, "the update rode the request's own stream"); } + /// 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. + #[tokio::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']); + + 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; + + 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::tokio(), + session, + origin.consume(), + Control::new(None, false), + None, + clustered(Some(false)), + VERSION, + ) + .with_request_token(crate::RequestToken::new(Some(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, &token) >= 1 { + break; + } + settle().await; + } + assert_eq!(occurrences(&log, &token), 1, "the token rides the PUBLISH_NAMESPACE"); + + let _warm = origin + .announce("cam", crate::origin::Route::default().with_cost(0)) + .unwrap(); + 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, "the reprice is token-free"); + assert_eq!(occurrences(&log, &token), 1, "the token was not re-sent on the reprice"); + } + /// 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. @@ -4788,6 +6888,7 @@ mod tests { crate::Hops::try_from(vec![crate::Hop::new(8).unwrap(), crate::Hop::new(1).unwrap()]).unwrap(), )), cost: Some(0), + authorization_token: None, }, ) .await; @@ -4866,6 +6967,7 @@ mod tests { request_id: RequestId(3), hops: None, cost: Some(0), + authorization_token: None, }, ) .await; @@ -5094,6 +7196,7 @@ mod tests { filter: Filter::NextObject, fill: None, properties_wanted: true, + authorization_token: None, }, ) .await @@ -5447,6 +7550,7 @@ mod range_tests { filter, fill: None, properties_wanted: true, + authorization_token: None, } } 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..fc8d59de0f --- /dev/null +++ b/rs/moq-net/src/ietf/request_update.rs @@ -0,0 +1,140 @@ +//! 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 super::Version; + +/// 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 + ) +} + +/// 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); + } +} + +/// The MAX_REQUEST_UPDATES the peer advertised, if it set a limit. `None` on a draft +/// without the option, on a peer that sent none, and on an explicit `0`: draft-19 section +/// 10.3.1.7 reads both absent and `0` as no limit. Recorded for a future sender that paces +/// to the peer's credit; today the sender keeps one renewal in flight per request, which +/// honors any limit without reading it. +pub fn from_setup(params: &super::Parameters, version: Version) -> Option { + match supported(version) { + // Draft-19 section 10.3.1.7: an absent option and a `0` value both mean no limit. + true => params + .get_varint(super::ParameterVarInt::MaxRequestUpdates) + .filter(|&n| n != 0), + false => None, + } +} + +#[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" + ); + } + } + + /// What we advertise is what a peer reading our SETUP records, on the drafts that carry + /// the option; an older draft or an absent option records no limit. + #[test] + fn records_the_advertised_limit() { + let mut params = super::super::Parameters::default(); + into_setup(&mut params, Version::Draft19); + assert_eq!( + from_setup(¶ms, Version::Draft19), + Some(MAX_REQUEST_UPDATES), + "a draft-19 peer must record the advertised credit" + ); + + // A draft-19 SETUP carrying no option reads as no limit, not as a default. + assert_eq!( + from_setup(&super::super::Parameters::default(), Version::Draft19), + None, + "an absent option is no limit" + ); + + // An explicit 0 is also no limit (draft-19 section 10.3.1.7), not a zero credit. + let mut zero = super::super::Parameters::default(); + zero.set_varint(super::super::ParameterVarInt::MaxRequestUpdates, 0); + assert_eq!( + from_setup(&zero, Version::Draft19), + None, + "0 means no limit, not zero credit" + ); + + // The option does not exist below draft-19, so even a stray value is ignored. + let mut legacy = super::super::Parameters::default(); + legacy.set_varint(super::super::ParameterVarInt::MaxRequestUpdates, MAX_REQUEST_UPDATES); + assert_eq!( + from_setup(&legacy, Version::Draft18), + None, + "draft-18 defines no MAX_REQUEST_UPDATES option to read" + ); + } +} diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index e30f547ef0..8d0d94e31d 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -9,7 +9,7 @@ use crate::{ use super::{ Control, Message, Publisher, Subscriber, Version, adapter::ControlStreamAdapter, auth, cluster, hidden, peer, - solicit, subscriber::is_protocol_violation, + request_update, solicit, subscriber::is_protocol_violation, }; /// Everything one moq-transport session needs to start. @@ -70,6 +70,12 @@ pub struct Config { /// peer's token requests during its handshake. Supports AUTH exactly when the /// version can negotiate it; the peer's SETUP decides whether it does. pub auth: crate::auth::Handle, + + /// The extensions we offer in our SETUP (draft-17+). Without MoQ Solicit a peer that + /// speaks it sends an unsolicited PUBLISH_NAMESPACE (the base moq-transport behavior) + /// instead of answering our SUBSCRIBE_NAMESPACE inline. MoQ Auth is offered only when + /// [`Self::auth`] also supports it. + pub extensions: crate::setup::Extensions, } pub fn start(config: Config) -> Result<(MaybeSendBox<'static, Result<(), Error>>, crate::goaway::Handle), Error> @@ -92,7 +98,10 @@ where peer_setup_stream, peer_declared, auth, + extensions, } = config; + let request_token = auth.request_token(); + let solicit = extensions.solicit; // GOAWAY wiring: the public Session holds one half (drain trigger, received // signal), the protocol tasks below hold the other. @@ -168,7 +177,8 @@ where peer_setup.clone(), version, ) - .with_auth(auth.clone()); + .with_auth(auth.clone()) + .with_request_token(request_token.clone()); let (tasks, mut task_set) = TaskSet::new(); let subscriber = Subscriber::new( runtime.clone(), @@ -183,7 +193,9 @@ where tasks.clone(), goaway.going_away.clone(), ) - .with_auth(auth.clone()); + .with_auth(auth.clone()) + .with_request_token(request_token.clone()) + .with_solicit(solicit); // GOAWAY send task: draft-14-16 carry GOAWAY on the shared control // stream. Parked on the drain trigger; races the transport close so @@ -309,9 +321,23 @@ where let runtime = runtime.clone(); let session = session.clone(); let goaway = goaway.clone(); + let extensions = crate::setup::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"); } @@ -327,9 +353,11 @@ where 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).await, + true => enforce_grant(auth, origin, session, request_token, peer_setup, version).await, false => std::future::pending().await, } } @@ -343,7 +371,8 @@ where peer_setup.clone(), version, ) - .with_auth(auth.clone()); + .with_auth(auth.clone()) + .with_request_token(request_token.clone()); let (tasks, mut task_set) = TaskSet::new(); let subscriber = Subscriber::new( runtime.clone(), @@ -358,7 +387,9 @@ where tasks, goaway.going_away.clone(), ) - .with_auth(auth.clone()); + .with_auth(auth.clone()) + .with_request_token(request_token.clone()) + .with_solicit(solicit); // Our tokens, one Auth request each, once the peer's SETUP negotiates it. let present = auth::run_present( @@ -604,6 +635,7 @@ fn peer_from_params(params: &ietf::Parameters, version: Version) -> Result Result( runtime: crate::time::Clock, @@ -623,6 +655,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); @@ -639,9 +672,14 @@ 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); - auth::into_setup(&mut parameters, version); + if extensions.auth { + auth::into_setup(&mut parameters, version); + } + request_update::into_setup(&mut parameters, version); let parameters = parameters.encode_bytes(version)?; writer.encode(&setup::Setup { parameters }).await?; @@ -836,6 +874,13 @@ where } } +/// Whether to serve an inbound AUTH stream: only when the peer advertised MoQ Auth AND this +/// endpoint offered it (its handle is supported). The Auth draft's Setup Negotiation requires +/// both offers; without this endpoint's offer, an inbound AUTH stream is a protocol violation. +fn serve_inbound_auth(peer_offered: bool, local_supported: bool) -> bool { + peer_offered && local_supported +} + /// Accept incoming bidi streams and dispatch to the correct handler based on message type. async fn run_dispatch( session: S, @@ -855,12 +900,12 @@ where // costs a handshake round rather than blocking. let peer = subscriber.peer().await; - // An AUTH from a peer that did not negotiate MoQ Auth is an unknown request, which - // falls through to the protocol violation below. - let serve = match peer_setup.get().await.auth { - true => serve, - false => None, - }; + // Serve inbound AUTH only when both endpoints negotiated it. The handle carries + // `peer.auth && local.auth` from the handshake, so a peer that advertised AUTH this + // endpoint never offered leaves no serve task and its AUTH stream falls through to the + // protocol violation below, as the Auth draft's Setup Negotiation requires both offers. + let peer_auth = peer_setup.get().await.auth; + let serve = serve.filter(|serve| serve_inbound_auth(peer_auth, serve.handle.supported())); // From the same slot, so this costs nothing extra: it decides whether an unsolicited // advertisement is the peer ignoring our own SETUP (MoQ Solicit). @@ -1039,7 +1084,18 @@ 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 { @@ -1054,11 +1110,36 @@ async fn enforce_grant( 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::*; use crate::model::ProduceTest; + // An inbound AUTH is served only when both sides offered it. Before the fix the dispatch + // kept the serve task on the peer's offer alone, so a server that declined MoQ Auth still + // served a client that offered it; now a declined local offer drops the serve task and the + // AUTH stream becomes an UnexpectedStream protocol violation. + #[test] + fn inbound_auth_requires_both_offers() { + assert!(serve_inbound_auth(true, true), "both offered: serve it"); + assert!( + !serve_inbound_auth(true, false), + "peer offered, this endpoint declined: an inbound AUTH is a protocol violation" + ); + assert!(!serve_inbound_auth(false, true), "peer did not offer: nothing to serve"); + assert!(!serve_inbound_auth(false, false), "neither offered"); + } + 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() @@ -1128,6 +1209,7 @@ mod tests { ..Default::default() }), auth: crate::auth::Handle::new(false), + extensions: Default::default(), }) .expect("start the session"); @@ -1181,6 +1263,7 @@ mod tests { // The requests wait on the peer's SETUP (MoQ Hidden). peer_declared: Some(peer::Peer::default()), auth: crate::auth::Handle::new(false), + extensions: Default::default(), }) .expect("start the session"); let _driver = tokio::spawn(driver); @@ -1203,6 +1286,85 @@ 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. + #[tokio::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(bytes::Bytes::from_static(b"jwt"))), + 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. + #[tokio::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(bytes::Bytes::from_static(b"jwt"))), + 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. /// @@ -1233,6 +1395,7 @@ mod tests { peer_setup_stream: None, peer_declared, auth: crate::auth::Handle::new(false), + extensions: Default::default(), }) .expect("start the session"); let _driver = tokio::spawn(driver); @@ -1334,6 +1497,7 @@ mod tests { ..Default::default() }), auth: handle.clone(), + extensions: Default::default(), }) .expect("start the session"); AuthSession { @@ -1447,6 +1611,7 @@ mod tests { // carry and the dispatch loop actually runs. peer_declared: Some(peer::Peer::default()), auth: crate::auth::Handle::new(false), + extensions: Default::default(), }) .expect("start the session"); @@ -1486,6 +1651,7 @@ mod tests { peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), auth: crate::auth::Handle::new(false), + extensions: Default::default(), }) .expect("start the session"); @@ -1612,6 +1778,7 @@ mod tests { request_id: RequestId(1), track_namespace: crate::Path::new("room/host"), cluster: None, + authorization_token: None, }) .await .unwrap(); @@ -1683,6 +1850,7 @@ mod tests { peer_setup_stream: None, peer_declared: None, auth: crate::auth::Handle::new(false), + extensions: Default::default(), }) .expect("start the session"); let driver = tokio::spawn(driver); diff --git a/rs/moq-net/src/ietf/subscribe.rs b/rs/moq-net/src/ietf/subscribe.rs index 07facd099e..31f93af66a 100644 --- a/rs/moq-net/src/ietf/subscribe.rs +++ b/rs/moq-net/src/ietf/subscribe.rs @@ -5,7 +5,7 @@ use std::borrow::Cow; use crate::{ Path, coding::*, - ietf::{Fill, Filter, GroupOrder, Location, Param, Parameters, Properties, RequestId}, + ietf::{Fill, Filter, GroupOrder, Location, Param, ParameterBytes, Parameters, Properties, RequestId}, }; use super::Message; @@ -40,7 +40,7 @@ impl Param for IncludeProperties { /// Subscribe message (0x03) /// Sent by the subscriber to request all future objects for the given track. -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct Subscribe<'a> { pub request_id: RequestId, pub track_namespace: Path<'a>, @@ -53,6 +53,30 @@ pub struct Subscribe<'a> { pub fill: Option, /// Whether the subscriber wants Track Properties on the response (draft-20). pub properties_wanted: bool, + /// The `AUTHORIZATION TOKEN` (0x03) the subscriber presented on this request, if any. + /// A relay verifies it when the session grant does not already cover the namespace + /// (MoQ request-token); the value is the Token structure of section 8.9, decoded with + /// [`super::token::decode_value`]. + pub authorization_token: Option, +} + +impl std::fmt::Debug for Subscribe<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Subscribe") + .field("request_id", &self.request_id) + .field("track_namespace", &self.track_namespace) + .field("track_name", &self.track_name) + .field("subscriber_priority", &self.subscriber_priority) + .field("group_order", &self.group_order) + .field("filter", &self.filter) + .field("fill", &self.fill) + .field("properties_wanted", &self.properties_wanted) + .field( + "authorization_token", + &super::token::Redacted(&self.authorization_token), + ) + .finish() + } } impl Message for Subscribe<'_> { @@ -78,7 +102,12 @@ impl Message for Subscribe<'_> { let filter = Filter::decode(r, version)?; - let _params = Parameters::decode(r, version)?; + // The legacy trailing parameter block: read the AUTHORIZATION TOKEN out of it + // rather than dropping it, so a draft-14 subscriber can present a request token. + let params = Parameters::decode(r, version)?; + let authorization_token = params + .get_bytes(ParameterBytes::AuthorizationToken) + .map(bytes::Bytes::copy_from_slice); Ok(Self { request_id, @@ -89,11 +118,13 @@ impl Message for Subscribe<'_> { filter, fill: None, properties_wanted: true, + authorization_token, }) } _ => { decode_params!(r, version, 0x02 => _object_delivery_timeout: Option, + 0x03 => authorization_token: Option, 0x04 => rendezvous_timeout: Option, 0x06 => _subgroup_delivery_timeout: Option, 0x10 => forward: Option, @@ -144,6 +175,7 @@ impl Message for Subscribe<'_> { filter, fill, properties_wanted, + authorization_token, }) } } @@ -164,7 +196,13 @@ impl Message for Subscribe<'_> { true.encode(w, version)?; // forward self.filter.encode(w, version)?; - 0u8.encode(w, version)?; // no parameters + // The legacy trailing parameter block, carrying the AUTHORIZATION TOKEN when + // the subscriber presents one; otherwise an empty block (count 0), as before. + let mut params = Parameters::default(); + if let Some(token) = &self.authorization_token { + params.set_bytes(ParameterBytes::AuthorizationToken, token.to_vec()); + } + params.encode(w, version)?; } _ => { // FILL_PARAMETERS arrived in draft-20. Sending it to an older peer would be an @@ -179,6 +217,7 @@ impl Message for Subscribe<'_> { (!self.properties_wanted && Filter::is_draft20(version)).then_some(IncludeProperties(false)); encode_params!(w, version, + 0x03 => self.authorization_token.clone(), 0x10 => true, 0x20 => self.subscriber_priority, 0x21 => self.filter, @@ -365,14 +404,43 @@ impl Message for Unsubscribe { } /// SubscribeUpdate message (0x02) -#[derive(Clone, Debug)] +#[derive(Clone)] 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 has no way to 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) presented on this REQUEST_UPDATE, if any. A fresh + /// token refreshes the request's grant (MoQ request-token); the value is the Token + /// structure of section 8.9, decoded with [`super::token::decode_value`]. + pub authorization_token: Option, +} + +impl std::fmt::Debug for SubscribeUpdate { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SubscribeUpdate") + .field("request_id", &self.request_id) + .field("subscription_request_id", &self.subscription_request_id) + .field("start_location", &self.start_location) + .field("end_group", &self.end_group) + .field("subscriber_priority", &self.subscriber_priority) + .field("forward", &self.forward) + .field("filter", &self.filter) + .field( + "authorization_token", + &super::token::Redacted(&self.authorization_token), + ) + .finish() + } } impl Message for SubscribeUpdate { @@ -387,9 +455,15 @@ impl Message for SubscribeUpdate { .encode(w, version)?; self.start_location.encode(w, version)?; self.end_group.encode(w, version)?; - self.subscriber_priority.encode(w, version)?; - self.forward.encode(w, version)?; - 0u8.encode(w, version)?; // no parameters + self.subscriber_priority.unwrap_or(128).encode(w, version)?; + self.forward.unwrap_or(true).encode(w, version)?; + // The legacy trailing parameter block, carrying the AUTHORIZATION TOKEN when a + // renewal presents one; otherwise an empty block (count 0), as before. + let mut params = Parameters::default(); + if let Some(token) = &self.authorization_token { + params.set_bytes(ParameterBytes::AuthorizationToken, token.to_vec()); + } + params.encode(w, version)?; } Version::Draft15 | Version::Draft16 => { self.request_id.encode(w, version)?; @@ -397,9 +471,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.clone(), ); } _ => { @@ -413,9 +488,10 @@ impl Message for SubscribeUpdate { 0u64.encode(w, version)?; // 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.clone(), ); } } @@ -430,9 +506,12 @@ impl Message for SubscribeUpdate { let subscription_request_id = Some(RequestId::decode(r, version)?); let start_location = Location::decode(r, version)?; let end_group = u64::decode(r, version)?; - let subscriber_priority = u8::decode(r, version)?; - let forward = bool::decode(r, version)?; - let _parameters = Parameters::decode(r, version)?; + let subscriber_priority = Some(u8::decode(r, version)?); + let forward = Some(bool::decode(r, version)?); + let params = Parameters::decode(r, version)?; + let authorization_token = params + .get_bytes(ParameterBytes::AuthorizationToken) + .map(bytes::Bytes::copy_from_slice); Ok(Self { request_id, @@ -441,6 +520,8 @@ impl Message for SubscribeUpdate { end_group, subscriber_priority, forward, + filter: None, + authorization_token, }) } Version::Draft15 | Version::Draft16 => { @@ -448,15 +529,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: Option, 0x06 => _subgroup_delivery_timeout: Option, 0x10 => forward: Option, 0x20 => subscriber_priority: Option, - 0x21 => _filter: Option, + 0x21 => filter: Option, ); - let subscriber_priority = subscriber_priority.unwrap_or(128); - let forward = forward.unwrap_or(true); - Ok(Self { request_id, subscription_request_id, @@ -464,6 +543,8 @@ impl Message for SubscribeUpdate { end_group: 0, subscriber_priority, forward, + filter, + authorization_token, }) } _ => { @@ -474,10 +555,11 @@ impl Message for SubscribeUpdate { } decode_params!(r, version, 0x02 => _object_delivery_timeout: Option, + 0x03 => authorization_token: Option, 0x06 => _subgroup_delivery_timeout: Option, 0x10 => forward: Option, 0x20 => subscriber_priority: Option, - 0x21 => _filter: Option, + 0x21 => filter: Option, 0x23 => fill: Option, ); @@ -487,9 +569,6 @@ impl Message for SubscribeUpdate { return Err(DecodeError::InvalidValue); } - let subscriber_priority = subscriber_priority.unwrap_or(128); - let forward = forward.unwrap_or(true); - Ok(Self { request_id, subscription_request_id: None, @@ -497,6 +576,8 @@ impl Message for SubscribeUpdate { end_group: 0, subscriber_priority, forward, + filter, + authorization_token, }) } } @@ -530,6 +611,7 @@ mod tests { filter: Filter::NextObject, fill: None, properties_wanted: true, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft14); @@ -541,6 +623,81 @@ mod tests { assert_eq!(decoded.subscriber_priority, 128); } + /// A request-borne AUTHORIZATION TOKEN round-trips on a legacy draft (draft-14's + /// trailing parameter block) and a strict one (draft-18's message parameters), so a + /// non-moq-dev peer can present or refresh a credential the draft-17+ standard way. + #[test] + fn authorization_token_round_trips_legacy_and_strict() { + // A Token structure value that is not text, so no codec can assume UTF-8. + let token = bytes::Bytes::from_static(&[0x03, 0x81, 0x2c, 0x00, 0xff]); + for version in [Version::Draft14, Version::Draft18] { + let msg = Subscribe { + request_id: RequestId(1), + track_namespace: Path::new("room/alice"), + track_name: "cam".into(), + subscriber_priority: 128, + group_order: GroupOrder::Descending, + filter: Filter::NextObject, + fill: None, + properties_wanted: true, + authorization_token: Some(token.clone()), + }; + let encoded = encode_message(&msg, version); + let decoded: Subscribe = decode_message(&encoded, version).unwrap(); + assert_eq!(decoded.authorization_token, Some(token.clone()), "{version:?}"); + assert_eq!(decoded.track_namespace.as_str(), "room/alice", "{version:?}"); + assert_eq!(decoded.track_name, "cam", "{version:?}"); + } + } + + /// No token stays no token: a SUBSCRIBE without one carries no phantom parameter, on + /// both families. + #[test] + fn absent_authorization_token_stays_none() { + for version in [Version::Draft14, Version::Draft18] { + let msg = Subscribe { + request_id: RequestId(2), + track_namespace: Path::new("room/bob"), + track_name: "cam".into(), + subscriber_priority: 128, + group_order: GroupOrder::Descending, + filter: Filter::NextObject, + fill: None, + properties_wanted: true, + authorization_token: None, + }; + let encoded = encode_message(&msg, version); + let decoded: Subscribe = decode_message(&encoded, version).unwrap(); + assert_eq!(decoded.authorization_token, None, "{version:?}"); + } + } + + /// A REQUEST_UPDATE (SubscribeUpdate) carries the AUTHORIZATION TOKEN too, so a peer + /// can refresh its credential, on a legacy draft and a strict one. + #[test] + fn subscribe_update_authorization_token_round_trips_legacy_and_strict() { + let token = bytes::Bytes::from_static(&[0x03, 0x81, 0x2c, 0x00, 0xff]); + for version in [Version::Draft14, Version::Draft18] { + let subscription_request_id = match version { + Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(RequestId(3)), + _ => None, + }; + let msg = SubscribeUpdate { + request_id: RequestId(1), + 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(token.clone()), + }; + let encoded = encode_message(&msg, version); + let decoded: SubscribeUpdate = decode_message(&encoded, version).unwrap(); + assert_eq!(decoded.authorization_token, Some(token.clone()), "{version:?}"); + } + } + #[test] fn test_subscribe_round_trip_v15() { let msg = Subscribe { @@ -552,6 +709,7 @@ mod tests { filter: Filter::NextObject, fill: None, properties_wanted: true, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft15); @@ -642,6 +800,7 @@ mod tests { filter: Filter::NextObject, fill: None, properties_wanted: true, + authorization_token: None, }; for version in [Version::Draft17, Version::Draft18, Version::Draft19, Version::Draft20] { @@ -671,6 +830,7 @@ mod tests { filter: Filter::NextObject, fill: None, properties_wanted: true, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft14); @@ -854,6 +1014,7 @@ mod tests { filter: Filter::NextObject, fill: None, properties_wanted: wanted, + authorization_token: None, }; let encoded = encode_message(&msg, version); @@ -888,8 +1049,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: None, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft15); @@ -897,8 +1060,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] @@ -908,8 +1071,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: None, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft14); @@ -919,8 +1084,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] @@ -976,6 +1141,7 @@ mod tests { filter: Filter::NextObject, fill: None, properties_wanted: true, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft17); @@ -1010,8 +1176,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: None, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft17); @@ -1019,8 +1187,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] @@ -1034,6 +1202,7 @@ mod tests { filter: Filter::NextObject, fill: None, properties_wanted: true, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft18); @@ -1210,8 +1379,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: None, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft18); @@ -1219,8 +1390,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 @@ -1233,8 +1404,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: None, + authorization_token: None, }; let v18_msg = SubscribeUpdate { ..v17_msg.clone() }; diff --git a/rs/moq-net/src/ietf/subscribe_namespace.rs b/rs/moq-net/src/ietf/subscribe_namespace.rs index bf57ee0bb7..b06bfa30ef 100644 --- a/rs/moq-net/src/ietf/subscribe_namespace.rs +++ b/rs/moq-net/src/ietf/subscribe_namespace.rs @@ -76,7 +76,7 @@ impl Message for SubscribeNamespace<'_> { } let request_id = RequestId::decode(r, version)?; let namespace = decode_namespace(r, version)?; - decode_params!(r, version, HIDDEN_PARAM => hidden: Option); + decode_params!(r, version, 0x03 => _authorization_token: Option, HIDDEN_PARAM => hidden: Option); Ok(Self { request_id, @@ -135,7 +135,7 @@ impl Message for SubscribeNamespaceLegacy<'_> { _ => 0x01, }; - decode_params!(r, version, HIDDEN_PARAM => hidden: Option); + decode_params!(r, version, 0x03 => _authorization_token: Option, HIDDEN_PARAM => hidden: Option); Ok(Self { request_id, @@ -419,6 +419,7 @@ mod tests { hops: hop_path(&[7]), cost: 0, }), + authorization_token: None, }; let mut buf = BytesMut::new(); @@ -536,4 +537,37 @@ mod tests { Err(DecodeError::Version) )); } + + /// A request-token peer may present an AUTHORIZATION TOKEN (0x03) on a SUBSCRIBE_NAMESPACE. + /// Both message shapes consume it, so the message is accepted rather than failing the + /// session; the token is dropped. + #[test] + fn subscribe_namespace_accepts_a_dropped_authorization_token() -> Result<(), EncodeError> { + let token = &[0x03u8, 0x81, 0x2c, 0x00, 0xff]; + + // Modern (draft-18, 0x50). + let version = Version::Draft18; + let mut buf = BytesMut::new(); + RequestId(4).encode(&mut buf, version).unwrap(); + encode_namespace(&mut buf, &Path::new("example"), version).unwrap(); + encode_params!(&mut buf, version, 0x03 => Some(bytes::Bytes::from_static(token))); + let mut bytes = buf.freeze(); + assert_eq!( + SubscribeNamespace::decode_msg(&mut bytes, version) + .expect("modern token accepted") + .request_id, + RequestId(4) + ); + assert!(bytes.is_empty()); + + // Legacy (draft-14, 0x11). + let version = Version::Draft14; + let mut buf = BytesMut::new(); + RequestId(4).encode(&mut buf, version).unwrap(); + encode_namespace(&mut buf, &Path::new("example"), version).unwrap(); + encode_params!(&mut buf, version, 0x03 => Some(bytes::Bytes::from_static(token))); + let mut bytes = buf.freeze(); + SubscribeNamespaceLegacy::decode_msg(&mut bytes, version).expect("legacy token accepted"); + Ok(()) + } } diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index c4d4b0432c..d696555084 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -14,7 +14,7 @@ use crate::{ util::{MaybeBoxedExt, MaybeSendBox, TaskSet, Tasks}, }; -use super::{Message, Version, cluster, error::request, peer}; +use super::{Message, Version, cluster, error::request, peer, request_update}; use crate::tail::{Reading, Settle, Tail}; use kio::Lock; @@ -440,6 +440,15 @@ pub(super) struct Subscriber { going_away: crate::goaway::GoingAway, // Our grant (MoQ Auth): a subscription it stops covering is cancelled. auth: crate::auth::Handle, + // 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, + // Whether we declared MoQ Solicit in our SETUP (`solicit::into_setup`). True by default; + // a side that does not offer it (`Extensions::solicit` off) sets it false. It gates whether an + // unsolicited PUBLISH_NAMESPACE from a solicit-aware peer is a violation: only a peer that + // disregarded a requirement we actually stated is at fault. + declared_solicit: bool, } /// Resolve the subscription a data stream belongs to. @@ -507,6 +516,9 @@ where version, going_away, auth: crate::auth::Handle::new(false), + request_token: crate::RequestToken::default(), + // We declare MoQ Solicit by default; `with_solicit(false)` opts out. + declared_solicit: true, } } @@ -569,6 +581,24 @@ where }); } + /// Whether we declared MoQ Solicit in our SETUP. A side that does not offer it + /// ([`Extensions::solicit`](crate::setup::Extensions::solicit) off) passes false, so an + /// unsolicited PUBLISH_NAMESPACE from a solicit-aware peer is expected rather than a + /// violation. + pub fn with_solicit(mut self, declared: bool) -> Self { + self.declared_solicit = declared; + 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 + } + /// End every active subscription with the error that ended the session. pub fn abort(&self, err: &Error) { self.state.lock().abort(err); @@ -1002,6 +1032,12 @@ where /// 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 { + // We only hold a peer to a requirement we actually stated. A side that did not offer MoQ + // Solicit invited unsolicited advertisements, so one is + // expected even from a solicit-aware peer. + if !self.declared_solicit { + return false; + } match self.version { Version::Draft14 | Version::Draft15 => false, _ => declared.is_some(), @@ -1039,6 +1075,76 @@ 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()) + { + // An alias reference (DELETE/USE_ALIAS) is a connection-level protocol violation + // and closes the session exactly as on the SETUP path; a merely-undecodable + // structure is refused per request without tearing down the connection. + let structure = match crate::ietf::token::decode_value(token, self.version) { + Ok(structure) => structure, + Err(err @ Error::ProtocolViolation) => { + self.session + .close(crate::SessionError::ProtocolViolation.to_code(), &err.to_string()); + return Err(err); + } + Err(_) => { + self.write_error( + &mut stream, + request_id, + &Error::Unauthorized, + "malformed authorization token", + ) + .await?; + let _ = stream.writer.close().await; + return Ok(()); + } + }; + let verdict = self.auth.verify_request( + bytes::Bytes::from(structure.value), + structure.kind, + path.clone(), + crate::auth::RequestKind::PublishNamespace, + ); + match verdict.grant().await { + // The token's grant must cover this announce; it authorizes nothing else and + // never joins the session union. + Ok(grant) if crate::auth::RequestKind::PublishNamespace.covers(&grant, path.as_str()) => { + token_grant = Some(crate::auth::RequestGrant::new( + &self.runtime, + verdict, + grant, + path.clone(), + crate::auth::RequestKind::PublishNamespace, + )); + } + Ok(_) => { + self.write_error( + &mut stream, + request_id, + &Error::Unauthorized, + "token does not cover this request", + ) + .await?; + let _ = stream.writer.close().await; + return Ok(()); + } + // UNAUTHORIZED for a refusal, NOT_SUPPORTED when no consumer verifies tokens. + 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 { @@ -1063,7 +1169,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 { @@ -1084,10 +1190,7 @@ where /// travels the other way, so receiving it on an advertisement *we* were offered is a /// violation, not a withdrawal. fn terminal_publish_namespace(&self, type_id: u64) -> bool { - match self.version { - Version::Draft14 | Version::Draft15 | Version::Draft16 => type_id == ietf::PublishNamespaceDone::ID, - _ => false, - } + terminal_publish_namespace(self.version, type_id) } /// Read advertisement updates off a live PUBLISH_NAMESPACE stream until it closes. @@ -1103,11 +1206,170 @@ where mut held: Option, peer: cluster::Peer, attached: &mut bool, + mut token_grant: Option>, ) -> Result<(), Error> { + let mut pending: Option<(crate::auth::RequestVerdict, RequestId)> = None; + // Updates that arrived while a renewal was pending, handled in order once each verdict + // resolves. A FIFO queue, not a single slot: draft-18 section 10.9.1 permits coalescing + // the cumulative deltas but still requires an answer per update, so an earlier buffered + // update must not be dropped by a later one. The queue is bounded by MAX_REQUEST_UPDATES, + // counting the one being verified, so a peer cannot grow it without limit behind a slow + // verdict. + let mut stashed: std::collections::VecDeque<(u64, bytes::Bytes)> = std::collections::VecDeque::new(); + // A withdrawal read while a renewal was pending, handled ahead of any queued update so + // a buffered update never masks it: nothing more is owed once the peer retracts. + let mut terminal_msg: Option<(u64, bytes::Bytes)> = None; loop { - let type_id: u64 = match stream.reader.decode_maybe().await? { - Some(id) => id, - None => return Ok(()), + // A renewal verify in flight is raced against the request grant's deadline (never + // a bare await), so the old deadline can still fire while a slow acceptor decides, + // and against the stream, so a peer ending the announce ends it whatever the + // acceptor does. A message that arrives meanwhile waits for the verdict. + if let Some((verdict, rid)) = pending.as_mut() { + let rid = *rid; + enum Ren { + Renewal(Result), + Ended(Error), + Closed(Result<(), Error>), + // The peer withdrew the announce: the renewal no longer matters. + Withdrawn, + // A non-terminal update read while the verdict is pending: buffered to handle + // once it resolves, so the read keeps watching for a terminal meanwhile. + Buffered((u64, bytes::Bytes)), + } + let version = self.version; + let ren = { + let mut read = std::pin::pin!(super::publisher::read_control(&mut stream.reader)); + kio::wait(|waiter| { + if let Some(rg) = token_grant.as_mut() + && let Poll::Ready(err) = rg.poll_ended(waiter) + { + return Poll::Ready(Ren::Ended(err)); + } + if let Poll::Ready(res) = verdict.poll_grant(waiter) { + return Poll::Ready(Ren::Renewal(res)); + } + // Keep reading while the verdict is pending so a withdrawal still ends the + // announce: a buffered update must not mask a later terminal. A terminal is + // stashed and reported now; a non-terminal update is buffered and the wait + // broken, so the next read starts fresh and keeps watching. + match waiter.poll_future(read.as_mut()) { + Poll::Ready(Ok(Some((id, data)))) if terminal_publish_namespace(version, id) => { + terminal_msg = Some((id, data)); + Poll::Ready(Ren::Withdrawn) + } + Poll::Ready(Ok(Some(message))) => Poll::Ready(Ren::Buffered(message)), + Poll::Ready(Ok(None)) => Poll::Ready(Ren::Closed(Ok(()))), + Poll::Ready(Err(err)) => Poll::Ready(Ren::Closed(Err(err))), + Poll::Pending => Poll::Pending, + } + }) + .await + }; + let res = match ren { + Ren::Ended(err) => return Err(err), + Ren::Closed(res) => return res, + Ren::Withdrawn => { + pending = None; + continue; + } + Ren::Buffered(message) => { + // Queue the update to handle once the verdict resolves; the loop keeps + // reading, so a terminal is never masked by it. Each buffered update is + // kept, in order, so none loses its cluster delta or its answer. Only a + // REQUEST_UPDATE counts toward the limit (anything else is caught as an + // unexpected message when it drains); the one being verified plus the + // queued updates are the outstanding REQUEST_UPDATEs, and what happens when + // another would exceed the ceiling is version-appropriate. + let is_update = message.0 == ietf::PublishNamespaceUpdate::ID; + let outstanding = stashed + .iter() + .filter(|(id, _)| *id == ietf::PublishNamespaceUpdate::ID) + .count() as u64 + 1; + if request_update::supported(self.version) && is_update { + // Draft-19+: we advertised MAX_REQUEST_UPDATES, so a peer with that many + // already outstanding sending another broke the negotiated limit. + // Draft-19 section 10.3.1.7 answers that with a session close, + // TOO_MANY_REQUEST_UPDATES. Returning the error is not enough here: the + // dispatcher closes only on is_protocol_violation, which excludes + // Error::Session, so close explicitly as the publisher does. A conforming + // peer self-limits and never reaches here. + if outstanding >= request_update::MAX_REQUEST_UPDATES { + self.session.clone().close( + crate::SessionError::TooManyRequestUpdates.to_code(), + "too many request updates", + ); + return Err(Error::Session(crate::SessionError::TooManyRequestUpdates)); + } + } else if stashed.len() >= request_update::UNNEGOTIATED_GUARD { + // Drafts below 19 negotiate no limit, so a peer agreed to no ceiling: + // this is a local memory guard, not a protocol fault. End this announce, + // never the session: finish the stream and stop. + if stream.writer.finish().is_ok() { + let _ = stream.writer.closed().await; + } + return Ok(()); + } + stashed.push_back(message); + continue; + } + Ren::Renewal(res) => res, + }; + let (verdict, _) = pending.take().expect("a pending renewal"); + let Some(rg) = token_grant.as_mut() else { + continue; + }; + match res { + // On accept the old grant is dropped and the deadline re-armed (REQUEST_OK). + Ok(grant) if rg.covers(&grant) => { + rg.renew(verdict, grant); + self.write_ok(stream, rid).await?; + } + // A refused or uncovered renewal keeps the old grant until it lapses and + // answers UNAUTHORIZED without tearing down the announce. + _ => { + self.write_error(stream, rid, &Error::Unauthorized, "renewal not granted") + .await?; + } + } + continue; + } + + // Read one control message, ending the announce (never the session) if the + // request grant lapses or is revoked meanwhile. The read future borrows only the + // reader, in an inner block, so that borrow is gone before a renewal answers on + // the writer. + enum Ctl { + Message(u64, bytes::Bytes), + Closed, + Ended(Error), + } + let ctl = if let Some((id, data)) = terminal_msg.take() { + // A withdrawal read while a renewal was pending ends the announce now, ahead of + // any queued update: nothing more is owed once the peer retracts. + Ctl::Message(id, data) + } else if let Some((id, data)) = stashed.pop_front() { + Ctl::Message(id, data) + } else { + let mut read = std::pin::pin!(super::publisher::read_control(&mut stream.reader)); + kio::wait(|waiter| -> Poll> { + if let Some(rg) = token_grant.as_mut() + && let Poll::Ready(err) = rg.poll_ended(waiter) + { + return Poll::Ready(Ok(Ctl::Ended(err))); + } + match waiter.poll_future(read.as_mut()) { + Poll::Ready(Ok(Some((id, data)))) => Poll::Ready(Ok(Ctl::Message(id, data))), + Poll::Ready(Ok(None)) => Poll::Ready(Ok(Ctl::Closed)), + Poll::Ready(Err(err)) => Poll::Ready(Err(err)), + Poll::Pending => Poll::Pending, + } + }) + .await? + }; + let (type_id, mut data) = match ctl { + Ctl::Message(type_id, data) => (type_id, data), + Ctl::Closed => return Ok(()), + Ctl::Ended(err) => return Err(err), }; let terminal = self.terminal_publish_namespace(type_id); if type_id != ietf::PublishNamespaceUpdate::ID && !terminal { @@ -1117,9 +1379,6 @@ where return Err(Error::UnexpectedMessage); } - let size: u16 = stream.reader.decode().await?; - let mut data = stream.reader.read_exact(size as usize).await?; - if terminal { ietf::PublishNamespaceDone::decode_msg(&mut data, self.version)?; if !data.is_empty() { @@ -1136,54 +1395,101 @@ 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()); + // 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> = if carries_cluster { + 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), + } + } } - self.write_ok(stream, msg.request_id).await?; - continue; + } else { + Ok(()) }; - 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), - }; + // 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(()); + } - 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; + // 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() + { + // An alias reference (DELETE/USE_ALIAS) is a connection-level protocol violation + // and closes the session, as on the SETUP path; a merely-undecodable structure + // is refused per request, leaving the old grant to stand until it lapses. + let structure = match crate::ietf::token::decode_value(token, self.version) { + Ok(structure) => structure, + Err(err @ Error::ProtocolViolation) => { + self.session + .close(crate::SessionError::ProtocolViolation.to_code(), &err.to_string()); + return Err(err); } - return Ok(()); - } + Err(_) => { + self.write_error( + stream, + msg.request_id, + &Error::Unauthorized, + "malformed authorization token", + ) + .await?; + continue; + } + }; + let verdict = self.auth.verify_request( + bytes::Bytes::from(structure.value), + structure.kind, + path.clone(), + crate::auth::RequestKind::PublishNamespace, + ); + pending = Some((verdict, msg.request_id)); + continue; } + + // No token rides this update: acknowledge it now. + self.write_ok(stream, msg.request_id).await?; } } @@ -1641,22 +1947,37 @@ where return; } - // Subscribe only to what our grant covers (MoQ Auth), and cancel once it no longer - // does, leaving the rest of the session alone. - if !self - .auth - .allows(crate::auth::Direction::Subscribe, broadcast_path.as_str()) - { + // 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 mut gate = crate::auth::Gate::new( + 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 subscription = request.subscription(); + // The wire priority this subscription was opened at, re-sent unchanged on a + // request-token renewal so the update carries the token without disturbing anything. + let subscriber_priority = super::priority::to_wire(subscription.as_ref().map(|s| s.priority).unwrap_or(0)); // A live join delivers nothing below the group SUBSCRIBE_OK names as Largest. let live = subscription.as_ref().and_then(|s| s.start).is_none(); let join = match subscribe_join( @@ -1830,6 +2151,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), + }); let info = track::Info::default() .with_timescale(Timescale::MICRO) .with_max_age(self.origin.default_max_age()) @@ -1891,12 +2218,32 @@ where enum End { Unused, 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), } let mut fetch_done = fetching.is_none(); + // 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); + let mut in_flight: 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 cancelled = { - 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 @@ -1911,6 +2258,19 @@ where if track.poll_unused(waiter).is_ready() { return Poll::Ready(End::Unused); } + 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; @@ -1923,6 +2283,62 @@ where } Err(used) => track = used, }, + // 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(()) if throttle => { + in_flight = Some(token); + // 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(); + 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(()) => in_flight = Some(token), + 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.abort(Error::Unauthorized); @@ -1989,16 +2405,51 @@ 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.decode_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+, SUBSCRIBE_ERROR on draft-14; draft-14 is + /// silent on an accepted renewal). Each answer bumps `answers` so the send loop can + /// release the renewal it coalesced behind the one in flight; the read continues + /// regardless, since a refused renewal leaves the old grant standing until it lapses, + /// so the subscription ends then, with its PUBLISH_DONE, not on the answer. + async fn read_publish_done( + reader: &mut Reader, + version: Version, + answers: &kio::Shared, + ) -> Result { + loop { + match reader.decode_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-17+ generalized SUBSCRIBE_ERROR into REQUEST_ERROR at the same id; + // draft-14 still frames it as SUBSCRIBE_ERROR. Either way it refuses the + // renewal, and the old grant stands until it lapses. + 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) } /// Tell the publisher to stop serving a subscription we are walking away from. @@ -2054,6 +2505,46 @@ where Ok(()) } + /// 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). + async fn send_request_token_update( + &self, + writer: &mut crate::coding::Writer, + subscription_id: RequestId, + subscriber_priority: u8, + start: ietf::Location, + token: bytes::Bytes, + ) -> Result<(), Error> { + let request_id = self.control.next_request_id(&self.runtime).await?; + // 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(&ietf::SubscribeUpdate::ID).await?; + writer + .encode(&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(token), + }) + .await?; + Ok(()) + } + async fn write_subscribe( &self, stream: &mut Stream, @@ -2077,6 +2568,7 @@ where filter: join.filter, fill: join.fill, properties_wanted: true, + authorization_token: self.request_token.peek(), }) .await?; Ok(()) @@ -3181,7 +3673,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 answers = kio::Shared::new(0u64); + let result = Subscriber::::read_publish_done(&mut reader, Version::Draft19, &answers).await; if clean { assert_eq!(result.unwrap(), 0); } else { @@ -3909,6 +4402,247 @@ mod tests { /// /// Decoding the framing rather than scanning for a byte: a type id is one varint among /// many, and a substring match would happily find one inside a length or a payload. + /// 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.encode(&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). + #[tokio::test(start_paused = true)] + 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(first.clone())); + let mut subscriber = Subscriber::new( + crate::time::Clock::tokio(), + 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 = tokio::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(second.clone())); + 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.encode(&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(second.clone()), + }) + .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 (B1b, quest Goal): 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. + #[tokio::test(start_paused = true)] + 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::tokio(), + 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(token.clone()))); + 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 = tokio::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::tokio(), + 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 = tokio::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(); + } + fn control_message_types(log: &crate::lite::test_transport::Log, version: Version) -> Vec { use crate::coding::Decode; @@ -4590,11 +5324,929 @@ mod tests { ); } - /// NAMESPACE has no REQUEST_UPDATE, so a peer reprices one by re-sending it on the - /// SUBSCRIBE_NAMESPACE stream. The repeat is neither a duplicate nor a violation: it - /// replaces the advertisement in place, and the route is never retracted for it. - #[tokio::test(start_paused = true)] - async fn a_re_sent_namespace_reprices_in_place() { + /// 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::tokio(), + 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| { + crate::runtime::Timers::now(runtime) + .checked_add(std::time::Duration::from_secs(s)) + .unwrap() + }), + } + } + + /// A Token structure value that decodes via `token::decode_value` (USE_VALUE, kind 300). + fn announce_token() -> bytes::Bytes { + bytes::Bytes::from_static(&[0x03, 0x81, 0x2c, 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.encode(&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.encode(&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. + #[tokio::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. + #[tokio::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. + #[tokio::test(start_paused = true)] + 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. + tokio::time::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" + ); + } + /// 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. + #[tokio::test(start_paused = true)] + 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.warm == 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.encode(&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. + #[tokio::test(start_paused = true)] + 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(tokio::sync::Notify::new()); + 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.warm == 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. + #[tokio::test(start_paused = true)] + 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. + #[tokio::test(start_paused = true)] + 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. + #[tokio::test(start_paused = true)] + 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. + #[tokio::test(start_paused = true)] + 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" + ); + } + + /// An alias reference (DELETE/USE_ALIAS) on a PUBLISH_NAMESPACE token is a connection-level + /// protocol violation, closing the session as on the SETUP path, not a per-request refusal. + #[tokio::test] + async fn an_alias_token_on_a_request_is_a_protocol_violation() { + const VERSION: Version = Version::Draft18; + let (mut subscriber, _requests, _cred, _consumer, session) = auth_announce_harness(VERSION, Vec::new()); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + + let mut msg = token_publish_namespace(); + // USE_ALIAS (0x02): an alias reference cannot precede SETUP, so it is a PROTOCOL_VIOLATION. + msg.authorization_token = Some(bytes::Bytes::from_static(&[0x02, 0x07])); + + let err = subscriber + .run_publish_namespace_stream(stream, msg, cluster::Peer::default(), None) + .await + .unwrap_err(); + assert!(matches!(err, Error::ProtocolViolation), "{err:?}"); + assert_eq!( + session.log.closes().first().map(|c| c.0), + Some(crate::SessionError::ProtocolViolation.to_code()), + "the session must close with PROTOCOL_VIOLATION" + ); + } + + /// 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::tokio(), + 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. + #[tokio::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. + #[tokio::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. + #[tokio::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. + #[tokio::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" + ); + } + + /// NAMESPACE has no REQUEST_UPDATE, so a peer reprices one by re-sending it on the + /// SUBSCRIBE_NAMESPACE stream. The repeat is neither a duplicate nor a violation: it + /// replaces the advertisement in place, and the route is never retracted for it. + #[tokio::test(start_paused = true)] + async fn a_re_sent_namespace_reprices_in_place() { const VERSION: Version = Version::Draft19; let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); @@ -4727,6 +6379,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) @@ -4783,6 +6436,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) @@ -5063,6 +6717,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(); @@ -5200,6 +6855,7 @@ mod tests { Some(clean.clone()), peer, &mut attached, + None, )); for _ in 0..100 { @@ -5246,6 +6902,7 @@ mod tests { Some(clean.clone()), peer, &mut attached, + None, )); // Both updates apply, then the loop parks on the exhausted script. The @@ -5292,6 +6949,7 @@ mod tests { request_id: RequestId(3), hops: None, cost: Some(0), + authorization_token: None, }) .await .unwrap(); @@ -5313,6 +6971,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"); @@ -5361,6 +7020,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()) { @@ -5407,6 +7067,7 @@ mod tests { Some(clean.clone()), peer, &mut attached, + None, )); for _ in 0..20 { assert!( @@ -5447,6 +7108,7 @@ mod tests { cost: 0, ..clean.clone() }), + authorization_token: None, }) .await .unwrap(); @@ -5465,6 +7127,7 @@ mod tests { Some(clean.clone()), peer, &mut attached, + None, )); let mut result = None; for _ in 0..20 { @@ -5606,6 +7269,15 @@ mod tests { } } +/// Whether `type_id` ends a PUBLISH_NAMESPACE stream: PUBLISH_NAMESPACE_DONE before +/// draft-17; later drafts end it by closing the stream. +fn terminal_publish_namespace(version: Version, type_id: u64) -> bool { + match version { + Version::Draft14 | Version::Draft15 | Version::Draft16 => type_id == ietf::PublishNamespaceDone::ID, + _ => false, + } +} + /// What a SUBSCRIBE asks for: the range it delivers, and the backfill covering a head that /// range excludes. #[derive(Debug, Default, PartialEq, Eq)] diff --git a/rs/moq-net/src/ietf/token.rs b/rs/moq-net/src/ietf/token.rs index 129e9e17e4..36b997244e 100644 --- a/rs/moq-net/src/ietf/token.rs +++ b/rs/moq-net/src/ietf/token.rs @@ -32,6 +32,14 @@ pub fn from_setup(params: &Parameters, version: Version) -> Result .transpose() } +/// Decode a Token structure carried as a request message's `AUTHORIZATION TOKEN` +/// parameter value (section 8.9), the same structure the SETUP option carries. The +/// message parameter's Length framing is stripped by the parameter decoder, so this sees +/// the bare structure, exactly as [`from_setup`] hands one to [`decode`]. +pub fn decode_value(value: &[u8], version: Version) -> Result { + decode(value, version) +} + /// 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> { @@ -66,6 +74,46 @@ fn decode(mut buf: &[u8], version: Version) -> Result { }) } +/// Debug for a request-borne `AUTHORIZATION TOKEN` field that shows its length, never its +/// bytes, so a credential cannot reach the logs through a message's `Debug`. +pub(super) struct Redacted<'a>(pub &'a Option); + +impl std::fmt::Debug for Redacted<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.0 { + Some(token) => write!(f, "Some(<{} bytes>)", token.len()), + None => f.write_str("None"), + } + } +} + +#[cfg(test)] +mod redacted_tests { + use super::super::{PublishNamespace, PublishNamespaceUpdate, RequestId}; + + /// A request-borne credential shows only its length in a message's `Debug`. + #[test] + fn debug_never_shows_the_token_bytes() { + let secret = bytes::Bytes::from_static(b"s3cr3t-jwt"); + let announce = PublishNamespace { + request_id: RequestId(1), + track_namespace: crate::Path::new("room"), + cluster: None, + authorization_token: Some(secret.clone()), + }; + let update = PublishNamespaceUpdate { + request_id: RequestId(2), + hops: None, + cost: None, + authorization_token: Some(secret), + }; + for debug in [format!("{announce:?}"), format!("{update:?}")] { + assert!(!debug.contains("s3cr3t"), "{debug}"); + assert!(debug.contains("<10 bytes>"), "{debug}"); + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -152,6 +200,17 @@ mod tests { } } + /// The reusable message-parameter decoder reads the same structure `from_setup` does, + /// so a token on a request and a token on the SETUP are decoded identically. + #[test] + fn decode_value_matches_from_setup() { + for version in VERSIONS { + let params = structure(version, &[REGISTER, 7, token().kind], &token().value); + let value = params.get_bytes(ParameterBytes::AuthorizationToken).unwrap(); + assert_eq!(decode_value(value, version).unwrap(), token(), "{version:?}"); + } + } + #[test] fn absent_is_none() { for version in VERSIONS { diff --git a/rs/moq-net/src/ietf/track.rs b/rs/moq-net/src/ietf/track.rs index 2c600c331c..6d7e01b8dd 100644 --- a/rs/moq-net/src/ietf/track.rs +++ b/rs/moq-net/src/ietf/track.rs @@ -68,7 +68,9 @@ impl Message for TrackStatus<'_> { let _params = Parameters::decode(r, version)?; } _ => { - decode_params!(r, version,); + decode_params!(r, version, + 0x03 => _authorization_token: Option, + ); } } @@ -150,6 +152,48 @@ mod tests { assert_eq!(decoded.track_name, "video"); } + /// A request-token peer may present an AUTHORIZATION TOKEN (0x03) on a TRACK_STATUS. The + /// strict decoder consumes it and the legacy trailing block carries it, so the message + /// is accepted rather than failing the session; the token is dropped (TRACK_STATUS is + /// refused NOT_SUPPORTED regardless). + #[test] + fn track_status_accepts_a_dropped_authorization_token() -> Result<(), EncodeError> { + let token = &[0x03u8, 0x81, 0x2c, 0x00, 0xff]; + + // Strict (draft-18): the parameter is consumed by decode_params. + let version = Version::Draft18; + let mut body = BytesMut::new(); + RequestId(1).encode(&mut body, version).unwrap(); + encode_namespace(&mut body, &Path::new("test/ns"), version).unwrap(); + "video".encode(&mut body, version).unwrap(); + encode_params!(&mut body, version, 0x03 => Some(bytes::Bytes::from_static(token))); + let mut buf = body.freeze(); + assert_eq!( + TrackStatus::decode_msg(&mut buf, version) + .expect("strict token accepted") + .track_name, + "video" + ); + assert!(buf.is_empty()); + + // Legacy (draft-14): the token rides the trailing parameter block. + let version = Version::Draft14; + let mut params = Parameters::default(); + params.set_bytes(crate::ietf::ParameterBytes::AuthorizationToken, token.to_vec()); + let mut body = BytesMut::new(); + RequestId(1).encode(&mut body, version).unwrap(); + encode_namespace(&mut body, &Path::new("test/ns"), version).unwrap(); + "video".encode(&mut body, version).unwrap(); + 0u8.encode(&mut body, version).unwrap(); // subscriber priority + GroupOrder::Descending.encode(&mut body, version).unwrap(); + false.encode(&mut body, version).unwrap(); // forward + Filter::NextObject.encode(&mut body, version).unwrap(); + params.encode(&mut body, version).unwrap(); + let mut buf = body.freeze(); + TrackStatus::decode_msg(&mut buf, version).expect("legacy token accepted"); + Ok(()) + } + #[test] fn test_track_status_v17_round_trip() { let msg = TrackStatus { diff --git a/rs/moq-net/src/ietf/version.rs b/rs/moq-net/src/ietf/version.rs index c78d2bbb13..bab54201a4 100644 --- a/rs/moq-net/src/ietf/version.rs +++ b/rs/moq-net/src/ietf/version.rs @@ -94,6 +94,7 @@ mod tests { range_filters: false, }), properties_wanted: false, + authorization_token: None, }; let subscribe_ok = SubscribeOk { diff --git a/rs/moq-net/src/lib.rs b/rs/moq-net/src/lib.rs index a7f20a366a..5e04e2acca 100644 --- a/rs/moq-net/src/lib.rs +++ b/rs/moq-net/src/lib.rs @@ -100,6 +100,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/server.rs b/rs/moq-net/src/server.rs index 074a2af05a..3a34377ba2 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -20,6 +20,7 @@ pub struct Server { subscribe: Option, stats: stats::Session, versions: Versions, + extensions: crate::setup::Extensions, } impl Server { @@ -65,6 +66,12 @@ impl Server { self } + /// Choose which moq-transport extensions this server offers in its SETUP. Defaults to all. + pub fn with_extensions(mut self, extensions: crate::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 @@ -426,8 +433,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 MoQ Auth is negotiated. - auth: crate::auth::Handle::new(peer_setup.declared.auth), + // The client's SETUP already settled whether MoQ Auth is negotiated, unless this + // server does not offer it. + auth: crate::auth::Handle::new(peer_setup.declared.auth && self.extensions.auth), inner: Some(RequestInner { server: self.clone(), runtime, @@ -565,6 +573,7 @@ where peer_setup_stream: Some(peer_setup.stream), peer_declared: Some(peer_setup.declared), auth: auth.clone(), + extensions: server.extensions, })?; tracing::debug!(?version, "connected"); Ok(Session::new( @@ -627,7 +636,9 @@ where let mut parameters = ietf::Parameters::default(); parameters.set_varint(ietf::ParameterVarInt::MaxRequestId, u32::MAX as u64); 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); parameters.encode_bytes(v)? } @@ -683,6 +694,9 @@ where peer_setup_stream: None, peer_declared: Some(peer_declared), auth: auth.clone(), + // The legacy server already declared its extensions in its SETUP above; + // this arm sends no further SETUP. + extensions: server.extensions, })?; (None, crate::driver::Protocol::Ietf(protocol), goaway, auth) } @@ -1088,6 +1102,57 @@ 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. + #[tokio::test(start_paused = true)] + 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(tokio::time::Instant::now().into_std(), 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( + Bytes::from_static(b"jwt"), + 1, + crate::PathOwned::from("room/alice".to_string()), + crate::auth::RequestKind::PublishNamespace, + ); + let received = requests.next().await.expect("the pre-ok consumer receives the request"); + assert_eq!(received.path(), Some("room/alice"), "{version:?}"); + assert_eq!( + received.kind(), + Some(crate::auth::RequestKind::PublishNamespace), + "{version:?}" + ); + let _issued = received.accept(crate::auth::Grant::all()); + assert!(verdict.grant().await.is_ok(), "granting admits at {version:?}"); + } + } + #[tokio::test(start_paused = true)] async fn accept_request_exposes_the_setup_token() { let modern = FakeSession::new( diff --git a/rs/moq-net/src/setup.rs b/rs/moq-net/src/setup.rs index 24caaffebb..ae076f55ed 100644 --- a/rs/moq-net/src/setup.rs +++ b/rs/moq-net/src/setup.rs @@ -19,6 +19,28 @@ 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. +#[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. + 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 index 49ade2886a..5f0e9757f3 100644 --- a/rs/moq-net/tests/auth.rs +++ b/rs/moq-net/tests/auth.rs @@ -20,6 +20,9 @@ const TEST_TIMEOUT: Duration = Duration::from_secs(10); const LITE_06: &str = "moq-lite-06"; /// The first draft that negotiates MoQ Auth, and the newest. const MOQT_17: &str = "moq-transport-17"; +/// A draft at the deployed floor, used for the request-token launch shape. +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. @@ -164,6 +167,14 @@ struct Options { 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>, } @@ -188,8 +199,19 @@ async fn connect(opts: Options) -> Pair { 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); } @@ -199,8 +221,13 @@ async fn connect(opts: Options) -> Pair { 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); + } tokio::spawn(run(driver)); session }; @@ -334,6 +361,459 @@ async fn an_out_of_scope_announce_aborts_with_the_path(version: &'static str) { .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. +#[tokio::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()), + // USE_VALUE (0x03), token kind 0, value "ok": a decodable request token the + // acceptor answers unconditionally below. + client_request_token: Some(vec![0x03, 0x00, b'o', b'k']), + 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. +/// A server that does not offer the MoQ Auth extension leaves it un-negotiated even with a +/// client that offers it: neither side presents a session token. +#[tokio::test] +async fn a_server_may_decline_the_auth_extension() { + within(async { + // Control: with the default extensions the client's connection credential earns a grant. + let offered = connect(Options { + version: Some(MOQT_18), + ..Default::default() + }) + .await; + wait_for(offered.client.auth().grant(), Option::is_some).await.unwrap(); + + let declined = connect(Options { + version: Some(MOQT_18), + server_decline_auth: true, + ..Default::default() + }) + .await; + let granted = tokio::time::timeout( + Duration::from_millis(200), + wait_for(declined.client.auth().grant(), Option::is_some), + ) + .await; + assert!(granted.is_err(), "no grant without the extension: {granted:?}"); + assert!(matches!(declined.client.auth().add("x").await, Err(Error::Unsupported))); + }) + .await + .expect("timed out"); +} + +#[tokio::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_age(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()), + // USE_VALUE (0x03), token kind 0, value "ok". + client_request_token: Some(vec![0x03, 0x00, b'o', b'k']), + 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"); +} + +/// 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). +#[tokio::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_age(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 = vec![0x03, 0x00, b'a', b'a']; + let second = vec![0x03, 0x00, b'b', b'b']; + 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. Real time, not a paused + // clock: both drivers and the mock transport run on their own tasks. + let expires = Some(now() + Duration::from_secs(1)); + // The acceptor sees the Token structure's value, past its USE_VALUE header. + let renewal = second[2..].to_vec(); + 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[2..]); + sub.recv_group().await.unwrap().unwrap(); + + pair.client.auth().set_request_token(second.clone()); + let (token, _renewed) = answered.recv().await.expect("the renewal reached the acceptor"); + assert_eq!(token, second[2..], "the replaced token rides the REQUEST_UPDATE"); + + // Past the first grant's expiry the subscription still delivers. + tokio::time::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 +#[tokio::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_age(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(); + + // USE_VALUE (0x03), token kind 0, then a distinct value per credential. + let token = |n: u8| vec![0x03, 0x00, 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) = tokio::sync::mpsc::unbounded_channel::>(); + let release = std::sync::Arc::new(tokio::sync::Notify::new()); + let release_waiter = release.clone(); + let acceptor = tokio::spawn(async move { + let mut issued = Vec::new(); + let mut count = 0u64; + while let Some(request) = requests.next().await { + let token = request.token().to_vec(); + count += 1; + // Request 1 is the initial SUBSCRIBE token; request 2 is the first renewal, + // held until the test releases it. + if count == 2 { + seen_tx.send(token).ok(); + release_waiter.notified().await; + } else { + seen_tx.send(token).ok(); + } + 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.recv().await.expect("initial token"), + initial[2..], + "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)); + assert_eq!( + seen.recv().await.expect("first renewal"), + token(1)[2..], + "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)); + tokio::time::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.notify_one(); + assert_eq!( + seen.recv().await.expect("coalesced renewal"), + token(64)[2..], + "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!( + tokio::time::timeout(Duration::from_millis(100), seen.recv()) + .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 (the deployed floor) in the launch shape: the client also declines +/// 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. +#[tokio::test] +async fn a_server_that_declines_solicit_gets_a_token_bearing_unsolicited_announce() { + within(async { + // USE_VALUE (0x03), token kind 0, value "ok". + let request_token = vec![0x03, 0x00, b'o', b'k']; + + // 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 { @@ -697,6 +1177,8 @@ async fn a_revoked_grant_cancels_its_subscriptions(version: &'static str) { 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| { diff --git a/rs/moq-tokio/src/client.rs b/rs/moq-tokio/src/client.rs index 686fd6c6df..bc184e07bb 100644 --- a/rs/moq-tokio/src/client.rs +++ b/rs/moq-tokio/src/client.rs @@ -42,6 +42,10 @@ impl Config { #[derive(Clone)] pub struct Client { moq: moq_net::Client, + /// The request token each session presents, from [`crate::connect::Config::with_request_token`]. + /// Only the dial paths read it, so it is absent without a transport, like [`Self::timeout`]. + #[cfg(feature = "_transport")] + 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 @@ -132,7 +136,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", @@ -381,7 +388,12 @@ impl Client { if url.scheme() == "tcp" { let session = crate::tcp::connect(url, &self.versions.alpns(), self.failover_delay, self.resolution_delay).await?; - return Ok(connect_session(&moq, crate::transport::Session::new(session)).await?); + return Ok(connect_session( + &moq, + self.request_token.as_ref(), + crate::transport::Session::new(session), + ) + .await?); } // Unix domain socket (qmux, no TLS). Same-host only; the server can @@ -389,7 +401,12 @@ impl Client { #[cfg(all(feature = "uds", unix))] if url.scheme() == "unix" { let session = crate::unix::connect(url, &self.versions.alpns()).await?; - return Ok(connect_session(&moq, crate::transport::Session::new(session)).await?); + return Ok(connect_session( + &moq, + self.request_token.as_ref(), + crate::transport::Session::new(session), + ) + .await?); } // A WebSocket URL names its transport. No QUIC backend can dial it, so there is @@ -415,7 +432,12 @@ impl Client { crate::iroh::Binding::H3 => self.moq.clone(), }; - return Ok(connect_session(&moq, crate::transport::Session::new(session)).await?); + return Ok(connect_session( + &moq, + self.request_token.as_ref(), + crate::transport::Session::new(session), + ) + .await?); } #[cfg(feature = "noq")] @@ -437,7 +459,7 @@ impl Client { #[cfg(not(feature = "websocket"))] { let session = quic_handle.await?; - return Ok(connect_session(&moq, session).await?); + return Ok(connect_session(&moq, self.request_token.as_ref(), session).await?); } } @@ -455,7 +477,12 @@ impl Client { let alpns = self.versions.alpns(); let session = crate::websocket::connect(&self.websocket, &self.tls, self.tls_host_name.as_deref(), addr, &alpns).await?; - Ok(connect_session(&self.moq, crate::transport::Session::new(session)).await?) + Ok(connect_session( + &self.moq, + self.request_token.as_ref(), + crate::transport::Session::new(session), + ) + .await?) } /// Race the QUIC dial against the WebSocket fallback, handshaking whichever wins. @@ -488,10 +515,13 @@ impl Client { }; match race_transport_connect(quic, websocket).await? { - TransportRace::Quic(quic) => Ok(connect_session(moq, quic).await?), - TransportRace::WebSocket(websocket) => { - Ok(connect_session(&self.moq, crate::transport::Session::new(websocket)).await?) - } + TransportRace::Quic(quic) => Ok(connect_session(moq, self.request_token.as_ref(), quic).await?), + TransportRace::WebSocket(websocket) => Ok(connect_session( + &self.moq, + self.request_token.as_ref(), + crate::transport::Session::new(websocket), + ) + .await?), } } } @@ -656,11 +686,16 @@ where ))] async fn connect_session( client: &moq_net::Client, + request_token: Option<&bytes::Bytes>, transport: S, ) -> Result { let (session, driver) = client .connect(tokio::time::Instant::now().into_std(), transport) .await?; + // Before the driver runs, so the session's first request already carries it. + if let Some(token) = request_token { + session.auth().set_request_token(token.clone()); + } use tracing::Instrument; tokio::spawn(moq_net::time::run(driver).instrument(tracing::Span::current())); Ok(session) diff --git a/rs/moq-tokio/src/connect.rs b/rs/moq-tokio/src/connect.rs index 3dbbe6a889..cf913a730f 100644 --- a/rs/moq-tokio/src/connect.rs +++ b/rs/moq-tokio/src/connect.rs @@ -361,6 +361,31 @@ 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(&b"s3cr3t"[..]); + 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::default()) + .expect("serialize") + .contains("extensions") + ); + } + #[test] fn auth_statuses_are_terminal() { assert_eq!(ConnectError::from_status_u16(401), Some(ConnectError::Unauthorized)); @@ -464,6 +489,21 @@ failover_delay = "1s" } } +/// A request token held by a [`Config`], shown by length only so it stays out of logs. +#[derive(Clone, PartialEq, Eq)] +pub(crate) struct RequestToken(pub bytes::Bytes); + +impl std::fmt::Debug for RequestToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "<{} bytes>", self.0.len()) + } +} + +/// Whether `extensions` is the default, every extension offered; such a config omits it. +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 +649,17 @@ pub struct Config { )] pub version: Vec, + /// The moq-transport extensions to offer in the 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 +738,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 +754,14 @@ impl Default for Config { } impl Config { + /// Present `token` as the `AUTHORIZATION TOKEN` on every session's own requests (MoQ + /// request-token), set on each session before it sends anything, so it survives + /// reconnects. Renewing it on a live session is [`moq_net::auth::Handle::set_request_token`]. + pub fn with_request_token(mut self, token: impl Into) -> Self { + self.request_token = Some(RequestToken(token.into())); + 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/listen.rs b/rs/moq-tokio/src/listen.rs index 2d2cea2e92..3ee8cbd4df 100644 --- a/rs/moq-tokio/src/listen.rs +++ b/rs/moq-tokio/src/listen.rs @@ -143,6 +143,11 @@ pub struct Config { )] pub version: Vec, + /// The moq-transport extensions to offer in the SETUP, all by default. + #[serde(default, skip_serializing_if = "crate::connect::all_extensions")] + #[usage(skip)] + pub extensions: moq_net::setup::Extensions, + /// The certificates to serve and the roots that authenticate mTLS clients /// (`--listen-tls-*`). #[usage(flatten)] @@ -392,6 +397,20 @@ 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]\nsolicit = false\n").expect("parse"); + assert!(!config.extensions.solicit); + assert!(config.extensions.auth); + 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 bb76de1643..84dfaa19ac 100644 --- a/rs/moq-tokio/src/server.rs +++ b/rs/moq-tokio/src/server.rs @@ -350,7 +350,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); } @@ -1509,6 +1512,18 @@ 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 PUBLISH_NAMESPACE, + /// SUBSCRIBE, or other request). + /// + /// 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. Mirrors the moq-net-native accept + /// path. + 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. /// @@ -1643,6 +1658,15 @@ mod tests { } } + /// `Request::auth` must exist and yield an owned `auth::Handle`, so a QUIC app can take + /// `requests()` on it before `ok()`. Constructing a `Request` needs a live transport + /// handshake (the pre-ok runtime behavior is exercised over real QUIC by the request-token + /// end-to-end verification), so this pins the accessor's shape without binding one. + #[test] + fn request_exposes_an_auth_handle() { + let _signature: fn(&Request) -> moq_net::auth::Handle = Request::auth; + } + /// The handles have to exist before anything binds, and cover the stream /// listeners rather than just the ones an owner happens to construct itself. ///