Skip to main content

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

Mirrored from the canonical text at commit 260cbd09 ().

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:

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:

Out of scope:

2. Terms

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.

fieldsizemeaning
magic4WRE1 (ASCII)
version11 in this revision
room32the room id (live-rooms §3.2)
suite2u16: the secure-objects ciphersuite (secure objects §7.2) every sealed object of the room uses
mls_suite2u16: the MLS ciphersuite (RFC 9420 §17.1) of the room’s group
sig64§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

protectionfields
hop by hop only (TLS of each MOQT session)Track Alias; the object’s mutable properties, those outside Immutable Properties
end to end, authenticatedthe Full Track Name; Group ID; Object ID; Publisher Priority; the Immutable Properties, the Key ID among them
end to end, encrypted and authenticatedthe 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:

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

suitenameAEADhashNkNnNt
0x0004AES_128_GCM_SHA256_128AES-128-GCMSHA-256161216
0x0005AES_256_GCM_SHA512_128AES-256-GCMSHA-512321216

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:

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:

A commit that fails is discarded, and its epoch never exists for that member.

Why the members, and why this rule:

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:

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

13. Reasons

reasonraised 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:

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:

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

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.