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
11 changes: 6 additions & 5 deletions doc/concept/hang.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ hang is the media format on top of [moq-lite](/concept/moq-lite): a catalog
track that describes the media tracks, and a container that gives each frame a
timestamp. It is modeled on [WebCodecs](https://www.w3.org/TR/webcodecs/) so a
browser can decode it directly. The spec is
[draft-lcurley-moq-hang](/draft/moq-hang). Broadcast names end in `.hang` so
a player knows which catalog to expect. End-to-end encrypted broadcasts live
under `foo.hang.e2ee/<epoch>`, one epoch per publisher run; the `.e2ee` suffix
is an untrusted discovery convention that keeps players off the ciphertext, and
the payloads follow [moq-e2ee](/draft/moq-e2ee).
[draft-lcurley-moq-hang](/draft/moq-hang). Plaintext broadcast names end in
`.hang` so a player knows which catalog to expect. End-to-end encrypted
broadcasts live under `<opaque>/<epoch>`, where `<opaque>` is derived from the
credential and a semantic name such as `foo.hang`, and one epoch identifies
each publisher run. The path exposes no format or protection marker; the
payloads follow [moq-e2ee](/draft/moq-e2ee).

## Catalog

Expand Down
44 changes: 31 additions & 13 deletions drafts/draft-lcurley-moq-e2ee.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ informative:

This document specifies moq-e2ee-00, a versioned profile for end-to-end encryption of MoQ application payloads.
Authorized publishers and subscribers share a 32-byte broadcast secret out of band.
Each publisher instance mints an epoch and publishes under a broadcast path ending in it.
Each publisher instance mints an epoch and publishes under an opaque broadcast path ending in it.
HKDF-SHA-256 derives opaque physical track names and per-track AES-128-GCM keys from the secret and the epoch; grouped frames and datagrams use separate key domains.
Media frames and datagrams carry only ciphertext plus a 16-byte tag.
The profile binds object identity through derivation and the nonce, not an on-wire header.
Expand All @@ -52,7 +52,7 @@ Submit an [issue](https://github.com/moq-dev/moq/issues) or [PR](https://github.

# Introduction
MoQ relays forward named tracks of groups and frames ({{moql}}, {{moqt}}) without parsing application payloads.
This profile encrypts those payloads and the semantic track names that would otherwise describe them, so a relay cannot recover content.
This profile encrypts those payloads and hides the semantic broadcast and track names that would otherwise describe them, so a relay cannot recover content.
It reuses AES-128-GCM and the 96-bit group/frame nonce shape of {{secure}} where those identities map, and specifies the moq-lite and datagram bindings that draft does not cover.

The profile does not distribute keys, sign senders, pad payloads, or rotate a key inside an epoch.
Expand Down Expand Up @@ -113,7 +113,7 @@ Exactly 32 bytes from a cryptographically secure random generator.
It MUST NOT be a password, passphrase, or other guessable input.

`kid` MUST be in `0..=2^53-1` inclusive, the largest integer TypeScript can represent exactly.
`context`, every `epoch`, and every `semantic_name` MUST be at most 65535 bytes, the `bytes` encoding width.
`context`, every `epoch`, and every semantic broadcast or track name MUST be at most 65535 bytes, the `bytes` encoding width.
An implementation MUST refuse a credential outside those ranges (`identity`) or whose secret is not 32 bytes (`invalid_secret`).

Applications distribute credentials over their own authenticated channel.
Expand All @@ -131,10 +131,11 @@ Each instance of a broadcast MUST mint an epoch that no other instance under the
Two instances MUST NOT share an epoch: they would derive the same keys and collide on nonces.
The RECOMMENDED epoch is the lowercase text of a UUID version 7 ({{RFC9562}}): its leading 48-bit timestamp makes epochs sort by creation time and its random bits make collisions negligible.

A protected broadcast is published at `<name>.e2ee/<epoch>`, where `<name>.e2ee` is the application's broadcast name with an `.e2ee` suffix and `<epoch>` is the epoch text.
The `.e2ee` suffix marks every path beneath it as ciphertext.
A protected path never ends in a plaintext format suffix such as `.hang`; the format an authorized client finds after decryption may appear inside the name, as in `meeting.hang.e2ee/<epoch>`.
Subscribers discover instances by the `<name>.e2ee/` prefix and select the greatest epoch when epochs are UUID version 7 text, where greatest is newest.
A protected broadcast is published at `<opaque>/<epoch>`, where `<opaque>` is the 22-character base64url segment derived from the credential and the application's semantic broadcast name according to {{derive}}, and `<epoch>` is the epoch text.
The opaque derivation does not include the epoch, so every instance of the same semantic broadcast shares a discovery prefix.
The path carries no format or protection marker; for example, the semantic name `meeting.hang` appears only as an input to the opaque derivation.
A plaintext consumer that opens the protected broadcast fails because it cannot find the plaintext catalog it expects, not because of a path naming rule.
Subscribers discover instances by the `<opaque>/` prefix and select the greatest epoch when epochs are UUID version 7 text, where greatest is newest.
Opaque epochs carry no creation order, so any other epoch form needs an application rule for which instance is current.
A subscriber that already knows the full path takes the epoch from its last segment.

Expand Down Expand Up @@ -165,7 +166,21 @@ Let `salt` be the ASCII bytes of `"moq-e2ee-00"`.
prk = HKDF-Extract(salt, secret)
~~~

Physical name material is 16 bytes:
Opaque broadcast path material is 16 bytes:

~~~
path_info = "moq-e2ee-00 path"
|| bytes(context)
|| u64(kid)
|| bytes(semantic_broadcast)
opaque = HKDF-Expand(prk, path_info, 16)
~~~

`semantic_broadcast` is the UTF-8 bytes of the application's semantic broadcast name.
The opaque path segment is the unpadded base64url encoding of `opaque` ({{RFC4648}} Section 5): 22 ASCII characters.
The epoch is deliberately absent from this derivation, so a subscriber can derive the prefix before discovering an instance.

Physical track name material is 16 bytes:

~~~
name_info = "moq-e2ee-00 name"
Expand All @@ -178,7 +193,7 @@ physical = HKDF-Expand(prk, name_info, 16)

`semantic_name` is the UTF-8 bytes of the application's track name (`catalog.json`, `video`, and so on).
The physical track name is the unpadded base64url encoding of `physical` ({{RFC4648}} Section 5): 22 ASCII characters, which is a valid moq-lite track name.
The same function hides any name the application wants a relay not to read; it is not limited to tracks.
This function may hide any track-shaped name the application wants a relay not to read; it is not limited to media tracks.

AEAD keys are 16 bytes, one per physical name and domain:

Expand Down Expand Up @@ -305,7 +320,7 @@ Each negative row specifies an `operation`, its inputs, and its expected typed e
Non-finite frame inputs use the strings `NaN`, `Infinity`, and `-Infinity`; group inputs in identity tests are decimal strings.
Implementations whose types cannot represent an invalid input MUST reject it at their input boundary.

The file covers derivation, physical naming, grouped frames, a datagram at the fixed budget, the same identity under two epochs, relocation across every identity dimension, tag failure, malformed physical names, identity bounds, and oversize plaintext.
The file covers opaque path derivation, key derivation, physical naming, grouped frames, a datagram at the fixed budget, the same identity under two epochs, relocation across every identity dimension, tag failure, malformed physical names, identity bounds, and oversize plaintext.

The shared verifier is stateless.
It does not verify `reuse`, `exhausted`, `duplicate`, or failure propagation.
Expand All @@ -318,17 +333,20 @@ Relays, caches, recorders, and control planes are untrusted for content.
Authorized endpoints that hold the broadcast secret are trusted.
Sender authenticity against another endpoint that also holds the secret is not a goal of `moq-e2ee-00`.

A relay can still observe the outer broadcast path including the epoch, opaque physical names, group and frame structure, timestamps, sizes, and traffic patterns.
A relay can still observe the outer broadcast path including the opaque segment and epoch, opaque physical names, group and frame structure, timestamps, sizes, and traffic patterns.
Padding and metadata-flow confidentiality are out of scope.

The opaque segment hides the application's semantic broadcast name from a relay only while the application does not publish or otherwise expose the same name in plaintext.
The epoch remains visible to every relay on the path.

Nonce reuse under one key is catastrophic for AES-GCM.
The profile prevents it by deriving every key from an epoch that only one publisher instance ever uses, allocating identities monotonically within that instance, separating datagram and grouped domains, and capping invocations and plaintext bytes per key.
No state survives an instance: nothing needs to be persisted across restarts to stay safe.

Empty AAD does not weaken the binding: every immutable end-to-end field is in the HKDF info or the nonce.
Timestamps are excluded because relays rewrite them; a relay can therefore shift or reorder authentic objects in time within a receiver's tolerance.

Physical names are deterministic functions of the secret and epoch.
The opaque path segment is a deterministic function of the secret and semantic broadcast name; physical track names are deterministic functions of the secret and epoch.
An attacker without the secret cannot predict them; an attacker with the secret can derive every name, which is intended.


Expand All @@ -344,7 +362,7 @@ This document requests no registrations.
## draft-lcurley-moq-e2ee-00
{:numbered="false"}

- Initial `moq-e2ee-00` profile: out-of-band credential, publisher-minted epoch as the last broadcast path segment under `.e2ee`, HKDF physical names and keys, AES-128-GCM payloads, identity bounds, typed failures, and shared primitive vectors.
- Initial `moq-e2ee-00` profile: out-of-band credential, opaque broadcast path with a publisher-minted epoch as its last segment, HKDF physical names and keys, AES-128-GCM payloads, identity bounds, typed failures, and shared primitive vectors.


# Acknowledgments
Expand Down
15 changes: 15 additions & 0 deletions drafts/moq-e2ee-00.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"profile": "moq-e2ee-00",
"constants": {
"salt": "6d6f712d653265652d3030",
"path_label": "6d6f712d653265652d30302070617468",
"name_label": "6d6f712d653265652d3030206e616d65",
"key_label": "6d6f712d653265652d3030206b6579",
"domain_group": 0,
Expand All @@ -26,6 +27,20 @@
"kid": 7,
"secret": "6d6f712d653265652d3030207465737420736563726574212121212121212121"
},
"paths": [
{
"id": "broadcast-meeting-hang",
"context": "6578616d706c652e636f6d2f6d656574696e672d313233",
"kid": 7,
"secret": "6d6f712d653265652d3030207465737420736563726574212121212121212121",
"semantic_name": "meeting.hang",
"semantic_name_bytes": "6d656574696e672e68616e67",
"prk": "a8230410bf77bc88af14260a5ed0ec320e3b174d5a8f4f76780b27df2230f080",
"path_info": "6d6f712d653265652d3030207061746800176578616d706c652e636f6d2f6d656574696e672d3132330000000000000007000c6d656574696e672e68616e67",
"path_material": "a8d326b2e0bd8f7813a35028fb448421",
"opaque": "qNMmsuC9j3gTo1Ao-0SEIQ"
}
],
"derivation": [
{
"id": "group-video",
Expand Down
69 changes: 68 additions & 1 deletion drafts/moq-e2ee-00.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { fileURLToPath } from "node:url";

export const PROFILE = "moq-e2ee-00";
export const SALT = utf8("moq-e2ee-00");
export const PATH_LABEL = utf8("moq-e2ee-00 path");
export const NAME_LABEL = utf8("moq-e2ee-00 name");
export const KEY_LABEL = utf8("moq-e2ee-00 key");
export const DOMAIN_GROUP = 0x00;
Expand Down Expand Up @@ -191,6 +192,16 @@ export function nameInfo(generation: Generation, semanticName: Uint8Array<ArrayB
);
}

/** Canonical path bytes for a credential and semantic name, excluding the epoch. */
export function pathInfo(credential: Credential, semanticName: Uint8Array<ArrayBuffer>): Uint8Array<ArrayBuffer> {
return concat(
PATH_LABEL,
encodeBytes(credential.context),
encodeU64(credential.kid),
encodeBytes(semanticName),
);
}

export function keyInfo(generation: Generation, physicalName: string, domain: Domain): Uint8Array<ArrayBuffer> {
return concat(
KEY_LABEL,
Expand Down Expand Up @@ -219,6 +230,24 @@ export async function deriveName(
return { prk: extracted, info, material, physicalName: base64url(material) };
}

/** Derive the opaque broadcast prefix for a semantic name, independent of epoch. */
export async function derivePath(
credential: Credential,
semanticName: Uint8Array<ArrayBuffer>,
prk?: Uint8Array<ArrayBuffer>,
): Promise<{
prk: Uint8Array<ArrayBuffer>;
info: Uint8Array<ArrayBuffer>;
material: Uint8Array<ArrayBuffer>;
opaque: string;
}> {
checkCredential(credential);
const extracted = prk ?? (await extract(credential));
const info = pathInfo(credential, semanticName);
const material = await hkdfExpand(extracted, info, NAME_LEN);
return { prk: extracted, info, material, opaque: base64url(material) };
}

export async function deriveKey(
credential: Generation,
physicalName: string,
Expand Down Expand Up @@ -297,6 +326,28 @@ function generationJson(generation: Generation) {
};
}

function credentialJson(credential: Credential) {
return {
context: hex(credential.context),
kid: Number(credential.kid),
secret: hex(credential.secret),
};
}

async function pathVector(id: string, credential: Credential, semanticName: string) {
const path = await derivePath(credential, utf8(semanticName));
return {
id,
...credentialJson(credential),
semantic_name: semanticName,
semantic_name_bytes: hex(utf8(semanticName)),
prk: hex(path.prk),
path_info: hex(path.info),
path_material: hex(path.material),
opaque: path.opaque,
};
}

async function derivationVector(id: string, generation: Generation, semanticName: string, domain: Domain) {
const named = await deriveName(generation, utf8(semanticName));
const keyed = await deriveKey(generation, named.physicalName, domain, named.prk);
Expand Down Expand Up @@ -348,6 +399,7 @@ async function payloadVector(
async function generate() {
const base = gen();
const video = await deriveName(base, utf8("video"));
const paths = [await pathVector("broadcast-meeting-hang", base, "meeting.hang")];

const derivation = [
await derivationVector("group-video", base, "video", DOMAIN_GROUP),
Expand Down Expand Up @@ -501,6 +553,7 @@ async function generate() {
profile: PROFILE,
constants: {
salt: hex(SALT),
path_label: hex(PATH_LABEL),
name_label: hex(NAME_LABEL),
key_label: hex(KEY_LABEL),
domain_group: DOMAIN_GROUP,
Expand All @@ -520,6 +573,7 @@ async function generate() {
max_datagram_plaintext: MAX_DATAGRAM_PLAINTEXT,
},
generation: generationJson(base),
paths,
derivation,
naming,
groups,
Expand Down Expand Up @@ -575,6 +629,19 @@ function pathFor(file: string): string {
async function verify(doc: Awaited<ReturnType<typeof generate>>): Promise<void> {
if (doc.profile !== PROFILE) throw new Error(`profile ${doc.profile}`);

for (const row of doc.paths) {
const credential: Credential = {
context: unhex(row.context),
kid: BigInt(row.kid),
secret: unhex(row.secret),
};
const path = await derivePath(credential, utf8(row.semantic_name));
assertEqual("path prk", hex(path.prk), row.prk);
assertEqual("path info", hex(path.info), row.path_info);
assertEqual("path material", hex(path.material), row.path_material);
assertEqual("opaque path", path.opaque, row.opaque);
}

for (const row of doc.derivation) {
const generation = parseGeneration(row);
const named = await deriveName(generation, utf8(String(row.semantic_name)));
Expand Down Expand Up @@ -700,7 +767,7 @@ async function main(): Promise<void> {
throw new Error("drafts/moq-e2ee-00.json is stale; run bun drafts/moq-e2ee-00.ts --write");
}
console.log(
`moq-e2ee-00: ${onDisk.derivation.length} derivation, ${onDisk.groups.length} group, ${onDisk.datagrams.length} datagram, ${onDisk.negative.length} negative vectors`,
`moq-e2ee-00: ${onDisk.paths.length} path, ${onDisk.derivation.length} derivation, ${onDisk.groups.length} group, ${onDisk.datagrams.length} datagram, ${onDisk.negative.length} negative vectors`,
);
}

Expand Down
1 change: 0 additions & 1 deletion quest/m0/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,6 @@ do not add another media abstraction or a renderer crate during stabilization.

## Quests

- [E2EE path](/quest/m0/e2ee-path.md) - align the unpublished path derivation and vectors before the core adopts them
- [E2EE API](/quest/m0/e2ee-api.md) - epoch-scoped ownership replaces raw crypto, catalog helpers, and process-global claims
- [Socket group](/quest/m0/sock-group.md) - complete formation and retained sockets precede usable serving handles
- [uring identity](/quest/m0/uring-identity.md) - sockets and connections carry their worker and steering identity
Expand Down
6 changes: 1 addition & 5 deletions quest/m0/e2ee-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ status separately before replacing a wire profile.
## Plan

- Replace `Credential::new(profile, context, generation, kid, secret)` with `Credential::new(Config { context, kid, secret })`, accepting an application-owned 32-byte secret. Remove `Credential::generate`: it hides the generated secret and cannot provision another process or device. Drop `profile`, `Pin`, `check_pin`, `prk_bytes`, `name_info`, `key_info`, and `key_bytes` from the public surface; the crate is the profile and the vectors run in-crate.
- Add `Credential::path(semantic) -> Path`, the epoch-free opaque broadcast name from [Opaque broadcast path](/quest/m0/e2ee-path.md), and `Generation` from `credential.generation(epoch)`. It owns `name(semantic) -> Name`, `produce(moq_net::track::Producer) -> track::Producer`, `consume(moq_net::track::Subscriber) -> track::Consumer`, and `Epoch::mint()` returning a lowercase UUIDv7 via the `uuid` crate (`v7` feature, added to `[workspace.dependencies]`). `Name` replaces `PhysicalName`; `Epoch` is a validated path segment (nonempty, no `/`, at most 65535 bytes).
- Add `Credential::path(semantic) -> Path`, the profile's epoch-free opaque broadcast name, and `Generation` from `credential.generation(epoch)`. It owns `name(semantic) -> Name`, `produce(moq_net::track::Producer) -> track::Producer`, `consume(moq_net::track::Subscriber) -> track::Consumer`, and `Epoch::mint()` returning a lowercase UUIDv7 via the `uuid` crate (`v7` feature, added to `[workspace.dependencies]`). `Name` replaces `PhysicalName`; `Epoch` is a validated path segment (nonempty, no `/`, at most 65535 bytes).
- Delete `Publication` and its process-global generation set, `retransmit_datagram`, `datagram_ciphertext`, `group::Producer::ciphertext`, the producer-side datagram retention map, `GroupWindow`, `set_subscribe`, `datagram_payload_limit`, `varint_len`, and the `catalog` module. Keep `TrackKey`, `protect`, `open`, and `nonce` crate-private.
- Clones of a generation share per-track publication claims. Reopening the same physical track must not reset its nonce counters; mint a fresh epoch for a new publisher instance. Keep this state inside the generation, never in a process-global registry.
- `track::Producer` allocates sequences monotonically and refuses `create_group` or `insert_datagram` below the next sequence with `Reuse` within the claimed track. Frames are numbered by write order. The datagram plaintext cap is the constant `MAX_DATAGRAM_PLAINTEXT` (1160).
Expand All @@ -28,10 +28,6 @@ because the target draft already exists. Verify publication status before
implementation and preserve any actually published profile as required by
the repository's wire-compatibility rule.

## Required

- [Opaque broadcast path](/quest/m0/e2ee-path.md) - settles the path derivation and vectors this crate implements

## Related

- [Receive failure](/quest/m2/e2ee/receiver-failure.md) - key usage accounting and waking terminal reads, without an API change
Expand Down
Loading
Loading