diff --git a/doc/concept/hang.md b/doc/concept/hang.md index 96b0a3409c..9e00d3470b 100644 --- a/doc/concept/hang.md +++ b/doc/concept/hang.md @@ -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/`, 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 `/`, where `` 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 diff --git a/drafts/draft-lcurley-moq-e2ee.md b/drafts/draft-lcurley-moq-e2ee.md index 3b33487038..ec68f21aa9 100644 --- a/drafts/draft-lcurley-moq-e2ee.md +++ b/drafts/draft-lcurley-moq-e2ee.md @@ -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. @@ -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. @@ -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. @@ -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 `.e2ee/`, where `.e2ee` is the application's broadcast name with an `.e2ee` suffix and `` 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/`. -Subscribers discover instances by the `.e2ee/` prefix and select the greatest epoch when epochs are UUID version 7 text, where greatest is newest. +A protected broadcast is published at `/`, where `` is the 22-character base64url segment derived from the credential and the application's semantic broadcast name according to {{derive}}, and `` 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 `/` 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. @@ -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" @@ -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: @@ -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. @@ -318,9 +333,12 @@ 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. @@ -328,7 +346,7 @@ No state survives an instance: nothing needs to be persisted across restarts to 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. @@ -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 diff --git a/drafts/moq-e2ee-00.json b/drafts/moq-e2ee-00.json index 51b8c210bc..5c18dce4ad 100644 --- a/drafts/moq-e2ee-00.json +++ b/drafts/moq-e2ee-00.json @@ -2,6 +2,7 @@ "profile": "moq-e2ee-00", "constants": { "salt": "6d6f712d653265652d3030", + "path_label": "6d6f712d653265652d30302070617468", "name_label": "6d6f712d653265652d3030206e616d65", "key_label": "6d6f712d653265652d3030206b6579", "domain_group": 0, @@ -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", diff --git a/drafts/moq-e2ee-00.ts b/drafts/moq-e2ee-00.ts index 9d8672ed7b..e965a65f67 100644 --- a/drafts/moq-e2ee-00.ts +++ b/drafts/moq-e2ee-00.ts @@ -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; @@ -191,6 +192,16 @@ export function nameInfo(generation: Generation, semanticName: Uint8Array): Uint8Array { + return concat( + PATH_LABEL, + encodeBytes(credential.context), + encodeU64(credential.kid), + encodeBytes(semanticName), + ); +} + export function keyInfo(generation: Generation, physicalName: string, domain: Domain): Uint8Array { return concat( KEY_LABEL, @@ -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, + prk?: Uint8Array, +): Promise<{ + prk: Uint8Array; + info: Uint8Array; + material: Uint8Array; + 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, @@ -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); @@ -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), @@ -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, @@ -520,6 +573,7 @@ async function generate() { max_datagram_plaintext: MAX_DATAGRAM_PLAINTEXT, }, generation: generationJson(base), + paths, derivation, naming, groups, @@ -575,6 +629,19 @@ function pathFor(file: string): string { async function verify(doc: Awaited>): Promise { 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))); @@ -700,7 +767,7 @@ async function main(): Promise { 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`, ); } diff --git a/quest/m0/README.md b/quest/m0/README.md index c1b44eda82..0c110e2e16 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -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 diff --git a/quest/m0/e2ee-api.md b/quest/m0/e2ee-api.md index fcc7e175d0..f81cf17afb 100644 --- a/quest/m0/e2ee-api.md +++ b/quest/m0/e2ee-api.md @@ -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). @@ -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 diff --git a/quest/m0/e2ee-path.md b/quest/m0/e2ee-path.md deleted file mode 100644 index 8a5173568c..0000000000 --- a/quest/m0/e2ee-path.md +++ /dev/null @@ -1,48 +0,0 @@ -# [S] Opaque broadcast path - -## Goal - -A protected broadcast is published at `/`, where `` is -derived from the credential and the application's semantic broadcast name -(`meeting.hang`) the same way track names are, and nothing in the path says -the bytes are encrypted. The `.e2ee` suffix is gone: encryption is opaque to -the path, so no matcher, exporter, recorder, or classifier grows a naming -rule, and the format after decryption stays `.hang` inside the opaque name -where only a credential holder can read it. The epoch stays as the last -segment: a subscriber needs it before it can derive anything, so it has to -travel in the clear, and the prefix is what it discovers instances under. - -The draft and the vectors change together; no code moves until the Rust -and TypeScript cores take this contract. - -## Plan - -- `drafts/draft-lcurley-moq-e2ee.md`: rewrite {{epoch}} so the path is - `/`; `` is 22 base64url characters from a new - derivation with label `"moq-e2ee-00 path"` over `context`, `u64(kid)`, and - the semantic broadcast name, with no epoch, or subscribers could not find - the prefix. Keep discovery by prefix and greatest UUIDv7. Delete the - `.e2ee` sentences and the `.hang` prohibition; say instead that the path - carries no format or protection marker and a plaintext consumer fails on - the catalog it cannot find. Note in Security Considerations that the - opaque name hides the application's name from a relay only as long as the - application does not reuse it in plaintext, and that a relay still sees the - epoch. The profile stays `moq-e2ee-00`: nothing has shipped. -- `drafts/moq-e2ee-00.ts` and `.json`: add a `path` label constant and a - derivation vector for `meeting.hang` under the sample credential, and a - negative vector for a semantic name containing `/` if the draft forbids it. -- The questline already states this contract (its README, the - [browser](/quest/m2/e2ee/browser.md) and [CLI](/quest/m2/e2ee/cli.md) - quests); keep them aligned with whatever the draft settles on. -- Library shape, for the cores to implement: `Credential::path(semantic) -> - Path` beside `Generation::name(semantic) -> Name`, so the epoch-free - derivation cannot be confused with the epoch-scoped one. Amend - [#3717](https://github.com/moq-dev/moq/issues/3717) to record that the - epoch segment stays and why. - -Public API: none yet (the cores adopt it). Wire: none; broadcast paths are -application data. - -## Closes - -- [#3717](https://github.com/moq-dev/moq/issues/3717) - close this issue when the quest finishes diff --git a/quest/m1/release.md b/quest/m1/release.md index 08cac7aa1c..364f75c111 100644 --- a/quest/m1/release.md +++ b/quest/m1/release.md @@ -84,7 +84,6 @@ Public API: none beyond the required quests. Wire: none. ## Required -- [E2EE path](/quest/m0/e2ee-path.md) - settle the unpublished opaque path derivation and vectors - [E2EE API](/quest/m0/e2ee-api.md) - expose epoch-scoped ownership and align the implemented profile - [Socket group](/quest/m0/sock-group.md) - make partial reuseport groups and early socket drops unrepresentable - [uring identity](/quest/m0/uring-identity.md) - bind sockets, connections, workers, and steering identity together diff --git a/quest/m2/e2ee/README.md b/quest/m2/e2ee/README.md index 2527e38437..57ead4b8c3 100644 --- a/quest/m2/e2ee/README.md +++ b/quest/m2/e2ee/README.md @@ -19,7 +19,7 @@ The contract is [draft-lcurley-moq-e2ee](/drafts/draft-lcurley-moq-e2ee.md), pro ### Epoch and identity -- Every publisher instance mints an epoch, a UUIDv7 in lowercase text, and publishes at `/`, where `` derives from the credential and the semantic broadcast name without the epoch ([Opaque broadcast path](/quest/m0/e2ee-path.md)). The epoch is an input to every HKDF derivation, so a restart, takeover, or explicit group sequence cannot repeat a nonce under a key: nothing is persisted across instances and no generation counter is redistributed. Subscribers discover instances under the `/` prefix and take the greatest epoch, which sorts newest; a known full path carries its epoch in the last segment. +- Every publisher instance mints an epoch, a UUIDv7 in lowercase text, and publishes at `/`, where `` derives from the credential and the semantic broadcast name without the epoch. The epoch is an input to every HKDF derivation, so a restart, takeover, or explicit group sequence cannot repeat a nonce under a key: nothing is persisted across instances and no generation counter is redistributed. Subscribers discover instances under the `/` prefix and take the greatest epoch, which sorts newest; a known full path carries its epoch in the last segment. - The epoch is untrusted and unauthenticated. A wrong epoch fails authentication and a withheld one denies service; neither can make a nonce repeat, because only the publisher instance chooses what it encrypts under. This is the same trust a cache needs to serve the right instance, and it is deliberately e2ee-only: plaintext hang keeps its current paths. - Nothing in the path says the bytes are encrypted. The format after decryption (`meeting.hang`) is inside the opaque name; a plaintext player, exporter, or matcher that opens a protected broadcast finds no catalog it can read and fails with its usual typed refusal, the same as for any format it does not support. - One 32-byte secret authorizes the whole broadcast; HKDF-SHA-256 derives separate AES-128-GCM keys for each physical track and for grouped-frame versus datagram domains. A grouped frame uses the 96-bit nonce `uint64_be(group) || uint32_be(frame)`; a datagram uses its sequence with frame zero under the datagram domain. Empty AAD: every immutable end-to-end field is in the HKDF info or the nonce. @@ -30,8 +30,8 @@ The contract is [draft-lcurley-moq-e2ee](/drafts/draft-lcurley-moq-e2ee.md), pro The Rust and TypeScript cores expose the same surface, and nothing else: -- `Credential { context, kid, secret }` accepts the application-owned secret. The application generates and distributes it over its authenticated channel; the library does not mint a secret it cannot return. Credential is cheap to clone, never serializes the secret, and redacts it from `Debug`. -- `credential.generation(epoch)` binds a discovered or minted epoch. `Generation` owns `name(semantic)` for opaque names (any string, tracks or path segments alike), `produce(track)` and `consume(track)` for protected `moq-net` tracks, and `Epoch::mint()` returns a fresh UUIDv7. Clones share publisher claims so reopening a track cannot reset its nonce counters. +- `Credential { context, kid, secret }` accepts the application-owned secret. The application generates and distributes it over its authenticated channel; the library does not mint a secret it cannot return. `credential.path(semantic)` derives the epoch-free opaque broadcast name. Credential is cheap to clone, never serializes the secret, and redacts it from `Debug`. +- `credential.generation(epoch)` binds a discovered or minted epoch. `Generation` owns `name(semantic)` for opaque track names, `produce(track)` and `consume(track)` for protected `moq-net` tracks, and `Epoch::mint()` returns a fresh UUIDv7. Clones share publisher claims so reopening a track cannot reset its nonce counters. - `track::Producer` appends groups and datagrams and allocates identities; `track::Consumer` yields groups and datagram events. `group::Producer` and `group::Consumer` wrap the whole group lifecycle so every AEAD call has the canonical physical name and transport identity. - Errors are the draft's typed codes plus the transport's. Nothing catalog-, hang-, or MSF-shaped lives here: a catalog is a track under a derived name, and compression is the catalog owner's job. - Stateless `seal`/`open` primitives with caller-chosen identities, HKDF labels and info builders, raw key bytes, and process-global claims are not public. The vectors are tested inside each core. @@ -68,6 +68,5 @@ The Rust and TypeScript cores expose the same surface, and nothing else: ## Related - [Release E2EE API](/quest/m0/e2ee-api.md) - the Rust surface and epoch profile are release requirements; browser and application integration follow here -- [Opaque broadcast path](/quest/m0/e2ee-path.md) - the draft and vectors the cores implement - [archive](/quest/m2/archive/README.md) - protected broadcasts are deliberately outside recording and replay formats - [Merge dev](/quest/m1/merge-dev.md) - its HLS soak (a fresh viewer joining a days-old broadcast, playable since #3240) covers plaintext broadcasts only; stock HLS and DASH cannot read a protected catalog diff --git a/quest/m2/e2ee/typescript.md b/quest/m2/e2ee/typescript.md index 4bbf077a33..13c1e06538 100644 --- a/quest/m2/e2ee/typescript.md +++ b/quest/m2/e2ee/typescript.md @@ -8,7 +8,7 @@ the Rust crate name for name, without putting keys or crypto policy in `@moq/net ## Plan -- Add the package above `@moq/net`. `Credential` accepts a 32-byte secret or a nonextractable HKDF `CryptoKey` with `deriveBits` usage, rejects extractable keys, and never serializes the secret. `credential.path()` returns the opaque broadcast name and `credential.generation(epoch)` returns a `Generation` with `name()`, `produce()`, `consume()`, and a `mint()` that returns a lowercase UUIDv7 from `Date.now()` plus `crypto.getRandomValues`. +- Add the package above `@moq/net`. `Credential` accepts a 32-byte secret or a nonextractable HKDF `CryptoKey` with `deriveBits` usage, rejects extractable keys, and never serializes the secret. `credential.path(semantic)` returns the opaque broadcast name and `credential.generation(epoch)` returns a `Generation` with `name()`, `produce()`, `consume()`, and a `mint()` that returns a lowercase UUIDv7 from `Date.now()` plus `crypto.getRandomValues`. - Mirror the Rust modules: `Track.Producer`, `Track.Consumer`, `Group.Producer`, `Group.Consumer`, `Datagram.Event`, and a `Failure` whose `code` is the draft's typed set. No catalog helpers, no exported constants beyond the profile limits an application sizes payloads with, no stateless `protect`/`open` with caller-chosen identities, and no test-only methods on public classes. - WebCrypto AES-GCM is async, so each producer and consumer runs a serial pump: one AEAD call in flight, completions in submit order, a bounded waiter queue that throws when full. Depth one keeps frame ordinals equal to the inner `@moq/net` write order without reservation sets; raise it only when a measured capture burst needs it. Propagate backpressure, cancellation, and authentication failure explicitly; never reorder media or fall back to plaintext. - Identity state is two counters per key (invocations, plaintext bytes) and one monotonic sequence per track. Check payload size before taking a frame ordinal so a rejected write never burns an identity. Datagram receivers keep a 1024-bit sliding bitmask marked only after a successful open; failed opens count against the key; a grouped authentication failure closes the ordered track.