diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 4a207f14975..de319f608de 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -179,9 +179,12 @@ to a prefix. Right after setup both sides present the credential the connection already carried (the URL token, a client certificate, or nothing), so a publisher learns before anyone subscribes whether its broadcasts can reach the peer. More tokens can be added without reconnecting; the session's scope is the -union of every open token's grant. A token the peer has no verifier for, or -whose grant is too large to send, fails with `Unsupported` and ends only that -token; the session and its other grants stay up. A relay with an +union of every open token's grant. An announcement whose route names a token +(`Route::auth`) is instead held to that token's grant alone and ends with it; +naming a new token and dropping the old one renews it without a gap. A token +the peer has no verifier for, or whose grant is too large to send, fails with +`Unsupported` and ends only that token; the session and its other grants stay +up. A relay with an [auth server](/bin/relay/auth#in-band-renewal) verifies these tokens through it, so a fresh grant can keep the connection alive after its original credential expires; under public rules or a client certificate it refuses them as @@ -197,8 +200,9 @@ to namespace prefixes. Older versions have no grant; the URL token keeps working everywhere. On moq-transport an added token is kept even without the extension, so an announcement can name it: it rides that `PUBLISH_NAMESPACE` as its authorization token. The token belongs to the session that added it, so after a -reconnect the application adds it again and re-announces. moq-lite has no -per-request token yet, so there an announcement rides the session's scope. +reconnect the application adds it again and re-announces. Subscriptions do not +name a token yet, so they ride the session's scope, and a JavaScript acceptor +holds every request to its origin scopes rather than the named token's grant. ## Subscriptions diff --git a/drafts/draft-lcurley-moq-lite.md b/drafts/draft-lcurley-moq-lite.md index 7530b90dbe5..f84403b93ae 100644 --- a/drafts/draft-lcurley-moq-lite.md +++ b/drafts/draft-lcurley-moq-lite.md @@ -565,7 +565,8 @@ Each endpoint SHOULD open one Auth Stream with an empty token right after the se The acceptor replies with an AUTH_OK carrying the grant, or an AUTH_ERROR refusing the token. It MAY send further AUTH_OK messages to replace the grant, such as with a lowered expiry, and MAY send an AUTH_ERROR after an AUTH_OK to revoke it; the latest AUTH_OK is the token's grant. After an AUTH_ERROR the acceptor closes its side of the stream. -The opener withdraws a token by closing or resetting its side of the stream, and the acceptor then closes its own. +The opener withdraws a token by closing its side of the stream: the grant leaves the union at once, and the acceptor closes its own side once no request names the token (see [Named Requests](#named-requests)). +Resetting the stream ends the token at once, with every request naming it. A grant names the paths the opener may publish to the acceptor and subscribe to from it, relative to the session. An endpoint's scope is the union of the grants of its open Auth Streams, and a stream that ends removes its grant from the union. @@ -577,6 +578,21 @@ An acceptor that does not verify a token in band, or cannot express its grant in This applies before or after an AUTH_OK, including when a grant exceeds the message size limit. A presenter receiving a stream reset before any reply treats it as unsupported, for compatibility with peers that do not support the Auth Stream (see [STREAM_TYPE](#stream_type)). +#### Named Requests {#named-requests} +SUBSCRIBE, SUBSCRIBE_UPDATE, FETCH, ANNOUNCE_REQUEST, ANNOUNCE_START, ANNOUNCE_UPDATE, and ANNOUNCE_RESTART carry an `Auth` field: 0 for the sender's scope, or the [Auth ID](#auth-message) of one of the sender's Auth Streams plus 1. +A named request is held to that token's grant alone instead of the scope: the subscribe patterns for a subscriber's request, the publish patterns for an announcement. +The receiver: + +- holds a request naming an Auth ID it has not received, or not yet answered, until it has; +- refuses a request naming a token that has ended or been withdrawn with UNAUTHORIZED; +- ends a request with UNAUTHORIZED, or withholds an announcement, once its token ends or its grant stops covering it. + +A named ANNOUNCE_REQUEST receives only the routes its token's grant covers. +An announcement outside its token's grant is withheld rather than closing the session. +SUBSCRIBE_UPDATE, ANNOUNCE_UPDATE, and ANNOUNCE_RESTART replace the request's `Auth`; the previous token keeps deciding the request until the new one is answered. +A withdrawn token keeps deciding the requests that already name it, so an opener renews a token by presenting the new one, naming it, and then withdrawing the old one. +An announcement ends with the token it names: once that token ends, the sender retracts it rather than moving it to its scope (`Auth` 0), and announces it again only under another token. + ### Goaway Either endpoint can open a Goaway Stream (0x5) to initiate a graceful session shutdown. @@ -920,6 +936,7 @@ ANNOUNCE_REQUEST Message { Message Length (i) Broadcast Path Prefix (s), Hidden (8), + Auth (i), } ~~~ @@ -930,6 +947,9 @@ Indicate interest for any broadcasts with a path that starts with this prefix. 1 to also receive hidden routes (see [Hidden Paths](#hidden)), 0 otherwise. Any other value is a PROTOCOL_VIOLATION. +**Auth**: +The Auth Stream whose grant admits the request (see [Named Requests](#named-requests)). + The publisher MUST respond with an ANNOUNCE_OK message followed by ANNOUNCE_START messages for any matching routes, followed by ANNOUNCE_START, ANNOUNCE_END, ANNOUNCE_UPDATE, and ANNOUNCE_RESTART messages for any future updates, subject to [Routing](#routing). Implementations SHOULD consider reasonable limits on the number of matching broadcasts to prevent resource exhaustion. @@ -976,6 +996,7 @@ ANNOUNCE_START Message { Epoch (b), Hops (..), Route Cost (i), + Auth (i), } Hops { @@ -1032,6 +1053,9 @@ A peer whose wire carries no route cost contributes 0 before the arriving link's lite-06 carries Warm and Cold Route Cost fields. A receiver MUST read Warm as Route Cost and ignore Cold. A sender MUST write Route Cost as Warm and 2^62-1 as Cold. +**Auth**: +The Auth Stream whose grant admits the route (see [Named Requests](#named-requests)). + ## ANNOUNCE_END {#announce-end} A publisher sends an ANNOUNCE_END message to retract a previously started route, referencing its Announce ID. @@ -1069,6 +1093,7 @@ ANNOUNCE_UPDATE Message { Announce ID (i), Hops (..), Route Cost (i), + Auth (i), } ~~~ @@ -1079,10 +1104,10 @@ Set to 0x2 to indicate an ANNOUNCE_UPDATE message. The ordinal implicitly assigned by a prior ANNOUNCE_START on this stream. Referencing an id that was never assigned, or one already retired by an ANNOUNCE_END, is a protocol violation. -**Hops** and **Route Cost**: +**Hops**, **Route Cost**, and **Auth**: As defined for [ANNOUNCE_START](#announce-start). `Hop Base` may name this advertisement itself, which resolves against the list being replaced. -An update whose only change is a Route Cost is valid: it is how a relay re-prices a route without disturbing it. +An update whose only change is a Route Cost or Auth is valid: it is how a relay re-prices a route, or a publisher renews its token, without disturbing it. ## ANNOUNCE_RESTART {#announce-restart} @@ -1098,6 +1123,7 @@ ANNOUNCE_RESTART Message { Epoch (b), Hops (..), Route Cost (i), + Auth (i), } ~~~ @@ -1107,7 +1133,7 @@ Set to 0x3 to indicate an ANNOUNCE_RESTART message. **Announce ID**: As defined for [ANNOUNCE_UPDATE](#announce-update). -**Epoch**, **Hops**, and **Route Cost**: +**Epoch**, **Hops**, **Route Cost**, and **Auth**: As defined for [ANNOUNCE_START](#announce-start), replacing the advertisement's. `Hop Base` may name this advertisement itself, which resolves against the list being replaced. @@ -1129,6 +1155,7 @@ SUBSCRIBE Message { Group End (i) Frame Start (i) Frame End (i) + Auth (i) } ~~~ @@ -1183,6 +1210,9 @@ MUST be 0 when `Group End` is 0, since an unbounded subscription has no end grou `Group End` and `Frame End` are offset by 1 so 0 can mean "absent". `Group Start` is an absolute sequence, and 0 is group 0. +**Auth**: +The Auth Stream whose grant admits the subscription (see [Named Requests](#named-requests)). + ## SUBSCRIBE_UPDATE A subscriber can modify a subscription with a SUBSCRIBE_UPDATE message. A subscriber MAY send multiple SUBSCRIBE_UPDATE messages to update the subscription. @@ -1198,6 +1228,7 @@ SUBSCRIBE_UPDATE Message { Group End (i) Frame Start (i) Frame End (i) + Auth (i) } ~~~ @@ -1356,6 +1387,7 @@ FETCH Message { Group Sequence (i) Frame Start (i) Frame End (i) + Auth (i) } ~~~ @@ -1385,6 +1417,9 @@ The last frame to return (inclusive), encoded as the absolute frame index + 1. A value of 0 means through the end of the group (default). A `Frame End` below `Frame Start` once decoded is a protocol violation; equal bounds are a legal single-frame range. +**Auth**: +The Auth Stream whose grant admits the fetch (see [Named Requests](#named-requests)). + The publisher responds with FRAME messages directly on the same stream — there is no response header. The subscriber parses them using the track's [TRACK_INFO](#track-info), which it MUST already have (see the [Track Stream](#track-stream)); the group sequence and the index of the first frame are implicit from the FETCH request. The publisher FINs the stream after the last frame, or resets on error. @@ -1450,10 +1485,15 @@ AUTH is the first message on an Auth Stream, presenting a token. ~~~ AUTH Message { Message Length (i) + Auth ID (i) Token (b) } ~~~ +**Auth ID**: +The opener's number for the token, which its requests name: 0 for the first Auth Stream it opens on the session, and one more for each after. +A receiver MUST close the session with PROTOCOL_VIOLATION on an Auth ID it already received, or on 2^64-1, which no request can name. + **Token**: The credential to verify, opaque to moq-lite. An empty token presents the credential the connection already carried, or nothing. @@ -1634,6 +1674,7 @@ The `Message Length` describes the payload size on the wire. - Any Setup Stream or bidirectional stream message that fails to decode closes the session with PROTOCOL_VIOLATION; previously only an unexpected Message Length did, as a SHOULD. A stream ending mid-message outside the Setup and Auth Streams, a malformed GROUP or FRAME, and a stream type the negotiated version predates still affect only that stream. - A Report-level publisher reads and ignores PROBE targets instead of resetting the Probe Stream. - Added NOT_SUPPORTED (0x30) to session errors; unsupported Auth Stream tokens and grants use AUTH_ERROR before or after a grant. +- AUTH carries an Auth ID, numbered from 0 per opener. SUBSCRIBE, SUBSCRIBE_UPDATE, FETCH, ANNOUNCE_REQUEST, ANNOUNCE_START, ANNOUNCE_UPDATE, and ANNOUNCE_RESTART carry an `Auth` field naming one, which holds the request to that token's grant instead of the scope. Closing an Auth Stream withdraws its token while named requests linger on it; a reset ends them. - Added the Auth Stream (0x7) with AUTH, AUTH_OK, and AUTH_ERROR: either endpoint presents a token on its own stream and learns the [path patterns](#path-pattern) it may publish and subscribe to. The union of a session's open grants is its scope. A shrink withdraws what it no longer covers, and an announcement outside the scope closes the session with UNAUTHORIZED. A peer without the stream resets it. ## moq-lite-06 diff --git a/js/net/src/auth.test.ts b/js/net/src/auth.test.ts index 1b9f44e325d..0859d14ec77 100644 --- a/js/net/src/auth.test.ts +++ b/js/net/src/auth.test.ts @@ -13,10 +13,11 @@ import { Version as IetfVersion } from "./ietf/version.ts"; import { AuthMessage as LiteAuthMessage, LiteAuthWire } from "./lite/auth.ts"; import * as Lite from "./lite/index.ts"; import * as LiteMessage from "./lite/message.ts"; +import { StreamId as LiteStreamId } from "./lite/stream.ts"; import { createMockTransportPair, type MockTransport } from "./mock.ts"; import { Producer as OriginProducer } from "./origin.ts"; import * as Path from "./path.ts"; -import { type Stream, Writer } from "./stream.ts"; +import { Stream, Writer } from "./stream.ts"; import { withTimeout } from "./util/timeout.ts"; import { nextTask } from "./util/yield.ts"; import { wireOf } from "./wire.ts"; @@ -529,9 +530,14 @@ test("withdrawn queued tokens are pruned before the next application decision", const setup = await requests.next(); if (!setup) throw new Error("setup token missing"); setup.accept(grant(["a"], [])); - const wire = new LiteAuthWire(transport, Lite.Version.DRAFT_07); + // Numbered past the client's own, which a reused ID would close the session over. for (let index = 0; index < 3; index++) { - const stream = await wire.present(new TextEncoder().encode("withdrawn")); + const stream = await Stream.open(transport, { version: Lite.Version.DRAFT_07 }); + await stream.writer.u53(LiteStreamId.Auth); + await new LiteAuthMessage(BigInt(100 + index), new TextEncoder().encode("withdrawn")).encode( + stream.writer, + Lite.Version.DRAFT_07, + ); await stream.writer.close(); await stream.reader.closed; } @@ -560,9 +566,9 @@ describe.each([Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("AUTH this: LiteAuthWire & IetfAuthWire, token, ) { - const stream = await original.call(this, token); - streams.push(stream); - return stream; + const presented = await original.call(this, token); + streams.push(presented.stream); + return presented; }); let pair: Pair | undefined; try { diff --git a/js/net/src/auth_session.ts b/js/net/src/auth_session.ts index daeb0afcccd..291a7241f40 100644 --- a/js/net/src/auth_session.ts +++ b/js/net/src/auth_session.ts @@ -32,8 +32,10 @@ export type WireReply = { grant: WireGrant } | { refused: Error }; * @internal */ export interface AuthWire { - /** Open a stream and present the token on it. */ - present(token: Uint8Array): Promise; + /** Open a stream and present the token on it, with the ID a moq-lite request names it by. */ + present(token: Uint8Array): Promise<{ stream: Stream; id?: bigint }>; + /** Withdraw a presented token, whose stream then ends once the acceptor lets go. */ + withdraw(stream: Stream): void; /** The next reply on a presented token's stream, or undefined once the acceptor finished it. */ read(stream: Stream): Promise; /** The token the peer presents on a stream it opened. */ @@ -64,10 +66,6 @@ function transportClosed(err: unknown): boolean { return typeof err === "object" && err !== null && "source" in err && err.source === "session"; } -function cancel(): StreamError { - return new StreamError(StreamCode.Cancel, { message: "cancel" }); -} - /** One token this side presented. */ class Presented implements Auth { readonly grant = new Signal(undefined); @@ -77,6 +75,8 @@ class Presented implements Auth { readonly token: Uint8Array; stream?: Stream; + /** The ID a moq-lite request names it by, once its stream opened. */ + id?: bigint; withdrawn = false; isAnswered = false; ended = false; @@ -84,10 +84,12 @@ class Presented implements Auth { #close!: (err: Error | null) => void; #answer!: () => void; #refuse!: (err: Error) => void; + #withdraw: (token: Presented) => void; - constructor(token: Uint8Array, setup: boolean) { + constructor(token: Uint8Array, setup: boolean, withdraw: (token: Presented) => void) { this.token = token; this.setup = setup; + this.#withdraw = withdraw; this.closed = new Promise((resolve) => { this.#close = resolve; }); @@ -119,10 +121,7 @@ class Presented implements Auth { close() { if (this.withdrawn) return; this.withdrawn = true; - // The stream's loop notices and ends the token; one still opening checks on arrival. - // One without a stream to come (carried only by requests) ends here. - if (this.stream) this.stream.abort(cancel()); - else if (this.isAnswered) this.end(null); + this.#withdraw(this); } } @@ -336,7 +335,7 @@ export class AuthSession implements Handle { const bytes = typeof token === "string" ? new TextEncoder().encode(token) : token.slice(); if (!this.#wire) { // Only a request carries it, and the peer judges it there: no grant to wait for. - const presented = new Presented(bytes, false); + const presented = new Presented(bytes, false, (token) => this.#withdraw(token)); presented.answer(); this.#track(presented); return presented; @@ -356,7 +355,21 @@ export class AuthSession implements Handle { return token.token; } - /** Bumps whenever one of this session's tokens ends, so a request carrying it drops it. */ + /** + * The ID a moq-lite request names `token` by: undefined for none or for one another session + * issued, leaving the request under the session's scope. Null once `token` is withdrawn or + * ended: what names it ends with it, rather than falling back to the scope. + */ + named(token: Auth | undefined): bigint | null | undefined { + if (!(token instanceof Presented) || !this.#issued.has(token)) return undefined; + if (token.withdrawn || token.ended) return null; + return token.id; + } + + /** + * Bumps whenever one of this session's tokens is withdrawn or ends, so a request carrying + * or naming it drops it. + */ get ended(): Getter { return this.#ended; } @@ -424,7 +437,7 @@ export class AuthSession implements Handle { } #present(wire: AuthWire, token: Uint8Array, setup: boolean): Presented { - const presented = new Presented(token, setup); + const presented = new Presented(token, setup, (token) => this.#withdraw(token)); this.#track(presented); if (setup) this.#setupPending.update((n) => n + 1); void this.#run(wire, presented); @@ -441,13 +454,28 @@ export class AuthSession implements Handle { }); } + /** + * Withdraw `token`: its grant leaves the union at once, while its stream, if any, ends once + * the wire lets it go. One still opening checks on arrival, and one without a stream to + * come (carried only by requests) ends here. + */ + #withdraw(token: Presented) { + token.grant.set(undefined); + this.#recompute(); + this.#ended.update((n) => n + 1); + if (token.stream) this.#wire?.withdraw(token.stream); + else if (token.isAnswered) token.end(null); + } + async #run(wire: AuthWire, token: Presented) { let result: Error | null = null; let closed = false; try { - const stream = await wire.present(token.token); + const { stream, id } = await wire.present(token.token); token.stream = stream; - if (token.withdrawn) throw cancel(); + token.id = id; + // Withdrawn while its stream opened: let it go as if it had been open all along. + if (token.withdrawn) wire.withdraw(stream); for (;;) { let reply: WireReply | undefined; @@ -464,13 +492,16 @@ export class AuthSession implements Handle { throw new ProtocolViolation("malformed AUTH reply", { cause: err }); } if (!reply) { - // The peer ended the grant without revoking it, or closed without ever - // answering. Close our side too, which is what settles its Issued.closed. - result = token.isAnswered ? null : new Unsupported(); + // The peer ended the grant without revoking it, let a withdrawn one go, or + // closed without ever answering. Close our side too, which is what settles + // its Issued.closed. + result = token.isAnswered || token.withdrawn ? null : new Unsupported(); stream.close(); break; } if ("grant" in reply) { + // A withdrawn token's grant stays out of the union while it lingers. + if (token.withdrawn) continue; const expires = reply.grant.expires === undefined ? undefined : wall() + reply.grant.expires; token.grant.set({ publish: reply.grant.publish, subscribe: reply.grant.subscribe, expires }); this.#replied = true; diff --git a/js/net/src/hop.ts b/js/net/src/hop.ts index 9017b38e936..3c0620359ae 100644 --- a/js/net/src/hop.ts +++ b/js/net/src/hop.ts @@ -103,12 +103,14 @@ export interface Route { cost: Cost; /** * The token the request advertising this route carries, from `auth.add()`, or undefined - * for the session's own credential. Only the session that issued it carries it, as the - * token on its moq-transport PUBLISH_NAMESPACE: any other session, the inline NAMESPACE - * answering a solicited SUBSCRIBE_NAMESPACE from draft 16 on (drafts 14 and 15 answer - * with a PUBLISH_NAMESPACE, which carries it), and moq-lite advertise the route unnamed. - * Swapping it renews the request with REQUEST_UPDATE from draft 17, or ends and makes it - * again before. Local: a relay never forwards it. Mirrors `Route::auth` in rs/moq-net. + * for the session's own credential. Only the session that issued it carries it: as the + * token on its moq-transport PUBLISH_NAMESPACE, or by its AUTH ID on a moq-lite-07 + * announcement, which the peer then holds to that token's grant alone. Any other session, + * the inline NAMESPACE answering a solicited SUBSCRIBE_NAMESPACE from draft 16 on (drafts + * 14 and 15 answer with a PUBLISH_NAMESPACE, which carries it), and older moq-lite + * advertise the route unnamed. Swapping it renews the request: in place on moq-lite, with + * REQUEST_UPDATE from draft 17, or by ending and making it again before. Local: a relay + * never forwards it. Mirrors `Route::auth` in rs/moq-net. */ auth?: Auth; } diff --git a/js/net/src/ietf/auth.ts b/js/net/src/ietf/auth.ts index 13c42ddb0e8..b4961ec0566 100644 --- a/js/net/src/ietf/auth.ts +++ b/js/net/src/ietf/auth.ts @@ -1,6 +1,6 @@ import { Unsupported } from "../auth.ts"; import type { AuthWire, WireGrant, WireReply } from "../auth_session.ts"; -import { SessionCode, SessionError } from "../error.ts"; +import { SessionCode, SessionError, StreamCode, StreamError } from "../error.ts"; import * as Path from "../path.ts"; import type { Reader, Stream, Writer } from "../stream.ts"; import type { Session } from "./adapter.ts"; @@ -224,12 +224,16 @@ export class IetfAuthWire implements AuthWire { this.#version = session.version; } - async present(token: Uint8Array): Promise { + async present(token: Uint8Array): Promise<{ stream: Stream }> { const requestId = await this.#session.nextRequestId(); if (requestId === undefined) throw new Error("no request id available"); const stream = await this.#session.openBi(); await new AuthMessage(requestId, token).encode(stream.writer, this.#version); - return stream; + return { stream }; + } + + withdraw(stream: Stream): void { + stream.abort(new StreamError(StreamCode.Cancel, { message: "cancel" })); } async read(stream: Stream): Promise { diff --git a/js/net/src/lite/announce.test.ts b/js/net/src/lite/announce.test.ts index d03c8a24838..387bf3b866c 100644 --- a/js/net/src/lite/announce.test.ts +++ b/js/net/src/lite/announce.test.ts @@ -313,7 +313,7 @@ const GOLDEN_RESOLVED = [ // Pinned from the Rust encoder (`lite::compress::tests::golden_stream_is_pinned`), so the // JS decoder is checked against real compressed output. const GOLDEN = - "001600000a726f6f6d2f612f63616d0000029111a2220000000d0102036d6963000101b33301000208000101c044440100010101000c02010162000201c055550100"; + "001700000a726f6f6d2f612f63616d0000029111a222000000000e0102036d6963000101b3330100000209000101c04444010000010101000d02010162000201c05555010000"; test("AnnounceHistory resolves the Rust encoder's compressed stream", async () => { expect(await resolveStream(unhex(GOLDEN))).toEqual(GOLDEN_RESOLVED); @@ -321,7 +321,7 @@ test("AnnounceHistory resolves the Rust encoder's compressed stream", async () = // JS always encodes literally; Rust decodes these bytes too (`js_literal_stream_decodes`). const JS_LITERAL = - "001600000a726f6f6d2f612f63616d0000029111a2220000001600000a726f6f6d2f612f6d6963000002b333a2220000020a000002c04444a22200000101010013000006726f6f6d2f62000002c05555a2220000"; + "001700000a726f6f6d2f612f63616d0000029111a222000000001700000a726f6f6d2f612f6d6963000002b333a222000000020b000002c04444a2220000000101010014000006726f6f6d2f62000002c05555a222000000"; test("the literal draft-07 stream matches what Rust decodes", async () => { const wire = await bytes(async (w) => { @@ -415,12 +415,13 @@ test("route costs saturate at 2^62-1 on every version", async () => { } // ANNOUNCE_START: path base, path keep, empty suffix, no epoch, hop base, no hops, hop - // keep, then a cost of 2^64-1, which only lite-07's varints can carry. + // keep, then a cost of 2^64-1, which only lite-07's varints can carry, and no auth. const wire = await bytes(async (w) => { await w.u53(0); - await w.u53(16); + await w.u53(17); for (const b of [0, 0, 0, 0, 0, 0, 0]) await w.u8(b); await w.u62(huge); + await w.u8(0); }, Version.DRAFT_07); const got = await decodeAnnounceBroadcast(new Reader(undefined, wire, Version.DRAFT_07), Version.DRAFT_07); expect(got).toMatchObject({ cost: ceiling }); @@ -432,13 +433,13 @@ test("wip start and update carry one static cost", async () => { (w) => encodeAnnounceBroadcast(w, { status: "active", suffix: Path.empty(), hops: [], cost: Cost.zero }, v), v, ); - // Path base, path keep, empty suffix, no epoch, hop base, no hops, hop keep, cost. - expect([...start]).toEqual([0, 8, 0, 0, 0, 0, 0, 0, 0, 0]); + // Path base, path keep, empty suffix, no epoch, hop base, no hops, hop keep, cost, auth. + expect([...start]).toEqual([0, 9, 0, 0, 0, 0, 0, 0, 0, 0, 0]); const update = await bytes( (w) => encodeAnnounceBroadcast(w, { status: "update", id: 0n, hops: [], cost: Cost.zero }, v), v, ); - expect([...update]).toEqual([2, 5, 0, 0, 0, 0, 0]); + expect([...update]).toEqual([2, 6, 0, 0, 0, 0, 0, 0]); }); test("lite06 ignores a legacy cold price and writes its saturation ceiling", async () => { diff --git a/js/net/src/lite/announce.ts b/js/net/src/lite/announce.ts index d658c44dc71..bd309428b9f 100644 --- a/js/net/src/lite/announce.ts +++ b/js/net/src/lite/announce.ts @@ -3,6 +3,7 @@ import { ProtocolViolation } from "../error.ts"; import { type Cost, type Hop, HopSchema, MAX_HOPS, UNKNOWN_HOP } from "../hop.ts"; import * as Path from "../path.ts"; import type { Reader, Writer } from "../stream.ts"; +import { decodeRequestAuth, encodeRequestAuth } from "./auth.ts"; import { decodeEpoch, encodeEpoch } from "./epoch.ts"; import * as Message from "./message.ts"; import { @@ -55,7 +56,8 @@ export type AnnounceBroadcast = * (lite-06+) the route cost. An absent cost encodes as zero; it decodes as * `undefined` on a wire with no room for one. On lite-07, `suffix` follows the * segments `pathBase` copies and `hops` precede the ones `hopBase` copies. The epoch - * (lite-07+) changes only with a `restart`. */ + * (lite-07+) changes only with a `restart`. `auth` (lite-07+) is the ID of the + * publisher's AUTH that admits the route, or undefined for the session's scope. */ | { status: "active"; suffix: Path.Valid; @@ -64,6 +66,7 @@ export type AnnounceBroadcast = cost?: Cost; pathBase?: Base; hopBase?: Base; + auth?: bigint; } /** Pre-lite-06: a broadcast is no longer available, retracted by path. */ | { status: "ended"; suffix: Path.Valid } @@ -72,11 +75,11 @@ export type AnnounceBroadcast = | { status: "endedId"; id: bigint } /** Lite06+: atomically replace the metadata of the announcement with this id (e.g. a * new hop chain after a relay failover, or a route whose cost moved). The id stays live. */ - | { status: "update"; id: bigint; hops: Hop[]; cost?: Cost; hopBase?: Base } + | { status: "update"; id: bigint; hops: Hop[]; cost?: Cost; hopBase?: Base; auth?: bigint } /** Lite07+: another publisher instance replaces the announcement with this id, under * the epoch given (or none). What the receiver resolved under its prefix is the old * instance. The id stays live. */ - | { status: "restart"; id: bigint; epoch?: Epoch.Valid; hops: Hop[]; cost?: Cost; hopBase?: Base } + | { status: "restart"; id: bigint; epoch?: Epoch.Valid; hops: Hop[]; cost?: Cost; hopBase?: Base; auth?: bigint } /** An unknown lite-06+ announce type, skipped by length. Does not assign an id. */ | { status: "skipped" }; @@ -221,6 +224,7 @@ async function encodeAnnounce06Body(w: Writer, msg: AnnounceBroadcast, version: await encodeEpoch(w, version, msg.epoch); await encodeHopsBlock(w, version, msg.hops, msg.hopBase); await encodeRouteCost(w, version, msg.cost); + await encodeRequestAuth(w, version, msg.auth); break; case "endedId": await w.u62(msg.id); @@ -229,12 +233,14 @@ async function encodeAnnounce06Body(w: Writer, msg: AnnounceBroadcast, version: await w.u62(msg.id); await encodeHopsBlock(w, version, msg.hops, msg.hopBase); await encodeRouteCost(w, version, msg.cost); + await encodeRequestAuth(w, version, msg.auth); break; case "restart": await w.u62(msg.id); await encodeEpoch(w, version, msg.epoch); await encodeHopsBlock(w, version, msg.hops, msg.hopBase); await encodeRouteCost(w, version, msg.cost); + await encodeRequestAuth(w, version, msg.auth); break; case "ended": // The pre-lite-06 path-form retraction has no place on lite-06. @@ -270,14 +276,16 @@ async function decodeAnnounce06Body(r: Reader, typ: number, version: Version): P const path = await decodePath(r, version); const epoch = await decodeEpoch(r, version); const hops = await decodeHopsBlock(r, version); - return { status: "active", ...path, epoch, ...hops, cost: await decodeRouteCost(r, version) }; + const cost = await decodeRouteCost(r, version); + return { status: "active", ...path, epoch, ...hops, cost, auth: await decodeRequestAuth(r, version) }; } case ANNOUNCE_END: return { status: "endedId", id: await r.u62() }; case ANNOUNCE_UPDATE: { const id = await r.u62(); const hops = await decodeHopsBlock(r, version); - return { status: "update", id, ...hops, cost: await decodeRouteCost(r, version) }; + const cost = await decodeRouteCost(r, version); + return { status: "update", id, ...hops, cost, auth: await decodeRequestAuth(r, version) }; } case ANNOUNCE_RESTART: { // Unknown before lite-07: skipped below like any other. @@ -285,7 +293,8 @@ async function decodeAnnounce06Body(r: Reader, typ: number, version: Version): P const id = await r.u62(); const epoch = await decodeEpoch(r, version); const hops = await decodeHopsBlock(r, version); - return { status: "restart", id, epoch, ...hops, cost: await decodeRouteCost(r, version) }; + const cost = await decodeRouteCost(r, version); + return { status: "restart", id, epoch, ...hops, cost, auth: await decodeRequestAuth(r, version) }; } } // Skip the length-prefixed body so an earlier Lite06 build negotiating @@ -440,11 +449,15 @@ export class AnnounceRequest { * the wire earlier, so a value set here is ignored when encoding for an older version * and decodes as false. */ hidden: boolean; + /** Lite07+: the ID of the subscriber's AUTH that admits the request, or undefined for + * the session's scope. */ + auth?: bigint; - constructor(prefix: Path.Valid, excludeHop: bigint = 0n, hidden = false) { + constructor(prefix: Path.Valid, excludeHop: bigint = 0n, hidden = false, auth?: bigint) { this.prefix = prefix; this.excludeHop = excludeHop; this.hidden = hidden; + this.auth = auth; } async #encode(w: Writer, version: Version) { @@ -455,13 +468,14 @@ export class AnnounceRequest { if (hasHidden(version)) { await w.bool(this.hidden); } + await encodeRequestAuth(w, version, this.auth); } static async #decode(r: Reader, version: Version): Promise { const prefix = Path.decode(await r.string()); const excludeHop = hasExcludeHop(version) ? await r.u62() : 0n; const hidden = hasHidden(version) ? await r.bool() : false; - return new AnnounceRequest(prefix, excludeHop, hidden); + return new AnnounceRequest(prefix, excludeHop, hidden, await decodeRequestAuth(r, version)); } async encode(w: Writer, version: Version): Promise { diff --git a/js/net/src/lite/auth.test.ts b/js/net/src/lite/auth.test.ts index aa397b084e8..2e4202c241f 100644 --- a/js/net/src/lite/auth.test.ts +++ b/js/net/src/lite/auth.test.ts @@ -2,7 +2,15 @@ import { expect, test } from "bun:test"; import { SessionCode } from "../error.ts"; import * as Path from "../path.ts"; import { Reader, Stream, Writer } from "../stream.ts"; -import { AuthError, AuthMessage, AuthOk, decodeAuthReplyMaybe, LiteAuthWire } from "./auth.ts"; +import { + AuthError, + AuthMessage, + AuthOk, + decodeAuthReplyMaybe, + decodeRequestAuth, + encodeRequestAuth, + LiteAuthWire, +} from "./auth.ts"; import * as Lite from "./index.ts"; import * as Message from "./message.ts"; @@ -34,10 +42,76 @@ async function roundTrip(write: (w: Writer) => Promise): Promise { return new Reader(undefined, buf, Lite.Version.DRAFT_07); } -test("AUTH round-trips its token", async () => { - const r = await roundTrip((w) => new AuthMessage(new TextEncoder().encode("jwt")).encode(w, Lite.Version.DRAFT_07)); - const msg = await AuthMessage.decode(r, Lite.Version.DRAFT_07); - expect(new TextDecoder().decode(msg.token)).toBe("jwt"); +test("AUTH round-trips its ID and token", async () => { + for (const id of [0n, 0xffff_ffff_ffff_fffen]) { + const r = await roundTrip((w) => + new AuthMessage(id, new TextEncoder().encode("jwt")).encode(w, Lite.Version.DRAFT_07), + ); + const msg = await AuthMessage.decode(r, Lite.Version.DRAFT_07); + expect(msg.id).toBe(id); + expect(new TextDecoder().decode(msg.token)).toBe("jwt"); + } +}); + +// The same bytes as `auth_golden` in `rs/moq-net/src/lite/auth.rs`. +test("AUTH matches the Rust encoding", async () => { + const r = await roundTrip((w) => + new AuthMessage(1n, new TextEncoder().encode("jwt")).encode(w, Lite.Version.DRAFT_07), + ); + expect([...(await r.readAll())]).toEqual([0x05, 0x01, 0x03, ...new TextEncoder().encode("jwt")]); +}); + +// No request can name the largest varint as its ID plus one, so neither side sends it. +test("the largest AUTH ID is refused", async () => { + const max = 0xffff_ffff_ffff_ffffn; + await expect( + roundTrip((w) => new AuthMessage(max, new Uint8Array()).encode(w, Lite.Version.DRAFT_07)), + ).rejects.toThrow(); + const r = await roundTrip((w) => + Message.encode(w, async (body) => { + await body.u62(max); + await body.u53(0); + }), + ); + await expect(AuthMessage.decode(r, Lite.Version.DRAFT_07)).rejects.toThrow("AUTH ID too large"); +}); + +test("a request names an AUTH as its ID plus one", async () => { + for (const auth of [undefined, 0n, 0xffff_ffff_ffff_fffen]) { + const r = await roundTrip((w) => encodeRequestAuth(w, Lite.Version.DRAFT_07, auth)); + expect(await decodeRequestAuth(r, Lite.Version.DRAFT_07)).toBe(auth); + } + // Older versions have no field: nothing to read, and nothing to name. + const r = await roundTrip((w) => encodeRequestAuth(w, Lite.Version.DRAFT_06, undefined)); + expect([...(await r.readAll())]).toEqual([]); + await expect(roundTrip((w) => encodeRequestAuth(w, Lite.Version.DRAFT_06, 0n))).rejects.toThrow(); +}); + +test("a reused AUTH ID is a protocol violation", async () => { + const wire = new LiteAuthWire({} as WebTransport, Lite.Version.DRAFT_07); + const accept = async (id: bigint) => { + const data = await ( + await roundTrip((w) => new AuthMessage(id, new Uint8Array()).encode(w, Lite.Version.DRAFT_07)) + ).readAll(); + const stream = new Stream({ + readable: new ReadableStream({ + start(controller) { + controller.enqueue(data); + controller.close(); + }, + }), + writable: new WritableStream(), + version: Lite.Version.DRAFT_07, + }); + return wire.accept(stream); + }; + // Out of order is fine; each ID only once, ahead of a gap or below it. + await accept(1n); + await accept(0n); + await expect(accept(1n)).rejects.toThrow("reused"); + await expect(accept(0n)).rejects.toThrow("reused"); + await accept(3n); + await expect(accept(3n)).rejects.toThrow("reused"); }); test("AUTH_OK and AUTH_ERROR round-trip behind their type", async () => { @@ -116,7 +190,7 @@ test.each([ ["lite-05", Lite.Version.DRAFT_05], ["lite-06", Lite.Version.DRAFT_06], ])("%s carries no AUTH", async (_, version) => { - await expect(roundTrip((w) => new AuthMessage(new Uint8Array()).encode(w, version))).rejects.toThrow(); + await expect(roundTrip((w) => new AuthMessage(0n, new Uint8Array()).encode(w, version))).rejects.toThrow(); }); test("unsupported after AUTH_OK is an AUTH_ERROR on the wire", async () => { diff --git a/js/net/src/lite/auth.ts b/js/net/src/lite/auth.ts index 3e53e3d1a13..5d3c721bf68 100644 --- a/js/net/src/lite/auth.ts +++ b/js/net/src/lite/auth.ts @@ -16,22 +16,35 @@ function guardAuth(version: Version) { /** Longest AUTH_ERROR reason, in bytes, matching the Rust decoder. */ const MAX_REASON = 8192; +/** The largest varint, which no request can name as its AUTH ID plus one. */ +const MAX_ID = 0xffff_ffff_ffff_ffffn; + /** The first message on an Auth Stream: the token the opener presents. Lite07+. */ export class AuthMessage { + /** + * The opener's number for this token, which its requests name: 0 for its first Auth + * Stream, one more for each after. Never the largest varint. + */ + id: bigint; token: Uint8Array; - constructor(token: Uint8Array) { + constructor(id: bigint, token: Uint8Array) { + this.id = id; this.token = token; } async #encode(w: Writer) { + if (this.id >= MAX_ID) throw new Error("AUTH ID too large"); + await w.u62(this.id); await w.u53(this.token.byteLength); if (this.token.byteLength > 0) await w.write(this.token); } static async #decode(r: Reader): Promise { + const id = await r.u62(); + if (id >= MAX_ID) throw new ProtocolViolation("AUTH ID too large"); const size = await r.u53(); - return new AuthMessage(await r.read(size)); + return new AuthMessage(id, await r.read(size)); } async encode(w: Writer, version: Version): Promise { @@ -45,6 +58,25 @@ export class AuthMessage { } } +/** + * Encode a request's `Auth` field: the ID of the requester's AUTH that admits it, or + * undefined for the session's scope. Versions before lite-07 have no field to name one in. + */ +export async function encodeRequestAuth(w: Writer, version: Version, auth: bigint | undefined): Promise { + if (!hasAuth(version)) { + if (auth !== undefined) throw new Error("auth not supported for this version"); + return; + } + await w.u62(auth === undefined ? 0n : auth + 1n); +} + +/** Decode a request's `Auth` field, the inverse of {@link encodeRequestAuth}. */ +export async function decodeRequestAuth(r: Reader, version: Version): Promise { + if (!hasAuth(version)) return undefined; + const value = await r.u62(); + return value === 0n ? undefined : value - 1n; +} + // Each pattern travels as its canonical text. async function encodePatterns(w: Writer, patterns: Path.Patterns) { await w.u53(patterns.size); @@ -181,17 +213,29 @@ export async function decodeAuthReplyMaybe(r: Reader, version: Version): Promise export class LiteAuthWire implements AuthWire { #quic: WebTransport; #version: Version; + // Our Auth Streams are numbered from 0 in the order we present them. + #next = 0n; + // The peer's: every ID below `#arrived` has come, plus those ahead of a gap. + #arrived = 0n; + #ahead = new Set(); constructor(quic: WebTransport, version: Version) { this.#quic = quic; this.#version = version; } - async present(token: Uint8Array): Promise { + async present(token: Uint8Array): Promise<{ stream: Stream; id: bigint }> { + // Numbered before the stream opens, so concurrent presents keep their order. + const id = this.#next++; const stream = await Stream.open(this.#quic, { version: this.#version }); await stream.writer.u53(StreamId.Auth); - await new AuthMessage(token).encode(stream.writer, this.#version); - return stream; + await new AuthMessage(id, token).encode(stream.writer, this.#version); + return { stream, id }; + } + + /** A FIN, so the peer keeps the token for the requests still naming it until they move off. */ + withdraw(stream: Stream): void { + stream.writer.close(); } async read(stream: Stream): Promise { @@ -217,6 +261,11 @@ export class LiteAuthWire implements AuthWire { /** The stream type is already consumed by the dispatcher. */ async accept(stream: Stream): Promise { const msg = await AuthMessage.decode(stream.reader, this.#version); + if (msg.id < this.#arrived || this.#ahead.has(msg.id)) { + throw new ProtocolViolation(`AUTH ID ${msg.id} reused`); + } + this.#ahead.add(msg.id); + while (this.#ahead.delete(this.#arrived)) this.#arrived++; return msg.token; } diff --git a/js/net/src/lite/connection.ts b/js/net/src/lite/connection.ts index 6b593da3267..6840cb8f555 100644 --- a/js/net/src/lite/connection.ts +++ b/js/net/src/lite/connection.ts @@ -162,9 +162,12 @@ export class Connection implements Established { this.abort(); }); + const auth = this.#auth; this.#publisher = new Publisher(this.#quic, this.#version, this.hop, publish, { - grant: this.#auth.scope, - ready: this.#auth.setupAnswered(), + grant: auth.scope, + ready: auth.setupAnswered(), + named: (token) => auth.named(token), + ended: auth.ended, }); this.#subscriber = new Subscriber( this.#quic, diff --git a/js/net/src/lite/fetch.ts b/js/net/src/lite/fetch.ts index 823f85a6268..48911b986e4 100644 --- a/js/net/src/lite/fetch.ts +++ b/js/net/src/lite/fetch.ts @@ -2,6 +2,7 @@ import type { Milli } from "@moq/clock"; import type * as Epoch from "../epoch.ts"; import * as Path from "../path.ts"; import type { Reader, Writer } from "../stream.ts"; +import { decodeRequestAuth, encodeRequestAuth } from "./auth.ts"; import { decodeEpoch, encodeEpoch } from "./epoch.ts"; import * as Message from "./message.ts"; import { hasFetchMaxDelay, hasFrameBounds, Version } from "./version.ts"; @@ -35,6 +36,9 @@ export class Fetch { */ endFrame?: number; + /** The ID of the requester's AUTH that admits this fetch, or undefined for the session's scope. Lite-07+. */ + auth?: bigint; + constructor({ broadcast, epoch, @@ -44,6 +48,7 @@ export class Fetch { group, startFrame = 0, endFrame, + auth, }: { broadcast: Path.Valid; epoch?: Epoch.Valid; @@ -53,6 +58,7 @@ export class Fetch { group: number; startFrame?: number; endFrame?: number; + auth?: bigint; }) { this.broadcast = broadcast; this.epoch = epoch; @@ -62,6 +68,7 @@ export class Fetch { this.group = group; this.startFrame = startFrame; this.endFrame = endFrame; + this.auth = auth; } async #encode(w: Writer, version: Version) { @@ -80,6 +87,7 @@ export class Fetch { // The peer would serve the whole group, including frames we excluded. throw new Error("frame bounds not supported for this version"); } + await encodeRequestAuth(w, version, this.auth); } static async #decode(r: Reader, version: Version): Promise { @@ -110,6 +118,7 @@ export class Fetch { group, startFrame, endFrame: endFrame > 0 ? endFrame - 1 : undefined, + auth: await decodeRequestAuth(r, version), }); } diff --git a/js/net/src/lite/publisher.test.ts b/js/net/src/lite/publisher.test.ts index 748609dea3a..e431981df8f 100644 --- a/js/net/src/lite/publisher.test.ts +++ b/js/net/src/lite/publisher.test.ts @@ -1,7 +1,7 @@ import { expect, jest, mock, spyOn, test } from "bun:test"; import { Milli } from "@moq/clock"; import { Signal } from "@moq/signals"; -import type { Grant } from "../auth.ts"; +import type { Auth, Grant } from "../auth.ts"; import { Producer as BroadcastProducer } from "../broadcast.ts"; import { SessionCode, StreamCode, toStreamCode } from "../error.ts"; import { Producer as GroupProducer } from "../group.ts"; @@ -1985,6 +1985,66 @@ test("lite draft-06: announces wait for the setup grant", async () => { pair.server.close(); }); +// A route ends with the token it names: once that token is over, the route is retracted, and a +// re-price still naming it stays off the wire rather than falling back to the session's scope. +test("lite draft-07: a route ends with the token it names", async () => { + const pair = createMockTransportPair(ALPN_07_WIP); + const origin = new OriginProducer(); + const token = {} as Auth; + let id: bigint | null = 3n; + const ended = new Signal(0); + const publisher = new Publisher(pair.server, Version.DRAFT_07, randomHop(), origin.consume(), { + grant: new Signal(undefined), + ready: Promise.resolve(), + named: (auth) => (auth === token ? id : undefined), + ended, + }); + const broadcast = origin.createBroadcast(Path.from("cam")); + broadcast.announce({ auth: token }); + + const written: Uint8Array[] = []; + const stream = new Stream({ + version: Version.DRAFT_07, + readable: new ReadableStream(), + writable: new WritableStream({ + write(chunk) { + written.push(new Uint8Array(chunk)); + }, + }), + }); + const settle = () => new Promise((resolve) => setTimeout(resolve, 10)); + const messages = async (from: number): Promise => { + const bytes = Uint8Array.from(written.slice(from).flatMap((chunk) => [...chunk])); + const reader = new Reader(undefined, bytes, Version.DRAFT_07); + const out: AnnounceBroadcast[] = []; + while (!(await reader.done())) out.push(await decodeAnnounceBroadcast(reader, Version.DRAFT_07)); + return out; + }; + const running = publisher.runAnnounce(new AnnounceRequest(Path.empty()), stream); + await settle(); + // ANNOUNCE_OK, then the route naming the token. + const announced = written.length; + expect(announced).toBeGreaterThan(0); + + id = null; + ended.update((n) => n + 1); + await settle(); + expect(await messages(announced)).toMatchObject([{ status: "endedId", id: 0n }]); + + const retracted = written.length; + broadcast.announce({ cost: 7n, auth: token }); + await settle(); + expect(await messages(retracted)).toEqual([]); + + stream.close(); + await running; + publisher.close(); + broadcast.close(); + origin.close(); + pair.client.close(); + pair.server.close(); +}); + // A local close while an announce waits on the setup grant finishes it at once, rather than // holding the close until its deadline. test("lite draft-06: closing while announces wait for the setup grant does not hang", async () => { diff --git a/js/net/src/lite/publisher.ts b/js/net/src/lite/publisher.ts index 970db29da14..5b8737acc42 100644 --- a/js/net/src/lite/publisher.ts +++ b/js/net/src/lite/publisher.ts @@ -1,6 +1,6 @@ import { Instant, Milli, Nano, now, sleep } from "@moq/clock"; import { type Dispose, type Getter, Once, race, Signal } from "@moq/signals"; -import type { Grant } from "../auth.ts"; +import type { Auth, Grant } from "../auth.ts"; import { Gate } from "../auth_gate.ts"; import { enforceGrant } from "../auth_session.ts"; import type * as broadcast from "../broadcast.ts"; @@ -41,6 +41,7 @@ import { hasAnnounceId, hasAnnounceOk, hasAnnounceRestart, + hasAuth, hasDatagrams, hasLargest, hasProbeRtt, @@ -399,6 +400,11 @@ export class Publisher { // withdraws what it no longer covers. Undefined until the peer answers, and forever on a // version without AUTH, which allows everything. #grant?: Getter; + // The ID a route's own token goes out under (lite-07+), undefined for the session's scope, + // or null once that token is over, which ends the route with it. + #named?: (auth: Auth | undefined) => bigint | null | undefined; + // Bumps whenever one of our tokens is withdrawn or ends, re-checking the routes naming it. + #ended?: Getter; // Holds every incoming request to that grant from dispatch to its end. #gate: Gate; @@ -420,8 +426,8 @@ export class Publisher { * @param version - Negotiated protocol version * @param origin - Hop id shared with the Subscriber * @param publish - The origin whose broadcasts this session serves; omit to publish nothing - * @param auth - The union of our tokens' grants, which bounds what we publish, and when the - * setup tokens are answered + * @param auth - The union of our tokens' grants, which bounds what we publish, when the + * setup tokens are answered, and the ID a route names its own token by * @param limits - How many live requests the peer may hold * * @internal @@ -431,13 +437,20 @@ export class Publisher { version: Version, hop: Hop, publish?: OriginConsumer, - auth?: { grant: Getter; ready: Promise }, + auth?: { + grant: Getter; + ready: Promise; + named?: (auth: Auth | undefined) => bigint | null | undefined; + ended?: Getter; + }, limits: Limits = LIMITS, ) { this.#quic = quic; this.#interests = new Interests(new Slots(limits.subscriptions, { quic, reason: "too many subscriptions" })); this.#fetches = new Slots(limits.fetches, { quic, reason: "too many fetches" }); this.#grant = auth?.grant; + this.#named = auth?.named; + this.#ended = auth?.ended; this.#gate = new Gate(auth?.grant); this.#ready = auth?.ready ?? Promise.resolve(); this.version = version; @@ -482,9 +495,13 @@ export class Publisher { return [...route.hops, this.hop]; }; + // The ID of our token a route names on lite-07+, undefined for the session's scope, or + // null once that token is over. + const named = (route: Route): bigint | null | undefined => + hasAuth(this.version) ? this.#named?.(route.auth) : undefined; + // What the peer decodes for a route: pre-lite-06 wires carry no cost, so a re-price - // there must not restart. - // moq-lite carries no per-request auth, so a swap alone sends nothing. + // there must not restart, and the token goes out as its ID alone. const onWire = (route: Route): Route => ({ ...route, cost: hasRouteCost(this.version) ? route.cost : Cost.zero, @@ -496,7 +513,14 @@ export class Publisher { if (hasAnnounceId(this.version)) announceIds.set(suffix, nextAnnounceId++); await encodeAnnounceBroadcast( stream.writer, - { status: "active", suffix, epoch: route.epoch, hops: wireHops(route), cost: route.cost }, + { + status: "active", + suffix, + epoch: route.epoch, + hops: wireHops(route), + cost: route.cost, + auth: named(route) ?? undefined, + }, this.version, ); }; @@ -515,7 +539,7 @@ export class Publisher { console.debug(`announce: broadcast=${suffix} update=true`); await encodeAnnounceBroadcast( stream.writer, - { status: "update", id, hops: wireHops(route), cost: route.cost }, + { status: "update", id, hops: wireHops(route), cost: route.cost, auth: named(route) ?? undefined }, this.version, ); }; @@ -531,7 +555,14 @@ export class Publisher { console.debug(`announce: broadcast=${suffix} restart=true epoch=${route.epoch}`); await encodeAnnounceBroadcast( stream.writer, - { status: "restart", id, epoch: route.epoch, hops: wireHops(route), cost: route.cost }, + { + status: "restart", + id, + epoch: route.epoch, + hops: wireHops(route), + cost: route.cost, + auth: named(route) ?? undefined, + }, this.version, ); }; @@ -559,10 +590,10 @@ export class Publisher { const visible = (table: Advertisements): Map => { const out = presented(msg.prefix, table, carries); const grant = this.#grant?.peek(); - if (grant) { - for (const suffix of [...out.keys()]) { - if (!grant.publish.matches(Path.join(msg.prefix, suffix))) out.delete(suffix); - } + for (const [suffix, snap] of [...out]) { + // A route ends with the token it names, never falling back to the session's scope. + if (named(snap.route) === null) out.delete(suffix); + else if (grant && !grant.publish.matches(Path.join(msg.prefix, suffix))) out.delete(suffix); } return out; }; @@ -578,9 +609,11 @@ export class Publisher { new Promise<"changed">((resolve) => { const table = this.#advertised.changed(() => resolve("changed")); const grant = this.#grant?.changed(() => resolve("changed")); + const ended = this.#ended?.changed(() => resolve("changed")); dispose = () => { table(); grant?.(); + ended?.(); }; }); let changed = arm(); @@ -653,7 +686,10 @@ export class Publisher { await announce(suffix, snap.route); } else if (!sameInstance(prev, snap)) { await restart(suffix, snap.route); - } else if (!routesEqual(onWire(prev.route), onWire(snap.route))) { + } else if ( + !routesEqual(onWire(prev.route), onWire(snap.route)) || + named(prev.route) !== named(snap.route) + ) { await update(suffix, snap.route); } } diff --git a/js/net/src/lite/subscribe.ts b/js/net/src/lite/subscribe.ts index f9be30b4785..19c7f45bb4a 100644 --- a/js/net/src/lite/subscribe.ts +++ b/js/net/src/lite/subscribe.ts @@ -3,6 +3,7 @@ import { ProtocolViolation } from "../error.ts"; import * as Path from "../path.ts"; import type { Reader, Writer } from "../stream.ts"; import type { Location, Order } from "../track.ts"; +import { decodeRequestAuth, encodeRequestAuth } from "./auth.ts"; import { decodeEpoch, encodeEpoch } from "./epoch.ts"; import * as Message from "./message.ts"; import { @@ -200,6 +201,8 @@ export class SubscribeUpdate { startFrame: number; /** See {@link Subscribe.endFrame}. */ endFrame?: number; + /** See {@link Subscribe.auth}: the update moves the subscription to this auth. */ + auth?: bigint; constructor(props: { priority: number; @@ -209,6 +212,7 @@ export class SubscribeUpdate { endGroup?: number; startFrame?: number; endFrame?: number; + auth?: bigint; }) { this.priority = props.priority; this.order = props.order ?? "descending"; @@ -217,6 +221,7 @@ export class SubscribeUpdate { this.endGroup = props.endGroup; this.startFrame = props.startFrame ?? 0; this.endFrame = props.endFrame; + this.auth = props.auth; } async #encode(w: Writer, version: Version) { @@ -232,6 +237,7 @@ export class SubscribeUpdate { await encodeStartGroup(w, version, this.startGroup); await w.u53(this.endGroup !== undefined ? this.endGroup + 1 : 0); await encodeFrameBounds(w, version, this); + await encodeRequestAuth(w, version, this.auth); break; } } @@ -249,6 +255,7 @@ export class SubscribeUpdate { const endGroup = (await r.u53()) || undefined; const end = endGroup !== undefined ? endGroup - 1 : undefined; const frames = await decodeFrameBounds(r, version, startGroup, end); + const auth = await decodeRequestAuth(r, version); return new SubscribeUpdate({ priority, order, @@ -256,6 +263,7 @@ export class SubscribeUpdate { startGroup: canonicalStartGroup(version, startGroup, frames.startFrame), endGroup: end, ...frames, + auth, }); } } @@ -305,6 +313,12 @@ export class Subscribe { */ endFrame?: number; + /** + * The ID of the subscriber's AUTH that admits this subscription, or undefined for the + * session's scope. Lite-07+. + */ + auth?: bigint; + constructor(props: { id: bigint; broadcast: Path.Valid; @@ -317,6 +331,7 @@ export class Subscribe { endGroup?: number; startFrame?: number; endFrame?: number; + auth?: bigint; }) { this.id = props.id; this.broadcast = props.broadcast; @@ -329,6 +344,7 @@ export class Subscribe { this.endGroup = props.endGroup; this.startFrame = props.startFrame ?? 0; this.endFrame = props.endFrame; + this.auth = props.auth; } async #encode(w: Writer, version: Version) { @@ -348,6 +364,7 @@ export class Subscribe { await encodeStartGroup(w, version, this.startGroup); await w.u53(this.endGroup !== undefined ? this.endGroup + 1 : 0); await encodeFrameBounds(w, version, this); + await encodeRequestAuth(w, version, this.auth); break; } } @@ -370,6 +387,7 @@ export class Subscribe { const endGroup = (await r.u53()) || undefined; const end = endGroup !== undefined ? endGroup - 1 : undefined; const frames = await decodeFrameBounds(r, version, startGroup, end); + const auth = await decodeRequestAuth(r, version); return new Subscribe({ id, broadcast, @@ -381,6 +399,7 @@ export class Subscribe { startGroup: canonicalStartGroup(version, startGroup, frames.startFrame), endGroup: end, ...frames, + auth, }); } } diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index 270bef2c170..a27dba36def 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -132,7 +132,7 @@ because `main` assigned 0x3A to NOT_FETCHABLE first. - [Live requests follow their auth](/quest/m1/auth/request-update.md) - a route swaps its auth and the session sends the update, keeping the request up - [Auth outlives its session](/quest/m1/auth/issuer.md) - one `auth::Handle` spans a connection's sessions, so a route's auth survives a reconnect -- [Requests name their auth](/quest/m1/auth/request-auth-id.md) - lite-07 AUTH carries an ID and each request names the auth that admits it +- [moq-transport requests name their auth](/quest/m1/auth/request-auth-id-ietf.md) - the moq-auth extension mirrors lite-07's named requests - [Bindings](/quest/m1/auth/bindings.md) - grants and tokens reach every binding through moq-ffi - [Token in band](/quest/m1/auth/token-in-band.md) - the credential can leave diff --git a/quest/m1/auth/request-auth-id-ietf.md b/quest/m1/auth/request-auth-id-ietf.md new file mode 100644 index 00000000000..e2a5972e289 --- /dev/null +++ b/quest/m1/auth/request-auth-id-ietf.md @@ -0,0 +1,40 @@ +# [M] moq-transport requests name their auth + +## Goal + +The `draft-lcurley-moq-auth` extension mirrors moq-lite-07's named requests +for moq-transport, additively: AUTH's Request ID becomes the auth's ID, and a +new request parameter names the auth that admits a request. A named request is +admitted only by that auth's grant and ends when the auth ends or narrows away +from it, matching lite. + +## Plan + +Split from Requests name their auth, which landed the lite-07 wire, the Rust +`auth::Authority` that holds a request to its named token, and FIN-linger +withdrawal for gapless renewal. The decisions are recorded in the +[auth README](/quest/m1/auth/README.md). + +- ✅ Decided 2026-10-15 (maintainer): one kind of auth. Each request names at + most one; moq-transport's per-request token is only an encoding of that + model, never a separate API. +- Settled in the #5148 design: at most one token per moq-transport request. + Distinct tokens are refused NOT_SUPPORTED, and a repeated identical token is + refused MALFORMED_AUTH_TOKEN. Test both. +- The alias types map as: USE_VALUE registers a request-lifetime auth and + names it; REGISTER registers a session-lifetime auth and names it for the + carrying request, so that request ends when the auth ends or narrows away + from it; USE_ALIAS on a later request names it; DELETE withdraws one. +- Reuse `auth::Authority` and the peer tracking in `rs/moq-net/src/auth.rs` + rather than a moq-transport copy. Withdrawal on moq-transport is still a + reset; decide whether the extension gains a FIN-linger like lite's. +- Update `drafts/draft-lcurley-moq-auth.md`, Rust and `js/net`, the + `named` bench in `rs/moq-net/benches/auth.rs` for the IETF path, and + `doc/concept/standard.md`, which describes only moq-transport request + tokens. Run `just test interop --all`. +- The JS acceptor decodes a lite request's `Auth` but holds every request to + its origin scopes. Enforce named grants there too, sharing the logic with + the moq-transport path. + +Public API: none; naming is automatic. +Wire: an additive request parameter in `draft-lcurley-moq-auth`. diff --git a/quest/m1/auth/request-auth-id.md b/quest/m1/auth/request-auth-id.md deleted file mode 100644 index dec288e79aa..00000000000 --- a/quest/m1/auth/request-auth-id.md +++ /dev/null @@ -1,61 +0,0 @@ -# [M] Requests name their auth on the lite wire - -## Goal - -In `moq-lite-07-wip`, AUTH carries an `ID (i)` the opener chooses -(increasing, never reused), and SUBSCRIBE, SUBSCRIBE_UPDATE, FETCH, -ANNOUNCE_REQUEST, ANNOUNCE_START and ANNOUNCE_UPDATE carry `Auth (i)`: 0 for -unnamed, otherwise the ID plus 1. A named request is admitted only by that auth's -grant and ends when the auth ends or narrows away from it. The presenter -names auths automatically. The `draft-lcurley-moq-auth` extension mirrors it -for moq-transport, additively. - -## Plan - -Step 2 of the unified auth model settled on -[#5148](https://github.com/moq-dev/moq/pull/5148) on 2026-10-15. Step 1 -(#5148) makes a moq-transport request token an auth on the shared -`requests()` queue; the decisions are recorded in the -[auth README](/quest/m1/auth/README.md). - -- ✅ Decided 2026-10-15 (maintainer): one kind of auth. Each request names at - most one (`auth=id`); moq-transport's per-request token is only an encoding - of that model, never a separate API. -- Settled in the #5148 design: at most one token per moq-transport request. - Distinct tokens are refused NOT_SUPPORTED, and a repeated identical token is - refused MALFORMED_AUTH_TOKEN. Test both. -- ✅ Decided 2026-10-15 (maintainer): an unnamed request is admitted by the - union of session-lifetime auths only, so a request-lifetime token never - widens tokenless requests. -- ✅ Decided 2026-10-15 (maintainer): a named request outside its auth's grant - is refused, even when session scope would admit it, and it ends with its - auth. -- ✅ Decided 2026-10-15 (maintainer): spec it in lite-07-wip now, before the - cut, so it doesn't cost lite-08. -- A request naming an ID that hasn't arrived yet waits for it while holding - its slot; one naming an ID that already ended is refused UNAUTHORIZED. The - wait spends only the requester's own session budget (its stream and - subscription limits), and ends when the AUTH arrives, the requester cancels, - or the session closes; no timer. -- Renewal on lite: add a new auth, SUBSCRIBE_UPDATE (ANNOUNCE_UPDATE for a route) the request to it, drop - the old one. AUTH and Subscribe streams are unordered, so dropping the old - auth must not race the update into an UNAUTHORIZED reset: settle what tells - the renewer the update applied before it drops the old auth, and spec it. -- moq-transport: AUTH's Request ID becomes the auth id, and a new request - parameter names it. The alias types map as: USE_VALUE registers a - request-lifetime auth and names it; REGISTER registers a session-lifetime - auth and names it for the carrying request, so that request ends when the - auth ends or narrows away from it; USE_ALIAS on a later request names it; - DELETE withdraws one. -- An AUTH with ID `u64::MAX` is refused, since no request can name it as - ID plus 1. Cover the boundary in the Rust and JS interop tests. -- Extend `rs/moq-net/benches/auth.rs` with a sweep over live auths and named - requests, measuring the end or narrowing of one auth. -- Update `drafts/draft-lcurley-moq-lite.md` and - `drafts/draft-lcurley-moq-auth.md`, Rust and `js/net`, and run - `just test interop --all`. Update `doc/concept/moq-lite.md`'s - Authorization section (scope is no longer only the session union) and - `doc/concept/standard.md`, which describes only moq-transport request tokens. - -Public API: none beyond step 1's `requests()` and `add()`; naming is -automatic. Wire: lite-07-wip AUTH and six request messages gain fields. diff --git a/quest/m1/auth/request-update.md b/quest/m1/auth/request-update.md index 678097caccd..3b4e691aaa9 100644 --- a/quest/m1/auth/request-update.md +++ b/quest/m1/auth/request-update.md @@ -5,9 +5,8 @@ A publisher renews the credential behind a live announcement without reopening it. A `Route` holds an optional `moq_net::auth::Auth`; when the application swaps it, the session sends REQUEST_UPDATE (moq-transport) -carrying the new auth, and the request stays up. Lite renews by -ANNOUNCE_UPDATE once [Requests name their auth](/quest/m1/auth/request-auth-id.md) -gives it a way to name one. The first consumer is a client that keeps a +carrying the new auth, and the request stays up. Lite-07 already renews by +ANNOUNCE_UPDATE naming the new auth. The first consumer is a client that keeps a PUBLISH_NAMESPACE alive past its credential's expiry by sending REQUEST_UPDATE with a fresh token before it expires. @@ -68,19 +67,13 @@ because it has a consumer now. The decisions are recorded in the against #5148's earlier serial refresh.) - On moq-transport the update carries the token as USE_VALUE and needs only step 1, so the PUBLISH_NAMESPACE consumer doesn't wait on the lite wire. On - lite it names the auth registered through - [Requests name their auth](/quest/m1/auth/request-auth-id.md), so the lite - path follows that quest. A refused update ends only that request. + lite-07 the ANNOUNCE_UPDATE names the new auth's ID, which already ships. A refused update ends only that request. - Mirror `Auth` and `Route::auth` in `js/net`; the bindings expose no auth yet, so [Bindings](/quest/m1/auth/bindings.md) carries them. - Describe request renewal in `doc/concept/moq-lite.md`'s Authorization section, and in `doc/concept/standard.md`, which describes only incoming request - tokens; the moq-transport path can land before the lite one. + tokens. Public API: a new optional auth on routes in Rust and JS. Wire: none beyond the existing AUTHORIZATION TOKEN on PUBLISH_NAMESPACE and its -REQUEST_UPDATE; lite's ANNOUNCE_UPDATE waits on Requests name their auth. - -## Related - -- [Requests name their auth](/quest/m1/auth/request-auth-id.md) - the lite path's prerequisite; moq-transport renewal doesn't wait on it +REQUEST_UPDATE. diff --git a/rs/moq-net/benches/auth.rs b/rs/moq-net/benches/auth.rs index 359227297fc..3e9f00fc424 100644 --- a/rs/moq-net/benches/auth.rs +++ b/rs/moq-net/benches/auth.rs @@ -8,6 +8,11 @@ //! accepted every token. It sweeps the routes and the sessions publishing that origin: //! the others carry no auth for those routes, so their cost is waking on a change that //! sends nothing. +//! +//! `named` times narrowing one of the peer's lite-07 tokens off the one route that names it, +//! and widening it back, until the subscriber has withheld and restored that route. It +//! sweeps the tokens the peer holds and the routes naming the others, which the change +//! leaves alone: a cost growing with either is a scan of the whole table. #[path = "../tests/integration/support/mod.rs"] mod support; @@ -206,5 +211,141 @@ fn renewal(c: &mut Criterion) { group.finish(); } -criterion_group!(benches, admission, renewal); +/// A lite-07 client publishing `origin` to a server subscribing into `relay`, with the +/// server's token requests. +async fn named_pair(origin: &origin::Producer, relay: origin::Producer) -> (Session, Session, auth::Requests) { + let version: Version = "moq-lite-07-wip".parse().unwrap(); + let mut mock = moq_net::transport::memory::Config::default(); + mock.protocol = Some(version.alpn().to_string()); + let (client, server) = create_mock_session_pair(mock); + let client = async { + let (session, driver) = moq_net::Client::new() + .with_versions(version.into()) + .with_publisher(origin.consume()) + .connect(harness::now(), client) + .await + .unwrap(); + harness::spawn(driver); + session + }; + let server = async { + let request = moq_net::Server::new() + .with_versions(version.into()) + .with_subscriber(relay) + .accept_request(harness::now(), server) + .await + .unwrap(); + let requests = request.auth().requests().unwrap(); + let (session, driver) = request.ok().await.unwrap(); + harness::spawn(driver); + (session, requests) + }; + let (client, (server, requests)) = futures::join!(client, server); + (client, server, requests) +} + +/// Read `announced` until `path` starts (`true`) or ends (`false`). +async fn announced(announced: &mut moq_net::announce::Consumer, path: &str, active: bool) { + loop { + match announced.next().await.unwrap() { + moq_net::announce::Event::Start(update) | moq_net::announce::Event::Restart(update) + if active && update.prefix.as_str() == path => + { + return; + } + moq_net::announce::Event::End(update) if !active && update.prefix.as_str() == path => return, + _ => {} + } + } +} + +fn named(c: &mut Criterion) { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let mut group = c.benchmark_group("auth/named"); + group.sample_size(10); + group.warm_up_time(Duration::from_millis(100)); + group.measurement_time(Duration::from_millis(300)); + for tokens in [1, 16, 128] { + for routes in [1, 16, 128] { + group.throughput(Throughput::Elements(1)); + group.bench_with_input( + BenchmarkId::new(tokens.to_string(), routes), + &(tokens, routes), + |b, &(tokens, routes)| { + let _guard = runtime.enter(); + let (origin, driver) = origin::Producer::new(origin::Config::new(Hop::new(2).unwrap())); + harness::spawn(driver); + // No hold, so an iteration times the change rather than the origin batching it. + let mut config = origin::Config::new(Hop::new(1).unwrap()); + config.update_hold = Duration::ZERO; + let (relay, driver) = origin::Producer::new(config); + harness::spawn(driver); + let mut watch = relay.consume().announced(); + let (client, _server, mut requests) = runtime.block_on(named_pair(&origin, relay)); + let handle = client.auth(); + let (auths, token, _issued) = runtime.block_on(async { + let initial = requests.next().await.unwrap().accept(auth::Grant::all()); + // Token 0 first and alone, so its grant is the one answered next. + let (first, token) = futures::join!(handle.add(Bytes::from_static(b"token")), async { + requests.next().await.unwrap().accept(auth::Grant::all()) + }); + let present = join_all((1..tokens).map(|_| handle.add(Bytes::from_static(b"token")))); + let accept = async { + let mut issued = vec![initial]; + for _ in 1..tokens { + issued.push(requests.next().await.unwrap().accept(auth::Grant::all())); + } + issued + }; + let (rest, issued) = futures::join!(present, accept); + let auths: Vec<_> = std::iter::once(first).chain(rest).map(Result::unwrap).collect(); + (auths, token, issued) + }); + // Route 0 alone names token 0; the rest share the other tokens, or the + // session's scope when there are none. + let broadcasts: Vec<_> = (0..routes) + .map(|route| { + let broadcast = origin.create_broadcast(format!("room/{route}").as_str()).unwrap(); + let auth = match (route, tokens) { + (0, _) => Some(auths[0].clone()), + (_, 1) => None, + (route, tokens) => Some(auths[1 + route % (tokens - 1)].clone()), + }; + let route = match auth { + Some(auth) => origin::Route::default().with_auth(auth), + None => origin::Route::default(), + }; + broadcast.announce(route).unwrap(); + broadcast + }) + .collect(); + runtime.block_on(async { + let mut pending: std::collections::HashSet<_> = + (0..routes).map(|route| format!("room/{route}")).collect(); + while !pending.is_empty() { + if let moq_net::announce::Event::Start(update) = watch.next().await.unwrap() { + pending.remove(update.prefix.as_str()); + } + } + }); + b.iter(|| { + runtime.block_on(async { + token.update(auth::Grant::default()); + announced(&mut watch, "room/0", false).await; + token.update(auth::Grant::all()); + announced(&mut watch, "room/0", true).await; + }) + }); + drop(broadcasts); + }, + ); + } + } + group.finish(); +} + +criterion_group!(benches, admission, renewal, named); criterion_main!(benches); diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 1235203cd6b..6f726fd314c 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -19,8 +19,9 @@ //! Every token the peer presents is one kind of [`Request`], answered on the same queue, //! wherever it rode: its own AUTH stream, or a moq-transport `AUTHORIZATION TOKEN` on a //! SUBSCRIBE or PUBLISH_NAMESPACE. The session enforces each grant it issues. A request -//! that carried a token is held to that token's grant alone and ends with it; any other -//! request is held to the session's scope, which a token carried by a request never joins. +//! that carried a token, or on moq-lite-07 named an AUTH stream's token by its ID, is held +//! to that token's grant alone and ends with it; any other request is held to the session's +//! scope, which a token carried by a request never joins. //! //! moq-transport draft-17+ carries the same exchange when both sides negotiate the //! MoQ Auth extension. Older versions, and peers that do not negotiate it, carry no @@ -31,7 +32,7 @@ //! union of subtrees rather than widen it. use std::{ - collections::{BTreeMap, VecDeque}, + collections::{BTreeMap, BTreeSet, HashMap, VecDeque}, task::Poll, }; @@ -122,6 +123,67 @@ fn earliest(a: Option, b: Option) -> Option { } } +/// Grants by ID, with their union kept as counts per pattern and deadline, so changing one +/// costs that grant rather than a pass over all of them. +#[derive(Default)] +struct Tally { + grants: BTreeMap, + publish: BTreeMap, + subscribe: BTreeMap, + expires: BTreeMap, +} + +impl Tally { + fn get(&self, id: u64) -> Option<&Grant> { + self.grants.get(&id) + } + + /// Replace the grant for `id`, or forget it with `None`. + fn set(&mut self, id: u64, grant: Option) { + let old = match grant { + Some(grant) => { + self.count(&grant, true); + self.grants.insert(id, grant) + } + None => self.grants.remove(&id), + }; + if let Some(old) = old { + self.count(&old, false); + } + } + + fn count(&mut self, grant: &Grant, add: bool) { + fn step(counts: &mut BTreeMap, key: &K, add: bool) { + if add { + *counts.entry(key.clone()).or_default() += 1; + } else if let Some(count) = counts.get_mut(key) { + *count -= 1; + if *count == 0 { + counts.remove(key); + } + } + } + for pattern in grant.publish.iter() { + step(&mut self.publish, pattern, add); + } + for pattern in grant.subscribe.iter() { + step(&mut self.subscribe, pattern, add); + } + if let Some(expires) = &grant.expires { + step(&mut self.expires, expires, add); + } + } + + /// What any of the grants allows, lapsing at the earliest expiry. + fn union(&self) -> Grant { + Grant { + publish: self.publish.keys().cloned().collect(), + subscribe: self.subscribe.keys().cloned().collect(), + expires: self.expires.keys().next().copied(), + } + } +} + /// The shared state behind every auth handle of one session. #[derive(Default)] pub(crate) struct State { @@ -134,8 +196,14 @@ pub(crate) struct State { closed: Option, #[cfg_attr(not(feature = "async"), allow(dead_code))] next_id: u64, + /// The ID the next AUTH stream this side opens carries, so the IDs on the wire run from + /// 0 without a gap however tokens are withdrawn before their stream opens. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + next_wire: u64, /// Every token this side presented that has not ended. tokens: BTreeMap, + /// Each token's latest AUTH_OK, absent before the first and after the token ends. + granted: Tally, /// Tokens waiting for the driver to open their stream. opening: VecDeque, /// The union of every open token's grant, `None` until the peer first replies. @@ -148,7 +216,7 @@ pub(crate) struct State { limit: Option, /// The grants the application issued to the peer's AUTH tokens, by stream, in the /// peer's terms. A token on a single request never joins them. - issued: BTreeMap, + issued: Tally, #[cfg_attr(not(feature = "async"), allow(dead_code))] next_issue: u64, /// The session's scope for the peer: the limit and every issued grant, `None` while the @@ -167,10 +235,10 @@ pub(crate) struct Slot { /// Presented by the session itself at setup, so enforcement waits on it. #[cfg_attr(not(feature = "async"), allow(dead_code))] setup: bool, - /// The latest AUTH_OK, `None` before the first and after the token ends. - grant: Option, /// Handed to the driver to open its AUTH stream, which then owns ending it. streamed: bool, + /// The ID its AUTH stream carries, which a moq-lite request names it by. + wire: Option, /// The first reply: `Ok` for an AUTH_OK, the refusal otherwise. #[cfg_attr(not(feature = "async"), allow(dead_code))] answered: Option>, @@ -178,6 +246,9 @@ pub(crate) struct Slot { ended: Option, /// The last [`Auth`] clone dropped: the driver closes the stream. withdrawn: bool, + /// Closes once withdrawn, waking only this token's stream rather than every waiter on + /// the state. + withdrawal: kio::Consumer<()>, } /// Who answers the peer's tokens. Decided once, when the driver first runs. @@ -194,16 +265,146 @@ enum Acceptor { Default, } +/// The peer's moq-lite AUTH tokens by the ID it numbered them with, which its requests name. +/// +/// The peer numbers its Auth Streams from 0 without a gap, but they arrive in any order, so +/// an ID is either still in flight, live, or over: the arrival watermark and the few IDs +/// ahead of it tell the first from the last without remembering every ended ID. +/// +/// Kept apart from [`State`], so a change to one token wakes only the [`Changes`] of what +/// names it, never every waiter on the session's state. +#[derive(Default)] +struct Peer { + /// Every ID below this one has arrived. + arrived: u64, + /// IDs that arrived ahead of a lower one still in flight. + ahead: BTreeSet, + /// The tokens not over yet. + live: BTreeMap, + /// Who to tell when a token changes, by its ID, arrived or not: one entry per [`Naming`]. + watchers: HashMap>, +} + +/// One of the peer's AUTH tokens, as its requests see it. +#[derive(Default)] +struct Named { + /// The grant last sent for it, `None` before the first. + grant: Option, + /// The peer closed its side: no request may newly name it, and it ends once none does. + withdrawn: bool, + /// How many admitted requests name it. + requests: usize, + /// The token's stream, waiting for `requests` to reach 0 once withdrawn. + unnamed: kio::WaiterList, +} + +impl Named { + /// Count one admitted request in, or out, waking the stream once none is left. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + fn count(&mut self, admitted: bool) { + match admitted { + true => self.requests += 1, + false => { + self.requests -= 1; + if self.requests == 0 { + self.unnamed.wake(); + } + } + } + } +} + +/// The IDs of the peer's tokens that changed since a request naming them last looked. +/// +/// Each request, or each announce stream holding many, owns one, so a token's change wakes +/// only what names it, and tells it which token to re-evaluate. +#[derive(Clone, Default)] +pub(crate) struct Changes(kio::Shared>); + +impl Changes { + /// The IDs changed since the last call, once there are any. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn poll(&self, waiter: &kio::Waiter) -> Poll> { + let mut changed = ready_or!(self.0.poll(waiter, |changed| match changed.is_empty() { + true => Poll::Pending, + false => Poll::Ready(()), + })); + Poll::Ready(std::mem::take(&mut *changed)) + } +} + +/// What a request naming one of the peer's tokens may do right now. +#[cfg_attr(not(feature = "async"), allow(dead_code))] +enum Status<'a> { + /// The token has not arrived, or the acceptor has not answered it yet. + Waiting, + /// The token's grant, and whether the peer withdrew it. + Granted(&'a Grant, bool), + /// The token is over: refused, revoked, or withdrawn and no longer named. + Ended, +} + +impl Peer { + /// Record that the token `id` arrived, or `false` for an ID that already did. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + fn arrive(&mut self, id: u64) -> bool { + if id < self.arrived || !self.ahead.insert(id) { + return false; + } + while self.ahead.remove(&self.arrived) { + self.arrived += 1; + } + self.live.insert(id, Named::default()); + self.changed(id); + true + } + + /// Tell everything naming the token `id` that it changed. + fn changed(&self, id: u64) { + for changes in self.watchers.get(&id).into_iter().flatten() { + changes.0.lock().insert(id); + } + } + + #[cfg_attr(not(feature = "async"), allow(dead_code))] + fn watch(&mut self, id: u64, changes: &Changes) { + self.watchers.entry(id).or_default().push(changes.clone()); + } + + #[cfg_attr(not(feature = "async"), allow(dead_code))] + fn unwatch(&mut self, id: u64, changes: &Changes) { + let Some(watchers) = self.watchers.get_mut(&id) else { + return; + }; + if let Some(index) = watchers.iter().position(|other| other.0.same_channel(&changes.0)) { + watchers.swap_remove(index); + } + if watchers.is_empty() { + self.watchers.remove(&id); + } + } + + #[cfg_attr(not(feature = "async"), allow(dead_code))] + fn status(&self, id: u64) -> Status<'_> { + match self.live.get(&id) { + Some(Named { + grant: Some(grant), + withdrawn, + .. + }) => Status::Granted(grant, *withdrawn), + Some(_) => Status::Waiting, + None if id < self.arrived || self.ahead.contains(&id) => Status::Ended, + None => Status::Waiting, + } + } +} + impl State { fn recompute(&mut self) { - let mut granted = self.tokens.values().filter_map(|slot| slot.grant.as_ref()).peekable(); - if granted.peek().is_none() && !self.replied { + if self.granted.grants.is_empty() && !self.replied { return; } - let mut union = Grant::default(); - for grant in granted { - union.union(grant); - } + let union = self.granted.union(); if self.union.as_ref() != Some(&union) { self.union = Some(union); self.epoch += 1; @@ -214,9 +415,7 @@ impl State { fn rescope(&mut self) { let scope = self.limit.as_ref().map(|limit| { let mut scope = limit.clone(); - for grant in self.issued.values() { - scope.union(grant); - } + scope.union(&self.issued.union()); scope }); if self.scope != scope { @@ -277,6 +476,16 @@ pub(crate) struct Permit { } impl Permit { + /// The same grant under another limit: the peer's token a request names, standing in + /// for the scope. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn limited(self, limit: Patterns) -> Self { + Self { + limit: Some(limit), + ..self + } + } + /// Whether both allow `path`. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn matches(&self, path: &str) -> bool { @@ -314,6 +523,8 @@ impl Permit { #[derive(Clone)] pub struct Handle { state: kio::Shared, + /// The peer's AUTH tokens, for the requests that name one. + peer: kio::Shared, } impl Handle { @@ -325,6 +536,7 @@ impl Handle { supported, ..Default::default() }), + peer: Default::default(), } } @@ -338,6 +550,7 @@ impl Handle { requests: true, ..Default::default() }), + peer: Default::default(), } } @@ -387,16 +600,18 @@ impl Handle { state.next_id += 1; // Without AUTH only a request presents it, so nothing will answer. let answered = (!state.supported).then_some(Ok(())); + let withdrawal = kio::Producer::new(()); state.tokens.insert( id, Slot { token, setup, - grant: None, streamed: false, + wire: None, answered, ended: None, withdrawn: false, + withdrawal: withdrawal.consume(), }, ); if state.supported { @@ -406,10 +621,30 @@ impl Handle { presented: std::sync::Arc::new(Presented { state: self.state.clone(), id, + _withdrawal: withdrawal, }), }) } + /// The ID a moq-lite request names `auth` by, or `None` when the request goes out unnamed + /// under the session's scope: `auth` came from another handle, or its stream has not + /// opened. + /// + /// # Errors + /// + /// [`Error::Unauthorized`] once `auth` ended: what names it ends with it, rather than + /// falling back to the scope, until the application names another. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn wire(&self, auth: &Auth) -> Result> { + if !auth.presented.state.same_channel(&self.state) { + return Ok(None); + } + match self.state.read().tokens.get(&auth.presented.id) { + Some(slot) if slot.ended.is_none() && !slot.withdrawn => Ok(slot.wire), + _ => Err(Error::Unauthorized), + } + } + /// The token a request carries for `auth`, or `None` when the request goes out unnamed /// under the session's scope: `auth` came from another handle, it ended, or requests /// carry no token on this version. @@ -499,9 +734,10 @@ impl Handle { } } - /// The next token to open a stream for, or `Ready(None)` once the session ends. + /// The next token to open a stream for, or `Ready(None)` once the session ends: its slot, + /// the ID its AUTH carries, and the token. #[cfg_attr(not(feature = "async"), allow(dead_code))] - pub(crate) fn poll_opening(&self, waiter: &kio::Waiter) -> Poll> { + pub(crate) fn poll_opening(&self, waiter: &kio::Waiter) -> Poll> { let mut state = ready_or!(self.state.poll(waiter, |state| { match state.opening.is_empty() && state.closed.is_none() { true => Poll::Pending, @@ -512,12 +748,17 @@ impl Handle { return Poll::Ready(None); } let id = state.opening.pop_front().expect("checked non-empty"); + let wire = state.next_wire; let token = state.tokens.get_mut(&id).map(|slot| { slot.streamed = true; + slot.wire = Some(wire); slot.token.clone() }); match token { - Some(token) => Poll::Ready(Some((id, token))), + Some(token) => { + state.next_wire += 1; + Poll::Ready(Some((id, wire, token))) + } // Withdrawn before its stream opened: nothing to present. None => { drop(state); @@ -529,11 +770,8 @@ impl Handle { /// Whether the token's handle dropped, so its stream should close. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn poll_withdrawn(&self, id: u64, waiter: &kio::Waiter) -> Poll<()> { - let _ = ready_or!(self.state.poll(waiter, |state| match state.tokens.get(&id) { - Some(slot) if !slot.withdrawn => Poll::Pending, - _ => Poll::Ready(()), - })); - Poll::Ready(()) + let withdrawal = self.state.read().tokens.get(&id).map(|slot| slot.withdrawal.clone()); + withdrawal.map_or(Poll::Ready(()), |withdrawal| withdrawal.poll_closed(waiter)) } /// Record an AUTH_OK for the token. @@ -551,8 +789,12 @@ impl Handle { let Some(slot) = state.tokens.get_mut(&id) else { return; }; - slot.grant = Some(grant); slot.answered.get_or_insert(Ok(())); + // A withdrawn token's stream stays up while the peer still has requests naming it, + // but its grant left the union with the withdrawal. + if !slot.withdrawn { + state.granted.set(id, Some(grant)); + } state.replied = true; state.recompute(); } @@ -573,10 +815,10 @@ impl Handle { #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn ended(&self, id: u64, err: Error) { let mut state = self.state.lock(); + state.granted.set(id, None); let Some(slot) = state.tokens.get_mut(&id) else { return; }; - slot.grant = None; slot.answered.get_or_insert(Err(err.clone())); slot.ended = Some(err); // The handle is gone and the token is over: nothing will read the slot again. @@ -636,13 +878,68 @@ impl Handle { /// `None`, resizing the scope. fn issue(&self, id: u64, grant: Option) { let mut state = self.state.lock(); - match grant { - Some(grant) => state.issued.insert(id, grant), - None => state.issued.remove(&id), - }; + state.issued.set(id, grant); state.rescope(); } + /// Record the peer's moq-lite AUTH numbered `id`, which its requests may now name. + /// + /// # Errors + /// + /// [`Error::ProtocolViolation`] for an ID the peer already used. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn peer_arrived(&self, id: u64) -> Result<()> { + match self.peer.lock().arrive(id) { + true => Ok(()), + false => Err(Error::ProtocolViolation), + } + } + + /// Record the grant last sent for the peer's token `id`, which decides every request naming + /// it. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn peer_granted(&self, id: u64, grant: &Grant) { + let mut peer = self.peer.lock(); + if let Some(named) = peer.live.get_mut(&id) + && named.grant.as_ref() != Some(grant) + { + named.grant = Some(grant.clone()); + peer.changed(id); + } + } + + /// The peer closed its side of token `id`'s stream: no request may newly name it. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn peer_withdrawn(&self, id: u64) { + let mut peer = self.peer.lock(); + if let Some(named) = peer.live.get_mut(&id) { + named.withdrawn = true; + peer.changed(id); + } + } + + /// The peer's token `id` is over, ending every request that still names it. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn peer_ended(&self, id: u64) { + let mut peer = self.peer.lock(); + if let Some(mut named) = peer.live.remove(&id) { + named.unnamed.wake(); + peer.changed(id); + } + } + + /// Resolve once no admitted request names the peer's token `id`. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn poll_peer_unnamed(&self, id: u64, waiter: &kio::Waiter) -> Poll<()> { + match self.peer.lock().live.get_mut(&id) { + Some(named) if named.requests > 0 => { + named.unnamed.register(waiter); + Poll::Pending + } + _ => Poll::Ready(()), + } + } + /// The union once it changed since `epoch` last saw it, advancing `epoch`. It /// only ever changes into `Some`: no answer yet is the initial state. #[cfg_attr(not(feature = "async"), allow(dead_code))] @@ -749,10 +1046,10 @@ impl Handle { state.closed = Some(err.clone()); state.opening.clear(); for slot in state.tokens.values_mut() { - slot.grant = None; slot.answered.get_or_insert(Err(err.clone())); slot.ended.get_or_insert(err.clone()); } + state.granted = Tally::default(); state.recompute(); let queue = match &state.acceptor { Acceptor::App(queue) => Some(queue.clone()), @@ -781,6 +1078,8 @@ pub struct Auth { struct Presented { state: kio::Shared, id: u64, + /// Dropped with the last clone, closing the slot's `withdrawal`. + _withdrawal: kio::Producer<()>, } impl Auth { @@ -795,7 +1094,7 @@ impl Auth { /// close. #[cfg(feature = "async")] pub async fn closed(&self) -> Error { - let Presented { state, id } = &*self.presented; + let Presented { state, id, .. } = &*self.presented; kio::wait(|waiter| { let state = ready_or!(state.poll(waiter, |state| match state.tokens.get(id) { Some(slot) if slot.ended.is_none() => Poll::Pending, @@ -809,7 +1108,7 @@ impl Auth { #[cfg_attr(not(feature = "async"), allow(dead_code))] fn poll_answered(&self, waiter: &kio::Waiter) -> Poll> { - let Presented { state, id } = &*self.presented; + let Presented { state, id, .. } = &*self.presented; let state = ready_or!(state.poll(waiter, |state| match state.tokens.get(id) { Some(slot) if slot.answered.is_none() => Poll::Pending, _ => Poll::Ready(()), @@ -837,6 +1136,8 @@ impl std::fmt::Debug for Auth { impl Drop for Presented { fn drop(&mut self) { let mut state = self.state.lock(); + // The grant leaves the union now, even while the stream closes gracefully. + state.granted.set(self.id, None); let Some(slot) = state.tokens.get_mut(&self.id) else { return; }; @@ -845,8 +1146,8 @@ impl Drop for Presented { if slot.ended.is_some() || !slot.streamed { state.opening.retain(|id| *id != self.id); state.tokens.remove(&self.id); - state.recompute(); } + state.recompute(); } } @@ -878,7 +1179,7 @@ impl Watch { fn current(&self, state: &State) -> Option { match self.selector { Selector::Union => state.union.clone(), - Selector::Slot(id) => state.tokens.get(&id).and_then(|slot| slot.grant.clone()), + Selector::Slot(id) => state.granted.get(id).cloned(), } } @@ -912,7 +1213,7 @@ impl Watch { let state = ready_or!(self.state.poll(waiter, |state| { let current = match selector { Selector::Union => state.union.as_ref(), - Selector::Slot(id) => state.tokens.get(id).and_then(|slot| slot.grant.as_ref()), + Selector::Slot(id) => state.granted.get(*id), }; let ended = state.closed.is_some() || match selector { @@ -1115,6 +1416,16 @@ impl Serving { } } + /// The peer withdrew the token while requests still name it: its grant leaves the scope + /// for good, while the acceptor may still update or revoke it for those requests. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn withdraw(&self) { + let mut issue = self.issue.lock(); + if let Some((handle, id)) = issue.scoped.take() { + handle.issue(id, None); + } + } + /// Record why the stream ended on the peer's side; the first reason wins. Whatever it /// granted leaves the scope. #[cfg_attr(not(feature = "async"), allow(dead_code))] @@ -1619,6 +1930,8 @@ pub(crate) struct Gate { part: Part, /// Whether the scope allowed the path at the last permit. within_limit: bool, + /// Re-check on the next poll even without a change: the request was just named or renamed. + stale: bool, } /// Which part of a [`Permit`] a [`Gate`] watches. @@ -1630,6 +1943,8 @@ enum Part { Granted, /// The peer's grant, and the scope once the announce that stood in for it closes. Announced(kio::Consumer<()>), + /// The peer's grant, and the scope or the moq-lite token the request names. + Named(Authority, Verdict), } impl Gate { @@ -1643,6 +1958,7 @@ impl Gate { epoch: 0, part: Part::Both, within_limit, + stale: false, } } @@ -1653,6 +1969,39 @@ impl Gate { self } + /// Hold a moq-lite request to the peer's token `auth` names instead of the scope, and let + /// the peer [`rename`](Self::rename) it later. `None` is the scope. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn naming(mut self, auth: Option) -> Self { + let authority = Authority::new(self.handle.clone(), auth, &Changes::default()); + self.part = Part::Named(authority, Verdict::Waiting); + self.stale = true; + self + } + + /// Move a [`naming`](Self::naming) request to the peer's token `auth`, or the scope with + /// `None`, once that one decides. Until it does, the one it names now still decides it. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn rename(&mut self, auth: Option) { + if let Part::Named(authority, _) = &mut self.part { + authority.rename(auth); + self.stale = true; + } + } + + /// Resolve once the request may start, with whether it may: a request naming a token that + /// has not arrived, or is not answered yet, waits for it. + #[cfg_attr(not(feature = "async"), allow(dead_code))] + pub(crate) fn poll_admitted(&mut self, waiter: &kio::Waiter) -> Poll { + if self.poll_denied(waiter).is_ready() { + return Poll::Ready(false); + } + match &self.part { + Part::Named(_, Verdict::Waiting) => Poll::Pending, + _ => Poll::Ready(true), + } + } + /// A gate for a request under a namespace the peer announced, while `live` stays open: /// the announce stands in for the scope, as its own token may have authorized it. #[cfg_attr(not(feature = "async"), allow(dead_code))] @@ -1677,6 +2026,7 @@ impl Gate { Part::Both => self.within_limit, Part::Granted => true, Part::Announced(live) => self.within_limit || !live.is_closed(), + Part::Named(_, verdict) => matches!(verdict, Verdict::Admitted), } } @@ -1722,12 +2072,25 @@ impl Gate { /// allow everything. #[cfg_attr(not(feature = "async"), allow(dead_code))] pub(crate) fn poll_denied(&mut self, waiter: &kio::Waiter) -> Poll<()> { - while let Poll::Ready(permit) = self.handle.poll_permit(self.direction, &mut self.epoch, waiter) { + loop { + // A rename, or a change to the token the request names, re-checks it under the + // same permit. Not short-circuited, so the token's change always registers. + let named = std::mem::take(&mut self.stale) + | matches!(&self.part, Part::Named(authority, _) if authority.poll_changed(waiter).is_ready()); + let permit = match self.handle.poll_permit(self.direction, &mut self.epoch, waiter) { + Poll::Ready(permit) => permit, + Poll::Pending if named => self.handle.state.read().permit(self.direction), + Poll::Pending => break, + }; let path = self.path.as_str(); self.within_limit = permit.within_limit(path); - let allowed = match self.part { + let allowed = match &mut self.part { Part::Both => permit.matches(path), Part::Granted | Part::Announced(_) => permit.granted(path), + Part::Named(authority, verdict) => { + *verdict = authority.verdict(self.direction, path, self.within_limit); + permit.granted(path) && !matches!(verdict, Verdict::Denied) + } }; if !allowed { return Poll::Ready(()); @@ -1740,6 +2103,180 @@ impl Gate { } } +/// What the grant deciding a request allows it right now. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(not(feature = "async"), allow(dead_code))] +pub(crate) enum Verdict { + /// The token it names has not arrived, or is not answered yet. + Waiting, + Admitted, + Denied, +} + +/// A moq-lite request's claim on the peer's token it names, by the ID the peer's AUTH +/// carried. +/// +/// Once admitted it counts toward that token, so a token the peer withdraws stays up until +/// no request names it. Dropping it, or a denial, gives the count back. While it lives, a +/// change to that token lands in its [`Changes`]. +#[cfg_attr(not(feature = "async"), allow(dead_code))] +pub(crate) struct Naming { + handle: Handle, + id: u64, + changes: Changes, + counted: bool, +} + +/// What a [`Naming`] limits an announce request to, from [`Naming::limit`]. +#[cfg_attr(not(feature = "async"), allow(dead_code))] +pub(crate) enum Limit { + /// The token has not arrived, or is not answered yet. + Waiting, + /// The patterns the token's grant allows in the request's direction. + Patterns(Patterns), + /// The token is over, or withdrawn before the request counted toward it. + Ended, +} + +#[cfg_attr(not(feature = "async"), allow(dead_code))] +impl Naming { + /// Name the peer's token `id`, telling `changes` whenever it changes. + pub(crate) fn new(handle: Handle, id: u64, changes: &Changes) -> Self { + handle.peer.lock().watch(id, changes); + Self { + handle, + id, + changes: changes.clone(), + counted: false, + } + } + + /// Resolve once the token changed since the last call. + pub(crate) fn poll_changed(&self, waiter: &kio::Waiter) -> Poll<()> { + self.changes.poll(waiter).map(drop) + } + + /// The patterns the token allows in `direction`, counting the request toward it while it + /// is live. A withdrawn token admits only a request already counted. + pub(crate) fn limit(&mut self, direction: Direction) -> Limit { + let mut peer = self.handle.peer.lock(); + let limit = match peer.status(self.id) { + Status::Waiting => Limit::Waiting, + Status::Ended => Limit::Ended, + Status::Granted(_, true) if !self.counted => Limit::Ended, + // The grant names what the peer may do: what we send is what it may subscribe to. + Status::Granted(grant, _) => Limit::Patterns(match direction { + Direction::Publish => grant.subscribe.clone(), + Direction::Subscribe => grant.publish.clone(), + }), + }; + let count = matches!(limit, Limit::Patterns(_)); + if count != self.counted + && let Some(named) = peer.live.get_mut(&self.id) + { + named.count(count); + } + self.counted = count && peer.live.contains_key(&self.id); + limit + } + + /// Whether the token admits `direction` at `path` right now. + pub(crate) fn verdict(&mut self, direction: Direction, path: &str) -> Verdict { + match self.limit(direction) { + Limit::Waiting => Verdict::Waiting, + Limit::Patterns(patterns) if patterns.matches(path) => Verdict::Admitted, + Limit::Patterns(_) => { + self.release(); + Verdict::Denied + } + Limit::Ended => Verdict::Denied, + } + } + + /// Stop counting toward the token, which a withdrawn one may then end on. + fn release(&mut self) { + if std::mem::take(&mut self.counted) + && let Some(named) = self.handle.peer.lock().live.get_mut(&self.id) + { + named.count(false); + } + } +} + +impl Drop for Naming { + fn drop(&mut self) { + self.release(); + self.handle.peer.lock().unwatch(self.id, &self.changes); + } +} + +/// Which grant decides one of the peer's moq-lite requests: the session's scope, or the +/// token it names. A rename takes effect once the new one decides, so a request moving to a +/// token still in flight stays held to the old one meanwhile, never to nothing. +#[cfg_attr(not(feature = "async"), allow(dead_code))] +pub(crate) struct Authority { + handle: Handle, + /// Told when either token it names changes. + changes: Changes, + /// The token deciding it now, or `None` for the scope. + current: Option, + /// A rename waiting for its token to decide. + next: Option>, +} + +#[cfg_attr(not(feature = "async"), allow(dead_code))] +impl Authority { + /// Decide by the token `auth`, or the scope with `None`, telling `changes` when a token + /// it names changes. + pub(crate) fn new(handle: Handle, auth: Option, changes: &Changes) -> Self { + Self { + current: auth.map(|id| Naming::new(handle.clone(), id, changes)), + handle, + changes: changes.clone(), + next: None, + } + } + + /// Move to the token `auth`, or the scope with `None`, once it decides. + pub(crate) fn rename(&mut self, auth: Option) { + self.next = match auth == self.current.as_ref().map(|naming| naming.id) { + true => None, + false => Some(auth.map(|id| Naming::new(self.handle.clone(), id, &self.changes))), + }; + } + + /// The peer's tokens it names: the one deciding, and the one it is renamed to. + pub(crate) fn ids(&self) -> impl Iterator + '_ { + let next = self.next.as_ref().and_then(Option::as_ref); + self.current.iter().chain(next).map(|naming| naming.id) + } + + /// Resolve once a token it names changed since the last call. + pub(crate) fn poll_changed(&self, waiter: &kio::Waiter) -> Poll<()> { + self.changes.poll(waiter).map(drop) + } + + /// Whether the request may go on at `path`, given whether the session's scope allows it: + /// a pending rename applies once its token decides, and a refused one denies the request. + pub(crate) fn verdict(&mut self, direction: Direction, path: &str, scoped: bool) -> Verdict { + let scope = match scoped { + true => Verdict::Admitted, + false => Verdict::Denied, + }; + if let Some(next) = &mut self.next { + match next.as_mut().map_or(scope, |naming| naming.verdict(direction, path)) { + Verdict::Waiting => {} + Verdict::Admitted => self.current = self.next.take().expect("checked above"), + Verdict::Denied => return Verdict::Denied, + } + } + match &mut self.current { + Some(naming) => naming.verdict(direction, path), + None => scope, + } + } +} + /// The default acceptor's answer to the peer's connection credential: the grant /// the session's origin handles allow, within the limit as it changes. #[cfg_attr(not(feature = "async"), allow(dead_code))] @@ -2024,12 +2561,55 @@ mod tests { assert_eq!(Handle::ietf(false).token(&auth), None); } + /// A tally's union is what the grants it still holds allow, lapsing at the earliest + /// expiry among them, however they were replaced. + #[test] + fn a_tally_unions_what_it_holds() { + let soon = Instant::ZERO + std::time::Duration::from_secs(1); + let later = soon + std::time::Duration::from_secs(1); + let mut tally = Tally::default(); + tally.set(0, Some(grant(&["room"], &["room"]))); + tally.set(1, Some(grant(&["room/alice"], &["lobby"]))); + tally.set( + 2, + Some(Grant { + expires: Some(soon), + ..grant(&[], &["lobby"]) + }), + ); + assert_eq!( + tally.union(), + Grant { + expires: Some(soon), + ..grant(&["room"], &["room", "lobby"]) + } + ); + + tally.set(0, Some(grant(&[], &[]))); + tally.set( + 2, + Some(Grant { + expires: Some(later), + ..grant(&[], &[]) + }), + ); + assert_eq!( + tally.union(), + Grant { + expires: Some(later), + ..grant(&["room/alice"], &["lobby"]) + } + ); + tally.set(2, None); + assert_eq!(tally.union(), grant(&["room/alice"], &["lobby"])); + } + /// Every clone names one credential, which stays presented until the last drops. #[test] fn the_last_clone_withdraws_the_auth() { let handle = Handle::new(true); let auth = handle.present(Bytes::from_static(b"jwt"), false).unwrap(); - let Poll::Ready(Some((id, _))) = handle.poll_opening(&kio::Waiter::noop()) else { + let Poll::Ready(Some((id, _, _))) = handle.poll_opening(&kio::Waiter::noop()) else { panic!("nothing to open"); }; let clone = auth.clone(); @@ -2622,6 +3202,124 @@ mod request_token_tests { assert!(RequestKind::Subscribe.covers(&read, "room")); } + /// A grant letting the peer subscribe to `room`. + fn room() -> Grant { + Grant { + publish: Patterns::new(), + subscribe: crate::Pattern::subtree("room").unwrap().into(), + expires: None, + } + } + + /// The peer numbers its AUTH streams from 0 without a gap, so an ID it has not sent is in + /// flight, one that came and went is over, and a repeat is refused. + #[test] + fn a_named_token_is_in_flight_live_or_over() { + let handle = Handle::new(true); + let changes = Changes::default(); + let verdict = |id, path| Naming::new(handle.clone(), id, &changes).verdict(Direction::Publish, path); + + assert_eq!(verdict(1, "room"), Verdict::Waiting, "not arrived"); + handle.peer_arrived(1).unwrap(); + assert_eq!(verdict(1, "room"), Verdict::Waiting, "not answered"); + handle.peer_granted(1, &room()); + assert_eq!(verdict(1, "room"), Verdict::Admitted); + assert_eq!(verdict(1, "other"), Verdict::Denied, "outside its own grant"); + assert!(handle.peer_arrived(1).is_err(), "a repeated ID"); + + handle.peer_ended(1); + assert_eq!(verdict(1, "room"), Verdict::Denied, "over"); + assert_eq!( + verdict(0, "room"), + Verdict::Waiting, + "still in flight below the ended one" + ); + handle.peer_arrived(0).unwrap(); + handle.peer_ended(0); + assert!(handle.peer_arrived(0).is_err(), "below the watermark"); + } + + /// A token the peer withdraws keeps deciding the requests already naming it, takes no + /// new one, and ends once the last moves off. + #[test] + fn a_withdrawn_token_lingers_while_named() { + let handle = Handle::new(true); + handle.peer_arrived(0).unwrap(); + handle.peer_granted(0, &room()); + let changes = Changes::default(); + let mut named = Naming::new(handle.clone(), 0, &changes); + assert_eq!(named.verdict(Direction::Publish, "room"), Verdict::Admitted); + + handle.peer_withdrawn(0); + assert_eq!(named.verdict(Direction::Publish, "room"), Verdict::Admitted); + assert_eq!( + Naming::new(handle.clone(), 0, &changes).verdict(Direction::Publish, "room"), + Verdict::Denied, + "no request newly names a withdrawn token" + ); + let waiter = kio::Waiter::noop(); + assert!(handle.poll_peer_unnamed(0, &waiter).is_pending()); + drop(named); + assert!(handle.poll_peer_unnamed(0, &waiter).is_ready()); + } + + /// A request moving to a token still in flight stays held to the one it names now, even + /// once that one is withdrawn, so a renewal never leaves a gap. + #[test] + fn a_rename_applies_once_its_token_decides() { + let handle = Handle::new(true); + handle.peer_arrived(0).unwrap(); + handle.peer_granted(0, &room()); + let mut authority = Authority::new(handle.clone(), Some(0), &Changes::default()); + assert_eq!(authority.verdict(Direction::Publish, "room", false), Verdict::Admitted); + + authority.rename(Some(1)); + handle.peer_withdrawn(0); + assert_eq!(authority.verdict(Direction::Publish, "room", false), Verdict::Admitted); + + handle.peer_arrived(1).unwrap(); + handle.peer_granted(1, &room()); + assert_eq!(authority.verdict(Direction::Publish, "room", false), Verdict::Admitted); + let waiter = kio::Waiter::noop(); + assert!( + handle.poll_peer_unnamed(0, &waiter).is_ready(), + "moving off releases the old token" + ); + + authority.rename(None); + assert_eq!( + authority.verdict(Direction::Publish, "room", false), + Verdict::Denied, + "the scope does not cover it" + ); + } + + /// A change to one of the peer's tokens tells only what names it, by ID, so a request + /// naming another token never wakes. + #[test] + fn a_token_change_tells_only_what_names_it() { + let handle = Handle::new(true); + let waiter = kio::Waiter::noop(); + let (zero, one) = (Changes::default(), Changes::default()); + let named = [ + Naming::new(handle.clone(), 0, &zero), + Naming::new(handle.clone(), 1, &one), + ]; + + handle.peer_arrived(0).unwrap(); + handle.peer_granted(0, &room()); + assert_eq!(zero.poll(&waiter), Poll::Ready(BTreeSet::from([0]))); + assert!(one.poll(&waiter).is_pending(), "token 1 did not change"); + + drop(named); + handle.peer_ended(0); + assert!(zero.poll(&waiter).is_pending(), "nothing names token 0 any more"); + assert!( + handle.peer.read().watchers.is_empty(), + "a dropped naming stops watching" + ); + } + /// An acceptor-side revoke ends the request before the deadline, and does not close the /// session. #[moq_clock::test] diff --git a/rs/moq-net/src/fuzz.rs b/rs/moq-net/src/fuzz.rs index be45262c4c6..b1a8f2ba1d7 100644 --- a/rs/moq-net/src/fuzz.rs +++ b/rs/moq-net/src/fuzz.rs @@ -246,12 +246,14 @@ impl AnnounceWriter { suffix, hops, cost: Default::default(), + auth: None, } } Announced::Update(id, hops) => lite::AnnounceBroadcast::Update { id: *id, hops: self.encoder.update(*id, hops.clone()), cost: Default::default(), + auth: None, }, Announced::End(id) => { self.encoder.end(*id); @@ -424,6 +426,7 @@ impl LiteSample { end_group: None, start_frame: 0, end_frame: None, + auth: None, } .encode(w, version) .unwrap(), @@ -609,6 +612,7 @@ impl Default for Messages { end_group: None, start_frame: 0, end_frame: None, + auth: None, }, lite_update: lite::SubscribeUpdate { order: crate::track::Order::Descending, @@ -618,6 +622,7 @@ impl Default for Messages { end_group: Some(2_000), start_frame: 0, end_frame: None, + auth: None, }, lite_start: lite::SubscribeResponse::Start(lite::SubscribeStart { group: 1_234, diff --git a/rs/moq-net/src/ietf/auth.rs b/rs/moq-net/src/ietf/auth.rs index 0cceeaed4bd..e5fe7706bd4 100644 --- a/rs/moq-net/src/ietf/auth.rs +++ b/rs/moq-net/src/ietf/auth.rs @@ -254,7 +254,8 @@ where let mut tasks = crate::util::TaskSet::owned(); let violation: kio::Shared> = Default::default(); - while let Some((id, token)) = tasks + // moq-transport numbers each token by its Request ID instead. + while let Some((id, _, token)) = tasks .drive(|waiter| { if let Poll::Ready(mut err) = violation.poll(waiter, |err| match err.is_some() { true => Poll::Ready(()), diff --git a/rs/moq-net/src/lite/announce.rs b/rs/moq-net/src/lite/announce.rs index adb489dcd43..56537a3d0e6 100644 --- a/rs/moq-net/src/lite/announce.rs +++ b/rs/moq-net/src/lite/announce.rs @@ -53,6 +53,9 @@ pub enum AnnounceBroadcast<'a> { epoch: Option, hops: HopsRef, cost: Cost, + /// The ID of the publisher's AUTH that admits the route, or `None` for the + /// session's scope. Lite07+ only. + auth: Option, }, /// Pre-lite-06: a broadcast is no longer available, retracted by path. Ended { suffix: Path<'a>, hops: Hops }, @@ -61,8 +64,13 @@ pub enum AnnounceBroadcast<'a> { EndedId { id: u64 }, /// ANNOUNCE_UPDATE (lite-06+): atomically replace the metadata of the announcement /// with this id (e.g. a new hop chain after a relay failover, or a route whose cost - /// moved). The id stays live. - Update { id: u64, hops: HopsRef, cost: Cost }, + /// moved, or a renewed auth). The id stays live. + Update { + id: u64, + hops: HopsRef, + cost: Cost, + auth: Option, + }, /// ANNOUNCE_RESTART (lite-07+): another publisher instance replaces the announcement /// with this id, under the epoch given (or none). Whatever the receiver resolved /// under its prefix is the old instance. The id stays live. @@ -71,6 +79,7 @@ pub enum AnnounceBroadcast<'a> { epoch: Option, hops: HopsRef, cost: Cost, + auth: Option, }, /// An unknown lite-06+ announce type. The length-prefixed body was skipped so /// the stream stays up; it does not assign an announce id. @@ -186,6 +195,7 @@ impl AnnounceBroadcast<'_> { epoch, hops, cost, + auth, } => AnnounceBroadcast::Active { suffix: PathRef { base: suffix.base, @@ -195,14 +205,27 @@ impl AnnounceBroadcast<'_> { epoch, hops, cost, + auth, }, Self::Ended { suffix, hops } => AnnounceBroadcast::Ended { suffix: suffix.into_owned(), hops, }, Self::EndedId { id } => AnnounceBroadcast::EndedId { id }, - Self::Update { id, hops, cost } => AnnounceBroadcast::Update { id, hops, cost }, - Self::Restart { id, epoch, hops, cost } => AnnounceBroadcast::Restart { id, epoch, hops, cost }, + Self::Update { id, hops, cost, auth } => AnnounceBroadcast::Update { id, hops, cost, auth }, + Self::Restart { + id, + epoch, + hops, + cost, + auth, + } => AnnounceBroadcast::Restart { + id, + epoch, + hops, + cost, + auth, + }, Self::Skipped => AnnounceBroadcast::Skipped, } } @@ -264,23 +287,33 @@ impl Encode for AnnounceBroadcast<'_> { epoch, hops, cost, + auth, } => { suffix.encode(w, version)?; super::epoch::encode_epoch(w, version, epoch.as_ref())?; hops.encode(w, version)?; cost.encode(w, version)?; + super::encode_auth(w, version, *auth)?; } Self::EndedId { id } => w.varint(*id)?, - Self::Update { id, hops, cost } => { + Self::Update { id, hops, cost, auth } => { w.varint(*id)?; hops.encode(w, version)?; cost.encode(w, version)?; + super::encode_auth(w, version, *auth)?; } - Self::Restart { id, epoch, hops, cost } => { + Self::Restart { + id, + epoch, + hops, + cost, + auth, + } => { w.varint(*id)?; super::epoch::encode_epoch(w, version, epoch.as_ref())?; hops.encode(w, version)?; cost.encode(w, version)?; + super::encode_auth(w, version, *auth)?; } Self::Ended { .. } | Self::Skipped => unreachable!("refused above"), } @@ -296,9 +329,9 @@ impl Encode for AnnounceBroadcast<'_> { let prefix = w.prefix_varint(); match self { // The cost is a lite-06 addition, so it is simply not on the wire here. - Self::Active { suffix, hops, .. } => { - // Bases are a lite-07 addition; PathRef and HopsRef refuse one here. - if suffix.base != 0 || hops.base != 0 { + Self::Active { suffix, hops, auth, .. } => { + // Bases and auths are lite-07 additions; PathRef and HopsRef refuse a base here. + if suffix.base != 0 || hops.base != 0 || auth.is_some() { return Err(EncodeError::Version); } AnnounceStatus::Active.encode(w, version)?; @@ -336,18 +369,21 @@ impl Decode for AnnounceBroadcast<'_> { epoch: super::epoch::decode_epoch(&mut body, version)?, hops: HopsRef::decode(&mut body, version)?, cost: Cost::decode(&mut body, version)?, + auth: super::decode_auth(&mut body, version)?, }, ANNOUNCE_END => Self::EndedId { id: body.varint()? }, ANNOUNCE_UPDATE => Self::Update { id: body.varint()?, hops: HopsRef::decode(&mut body, version)?, cost: Cost::decode(&mut body, version)?, + auth: super::decode_auth(&mut body, version)?, }, ANNOUNCE_RESTART if version.has_announce_restart() => Self::Restart { id: body.varint()?, epoch: super::epoch::decode_epoch(&mut body, version)?, hops: HopsRef::decode(&mut body, version)?, cost: Cost::decode(&mut body, version)?, + auth: super::decode_auth(&mut body, version)?, }, // Unknown types are skipped by length so an earlier Lite06 build // negotiating the same ALPN does not kill the announce stream. @@ -400,6 +436,7 @@ impl AnnounceBroadcast<'_> { epoch: None, hops: HopsRef::literal(hops), cost: Cost::UNKNOWN, + auth: None, }, AnnounceStatus::Ended => Self::Ended { suffix, hops }, // On lite-05 an update travels as a duplicate ANNOUNCE (a second `Active`), so accept @@ -412,6 +449,7 @@ impl AnnounceBroadcast<'_> { epoch: None, hops: HopsRef::literal(hops), cost: Cost::UNKNOWN, + auth: None, }, AnnounceStatus::Update => return Err(DecodeError::InvalidValue), }) @@ -444,6 +482,8 @@ pub struct AnnounceRequest<'a> { // Not on the wire earlier, so the value set here is ignored when encoding for an // older version and decodes as false. pub hidden: bool, + /// See [`Subscribe::auth`](super::Subscribe::auth). + pub auth: Option, } impl Message for AnnounceRequest<'_> { @@ -457,10 +497,12 @@ impl Message for AnnounceRequest<'_> { true => r.bool()?, false => false, }; + let auth = super::decode_auth(r, version)?; Ok(Self { prefix, exclude_hop, hidden, + auth, }) } @@ -472,6 +514,7 @@ impl Message for AnnounceRequest<'_> { if version.has_hidden() { w.bool(self.hidden); } + super::encode_auth(w, version, self.auth)?; Ok(()) } @@ -602,6 +645,7 @@ mod tests { suffix: PathRef::literal(Path::new("foo/bar")), hops: HopsRef::default(), cost: Cost::default(), + auth: None, } .encode(&mut Encoder::new(&mut buf, version.into()), version) .expect("encode"); @@ -693,6 +737,7 @@ mod tests { suffix: PathRef::literal(Path::new("room/cam")), hops: HopsRef::literal(hops.clone()), cost: Cost::UNKNOWN, + auth: None, }; assert_eq!(broadcast_round_trip(&msg, Version::Lite05), msg); @@ -716,6 +761,7 @@ mod tests { suffix: PathRef::literal(Path::new("room/cam")), hops: HopsRef::literal(hops.clone()), cost, + auth: None, }; assert_eq!(broadcast_round_trip(&active, Version::Lite06), active); @@ -726,6 +772,7 @@ mod tests { id: 3, hops: HopsRef::literal(hops), cost, + auth: None, }; assert_eq!(broadcast_round_trip(&update, Version::Lite06), update); } @@ -750,6 +797,7 @@ mod tests { keep: 2, }, cost, + auth: None, }; assert_eq!(broadcast_round_trip(&active, Version::Lite07), active); @@ -761,6 +809,7 @@ mod tests { keep: 1, }, cost, + auth: None, }; assert_eq!(broadcast_round_trip(&update, Version::Lite07), update); @@ -774,6 +823,7 @@ mod tests { keep: 1, }, cost, + auth: None, }; assert_eq!(broadcast_round_trip(&restart, Version::Lite07), restart); } @@ -788,6 +838,7 @@ mod tests { epoch: None, hops: HopsRef::default(), cost: Cost::default(), + auth: None, }; let mut buf = Vec::new(); assert!(matches!( @@ -838,6 +889,7 @@ mod tests { }, hops: HopsRef::default(), cost: Cost::default(), + auth: None, }; for version in [Version::Lite05, Version::Lite06] { let mut buf = Vec::new(); @@ -861,7 +913,8 @@ mod tests { AnnounceBroadcast::Update { id: 1, hops: HopsRef::default(), - cost: Cost::default() + cost: Cost::default(), + auth: None, } .encode(&mut Encoder::new(&mut buf, Version::Lite05.into()), Version::Lite05), Err(EncodeError::Version) @@ -887,6 +940,7 @@ mod tests { suffix: PathRef::literal(Path::new("room/cam")), hops: HopsRef::default(), cost: Cost::new(9), + auth: None, }; let got = broadcast_round_trip(&msg, Version::Lite05); assert_eq!( @@ -896,6 +950,7 @@ mod tests { suffix: PathRef::literal(Path::new("room/cam")), hops: HopsRef::default(), cost: Cost::UNKNOWN, + auth: None, } ); } @@ -961,9 +1016,68 @@ mod tests { prefix: got.prefix.to_owned(), exclude_hop: got.exclude_hop, hidden: got.hidden, + auth: got.auth, + } + } + + // Lite07 names the requester's auth; every earlier version names none and has no field. + #[test] + fn announce_request_carries_auth_from_lite07() { + for auth in [None, Some(0), Some(7)] { + let msg = AnnounceRequest { + prefix: Path::new("room"), + exclude_hop: 0, + hidden: false, + auth, + }; + assert_eq!(request_round_trip(&msg, Version::Lite07).auth, auth); } } + // Every message that advertises a route names the auth that admits it. + #[test] + fn announcements_carry_auth_on_lite07() { + for auth in [None, Some(0), Some(3)] { + let active = AnnounceBroadcast::Active { + epoch: None, + suffix: PathRef::literal(Path::new("room/cam")), + hops: HopsRef::default(), + cost: Cost::default(), + auth, + }; + assert_eq!(broadcast_round_trip(&active, Version::Lite07), active); + let update = AnnounceBroadcast::Update { + id: 0, + hops: HopsRef::default(), + cost: Cost::default(), + auth, + }; + assert_eq!(broadcast_round_trip(&update, Version::Lite07), update); + let restart = AnnounceBroadcast::Restart { + id: 0, + epoch: None, + hops: HopsRef::default(), + cost: Cost::default(), + auth, + }; + assert_eq!(broadcast_round_trip(&restart, Version::Lite07), restart); + } + + // lite-06 has no field, so a named route cannot be sent there. + let named = AnnounceBroadcast::Active { + epoch: None, + suffix: PathRef::literal(Path::new("room/cam")), + hops: HopsRef::default(), + cost: Cost::default(), + auth: Some(0), + }; + let mut buf = Vec::new(); + assert!(matches!( + named.encode(&mut Encoder::new(&mut buf, Version::Lite06.into()), Version::Lite06), + Err(EncodeError::Version) + )); + } + // Lite07 carries the hidden opt-in; every earlier version decodes as not opted in. #[test] fn announce_request_carries_hidden_from_lite07() { @@ -972,6 +1086,7 @@ mod tests { prefix: Path::new("room/"), exclude_hop: 0, hidden, + auth: None, }; assert_eq!(request_round_trip(&msg, Version::Lite07).hidden, hidden); assert!(!request_round_trip(&msg, Version::Lite06).hidden); @@ -1002,6 +1117,7 @@ mod tests { prefix: Path::new("room/"), exclude_hop: 42, hidden: false, + auth: None, }; assert_eq!(request_round_trip(&msg, Version::Lite05).exclude_hop, 42); } @@ -1014,6 +1130,7 @@ mod tests { prefix: Path::new("room/"), exclude_hop: 42, hidden: false, + auth: None, }; assert_eq!(request_round_trip(&msg, Version::Lite06).exclude_hop, 0); diff --git a/rs/moq-net/src/lite/auth.rs b/rs/moq-net/src/lite/auth.rs index 1a072b24ff9..4fdbed56d35 100644 --- a/rs/moq-net/src/lite/auth.rs +++ b/rs/moq-net/src/lite/auth.rs @@ -13,6 +13,9 @@ use super::{Message, Version}; /// URL, a client certificate), or nothing. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Auth { + /// The opener's number for this token, which its requests name: 0 for its first + /// Auth Stream, one more for each after. Never `u64::MAX`, which no request can name. + pub id: u64, pub token: Bytes, } @@ -21,7 +24,12 @@ impl Message for Auth { if !version.has_auth() { return Err(DecodeError::Version); } + let id = r.varint()?; + if id == u64::MAX { + return Err(DecodeError::InvalidValue); + } Ok(Self { + id, token: Bytes::copy_from_slice(r.bytes()?), }) } @@ -30,10 +38,33 @@ impl Message for Auth { if !version.has_auth() { return Err(EncodeError::Version); } + if self.id == u64::MAX { + return Err(EncodeError::TooLarge); + } + w.varint(self.id)?; w.bytes(&self.token) } } +/// Decode a request's `Auth` field: the ID of the peer's AUTH that admits it, or `None` +/// for the session's scope. Versions before lite-07 carry no field and name nothing. +pub(super) fn decode_auth(r: &mut Decoder<'_>, version: Version) -> Result, DecodeError> { + match version.has_auth() { + true => r.varint_opt(), + false => Ok(None), + } +} + +/// Encode a request's `Auth` field, the inverse of [`decode_auth`]. A version without the +/// field has no AUTH stream to name. +pub(super) fn encode_auth(w: &mut Encoder<'_>, version: Version, auth: Option) -> Result<(), EncodeError> { + match version.has_auth() { + true => w.varint_opt(auth), + false if auth.is_some() => Err(EncodeError::Version), + false => Ok(()), + } +} + /// The grant a token earns, as the acceptor writes it on the Auth Stream. #[derive(Clone, Debug, PartialEq, Eq)] pub struct AuthOk { @@ -207,12 +238,71 @@ mod tests { #[test] fn auth_round_trips() { - for token in [Bytes::new(), Bytes::from_static(b"eyJhbGciOi.jwt")] { - let msg = Auth { token }; + for (id, token) in [(0, Bytes::new()), (u64::MAX - 1, Bytes::from_static(b"eyJhbGciOi.jwt"))] { + let msg = Auth { id, token }; assert_eq!(round_trip(&msg), msg); } } + /// The exact bytes, shared with `js/net/src/lite/auth.test.ts` so both encoders agree. + #[test] + fn auth_golden() { + let msg = Auth { + id: 1, + token: Bytes::from_static(b"jwt"), + }; + let buf = encode(&msg, Version::Lite07).unwrap(); + assert_eq!(&buf[..], &[0x05, 0x01, 0x03, b'j', b'w', b't']); + } + + /// No request can name `u64::MAX` as its ID plus one, so neither side puts it on the wire. + #[test] + fn the_largest_id_is_refused() { + let msg = Auth { + id: u64::MAX, + token: Bytes::new(), + }; + assert!(matches!(encode(&msg, Version::Lite07), Err(EncodeError::TooLarge))); + + let mut buf = Vec::new(); + let mut w = Encoder::new(&mut buf, Version::Lite07.into()); + let prefix = w.prefix_varint(); + w.varint(u64::MAX).unwrap(); + w.bytes(b"").unwrap(); + w.fill(prefix).unwrap(); + assert!(matches!( + Auth::decode(&mut Decoder::new(&buf, Version::Lite07.into()), Version::Lite07), + Err(DecodeError::InvalidValue) + )); + } + + /// A request names an auth as its ID plus one, so 0 is the session's scope. + #[test] + fn request_auth_round_trips() { + for auth in [None, Some(0), Some(u64::MAX - 1)] { + let mut buf = Vec::new(); + encode_auth( + &mut Encoder::new(&mut buf, Version::Lite07.into()), + Version::Lite07, + auth, + ) + .unwrap(); + let mut r = Decoder::new(&buf, Version::Lite07.into()); + assert_eq!(decode_auth(&mut r, Version::Lite07).unwrap(), auth); + assert!(r.is_empty()); + } + + // An older version has no field: nothing is written, and naming an auth fails. + let mut buf = Vec::new(); + let mut w = Encoder::new(&mut buf, Version::Lite06.into()); + encode_auth(&mut w, Version::Lite06, None).unwrap(); + assert!(matches!( + encode_auth(&mut w, Version::Lite06, Some(0)), + Err(EncodeError::Version) + )); + assert!(buf.is_empty()); + } + /// `**` grants everything, the empty pattern only the root, and the empty list /// nothing; literals and wildcards travel exactly, never widened to a prefix. #[test] @@ -319,6 +409,7 @@ mod tests { #[test] fn oversized_message_is_refused() { let msg = Auth { + id: 0, token: Bytes::from(vec![0; super::super::message::MAX_MESSAGE_SIZE + 1]), }; assert!(matches!(encode(&msg, Version::Lite07), Err(EncodeError::TooLarge))); @@ -335,7 +426,13 @@ mod tests { Version::Lite06, ] { assert!(matches!( - encode(&Auth { token: Bytes::new() }, version), + encode( + &Auth { + id: 0, + token: Bytes::new() + }, + version + ), Err(EncodeError::Version) )); assert!(matches!( diff --git a/rs/moq-net/src/lite/compress.rs b/rs/moq-net/src/lite/compress.rs index f640660c320..c44cfaaeda8 100644 --- a/rs/moq-net/src/lite/compress.rs +++ b/rs/moq-net/src/lite/compress.rs @@ -622,7 +622,7 @@ mod tests { /// A compressed stream pinned byte for byte: `js/net/src/lite/announce.test.ts` /// decodes the same hex, so a codec change on either side breaks both. - const GOLDEN: &str = "001600000a726f6f6d2f612f63616d0000029111a2220000000d0102036d6963000101b33301000208000101c044440100010101000c02010162000201c055550100"; + const GOLDEN: &str = "001700000a726f6f6d2f612f63616d0000029111a222000000000e0102036d6963000101b3330100000209000101c04444010000010101000d02010162000201c05555010000"; #[cfg(feature = "async")] fn golden() -> Vec { @@ -647,7 +647,7 @@ mod tests { /// The literal lite-07 stream `js/net` writes for the same announcements, which /// never picks a base. - const JS_LITERAL: &str = "001600000a726f6f6d2f612f63616d0000029111a2220000001600000a726f6f6d2f612f6d6963000002b333a2220000020a000002c04444a22200000101010013000006726f6f6d2f62000002c05555a2220000"; + const JS_LITERAL: &str = "001700000a726f6f6d2f612f63616d0000029111a222000000001700000a726f6f6d2f612f6d6963000002b333a222000000020b000002c04444a2220000000101010014000006726f6f6d2f62000002c05555a222000000"; #[test] #[cfg(feature = "async")] diff --git a/rs/moq-net/src/lite/fetch.rs b/rs/moq-net/src/lite/fetch.rs index 6472ed3108b..f2099861dff 100644 --- a/rs/moq-net/src/lite/fetch.rs +++ b/rs/moq-net/src/lite/fetch.rs @@ -26,6 +26,8 @@ pub struct Fetch<'a> { /// Index of the last frame to return (inclusive), or `None` for through the end of /// the group. Lite06+ only. pub end_frame: Option, + /// See [`Subscribe::auth`](super::Subscribe::auth). + pub auth: Option, } impl Message for Fetch<'_> { @@ -55,6 +57,7 @@ impl Message for Fetch<'_> { if end_frame.is_some_and(|end| end < start_frame) { return Err(DecodeError::InvalidSubscribeLocation); } + let auth = super::decode_auth(r, version)?; Ok(Self { broadcast, @@ -65,6 +68,7 @@ impl Message for Fetch<'_> { group, start_frame, end_frame, + auth, }) } @@ -99,6 +103,7 @@ impl Message for Fetch<'_> { // The peer would serve the whole group, including frames the caller excluded. return Err(EncodeError::Version); } + super::encode_auth(w, version, self.auth)?; Ok(()) } @@ -118,6 +123,7 @@ mod test { group: 7, start_frame: 0, end_frame: None, + auth: None, } } diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index ae8a434c221..15c2706d752 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -410,6 +410,10 @@ struct AuthServe { /// The default acceptor's grant for the connection's credential, re-sent as a /// new limit changes it. `None` when the app answers. default: Option, + /// The ID the peer's AUTH carried, which its requests name the token by. + id: Option, + /// The presenter finished its side: the token ends once none of its requests names it. + withdrawn: bool, /// Our side is finished: FIN sent, waiting for the acknowledgement. finished: bool, unsupported: bool, @@ -426,6 +430,8 @@ impl AuthServe { stream: Some(stream), serving: None, default: None, + id: None, + withdrawn: false, finished: false, unsupported: false, }) @@ -457,6 +463,9 @@ impl AuthServe { if let Some(serving) = &self.serving { serving.end(err.clone()); } + if let Some(id) = self.id { + self.shared.auth.peer_ended(id); + } if matches!(err, Error::ProtocolViolation) { self.shared.auth.close(err.clone()); return Poll::Ready(Err(err)); @@ -472,6 +481,9 @@ impl AuthServe { Some(serving) => serving.issue.clone(), None => { let msg = ready!(stream.reader.poll_decode::(&mut cx))?; + // A reused ID would make the peer's requests ambiguous. + self.shared.auth.peer_arrived(msg.id)?; + self.id = Some(msg.id); let serving = crate::auth::Serving::new(self.shared.auth.clone()); let issue = serving.issue.clone(); match self.shared.auth.acceptor() { @@ -509,20 +521,42 @@ impl AuthServe { // The presenter withdrew the token, by FIN or by a cancelling reset, or its // stream died. - if let Poll::Ready(res) = stream.reader.poll_read_chunk(&mut cx, 1).map(|res| { - res.and_then(|chunk| match chunk { - None => Ok(()), - Some(_) => Err(Error::ProtocolViolation), - }) - }) { + if !self.withdrawn + && let Poll::Ready(res) = stream.reader.poll_read_chunk(&mut cx, 1).map(|res| { + res.and_then(|chunk| match chunk { + None => Ok(()), + Some(_) => Err(Error::ProtocolViolation), + }) + }) { match res { - Ok(()) | Err(Error::Stream(crate::StreamError::Cancel)) => {} + // A FIN withdraws it gracefully: it leaves the scope and no request may newly + // name it, but what names it now keeps it until it moves off or ends, so a + // rename racing the withdrawal across streams still lands. + Ok(()) => { + self.withdrawn = true; + self.serving.as_ref().expect("serving").withdraw(); + if let Some(id) = self.id { + self.shared.auth.peer_withdrawn(id); + } + continue; + } + Err(Error::Stream(crate::StreamError::Cancel)) => {} Err(err) => return Poll::Ready(Err(err)), } // That is why the token ended, whether or not our own FIN reaches a // presenter that left. issue.lock().peer.get_or_insert(Error::Cancel); - stream.writer.finish()?; + Self::finish(&self.shared, self.id, stream)?; + self.finished = true; + continue; + } + + if self.withdrawn + && let Some(id) = self.id + && self.shared.auth.poll_peer_unnamed(id, waiter).is_ready() + { + issue.lock().peer.get_or_insert(Error::Cancel); + Self::finish(&self.shared, self.id, stream)?; self.finished = true; continue; } @@ -543,7 +577,7 @@ impl AuthServe { match state.outbox.pop_front() { Some(reply) => { drop(state); - if !Self::write(&self.shared, stream, reply)? { + if !Self::write(&self.shared, self.id, stream, reply)? { self.unsupported = true; let mut state = issue.lock(); state.outbox.clear(); @@ -553,14 +587,32 @@ impl AuthServe { // The app is done with the grant, or refused the token: close our side. None => { drop(state); - stream.writer.finish()?; + Self::finish(&self.shared, self.id, stream)?; self.finished = true; } } } } - fn write(shared: &Shared, stream: &mut Stream, reply: crate::auth::Reply) -> Result { + /// Close our side, which ends the token for every request naming it. + fn finish(shared: &Shared, id: Option, stream: &mut Stream) -> Result<(), Error> { + if let Some(id) = id { + shared.auth.peer_ended(id); + } + stream.writer.finish() + } + + fn write( + shared: &Shared, + id: Option, + stream: &mut Stream, + reply: crate::auth::Reply, + ) -> Result { + // The latest grant sent decides every request that names the token. + let named = match (id, &reply) { + (Some(id), crate::auth::Reply::Grant(grant)) => Some((id, grant.clone())), + _ => None, + }; let msg = match reply { crate::auth::Reply::Grant(grant) => { let now = shared.runtime.now(); @@ -584,6 +636,9 @@ impl AuthServe { }))?; return Ok(false); } + if let Some((id, grant)) = named { + shared.auth.peer_granted(id, &grant); + } Ok(true) } } @@ -838,7 +893,11 @@ enum AnnounceState { Decode, /// Waiting on the peer's SETUP for the session-wide excluded origin /// (lite-05, whose wire carries no per-stream exclude_hop). - ExcludeHop { prefix: crate::PathOwned, hidden: bool }, + ExcludeHop { + prefix: crate::PathOwned, + hidden: bool, + auth: Option, + }, /// Streaming announce updates. Run { origin: origin::Consumer, @@ -866,6 +925,7 @@ impl AnnounceServe { let interest = ready!(stream.reader.poll_decode::(&mut cx))?; let prefix = interest.prefix.to_owned(); let hidden = interest.hidden; + let auth = interest.auth; // The identity whose routes we filter out. Lite-04/05 carry it per // announce stream; lite-06+ reads the session-wide SETUP Hop @@ -877,20 +937,20 @@ impl AnnounceServe { 0 => assigned, id => id, }; - self.start(prefix, exclude_hop, hidden); + self.start(prefix, exclude_hop, hidden, auth); } else if self.shared.version.has_setup_stream() { - self.state = AnnounceState::ExcludeHop { prefix, hidden }; + self.state = AnnounceState::ExcludeHop { prefix, hidden, auth }; } else { - self.start(prefix, assigned, hidden); + self.start(prefix, assigned, hidden, auth); } } - AnnounceState::ExcludeHop { prefix, hidden } => { + AnnounceState::ExcludeHop { prefix, hidden, auth } => { let assigned = self.shared.peer_hop.map(|origin| origin.id()).unwrap_or(0); let exclude_hop = ready!(self.shared.peer_setup.poll_hop(waiter)) .map(|origin| origin.id()) .unwrap_or(assigned); - let (prefix, hidden) = (prefix.clone(), *hidden); - self.start(prefix, exclude_hop, hidden); + let (prefix, hidden, auth) = (prefix.clone(), *hidden, *auth); + self.start(prefix, exclude_hop, hidden, auth); } AnnounceState::Run { origin, announced, run } => { let stream = self.stream.as_mut().expect("stream present"); @@ -919,7 +979,7 @@ impl AnnounceServe { } } - fn start(&mut self, prefix: crate::PathOwned, exclude_hop: u64, hidden: bool) { + fn start(&mut self, prefix: crate::PathOwned, exclude_hop: u64, hidden: bool, auth: Option) { // If the requested prefix is outside our scope (an empty origin, or a token // that doesn't grant it), we simply have nothing to announce. Respond with an // empty set and keep the stream open (the subscriber treats a FIN here as a @@ -954,6 +1014,8 @@ impl AnnounceServe { run.prefix.clone(), crate::auth::Direction::Publish, )); + run.named = auth.map(|id| crate::auth::Naming::new(self.shared.auth.clone(), id, &Default::default())); + run.presenter = Some(self.shared.auth.clone()); self.state = AnnounceState::Run { origin, announced, run }; } } @@ -978,7 +1040,13 @@ struct AnnounceRun { // Our grant and the ceiling on the peer: only what both let us publish is // announced, and a shrink withdraws what they no longer cover. auth: Option, + /// The peer's token the request names, which stands in for the scope. + named: Option, + /// Our tokens, which give the ID a route's own token goes out under. + presenter: Option, epoch: u64, + // What the session allows, before the named token narrows it. + session: crate::auth::Permit, // What we may announce right now. permit: crate::auth::Permit, } @@ -987,10 +1055,20 @@ struct AnnounceRun { struct Advertised { /// The announce id, on versions that assign them. id: Option, - /// The chain and cost last put on the wire. The origin also reports changes the - /// wire cannot carry (the route's source, servability), which must not restart. + /// What was last put on the wire. The origin also reports changes the wire cannot + /// carry (the route's source, servability), which must not restart. + wire: Outgoing, +} + +/// The parts of a route the wire carries. +#[derive(Clone, PartialEq)] +struct Outgoing { hops: Hops, cost: crate::origin::Cost, + /// The ID of our token the route names (lite-07+), or `None` for the session's scope. + auth: Option, + /// The token `auth` names, so the advertisement is retracted once it ends. + credential: Option, } enum AnnouncePhase { @@ -1012,11 +1090,62 @@ impl AnnounceRun { live: HashMap::new(), phase: AnnouncePhase::Init, auth: None, + named: None, + presenter: None, epoch: 0, + session: Default::default(), permit: Default::default(), } } + /// Retract every advertisement named by a token of ours that ended: it ends with that + /// token until the route names another. + fn retract_ended( + &mut self, + stream: &mut Stream, + origin: &origin::Consumer, + ) -> Result<(), Error> { + let Some(presenter) = &self.presenter else { + return Ok(()); + }; + let ended: Vec<_> = self + .live + .iter() + .filter(|(_, advertised)| { + let credential = advertised.wire.credential.as_ref(); + credential.is_some_and(|auth| presenter.wire(auth).is_err()) + }) + .map(|(suffix, _)| suffix.clone()) + .collect(); + for suffix in ended { + let absolute = origin.absolute(&suffix); + tracing::debug!(route = %absolute, "announce token ended"); + self.retract(stream, suffix, &absolute)?; + } + Ok(()) + } + + /// Resolve once the token the request names changed. + fn poll_named(&self, waiter: &kio::Waiter) -> Poll<()> { + match &self.named { + Some(named) => named.poll_changed(waiter), + None => Poll::Pending, + } + } + + /// What we may announce under the session's `permit`, narrowed to the token the request + /// names: `None` while that token is in flight or unanswered. + fn narrow(&mut self, permit: crate::auth::Permit) -> Result, Error> { + let Some(named) = &mut self.named else { + return Ok(Some(permit)); + }; + match named.limit(crate::auth::Direction::Publish) { + crate::auth::Limit::Waiting => Ok(None), + crate::auth::Limit::Patterns(patterns) => Ok(Some(permit.limited(patterns))), + crate::auth::Limit::Ended => Err(Error::Unauthorized), + } + } + /// Whether we may announce `suffix` (relative to the requested prefix). fn permitted(&self, suffix: &crate::Path) -> bool { self.permit.matches(self.prefix.join(suffix).as_str()) @@ -1070,39 +1199,38 @@ impl AnnounceRun { continue; } let absolute = origin.absolute(&suffix); - if let Some((hops, cost)) = self.outgoing(&route, &absolute) { - self.advertise(stream, suffix, hops, cost, route.epoch, &absolute)?; + if let Some(wire) = self.outgoing(&route, &absolute) { + self.advertise(stream, suffix, wire, route.epoch, &absolute)?; } } Ok(()) } - /// Put `suffix` on the wire with this chain and cost: start it, restart it in place - /// when its metadata changed, or leave it alone when the peer already holds it. + /// Put `suffix` on the wire: start it, restart it in place when its metadata + /// changed, or leave it alone when the peer already holds it. fn advertise( &mut self, stream: &mut Stream, suffix: crate::PathOwned, - hops: Hops, - cost: crate::origin::Cost, + wire: Outgoing, epoch: Option, absolute: &crate::Path, ) -> Result<(), Error> { match self.live.get_mut(&suffix) { // The peer would decode what it already holds. - Some(advertised) if advertised.hops == hops && advertised.cost == cost => {} + Some(advertised) if advertised.wire == wire => {} // A metadata update on a live advertisement: restart it in // place (lite-05 restarts via a duplicate ANNOUNCE). Some(advertised) if lite::update_supported(self.version) => { tracing::debug!(route = %absolute, "reannounce"); - advertised.hops = hops.clone(); - advertised.cost = cost; + advertised.wire = wire.clone(); + let Outgoing { hops, cost, auth, .. } = wire; match advertised.id { Some(id) => { let hops = self.encoder.update(id, hops); stream .writer - .buffer(&lite::AnnounceBroadcast::Update { id, hops, cost })? + .buffer(&lite::AnnounceBroadcast::Update { id, hops, cost, auth })? } // lite-05: a duplicate ANNOUNCE, which assigns no id. None => stream.writer.buffer(&lite::AnnounceBroadcast::Active { @@ -1110,6 +1238,7 @@ impl AnnounceRun { suffix: lite::PathRef::literal(suffix), hops: lite::HopsRef::literal(hops), cost, + auth, })?, } } @@ -1118,15 +1247,14 @@ impl AnnounceRun { Some(_) => {} None => { tracing::debug!(route = %absolute, "announce"); - self.start(stream, suffix, hops, cost, epoch)?; + self.start(stream, suffix, wire, epoch)?; } } Ok(()) } - /// The chain and cost to put on the wire for `route`, or `None` when it must - /// not be forwarded. - fn outgoing(&self, route: &crate::origin::Route, absolute: &crate::Path) -> Option<(Hops, crate::origin::Cost)> { + /// What to put on the wire for `route`, or `None` when it must not be forwarded. + fn outgoing(&self, route: &crate::origin::Route, absolute: &crate::Path) -> Option { let mut hops = route.hops.clone(); // A route that already passed through us is a reflection. The origin @@ -1150,7 +1278,24 @@ impl AnnounceRun { true => route.cost, false => crate::origin::Cost::UNKNOWN, }; - Some((hops, cost)) + // A route named by a token of ours that ended ends with it, never falling back to the + // session's scope. + let auth = match (&self.presenter, &route.auth) { + (Some(presenter), Some(auth)) if self.version.has_auth() => match presenter.wire(auth) { + Ok(auth) => auth, + Err(_) => { + tracing::debug!(route = %absolute, "dropping announce; its token ended"); + return None; + } + }, + _ => None, + }; + Some(Outgoing { + hops, + cost, + auth, + credential: route.auth.clone().filter(|_| auth.is_some()), + }) } /// Start advertising `suffix`, recording its announce id. @@ -1158,18 +1303,19 @@ impl AnnounceRun { &mut self, stream: &mut Stream, suffix: crate::PathOwned, - hops: Hops, - cost: crate::origin::Cost, + wire: Outgoing, // Fixed while the peer holds the advertisement: a new one is a restart. epoch: Option, ) -> Result<(), Error> { - let (id, wire, chain) = self.encoder.start(suffix.clone(), hops.clone()); - self.live.insert(suffix, Advertised { id, hops, cost }); + let (id, path, chain) = self.encoder.start(suffix.clone(), wire.hops.clone()); + let (cost, auth) = (wire.cost, wire.auth); + self.live.insert(suffix, Advertised { id, wire }); stream.writer.buffer(&lite::AnnounceBroadcast::Active { epoch, - suffix: wire, + suffix: path, hops: chain, cost, + auth, })?; Ok(()) } @@ -1180,8 +1326,7 @@ impl AnnounceRun { &mut self, stream: &mut Stream, suffix: crate::PathOwned, - hops: Hops, - cost: crate::origin::Cost, + wire: Outgoing, epoch: Option, absolute: &crate::Path, ) -> Result<(), Error> { @@ -1193,18 +1338,22 @@ impl AnnounceRun { suffix, Advertised { id: Some(id), - hops: hops.clone(), - cost, + wire: wire.clone(), }, ); + let Outgoing { hops, cost, auth, .. } = wire; let hops = self.encoder.update(id, hops); - stream - .writer - .buffer(&lite::AnnounceBroadcast::Restart { id, epoch, hops, cost })?; + stream.writer.buffer(&lite::AnnounceBroadcast::Restart { + id, + epoch, + hops, + cost, + auth, + })?; } _ => { self.retract(stream, suffix.clone(), absolute)?; - self.start(stream, suffix, hops, cost, epoch)?; + self.start(stream, suffix, wire, epoch)?; } } Ok(()) @@ -1254,9 +1403,9 @@ impl AnnounceRun { return None; } let absolute = origin.absolute(&suffix); - let (hops, cost) = self.outgoing(&update.route, &absolute)?; + let wire = self.outgoing(&update.route, &absolute)?; tracing::debug!(route = %absolute, "announce"); - Some((suffix, hops, cost, update.route.epoch)) + Some((suffix, wire, update.route.epoch)) }) .collect(); match self.version { @@ -1264,8 +1413,8 @@ impl AnnounceRun { let suffixes = initial.iter().map(|(suffix, ..)| suffix.clone()).collect(); stream.writer.buffer(&lite::AnnounceInit { suffixes })?; // The peer holds these now, so a later retraction or narrower grant ends them. - for (suffix, hops, cost, _) in initial { - self.live.insert(suffix, Advertised { id: None, hops, cost }); + for (suffix, wire, _) in initial { + self.live.insert(suffix, Advertised { id: None, wire }); } } _ => { @@ -1273,8 +1422,8 @@ impl AnnounceRun { origin: self.self_origin, active: initial.len() as u64, })?; - for (suffix, hops, cost, epoch) in initial { - self.start(stream, suffix, hops, cost, epoch)?; + for (suffix, wire, epoch) in initial { + self.start(stream, suffix, wire, epoch)?; } } } @@ -1317,10 +1466,19 @@ impl AnnounceRun { // advertises something it would withdraw, or abort over, a moment later. if let Some(auth) = &self.auth { ready!(auth.poll_setup_answered(waiter)); - if let Poll::Ready(permit) = auth.poll_permit(&mut self.epoch, waiter) { - self.permit = permit; + while let Poll::Ready(permit) = auth.poll_permit(&mut self.epoch, waiter) { + self.session = permit; } } + // A request naming a token still in flight waits for it before announcing anything. + self.permit = loop { + let changed = self.poll_named(waiter); + match self.narrow(self.session.clone())? { + Some(permit) => break permit, + None if changed.is_ready() => continue, + None => return Poll::Pending, + } + }; self.init(stream, origin, announced)?; self.phase = AnnouncePhase::Running; } @@ -1338,13 +1496,26 @@ impl AnnounceRun { return stream.writer.poll_closed(&mut cx); } - // A grant change applies before the next update, so a route it no longer - // covers is withdrawn rather than re-sent. - if let Some(auth) = &self.auth - && let Poll::Ready(permit) = auth.poll_permit(&mut self.epoch, waiter) - && self.permit != permit - { - self.regrant(stream, origin, permit)?; + // A grant change, or one to the token the request names, applies before the next + // update, so a route it no longer covers is withdrawn rather than re-sent. Both + // are polled, so each registers. + let session = match &self.auth { + Some(auth) => auth.poll_permit(&mut self.epoch, waiter), + None => Poll::Pending, + }; + if self.poll_named(waiter).is_ready() || session.is_ready() { + if let Poll::Ready(permit) = session { + self.session = permit; + self.retract_ended(stream, origin)?; + } + // Once answered a token never goes back to waiting; were it to, announce nothing. + let permit = match self.narrow(self.session.clone())? { + Some(permit) => permit, + None => self.session.clone().limited(Default::default()), + }; + if self.permit != permit { + self.regrant(stream, origin, permit)?; + } continue; } @@ -1377,12 +1548,10 @@ impl AnnounceRun { } match self.outgoing(&update.route, &absolute) { - Some((hops, cost)) if restart && self.live.contains_key(&suffix) => { - self.restart(stream, suffix, hops, cost, update.route.epoch, &absolute)?; - } - Some((hops, cost)) => { - self.advertise(stream, suffix, hops, cost, update.route.epoch.clone(), &absolute)? + Some(wire) if restart && self.live.contains_key(&suffix) => { + self.restart(stream, suffix, wire, update.route.epoch, &absolute)?; } + Some(wire) => self.advertise(stream, suffix, wire, update.route.epoch.clone(), &absolute)?, // The chain must not be forwarded (reflected, or full): retract // whatever the peer holds. None => self.retract(stream, suffix, &absolute)?, @@ -1429,6 +1598,16 @@ trait Request: Sized { /// The publisher instance the request names, if any. fn epoch(msg: &Self::Message) -> Option<&crate::Epoch>; + /// The ID of the requester's AUTH that admits the request, or `None` for the scope. + fn auth(_msg: &Self::Message) -> Option { + None + } + + /// The auth an update moves the request to, if it can carry one. + fn rename(_update: &Self::Update) -> Option> { + None + } + /// Start answering, once the broadcast resolves. fn start(shared: &Shared, msg: Self::Message, broadcast: crate::broadcast::Consumer) -> Result; @@ -1560,11 +1739,6 @@ impl> RequestServe { } fn poll_serve(&mut self, waiter: &kio::Waiter) -> Poll> { - if let Some(gate) = &mut self.gate - && gate.poll_denied(waiter).is_ready() - { - return Poll::Ready(Err(Error::Unauthorized)); - } let mut cx = waiter.context(); loop { ready!(self.budget.poll_yield(waiter)); @@ -1580,6 +1754,13 @@ impl> RequestServe { // completed for nobody. self.state = RequestState::Finish { finished: false }; } + // Checked every pass after reading updates, so nothing is served under an auth one + // renamed the request to before the gate judges it. + if let Some(gate) = &mut self.gate + && gate.poll_denied(waiter).is_ready() + { + return Poll::Ready(Err(Error::Unauthorized)); + } let stream = self.stream.as_mut().expect("stream present"); match &mut self.state { @@ -1617,6 +1798,9 @@ impl> RequestServe { // Checked before anything is resolved, so a broadcast the session does not // allow is never served, not just cut off later. let mut gate = self.shared.gate(R::target(&msg).0); + if self.shared.version.has_auth() { + gate = gate.naming(R::auth(&msg)); + } let denied = gate.poll_denied(waiter).is_ready(); self.gate = Some(gate); self.state = RequestState::Resolve { msg, requesting: None }; @@ -1626,6 +1810,12 @@ impl> RequestServe { } RequestState::Resolve { msg, requesting } => { if requesting.is_none() { + // A request naming an auth that is still in flight, or unanswered, waits + // for it while holding its slot. + let gate = self.gate.as_mut().expect("gated at decode"); + if !ready!(gate.poll_admitted(waiter)) { + return Poll::Ready(Err(Error::Unauthorized)); + } // The peer requested this exact path, so it has already seen an // announcement for it. `request_broadcast` resolves it immediately, is // served on demand by the route covering it (an `origin::Dynamic`), or @@ -1727,6 +1917,13 @@ impl> RequestServe { return Poll::Ready(Err(err)); } }; + if let Some(auth) = R::rename(&update) + && let Some(gate) = &mut self.gate + { + gate.rename(auth); + // Re-poll so the gate judges the rename even if nothing else wakes us. + cx.waker().wake_by_ref(); + } match &mut self.state { RequestState::Serve(request) => request.update(update), _ => self.update = Some(update), @@ -1930,6 +2127,14 @@ impl Request for SubscribeServe { msg.epoch.as_ref() } + fn auth(msg: &Self::Message) -> Option { + msg.auth + } + + fn rename(update: &Self::Update) -> Option> { + Some(update.auth) + } + fn start(shared: &Shared, msg: Self::Message, broadcast: crate::broadcast::Consumer) -> Result { let subscription = crate::track::Subscription { priority: msg.priority, @@ -2110,6 +2315,10 @@ impl Request for FetchServe { msg.epoch.as_ref() } + fn auth(msg: &Self::Message) -> Option { + msg.auth + } + fn start(_: &Shared, msg: Self::Message, broadcast: crate::broadcast::Consumer) -> Result { let fetching = broadcast .track(&msg.track)? @@ -2704,7 +2913,7 @@ mod announce_test { moq_clock::sleep(crate::origin::DEFAULT_UPDATE_HOLD).await; settle().await; match h.wire.take_announces().as_slice() { - [lite::AnnounceBroadcast::Update { id: 0, hops, cost }] => { + [lite::AnnounceBroadcast::Update { id: 0, hops, cost, .. }] => { assert_eq!(hops, &lite::HopsRef::literal(pub_hops())); assert_eq!(*cost, crate::origin::Cost::new(3)); } @@ -4727,6 +4936,7 @@ mod serve_group_test { end_group: None, start_frame: 0, end_frame: None, + auth: None, }); { let mut poll = std::pin::pin!(kio::wait(|waiter| run.poll(&mut writer, waiter))); @@ -4801,6 +5011,7 @@ mod serve_group_test { end_group: None, start_frame: 0, end_frame: None, + auth: None, }); let second = track .append_datagram(Timestamp::from_millis(2).unwrap(), b"b".as_slice()) @@ -5060,6 +5271,7 @@ mod serve_group_test { end_group: None, start_frame: 0, end_frame: None, + auth: None, }); while run.poll_step(&mut writer, &kio::Waiter::noop()).is_ready() {} assert_eq!(drops(&mut writer, &log, version).await, [(2, 2)]); @@ -5214,6 +5426,7 @@ mod serve_group_test { end_group: None, start_frame: 0, end_frame: None, + auth: None, }); { let mut run = std::pin::pin!(kio::wait(|waiter| run.poll(&mut writer, waiter))); @@ -5256,6 +5469,7 @@ mod serve_group_test { end_group: None, start_frame: 0, end_frame: None, + auth: None, }); { let mut run = std::pin::pin!(kio::wait(|waiter| run.poll(&mut writer, waiter))); @@ -5301,6 +5515,7 @@ mod serve_group_test { end_group: None, start_frame: 0, end_frame: None, + auth: None, }; run.update(update(5)); { @@ -5346,6 +5561,7 @@ mod serve_group_test { end_group: None, start_frame: 0, end_frame: None, + auth: None, }; run.update(update(5)); while run.poll_step(&mut writer, &kio::Waiter::noop()).is_ready() {} @@ -5393,6 +5609,7 @@ mod serve_group_test { end_group: None, start_frame: 0, end_frame: None, + auth: None, }); write_group(&mut track, 5, 5); assert_eq!(settle(&mut run), 1, "served a group below the latest"); @@ -5424,6 +5641,7 @@ mod serve_group_test { end_group: None, start_frame: 0, end_frame: None, + auth: None, }); write_group(&mut track, 0, 0); @@ -5560,6 +5778,7 @@ mod serve_group_test { end_group: None, start_frame: 0, end_frame: None, + auth: None, }); relay.settle(); assert_eq!(relay.opened(), 1, "the finished group is the live edge"); @@ -5591,6 +5810,7 @@ mod serve_group_test { end_group: None, start_frame: 0, end_frame: None, + auth: None, }); relay.settle(); assert_eq!(relay.opened(), 2, "group 0 was passed, not skipped"); @@ -5619,6 +5839,7 @@ mod serve_group_test { end_group: None, start_frame: 0, end_frame: None, + auth: None, }; relay.run.update(update); relay.settle(); @@ -6346,6 +6567,7 @@ mod tests { end_group: None, start_frame: 0, end_frame: None, + auth: None, }; leave_request::>(version, &subscribe, wait, close).await; @@ -6358,6 +6580,7 @@ mod tests { group: 0, start_frame: 0, end_frame: None, + auth: None, }; leave_request::(version, &fetch, wait, close).await; } @@ -6496,6 +6719,7 @@ mod tests { end_group: None, start_frame: 0, end_frame: None, + auth: None, } .encode(&mut crate::coding::Encoder::new(&mut script, version.into()), version) .unwrap(); @@ -6520,6 +6744,7 @@ mod tests { end_group: None, start_frame: 0, end_frame: None, + auth: None, }; session.push(&update.encode_bytes(version).unwrap()); assert!(!drive(&mut serve).await, "an update ended the subscription"); @@ -6538,6 +6763,246 @@ mod tests { assert_eq!(run.steer_tx.read().priority, 7, "the update was lost"); } + /// Serve a lite-07 SUBSCRIBE to `room` naming the peer's token `auth`, under a session + /// whose scope grants the peer nothing, so only the named token can admit it. + async fn named_subscribe( + auth: &crate::auth::Handle, + named: Option, + ) -> ( + RequestServe>, + ScriptedSession, + crate::broadcast::Dynamic, + crate::broadcast::Producer, + ) { + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let broadcast = origin.create_broadcast("room").unwrap(); + let dynamic = broadcast.dynamic(); + broadcast.announce(Default::default()).unwrap(); + auth.authorize(&crate::auth::Grant::default()); + + let peer_setup = crate::lite::PeerSetup::new(false, crate::transport::Binding::InBand); + peer_setup.set(crate::lite::Setup::default()); + let (_, goaway) = crate::goaway::Handle::new(true); + let version = Version::Lite07; + let publisher = Publisher::new(PublisherConfig { + runtime: crate::time::sim(), + session: ScriptedSession::new(Vec::new()), + origin: origin.consume(), + version, + peer_setup, + goaway, + peer_hop: None, + auth: auth.clone(), + peer_grant: Default::default(), + client: false, + subscriptions: Default::default(), + fetches: Default::default(), + estimate: kio::Producer::default().weak(), + }); + + let mut script = Vec::new(); + lite::Subscribe { + order: crate::track::Order::Descending, + epoch: None, + id: 0, + broadcast: crate::Path::new("room"), + track: "video".into(), + priority: 1, + max_delay: Duration::ZERO, + start_group: None, + end_group: None, + start_frame: 0, + end_frame: None, + auth: named, + } + .encode(&mut crate::coding::Encoder::new(&mut script, version.into()), version) + .unwrap(); + let mut session = ScriptedSession::new(script); + let (send, recv) = futures::future::poll_fn(|cx| { + ::poll_open_bi(&mut session, cx, Default::default()) + }) + .await + .unwrap(); + let stream = Stream:: { + writer: Writer::new(send, version), + reader: crate::coding::Reader::new(recv, version), + }; + let serve = RequestServe::<_, SubscribeServe>::new(publisher.shared.clone(), stream); + (serve, session, dynamic, broadcast) + } + + /// A SUBSCRIBE naming a token still in flight waits for it, holding its slot, rather than + /// being judged by the session's scope; once that token admits it, the token ending ends + /// the subscription. + #[moq_clock::test] + async fn a_named_subscribe_waits_for_its_token_and_ends_with_it() { + let auth = crate::auth::Handle::new(true); + let (mut serve, session, mut dynamic, _broadcast) = named_subscribe(&auth, Some(0)).await; + assert!(!drive(&mut serve).await, "refused before its token arrived"); + assert!( + dynamic.requested_track().now_or_never().is_none(), + "resolved before its token decided" + ); + + auth.peer_arrived(0).unwrap(); + auth.peer_granted( + 0, + &crate::auth::Grant { + subscribe: crate::Pattern::subtree("room").unwrap().into(), + ..Default::default() + }, + ); + assert!(!drive(&mut serve).await); + let _track = dynamic + .requested_track() + .now_or_never() + .expect("track requested") + .unwrap() + .accept(crate::track::Info::timed()); + assert!(!drive(&mut serve).await, "the subscription ended"); + assert!(session.log.resets().is_empty()); + + auth.peer_ended(0); + assert!(drive(&mut serve).await, "outlived its token"); + assert_eq!(session.log.resets(), vec![crate::StreamError::Unauthorized.to_code()]); + } + + /// A named SUBSCRIBE is held to its own token's grant, not the union of the peer's tokens, + /// and a token that already ended admits nothing. + #[moq_clock::test] + async fn a_named_subscribe_is_held_to_its_own_token() { + let elsewhere = crate::auth::Grant { + subscribe: crate::Pattern::subtree("other").unwrap().into(), + ..Default::default() + }; + let auth = crate::auth::Handle::new(true); + auth.peer_arrived(0).unwrap(); + auth.peer_granted(0, &elsewhere); + let (mut serve, session, _dynamic, _broadcast) = named_subscribe(&auth, Some(0)).await; + assert!(drive(&mut serve).await, "admitted outside its token"); + assert_eq!(session.log.resets(), vec![crate::StreamError::Unauthorized.to_code()]); + + let auth = crate::auth::Handle::new(true); + auth.peer_arrived(0).unwrap(); + auth.peer_ended(0); + let (mut serve, session, _dynamic, _broadcast) = named_subscribe(&auth, Some(0)).await; + assert!(drive(&mut serve).await, "admitted by an ended token"); + assert_eq!(session.log.resets(), vec![crate::StreamError::Unauthorized.to_code()]); + } + + /// SUBSCRIBE_UPDATE moves a subscription to another token without a gap: the old one + /// decides until the new one does, so withdrawing the old one mid-renewal keeps it. + #[moq_clock::test] + async fn a_subscribe_update_renames_its_token() { + let room = crate::auth::Grant { + subscribe: crate::Pattern::subtree("room").unwrap().into(), + ..Default::default() + }; + let auth = crate::auth::Handle::new(true); + auth.peer_arrived(0).unwrap(); + auth.peer_granted(0, &room); + let (mut serve, session, mut dynamic, _broadcast) = named_subscribe(&auth, Some(0)).await; + assert!(!drive(&mut serve).await); + let _track = dynamic + .requested_track() + .now_or_never() + .expect("track requested") + .unwrap() + .accept(crate::track::Info::timed()); + assert!(!drive(&mut serve).await); + + let update = lite::SubscribeUpdate { + order: crate::track::Order::Descending, + priority: 1, + max_delay: Duration::ZERO, + start_group: None, + end_group: None, + start_frame: 0, + end_frame: None, + auth: Some(1), + }; + session.push(&update.encode_bytes(Version::Lite07).unwrap()); + assert!(!drive(&mut serve).await, "renamed to a token in flight"); + auth.peer_withdrawn(0); + assert!( + !drive(&mut serve).await, + "the withdrawn token stopped deciding mid-renewal" + ); + + auth.peer_arrived(1).unwrap(); + auth.peer_granted(1, &room); + assert!(!drive(&mut serve).await); + assert!( + auth.poll_peer_unnamed(0, &kio::Waiter::noop()).is_ready(), + "still named after moving off" + ); + auth.peer_ended(1); + assert!(drive(&mut serve).await, "outlived the token it moved to"); + assert_eq!(session.log.resets(), vec![crate::StreamError::Unauthorized.to_code()]); + } + + /// A SUBSCRIBE_UPDATE renaming a running subscription to a token that already ended stops + /// it before another group goes out under that token. + #[moq_clock::test] + async fn a_rename_to_an_ended_token_serves_nothing_more() { + let auth = crate::auth::Handle::new(true); + auth.peer_arrived(0).unwrap(); + auth.peer_granted( + 0, + &crate::auth::Grant { + subscribe: crate::Pattern::subtree("room").unwrap().into(), + ..Default::default() + }, + ); + auth.peer_arrived(1).unwrap(); + auth.peer_ended(1); + let (mut serve, session, mut dynamic, _broadcast) = named_subscribe(&auth, Some(0)).await; + assert!(!drive(&mut serve).await); + let mut track = dynamic + .requested_track() + .now_or_never() + .expect("track requested") + .unwrap() + .accept(crate::track::Info::timed()); + assert!(!drive(&mut serve).await); + let RequestState::Serve(SubscribeServe::Run(run)) = &serve.state else { + panic!("the subscription is not running"); + }; + let opens = run.ctx.opens.clone(); + let write = |track: &mut crate::track::Producer, sequence| { + let mut group = track.create_group(group::Info { sequence }).unwrap(); + group + .write_frame(crate::Timestamp::from_millis(sequence).unwrap(), b"x".as_slice()) + .unwrap(); + group.finish().unwrap(); + }; + write(&mut track, 0); + assert!(!drive(&mut serve).await); + assert_eq!( + opens.opened.load(Ordering::Relaxed), + 1, + "served under the token it named" + ); + + let update = lite::SubscribeUpdate { + order: crate::track::Order::Descending, + priority: 1, + max_delay: Duration::ZERO, + start_group: None, + end_group: None, + start_frame: 0, + end_frame: None, + auth: Some(1), + }; + session.push(&update.encode_bytes(Version::Lite07).unwrap()); + write(&mut track, 1); + + assert!(drive(&mut serve).await, "outlived the ended token it moved to"); + assert_eq!(session.log.resets(), vec![crate::StreamError::Unauthorized.to_code()]); + let started = opens.pending.load(Ordering::Relaxed) + opens.opened.load(Ordering::Relaxed); + assert_eq!(started, 1, "a group started under it"); + } + /// A subscription that always has another group ready yields within its budget, so a /// sibling (the transport's driver, say) runs between its polls. Without it, one poll /// served every group the producer kept appending and starved the rest of the runtime @@ -6589,6 +7054,7 @@ mod tests { end_group: None, start_frame: 0, end_frame: None, + auth: None, } .encode(&mut crate::coding::Encoder::new(&mut script, version.into()), version) .unwrap(); @@ -6694,6 +7160,7 @@ mod tests { end_group: None, start_frame: 0, end_frame: None, + auth: None, } .encode(&mut crate::coding::Encoder::new(script, VERSION.into()), VERSION) .unwrap() @@ -6782,6 +7249,7 @@ mod tests { group: 0, start_frame: 0, end_frame: None, + auth: None, } .encode(&mut crate::coding::Encoder::new(&mut script, VERSION.into()), VERSION) .unwrap(); @@ -6849,6 +7317,7 @@ mod tests { group: 7, start_frame: 0, end_frame: None, + auth: None, } .encode(&mut crate::coding::Encoder::new(&mut script, VERSION.into()), VERSION) .unwrap(); @@ -7015,6 +7484,7 @@ mod tests { end_group: None, start_frame: 0, end_frame: None, + auth: None, }; let mut session = ScriptedSession::new(request.encode_bytes(version).unwrap().to_vec()); let log = session.log.clone(); @@ -7067,6 +7537,7 @@ mod tests { end_group: None, start_frame: 0, end_frame: None, + auth: None, }; let mut session = ScriptedSession::new(request.encode_bytes(version).unwrap().to_vec()); let log = session.log.clone(); diff --git a/rs/moq-net/src/lite/session.rs b/rs/moq-net/src/lite/session.rs index 25e6e7eb1a0..b0a49e29bcc 100644 --- a/rs/moq-net/src/lite/session.rs +++ b/rs/moq-net/src/lite/session.rs @@ -492,7 +492,7 @@ impl Present { let _ = self.handle.acceptor(); self.started = true; } - while let Poll::Ready(Some((id, token))) = self.handle.poll_opening(waiter) { + while let Poll::Ready(Some((id, wire, token))) = self.handle.poll_opening(waiter) { self.tokens.push(PresentToken { runtime: self.runtime.clone(), session: self.session.clone(), @@ -501,7 +501,7 @@ impl Present { going_away: self.going_away.clone(), violation: self.violation.clone(), id, - state: PresentState::Open { token }, + state: PresentState::Open { wire, token }, }); } let _ = self.tokens.poll(waiter); @@ -527,8 +527,18 @@ struct PresentToken { } enum PresentState { - Open { token: bytes::Bytes }, - Run { stream: Stream, answered: bool }, + Open { + /// The ID the AUTH carries, which our requests name the token by. + wire: u64, + token: bytes::Bytes, + }, + Run { + stream: Stream, + answered: bool, + /// Withdrawn: our side is finished, and the peer ends the token once none of our + /// requests names it any more. + withdrawn: bool, + }, Done, } @@ -537,11 +547,20 @@ impl kio::Task for PresentToken { fn poll(&mut self, waiter: &kio::Waiter) -> Poll<()> { let err = ready!(self.poll_present(waiter)); - let answered = matches!(self.state, PresentState::Run { answered: true, .. }); - if let PresentState::Run { stream, .. } = std::mem::replace(&mut self.state, PresentState::Done) { + let (answered, withdrawn) = match &self.state { + PresentState::Run { + answered, withdrawn, .. + } => (*answered, *withdrawn), + _ => (false, false), + }; + if let PresentState::Run { stream, withdrawn, .. } = std::mem::replace(&mut self.state, PresentState::Done) + && !withdrawn + { stream.writer.abort(&Error::Cancel); } let err = match err { + // However the peer ends a token we withdrew, we are the reason it ended. + Error::Cancel | Error::Unsupported | Error::Stream(_) if withdrawn => Error::Cancel, // A peer that predates AUTH resets a stream type it does not know, and one // that takes no tokens in band refuses the same way: either way, before any // reply, this token earns nothing here. @@ -570,12 +589,15 @@ impl PresentToken { let mut cx = Context::from_waker(waiter.waker()); loop { match &mut self.state { - PresentState::Open { token } => { + PresentState::Open { wire, token } => { // After a GOAWAY the peer must not see new streams. if self.going_away.is_set() { return Poll::Ready(Error::GoingAway); } - let token = token.clone(); + let msg = super::Auth { + id: *wire, + token: token.clone(), + }; let mut stream = match ready!(Stream::poll_open(&mut self.session, self.version, &mut cx)) { Ok(stream) => stream, Err(err) => return Poll::Ready(err), @@ -583,22 +605,32 @@ impl PresentToken { let res = stream .writer .buffer(&super::ControlType::Auth) - .and_then(|()| stream.writer.buffer(&super::Auth { token })); + .and_then(|()| stream.writer.buffer(&msg)); if let Err(err) = res { return Poll::Ready(err); } self.state = PresentState::Run { stream, answered: false, + withdrawn: false, }; } - PresentState::Run { stream, answered } => { + PresentState::Run { + stream, + answered, + withdrawn, + } => { if let Err(err) = ready!(stream.writer.poll_flush(&mut cx)) { return Poll::Ready(err); } - // Withdrawn: closing the stream is what tells the peer. - if self.handle.poll_withdrawn(self.id, waiter).is_ready() { - return Poll::Ready(Error::Cancel); + // Withdrawn: finishing our side tells the peer, which keeps the token for + // whatever still names it (a route mid-rename) and then closes its own. + if !*withdrawn && self.handle.poll_withdrawn(self.id, waiter).is_ready() { + if let Err(err) = stream.writer.finish() { + return Poll::Ready(err); + } + *withdrawn = true; + continue; } let reply = match ready!(stream.reader.poll_decode_maybe::(&mut cx)) { Ok(Some(reply)) => reply, diff --git a/rs/moq-net/src/lite/subscribe.rs b/rs/moq-net/src/lite/subscribe.rs index f34b38d3d3a..be9be0b2e7e 100644 --- a/rs/moq-net/src/lite/subscribe.rs +++ b/rs/moq-net/src/lite/subscribe.rs @@ -37,6 +37,9 @@ pub struct Subscribe<'a> { /// Which pending group to send first. Lite07+ only; earlier wires decode as /// [`Order::Descending`] and cannot ask for anything else. pub order: Order, + /// The ID of the subscriber's AUTH that admits this subscription, or `None` for the + /// session's scope. Lite07+ only. + pub auth: Option, } impl Version { @@ -71,6 +74,7 @@ impl Message for Subscribe<'_> { let (start_frame, end_frame) = decode_frame_bounds(r, version, start_group, end_group)?; let start_group = canonical_start_group(version, start_group, start_frame); + let auth = super::decode_auth(r, version)?; Ok(Self { id, @@ -84,6 +88,7 @@ impl Message for Subscribe<'_> { start_frame, end_frame, order, + auth, }) } @@ -112,6 +117,7 @@ impl Message for Subscribe<'_> { self.end_group, self.end_frame, )?; + super::encode_auth(w, version, self.auth)?; Ok(()) } @@ -422,6 +428,8 @@ pub struct SubscribeUpdate { pub end_frame: Option, /// See [`Subscribe::order`]. pub order: Order, + /// See [`Subscribe::auth`]: the update moves the subscription to this auth. + pub auth: Option, } impl Message for SubscribeUpdate { @@ -444,6 +452,7 @@ impl Message for SubscribeUpdate { let (start_frame, end_frame) = decode_frame_bounds(r, version, start_group, end_group)?; let start_group = canonical_start_group(version, start_group, start_frame); + let auth = super::decode_auth(r, version)?; Ok(Self { priority, @@ -453,6 +462,7 @@ impl Message for SubscribeUpdate { start_frame, end_frame, order, + auth, }) } @@ -480,6 +490,7 @@ impl Message for SubscribeUpdate { self.end_group, self.end_frame, )?; + super::encode_auth(w, version, self.auth)?; Ok(()) } @@ -758,6 +769,7 @@ mod test { end_group: Some(9), start_frame: 4, end_frame: Some(2), + auth: None, } } @@ -954,6 +966,7 @@ mod test { start_frame: 0, end_frame: None, order, + auth: None, }; let mut buf = Vec::new(); update diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 828082fa9ea..69659d8f522 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -1,6 +1,6 @@ use crate::{frame, group, origin, track}; use std::{ - collections::{BTreeMap, BTreeSet, HashMap}, + collections::{BTreeMap, BTreeSet, HashMap, HashSet}, sync::{Arc, atomic}, task::{Poll, ready}, time::Duration, @@ -184,6 +184,7 @@ impl Subscriber { epoch, hops, cost, + auth, } => { let (suffix, hops) = match self.version.has_announce_id() { // Every `active` assigns the next ordinal, even ones we drop locally. @@ -192,6 +193,9 @@ impl Subscriber { false => (suffix.rest.into_owned(), hops.literal), }; let path = prefix.join(&suffix); + if !run.announced.contains(&path) { + run.announced.name(&self.auth, &path, auth); + } if lite::update_supported(self.version) && !self.version.has_announce_id() && run.announced.contains(&path) @@ -232,11 +236,12 @@ impl Subscriber { tracing::debug!(broadcast = %self.log_path(&path), "unannounced"); run.announced.withdraw(&path); } - lite::AnnounceBroadcast::Update { id, hops, cost } => { + lite::AnnounceBroadcast::Update { id, hops, cost, auth } => { // Resolve the id; it stays live (the replacement reuses it). An unknown // or retired id is a protocol violation. let (suffix, hops) = run.decoder.update(id, hops)?; let path = prefix.join(&suffix); + run.announced.name(&self.auth, &path, auth); self.update_announce( path, hops, @@ -246,11 +251,18 @@ impl Subscriber { &mut run.announced, )?; } - lite::AnnounceBroadcast::Restart { id, epoch, hops, cost } => { + lite::AnnounceBroadcast::Restart { + id, + epoch, + hops, + cost, + auth, + } => { // Resolved like an update: the id stays live. let (suffix, hops) = run.decoder.update(id, hops)?; let path = prefix.join(&suffix); run.announced.restart(&path, epoch); + run.announced.name(&self.auth, &path, auth); self.restart_announce( path, hops, @@ -423,10 +435,14 @@ impl Subscriber { tracing::debug!(route = %self.log_path(&path), hops = hops.len(), "update"); let metadata = self.announced_route(&path, hops, cost, link_cost, responder_origin, announced); - // A re-price keeps the route's prefix (and its - // served paths) and re-prices in place. In-flight tracks keep flowing. - if let Some(entry) = announced.attached(&path) { - entry.update(metadata); + // A re-price keeps the route's prefix (and its served paths) and re-prices in place, + // so in-flight tracks keep flowing, unless the token it now names refuses it. + if announced.attached(&path).is_some() { + if !announced.admits(self, &path) { + announced.withhold(self, path, metadata); + return Ok(false); + } + announced.attached(&path).expect("checked above").update(metadata); return Ok(true); } @@ -1376,6 +1392,7 @@ impl AnnouncePrefix { prefix: self.prefix.as_path(), exclude_hop: self.subscriber.self_origin.id(), hidden: true, + auth: None, })?; self.state = PrefixState::Send { stream }; } @@ -1482,12 +1499,17 @@ impl AnnouncePrefix { // A new limit holds back what the peer may no longer publish to us, // and brings back what it may again. The peer still holds each // advertisement either way. - while let Poll::Ready(permit) = - self.subscriber - .auth - .poll_permit(crate::auth::Direction::Subscribe, &mut run.auth_epoch, waiter) + while self + .subscriber + .auth + .poll_permit(crate::auth::Direction::Subscribe, &mut run.auth_epoch, waiter) + .is_ready() { - run.announced.limit(&permit, &self.subscriber); + run.announced.regrant(&self.subscriber); + } + // A change to one of the peer's tokens re-decides only what names it. + while let Poll::Ready(ids) = run.announced.changes.poll(waiter) { + run.announced.renamed(&self.subscriber, ids); } loop { match stream.reader.poll_decode_maybe::(&mut cx) { @@ -3627,6 +3649,44 @@ mod tests { request.await.unwrap().expect("the requested route serves it"); } + /// The token index holds only what live advertisements name. A peer that names a token, + /// moves off it, and retracts must leave nothing behind, or unique paths grow it forever. + #[moq_clock::test] + async fn a_retracted_rename_leaves_no_token_index() { + let auth = crate::auth::Handle::new(true); + let subscriber = Subscriber::new(SubscriberConfig { + runtime: crate::time::sim(), + session: SinkSession::new(Default::default()), + origin: origin::Config::new(crate::Hop::new(1).unwrap()).produce(), + recv_bandwidth: None, + version: VERSION, + peer_setup: crate::lite::PeerSetup::new(false, crate::transport::Binding::InBand), + cost: None, + peer_hop: Some(crate::Hop::new(777).unwrap()), + going_away: Default::default(), + auth: auth.clone(), + }); + + let mut announced = Announced::default(); + for i in 0..16 { + // Renamed twice with no verdict between, so the middle token is dropped unapplied. + let path = Path::new(&format!("room/{i}")).to_owned(); + announced.name(&auth, &path, Some(0)); + announced.name(&auth, &path, Some(1)); + announced.name(&auth, &path, None); + announced.withdraw(&path); + + // Renamed to the scope, which applies at once and moves it off its token. + let path = Path::new(&format!("lobby/{i}")).to_owned(); + announced.name(&auth, &path, Some(0)); + announced.name(&auth, &path, None); + announced.admits(&subscriber, &path); + announced.withdraw(&path); + } + assert!(announced.by_token.is_empty(), "left {:?}", announced.by_token); + assert!(announced.names.is_empty()); + } + /// A source minted under a claim goes once nothing holds it: the front that read it /// ended, so the session keeps nothing for the path, and a later request asks the /// route afresh. Withdrawing the claim still closes a source in use at once. @@ -4306,6 +4366,7 @@ mod tests { suffix: lite::PathRef::literal(Path::new(suffix)), hops: lite::HopsRef::literal(crate::Hops::new()), cost: crate::origin::Cost::default(), + auth: None, }; let mut script = Vec::new(); lite::AnnounceOk { @@ -4376,6 +4437,7 @@ mod tests { suffix: lite::PathRef::literal(Path::new(suffix)), hops: lite::HopsRef::literal(crate::Hops::new()), cost: crate::origin::Cost::default(), + auth: None, } .encode(w, version) .unwrap() @@ -4536,6 +4598,7 @@ mod tests { suffix: lite::PathRef::literal(Path::new(suffix)), hops: lite::HopsRef::literal(crate::Hops::new()), cost: crate::origin::Cost::default(), + auth: None, }; let mut script = Vec::new(); lite::AnnounceOk { @@ -4676,6 +4739,14 @@ enum Sub { /// owns its path and announce id until it retracts or restarts it. struct Announced { routes: HashMap, + /// The peer's token each advertisement names (lite-07+), which decides it in place of + /// the session's scope. Absent for one under the scope. + names: HashMap, + /// The advertisements naming each of the peer's tokens, so a change to one token + /// re-decides only those. Holds exactly what `names` names, so it never outgrows them. + by_token: HashMap>, + /// The peer's tokens that `names` name and that changed. + changes: crate::auth::Changes, /// The session's announce cap, and one slot taken from it per entry in `routes`. slots: crate::session::Slots, held: Vec, @@ -4688,6 +4759,13 @@ struct Announced { ready: kio::Queue, } +/// The peer's token an advertisement names, and the tokens it is filed under in +/// [`Announced::by_token`], so moving off one unfiles it there. +struct Name { + authority: crate::auth::Authority, + filed: Vec, +} + /// What we made of one advertisement the peer holds. enum Held { /// Not in the origin for good: a reflection, or outside our origin's scope. @@ -4710,6 +4788,9 @@ impl Announced { fn new(slots: crate::session::Slots) -> Self { Self { routes: HashMap::new(), + names: HashMap::new(), + by_token: HashMap::new(), + changes: Default::default(), slots, held: Vec::new(), epochs: HashMap::new(), @@ -4733,8 +4814,76 @@ impl Announced { self.routes.insert(path, Held::Attached(Box::new(route))); } + /// Name the peer's token `auth` decides the advertisement at `path`, or the session's + /// scope with `None`. A renamed one stays decided by the old token until the new one + /// decides. + fn name(&mut self, handle: &crate::auth::Handle, path: &PathOwned, auth: Option) { + match self.names.get_mut(path) { + Some(name) => name.authority.rename(auth), + None if auth.is_some() => { + let authority = crate::auth::Authority::new(handle.clone(), auth, &self.changes); + let filed = Vec::new(); + self.names.insert(path.clone(), Name { authority, filed }); + } + None => return, + } + self.refile(path); + } + + /// File the advertisement at `path` under exactly the tokens it names now. + fn refile(&mut self, path: &PathOwned) { + let (old, new) = match self.names.get_mut(path) { + Some(name) => { + let new: Vec = name.authority.ids().collect(); + (std::mem::replace(&mut name.filed, new.clone()), new) + } + None => return, + }; + self.unfile(path, old.iter().filter(|id| !new.contains(id))); + for id in new.into_iter().filter(|id| !old.contains(id)) { + self.by_token.entry(id).or_default().insert(path.clone()); + } + } + + fn unfile<'a>(&mut self, path: &PathOwned, ids: impl Iterator) { + for id in ids { + if let Some(named) = self.by_token.get_mut(id) { + named.remove(path); + if named.is_empty() { + self.by_token.remove(id); + } + } + } + } + + /// Whether the grant deciding the advertisement at `path` admits it right now: the + /// peer's token it names, or the session's limit. A token still in flight admits nothing. + fn admits(&mut self, subscriber: &Subscriber, path: &PathOwned) -> bool { + let direction = crate::auth::Direction::Subscribe; + let scoped = subscriber.auth.within_limit(direction, path.as_str()); + let Some(name) = self.names.get_mut(path) else { + return scoped; + }; + // A verdict may apply a rename, moving the advertisement off its old token. + let verdict = name.authority.verdict(direction, path.as_str(), scoped); + self.refile(path); + matches!(verdict, crate::auth::Verdict::Admitted) + } + + /// Hold the route at `path` back until a grant admits it, closing its sources if it was + /// attached (tracks in flight end on their own gates, with `Unauthorized`). + fn withhold( + &mut self, + subscriber: &Subscriber, + path: PathOwned, + route: crate::origin::Route, + ) { + tracing::debug!(route = %subscriber.log_path(&path), "withholding announce outside its grant"); + self.routes.insert(path, Held::Withheld(route)); + } + /// Put the peer's route into the origin, so paths under it resolve through this - /// session on demand, or hold it back when the session's limit does not cover it. + /// session on demand, or hold it back when the grant deciding it does not cover it. /// Returns whether it attached. fn offer( &mut self, @@ -4742,12 +4891,8 @@ impl Announced { path: PathOwned, route: crate::origin::Route, ) -> bool { - if !subscriber - .auth - .within_limit(crate::auth::Direction::Subscribe, path.as_str()) - { - tracing::debug!(route = %subscriber.log_path(&path), "withholding announce outside the limit"); - self.routes.insert(path, Held::Withheld(route)); + if !self.admits(subscriber, &path) { + self.withhold(subscriber, path, route); return false; } // An error means the prefix is outside our origin's scope, so don't serve it. @@ -4813,6 +4958,9 @@ impl Announced { /// session from the same peer. Dropping its sources closes their requests. fn withdraw(&mut self, path: &PathOwned) { self.epochs.remove(path); + if let Some(name) = self.names.remove(path) { + self.unfile(path, name.filed.iter()); + } let Some(held) = self.routes.remove(path) else { return; }; @@ -4868,21 +5016,36 @@ impl Announced { } } - /// Apply a new limit: hold back every attached route it no longer covers, closing - /// its sources (tracks in flight end on their own gates, with `Unauthorized`), and - /// attach every withheld one it now does. - fn limit(&mut self, permit: &crate::auth::Permit, subscriber: &Subscriber) { - let changed: Vec = self - .routes + /// Apply a change to the limit: re-decide every advertisement. + fn regrant(&mut self, subscriber: &Subscriber) { + let paths: Vec = self.routes.keys().cloned().collect(); + self.redecide(subscriber, paths); + } + + /// Apply a change to the peer's tokens `ids`: re-decide only the advertisements naming one. + fn renamed(&mut self, subscriber: &Subscriber, ids: BTreeSet) { + let paths = ids .iter() - .filter(|(path, held)| match held { - Held::Attached(_) => !permit.within_limit(path.as_str()), - Held::Withheld(_) => permit.within_limit(path.as_str()), - Held::Declined => false, - }) - .map(|(path, _)| path.clone()) + .filter_map(|id| self.by_token.get(id)) + .flatten() + .cloned() .collect(); - for path in changed { + self.redecide(subscriber, paths); + } + + /// Hold back each attached route in `paths` its grant no longer covers, closing its + /// sources (tracks in flight end on their own gates, with `Unauthorized`), and attach each + /// withheld one it now does. + fn redecide(&mut self, subscriber: &Subscriber, paths: Vec) { + for path in paths { + let attached = match self.routes.get(&path) { + Some(Held::Attached(_)) => true, + Some(Held::Withheld(_)) => false, + Some(Held::Declined) | None => continue, + }; + if attached == self.admits(subscriber, &path) { + continue; + } match self.routes.remove(&path) { Some(Held::Attached(entry)) => { tracing::info!(route = %subscriber.log_path(&path), "announce no longer authorized"); @@ -5288,6 +5451,7 @@ fn buffer_update( start_frame: bounds.start_frame, end_frame: bounds.end_frame, order: active.order, + auth: None, }) } @@ -5345,6 +5509,8 @@ impl Establish { start_frame: bounds.start_frame, end_frame: bounds.end_frame, order: self.subscription.order, + // Under the session's scope: a subscription names no token of its own yet. + auth: None, }; stream.writer.buffer(&lite::ControlType::Subscribe)?; stream.writer.buffer(&msg)?; @@ -6381,6 +6547,7 @@ impl kio::Task for FetchServeRun { // would cache a group indistinguishable from a complete one. A // downstream cap is applied when serving, not when fetching. end_frame: None, + auth: None, }; let buffered = stream .writer diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index 4947b99e5f8..5174d493288 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -466,13 +466,14 @@ pub struct Route { /// The credential the request advertising this route carries, from /// [`auth::Handle`](crate::auth::Handle)'s `add`, or `None` for the session's scope. /// - /// Only a session the auth was presented on carries it, as the token on its - /// moq-transport PUBLISH_NAMESPACE: any other session, the inline NAMESPACE answering a - /// solicited SUBSCRIBE_NAMESPACE from draft 16 on (drafts 14 and 15 answer with a - /// PUBLISH_NAMESPACE, which carries it), and moq-lite (which rides the session's scope) - /// advertise the route unnamed. Swapping it is an update that renews the request with - /// REQUEST_UPDATE from draft 17, or ends and makes it again before. After a - /// reconnect, add the token on the new session and name that. Local, like + /// Only a session the auth was presented on carries it: as the token on its + /// moq-transport PUBLISH_NAMESPACE, or by its AUTH ID on a moq-lite-07 announcement, + /// which the peer then holds to that token's grant alone. Any other session, the inline + /// NAMESPACE answering a solicited SUBSCRIBE_NAMESPACE from draft 16 on (drafts 14 and + /// 15 answer with a PUBLISH_NAMESPACE, which carries it), and older moq-lite advertise + /// the route unnamed. Swapping it is an update that renews the request: in place on + /// moq-lite, with REQUEST_UPDATE from draft 17, or by ending and making it again before. + /// After a reconnect, add the token on the new session and name that. Local, like /// [`Self::source`]: a relay never forwards it. pub auth: Option, diff --git a/rs/moq-net/src/test_interop.rs b/rs/moq-net/src/test_interop.rs index a7474e17a27..b9baa489a1c 100644 --- a/rs/moq-net/src/test_interop.rs +++ b/rs/moq-net/src/test_interop.rs @@ -366,6 +366,73 @@ fn lite_auth_unsupported_interop() { assert!(reader.is_empty()); } +#[derive(serde::Deserialize)] +struct AuthIdEcho { + id: String, + named: Option, + auth: Vec, + subscribe: Vec, + refused: bool, +} + +/// The largest AUTH ID a request can name crosses the Rust/JS lite-07 boundary both ways, on +/// AUTH and as a SUBSCRIBE's `Auth`, and the one past it is refused on both sides. +#[test] +#[ignore = "requires Bun; run by just test interop"] +fn lite_auth_id_interop() { + use crate::coding::Decode; + + let version = lite::Version::Lite07; + let id = u64::MAX - 1; + let auth = lite::Auth { + id, + token: bytes::Bytes::from_static(b"jwt"), + }; + let subscribe = lite::Subscribe { + id: 7, + broadcast: crate::Path::new("room"), + epoch: None, + track: "video".into(), + priority: 1, + order: crate::track::Order::Descending, + max_delay: std::time::Duration::ZERO, + start_group: None, + end_group: None, + start_frame: 0, + end_frame: None, + auth: Some(id), + }; + let auth_bytes = auth.encode_bytes(version).unwrap().to_vec(); + let subscribe_bytes = subscribe.encode_bytes(version).unwrap().to_vec(); + + // No encoder writes it, so build the refused AUTH by hand. + let mut refused = Vec::new(); + let mut w = Encoder::new(&mut refused, version.into()); + let prefix = w.prefix_varint(); + w.varint(u64::MAX).unwrap(); + w.bytes(b"").unwrap(); + w.fill(prefix).unwrap(); + assert!(lite::Auth::decode(&mut Decoder::new(&refused, version.into()), version).is_err()); + + let input = serde_json::json!({ "auth": auth_bytes, "subscribe": subscribe_bytes, "refused": refused }); + let echo: AuthIdEcho = serde_json::from_slice(&bun("auth-id.ts", input)).expect("JS returned its encodings"); + assert_eq!(echo.id, id.to_string()); + assert_eq!(echo.named, Some(id.to_string())); + assert!(echo.refused, "JS accepted an AUTH ID no request can name"); + assert_eq!(echo.auth, auth_bytes); + assert_eq!(echo.subscribe, subscribe_bytes); + assert_eq!( + lite::Auth::decode(&mut Decoder::new(&echo.auth, version.into()), version).unwrap(), + auth + ); + assert_eq!( + lite::Subscribe::decode(&mut Decoder::new(&echo.subscribe, version.into()), version) + .unwrap() + .auth, + Some(id) + ); +} + /// TRACK_STATUS on every draft: the JS publisher decodes what Rust encodes and answers with a /// TRACK_STATUS_OK the Rust subscriber reads, carrying what a SUBSCRIBE_OK would as far as the /// draft has room, or refuses a missing broadcast DOES_NOT_EXIST. diff --git a/rs/moq-net/tests/integration/auth.rs b/rs/moq-net/tests/integration/auth.rs index b18c09d8ec9..8c06d64bd0d 100644 --- a/rs/moq-net/tests/integration/auth.rs +++ b/rs/moq-net/tests/integration/auth.rs @@ -271,6 +271,157 @@ async fn moqt_22_a_revoked_route_auth_remakes_its_announce() { .expect("timed out"); } +/// A lite-07 route naming one of the client's tokens is admitted by that token's grant +/// alone: outside it the route is withheld even where the union of the client's tokens +/// covers it, and the token's revocation withholds it even where the setup grant still does. +#[moq_clock::test] +async fn lite_07_a_named_route_is_held_to_its_token() { + within(async { + let publisher = produce_origin(2); + let relay = produce_origin(1); + let mut pair = connect(Options { + client_publish: Some(publisher.clone()), + server_subscribe: Some(relay.clone()), + server_requests: true, + ..Default::default() + }) + .await; + let mut issued = serve(pair.requests.take().unwrap(), |token| match token { + b"" => Some(grant(&["a", "b"], &[])), + b"t1" => Some(grant(&["b"], &[])), + _ => None, + }); + let (_, _setup) = issued.next().await.unwrap(); + let t1 = pair.client.auth().add("t1").await.expect("t1 granted"); + let (_, t1_issued) = issued.next().await.unwrap(); + let served = relay.consume(); + + let outside = publisher.create_broadcast("a/x").unwrap(); + outside + .announce(origin::Route::default().with_auth(t1.clone())) + .unwrap(); + let inside = publisher.create_broadcast("b/y").unwrap(); + inside.announce(origin::Route::default().with_auth(t1)).unwrap(); + wait_announced(&served, "b/y", true).await; + moq_clock::sleep(Duration::from_secs(1)).await; + assert!( + futures::FutureExt::now_or_never(served.routed("a/x")) + .flatten() + .is_none(), + "admitted outside its token" + ); + + // Moving it to the session's scope admits it. + outside.announce(origin::Route::default()).unwrap(); + wait_announced(&served, "a/x", true).await; + + t1_issued.revoke(SessionError::Unauthorized, "revoked"); + wait_announced(&served, "b/y", false).await; + wait_announced(&served, "a/x", true).await; + assert_eq!(pair.client_transport.close_reason(), None); + }) + .await + .expect("timed out"); +} + +/// A lite-07 route whose token is revoked ends with it: a later update to the route, still +/// naming that token, never falls back to a session scope that covers it, until the route +/// names another token. +#[moq_clock::test] +async fn lite_07_a_route_ends_with_its_revoked_token() { + within(async { + let publisher = produce_origin(2); + let relay = produce_origin(1); + let mut pair = connect(Options { + client_publish: Some(publisher.clone()), + server_subscribe: Some(relay.clone()), + server_requests: true, + ..Default::default() + }) + .await; + let mut issued = serve(pair.requests.take().unwrap(), |_| Some(grant(&["b"], &[]))); + let (_, _setup) = issued.next().await.unwrap(); + let t1 = pair.client.auth().add("t1").await.expect("t1 granted"); + let (_, t1_issued) = issued.next().await.unwrap(); + let served = relay.consume(); + + let broadcast = publisher.create_broadcast("b/y").unwrap(); + broadcast + .announce(origin::Route::default().with_auth(t1.clone())) + .unwrap(); + wait_announced(&served, "b/y", true).await; + + t1_issued.revoke(SessionError::Unauthorized, "revoked"); + wait_announced(&served, "b/y", false).await; + + // A re-price still names the revoked token, so the route stays ended. + let repriced = origin::Route::default().with_cost(origin::Cost::new(7)).with_auth(t1); + broadcast.announce(repriced).unwrap(); + moq_clock::sleep(Duration::from_secs(1)).await; + assert!( + futures::FutureExt::now_or_never(served.routed("b/y")) + .flatten() + .is_none(), + "a route outlived its revoked token" + ); + + let t2 = pair.client.auth().add("t2").await.expect("t2 granted"); + let (_, _t2_issued) = issued.next().await.unwrap(); + broadcast.announce(origin::Route::default().with_auth(t2)).unwrap(); + wait_announced(&served, "b/y", true).await; + assert_eq!(pair.client_transport.close_reason(), None); + }) + .await + .expect("timed out"); +} + +/// Renewing a lite-07 route's token keeps it announced: the old token, withdrawn as the +/// route moves off it, still decides the route until the new one does, and then ends. +#[moq_clock::test] +async fn lite_07_renewing_a_route_token_keeps_it_announced() { + within(async { + let publisher = produce_origin(2); + let relay = produce_origin(1); + let mut pair = connect(Options { + client_publish: Some(publisher.clone()), + server_subscribe: Some(relay.clone()), + server_requests: true, + ..Default::default() + }) + .await; + let mut issued = serve(pair.requests.take().unwrap(), |token| match token { + b"" => Some(grant(&[], &[])), + _ => Some(grant(&["b"], &[])), + }); + let (_, _setup) = issued.next().await.unwrap(); + let served = relay.consume(); + let mut announced = served.announced(); + + let first = pair.client.auth().add("first").await.expect("granted"); + let (_, first_issued) = issued.next().await.unwrap(); + let broadcast = publisher.create_broadcast("b/y").unwrap(); + broadcast.announce(origin::Route::default().with_auth(first)).unwrap(); + wait_announced(&served, "b/y", true).await; + + let second = pair.client.auth().add("second").await.expect("granted"); + let (_, _second_issued) = issued.next().await.unwrap(); + // Dropping the only other handle on the first token withdraws it with the swap. + broadcast.announce(origin::Route::default().with_auth(second)).unwrap(); + assert!(matches!(first_issued.closed().await, Error::Cancel)); + + while let Some(event) = futures::FutureExt::now_or_never(announced.next()).flatten() { + assert!( + !matches!(event, moq_net::announce::Event::End(_)), + "the renewal dropped the route: {event:?}" + ); + } + wait_announced(&served, "b/y", true).await; + assert_eq!(pair.client_transport.close_reason(), None); + }) + .await + .expect("timed out"); +} + /// A pair where the client declines MoQ Auth, so every token the server sees rode a /// request, and the server declines MoQ Solicit, so the client announces with /// PUBLISH_NAMESPACE rather than answering a SUBSCRIBE_NAMESPACE. diff --git a/test/interop/auth-id.ts b/test/interop/auth-id.ts new file mode 100644 index 00000000000..a8490f3c7ba --- /dev/null +++ b/test/interop/auth-id.ts @@ -0,0 +1,43 @@ +// The Rust interop preflight sends lite-07 AUTH and SUBSCRIBE bytes naming the largest +// nameable Auth ID, plus an AUTH carrying the one that cannot be named, and reads ours back. +import { AuthMessage } from "../../js/net/src/lite/auth.ts"; +import { Subscribe } from "../../js/net/src/lite/subscribe.ts"; +import { Version } from "../../js/net/src/lite/version.ts"; +import { Reader, Writer } from "../../js/net/src/stream.ts"; + +const input = JSON.parse(process.argv[2]) as { auth: number[]; subscribe: number[]; refused: number[] }; +const version = Version.DRAFT_07; + +const reader = (bytes: number[]) => new Reader(undefined, Uint8Array.from(bytes), version); +async function encode(write: (w: Writer) => Promise): Promise { + const chunks: Uint8Array[] = []; + const writer = new Writer( + new WritableStream({ + write(chunk) { + chunks.push(new Uint8Array(chunk)); + }, + }), + version, + ); + await write(writer); + writer.close(); + await writer.closed; + return [...Buffer.concat(chunks)]; +} + +const auth = await AuthMessage.decode(reader(input.auth), version); +const subscribe = await Subscribe.decode(reader(input.subscribe), version); +const refused = await AuthMessage.decode(reader(input.refused), version).then( + () => false, + () => true, +); + +process.stdout.write( + JSON.stringify({ + id: auth.id.toString(), + named: subscribe.auth?.toString(), + auth: await encode((w) => auth.encode(w, version)), + subscribe: await encode((w) => subscribe.encode(w, version)), + refused, + }), +); diff --git a/test/justfile b/test/justfile index 4c972c5e43c..1a16631a73c 100644 --- a/test/justfile +++ b/test/justfile @@ -41,7 +41,7 @@ harness: # draft, and Lite AUTH_ERROR NOT_SUPPORTED after a grant in both directions. [positional-arguments] interop *args: - just rs test -p moq-net --lib varint_interop ietf_datagram_interop ietf_track_status_interop auth_unsupported_interop --run-ignored all + just rs test -p moq-net --lib varint_interop ietf_datagram_interop ietf_track_status_interop auth_unsupported_interop auth_id_interop --run-ignored all ./interop/interop.sh "$@" # Nightly main vs newest installable registry releases: tokens, moq-lite session