Skip to content
Closed
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
24 changes: 24 additions & 0 deletions doc/bin/relay/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,30 @@ TOKEN` option with Token Type 0; `moq auth serve` verifies it the same way. HMAC
can itself be **scoped** at generation (`--root`, `--publish`, `--subscribe`),
after which it can never sign a broader token.

### On a request

The same `AUTHORIZATION TOKEN` may ride an individual request, not only the
SETUP, but only a SUBSCRIBE, a PUBLISH\_NAMESPACE, or a REQUEST\_UPDATE renewing
one of those is authorized by it; a token on any other request (FETCH, PUBLISH,
SUBSCRIBE\_NAMESPACE, TRACK\_STATUS) is ignored. A request is authorized by the
session's grant first; when that does not cover the request's path, by the token
on the request; with neither it is refused `UNAUTHORIZED`. A request token's grant
covers only the request it rode on, never widens the session, and ends when
the request ends. A REQUEST\_UPDATE carrying a fresh token refreshes it, so a
long-lived request (an ingest PUBLISH\_NAMESPACE, a subscription) renews its
credential in place without reconnecting; a refused renewal ends only that
request, as the drafts require, with a PUBLISH\_DONE `UPDATE_FAILED` for a
subscription and a closed stream for a namespace. The session stays up and the
old grant does not survive the refusal.

Verifying a request token is a moq-net library capability: an application takes
`auth::Handle::requests()` before running the session and answers each token
tagged with the request's path and kind (`auth::Request::path()`, `::kind()`).
moq-relay does not yet opt in on the request path, so as of this release it
refuses a request token with `NOT_SUPPORTED`; wiring a per-request lease into
the `--auth-url` server and the in-process [`admissions()`](#in-process) API is
a follow-up.

### Claims

| Claim | Meaning |
Expand Down
17 changes: 13 additions & 4 deletions doc/concept/standard.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,19 @@ 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. An `AUTHORIZATION TOKEN` parameter on a request is read and ignored:
the session's credential is what authorizes it.
`USE_ALIAS`) closes the session with `UNKNOWN_AUTH_TOKEN_ALIAS` (`0x17`): a
cache size of zero prohibits token aliases, so none is ever registered. The
draft would reject the message, but `0x17` exists only as a session
termination code, not a request error code, so we close the session with it
instead; this close is the deviation (#4804). A structure that does not decode
closes with `KEY_VALUE_FORMATTING_ERROR`, and a second token is
refused. An `AUTHORIZATION TOKEN` on a request is authorized by the session's
grant first, and by the token on the request only when the application opts in
to request-token verification (`auth::Handle::requests()`) and the request is a
`SUBSCRIBE`, `PUBLISH_NAMESPACE`, or `REQUEST_UPDATE` renewing one; otherwise it
is read and ignored, the session's credential authorizing. A request token's
grant covers only that one request, never widens the session, and a refused
renewal ends only it.

A legal request that is not served is refused on its own with `NOT_SUPPORTED`,
leaving the session open: a `SUBSCRIBE` with `FORWARD=0`, a `SUBSCRIBE` or
Expand Down
18 changes: 15 additions & 3 deletions js/net/src/connection/accept.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as Lite from "../lite/index.ts";
import type { Consumer as OriginConsumer, Producer as OriginProducer } from "../origin.ts";
import { Stream } from "../stream.ts";
import type { Established } from "./established.ts";
import * as Extensions from "./extensions.ts";
import { forwardAnnounced } from "./forward.ts";
import { exchangeSetup } from "./handshake.ts";

Expand Down Expand Up @@ -33,6 +34,9 @@ export interface AcceptProps {
* nothing. The entries retract when the session dies; see the `consume` connect option.
*/
consume?: OriginProducer;

/** The moq-transport extensions to offer; each is on unless set to `false`. */
extensions?: Extensions.Extensions;
}

/** The per-session wiring shared by every negotiated protocol path. */
Expand All @@ -41,6 +45,8 @@ type SessionProps = {
publish?: OriginConsumer;
/** Whether this side dialed; only the dialing side aborts on a publication its grant does not cover. */
client: boolean;
/** The moq-transport extensions this side offers. */
extensions: Extensions.Offered;
};

/**
Expand Down Expand Up @@ -70,6 +76,7 @@ async function acceptInner(
discovery: props.discovery ?? true,
publish: props.publish,
client: false,
extensions: Extensions.offered(props.extensions),
};

if (protocol === Ietf.ALPN.DRAFT_22) {
Expand Down Expand Up @@ -118,7 +125,12 @@ async function acceptAlpn(
version: Ietf.IetfVersion,
wiring: SessionProps,
): Promise<Established> {
const { control, early, solicit, hidden, cluster, auth } = await exchangeSetup(transport, version, "moq-lite-js");
const { control, early, solicit, hidden, cluster, auth } = await exchangeSetup(
transport,
version,
"moq-lite-js",
wiring.extensions,
);

return new Ietf.Connection({
...wiring,
Expand Down Expand Up @@ -164,7 +176,7 @@ async function acceptSetup(
const params = new Ietf.SetupOptions();
params.setVarint(Ietf.SetupOption.MaxRequestId, 42069n);
params.setBytes(Ietf.SetupOption.Implementation, encoder.encode("moq-lite-js"));
Ietf.solicitIntoSetup(params);
Extensions.intoSetup(params, wiring.extensions, version);
Ietf.hiddenIntoSetup(params);

const server = new Ietf.ServerSetup({ version, parameters: params });
Expand Down Expand Up @@ -224,7 +236,7 @@ async function acceptNegotiated(
const params = new Ietf.SetupOptions();
params.setVarint(Ietf.SetupOption.MaxRequestId, 42069n);
params.setBytes(Ietf.SetupOption.Implementation, encoder.encode("moq-lite-js"));
Ietf.solicitIntoSetup(params);
Extensions.intoSetup(params, wiring.extensions, setupVersion);
Ietf.hiddenIntoSetup(params);

const server = new Ietf.ServerSetup({ version: selectedVersion, parameters: params });
Expand Down
16 changes: 14 additions & 2 deletions js/net/src/connection/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import * as Hex from "../util/hex.ts";
import { dev, redact } from "../util/log.ts";
import { isWebTransportSupported } from "./browser.ts";
import type { Established } from "./established.ts";
import * as Extensions from "./extensions.ts";
import { forwardAnnounced } from "./forward.ts";
import { exchangeSetup } from "./handshake.ts";

Expand Down Expand Up @@ -110,6 +111,9 @@ export interface ConnectProps {
*/
consume?: OriginProducer;

/** The moq-transport extensions to offer; each is on unless set to `false`. */
extensions?: Extensions.Extensions;

/**
* Aborts the connection attempt with the signal's reason. An already-aborted
* signal rejects before anything opens, and aborting after the connection is
Expand All @@ -124,6 +128,8 @@ type SessionProps = {
publish?: OriginConsumer;
/** Whether this side dialed; only the dialing side aborts on a publication its grant does not cover. */
client: boolean;
/** The moq-transport extensions this side offers. */
extensions: Extensions.Offered;
};

// Save if WebSocket won the last race, so we won't give QUIC a head start next time.
Expand Down Expand Up @@ -170,6 +176,7 @@ async function connectInner(url: URL, props: Omit<ConnectProps, "url">, abort: P
discovery: props.discovery ?? true,
publish: props.publish,
client: true,
extensions: Extensions.offered(props.extensions),
};

if (props.transport) {
Expand Down Expand Up @@ -308,7 +315,7 @@ async function negotiate(url: URL, session: WebTransport, wiring: SessionProps):
const params = new Ietf.SetupOptions();
params.setVarint(Ietf.SetupOption.MaxRequestId, 42069n);
params.setBytes(Ietf.SetupOption.Implementation, encoder.encode("moq-lite-js"));
Ietf.solicitIntoSetup(params);
Extensions.intoSetup(params, wiring.extensions, setupVersion);
Ietf.hiddenIntoSetup(params);

const client = new Ietf.ClientSetup({
Expand Down Expand Up @@ -365,7 +372,12 @@ async function handshakeAlpn(
version: Ietf.IetfVersion,
wiring: SessionProps,
): Promise<Established> {
const { control, early, solicit, hidden, cluster, auth } = await exchangeSetup(session, version, "moq-lite-js");
const { control, early, solicit, hidden, cluster, auth } = await exchangeSetup(
session,
version,
"moq-lite-js",
wiring.extensions,
);

return new Ietf.Connection({
...wiring,
Expand Down
20 changes: 20 additions & 0 deletions js/net/src/connection/extensions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { expect, test } from "bun:test";
import * as Ietf from "../ietf/index.ts";
import { intoSetup, offered } from "./extensions.ts";

test("every extension is offered by default", () => {
expect(offered()).toEqual({ auth: true, solicit: true });
expect(offered({ solicit: false })).toEqual({ auth: true, solicit: false });
});

test("only the offered extensions reach the SETUP", () => {
const all = new Ietf.SetupOptions();
intoSetup(all, offered(), Ietf.Version.DRAFT_18);
expect(Ietf.solicitFromSetup(all)).toBe(true);
expect(Ietf.Auth.fromSetup(all, Ietf.Version.DRAFT_18)).toBe(true);

const none = new Ietf.SetupOptions();
intoSetup(none, offered({ auth: false, solicit: false }), Ietf.Version.DRAFT_18);
expect(Ietf.solicitFromSetup(none)).toBeUndefined();
expect(Ietf.Auth.fromSetup(none, Ietf.Version.DRAFT_18)).not.toBe(true);
});
27 changes: 27 additions & 0 deletions js/net/src/connection/extensions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import * as Ietf from "../ietf/index.ts";

/**
* The moq-transport extensions a session offers in its SETUP. Each is on unless set to
* `false`; turning one off connects as a peer that does not speak it. moq-lite carries both
* in its core, so this applies to moq-transport sessions only.
*/
export interface Extensions {
/** The MoQ Auth extension: tokens presented and granted on their own stream. */
auth?: boolean;
/** The MoQ Solicit extension: the peer answers our SUBSCRIBE_NAMESPACE rather than announcing unasked. */
solicit?: boolean;
}

/** Every extension resolved to whether it is offered. */
export type Offered = Required<Extensions>;

/** Resolve unset extensions to offered. */
export function offered(extensions?: Extensions): Offered {
return { auth: extensions?.auth ?? true, solicit: extensions?.solicit ?? true };
}

/** Write the options for the offered extensions into a SETUP. */
export function intoSetup(params: Ietf.SetupOptions, extensions: Offered, version: Ietf.IetfVersion) {
if (extensions.solicit) Ietf.solicitIntoSetup(params);
if (extensions.auth) Ietf.Auth.intoSetup(params, version);
}
18 changes: 10 additions & 8 deletions js/net/src/connection/handshake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { StreamError } from "../error.ts";
import { type Hop, randomHop } from "../hop.ts";
import * as Ietf from "../ietf/index.ts";
import { Reader, Stream, UnexpectedEnd, Writer } from "../stream.ts";
import * as Extensions from "./extensions.ts";

/**
* Draft-17+ SETUP exchange. Each side opens a uni stream, writes its Setup
Expand All @@ -12,16 +13,17 @@ import { Reader, Stream, UnexpectedEnd, Writer } from "../stream.ts";
* Returns the control stream, the uni streams that arrived before it (see
* {@link receiveSetup}), plus what the peer's SETUP declared: whether it requires
* solicitation, which decides whether we announce namespaces unprompted (see the MoQ Solicit
* extension), its Hop ID (see the MoQ Cluster extension), and whether it offered MoQ Auth.
* We declare all three ourselves on
* every session: we send SUBSCRIBE_NAMESPACE for each prefix we want, so an unsolicited
* advertisement can tell us nothing we won't have asked for, and a peer that knows our Hop
* ID can withhold the advertisements that already flowed through us.
* extension), its Hop ID (see the MoQ Cluster extension), and whether MoQ Auth is negotiated.
* We declare our Hop ID on every session, and Solicit and Auth unless `extensions` turns them
* off: we send SUBSCRIBE_NAMESPACE for each prefix we want, so an unsolicited advertisement can
* tell us nothing we won't have asked for, and a peer that knows our Hop ID can withhold the
* advertisements that already flowed through us.
*/
export async function exchangeSetup(
transport: WebTransport,
version: Ietf.IetfVersion,
implementation: string,
extensions: Extensions.Offered,
): Promise<{
control: Stream;
early: Reader[];
Expand All @@ -33,9 +35,8 @@ export async function exchangeSetup(
const encoder = new TextEncoder();
const params = new Ietf.SetupOptions();
params.setBytes(Ietf.SetupOption.Implementation, encoder.encode(implementation));
Ietf.solicitIntoSetup(params);
Extensions.intoSetup(params, extensions, version);
Ietf.hiddenIntoSetup(params);
Ietf.Auth.intoSetup(params, version);

// One id per session, like the moq-lite connection: nothing in this process forwards
// between sessions, so there is nothing for a shared id to detect.
Expand All @@ -55,7 +56,8 @@ export async function exchangeSetup(
solicit: received.solicit,
hidden: received.hidden,
cluster: { self, peer: received.cluster },
auth: received.auth,
// Auth is negotiated only when both sides offer it.
auth: received.auth && extensions.auth,
};
}

Expand Down
1 change: 1 addition & 0 deletions js/net/src/connection/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export {
type WebTransportProps,
} from "./connect.ts";
export type { Established } from "./established.ts";
export type { Extensions } from "./extensions.ts";
export { Connection, type ConnectionProps } from "./pool.ts";
export type { Probe, Stats } from "./stats.ts";
export type { Transport } from "./transport.ts";
6 changes: 6 additions & 0 deletions js/net/src/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,14 @@ export const SessionCode = Object.freeze(
GoawayTimeout: 0x10 as SessionCode,
/** A control message took too long. */
Timeout: 0x11 as SessionCode,
/** A token registration would exceed the advertised cache size, which is 0 when unadvertised. */
AuthTokenCacheOverflow: 0x13 as SessionCode,
/** No version could be negotiated. */
Version: 0x15 as SessionCode,
/** A token named an alias that was never registered. */
UnknownAuthTokenAlias: 0x17 as SessionCode,
/** More REQUEST_UPDATEs were sent than the advertised MAX_REQUEST_UPDATES allows. */
TooManyRequestUpdates: 0x1b as SessionCode,
} as const),
);

Expand Down
7 changes: 4 additions & 3 deletions js/net/src/ietf/connection.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { expect, spyOn, test } from "bun:test";
import { offered } from "../connection/extensions.ts";
import { exchangeSetup } from "../connection/handshake.ts";
import { SessionCode } from "../error.ts";
import { createMockTransportPair } from "../mock.ts";
Expand Down Expand Up @@ -185,7 +186,7 @@ test("uni streams before SETUP are held until it lands", async () => {
parameters.setBytes(SetupOption.Implementation, new TextEncoder().encode("test"));
await new Setup({ parameters }).encode(setup, version);

const { control, early, solicit, hidden, cluster } = await exchangeSetup(pair.server, version, "test");
const { control, early, solicit, hidden, cluster } = await exchangeSetup(pair.server, version, "test", offered());
expect(early.length).toBe(2);

let closed = false;
Expand Down Expand Up @@ -235,7 +236,7 @@ test("malformed uni stream before SETUP fails the handshake", async () => {
parameters.setBytes(SetupOption.Implementation, new TextEncoder().encode("test"));
await new Setup({ parameters }).encode(setup, version);

await expect(exchangeSetup(pair.server, version, "test")).rejects.toThrow("leading-ones varint");
await expect(exchangeSetup(pair.server, version, "test", offered())).rejects.toThrow("leading-ones varint");
});

/** An early stream that ends partway through its type died; it is skipped like an empty one. */
Expand All @@ -254,6 +255,6 @@ test("truncated uni stream before SETUP is skipped", async () => {
parameters.setBytes(SetupOption.Implementation, new TextEncoder().encode("test"));
await new Setup({ parameters }).encode(setup, version);

const { early } = await exchangeSetup(pair.server, version, "test");
const { early } = await exchangeSetup(pair.server, version, "test", offered());
expect(early.length).toBe(0);
});
Loading
Loading