Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion doc/concept/moq-lite.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ certificate, or nothing), so a publisher learns before anyone subscribes
whether its broadcasts can reach the peer. More tokens can be presented later
without reconnecting; the session's scope is the union of every open token's
grant, and withdrawing, revoking, or narrowing one withdraws only what it alone
covered. A subscription or fetch that loses access resets with the
covered. A grant is a union of [path patterns](#path-patterns), so `room/*/cam`
or the exact broadcast `room/alice` arrives as issued rather than widened to a
prefix. A subscription or fetch that loses access resets with the
`UNAUTHORIZED` stream code, so the peer can tell it apart from the session
closing.

Expand Down
2 changes: 1 addition & 1 deletion doc/lib/js/net.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ for (;;) {
- **Subscriptions** carry a priority, a `Time.Milli` max age, and optional `groups` bounds. Groups arrive out of order and are read frame by frame, with `Error.TooFarBehind` when a reader asks for a frame the group never held and `Error.GroupTooLarge` when a write exceeds the cache budget and aborts the group.
- **Track ends**: `close()` ends a track at its live edge, while `finishAt(n)` declares the exclusive end ahead of it and still accepts the groups below. A subscriber reads the end with `final()` or awaits `finished()`. A remote track ends only once every group below its end has arrived or was dropped; one reset before its header arrived is skipped after the subscription's max age on moq-lite (one second without one), or after one second on IETF.
- **Datagrams** on moq-lite 05+ and fetch-by-sequence for history. `track.fetchGroup(sequence)` on moq-lite resolves when the publisher sends the first response byte or finishes an empty group. A missing group rejects the fetch with `StreamCode.NotFound`, including every concurrent caller sharing that fetch.
- **Authorization** on moq-lite 06, and on moq-transport draft-17+ when both sides negotiate the [MoQ Auth extension](/draft/moq-auth): an established session's `auth.grant` is a `Getter<Auth.Grant | undefined>` with what the relay lets this side publish and subscribe to, learned right after setup. `auth.add(token)` presents another token without reconnecting and resolves with an `Auth.Token` to `close()` later; it rejects with `Auth.Unsupported` when the peer takes no tokens in band. A connection that publishes a broadcast outside its grant closes with `SessionCode.Unauthorized`, naming the path in the reason. On moq-lite, a subscription the grant stops covering resets with `StreamCode.Unauthorized` and the session stays up.
- **Authorization** on moq-lite 06, and on moq-transport draft-17+ when both sides negotiate the [MoQ Auth extension](/draft/moq-auth): an established session's `auth.grant` is a `Getter<Auth.Grant | undefined>` with what the relay lets this side publish and subscribe to, learned right after setup. `auth.add(token)` presents another token without reconnecting and resolves with an `Auth.Token` to `close()` later; it rejects with `Auth.Unsupported` when the peer takes no tokens in band, or on moq-transport when the grant is not a union of subtrees, which its namespace prefixes cannot carry. A connection that publishes a broadcast outside its grant closes with `SessionCode.Unauthorized`, naming the path in the reason. On moq-lite, a subscription the grant stops covering resets with `StreamCode.Unauthorized` and the session stays up.
- **Errors** live under one namespace: a stream reset throws `Error.Stream` with a `StreamCode`, while a session close gives `Error.Session` with a `SessionCode`. The registries are disjoint, so the same number means different things in each, and 64+ is yours. Named conditions such as `Error.TooFarBehind`, `Error.FrameTooLarge`, and `Error.GroupTooLarge` subclass `Error.Stream`, so one `code` check handles a condition raised here or reported by the peer. IETF streams use their own mapping: cancellation sends CANCELLED, other local failures send INTERNAL\_ERROR, and received codes remain opaque.
- **Paths** with `Path.relative` for the cross-broadcast catalog references hang uses. Path patterns (`Path.Pattern`, `Path.Patterns`) are re-exported from [`@moq/pattern`](https://www.npmjs.com/package/@moq/pattern). Literal `Path` stays a coordinate.

Expand Down
7 changes: 4 additions & 3 deletions doc/lib/rs/moq-net.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,10 @@ origin handles allow and refuses any other token as unsupported. To verify
tokens yourself, take `handshake.auth().requests()` on the `server::Handshake`
before `ok()` (or `session.auth().requests()` before first polling the driver)
and answer every `auth::Request` with `accept(grant)`, which returns an
`auth::Issued` you can `update` or `revoke`, or `reject`. Both wires carry
prefix grants for now, so a grant that is not a union of subtrees is refused
and the presenter sees `Unsupported`; after a grant, such an update revokes it.
`auth::Issued` you can `update` or `revoke`, or `reject`. moq-lite carries any
pattern grant as issued. moq-transport carries namespace prefixes, so there a
grant that is not a union of subtrees is refused and the presenter sees
`Unsupported`; after a grant, such an update revokes it.

A client whose origin publishes a broadcast outside its grant closes the
session with `Unauthorized`, naming the path in the close reason. A grant that
Expand Down
35 changes: 27 additions & 8 deletions drafts/draft-lcurley-moq-lite.md
Original file line number Diff line number Diff line change
Expand Up @@ -1321,24 +1321,43 @@ AUTH_OK Message {
Type (i) = 0x0
Message Length (i)
Publish Count (i)
Publish Prefix (s) ...
Publish Pattern (s) ...
Subscribe Count (i)
Subscribe Prefix (s) ...
Subscribe Pattern (s) ...
Expires (i)
}
~~~

**Publish Prefix**:
A path prefix the opener may announce and serve, using the encoding and matching rules of the ANNOUNCE_REQUEST [prefix](#announce-request).
An empty prefix grants every path, and a count of zero grants none.
**Publish Pattern**:
A [pattern](#path-pattern) matching broadcast paths the opener may announce and serve.
A count of zero grants none.

**Subscribe Prefix**:
A path prefix the opener may subscribe to, with the same rules.
**Subscribe Pattern**:
A [pattern](#path-pattern) matching broadcast paths the opener may subscribe to.

**Expires**:
The number of milliseconds until the grant lapses, or 0 for never.
The acceptor revokes a lapsed grant with AUTH_ERROR; the opener uses Expires to present a replacement token in time.

### Path Pattern {#path-pattern}
A pattern is a set of broadcast paths, written as `/`-separated segments.
Each segment is one of:

- a literal, matching that segment exactly;
- `*`, matching any one segment;
- a literal with one `*` inside it, such as `cam-*.hang`, matching any segment that starts with the bytes before the `*` and ends with the bytes after it, without overlapping them;
- `**`, matching zero or more segments.

A pattern matches a path when its segments match the path's segments in order, covering the whole path.
The empty pattern matches only the empty path, `room/**` matches `room` and every path beneath it, and `**` matches every path.

A pattern has at most 32 segments and at most one `**`.
It has no leading, trailing, or repeated `/`, no segment with more than one `*`, and no `**` combined with other bytes in a segment.
It is canonical: a `**` is never immediately preceded by a `*` segment, since `*/**` and `**/*` match the same paths and only `**/*` is valid.
A pattern that breaks any of these rules is a PROTOCOL_VIOLATION.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Close the session for malformed grant patterns

When an acceptor sends an invalid or non-canonical pattern such as /room or */**, Rust's PresentToken absorbs the resulting decode error as a token termination, while JavaScript's auth loop catches it and likewise leaves the session open. The newly specified PROTOCOL_VIOLATION exists only in the session error space, so both implementations need to propagate this decode failure into a session close rather than merely dropping the offending token.

AGENTS.md reference: drafts/AGENTS.md:L1-L1

Useful? React with 馃憤聽/ 馃憥.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this here. Every malformed AUTH reply already ends just that token and leaves the session up in both languages, including an out-of-range Expires, which predates this PR. Closing the session on a malformed Auth Stream reply is a behavior change for the whole stream, not just patterns. It should be decided and tested on its own, so it is proposed as a follow-up quest.

(Written by Claude Opus 5.5)


A grant covers a request when one of its patterns matches the path.

## AUTH_ERROR {#auth-error}
AUTH_ERROR refuses a token, or revokes it after an AUTH_OK.

Expand Down Expand Up @@ -1425,7 +1444,7 @@ The `Message Length` describes the payload size on the wire.
## moq-lite-06

- Assigned `moq-lite-06` as this draft's protocol identifier.
- Added the Auth Stream (0x7) with AUTH, AUTH_OK, and AUTH_ERROR: either endpoint presents a token on its own stream and learns the paths 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.
- 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.
- Require error-code translation when bridging protocols and draft versions.
- Made a repeated non-zero Hop ID in one announcement's Hop ID list a PROTOCOL_VIOLATION, matching draft-lcurley-moq-cluster. Repeated 0 entries stay legal.
- Moved the Qmux-over-WebSocket binding details to draft-lcurley-qmux-websocket; the binding itself is unchanged.
Expand Down
74 changes: 56 additions & 18 deletions js/net/src/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,29 @@ describe.each([Lite.ALPN_06, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", (pro
server.close();
});

test("a grant the wire cannot carry is unsupported and leaves the rest alone", async () => {
test("a refused setup token grants nothing rather than everything", async () => {
const { client, server } = await connect({ publish: new OriginProducer(), protocol });
const requests = server.auth.requests();
void (async () => {
for (;;) {
const request = await requests.next();
if (!request) break;
request.reject(SessionCode.Unauthorized, "bad credential");
}
})();

const empty = await waitFor(client.auth.grant, (g) => g !== undefined);
expect(empty?.publish.size).toBe(0);
expect(empty?.subscribe.size).toBe(0);
client.close();
server.close();
});
});

// moq-transport carries namespace prefixes, so a grant that is not a union of subtrees
// is refused there rather than widened.
describe.each([Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", (protocol) => {
test("a grant namespace prefixes cannot carry is unsupported and leaves the rest alone", async () => {
const { client, server, transport } = await connect({ publish: new OriginProducer(), protocol });
const requests = server.auth.requests();
const issued: Issued[] = [];
Expand Down Expand Up @@ -188,7 +210,7 @@ describe.each([Lite.ALPN_06, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", (pro
}
expect(client.auth.grant.peek()?.publish.equals(patterns("a"))).toBe(true);

// An update the wire cannot carry revokes that token's grant, and only that one.
// An update namespace prefixes cannot carry revokes that token's grant, and only that one.
const t1 = await client.auth.add("t1");
await waitFor(client.auth.grant, (g) => g?.publish.equals(patterns("a", "b")) === true);
issued[issued.length - 1]?.update({
Expand All @@ -207,24 +229,40 @@ describe.each([Lite.ALPN_06, Ietf.ALPN.DRAFT_17, Ietf.ALPN.DRAFT_22])("%s", (pro
client.close();
server.close();
});
});

test("a refused setup token grants nothing rather than everything", async () => {
const { client, server } = await connect({ publish: new OriginProducer(), protocol });
const requests = server.auth.requests();
void (async () => {
for (;;) {
const request = await requests.next();
if (!request) break;
request.reject(SessionCode.Unauthorized, "bad credential");
}
})();
// moq-lite carries patterns, so every grant arrives exactly as issued.
test("lite-06 carries literal and wildcard grants exactly", async () => {
const { client, server } = await connect({ publish: new OriginProducer(), protocol: Lite.ALPN_06 });
const requests = server.auth.requests();
const unions: Record<string, [string[], string[]]> = {
"": [["a/**"], []],
exact: [["room/alice"], []],
wildcard: [["room/*/cam"], ["**/demo.hang"]],
mixed: [["room/**", "lobby", "cam-*.hang"], []],
root: [[""], ["**"]],
};
const parse = (texts: string[]) => new Path.Patterns(texts.map((text) => Path.Pattern.parse(text)));
const issued: Issued[] = [];
void (async () => {
for (;;) {
const request = await requests.next();
if (!request) break;
const [publish, subscribe] = unions[new TextDecoder().decode(request.token)] ?? [[], []];
issued.push(request.accept({ publish: parse(publish), subscribe: parse(subscribe) }));
}
})();

const empty = await waitFor(client.auth.grant, (g) => g !== undefined);
expect(empty?.publish.size).toBe(0);
expect(empty?.subscribe.size).toBe(0);
client.close();
server.close();
});
await waitFor(client.auth.grant, (g) => g !== undefined);
for (const token of ["exact", "wildcard", "mixed", "root"]) {
const [publish, subscribe] = unions[token];
const added = await client.auth.add(token);
const got = added.grant.peek();
expect(got?.publish.equals(parse(publish))).toBe(true);
expect(got?.subscribe.equals(parse(subscribe))).toBe(true);
}
client.close();
server.close();
});

test.each([Lite.ALPN_05, Ietf.ALPN.DRAFT_16])("%s has no grant", async (protocol) => {
Expand Down
50 changes: 42 additions & 8 deletions js/net/src/lite/auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { expect, test } from "bun:test";
import { Unsupported } from "../auth.ts";
import { SessionCode } from "../error.ts";
import * as Path from "../path.ts";
import { Reader, Writer } from "../stream.ts";
import { AuthError, AuthMessage, AuthOk, decodeAuthReplyMaybe, encodeAuthReply } from "./auth.ts";
import * as Lite from "./index.ts";

function patterns(...prefixes: string[]): Path.Patterns {
return new Path.Patterns(prefixes.map((prefix) => Path.Pattern.subtree(prefix)));
function patterns(...texts: string[]): Path.Patterns {
return new Path.Patterns(texts.map((text) => Path.Pattern.parse(text)));
}

/** Round-trip bytes through a writer and back out of a reader. */
Expand Down Expand Up @@ -40,7 +39,7 @@ test("AUTH round-trips its token", async () => {
});

test("AUTH_OK and AUTH_ERROR round-trip behind their type", async () => {
const ok = new AuthOk(patterns(""), patterns(), 60_000);
const ok = new AuthOk(patterns("**"), patterns(), 60_000);
const err = new AuthError(SessionCode.Unauthorized, "expired");
const r = await roundTrip(async (w) => {
await encodeAuthReply(w, ok, Lite.Version.DRAFT_06);
Expand All @@ -49,7 +48,7 @@ test("AUTH_OK and AUTH_ERROR round-trip behind their type", async () => {
const first = await decodeAuthReplyMaybe(r, Lite.Version.DRAFT_06);
expect(first).toBeInstanceOf(AuthOk);
if (!(first instanceof AuthOk)) throw new Error("unreachable");
// The empty prefix grants everything; the empty list grants nothing.
// `**` grants everything; the empty list grants nothing.
expect(first.publish.equals(new Path.Patterns([Path.Pattern.all()]))).toBe(true);
expect(first.subscribe.size).toBe(0);
expect(first.expires).toBe(60_000);
Expand All @@ -59,9 +58,44 @@ test("AUTH_OK and AUTH_ERROR round-trip behind their type", async () => {
expect(await decodeAuthReplyMaybe(r, Lite.Version.DRAFT_06)).toBeUndefined();
});

test("a grant the prefix wire cannot express is refused, never widened", async () => {
const narrow = new AuthOk(new Path.Patterns([Path.Pattern.literal("room/alice")]), patterns());
await expect(roundTrip((w) => narrow.encode(w, Lite.Version.DRAFT_06))).rejects.toBeInstanceOf(Unsupported);
test("literal and wildcard grants travel exactly, never widened", async () => {
const ok = new AuthOk(patterns("room/alice", "room/*/cam", "**/demo.hang"), patterns("", "lobby/**"));
const got = await AuthOk.decode(await roundTrip((w) => ok.encode(w, Lite.Version.DRAFT_06)), Lite.Version.DRAFT_06);
expect(got.publish.equals(ok.publish)).toBe(true);
expect(got.subscribe.equals(ok.subscribe)).toBe(true);
});

// The same bytes as `auth_ok_golden` in `rs/moq-net/src/lite/auth.rs`.
test("AUTH_OK matches the Rust encoding", async () => {
const ok = new AuthOk(patterns("room/*/cam", "**/b.hang"), patterns(""), 1000);
const r = await roundTrip((w) => encodeAuthReply(w, ok, Lite.Version.DRAFT_06));
const text = (s: string) => [s.length, ...new TextEncoder().encode(s)];
expect([...(await r.readAll())]).toEqual([
0x00, // AUTH_OK
0x1a, // length
0x02, // publish count, in canonical order
...text("**/b.hang"),
...text("room/*/cam"),
0x01, // subscribe count
0x00, // the empty pattern: the root alone
0x43,
0xe8, // expires: 1000ms
]);
});

test("only valid, canonical patterns decode", async () => {
for (const text of ["*/**", "/room", "room/", "room//a", "a*b*c", "**/**", "a**"]) {
const r = await roundTrip(async (w) => {
const body = new TextEncoder().encode(text);
await w.u53(0); // AUTH_OK
await w.u53(1 + 1 + body.byteLength + 1 + 1);
await w.u53(1);
await w.string(text);
await w.u53(0);
await w.u53(0);
});
await expect(decodeAuthReplyMaybe(r, Lite.Version.DRAFT_06)).rejects.toThrow();
}
});

test("lite-05 carries no AUTH", async () => {
Expand Down
30 changes: 14 additions & 16 deletions js/net/src/lite/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,23 +43,21 @@ export class AuthMessage {
}
}

// The wire carries prefixes, the ANNOUNCE_REQUEST encoding, so only a union of subtrees is
// representable. Anything else is refused rather than widened to its head.
async function encodePrefixes(w: Writer, patterns: Path.Patterns) {
const prefixes = patterns.toArray().map((pattern) => {
const prefix = pattern.asPrefix();
if (prefix === undefined) throw new Unsupported(`grant not representable as prefixes: ${pattern}`);
return prefix;
});
await w.u53(prefixes.length);
for (const prefix of prefixes) await w.string(prefix);
// Each pattern travels as its canonical text.
async function encodePatterns(w: Writer, patterns: Path.Patterns) {
await w.u53(patterns.size);
for (const pattern of patterns) await w.string(pattern.text);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject pattern text that UTF-8 cannot preserve

When a JavaScript issuer accepts a JSON pattern containing an unpaired UTF-16 surrogate, Pattern.parse preserves it but Writer.string uses TextEncoder, which silently replaces it with U+FFFD. The AUTH_OK therefore advertises a different grant from AuthOk.publish, so an action the presenter believes is authorized can be rejected by the acceptor's original grant. Validate that pattern text round-trips through UTF-8 and fail encoding instead of changing the authorization scope.

AGENTS.md reference: AGENTS.md:L17-L18

Useful? React with 馃憤聽/ 馃憥.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disagree for this PR. @moq/net never writes a lite AUTH_OK outside tests; the relay (Rust, where strings are always valid UTF-8) is the acceptor. Lossy surrogate encoding is a general Writer.string property that applies to every path and name on the wire. If it matters, it belongs in Writer.string, not in the pattern encoder.

(Written by Claude Opus 5.5)

}

async function decodePrefixes(r: Reader): Promise<Path.Patterns> {
async function decodePatterns(r: Reader): Promise<Path.Patterns> {
const count = await r.u53();
const patterns = new Path.Patterns();
for (let i = 0; i < count; i++) {
patterns.insert(Path.Pattern.subtree(await r.string()));
const text = await r.string();
const pattern = Path.Pattern.parse(text);
// Only the canonical spelling is valid, so each pattern has one encoding.
if (pattern.text !== text) throw new Error(`non-canonical pattern: ${text}`);
patterns.insert(pattern);
}
return patterns;
}
Expand All @@ -78,17 +76,17 @@ export class AuthOk {
}

async #encode(w: Writer) {
await encodePrefixes(w, this.publish);
await encodePrefixes(w, this.subscribe);
await encodePatterns(w, this.publish);
await encodePatterns(w, this.subscribe);
// 0 means never, so a lapsed grant rounds up to the smallest real expiry.
const expires =
this.expires === undefined ? 0 : Math.min(Math.max(Math.ceil(this.expires), 1), Number.MAX_SAFE_INTEGER);
await w.u53(expires);
}

static async #decode(r: Reader): Promise<AuthOk> {
const publish = await decodePrefixes(r);
const subscribe = await decodePrefixes(r);
const publish = await decodePatterns(r);
const subscribe = await decodePatterns(r);
const expires = await r.u53();
return new AuthOk(publish, subscribe, expires === 0 ? undefined : expires);
}
Expand Down
4 changes: 3 additions & 1 deletion js/pattern/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,9 @@ export class Pattern {
*/
static parse(text: string): Pattern {
if (text === "") return new Pattern([]);
return new Pattern(text.split("/").map(parseSegment));
// One past the limit is enough for the constructor to refuse, without splitting
// a peer's megabytes of `a/a/...` into segments first.
return new Pattern(text.split("/", MAX_PATTERN_SEGMENTS + 1).map(parseSegment));
}

/** A pattern from its segments, validating the grammar. Throws {@link InvalidPattern}. */
Expand Down
Loading
Loading