open-standards/wai/extensions/confidentiality.md
Transaction Science 9d273c286a
Some checks failed
joule-moq-room room service / joule-moq-room · does this change touch it (push) Successful in 28s
joule-moq-remote host agent / joule-moq-remote · does this change touch it (push) Successful in 32s
WAI operation catalogue / does this change touch it (push) Successful in 25s
wai-fleet service / does this change touch it (push) Successful in 27s
wai-app and Create / wai-app · does this change touch it (push) Successful in 29s
joule-moq-web browser client / joule-moq-web · does this change touch it (push) Successful in 30s
joule-code conformance gate / joule-moq-relay, its own energy on a counter (macos) (push) Successful in 28s
joule-moq-remote host agent / joule-moq-remote, every target (macos) (push) Successful in 1m51s
wai-fleet service / service · macos (push) Successful in 1m9s
wai-app and Create / wai-app · Create, browser end-to-end (macos) (push) Successful in 5m47s
joule-moq-web browser client / joule-moq-web · browser end-to-end (macos) (push) Successful in 12m44s
WAI breaking changes and the conformance runner / WAI breaking · does this change touch WAI (push) Successful in 8s
wai-app and Create / wai-app · tests, clippy, the line language's verifier (linux) (push) Successful in 48s
wai-fleet service / service · linux (push) Successful in 2m39s
joule-code conformance gate / Build + round-trip C1-C4 vectors (push) Successful in 6m0s
WAI breaking changes and the conformance runner / WAI breaking · wire, record and API against main · linux (push) Successful in 52s
WAI breaking changes and the conformance runner / WAI breaking and conform · their tests, and the independent verifiers through wai conform · linux (push) Successful in 3m31s
WAI operation catalogue / the catalogue and every door · linux (push) Successful in 9m13s
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (push) Has been cancelled
joule-moq-remote host agent / joule-moq-remote, every target (linux-arm64) (push) Has been cancelled
joule-moq-room room service / joule-moq-room, every target (push) Has been cancelled
joule-code conformance gate / joule-moq-relay, every target (push) Has been cancelled
joule-code conformance gate / joule-moq-ingest, every target (push) Has been cancelled
One rule for versions: 1.0 is the WAI1 envelope's wire version, and the standard is a Draft
SPEC states the rule after its status line and describes the manifest's wai member as the
envelope's wire version; its title, the four extension headers, the README and two code
comments no longer call the standard itself v1.0. Text that names an envelope by its wire
version is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 18:28:23 -05:00

50 KiB
Raw Permalink Blame History

WAI Extension: Confidentiality (end-to-end encrypted rooms)

Status: Draft. Sub-spec of WAI, 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 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/, 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) 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), and, in a room with a shared world, of every world track (live-worlds §8.1), each sealed and opened as a media track's object is. 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 §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 §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 §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 §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).

A conversation (live-messaging §7.2), whose members stay members while offline, replaces the rule: any member the commit does not remove commits, and the room service's log orders the commits as §15.2 has it. Every other check of this section holds there.

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, 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 §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 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).

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 §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 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/ 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) 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.