open-standards/wai/extensions/confidentiality.md
Transaction Science d916fcd80c confidentiality §15: a member in a browser, and a room service as the carrier of a room's MLS messages; READMEs, CHANGELOGs, CI
§1's scope now covers the browser and states what a browser member does not do in this
revision (§15.3): watch without being a member, ingest by WHIP or WebRTC, seek back. §15.2
makes the carrier's order binding on a member's own create and commit: applied only when the
log returns it, dropped when another's commit for the epoch comes first.

CI: joule-moq-room and joule-moq-web run on a joule-moq-mls change; the conformance job runs
joule-moq-web's tests with feature e2ee, which run only with it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-20 08:47:05 -04:00

855 lines
50 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# WAI Extension: Confidentiality (end-to-end encrypted rooms)
> Status: Draft. Sub-spec of WAI v1.0, and Lane B of SPEC §6.5: end-to-end encryption at the
> application layer. Registers the record `wai.live.e2ee`: the room authority's signed
> statement, `WRE1`, that a room of [live-rooms](live-rooms.md) is end-to-end encrypted and
> with which ciphersuites. In such a room every object of a media track is sealed by its
> publisher as an **MoQ secure object** (§4), so a relay routes, caches, meters and receipts
> ciphertext alone, and only the room's current members open it. The keys come from an MLS
> group (RFC 9420) whose membership follows the room's grants and lifecycle (§5): a principal
> that joins under a grant that may receive or publish is added, a principal that leaves,
> whose grant is revoked or whose grant ends is removed, and the next epoch's keys exclude it.
> The room's clock proposes every change and a member commits it, by a rule every member
> computes alike (§5.5). A recording of such a room is refused (§9). The reference
> implementation is open and in this tree: the `secure_objects` module of `joule-moq`
> (feature `secure-objects`) seals and opens objects and holds a receiver's keys and checks;
> the `joule-moq-mls` crate binds a room to an MLS group and exports each epoch's
> per-sender keys; `joule-moq-web` (feature `e2ee`) builds both for a browser page, and the
> room service `joule-moq-room` carries a room's MLS messages in one order (§15). The corpus
> is [`live-conformance/e2ee/`](../live-conformance/e2ee/), and its second implementation is
> `live-conformance/e2ee_verify.py`.
> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174.
>
> Documents read for this revision, each pinned to the revision named:
> `draft-ietf-moq-secure-objects-01` (6 July 2026, "secure objects"),
> `draft-jennings-moq-mocha-mls-keying-00` (6 July 2026, "the keying draft") and
> `draft-ietf-moq-transport-22` (1 October 2026, "MOQT"), all Internet-Drafts and none past
> Working Group Last Call; RFC 9605 (SFrame), RFC 9420 (MLS) and RFC 9750 (the MLS
> architecture); with RFC 5869 (HKDF), RFC 5116, NIST SP 800-38D (GCM) and FIPS 197 (AES)
> for the primitives. A section number of one of those documents in this text is that
> revision's. Where the documents disagree with one another, §11 says which reading this
> extension takes and why.
## 1. Scope and model
A room ([live-rooms](live-rooms.md)) carries each publisher's media on tracks named by the
room id, the publisher and the media kind (live-rooms §6.1), through relays that the room's
participants do not trust with the content. This extension makes those media tracks
private: the publisher seals each object before it leaves the device, every relay forwards
the sealed bytes as they are, and a member opens them after they arrive.
In scope:
- the room's confidentiality record (§3);
- object sealing, exactly as secure objects specifies it, on the objects of MOQT -22 (§4);
- keying: one MLS group per room, its credentials bound to the principals' room keys, its
membership driven by the room's lifecycle, the rule that says who commits, and each
epoch's per-sender keys (§5);
- what a receiver checks, in order, and the reason for each refusal (§6);
- rooms where several principals publish at once (§7);
- receipts and metering over ciphertext, and what a relay can and cannot prove (§8);
- recording and clips (§9);
- decrypt-then-dispatch and relay transparency (§10);
- a member in a browser, and a room service as the carrier of a room's MLS messages (§15).
Sealed in this revision: the objects of every **media track** of the room (live-rooms §6.1:
`audio`, `video`, `screen`, each layer). In the clear, as in a room without this extension:
- each publisher's **catalog track**, which states codecs and dimensions: the shape of what
is sent, never its content;
- each media track's **receipts track**, whose receipts commit to the sealed bytes (§8);
- every **events track** (live-events §8.1). The clock's lifecycle events must be read by
the clock itself, which is not a member (§5.3), and by every relay that admits grants; a
participant's events (chat, reactions, markers) are held to its grant's policy by relays
and by any room verifier (live-rooms §9), and sealing them would move those checks to the
members alone. Events stay signed in the clear in this revision; a participant that needs
a private message sends it on a media track.
Out of scope:
- **A transport.** Sealed objects ride MOQT exactly as plaintext objects do, through the
same relays, with no relay change (§10).
- **MLS itself.** Group state, the ratchet tree, commits, welcomes and the key schedule are
RFC 9420's; this extension fixes the ciphersuite, the credentials, the external sender,
who commits and what a commit holds, and the exporter call (§5).
- **What a browser member does not do in this revision** (§15.3): watch without being a
member, ingest by WHIP or WebRTC into such a room, or seek back.
- **Hardware-protected content** (SPEC §6.5, Lane A). This extension is Lane B: the
plaintext is in the member's application memory (§12).
## 2. Terms
- **Room**, **room id**, **room authority**, **clock**, **principal**, **grant**,
**grant in effect**, **present**: as in live-rooms §2 and §9.3.
- **Keyed grant**: a grant that may subscribe (`subscribe` 1) or may publish any media kind
(`publish` not 0). A principal present under a keyed grant needs the room's keys: to open
what it receives, or to seal what it publishes.
- **Member**: a principal that holds a leaf of the room's MLS group in an epoch.
- **Epoch**, **leaf index**, **commit**, **proposal**, **Welcome**, **KeyPackage**: as in
RFC 9420. The **roster** of an epoch maps each occupied leaf index to its member.
- **Committer**: the member that commits the end of an epoch (§5.5).
- **Sealed object**: an object of a media track whose payload is a secure-objects
ciphertext and whose Immutable Properties carry its Key ID (§4).
- **KID**: the Key ID of a sealed object (§5.7).
- **vi64**: the variable-length integer of MOQT §8.1. Where this text writes one, it is the
shortest encoding of its value; where it reads one, any encoding MOQT allows is read.
## 3. The room's confidentiality record (`wai.live.e2ee`)
A room is end-to-end encrypted when its authority signs a **confidentiality record** for
it. The record names the room and the two ciphersuites; members and publishers seal and open
under it, and a verifier is given it beside the room's descriptor.
### 3.1 Layout
Integers are little-endian, as in the records of live-rooms.
| field | size | meaning |
|---|---|---|
| `magic` | 4 | `WRE1` (ASCII) |
| `version` | 1 | 1 in this revision |
| `room` | 32 | the room id (live-rooms §3.2) |
| `suite` | 2 | u16: the secure-objects ciphersuite (secure objects §7.2) every sealed object of the room uses |
| `mls_suite` | 2 | u16: the MLS ciphersuite (RFC 9420 §17.1) of the room's group |
| `sig` | 64 | §3.2 |
### 3.2 Signature
The **signature** is Ed25519 by the descriptor's `authority` over the 18 bytes
`wai:live-room-e2ee` (ASCII) and 0x01, followed by the record from `magic` through
`mls_suite`. It is verified strictly, as [SPEC](../SPEC.md) §7 *Signatures* requires: a key
that fails step 1 or 2 is `bad_key`, and a signature that fails a later step is
`bad_signature`.
### 3.3 Checking the record
A verifier is given the record, the room id, the descriptor's `authority` and the
descriptor's recording `mode` (live-rooms §3.7), and refuses at the first of these that
holds:
1. Fewer than 4 bytes: `truncated`. A magic other than `WRE1`: `malformed`. No byte after
the magic: `truncated`. A version other than 1: `unknown_version`. Fewer than 105 bytes:
`truncated`. More: `malformed`.
2. `room` is not the room id: `wrong_room`.
3. `suite` is not `0x0004` (`AES_128_GCM_SHA256_128`) or `0x0005`
(`AES_256_GCM_SHA512_128`): `suite_unsupported`. Secure objects makes `0x0004`
mandatory to implement; the counter-mode suites are not used in rooms in this revision
(§11.2).
4. `mls_suite` is not `0x0001` (`MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519`):
`mls_suite_unsupported`. It is the suite whose signatures are Ed25519, the scheme of the
principals' room keys (§5.2).
5. The descriptor's recording `mode` is not 0: `recording_refused` (§9).
6. The signature (§3.2): `bad_key` or `bad_signature`.
A room has at most one confidentiality record: a room id is the digest of its descriptor,
and a room whose confidentiality changed would be a new room.
## 4. Object sealing
Sealing is secure objects' construction, applied to the objects of a room's media tracks
as MOQT -22 carries them. Nothing below changes a cryptographic step of secure objects;
§4.3 and §4.4 say how its fields sit on a -22 object, and §11 where the documents leave a
choice.
### 4.1 What is protected
| protection | fields |
|---|---|
| hop by hop only (TLS of each MOQT session) | Track Alias; the object's mutable properties, those outside Immutable Properties |
| end to end, authenticated | the Full Track Name; Group ID; Object ID; Publisher Priority; the Immutable Properties, the Key ID among them |
| end to end, encrypted and authenticated | the original payload; the Encrypted Properties List |
This is secure objects' Table 1. A relay reads everything in the first two rows, which is
all it needs to route, cache, prioritise and meter (MOQT §8.4, §10.7); it can change only
the first row without the receiver noticing.
### 4.2 The Serialized Full Track Name
The Serialized Full Track Name is the object's Track Namespace as MOQT §8.7 encodes it, a
vi64 count of fields and each field as a vi64 length and its bytes, followed by its Track
Name as a vi64 length and its bytes (secure objects §3.3.1). In a room the fields are those
of live-rooms §6.1: for `<room>-<principal>-video--1`, the count 3, the 64 hexadecimal
digits of the room id, the principal's 64 digits, `video`, then the Track Name `1`.
### 4.3 The Key ID property
The Key ID is an Object Property, `SECURE_OBJECT_KEY_ID`, whose value is a vi64, the KID
(§5.7), carried inside the object's Immutable Properties (MOQT §10.7), exactly once (secure
objects §4.1). Its type in this revision is **`0x3A4A`**, from the range MOQT §8.4 sets aside for applications, and not
the `0x2` secure objects §7.1 asks for: MOQT -22 §16.8 has given `0x02` to
OBJECT_DELIVERY_TIMEOUT, so a -22 relay that read a Key ID at `0x2` would hold it for a
delivery timeout of that many milliseconds (§11.1). The type is even, so the value is one
vi64 (MOQT §8.3). A sealed object carries no Key ID property outside its Immutable
Properties.
The **Serialized Immutable Properties** that secure objects §3.5 puts in the AAD are the
value of the object's Immutable Properties property: its Key-Value-Pairs exactly as the
object carries them, which MOQT §10.7 forbids anyone after the publisher to re-encode. A
publisher writes them in the order MOQT §8.3 requires, each type the delta from the one
before, each vi64 the shortest.
The object receipt properties of [moq-streaming-format](moq-streaming-format.md) §5
(`WAI_RECEIPT`, `JWP_OBJECT_RECEIPT`) commit to the sealed bytes, which depend on the
Immutable Properties through the AAD; a sealed object therefore carries neither inside its
Immutable Properties. A sealed track's receipts ride its receipts track (live-rooms §6.1;
§8).
### 4.4 The plaintext
The AEAD's plaintext is the original payload and, optionally, the Encrypted Properties List
(secure objects §3.3.2):
```
pt = vi64(len(payload)) ‖ payload ‖ [ list ]
list = 0x000A (2 bytes) ‖ vi64(len(entries)) ‖ entries
entry = type (2 bytes, not 0x0032) ‖ vi64(len(value)) ‖ value
padding = 0x0032 (2 bytes) ‖ length (2 bytes) ‖ length zero bytes
```
Types and the padding length are big-endian. The list's form is secure objects §3.3.3
step 4 and §4.2, an entry's form and the padding's are the forms of secure objects'
Appendix B and §4.3 (§11.3). The **original payload** is what the object would carry in a
room without this extension: one WAI envelope ([moq-streaming-format](moq-streaming-format.md)
§3) or one media frame.
This extension defines one entry:
- **`WAI_INNER_DIGEST`** (type `0x3A4B`): the 32-byte BLAKE3 hash of the original payload,
the content hash [jwp-receipts](jwp-receipts.md) §2.1 computes over an object's payload.
It is the hash a receipt of the object would carry in the clear, sealed with the object, so
a member checks the plaintext against a digest the publisher committed to (§8.2) and
plaintext-level records attach to it. A publisher **SHOULD** carry it on every sealed
object of a video or screen track and **MAY** leave it out where its 35 bytes weigh on a
small object, as a frame of speech is.
Padding (secure objects §4.3) is the last entry when present; a receiver ignores its bytes.
A receiver reads the plaintext so, and refuses with `plaintext_malformed` where it does not
read: a vi64 length and that many bytes of payload; if nothing remains, no list; otherwise
2 bytes that are `0x000A`, a vi64 length that is exactly the number of bytes left, and
entries that fill them, each read by its form, padding only as the last, at most one
`WAI_INNER_DIGEST`, of exactly 32 bytes. An entry of another type is skipped.
### 4.5 Key and salt
For the object's KID, the base key of its track (§5.7) and its Serialized Full Track Name,
with the suite's hash (`SHA-256` for `0x0004`, `SHA-512` for `0x0005`), as secure objects
§3.7 derives them:
```
moq_secret = HKDF-Extract("", track_base_key)
moq_key = HKDF-Expand(moq_secret, "MOQ 1.0 Secure Objects Secret key " ‖ ftn ‖ suite ‖ kid, Nk)
moq_salt = HKDF-Expand(moq_secret, "MOQ 1.0 Secret salt " ‖ ftn ‖ suite ‖ kid, Nn)
```
`suite` is the record's `suite` as 2 bytes big-endian, `kid` the KID as 8 bytes big-endian,
and the label strings end in the space shown.
### 4.6 Nonce
`CTR` is the Group ID as 8 bytes big-endian followed by the Object ID as 4 bytes big-endian,
and the nonce is `moq_salt XOR CTR` (secure objects §3.6). An Object ID above 2^32 − 1 has
no nonce: such an object is not sealed, and a receiver refuses it `object_id_too_large`. A
publisher of a sealed track numbers its objects within that bound.
### 4.7 AAD
```
aad = Group ID (8 bytes) ‖ Object ID (4 bytes) ‖ Publisher Priority (1 byte) ‖ Serialized Immutable Properties
```
big-endian (secure objects §3.5). The Publisher Priority is the object's as its publisher
sent it; a publisher of a sealed track states it in every subgroup header and datagram, so
it never falls back to a track default a relay could hold differently (MOQT §10.4).
### 4.8 Ciphersuites
| `suite` | name | AEAD | hash | Nk | Nn | Nt |
|---|---|---|---|---|---|---|
| `0x0004` | `AES_128_GCM_SHA256_128` | AES-128-GCM | SHA-256 | 16 | 12 | 16 |
| `0x0005` | `AES_256_GCM_SHA512_128` | AES-256-GCM | SHA-512 | 32 | 12 | 16 |
GCM is SP 800-38D's with a 96-bit nonce and a 128-bit tag, the tag following the ciphertext
(RFC 5116). The reference implements RFC 9605's counter-mode suites as well (§11.2), and
checks them against RFC 9605's vectors; a room does not use them in this revision.
### 4.9 Seal
To seal `payload` as object (`group`, `object`) of a track, at priority `p`, with the KID and
track base key of the publisher's current epoch (§5.7):
1. Build the Immutable Properties: the Key ID property (§4.3) and any other property the
publisher fixes before sealing (the layer properties of moq-streaming-format §4.1, the
envelope flag), serialized per §4.3.
2. Build the plaintext (§4.4).
3. Derive `moq_key` and `moq_salt` (§4.5); form the nonce (§4.6) and the AAD (§4.7).
4. The object's payload is `AEAD.Encrypt(moq_key, nonce, aad, pt)`, the tag included.
A publisher counts the objects it seals under each `moq_key` and stops before the limit of
secure objects §6.3 for its suite; a key is one track's in one epoch, and the next epoch
gives a fresh one (§5.8).
### 4.10 Open
A receiver opens an object only through the checks of §6, which end in
`AEAD.Decrypt(moq_key, nonce, aad, payload)`. A wrong key, a changed property, priority,
Group ID, Object ID or track, and a changed byte of the payload or tag all fail alike, as
`bad_tag`, and the receiver discards the object (secure objects §3.9).
## 5. Keying from MLS
### 5.1 The group
Each room has one MLS group at a time, of the record's `mls_suite`. Its group ID is the 32
bytes of the room id followed by a 4-byte big-endian **generation**, 0 for the room's first
group. Handshake messages (proposals and commits) are sent as PublicMessage (RFC 9420
§6.2), which RFC 9750 §8.1.2 weighs: here membership is already public in the room's
lifecycle events, and the clock, which is not a member, follows each epoch from them. MLS protocol messages ride the tracks of the
keying draft §3 within the room's namespace, under the names live-rooms §6.1 leaves free:
each member's `mls` track and `keypackage` track, owner its principal. A deployment that
carries them otherwise, a room service passing them directly, changes nothing below.
### 5.2 Credentials: the principal's room key
A member's leaf carries a BasicCredential (RFC 9420 §5.3) whose `identity` is the 32-byte
Ed25519 key of the principal its grant names, and whose leaf `signature_key` is that same
key: the principal signs its MLS messages with the key that signs its events and its proofs
of possession (live-rooms §10.5). MLS signs with its own labels (`"MLS 1.0 "` and the
structure's name, RFC 9420 §5.1.2) and WAI with its own domains, so no signature of one can
be taken for the other.
A member validates a credential (RFC 9420 §5.3.1, RFC 9750 §4) against the room: an Add
whose leaf's identity is not its signature key is refused `credential_mismatch`, and one
whose principal is not the subject the clock owes an Add (§5.4) `proposal_mismatch`; a
commit that holds a proposal a member refused never applies for that member. The room authority stands as the authentication service of RFC 9750: its grants say who
the principals are, and the clock's lifecycle says which of them are present.
### 5.3 The clock as the external sender
The group's GroupContext carries the `external_senders` extension (RFC 9420 §12.1.8.1) with
exactly one entry: the descriptor's `clock` key, with a BasicCredential whose identity is
that key. The clock is never a member and holds no epoch's secret; it signs external Add and
Remove proposals (RFC 9420 §12.1.8), as PublicMessage, for the group's current epoch. A
member that joins a group whose group ID or external sender is not the room's (§5.1) refuses
it `group_mismatch`.
### 5.4 Membership follows the room
The clock turns each lifecycle event of live-rooms §7.2 that changes who holds keys into one
proposal, in the order of the events:
- **`join`** under a keyed grant: an Add of the subject's KeyPackage. When no group exists
(the room's first keyed join, or every member has left), there is nothing to add to: the
subject creates the group, of the next generation, at epoch 0, itself at leaf 0, with the
clock as its external sender.
- **`leave`** of a member, for any reason of live-rooms §7.2: a Remove of its leaf. The
clock records a `leave` when a principal leaves (reason 1), when the authority removes it,
a revocation through the key log among the ways (live-rooms §5; reason 2), when its grant's
window ends (reason 3), and when its connection is lost (reason 4). Each is a Remove, and
the epoch the commit opens excludes the principal.
- **`grant_exchange`** from a keyed grant to one that is not: a Remove; from a grant that is
not keyed to a keyed one: an Add. An exchange between keyed grants changes nothing here.
Nothing else is proposed: a principal present under a grant that is not keyed (it sends
events only) is never a member, and the clock sends no Update and no other proposal.
What the clock owes the group is the difference between the principals present under keyed
grants and the group's membership once its pending proposals are committed. It proposes, one
at a time, the earliest owed change, in the order of the lifecycle events that call for
them, that can be proposed now: an Add into any group, a Remove only of a principal that
holds a leaf. So a principal that leaves before the clock proposes its Add is never added,
and one that leaves while its Add is pending is removed once a commit has added it. When a
commit ends an epoch without some of the clock's proposals for it, those are void, as RFC
9420 makes every proposal for an ended epoch, and the same rule proposes them again for the
new epoch, before anything owed later.
A group in which no member is still present under a keyed grant can never commit again: it
ends, and the next keyed join creates the group of the next generation.
A KeyPackage reaches the clock as the keying draft §4.7 has it: the joining principal
publishes it on its `keypackage` track, signed by its room key (§5.2), and each is used
once. A Welcome reaches the new member on the committer's `mls` track (the keying draft
§4.8).
### 5.5 Who commits
**The members commit, by one rule: the committer of the commit that ends epoch *E* is the
member at the lowest leaf index of epoch *E* that the commit's proposals do not remove.**
The commit holds, by reference, the first *k* proposals the clock sent for epoch *E*, for
some *k*, each once, in any order, and no other proposal: the clock's proposals reach the
committer in order on the clock's track, so the ones it holds when it commits are always the
first. It carries an UpdatePath, so the committer's own leaf is renewed with every epoch. A
commit with no proposal is allowed, from the committer alone: it renews the epoch's keys and
nothing else.
A member checks each proposal when it arrives, and keeps it only if it passes: the clock's
must be for the current epoch, its Add's credential bound as §5.2 requires
(`credential_mismatch`), and the change the clock owes (`proposal_mismatch`). A member's own
proposal is kept but never committed. A member applies a commit only after checking it, in
this order:
- its sender is the committer: otherwise `commit_not_committer`;
- every proposal it holds is the clock's: otherwise `proposal_not_clock`;
- the proposals it holds are the clock's first *k* for the epoch, each once: otherwise
`commit_mismatch`.
A commit that fails is discarded, and its epoch never exists for that member.
Why the members, and why this rule:
- **The clock cannot commit.** In MLS only a member commits, and a member holds every
epoch's secret. The clock is the room's own service; were it the committer, the room's
host could open every object, and "only the room's current members" would include a party
no participant joined with. As an external sender it orders every membership change and
holds no key.
- **One committer, computed alike everywhere.** The keying draft §4.2 has one committer per
group so that every epoch has exactly one commit and nothing forks. Here every member
computes the committer from the epoch's tree and the commit's own proposals, with no
election and no extra message: the rule is the keying draft's failover rule (§4.4 there),
the lowest eligible member, applied at every epoch rather than only after a timeout.
- **It heals itself.** A committer that goes away is a member whose connection is lost: the
clock records its `leave` (reason 4), and the commit that removes it is, by the rule, the
next member's. A committer that stays but does not commit is, to the room, a member that
has stopped responding, and is removed the same way.
- **It can be checked.** The rule needs the tree and the commit alone, so a member checks
every commit before applying it, and a verifier checks a room's whole transcript (§13).
### 5.6 Roster
A commit applies its proposals as RFC 9420 §12.3 orders them: every Remove blanks its leaf,
then every Add, in the order the commit lists them, takes the leftmost blank leaf, or a new
leaf at the right edge when none is blank (RFC 9420 §7.7, §12.1.1): the lowest leaf index
no member holds. The roster of the new epoch follows from the
roster before it and the commit alone, so the clock, which is not a member, knows each
member's leaf index, and so does a verifier.
### 5.7 Per-epoch, per-sender keys
At each epoch *E*, every member derives the epoch's **application secret** with the MLS
exporter (RFC 9420 §8.5):
```
application_secret[E] = MLS-Exporter("wai live e2ee", room_id, 32)
```
and from it the **track base key** of each namespace, as the keying draft §7.1 derives a
track key, with HKDF-Expand of the MLS suite's hash (SHA-256):
```
track_base_key[E, ns] = HKDF-Expand(application_secret[E], "mocha_track" ‖ Serialize(ns), 32)
```
`Serialize(ns)` is the namespace as §4.2 serializes it, and `mocha_track` is ASCII with no
separator. The **KID** of an object a member at leaf index *L* seals in epoch *E* is the
keying draft's §7.2:
```
KID = E × 2^24 + L (E < 2^40, L < 2^24)
```
The base key is the namespace's, not the track's: secure objects §3.2 scopes a Key ID to a
namespace, so the tracks of one namespace (a publisher's layers of one media kind) share it,
and secure objects' own derivation (§4.5) mixes in the Full Track Name, so each layer's key
and salt are its own. Every sender's own tracks are in a namespace of their own (live-rooms
§6.1), and the KID names the sender, so a sender's key is its own in every epoch: two
members never seal under one `(moq_key, nonce)`, even in a slot where a live publisher and
its standby send on the same track (live-rooms §7.1). A receiver reads the epoch and the
leaf index from the KID and derives the one key that opens the object, with no trial
decryption.
The KID's 40 bits of epoch and 24 of leaf index fit in one vi64 (MOQT §8.1); a group whose
epoch would reach 2^40 or whose tree would exceed 2^24 leaves ends, and the next join starts
the next generation.
### 5.8 Rotation and retention
Every commit opens an epoch whose application secret, and so every key derived from it, is
new. A member derives an epoch's keys when it applies the commit that opens it, and a
publisher seals with them from then on. A receiver that holds an object whose KID names an
epoch it has not reached yet **MAY** keep it until it applies the commit (secure objects
§3.9), and opens it then.
A member keeps an epoch's keys while objects of that epoch can still arrive, and deletes
them after; for a receiver, an epoch whose keys it deleted is one it no longer joins (§6
step 4). Keeping them no longer than needed is what gives MLS's forward secrecy to the room's
media: a member's keys taken later open nothing sent before the epochs it still holds.
## 6. Receiving
A receiver is a member. For an object of a media track of the room it runs these steps in
order, and refuses at the first that fails:
1. The object's properties read (MOQT §8.3, §10.7), and its Immutable Properties hold
exactly one Key ID property and the mutable list none: `properties_malformed` for a list
that does not read or a Key ID twice, outside, or not a vi64; `key_id_missing` for none.
A media object of an end-to-end encrypted room is always sealed: one without a Key ID is
refused, never decoded as plaintext.
2. The Object ID is at most 2^32 − 1: `object_id_too_large`.
3. The KID gives the epoch *E* and leaf index *L* (§5.7).
4. The receiver was a member in epoch *E*: `epoch_not_joined` when *E* precedes the epoch it
joined at, or it deleted *E*'s keys (§5.8); `removed_before_epoch` when it was removed at
or before *E*. (*E* later than the receiver's latest epoch is held, §5.8, not refused.)
5. *L* is occupied in epoch *E*'s roster: `key_id_unknown`.
6. The member at *L* in epoch *E* is the track's owner (live-rooms §6.1: the namespace's
second field is its key; for a slot's track, a publisher the room state binds to the
slot): `sender_not_owner`. A member holds every sender's keys in its epochs, so it could
seal under another's KID; this step, and the publisher's signed receipts (§8), are what
keep a member from speaking as another.
7. The member at *L* held *L* in every epoch from *E* to the receiver's latest: otherwise
`sender_removed`. Once a receiver has applied the commit that removed a member, it takes
nothing more sealed under that member's earlier KIDs, so a removed member cannot go on
publishing under the last epoch it held.
8. The receiver has not opened an object of this Full Track Name, Group ID and Object ID
before: `replayed`. MOQT forbids two objects at one location (secure objects §6.1); one
presented again is a relay's replay, and a receiver keeps the locations it opened while it
keeps the epoch's keys.
9. `AEAD.Decrypt` (§4.10): `bad_tag`.
10. The plaintext reads (§4.4): `plaintext_malformed`.
11. Where the plaintext carries `WAI_INNER_DIGEST`, it is BLAKE3 of the original payload:
`inner_digest_mismatch`.
The original payload is then dispatched (§10). Steps 4 to 7 need only the receiver's epochs,
their rosters and the track's name, so a receiver refuses them before deriving any key, and
records a location as opened (step 8) only once the object has opened.
## 7. Multi-sender rooms
Every member may publish, each on its own tracks, at once. Nothing in a sealed object needs
coordinating between them: each sender's KID carries its leaf index (§5.7), so its key is
its own and its nonces never meet another's; a receiver selects the key from the KID alone.
Each publisher numbers its own tracks' groups and objects, as MOQT has it, and each signs its
own tracks' receipts with its own room key (§8.1).
When a commit opens epoch *E* + 1, each member moves to it when it applies the commit, at its
own pace: a publisher's objects of *E* and *E* + 1 can interleave at a receiver, and each
opens under its own epoch (§5.8). Two commits for one epoch cannot both be applied: the
rule of §5.5 names one committer, and a member discards any other's commit.
A slot (live-rooms §7.1) has one live publisher and, with redundancy, a standby; both are
members, and their objects on the slot's tracks carry their own KIDs. A takeover or failover
changes who publishes, not who is a member, and needs no commit.
## 8. Receipts and metering over ciphertext
### 8.1 What receipts bind
A sealed object's receipt is the receipt of [jwp-receipts](jwp-receipts.md), unchanged,
over the object's payload as it travels: its content hash is BLAKE3 over the sealed bytes,
ciphertext and tag. The publisher signs its group receipts with its room key, and they ride
the track's receipts track (live-rooms §6.1, moq-streaming-format §4.1), in the clear. A
relay with a receipt door ([jwp-receipts](jwp-receipts.md) §4.1) checks each sealed object
against its receipt exactly as it checks a plaintext one, and holds each publisher's tracks
to its own key (the namespace names it).
### 8.2 What a member checks besides
A member that opens an object knows the plaintext came from the sealed bytes the publisher
signed for, under a key of the publisher's leaf: the AEAD authenticates the plaintext
under the KID's key, and the receipt binds those exact bytes to the publisher's room key.
Where the object carries `WAI_INNER_DIGEST` (§4.4), the member also checks the plaintext
against the digest the publisher sealed into it, the content hash a receipt of the plaintext
would carry; records made over plaintext (a decoded output's digest, a reconstruction
binding) attach to that hash.
### 8.3 What a relay can prove, and what it cannot
A relay, and anyone holding the receipts, can prove: which sealed bytes each publisher signed
for, at which track, group and object; that the relay forwarded exactly those bytes, since a
changed byte fails the receipt; how many objects and bytes of each track it carried, received
and dropped, and the energy each receipt states, which the per-hop records of
[live-metering](live-metering.md) count from what it carried (their byte counts are of sealed
bytes, the AEAD's tag and the Key ID among them); the track names, priorities and Immutable
Properties, which the AEAD authenticates but does not hide; and the room's membership over
time, which the room's events and the group's public handshake messages state.
A relay cannot prove, or learn: what any object says; that a sealed payload opens at all, or
that its plaintext is a frame of the codec its catalog names; that a member received or
opened what it was sent; or anything a measurement over plaintext states (staged-measurement
counts no sealed object, code `encrypted`; a decoded output's tier and digest are the
member's). A moderator who reviews media is a member and reviews what it opens; a relay can
withhold an object a moderator's decision names by its receipt leaf, and cannot review one by
its content ([live-moderation](live-moderation.md)).
## 9. Recording and clips: refused
**A room with a confidentiality record is never recorded.** Its descriptor's recording
`mode` is 0 (live-rooms §3.7), and §3.3 step 5 refuses a record for a room whose mode is not
(`recording_refused`): no principal holds the record power to any effect, no device commits a
take (capture-binding needs a `record_start`), no recording manifest is signed, no clip is
cut, and a relay keeps nothing of the room's tracks beyond delivery: no DVR window either,
which a relay that keeps one stops at the room's first sealed object
([live-recording](live-recording.md) §8.2).
The alternative was a recording of the relay's ciphertext with each epoch's keys escrowed to
the room authority. It is not taken, for these reasons:
- **Escrow makes a reader the members cannot see.** The MLS roster is where a member sees
who can open what it sends. An authority holding every epoch's keys from outside the tree
would open everything while no roster named it, and the room's claim to be end-to-end
encrypted would be false for every recorded room.
- **Escrowed keys outlive their epoch.** Forward secrecy, and a removed member being cut off,
rest on each epoch's keys being deleted (§5.8). Keys kept for a recording are kept for its
retention, and one later compromise of the escrow opens the whole session.
- **It is not what a recording is here.** A recording of [live-recording](live-recording.md)
is a package of the participants' takes, masters their devices upload to the room's store
in the clear, under a manifest the authority signs. A store of ciphertext and keys would be
a second format that live-recording's verifier does not read, and the takes would still
reach the store as plaintext. A room whose session must be recorded is not end-to-end
encrypted, and says so by having no confidentiality record.
- **It needs no new field.** Mode 0 already means "never recorded", so the rule is a check of
what the descriptor states.
A member keeps what it receives as any participant of an end-to-end encrypted system can;
that is the member's own act, outside the room's recording policy, and no record of this
family attests it.
## 10. Decrypt-then-dispatch and relay transparency
This refines SPEC §6.4. A receiver of a room's media track opens each object by §6, then
treats the original payload as it would the payload of a plaintext room: a WAI envelope is
read and dispatched by SPEC §4, a media frame decoded by the track's codec. It **MUST NOT**
feed a payload that has not opened to a decoder. The envelope inside a sealed object carries
no `security` member: the object around it was sealed, and the envelope is plaintext once
opened.
A relay forwards a sealed object as it forwards any object: it reads the track name, the
Group ID, the Object ID, the priority and the properties, applies its delivery timeouts,
caches and serves FETCH, checks receipts at a door, and meters; it never needs a key, and
the Key ID property is one more property it forwards unchanged (MOQT §8.4). Nothing in a
relay's forwarding changes for a sealed object, and the reference relay
(`joule-moq-relay`) forwards them with no change of its own.
## 11. Reading the documents together
The documents of this revision were written against different revisions of one another.
Where they disagree, this extension takes the reading below.
### 11.1 The Key ID's code point
Secure objects (§4.1, §7.1) gives the Key ID property the type `0x2`, written against MOQT
-18. MOQT -22 §16.8 gives `0x02` to OBJECT_DELIVERY_TIMEOUT, a Track and Object Property a
relay acts on, and its table of provisional registrations lists secure objects' `0x0A` and
`0x32` but no Key ID. This extension carries the Key ID at `0x3A4A` (§4.3), in the
application range of MOQT §8.4, until the two agree; every other rule of the property is
secure objects'.
### 11.2 The counter-mode suites' tag
Secure objects §7.2 says its suites' algorithms are those of RFC 9605. RFC 9605 §4.5.1
computes the counter-mode suites' tag as HMAC over the lengths of the AAD, the ciphertext and
the tag, then the nonce, the AAD and the ciphertext. The ciphertexts of secure objects'
Appendix B for suites `0x0001` and `0x0002` carry instead the HMAC of the AAD, the nonce and
the ciphertext, with no lengths; their counter-mode encryption, keys, salts, nonces and AADs
are RFC 9605's. This extension implements RFC 9605's construction and keeps the
counter-mode suites out of rooms until the text and the vectors agree; its corpus checks
both readings against those two vectors, so the difference is stated exactly.
### 11.3 Property forms
Secure objects' Appendix B serializes properties as a 16-bit type, a variable-length length
and the value, whatever the type's parity; MOQT -22 §8.3 serializes them with delta-coded
types, an even type's value a single integer. On a -22 object the Immutable Properties are
-22's (§4.3), which is what MOQT §10.7 keeps unchanged end to end; inside the plaintext, which
no relay reads, the Encrypted Properties List keeps Appendix B's form (§4.4). Padding is
secure objects §4.3's form, a 16-bit type and a 16-bit length; §4.3.1's processing steps
speak of a 1-byte type and a 4-byte length, and are not followed. Appendix B's own vectors are
checked with the property bytes they state, which the AEAD takes as they are.
### 11.4 The keying draft's track key
The keying draft §7.1 derives a track key and a track nonce from an "application secret"
and the Track Name alone. RFC 9420 gives an application one interface to its key schedule,
the exporter (§8.5), so the application secret here is an exported value (§5.7); a room's
Track Names are layer numbers that every publisher shares (live-rooms §6.1), and secure
objects §3.2 scopes a Key ID to a namespace, so the derivation is over the namespace; and
secure objects derives its own key and salt from a base key (§4.5), so the derived value is
that base key, and no nonce is derived beside it. The KID is the keying draft's §7.2 as
written.
### 11.5 Variable-length integers
Secure objects writes its lengths and its Key ID as the varints of the MOQT revision it was
written against. This extension writes them as MOQT -22's (§8.1), shortest, everywhere: in
the Serialized Full Track Name, the payload's length and the list's. Every value in secure
objects' Appendix B has the same bytes in both encodings, so those vectors hold unchanged.
## 12. Security and privacy considerations
- **Who can open.** The members of an epoch, and no one else: not a relay, not the clock,
not the room authority. A member is a principal present under a keyed grant, admitted by
the clock's proposal and a commit every member checked. The authority decides who is
present, so an authority that wants to read can admit a principal of its own, and that
principal is a member, named in the roster every member holds; the authority cannot read
without being seen to.
- **Within the group.** MLS gives every member every sender's keys (RFC 9605 §7.2:
SFrame has no per-sender authentication). A member's objects are bound to it by the
receiver's owner check (§6 step 6), by the relay's door holding each publisher's
namespace to its key, and by its signed receipts (§8.1). A grant that may publish but not
subscribe is a member, and can open what others send: subscription is a relay's
authorisation, not a line inside a group.
- **Removal.** A removed member keeps the keys of the epochs it held. It opens nothing of a
later epoch (§6 step 4), and once its removal is applied a receiver takes nothing more
sealed under its earlier KIDs (§6 step 7). Objects of earlier epochs that it received are
its own to keep.
- **Late joiners.** A member added at epoch *E* derives nothing before *E*: a Welcome carries
the epoch's secrets, not earlier ones, and objects a relay serves by FETCH from before the
join are refused `epoch_not_joined`.
- **Replay and reordering.** The AEAD binds each payload to its track, group, object,
priority and properties (secure objects §6.2); a receiver refuses a location presented
twice (§6 step 8). A relay can still drop, delay and reorder objects (secure objects §6.1);
the receipts chain, End of Group and End of Track statuses and the gap properties are how
drops are seen (secure objects §6.4, §6.5).
- **Forgery against fan-out.** A relay could try forgeries on many subscribers at once
(secure objects §6.1.1). The suites of rooms have 128-bit tags and the base keys are 256
bits, and a receiver discards a failed object indistinguishably from one it processed.
- **Metadata.** Track names, sizes, timing, priorities, the catalog and the membership are
visible by design. Padding (§4.4) hides an object's exact size from a relay where a
deployment needs it.
- **Key reuse.** The principal's room key signs its events, its proofs of possession and its
MLS messages, each under its own domain or label (§5.2). A compromise of that key lets an
attacker speak as the principal in all three until the authority revokes the grant (and,
with it, the member's leaf at the next commit).
- **The device.** The plaintext, and every key a member derives, are in the application's
memory; Lane B is not a protected-path robustness level (SPEC §6.5).
## 13. Reasons
| reason | raised at |
|---|---|
| `truncated`, `malformed`, `unknown_version` | §3.3 step 1 |
| `wrong_room` | §3.3 step 2 |
| `suite_unsupported` | §3.3 step 3 |
| `mls_suite_unsupported` | §3.3 step 4 |
| `recording_refused` | §3.3 step 5, §9 |
| `bad_key`, `bad_signature` | §3.3 step 6 |
| `group_mismatch` | §5.3 |
| `credential_mismatch` | §5.2 |
| `proposal_not_clock` | §5.5 |
| `proposal_mismatch` | §5.4, §5.5 |
| `commit_not_committer` | §5.5 |
| `commit_mismatch` | §5.5 |
| `properties_malformed` | §6 step 1 |
| `key_id_missing` | §6 step 1 |
| `object_id_too_large` | §4.6, §6 step 2 |
| `epoch_not_joined` | §6 step 4 |
| `removed_before_epoch` | §6 step 4 |
| `key_id_unknown` | §6 step 5 |
| `sender_not_owner` | §6 step 6 |
| `sender_removed` | §6 step 7 |
| `replayed` | §6 step 8 |
| `bad_tag` | §4.10, §6 step 9 |
| `plaintext_malformed` | §4.4, §6 step 10 |
| `inner_digest_mismatch` | §6 step 11 |
## 14. Conformance
[`live-conformance/e2ee/`](../live-conformance/e2ee/) holds the corpus. `vectors.json` is
written from fixed seeds by `joule_moq_e2ee_vectors gen`; beside it, `rfc9605.json` and
`secure-objects-01.json` hold the test vectors of RFC 9605 Appendix C and of secure objects'
Appendix B as those documents print them. The corpus has:
- **Published vectors.** Every RFC 9605 vector (header encoding, the counter-mode AEAD, and
SFrame encryption in all five suites), and every secure-objects vector (the Full Track
Name, key derivation, nonce and AAD, and encryption: exact in suites `0x0004` and `0x0005`,
and in `0x0001` and `0x0002` the difference of §11.2 stated).
- **Records**: an accepted confidentiality record, and one refused for each reason of §3.3.
- **Key schedule**: per-epoch application secrets and, for each sender and namespace, the
track base key, the KID, and each track's `moq_key` and `moq_salt`.
- **Objects**: sealed objects with every input and key stated (the Immutable Properties as
carried, the plaintext, the AAD, the nonce, the sealed payload and its content hash), with
and without an inner digest and padding, in both suites.
- **A room**: three members joining in turn, each publishing, the third removed (its grant
revoked), a fourth joining late at the third's old leaf, and the first leaving so that the
second commits, with the lifecycle, the clock's proposals, each commit and its committer,
each epoch's roster and application secret, and every sealed object delivered to its
receivers with the outcome §6 gives it: opened, with its payload's BLAKE3 hash, or refused
with its reason. Every epoch's keys open at least one object.
- **Refused rooms**: one transcript for each reason a proposal, a commit or a group's
creation raises, cut at the step it refuses.
- **Negatives**: one control for each reason of §13 an object, a transcript or a record
raises.
A conforming implementation gives every vector its stated outcome.
`joule_moq_e2ee_vectors verify` and the crate's tests check that the file regenerates byte for
byte, that the reference gives every vector its outcome, and that §13 lists exactly the
reference's codes. `live-conformance/e2ee_verify.py` is the second implementation, written
from this text and the documents it reads, standard library only: AES (FIPS 197) and GCM (SP
800-38D) written from their specifications for vectors only, HKDF and HMAC from Python's
standard library, BLAKE3 from `claim-conformance/_blake3.py`, and Ed25519 under WAI's strict
rule from `claim-conformance/_ed25519.py`. It requires every mutated copy of the corpus to
fail.
**The verifier's boundary.** It does not implement MLS. It takes each epoch's application
secret (§5.7) from the corpus as given and checks everything after it: the roster of each
epoch from the commits (§5.6), the committer of each commit and what it holds (§5.5), the
proposals against the room's lifecycle (§5.4), each track base key and KID, each key, salt,
nonce and AAD, every seal and open, and every refusal. That the application secrets are what
an MLS group of the room exports is checked by the reference's tests, which run the group.
## 15. A member in a browser
A browser page is a member as any other: the reference's member (`joule-moq-mls`), the
sealed objects of `joule-moq` and the codec, compiled for WebAssembly as one module that the
page loads for an end-to-end encrypted room alone (`joule-moq-web`, feature `e2ee`). Its MLS
state and every key it derives are in the page's memory and nowhere else: nothing of them is
written to the browser's storage or sent anywhere, and a page that is closed is a principal
whose connection is lost (§5.4).
### 15.1 Sealing frames
The page encodes its camera and microphone with W3C WebCodecs. Each encoded chunk, packed as
the room's original payload (§4.4), is sealed under the page's latest epoch (§4.9), and its
receipt is signed over the sealed bytes (§8.1) before it is published; until the page holds an
epoch, nothing is published. Each object the page receives is verified against its receipt,
then opened by §6, and its plaintext reaches a decoder only once it opened (§10). An object
whose KID names an epoch the page has not reached, or that arrives before the page's Welcome,
is held for a bounded time and opened when the page reaches that epoch (§5.8); an object that
does not open is counted with its reason and dropped. The page deletes an epoch's keys a fixed
time after the next epoch opens (§5.8).
### 15.2 A room service as the carrier
A room service that passes the group's messages directly (§5.1) keeps one log per room, in
which the clock's lifecycle records, as the clock signed them, its proposals, and every
GroupInfo, commit and Welcome a member posts stand in one order. The service's clock checks
each post as a member checks it (§5.4, §5.5), and a post it refuses is not carried. A member
reached through such a log:
- reads it from its first entry, and checks each lifecycle record as the clock's: its
signature, its room, and its place in the clock's chain;
- before its Welcome, follows the group from its GroupInfo and its handshake messages as the
clock does (§5.3), so that it knows whether a group exists and who holds which leaf, and
creates the group only when §5.4 owes it one;
- leaves its KeyPackage with the service before it publishes, so the clock's Add for it is
ready when the clock records its `join`;
- **MUST** apply a GroupInfo or a commit it made only when the log returns it in the log's
order, and **MUST** drop it when the service refuses it or the log carries another commit
for the same epoch first.
The last rule is what keeps one epoch one epoch. §5.5 names the committer from the commit's own
proposals, so two members whose views of the pending proposals differ can each be the
committer of the commit it makes: a member that has read the clock's Add and not yet a Remove
of itself, and the next member, which has read both. The log orders the two, the clock carries
the first and refuses the second, and every member applies the first.
The log adds no trust. Every message on it is signed by its sender and checked by every
member as §5 requires, and every lifecycle record by the clock's key; the service can withhold
or delay a message, as a relay can (§12), and cannot forge or change one.
### 15.3 What a browser member does not do in this revision
- **Watch without being a member.** A grant that may subscribe is keyed (§2), so its principal
is a member once present. The reference room service records presence from a publisher's
tracks, so a viewer it grants is never a member and opens nothing; its page offers no
watching in such a room.
- **Ingest by WHIP or WebRTC** ([live-ingest](live-ingest.md)) into such a room. A WebRTC
sender's frames would be sealed through the W3C encoded-transform interface, which is not
built.
- **Seek back.** The relay keeps nothing of such a room (§9).
The reference checks this section in `joule-moq-mls`'s tests (members behind a log that orders
their messages: a create and a commit applied only when the log returns them, two commits for
one epoch, a commit overtaken by another, a create the log refuses), `joule-moq-room`'s tests
(members keying through the service on a relay), `joule-moq-web`'s tests (its WebAssembly
surface on the host), and the `e2ee` scenario of `joule-moq-web/e2e/` in a browser.