From 7ddbcf1518039c43d5aaaf0dcb8d90f0c344483d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 11:08:12 -0700 Subject: [PATCH 1/3] quest: claim quest/m1/setup-token Co-Authored-By: Claude Opus 5.5 From e802c6d4d184eb1bcb3c8db78ec827e56b0a43f1 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 11:37:56 -0700 Subject: [PATCH 2/3] feat(net): the SETUP AUTHORIZATION TOKEN option reaches the verifier Decode the moq-transport SETUP AUTHORIZATION TOKEN option into moq_net::setup::Token, expose it on server::Handshake::token() and moq_tokio::server::Request::token(), and forward it as moq_auth::Request.token. moq auth serve verifies a type-0 token like ?jwt=. A SETUP that fails to parse now closes the session with its code instead of dropping the transport. Co-Authored-By: Claude Opus 5.5 --- doc/bin/relay/auth.md | 20 ++- doc/concept/standard.md | 9 ++ doc/lib/rs/moq-auth.md | 4 +- js/auth/src/contract.test.ts | 21 +++ js/auth/src/contract.ts | 15 +- js/net/src/ietf/index.ts | 1 + js/net/src/ietf/token.test.ts | 155 ++++++++++++++++++ js/net/src/ietf/token.ts | 119 ++++++++++++++ quest/m1/README.md | 1 - quest/m1/auth/request-token.md | 5 +- quest/m1/auth/token-in-band.md | 4 +- quest/m1/setup-token.md | 67 -------- quest/m2/cat/README.md | 11 +- quest/m2/cat/present.md | 2 - quest/m2/cat/verify.md | 5 - rs/moq-auth/src/request.rs | 46 +++++- rs/moq-auth/src/serve.rs | 106 +++++++++++-- rs/moq-net/src/ietf/mod.rs | 1 + rs/moq-net/src/ietf/session.rs | 5 + rs/moq-net/src/ietf/token.rs | 233 ++++++++++++++++++++++++++++ rs/moq-net/src/lib.rs | 2 +- rs/moq-net/src/server.rs | 186 ++++++++++++++++++++-- rs/moq-net/src/setup.rs | 28 +++- rs/moq-relay/Cargo.toml | 1 + rs/moq-relay/src/auth.rs | 4 + rs/moq-relay/src/session.rs | 17 +- rs/moq-relay/tests/auth_lifetime.rs | 45 ++++++ rs/moq-tokio/src/server.rs | 8 + 28 files changed, 984 insertions(+), 137 deletions(-) create mode 100644 js/net/src/ietf/token.test.ts create mode 100644 js/net/src/ietf/token.ts delete mode 100644 quest/m1/setup-token.md create mode 100644 rs/moq-net/src/ietf/token.rs diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index f9289e3f61..596f32e4a6 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -30,12 +30,14 @@ grant back. The schemas are `moq_auth::Request` and `moq_auth::Grant` `http` for a one-shot `/fetch` or `/announced` request), `remote` and `local` socket addresses, `server_name` (the SNI or the host the client addressed), `alpn` (the negotiated moq protocol), `path` exactly as dialed, `query` raw, -`role` the client declared at SETUP (`publisher`, `subscriber`, absent for -both), and `tls` with the verified client certificate when one was presented: +`token` with the credential a moq-transport client put in its SETUP's +`AUTHORIZATION TOKEN` option (`kind`, the draft's Token Type, and `value`, the +bytes as unpadded base64url), `role` the client declared at SETUP +(`publisher`, `subscriber`, absent for both), and `tls` with the verified client certificate when one was presented: `name` (first SAN DNS name, else CN, else the fingerprint; a server cannot tell which), `fingerprint` (SHA-256 of the leaf), `expires`, `issuer`. Nothing is parsed on the server's behalf: the `jwt` query parameter is a convention of `moq auth serve`, not of -the relay. An `end` adds `reason`, `duration` in seconds, and `bytes` sent and +the relay, and the relay forwards a SETUP token without reading it. An `end` adds `reason`, `duration` in seconds, and `bytes` sent and received. **Grant.** `publish` and `subscribe` as pattern unions (`foo/**` is a subtree, @@ -98,7 +100,7 @@ moq auth sessions --internal-url http://127.0.0.1:9101 --path 'demo/**' ``` `GET /sessions` is the dry run: the same matches, each request plus start -time, with `query` omitted so a jwt on the plane cannot be replayed. A +time, with `query` and `token` omitted so a credential on the plane cannot be replayed. A matching POST returns 202 and the ids; no match is 200 with an empty list. An unknown field, including `query`, is 400. One node, no cluster fan-out: the server already knows each session's `node` from `connect` and calls that @@ -142,7 +144,9 @@ moq auth verify --key public.jwk --in alice.jwt moq auth serve --key public.jwk # or --key-dir /etc/moq/keys/ for {kid}.jwk rotation ``` -The client dials `https://relay.example.com/rooms/123?jwt=`. HMAC +The client dials `https://relay.example.com/rooms/123?jwt=`. A +moq-transport client can instead put the JWT in its SETUP's `AUTHORIZATION +TOKEN` option with Token Type 0; `moq auth serve` verifies it the same way. HMAC (HS256/384/512), RSA (RS/PS), ECDSA (ES256/384), and EdDSA keys all work. A key can itself be **scoped** at generation (`--root`, `--publish`, `--subscribe`), after which it can never sign a broader token. @@ -237,14 +241,16 @@ moq auth serve --listen 127.0.0.1:4440 \ Policy runs in this order and stops at the first that applies: -1. A `jwt` in the query is verified against `--key FILE` or `--key-dir DIR` +1. A JWT, from the `jwt` query or a SETUP `token` of `kind` 0, is verified + against `--key FILE` or `--key-dir DIR` (by `kid`, read per request so rotation needs no restart). Its claims are authorized at the dialed path (`Claims::authorize`): the path may equal the root, extend it (which narrows the grant), or be a parent of it (the grant stays anchored at the root). Residuals become the grant. An unrelated path, or one the token grants nothing at, is refused. A malformed, expired, or unknown-key token is refused; it never falls - through to the anonymous rules. + through to the anonymous rules. So is a SETUP token of any other `kind`, + and a session presenting both a SETUP token and a `jwt` query. 2. A verified client certificate gets `--mtls-publish` and `--mtls-subscribe`, and nothing when they are empty. Cluster peers are admitted this way; a mesh needs `'**'` for both. diff --git a/doc/concept/standard.md b/doc/concept/standard.md index f2c7e9929e..282dcaf880 100644 --- a/doc/concept/standard.md +++ b/doc/concept/standard.md @@ -41,6 +41,15 @@ because they do not issue joining fetches. Other publishers may replay a cached backlog for that filter; selecting the next group instead would leave static tracks waiting for a group that never arrives. +A client may present one credential in its `SETUP` with the `AUTHORIZATION +TOKEN` option. The server reads a value (`USE_VALUE`, or `REGISTER`, which it +treats as a value since it advertises no token cache) and hands its Token Type +and bytes to the application unverified; a relay forwards them to its +[auth server](/bin/relay/auth#the-contract). An alias reference (`DELETE`, +`USE_ALIAS`) closes the session with `PROTOCOL_VIOLATION`, a structure that +does not decode with `KEY_VALUE_FORMATTING_ERROR`, and a second token is +refused. + Several project drafts extend the IETF wire without breaking it, since `SETUP` ignores unknown parameters: [cluster](/draft/moq-cluster) routing hop lists, [solicit](/draft/moq-solicit) to make announcements opt-in, diff --git a/doc/lib/rs/moq-auth.md b/doc/lib/rs/moq-auth.md index ddab0dc5a9..b406e0adf7 100644 --- a/doc/lib/rs/moq-auth.md +++ b/doc/lib/rs/moq-auth.md @@ -13,10 +13,10 @@ Everything a party needs to ask for or answer an authorization on answers the relay, in a service that mints tokens for clients, or in your own accept loop that decides in process. -- **Request and grant**: `Request` is the JSON a relay POSTs per session event (`connect`, `revalidate`, `end`) with everything it knows: id, node, transport, addresses, SNI and ALPN, the raw path and query, the declared role, and the verified certificate facts. `Grant` is the answer: `publish` and `subscribe` pattern unions, an optional `root` alias, `expires`, `revalidate`, `tier`, and `peer`, which marks the session as a cluster peer so the routes it announces report `Source::Peer`. `Grant::validate` refuses a grant that names nothing, asks to be revalidated without a bound or at no interval, or has already expired, with a few seconds of clock skew on `expires`. +- **Request and grant**: `Request` is the JSON a relay POSTs per session event (`connect`, `revalidate`, `end`) with everything it knows: id, node, transport, addresses, SNI and ALPN, the raw path and query, the moq-transport SETUP `token` (its Token Type and bytes, base64url on the wire), the declared role, and the verified certificate facts. `Grant` is the answer: `publish` and `subscribe` pattern unions, an optional `root` alias, `expires`, `revalidate`, `tier`, and `peer`, which marks the session as a cluster peer so the routes it announces report `Source::Peer`. `Grant::validate` refuses a grant that names nothing, asks to be revalidated without a bound or at no interval, or has already expired, with a few seconds of clock skew on `expires`. - **Lease**: `lease::Producer` and `lease::Consumer` are the handle a session holds for its grant. The consumer reads the current grant, waits for a change, and learns why the lease ended; the producer updates and revokes. Either side's terminal call returns the reason the lease actually ended with, so whichever got there first is what both report. `Consumer::fixed` is a grant nobody drives. `Consumer::revalidate` nudges a re-check now and the producer observes it via `poll_revalidate` or `revalidate_requested`, which is how the relay's session push lands. Whoever runs the accept loop builds the producer, so an embedder decides in process with no trait and no HTTP. Enforcing `expires` is the holder's job; the `Client` driver also revokes at expiry so its `end` event goes out. `Grant::deadline()` (feature `tokio`, also enabled by `client` and `serve`) snapshots expiry on Tokio's clock. Call it once per accepted grant and retain the deadline: future expiries are unchanged, and one already less than five seconds late gets the remainder of that skew window. - **Client**: `Client::new(url, tls)` and `Client::connect(request)` drive a lease against an auth server over `https://`, `unix://`, or loopback `http://`: revalidate on cadence with jittered backoff through an outage until `expires`, revoke on a 401/403 or an invalid grant, and POST `end` with the reason, duration, and byte totals the session reported through `lease::Consumer::close` when it ended. Dropping the consumer reports zero bytes. `end.reason` is `dropped`, `expired`, `refused`, `invalid`, or the session's own classification. -- **Server**: `serve::Policy` and `serve::Server` (feature `serve`) are the reference auth server behind `moq auth serve`: a `jwt` in the query verified against a key file or a `{kid}.jwk` directory, an explicit grant for verified certificates, the anonymous permissions, a tier, the revalidation cadence, a default `expires`, and live session caps per token and per remote address. A token is authorized at the dialed path with `Claims::authorize`; residuals become the grant. `Server::router` is an axum `POST /` you can mount in your own service. +- **Server**: `serve::Policy` and `serve::Server` (feature `serve`) are the reference auth server behind `moq auth serve`: a JWT from the `jwt` query or a type-0 SETUP token, verified against a key file or a `{kid}.jwk` directory (a token of another type, or both at once, is refused), an explicit grant for verified certificates, the anonymous permissions, a tier, the revalidation cadence, a default `expires`, and live session caps per token and per remote address. A token is authorized at the dialed path with `Claims::authorize`; residuals become the grant. `Server::router` is an axum `POST /` you can mount in your own service. - **Keys**: generate HS256/384/512, RS256/384/512, PS256/384/512, ES256/384, or EdDSA keys as JWKs, with a `kid` for rotation and an optional immutable scope that caps every token the key signs. - **Claims**: `root`, `publish`, `subscribe`, `exp`, `iat`. Grants are [`Pattern`](https://docs.rs/moq-pattern) unions: `foo` is one broadcast, `foo/**` is a subtree, `**` is everything. `Key::sign` and `Key::verify` handle the signature and expiry. Legacy `put`/`get` prefix claims and scopes read as subtrees (`p` is `p/**`), and grants that are all subtrees are written that way so older verifiers accept them. - **Authorization**: `Claims::authorize(path)` scopes verified claims to the path a client dialed and returns the publish and subscribe patterns relative to it, exactly as `moq auth serve` does. The relay forwards the raw path and enforces the grant it gets. diff --git a/js/auth/src/contract.test.ts b/js/auth/src/contract.test.ts index 3aed509742..8369ffb24b 100644 --- a/js/auth/src/contract.test.ts +++ b/js/auth/src/contract.test.ts @@ -52,6 +52,27 @@ test("an end request carries its facts beside the rest", () => { expect(invalid.reason).toBe("invalid"); }); +test("a SETUP token parses as the Rust vector", () => { + // What `moq_auth::Request` serializes a CAT of bytes 00 fb ff to. + const request = RequestSchema.parse( + JSON.parse( + '{"id":"00ff","event":"connect","node":"relay-1","transport":"quic","path":"/demo/room","token":{"kind":1,"value":"APv_"}}', + ), + ); + expect(request.token).toEqual({ kind: 1, value: "APv_" }); + + expect(() => + RequestSchema.parse({ + id: "1", + event: "connect", + node: "n", + transport: "quic", + path: "/", + token: { kind: 0, value: "AP+/" }, + }), + ).toThrow(); +}); + test("a connect must not carry end facts, and an unknown transport is refused", () => { expect(() => RequestSchema.parse({ id: "1", event: "end", node: "n", transport: "quic", path: "/" })).toThrow(); expect(() => diff --git a/js/auth/src/contract.ts b/js/auth/src/contract.ts index 3dfd527b73..f75fe18083 100644 --- a/js/auth/src/contract.ts +++ b/js/auth/src/contract.ts @@ -36,6 +36,15 @@ export const PeerSchema = z.object({ }); export type Peer = z.infer; +/** A credential from a moq-transport SETUP's `AUTHORIZATION TOKEN` option, unparsed. */ +export const TokenSchema = z.object({ + /** The moq-transport Token Type: 0 is negotiated out of band (a JWT to `moq auth serve`), 1 is a Common Access Token. */ + kind: z.int().check(z.nonnegative()), + /** The token bytes, base64url without padding. */ + value: z.string().check(z.regex(/^[A-Za-z0-9_-]*$/)), +}); +export type Token = z.infer; + /** Byte totals for a session, both directions from the relay's point of view. */ export const BytesSchema = z.object({ /** Bytes the relay sent to the peer. */ @@ -64,6 +73,8 @@ const BaseRequestSchema = z.object({ path: z.string(), /** The raw query string, without the leading `?`. */ query: z.optional(z.string()), + /** The credential a moq-transport client presented in its SETUP. */ + token: z.optional(TokenSchema), /** The direction the client declared at SETUP; absent means both. */ role: z.optional(RoleSchema), /** The verified client certificate, when one was presented. */ @@ -73,8 +84,8 @@ const BaseRequestSchema = z.object({ /** * Everything a relay knows about a session, sent to the auth server on every event. * - * Nothing is parsed on the relay's behalf: the server keys policy on the raw `path` - * and `query`, so no query parameter is special. The same shape carries every event; + * Nothing is parsed on the relay's behalf: the server keys policy on the raw `path`, + * `query`, and `token`, so no query parameter is special. The same shape carries every event; * an `end` adds what the session did. */ export const RequestSchema = z.discriminatedUnion("event", [ diff --git a/js/net/src/ietf/index.ts b/js/net/src/ietf/index.ts index e1ebb8199d..9c895e9e29 100644 --- a/js/net/src/ietf/index.ts +++ b/js/net/src/ietf/index.ts @@ -16,5 +16,6 @@ export * from "./solicit.ts"; export * from "./subscribe.ts"; export * from "./subscribe_namespace.ts"; export * from "./subscriber.ts"; +export * from "./token.ts"; export * from "./track.ts"; export * from "./version.ts"; diff --git a/js/net/src/ietf/token.test.ts b/js/net/src/ietf/token.test.ts new file mode 100644 index 0000000000..5d4b4137d9 --- /dev/null +++ b/js/net/src/ietf/token.test.ts @@ -0,0 +1,155 @@ +import { expect, test } from "bun:test"; +import { SessionCode, SessionError } from "../error.ts"; +import { Reader, Writer } from "../stream.ts"; +import * as Varint from "../varint.ts"; +import { SetupOption, SetupOptions } from "./parameters.ts"; +import { TOKEN_OUT_OF_BAND, type Token, tokenFromSetup, tokenIntoSetup } from "./token.ts"; +import { type IetfVersion, Version } from "./version.ts"; + +const VERSIONS: IetfVersion[] = [ + Version.DRAFT_14, + Version.DRAFT_15, + Version.DRAFT_16, + Version.DRAFT_17, + Version.DRAFT_18, + Version.DRAFT_19, + Version.DRAFT_20, + Version.DRAFT_21, + Version.DRAFT_22, +]; + +// A kind past one varint byte and a value that is not text, matching the Rust tests. +const TOKEN: Token = { kind: 300n, value: new Uint8Array([0x00, 0xff, 0x03, 0x80, 0x6a]) }; + +function leadingOnes(version: IetfVersion): boolean { + return version !== Version.DRAFT_14 && version !== Version.DRAFT_15 && version !== Version.DRAFT_16; +} + +function varint(v: bigint, version: IetfVersion): Uint8Array { + return leadingOnes(version) ? Varint.encodeLeadingOnes(v) : Varint.encode(v); +} + +/** The option as it arrives, after a trip through the SETUP parameter block. */ +async function received(params: SetupOptions, version: IetfVersion): Promise { + const chunks: Uint8Array[] = []; + const writer = new Writer( + new WritableStream({ + write(chunk) { + chunks.push(new Uint8Array(chunk)); + }, + }), + version, + ); + await params.encode(writer, version); + writer.close(); + await writer.closed; + + const bytes = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.byteLength, 0)); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return SetupOptions.decode(new Reader(undefined, bytes, version), version); +} + +/** A raw Token structure: varint fields then a value. */ +function structure(version: IetfVersion, fields: bigint[], value: Uint8Array = new Uint8Array()): SetupOptions { + const parts = [...fields.map((field) => varint(field, version)), value]; + const raw = new Uint8Array(parts.reduce((total, part) => total + part.length, 0)); + let offset = 0; + for (const part of parts) { + raw.set(part, offset); + offset += part.length; + } + const params = new SetupOptions(); + params.setBytes(SetupOption.AuthorizationToken, raw); + return params; +} + +function sessionCode(fn: () => unknown): SessionCode | undefined { + try { + fn(); + } catch (err) { + if (err instanceof SessionError) return err.code; + throw err; + } + return undefined; +} + +test("USE_VALUE round trips on every draft", async () => { + for (const version of VERSIONS) { + const params = new SetupOptions(); + tokenIntoSetup(params, TOKEN, version); + expect(tokenFromSetup(await received(params, version), version)).toEqual(TOKEN); + } +}); + +/** The same bytes `rs/moq-net/src/ietf/token.rs` asserts, so the two agree on the wire. */ +test("the encoding matches the cross-language vector", () => { + const token: Token = { kind: 300n, value: new Uint8Array([0x00, 0xff]) }; + for (const [version, expected] of [ + [Version.DRAFT_14, [0x03, 0x41, 0x2c, 0x00, 0xff]], + [Version.DRAFT_17, [0x03, 0x81, 0x2c, 0x00, 0xff]], + ] as const) { + const params = new SetupOptions(); + tokenIntoSetup(params, token, version); + expect(params.getBytes(SetupOption.AuthorizationToken)).toEqual(new Uint8Array(expected)); + } +}); + +test("an absent option is no token", () => { + for (const version of VERSIONS) { + expect(tokenFromSetup(new SetupOptions(), version)).toBeUndefined(); + } +}); + +test("an empty value is a token", () => { + for (const version of VERSIONS) { + const params = structure(version, [0x3n, TOKEN_OUT_OF_BAND]); + expect(tokenFromSetup(params, version)).toEqual({ kind: TOKEN_OUT_OF_BAND, value: new Uint8Array() }); + } +}); + +/** We advertise no cache, so a registration is the draft's own USE_VALUE. */ +test("REGISTER is a value", () => { + for (const version of VERSIONS) { + const params = structure(version, [0x1n, 7n, TOKEN.kind], TOKEN.value); + expect(tokenFromSetup(params, version)).toEqual(TOKEN); + } +}); + +test("an alias reference is a protocol violation", () => { + for (const version of VERSIONS) { + for (const aliasType of [0x0n, 0x2n]) { + const params = structure(version, [aliasType, 7n]); + expect(sessionCode(() => tokenFromSetup(params, version))).toBe(SessionCode.ProtocolViolation); + } + } +}); + +test("an undecodable structure is a formatting error", () => { + for (const version of VERSIONS) { + for (const fields of [[], [0x3n], [0x1n], [0x1n, 7n], [0x4n, 0n]]) { + const params = structure(version, fields); + expect(sessionCode(() => tokenFromSetup(params, version))).toBe(SessionCode.KeyValueFormatting); + } + } +}); + +/** One credential per connection: a second token is refused, not unioned or dropped. */ +test("two tokens are refused", async () => { + for (const version of VERSIONS) { + const value = [...varint(0x3n, version), ...varint(TOKEN_OUT_OF_BAND, version)]; + const key = SetupOption.AuthorizationToken; + // Delta-encoded from draft-16, so the repeat is a delta of zero. + const repeat = version === Version.DRAFT_14 || version === Version.DRAFT_15 ? key : 0n; + const count = leadingOnes(version) ? [] : [...varint(2n, version)]; + const entry = (k: bigint) => [...varint(k, version), ...varint(BigInt(value.length), version), ...value]; + const bytes = new Uint8Array([...count, ...entry(key), ...entry(repeat)]); + + await expect(SetupOptions.decode(new Reader(undefined, bytes, version), version)).rejects.toThrow( + /duplicate parameter/, + ); + } +}); diff --git a/js/net/src/ietf/token.ts b/js/net/src/ietf/token.ts new file mode 100644 index 0000000000..818466696f --- /dev/null +++ b/js/net/src/ietf/token.ts @@ -0,0 +1,119 @@ +import { SessionCode, SessionError } from "../error.ts"; +import * as Varint from "../varint.ts"; +import { SetupOption, type SetupOptions } from "./parameters.ts"; +import { type IetfVersion, Version } from "./version.ts"; + +/** + * The `AUTHORIZATION TOKEN` Setup Option (draft-ietf-moq-transport-21 section 9.1.4). + * + * The value is the Token structure of section 8.9: an Alias Type, then fields that type + * selects. We advertise no `MAX_AUTH_TOKEN_CACHE_SIZE`, so its default of 0 means no alias + * is ever registered and every token arrives by value. + * + * Mirrors `rs/moq-net/src/ietf/token.rs`. + * + * @module + * @internal + */ + +/** Retire a registered alias. */ +const DELETE = 0x0n; +/** Register an alias for this type and value, then use them. */ +const REGISTER = 0x1n; +/** Use the type and value a registered alias names. */ +const USE_ALIAS = 0x2n; +/** Use the type and value carried inline. */ +const USE_VALUE = 0x3n; + +/** + * A credential presented in a SETUP's `AUTHORIZATION TOKEN` option. + * + * @internal + */ +export interface Token { + /** The wire Token Type, naming how `value` is encoded. */ + kind: bigint; + /** The token itself. */ + value: Uint8Array; +} + +/** + * Token Type 0: a format the endpoints agreed on out of band, such as a JWT. + * + * @internal + */ +export const TOKEN_OUT_OF_BAND = 0x0n; + +/** + * Token Type 1: a Common Access Token (draft-ietf-moq-c4m). + * + * @internal + */ +export const TOKEN_CAT = 0x1n; + +/** Draft-17 replaced QUIC's two-bit-length varint with a leading-ones one. */ +function leadingOnes(version: IetfVersion): boolean { + return version !== Version.DRAFT_14 && version !== Version.DRAFT_15 && version !== Version.DRAFT_16; +} + +/** + * The token the peer's SETUP presented, if any. + * + * A second token is already refused as a duplicate option by {@link SetupOptions}: one + * credential per connection. + * + * @throws {SessionError} `ProtocolViolation` for an alias reference, which nothing before + * SETUP could have registered, or `KeyValueFormatting` for a structure that cannot be decoded. + * @internal + */ +export function tokenFromSetup(params: SetupOptions, version: IetfVersion): Token | undefined { + const raw = params.getBytes(SetupOption.AuthorizationToken); + if (raw === undefined) return undefined; + + // Section 8.9: a structure that cannot be decoded closes with KEY_VALUE_FORMATTING_ERROR. + const malformed = (cause?: unknown) => + new SessionError(SessionCode.KeyValueFormatting, { cause, reason: "malformed AUTHORIZATION TOKEN" }); + const unvarint = (buf: Uint8Array): [bigint, Uint8Array] => { + try { + return leadingOnes(version) ? Varint.decodeLeadingOnes(buf) : Varint.decodeBigInt(buf); + } catch (err) { + throw malformed(err); + } + }; + + let [aliasType, rest] = unvarint(raw); + switch (aliasType) { + case USE_VALUE: + break; + // With no cache, section 9.1.4 treats a registration as a value; the alias is unused. + case REGISTER: + [, rest] = unvarint(rest); + break; + // Section 9.1.4: nothing can have been registered before SETUP. + case DELETE: + case USE_ALIAS: + throw new SessionError(SessionCode.ProtocolViolation, { reason: "AUTHORIZATION TOKEN alias in SETUP" }); + default: + throw malformed(); + } + + const [kind, value] = unvarint(rest); + return { kind, value: value.slice() }; +} + +/** + * Present `token` in our SETUP, by value. + * + * @internal + */ +export function tokenIntoSetup(params: SetupOptions, token: Token, version: IetfVersion) { + const varint = (v: bigint) => (leadingOnes(version) ? Varint.encodeLeadingOnes(v) : Varint.encode(v)); + const aliasType = varint(USE_VALUE); + const kind = varint(token.kind); + + const out = new Uint8Array(aliasType.length + kind.length + token.value.length); + out.set(aliasType, 0); + out.set(kind, aliasType.length); + out.set(token.value, aliasType.length + kind.length); + params.setBytes(SetupOption.AuthorizationToken, out); +} diff --git a/quest/m1/README.md b/quest/m1/README.md index b97087b518..c896e84725 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -46,7 +46,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Wildcard](/quest/m1/wildcard/README.md) - a relay resolves subscriptions against advertised prefixes, a service claims the prefix it could serve and refuses the rest instead of enumerating broadcasts, and the browser player treats a covering claim as availability - [Tooling](/quest/m1/tooling/README.md) - justfiles become a one-line menu over `sh/`, one impact map scopes CI, and every workflow step runs a recipe - [Path patterns](/quest/m1/path-patterns.md) - one matcher for every predicate over broadcast paths: tokens, origins, interest -- [Setup token](/quest/m1/setup-token.md) - a moq-transport SETUP `AUTHORIZATION TOKEN` reaches the accepted handshake and the relay's auth request, so a verifier can run on it - [In-band auth](/quest/m1/auth/README.md) - a session tells its peer what it may publish and subscribe to, unions tokens presented in band, and fails loud on an out-of-scope publish - [Tests under load](/quest/m1/test-flakes.md) - three tests that time out or run out of file descriptors under `just check` are fixed at the cause - [Decoded frame ownership](/quest/m1/decoded-frames.md) - retain moq-video Frames across bindings, with native views or CPU conversion as needed diff --git a/quest/m1/auth/request-token.md b/quest/m1/auth/request-token.md index c6125f8295..34f77640e1 100644 --- a/quest/m1/auth/request-token.md +++ b/quest/m1/auth/request-token.md @@ -16,8 +16,8 @@ on the unknown key, and the legacy drafts silently ignore it. ## Plan -- Decode with the [Setup token](/quest/m1/setup-token.md) structure and - rules: `USE_VALUE` yields the token, `REGISTER` is a value since we +- Decode with the SETUP option's structure and rules + (`rs/moq-net/src/ietf/token.rs`, `js/net/src/ietf/token.ts`): `USE_VALUE` yields the token, `REGISTER` is a value since we advertise no `MAX_AUTH_TOKEN_CACHE_SIZE`, and `DELETE` or `USE_ALIAS` closes with `PROTOCOL_VIOLATION`. Both decoder families change: the strict `decode_params!` path, which rejects the key today, and the generic KVP @@ -64,6 +64,5 @@ to). Wire: none new; the parameter already exists in every supported draft. ## Required -- [Setup token](/quest/m1/setup-token.md) - supplies the token decoder - [Relay tokens](/quest/m1/auth/relay-refresh.md) - supplies the per-token lease and `Client::attach` path each request uses diff --git a/quest/m1/auth/token-in-band.md b/quest/m1/auth/token-in-band.md index 2c38ad0345..ee68cf6fb7 100644 --- a/quest/m1/auth/token-in-band.md +++ b/quest/m1/auth/token-in-band.md @@ -43,7 +43,7 @@ AUTH can carry the full grant once the pattern-interest prerequisite lands. stream; every other configured token gets its own AUTH stream on an AUTH-capable session, so no token is ever granted twice. On moq-transport the first token also rides the AUTHORIZATION TOKEN setup option - (`ParameterBytes::AuthorizationToken`, `USE_VALUE`, token type 0), which + (`ietf::token::into_setup`, `USE_VALUE`, token type 0), which scopes at accept the way the URL does. - The relay admits on the URL, then widens. An anonymous connection today is admitted with the public grant when one is configured and refused @@ -77,5 +77,3 @@ Additive. token setters sit beside - [moq-transport](/quest/m1/auth/moq-transport.md) - supplies the IETF AUTH exchange the setup-option token pairs with -- [Setup token](/quest/m1/setup-token.md) - supplies `setup::Token` and the - setup-option encoder diff --git a/quest/m1/setup-token.md b/quest/m1/setup-token.md deleted file mode 100644 index f90d4f5d97..0000000000 --- a/quest/m1/setup-token.md +++ /dev/null @@ -1,67 +0,0 @@ -# [M] The SETUP AUTHORIZATION TOKEN option reaches the verifier - -## Goal - -A moq-transport peer's SETUP `AUTHORIZATION TOKEN` option (key `0x03`, -draft-14 through the newest supported draft) is decoded by `rs/moq-net` into -a token type and value, exposed on the accepted handshake so an app can run -its own verifier, and forwarded by the relay in `moq_auth::Request` as -`token`, so an auth server can verify it. Today the option is stored as -opaque bytes and ignored. - -Boundaries: SETUP only. The same parameter on SUBSCRIBE, REQUEST_UPDATE, and -every other request is [Request tokens](/quest/m1/auth/request-token.md): a -fallback that authorizes only that request. No client configuration: [Token in -band](/quest/m1/auth/token-in-band.md) owns presenting one. - -## Plan - -- `moq_net::setup::Token { kind: u64, value: Vec }`, `kind` being the - wire Token Type: `Token::OUT_OF_BAND` is `0x0` and `Token::CAT` is the - `0x01` c4m-01 registers. `setup` becomes a public module exporting only - `Token`; the SETUP wire types stay crate-private. Decode the draft-21 - section 8.9 structure: Alias Type `USE_VALUE` yields the token; `REGISTER` - is treated as `USE_VALUE` because we advertise no - `MAX_AUTH_TOKEN_CACHE_SIZE` (the default of 0 makes that the draft's own - rule), so no alias state exists; `DELETE` or `USE_ALIAS` in SETUP closes - with `PROTOCOL_VIOLATION`; an undecodable structure closes with - `KEY_VALUE_FORMATTING_ERROR`. More than one token in SETUP is refused: one - credential per connection is the shape the contract has. -- Encode side: `Parameters` learns to write a `USE_VALUE` token, so [Token - in band](/quest/m1/auth/token-in-band.md) and - [Present](/quest/m2/cat/present.md) have nothing to add on the wire. -- `moq_net::server::Handshake::token() -> Option<&setup::Token>` beside - `path()` and `role()`, carried through the `Legacy` (draft-14 to 16) and - `PeerSetup` (draft-17+) paths; `moq_tokio::server::Request::token()` - forwards it; lite sessions return `None`. -- `moq_auth::Request.token: Option`, - serialized with `serde_with` base64 like the rest of the request. The relay - fills it in `request_for` in `rs/moq-relay/src/auth.rs`, beside the URL - query it already forwards. `moq auth serve` treats kind `0x0` as the JWT - the deployment negotiated out of band, verified exactly like `?jwt=`, and - refuses any other kind naming the code until - [Verify](/quest/m2/cat/verify.md) teaches it `0x01`. A request carrying - both a SETUP token and a `jwt` query is refused naming both. -- `js/net/src/ietf/parameters.ts` mirrors the decode and encode rules. No JS - accept-side API: nothing in `js/net` authorizes an IETF session. -- Docs: `doc/concept` on the IETF binding gains the option; - `doc/lib/rs/moq-auth.md` gains the request field; `doc/bin/relay/auth.md` - says the relay forwards the option and `moq auth serve` verifies a type-0 - token like `?jwt=`. -- Tests: decode and encode round trips for every alias type on every draft - in Rust and JS; `DELETE`/`USE_ALIAS` in SETUP close the session; `REGISTER` - is accepted as a value; two tokens in SETUP are refused; the handshake - exposes the bytes on one legacy and one draft-17+ session and a lite - session reports none; the relay forwards the bytes to a wiremock auth - server byte for byte; `moq auth serve` admits a type-0 JWT, refuses an - unknown kind, and refuses a token plus `?jwt=`. - -Public API: additive on `moq-net`, `moq-tokio`, `moq-auth`, and `js/net`. -Wire: none new; the option already exists in every supported draft. - -## Related - -- [Token in band](/quest/m1/auth/token-in-band.md) - writes the same option - from the client side with the shared `setup::Token` -- [Common Access Tokens](/quest/m2/cat/README.md) - verifies and presents a - CAT through this option diff --git a/quest/m2/cat/README.md b/quest/m2/cat/README.md index 8460cd1520..4c29c9c4c5 100644 --- a/quest/m2/cat/README.md +++ b/quest/m2/cat/README.md @@ -41,9 +41,9 @@ Boundaries decided while planning: ## Plan -Order: the wire first so a token reaches the auth server, which is the -standalone [Setup token](/quest/m1/setup-token.md) quest; verification; -then our clients present one. Everything rides `moq_auth::Request` and +Order: the SETUP option already reaches the auth server as +`moq_auth::Request.token`; verification comes first, then our clients present +one. Everything rides `moq_auth::Request` and `moq auth serve`, which shipped on dev. The JWT types sit at the crate root; the verify quest moves them under `moq_auth::jwt` so `cat` is a sibling module rather than a set of prefixed names. @@ -56,11 +56,6 @@ module rather than a set of prefixed names. - [Present](/quest/m2/cat/present.md) - a CAT is one kind of configured token, riding the SETUP option the in-band token quest already writes -## Required - -- [Setup token](/quest/m1/setup-token.md) - the SETUP option reaches - `moq_auth::Request` as `token` - ## Related - [In-band auth](/quest/m1/auth/README.md) - credentials presented after diff --git a/quest/m2/cat/present.md b/quest/m2/cat/present.md index cb318d20e1..7a2cf3d821 100644 --- a/quest/m2/cat/present.md +++ b/quest/m2/cat/present.md @@ -34,8 +34,6 @@ Public API: additive on `moq-tokio` and `js/net`. Wire: none. ## Required -- [Setup token](/quest/m1/setup-token.md) - the server-side exposure - and the shared `setup::Token` - [Verify](/quest/m2/cat/verify.md) - the server that admits the token the end-to-end test presents - [Token in band](/quest/m1/auth/token-in-band.md) - the token diff --git a/quest/m2/cat/verify.md b/quest/m2/cat/verify.md index 29a03c3126..c77c2c532e 100644 --- a/quest/m2/cat/verify.md +++ b/quest/m2/cat/verify.md @@ -82,8 +82,3 @@ rather than a set of prefixed names; `@moq/auth` stays flat. Public API: `moq_auth::cat` new, `moq auth serve` and `moq auth sign|verify` gain flags. Wire: none. - -## Required - -- [Setup token](/quest/m1/setup-token.md) - the token reaches the - server's request diff --git a/rs/moq-auth/src/request.rs b/rs/moq-auth/src/request.rs index 99efa583db..ae36b15bc1 100644 --- a/rs/moq-auth/src/request.rs +++ b/rs/moq-auth/src/request.rs @@ -1,4 +1,6 @@ use serde::{Deserialize, Serialize}; +use serde_with::base64::{Base64, UrlSafe}; +use serde_with::formats::Unpadded; use serde_with::{DurationSecondsWithFrac, TimestampSeconds, serde_as}; use std::net::SocketAddr; use std::time::{Duration, SystemTime}; @@ -8,8 +10,8 @@ use crate::lease::Reason; /// Everything a relay knows about a session, sent to the auth server on every event. /// /// Nothing is parsed on the relay's behalf: the server keys policy on the raw -/// [`path`](Self::path) and [`query`](Self::query), so no query parameter is special -/// and a credential can be whatever the server understands. The same shape carries +/// [`path`](Self::path), [`query`](Self::query), and [`token`](Self::token), so no +/// query parameter is special and a credential can be whatever the server understands. The same shape carries /// every [`Event`]; an `end` adds what the session did. #[serde_with::skip_serializing_none] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -46,6 +48,9 @@ pub struct Request { /// The raw query string, without the leading `?`. pub query: Option, + /// The credential a moq-transport client presented in its SETUP. + pub token: Option, + /// The direction the client declared at SETUP; absent means both. pub role: Option, @@ -73,12 +78,31 @@ impl Request { alpn: None, path: path.into(), query: None, + token: None, role: None, tls: None, } } } +/// A credential from a moq-transport SETUP's `AUTHORIZATION TOKEN` option, unparsed. +#[serde_as] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Token { + /// The moq-transport Token Type, naming how [`value`](Self::value) is encoded. + pub kind: u64, + /// The token bytes, base64url without padding on the wire. + #[serde_as(as = "Base64")] + pub value: Vec, +} + +impl Token { + /// Token Type 0: a format negotiated out of band; `moq auth serve` reads it as a JWT. + pub const OUT_OF_BAND: u64 = 0x0; + /// Token Type 1: a Common Access Token. + pub const CAT: u64 = 0x1; +} + /// The lifecycle moment a [`Request`] reports. #[serde_as] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -259,6 +283,24 @@ mod tests { ); } + /// The exact bytes `js/auth/src/contract.test.ts` parses: the value is base64url, so + /// bytes that are not text survive the JSON unchanged. + #[test] + fn a_setup_token_serializes_as_base64url() { + let mut request = Request::new("relay-1", Transport::Quic, "/demo/room"); + request.id = "00ff".into(); + request.token = Some(Token { + kind: Token::CAT, + value: vec![0x00, 0xfb, 0xff], + }); + let json = serde_json::to_string(&request).unwrap(); + assert_eq!( + json, + r#"{"id":"00ff","event":"connect","node":"relay-1","transport":"quic","path":"/demo/room","token":{"kind":1,"value":"APv_"}}"# + ); + assert_eq!(serde_json::from_str::(&json).unwrap(), request); + } + #[test] fn a_unix_session_has_no_addresses() { let request = Request::new("relay-1", Transport::Unix, ""); diff --git a/rs/moq-auth/src/serve.rs b/rs/moq-auth/src/serve.rs index 247381d1af..96e2de12bf 100644 --- a/rs/moq-auth/src/serve.rs +++ b/rs/moq-auth/src/serve.rs @@ -18,7 +18,7 @@ use axum::routing::post; use axum::{Json, Router}; use tokio::time::Instant; -use crate::{Event, Grant, Key, KeyId, Permissions, Request}; +use crate::{Event, Grant, Key, KeyId, Permissions, Request, Token}; /// Where the signing keys a `jwt` is verified against come from. Read per request, /// so a rotated file takes effect without a restart. @@ -48,8 +48,12 @@ pub struct Limits { } /// The decisions the server answers with, evaluated in order and stopping at the -/// first that applies: a `jwt` in the query, then a verified certificate, then the -/// anonymous rules. A malformed or expired token is a refusal, never a fall through. +/// first that applies: a JWT, then a verified certificate, then the anonymous rules. +/// A malformed or expired token is a refusal, never a fall through. +/// +/// The JWT is the `jwt` query parameter or a moq-transport SETUP token of type 0 +/// ([`Token::OUT_OF_BAND`]), verified alike. A session presenting both is refused, +/// as is a SETUP token of any other type. /// /// `#[non_exhaustive]`, so start from [`Policy::default`] and set the fields. #[derive(Clone, Debug)] @@ -110,12 +114,16 @@ pub enum Refusal { TokenLimit, #[error("too many live sessions from this address")] RemoteLimit, + #[error("both a SETUP token and a `jwt` query were presented; present one")] + TwoTokens, + #[error("SETUP token type {0:#x} is not supported; only type 0 (a JWT) is")] + UnsupportedToken(u64), } impl Policy { /// Decide `request` by the policy alone, ignoring session limits. pub async fn decide(&self, request: &Request) -> Result { - let (permissions, expires) = if let Some(jwt) = token(request) { + let (permissions, expires) = if let Some(jwt) = jwt(request)? { let key = self.key(jwt).await?; let claims = key.verify(jwt).map_err(|err| Refusal::InvalidToken(err.to_string()))?; let permissions = claims.authorize(&request.path).map_err(|err| match err { @@ -161,9 +169,21 @@ impl Policy { } } +/// The JWT the request presents: its SETUP token, or else its `jwt` query parameter. +fn jwt(request: &Request) -> Result, Refusal> { + match (&request.token, query_jwt(request)) { + (Some(_), Some(_)) => Err(Refusal::TwoTokens), + (Some(token), None) if token.kind == Token::OUT_OF_BAND => std::str::from_utf8(&token.value) + .map(Some) + .map_err(|_| Refusal::InvalidToken("the SETUP token is not UTF-8".into())), + (Some(token), None) => Err(Refusal::UnsupportedToken(token.kind)), + (None, jwt) => Ok(jwt), + } +} + /// The `jwt` query parameter, when the request carries a non-empty one. The last one /// wins, as it did on the relay, so a client that appends a fresh token is believed. -fn token(request: &Request) -> Option<&str> { +fn query_jwt(request: &Request) -> Option<&str> { let query = request.query.as_deref()?; // Borrow rather than decode: a JWT is base64url and never needs unescaping. query @@ -204,7 +224,8 @@ impl Sessions { slot.seen = Instant::now(); return Ok(()); } - let token = token(request).map(hash); + // Decided before counting, so the credential is already known to be one JWT. + let token = jwt(request).ok().flatten().map(hash); let remote = remote(request); if let (Some(cap), Some(token)) = (limits.token, token) && self.slots.values().filter(|slot| slot.token == Some(token)).count() >= cap @@ -232,7 +253,7 @@ impl Sessions { /// which survivor to revoke would be an accident of arrival order. fn revalidate(&mut self, request: &Request) { let slot = self.slots.entry(request.id.clone()).or_insert_with(|| Slot { - token: token(request).map(hash), + token: jwt(request).ok().flatten().map(hash), remote: remote(request), seen: Instant::now(), }); @@ -532,6 +553,73 @@ mod tests { assert!(policy.decide(&with_token(request("/demo"), &jwt)).await.is_ok()); } + fn with_setup_token(mut request: Request, kind: u64, value: &str) -> Request { + request.token = Some(Token { + kind, + value: value.as_bytes().to_vec(), + }); + request + } + + /// A type-0 SETUP token is the deployment's JWT, verified exactly like `?jwt=`. + #[tokio::test] + async fn a_type_zero_setup_token_is_a_jwt() { + let (dir, key) = key_dir(); + let policy = Policy { + keys: Some(Keys::Dir(dir.path().into())), + ..Default::default() + }; + let jwt = sign(&key, "demo", &["alice/**"], &[], None); + let grant = policy + .decide(&with_setup_token(request("/demo"), Token::OUT_OF_BAND, &jwt)) + .await + .unwrap(); + assert_eq!(grant.publish, patterns(&["alice/**"])); + + // And refused like one: a stranger's key never falls through to public. + let stranger = Key::generate(Algorithm::HS256, Some(KeyId::decode("kid1").unwrap())).unwrap(); + let forged = sign(&stranger, "demo", &["**"], &[], None); + let err = policy + .decide(&with_setup_token(request("/demo"), Token::OUT_OF_BAND, &forged)) + .await + .unwrap_err(); + assert!(matches!(err, Refusal::InvalidToken(_)), "{err}"); + } + + #[tokio::test] + async fn a_setup_token_of_another_type_is_refused() { + let (dir, key) = key_dir(); + let policy = Policy { + keys: Some(Keys::Dir(dir.path().into())), + public: rules(&["**"], &["**"]), + ..Default::default() + }; + let jwt = sign(&key, "demo", &["**"], &[], None); + let err = policy + .decide(&with_setup_token(request("/demo"), Token::CAT, &jwt)) + .await + .unwrap_err(); + assert_eq!(err, Refusal::UnsupportedToken(Token::CAT)); + assert_eq!( + err.to_string(), + "SETUP token type 0x1 is not supported; only type 0 (a JWT) is" + ); + } + + /// Two credentials would leave the server guessing which one the client meant. + #[tokio::test] + async fn a_setup_token_and_a_query_jwt_are_refused() { + let (dir, key) = key_dir(); + let policy = Policy { + keys: Some(Keys::Dir(dir.path().into())), + ..Default::default() + }; + let jwt = sign(&key, "demo", &["**"], &[], None); + let request = with_setup_token(with_token(request("/demo"), &jwt), Token::OUT_OF_BAND, &jwt); + let err = policy.decide(&request).await.unwrap_err(); + assert_eq!(err, Refusal::TwoTokens); + } + #[tokio::test] async fn the_last_jwt_in_the_query_wins() { let (dir, key) = key_dir(); @@ -545,7 +633,7 @@ mod tests { // relay; a trailing empty value does not blank it out. let mut request = request("/demo"); request.query = Some(format!("a=1&jwt=stale&jwt={fresh}&jwt=")); - assert_eq!(token(&request), Some(fresh.as_str())); + assert_eq!(query_jwt(&request), Some(fresh.as_str())); assert!(policy.decide(&request).await.is_ok()); request.query = Some(format!("jwt={fresh}&jwt=stale")); @@ -553,7 +641,7 @@ mod tests { assert!(matches!(err, Refusal::InvalidToken(_)), "{err}"); request.query = Some("jwt=&b=2".into()); - assert_eq!(token(&request), None); + assert_eq!(query_jwt(&request), None); } #[tokio::test] diff --git a/rs/moq-net/src/ietf/mod.rs b/rs/moq-net/src/ietf/mod.rs index 8ae7dac1ec..9c8577b340 100644 --- a/rs/moq-net/src/ietf/mod.rs +++ b/rs/moq-net/src/ietf/mod.rs @@ -30,6 +30,7 @@ pub mod solicit; mod subscribe; mod subscribe_namespace; mod subscriber; +pub(crate) mod token; mod track; mod version; diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index cb0fd4538a..121f3f10e4 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -435,6 +435,9 @@ pub struct PeerSetup { /// The request path the peer advertised, for URL-less transports. pub path: Option, + /// The credential the peer presented in its `AUTHORIZATION TOKEN` option. + pub token: Option, + /// The Setup Options it declared (see [`cluster`] and [`solicit`]). pub declared: peer::Peer, } @@ -485,11 +488,13 @@ pub async fn accept_setup( ), None => None, }; + let token = super::token::from_setup(¶ms, version)?; let declared = peer_from_params(¶ms, version)?; return Ok(PeerSetup { stream: reader, path, + token, declared, }); } diff --git a/rs/moq-net/src/ietf/token.rs b/rs/moq-net/src/ietf/token.rs new file mode 100644 index 0000000000..129e9e17e4 --- /dev/null +++ b/rs/moq-net/src/ietf/token.rs @@ -0,0 +1,233 @@ +//! The `AUTHORIZATION TOKEN` Setup Option (draft-ietf-moq-transport-21 section 9.1.4). +//! +//! The value is the Token structure of section 8.9: an Alias Type, then fields that +//! type selects. We advertise no `MAX_AUTH_TOKEN_CACHE_SIZE`, so its default of 0 means +//! no alias is ever registered and every token arrives by value. + +use crate::{ + Error, SessionError, + coding::{Decode, Encode, EncodeError}, + setup::Token, +}; + +use super::{ParameterBytes, Parameters, Version}; + +/// Retire a registered alias. +const DELETE: u64 = 0x0; +/// Register an alias for this type and value, then use them. +const REGISTER: u64 = 0x1; +/// Use the type and value a registered alias names. +const USE_ALIAS: u64 = 0x2; +/// Use the type and value carried inline. +const USE_VALUE: u64 = 0x3; + +/// The token the peer's SETUP presented, if any. +/// +/// A second token is already refused as a duplicate option by [`Parameters`]: one +/// credential per connection. +pub fn from_setup(params: &Parameters, version: Version) -> Result, Error> { + params + .get_bytes(ParameterBytes::AuthorizationToken) + .map(|value| decode(value, version)) + .transpose() +} + +/// Present `token` in our SETUP, by value. +#[cfg_attr(not(test), expect(dead_code))] +pub fn into_setup(params: &mut Parameters, token: &Token, version: Version) -> Result<(), EncodeError> { + let mut value = Vec::new(); + USE_VALUE.encode(&mut value, version)?; + token.kind.encode(&mut value, version)?; + value.extend_from_slice(&token.value); + params.set_bytes(ParameterBytes::AuthorizationToken, value); + Ok(()) +} + +/// Decode a Token structure, refusing what a SETUP cannot carry. +fn decode(mut buf: &[u8], version: Version) -> Result { + // Section 8.9: a structure that cannot be decoded closes with KEY_VALUE_FORMATTING_ERROR. + let malformed = |_| Error::Session(SessionError::KeyValueFormatting); + + match u64::decode(&mut buf, version).map_err(malformed)? { + USE_VALUE => {} + // With no cache, section 9.1.4 treats a registration as a value; the alias is unused. + REGISTER => { + u64::decode(&mut buf, version).map_err(malformed)?; + } + // Section 9.1.4: nothing can have been registered before SETUP. + DELETE | USE_ALIAS => return Err(Error::ProtocolViolation), + _ => return Err(Error::Session(SessionError::KeyValueFormatting)), + } + + let kind = u64::decode(&mut buf, version).map_err(malformed)?; + Ok(Token { + kind, + value: buf.to_vec(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const VERSIONS: [Version; 9] = [ + Version::Draft14, + Version::Draft15, + Version::Draft16, + Version::Draft17, + Version::Draft18, + Version::Draft19, + Version::Draft20, + Version::Draft21, + Version::Draft22, + ]; + + fn token() -> Token { + // A kind past one varint byte and a value that is not text, so neither is mistaken + // for the other and no codec can get away with assuming UTF-8. + Token { + kind: 300, + value: vec![0x00, 0xff, 0x03, 0x80, b'j'], + } + } + + /// The option as it arrives, after a trip through the SETUP parameter block. + fn received(params: &Parameters, version: Version) -> Parameters { + let mut bytes = params.encode_bytes(version).unwrap(); + Parameters::decode(&mut bytes, version).unwrap() + } + + /// A raw Token structure: the alias type then its varint fields then a value. + fn structure(version: Version, fields: &[u64], value: &[u8]) -> Parameters { + let mut raw = Vec::new(); + for field in fields { + field.encode(&mut raw, version).unwrap(); + } + raw.extend_from_slice(value); + let mut params = Parameters::default(); + params.set_bytes(ParameterBytes::AuthorizationToken, raw); + received(¶ms, version) + } + + #[test] + fn use_value_round_trips_on_every_draft() { + for version in VERSIONS { + let mut params = Parameters::default(); + into_setup(&mut params, &token(), version).unwrap(); + let params = received(¶ms, version); + assert_eq!(from_setup(¶ms, version).unwrap(), Some(token()), "{version:?}"); + } + } + + /// The same bytes `js/net/src/ietf/token.test.ts` asserts, so the two agree on the wire. + #[test] + fn the_encoding_matches_the_cross_language_vector() { + let token = Token { + kind: 300, + value: vec![0x00, 0xff], + }; + for (version, expected) in [ + (Version::Draft14, [0x03, 0x41, 0x2c, 0x00, 0xff]), + (Version::Draft17, [0x03, 0x81, 0x2c, 0x00, 0xff]), + ] { + let mut params = Parameters::default(); + into_setup(&mut params, &token, version).unwrap(); + assert_eq!( + params.get_bytes(ParameterBytes::AuthorizationToken), + Some(&expected[..]), + "{version:?}" + ); + } + } + + #[test] + fn an_empty_value_is_a_token() { + for version in VERSIONS { + let params = structure(version, &[USE_VALUE, Token::OUT_OF_BAND], &[]); + let expected = Token { + kind: Token::OUT_OF_BAND, + value: Vec::new(), + }; + assert_eq!(from_setup(¶ms, version).unwrap(), Some(expected), "{version:?}"); + } + } + + #[test] + fn absent_is_none() { + for version in VERSIONS { + assert_eq!(from_setup(&Parameters::default(), version).unwrap(), None); + } + } + + /// We advertise no cache, so a registration is the draft's own USE_VALUE. + #[test] + fn register_is_a_value() { + for version in VERSIONS { + let params = structure(version, &[REGISTER, 7, token().kind], &token().value); + assert_eq!(from_setup(¶ms, version).unwrap(), Some(token()), "{version:?}"); + } + } + + /// One credential per connection: a second token is refused, not unioned or dropped. + #[test] + fn two_tokens_are_refused() { + for version in VERSIONS { + let mut value = Vec::new(); + USE_VALUE.encode(&mut value, version).unwrap(); + Token::OUT_OF_BAND.encode(&mut value, version).unwrap(); + + let key = u64::from(ParameterBytes::AuthorizationToken); + let (count, keys): (Option, [u64; 2]) = match version { + Version::Draft14 | Version::Draft15 => (Some(2), [key, key]), + // Delta-encoded from draft-16, so the repeat is a delta of zero. + Version::Draft16 => (Some(2), [key, 0]), + _ => (None, [key, 0]), + }; + let mut raw = Vec::new(); + if let Some(count) = count { + count.encode(&mut raw, version).unwrap(); + } + for key in keys { + key.encode(&mut raw, version).unwrap(); + value.encode(&mut raw, version).unwrap(); + } + + let err = Parameters::decode(&mut raw.as_slice(), version).unwrap_err(); + assert!(matches!(err, crate::DecodeError::Duplicate), "{version:?}: {err:?}"); + } + } + + #[test] + fn an_alias_reference_is_a_protocol_violation() { + for version in VERSIONS { + for alias_type in [DELETE, USE_ALIAS] { + let params = structure(version, &[alias_type, 7], &[]); + let err = from_setup(¶ms, version).unwrap_err(); + assert!( + matches!(err, Error::ProtocolViolation), + "{version:?} {alias_type}: {err:?}" + ); + } + } + } + + #[test] + fn an_undecodable_structure_is_a_formatting_error() { + for version in VERSIONS { + for (fields, why) in [ + (&[][..], "no alias type"), + (&[USE_VALUE][..], "no token type"), + (&[REGISTER][..], "no alias"), + (&[REGISTER, 7][..], "no token type after the alias"), + (&[0x4, 0][..], "an unknown alias type"), + ] { + let params = structure(version, fields, &[]); + let err = from_setup(¶ms, version).unwrap_err(); + assert!( + matches!(err, Error::Session(SessionError::KeyValueFormatting)), + "{version:?} {why}: {err:?}" + ); + } + } + } +} diff --git a/rs/moq-net/src/lib.rs b/rs/moq-net/src/lib.rs index 732ce41989..ffb5a32415 100644 --- a/rs/moq-net/src/lib.rs +++ b/rs/moq-net/src/lib.rs @@ -85,7 +85,7 @@ mod lite; mod model; pub mod path; mod recv; -mod setup; +pub mod setup; mod tail; #[cfg(test)] mod test_interop; diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index e26f5e632d..a6e9fd0fc9 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -151,7 +151,17 @@ impl Server { /// which is what drops the thread-affinity bounds: a pinned `!Send` /// transport can gate on the advertised path too. Anything but a moq-lite /// ALPN is refused with [`Error::Version`]. - pub async fn accept_request_lite(&self, now: Instant, mut session: S) -> Result, Error> + pub async fn accept_request_lite(&self, now: Instant, session: S) -> Result, Error> + where + S: crate::transport::poll::Session, + { + let mut refused = session.clone(); + self.handshake_lite(now, session) + .await + .inspect_err(|err| close(&mut refused, err)) + } + + async fn handshake_lite(&self, now: Instant, mut session: S) -> Result, Error> where S: crate::transport::poll::Session, { @@ -215,6 +225,8 @@ impl Server { path, role, origin, + // moq-lite carries no SETUP token. + token: None, assigned_hop: crate::Hop::random(), inner: Some(RequestInner { server: self.clone(), @@ -250,7 +262,22 @@ impl Server { /// /// The path is surfaced for moq-lite-05 and newer, and every moq-transport /// draft we speak; it's empty on versions with no in-band request path (lite 01-04). - pub async fn accept_request(&self, now: Instant, mut session: S) -> Result, Error> + /// + /// A SETUP that fails to parse or negotiate closes the session with the matching code, + /// so the peer learns why instead of seeing a bare disconnect. + pub async fn accept_request(&self, now: Instant, session: S) -> Result, Error> + where + S: crate::transport::poll::Boxable, + S::SendStream: MaybeSync, + S::RecvStream: MaybeSync, + { + let mut refused = session.clone(); + self.handshake(now, session) + .await + .inspect_err(|err| close(&mut refused, err)) + } + + async fn handshake(&self, now: Instant, mut session: S) -> Result, Error> where S: crate::transport::poll::Boxable, S::SendStream: MaybeSync, @@ -295,7 +322,7 @@ impl Server { // Every lite ALPN goes through the same entry point, which is also // what a `!Send` transport calls directly. Some(ALPN_LITE_07_WIP | ALPN_LITE_06 | ALPN_LITE_05 | ALPN_LITE_04 | ALPN_LITE_03) => { - return self.accept_request_lite(now, session).await; + return self.handshake_lite(now, session).await; } Some(ALPN_LITE) | None => { let supported = self.versions.filter(&NEGOTIATED.into()).ok_or(Error::Version)?; @@ -319,7 +346,7 @@ impl Server { // Pull the request path and max request ID out now (IETF only) so `ok()` // doesn't re-decode the consumed parameters. moq-transport carries the path // in its SETUP just like lite-05. - let (path, request_id_max, peer_declared) = match version { + let (path, token, request_id_max, peer_declared) = match version { Version::Ietf(v) => { let params = ietf::Parameters::decode(&mut client.parameters, v)?; let path = match params.get_bytes(ietf::ParameterBytes::Path) { @@ -330,6 +357,7 @@ impl Server { ), None => None, }; + let token = ietf::token::from_setup(¶ms, v)?; let request_id_max = params .get_varint(ietf::ParameterVarInt::MaxRequestId) .map(ietf::RequestId); @@ -338,15 +366,16 @@ impl Server { hidden: ietf::hidden::from_setup(¶ms, v), ..Default::default() }; - (path, request_id_max, peer_declared) + (path, token, request_id_max, peer_declared) } - Version::Lite(_) => (None, None, ietf::peer::Peer::default()), + Version::Lite(_) => (None, None, None, ietf::peer::Peer::default()), }; Ok(Handshake { path, role: None, origin: None, + token, assigned_hop: crate::Hop::random(), inner: Some(RequestInner { server: self.clone(), @@ -382,6 +411,7 @@ impl Server { // A moq-transport peer only has an identity if it negotiated the MoQ // Cluster extension and declared a non-zero Hop ID. origin: peer_setup.declared.cluster.hop.filter(|h| *h != crate::Hop::UNKNOWN), + token: peer_setup.token.clone(), assigned_hop: crate::Hop::random(), inner: Some(RequestInner { server: self.clone(), @@ -407,6 +437,7 @@ pub struct Handshake { path: Option, role: Option, origin: Option, + token: Option, /// The identity this session's routes are stamped with when the peer declares none /// on the wire. Fresh per request unless the caller overrides it /// ([`Handshake::with_peer_hop`]). @@ -516,9 +547,8 @@ where .maybe_boxed() } - fn close(self: Box, err: Error) { - let mut session = self.session; - session.close(SessionError::from(&err).to_code(), &err.to_string()); + fn close(mut self: Box, err: Error) { + close(&mut self.session, &err); } } @@ -619,9 +649,8 @@ where .maybe_boxed() } - fn close(self: Box, err: Error) { - let mut session = self.session; - session.close(SessionError::from(&err).to_code(), &err.to_string()); + fn close(mut self: Box, err: Error) { + close(&mut self.session, &err); } } @@ -663,6 +692,14 @@ where self.origin } + /// The credential the client presented in its SETUP's `AUTHORIZATION TOKEN` option. + /// + /// Only moq-transport carries one, so moq-lite sessions return `None`. The transport + /// has not verified it: authorize on it the way you would a URL token. + pub fn token(&self) -> Option<&setup::Token> { + self.token.as_ref() + } + /// Publish to the connected client. Overrides any value from the [`Server`] /// builder; typically set after inspecting [`path`](Self::path). pub fn with_publisher(mut self, publish: impl Consume) -> Self { @@ -742,10 +779,15 @@ impl RequestInner { PausedHandshake::LiteSetup { session, .. } => session, PausedHandshake::Boxed(paused) => return paused.close(err), }; - session.close(SessionError::from(&err).to_code(), &err.to_string()); + close(&mut session, &err); } } +/// Close `session` with `err`'s wire code. +fn close(session: &mut S, err: &Error) { + session.close(SessionError::from(err).to_code(), &err.to_string()); +} + impl Drop for Handshake { // A dropped request would otherwise leave the client hanging until its idle // timeout: it already sent SETUP and is waiting on a response. Reject loudly. @@ -789,12 +831,15 @@ mod tests { } } - /// A session that replays a queue of unidirectional streams (each a `Vec`) in - /// order from `accept_uni`; everything else is inert. + /// A session that replays a queue of streams (each a `Vec`) in order from + /// `accept_uni` and `accept_bi`, and records the code it was closed with; everything + /// else is inert. #[derive(Clone)] struct FakeSession { protocol: Option<&'static str>, uni: Arc>>>, + bi: Arc>>>, + closed: Arc>>, } impl FakeSession { @@ -802,8 +847,19 @@ mod tests { Self { protocol: Some(protocol), uni: Arc::new(Mutex::new(uni.into_iter().collect())), + bi: Default::default(), + closed: Default::default(), } } + + fn with_bi(self, bi: Vec) -> Self { + self.bi.lock().unwrap().push_back(bi); + self + } + + fn closed(&self) -> Option { + *self.closed.lock().unwrap() + } } impl web_transport_trait::poll::Session for FakeSession { @@ -824,7 +880,10 @@ mod tests { &mut self, _cx: &mut std::task::Context<'_>, ) -> std::task::Poll> { - std::task::Poll::Pending + match self.bi.lock().unwrap().pop_front() { + Some(data) => std::task::Poll::Ready(Ok((FakeSend, FakeRecv { data: data.into() }))), + None => std::task::Poll::Pending, + } } fn poll_open_bi( &mut self, @@ -857,7 +916,9 @@ mod tests { fn protocol(&self) -> Option<&str> { self.protocol } - fn close(&mut self, _code: u32, _reason: &str) {} + fn close(&mut self, code: u32, _reason: &str) { + self.closed.lock().unwrap().get_or_insert(code); + } fn poll_closed(&mut self, _cx: &mut std::task::Context<'_>) -> std::task::Poll { std::task::Poll::Pending } @@ -936,6 +997,10 @@ mod tests { if let Some(path) = path { params.set_bytes(ietf::ParameterBytes::Path, path.as_bytes().to_vec()); } + ietf_setup_with(version, params) + } + + fn ietf_setup_with(version: ietf::Version, params: ietf::Parameters) -> Vec { let parameters = params.encode_bytes(version).unwrap(); let mut buf = Vec::new(); @@ -945,6 +1010,91 @@ mod tests { buf } + /// Encode a draft 14-16 CLIENT_SETUP, sent on the control bidi stream. + fn legacy_setup(version: ietf::Version, params: ietf::Parameters) -> Vec { + let mut buf = Vec::new(); + setup::Client { + versions: crate::coding::Versions::from([crate::Version::Ietf(version).into()]), + parameters: params.encode_bytes(version).unwrap(), + } + .encode(&mut buf, crate::Version::Ietf(version)) + .unwrap(); + buf + } + + fn setup_token() -> setup::Token { + setup::Token { + kind: setup::Token::OUT_OF_BAND, + value: vec![0x00, 0xff, b'j', b'w', b't'], + } + } + + fn token_params(version: ietf::Version) -> ietf::Parameters { + let mut params = ietf::Parameters::default(); + ietf::token::into_setup(&mut params, &setup_token(), version).unwrap(); + params + } + + #[tokio::test(start_paused = true)] + async fn accept_request_exposes_the_setup_token() { + let modern = FakeSession::new( + ALPN_19, + [ietf_setup_with( + ietf::Version::Draft19, + token_params(ietf::Version::Draft19), + )], + ); + let legacy = FakeSession::new(ALPN_16, []).with_bi(legacy_setup( + ietf::Version::Draft16, + token_params(ietf::Version::Draft16), + )); + for (name, session) in [("draft-19", modern), ("draft-16", legacy)] { + let request = Server::new() + .accept_request(tokio::time::Instant::now().into_std(), session) + .await + .unwrap(); + assert_eq!(request.token(), Some(&setup_token()), "{name}"); + } + } + + #[tokio::test(start_paused = true)] + async fn accept_request_without_a_token_reports_none() { + let ietf = FakeSession::new(ALPN_19, [ietf_setup(ietf::Version::Draft19, None)]); + let lite = FakeSession::new(ALPN_LITE_05, [lite05_setup(None, None, None)]); + for (name, session) in [("draft-19", ietf), ("lite-05", lite)] { + let request = Server::new() + .accept_request(tokio::time::Instant::now().into_std(), session) + .await + .unwrap(); + assert_eq!(request.token(), None, "{name}"); + } + } + + /// A SETUP the server refuses closes the session with the code naming why, on both + /// the draft-17+ uni stream and the draft 14-16 bidi stream. + #[tokio::test(start_paused = true)] + async fn a_refused_setup_token_closes_with_its_code() { + let delete = [0x0, 0x7]; // DELETE alias 7 + let truncated = [0x3]; // USE_VALUE with no Token Type + for (raw, code) in [ + (&delete[..], SessionError::ProtocolViolation), + (&truncated[..], SessionError::KeyValueFormatting), + ] { + let mut params = ietf::Parameters::default(); + params.set_bytes(ietf::ParameterBytes::AuthorizationToken, raw.to_vec()); + + let modern = FakeSession::new(ALPN_19, [ietf_setup_with(ietf::Version::Draft19, params.clone())]); + let legacy = FakeSession::new(ALPN_16, []).with_bi(legacy_setup(ietf::Version::Draft16, params)); + for (name, session) in [("draft-19", modern), ("draft-16", legacy)] { + let result = Server::new() + .accept_request(tokio::time::Instant::now().into_std(), session.clone()) + .await; + assert!(result.is_err(), "{name}"); + assert_eq!(session.closed(), Some(code.to_code()), "{name} {code}"); + } + } + } + #[tokio::test(start_paused = true)] async fn accept_request_reads_ietf_path() { // Every draft-17+ version gates on the SETUP stream before starting, so the @@ -1075,6 +1225,7 @@ mod tests { path: None, role: None, origin: None, + token: None, assigned_hop: Hop::random(), inner: Some(RequestInner { server: Server::new().with_publisher(&origin), @@ -1088,6 +1239,7 @@ mod tests { Version::Ietf(version), ), path: None, + token: None, declared: ietf::peer::Peer::default(), }, })), diff --git a/rs/moq-net/src/setup.rs b/rs/moq-net/src/setup.rs index 7d0006ecf0..3a4a96cba0 100644 --- a/rs/moq-net/src/setup.rs +++ b/rs/moq-net/src/setup.rs @@ -1,3 +1,7 @@ +//! The SETUP exchange, and the credential a peer may present in it. +//! +//! Only [`Token`] is public; the SETUP messages themselves are wire internals. + use bytes::Bytes; use crate::{ @@ -12,9 +16,27 @@ const SERVER_SETUP: u8 = 0x21; /// Draft-17 unified SETUP message type (varint 0x2F00) pub(crate) const SETUP_V17: u64 = 0x2F00; +/// A credential a moq-transport peer presented in its SETUP's `AUTHORIZATION TOKEN` option. +/// +/// The transport never reads the bytes; verifying them is the application's job. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Token { + /// The wire Token Type, naming how [`value`](Self::value) is encoded. + pub kind: u64, + /// The token itself. + pub value: Vec, +} + +impl Token { + /// Token Type 0: a format the endpoints agreed on out of band, such as a JWT. + pub const OUT_OF_BAND: u64 = 0x0; + /// Token Type 1: a Common Access Token (draft-ietf-moq-c4m). + pub const CAT: u64 = 0x1; +} + /// Draft-17+ unified SETUP message, with the same encoding for both client and server. #[derive(Debug, Clone)] -pub struct Setup { +pub(crate) struct Setup { pub parameters: Bytes, } @@ -88,7 +110,7 @@ impl SetupVersion { /// A version-agnostic setup message sent by the client. #[derive(Debug, Clone)] -pub struct Client { +pub(crate) struct Client { /// The list of supported versions in preferred order. pub versions: coding::Versions, @@ -171,7 +193,7 @@ impl Encode for Client { /// Sent by the server in response to a client setup. #[derive(Debug, Clone)] -pub struct Server { +pub(crate) struct Server { /// The list of supported versions in preferred order. pub version: coding::Version, diff --git a/rs/moq-relay/Cargo.toml b/rs/moq-relay/Cargo.toml index 76f663270a..ea8a6ad3b2 100644 --- a/rs/moq-relay/Cargo.toml +++ b/rs/moq-relay/Cargo.toml @@ -91,6 +91,7 @@ sd-notify = { workspace = true } [dev-dependencies] moq-auth = { path = "../moq-auth", features = ["client", "serve"] } moq-shaper = { path = "../moq-shaper" } +qmux = { workspace = true, features = ["tcp"] } rand = { workspace = true } rcgen = "0.14" tempfile = { workspace = true } diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index 57a5316b23..bbe0261d90 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -473,6 +473,10 @@ pub fn request_for(auth: &Auth, request: &moq_tokio::server::Request) -> Request }; let mut out = auth.request(transport, path); out.query = request.query().map(str::to_owned); + out.token = request.token().map(|token| moq_auth::Token { + kind: token.kind, + value: token.value.clone(), + }); out.remote = request.remote_addr(); out.local = request.local_addr(); out.server_name = request diff --git a/rs/moq-relay/src/session.rs b/rs/moq-relay/src/session.rs index 016e49f1d9..da438de041 100644 --- a/rs/moq-relay/src/session.rs +++ b/rs/moq-relay/src/session.rs @@ -136,7 +136,7 @@ impl Registry { /// /// `id` and every scalar are exact. `path` is a [`Pattern`] against the dialed /// path. `remote` is an IP or CIDR with the port dropped and IPv4-mapped IPv6 -/// folded. `query` is not a field: it may carry the credential. +/// folded. `query` and `token` are not fields: they carry the credential. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Filter { /// Session id. @@ -402,7 +402,7 @@ impl IntoResponse for Error { } /// One live session as the list route returns it: the request the server saw, -/// minus `query`, plus when it was admitted. +/// minus its credentials (`query` and `token`), plus when it was admitted. #[serde_as] #[serde_with::skip_serializing_none] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -453,7 +453,7 @@ impl View { /// `GET /sessions` body. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct List { - /// Matching sessions, `query` omitted. + /// Matching sessions, credentials omitted. pub sessions: Vec, } @@ -530,14 +530,21 @@ mod tests { } #[test] - fn list_omits_query() { + fn list_omits_credentials() { let registry = Registry::new(); - let _reg = registry.register(request("abc", "/room", "127.0.0.1:1")); + let mut session = request("abc", "/room", "127.0.0.1:1"); + session.query = Some("jwt=secret".into()); + session.token = Some(moq_auth::Token { + kind: moq_auth::Token::OUT_OF_BAND, + value: b"secret".to_vec(), + }); + let _reg = registry.register(session); let list = registry.list(&Filter::default()); assert_eq!(list.len(), 1); assert_eq!(list[0].id, "abc"); let json = serde_json::to_value(&list[0]).unwrap(); assert!(json.get("query").is_none()); + assert!(json.get("token").is_none()); } #[tokio::test] diff --git a/rs/moq-relay/tests/auth_lifetime.rs b/rs/moq-relay/tests/auth_lifetime.rs index 7d3922f23e..d5d39c72a8 100644 --- a/rs/moq-relay/tests/auth_lifetime.rs +++ b/rs/moq-relay/tests/auth_lifetime.rs @@ -348,12 +348,57 @@ async fn admits_and_reports_the_session() { assert!(connect.remote.is_some_and(|addr| addr.ip().is_loopback())); assert!(connect.local.is_some_and(|addr| addr.port() == port)); assert!(connect.tls.is_none()); + assert!(connect.token.is_none(), "moq-lite carries no SETUP token"); drop(pub_session); drop(sub_session); relay.abort(); } +/// A moq-transport client's SETUP token reaches the auth server byte for byte. +#[tokio::test] +async fn forwards_the_setup_token() { + use web_transport_trait::{RecvStream as _, SendStream as _, Session as _}; + + let script = Script::new(grant(Duration::from_secs(3600))); + let (port, relay) = spawn_relay(build_auth(script.spawn().await)).await; + + // No client presents a SETUP token yet, so write a draft-16 CLIENT_SETUP by hand. One + // parameter, AUTHORIZATION TOKEN (3), holding USE_VALUE (3), Token Type 0, then a + // value that is not text, so any lossy step on the way shows. + let value = [0x00, 0xff, 0x80, b'x']; + let token = [&[0x03, 0x00][..], &value].concat(); + let params = [&[0x01, 0x03, token.len() as u8][..], &token].concat(); + let setup = [&[0x20][..], &(params.len() as u16).to_be_bytes(), ¶ms].concat(); + + let session = qmux::tcp::Config::new(qmux::Version::QMux01) + .protocols(["moqt-16"]) + .connect(("127.0.0.1", port)) + .await + .expect("connect"); + let (mut send, mut recv) = session.open_bi().await.expect("open the control stream"); + send.write_all(&setup).await.expect("send CLIENT_SETUP"); + + // The relay answers SERVER_SETUP only once the auth server has admitted the session. + let mut reply = [0u8; 1]; + tokio::time::timeout(TIMEOUT, recv.read(&mut reply)) + .await + .expect("SERVER_SETUP timeout") + .expect("SERVER_SETUP"); + + let seen = script.seen.lock().unwrap().clone(); + let connect = seen.iter().find(|r| r.event == Event::Connect).expect("a connect"); + assert_eq!( + connect.token, + Some(moq_auth::Token { + kind: moq_auth::Token::OUT_OF_BAND, + value: value.to_vec(), + }) + ); + + relay.abort(); +} + /// A refusal at connect, a 5xx, and a garbage reply all refuse the session. #[tokio::test] async fn refusals_and_outages_refuse_at_connect() { diff --git a/rs/moq-tokio/src/server.rs b/rs/moq-tokio/src/server.rs index c06beaf474..fa498347a5 100644 --- a/rs/moq-tokio/src/server.rs +++ b/rs/moq-tokio/src/server.rs @@ -1470,6 +1470,14 @@ impl Request { request_ref!(self, r => r.peer_hop()) } + /// The credential a moq-transport client presented in its SETUP's `AUTHORIZATION + /// TOKEN` option, unverified. moq-lite sessions return `None`. + /// + /// Like [`query`](Self::query), it can hold a credential. Avoid logging it. + pub fn token(&self) -> Option<&moq_net::setup::Token> { + request_ref!(self, r => r.token()) + } + /// The client certificate chain the peer presented, if any, validated /// against a configured [`crate::tls::Listen::root`] during the handshake. /// From 0b790bc37f86135dbcdfb6e6aec7324876023006 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 18:08:06 -0700 Subject: [PATCH 3/3] fix(auth): refuse a SETUP token value Rust cannot decode The JS schema accepted any base64url alphabet string, including lengths and final characters that no byte string encodes to. Match the Rust deserializer, and pin both sides to the same vectors. Co-Authored-By: Claude Opus 5.5 --- js/auth/src/contract.test.ts | 10 +++++++++- js/auth/src/contract.ts | 6 ++++-- rs/moq-auth/src/request.rs | 10 ++++++++++ 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/js/auth/src/contract.test.ts b/js/auth/src/contract.test.ts index 8369ffb24b..aad28a2115 100644 --- a/js/auth/src/contract.test.ts +++ b/js/auth/src/contract.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { GrantSchema, RequestSchema } from "./contract.ts"; +import { GrantSchema, RequestSchema, TokenSchema } from "./contract.ts"; // The fixtures below are what the Rust `moq_auth::Request` and `Grant` serialize to, // so a server written against these schemas reads what a relay sends. @@ -71,6 +71,14 @@ test("a SETUP token parses as the Rust vector", () => { token: { kind: 0, value: "AP+/" }, }), ).toThrow(); + + // Not a length or final character any byte string encodes to, which Rust refuses too. + for (const value of ["A", "AB", "APv_A"]) { + expect(() => TokenSchema.parse({ kind: 0, value })).toThrow(); + } + for (const value of ["", "AA", "AAA", "AAAA", "AQ", "AAE"]) { + expect(TokenSchema.parse({ kind: 0, value }).value).toBe(value); + } }); test("a connect must not carry end facts, and an unknown transport is refused", () => { diff --git a/js/auth/src/contract.ts b/js/auth/src/contract.ts index f75fe18083..c3371ba6e7 100644 --- a/js/auth/src/contract.ts +++ b/js/auth/src/contract.ts @@ -40,8 +40,10 @@ export type Peer = z.infer; export const TokenSchema = z.object({ /** The moq-transport Token Type: 0 is negotiated out of band (a JWT to `moq auth serve`), 1 is a Common Access Token. */ kind: z.int().check(z.nonnegative()), - /** The token bytes, base64url without padding. */ - value: z.string().check(z.regex(/^[A-Za-z0-9_-]*$/)), + /** The token bytes, base64url without padding. The final character must leave no stray bits, as Rust decodes it. */ + value: z + .string() + .check(z.regex(/^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-][AQgw]|[A-Za-z0-9_-]{2}[AEIMQUYcgkosw048])?$/)), }); export type Token = z.infer; diff --git a/rs/moq-auth/src/request.rs b/rs/moq-auth/src/request.rs index ae36b15bc1..59628467da 100644 --- a/rs/moq-auth/src/request.rs +++ b/rs/moq-auth/src/request.rs @@ -299,6 +299,16 @@ mod tests { r#"{"id":"00ff","event":"connect","node":"relay-1","transport":"quic","path":"/demo/room","token":{"kind":1,"value":"APv_"}}"# ); assert_eq!(serde_json::from_str::(&json).unwrap(), request); + + // `js/auth` refuses the same malformed values. + for value in ["A", "AB", "APv_A", "AP+/"] { + let json = format!(r#"{{"kind":0,"value":"{value}"}}"#); + assert!(serde_json::from_str::(&json).is_err(), "{value}"); + } + for value in ["", "AA", "AAA", "AAAA", "AQ", "AAE"] { + let json = format!(r#"{{"kind":0,"value":"{value}"}}"#); + assert!(serde_json::from_str::(&json).is_ok(), "{value}"); + } } #[test]