Skip to main content

WAI Extension: Live Messaging (conversations and calls on live rooms)

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

Status: Draft. Registers two records. wai.live.message: a conversation is a room of live-rooms whose authority signs a conversation statement, WMC1, and which is end-to-end encrypted (confidentiality). Each member publishes its messages on a messages track of its own: each message one object, sealed under the room’s MLS group and signed into the member’s receipt chain as a room’s media track is. A message is a signed record, WMG1, of one of five kinds: text, a reply in a thread, a reaction, an edit and a retraction, the last four naming the message they concern by its link hash. Membership is the room’s: the clock records who the authority admits and removes, its proposals order every change, the members commit them, and the clock signs an epoch statement, WME1, naming each epoch’s roster. Delivery and reading are signed acknowledgements, WMA1. A history store keeps the conversation’s log, ciphertext, receipts and signed records only, so that a member that was offline catches up, and serves each principal only what it is owed. wai.live.call: a call is a room with a call descriptor, WCT1, signed by its authority; ringing is an invitation, WCI1, signed by the caller and held in the callee’s inbox at the room service; accepting, declining, cancelling and a missed call are signed answers, WCA1; a call between two principals and a call among many are the same mechanism, and its media is the room’s own audio and video tracks. Every record verifies offline. No new transport and no new receipt. The reference implementation is the live_messaging module of wai-rs (feature live_messaging); the corpus is live-conformance/messaging/, and its second implementation is live-conformance/live_messaging_verify.py. Keywords MUST, MUST NOT, SHOULD, MAY are RFC 2119/8174.

Texts read for this revision: the live family this extension builds on, as merged (live-rooms, live-events, confidentiality, live-recording, live-moderation), jwp-receipts and moq-streaming-format; RFC 9420 (MLS) and RFC 9750 (the MLS architecture); draft-ietf-moq-transport-22 (MOQT), an Internet-Draft, a section number of which in this text is that revision’s; RFC 8032, RFC 3629 and FIPS 180-4.

1. Scope

In scope: the conversation statement, and how a conversation’s lifecycle records membership (§3); the messages track, its objects, their sealing and their receipts (§4); the message record and its kinds (§5); threads, reactions, edits and retractions (§6); membership, commits and the epoch statement (§7); acknowledgements (§8); the history store: what it holds, what it takes, what each principal is owed (§9); calls: the call descriptor, the invitation, the answers, the inbox and the media (§10); verification of a conversation (§11).

Out of scope: a transport; electronic mail, calendars and address books; interworking with telephone networks; push notification services; how a principal obtains a grant; and how a member’s device keeps its group state between sessions (§7.5 states what it needs).

Relationship to the core spec: wai.live.message and wai.live.call are records (SPEC §5): signed records a WAI tool emits and verifies. They name no payload format and are not dispatchable.

Relationship to confidentiality: a conversation is an end-to-end encrypted room, and everything that extension fixes holds in it, but for two rules: a conversation’s lifecycle records membership rather than presence (§3.4), and any member may commit, the log ordering the commits (§7.2). This extension adds the messages track to the tracks confidentiality §1 seals. A message is not a live-events record: the events tracks of a conversation stay in the clear as confidentiality §1 leaves them, and carry the clock’s lifecycle alone.

2. Terms

Integers are little-endian, as in the records of live-rooms. A long field is a u16 length and that many bytes. Every signature is Ed25519 (RFC 8032), 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.

Every record of this extension is read the same way: a reader reads exactly the bytes it is given as one record, in the order of its layout, and refuses at the first of these that holds: fewer than 4 bytes, truncated; a magic other than the record’s, malformed; no byte after the magic, truncated; a version other than 1, unknown_version; the bytes end before a field the layout requires, truncated; bytes follow sig, malformed. The digest of a record is SHA-256 (FIPS 180-4) over its bytes from magic up to sig, and its signature is over its domain followed by those same bytes.

3. The conversation statement

3.1 Layout

fieldsizemeaning
magic4WMC1 (ASCII)
version11 in this revision
room32the room id (live-rooms §3.2)
max_text2u16: the most bytes of a text body (§5.3), from 1 to 16 384
max_reaction1u8: the most bytes of a reaction body (§5.3), from 1 to 64
retention_days2u16: the most days the history store keeps a message (§9.4); 0 states no bound
sig64§3.2

3.2 Signature

Ed25519 by the room descriptor’s authority over the 22 bytes wai:live-conversation (ASCII) and 0x01, followed by the bytes the digest covers.

3.3 Reading and rules

A verifier is given the room’s descriptor, its confidentiality record (or none) and the statement. It reads the descriptor (live-rooms §3.4: its reason, at descriptor), then, at record: none given is not_encrypted; the record is checked as confidentiality §3.3 checks it, with its reason. Then, at statement, in this order:

  1. The statement reads (§2): truncated, malformed or unknown_version.
  2. max_text is from 1 to 16 384 and max_reaction from 1 to 64: otherwise conversation_invalid.
  3. room is the room id: wrong_room.
  4. The signature verifies under the descriptor’s authority (§3.2): bad_key or bad_signature.

A room has at most one conversation statement: a conversation that needs other bounds is another room. A conversation is never recorded: its descriptor’s recording mode is 0, which confidentiality §3.3 step 5 already requires.

3.4 Membership is the room’s lifecycle

In a conversation the room’s clock records membership, not presence:

The group follows the lifecycle as confidentiality §5.4 has it: the clock proposes the Add of each principal it records joining and the Remove of each it records leaving. A grant of a conversation authorises its principal’s requests (to the history store, §9; to a relay, live-rooms §10): it is not what makes it a member.

4. The messages track

4.1 Naming

A member P of conversation R publishes one messages track. With R and P as 64 lowercase hexadecimal digits, its Track Namespace is [R, P, "messages"] and its Track Name is 0; its receipts track (moq-streaming-format §4.1) is in the same namespace, named 0_receipts. The names keep live-rooms §6.1’s rules. A messages track is not a media track: it has no layer, profile or slot, and it is never composed. A grant of a conversation maps onto one scope beyond live-rooms §10.2’s, after its step 4: [[2, 6], [R, P, "messages", nil]] (PUBLISH_NAMESPACE and PUBLISH of its messages track and that track’s receipts track); a relay that honours a room’s grants refuses a PUBLISH under [R, P, "messages"] from any session but one whose SETUP proved a grant for P in R.

A room service MAY carry a conversation’s messages tracks itself, as confidentiality §15.2 lets it carry the group’s messages: an object it carries keeps its Full Track Name, its Group ID, its Object ID and its Publisher Priority, which its sealing binds (confidentiality §4.7), and its receipts-track object, so nothing below changes with the carrier.

4.2 Objects

Each group of a messages track holds exactly one object, Object ID 0, of Publisher Priority 64: one message. The groups are numbered from 0 with no gap: the author’s nth message is group n − 1, whatever epoch it is sealed in.

Every object is sealed as confidentiality §4 seals the object of a media track, under the author’s keys of its current epoch (confidentiality §5.7), and opened as confidentiality §6 opens one. Its Immutable Properties hold the Key ID property alone; it carries no envelope flag and no other property. The original payload (confidentiality §4.4) is the message record (§5). Its plaintext MAY end with an Encrypted Properties List holding padding, which hides a message’s exact length from the store and every relay, and MAY carry WAI_INNER_DIGEST; a receiver reads the list as confidentiality §4.4 reads it. A message is sealed only in the latest epoch the author holds; one whose epoch has since ended is sealed again in the new one before it is posted (§9.2).

4.3 Receipts

A messages track carries receipts exactly as a room’s media track does (moq-streaming-format §4.1, §5; confidentiality §8.1): each group’s object is signed by the author’s key into a group receipt of one object (jwp-receipts §2.2, §2.3), its content hash taken over the sealed payload, ciphertext and tag, as jwp-receipts §2.1 takes it over an envelope; the group’s receipts, in the binary form of jwp-receipts §2.7.3, are the receipts track’s object of the same group. Its receipts’ track_alias (jwp-receipts §2.4.1) is 5, the messages track’s receipt alias.

The group receipt is in the labelled form (jwp-receipts §2.3, moq-jwp:group-sign\x02), whose signing payload covers the parent link: a message’s place in its author’s chain is then signed, and a reference by link hash (§6) names one message and its whole chain before it. A group in the legacy form is refused receipt_unlabelled. Its figure is the unmetered marker: joules_total 0, class tag 0, coverage Whole; its object receipt’s leaf is labelled unmetered (leaf_version 2, tag 0), joules_micro 0. A reader shows the marker as unmetered, never as a figure of zero joules. Each group receipt’s parent is the link hash of the group receipt before it on the track, and group 0’s is none.

4.4 What a relay and a store see

A relay, and the history store, see each message’s track, its Group ID, its Key ID (and so the epoch and the author’s leaf), its size, its receipt and its link hash; they open nothing. The plaintext, and with it the kind, the body and every reference, is the members’ alone.

5. The message record

5.1 Layout

fieldsizemeaning
magic4WMG1 (ASCII)
version11 in this revision
room32the room id
author32the author’s Ed25519 key: the messages track’s owner
group8u64: the Group ID of the object the record is sealed in
t8u64: the room clock (live-events §2), in microseconds, when the author sent it
kind1§5.3
ref32the link hash of the message it concerns (§6); 32 zero bytes for a text
bodylong§5.3
sig64§5.2

room, author and group repeat the object’s position, so that a record opened from one place cannot be shown as another’s (message_mismatch, §11.3). t is the author’s claim, as live-events §11 has every t, and it does not decrease along the author’s track (time_reversed).

5.2 Signature

Ed25519 by author over the 17 bytes wai:live-message (ASCII) and 0x01, followed by the bytes the digest covers. The record is signed inside the seal, so a member that opened it can show it to anyone, who verifies it with the author’s key alone.

5.3 Kinds and bodies

kindnamerefbody
1text32 zero bytesUTF-8 (RFC 3629), 1 to max_text bytes
2replythe thread’s root (§6.1)as text
3reactionthe message reacted to (§6.2)UTF-8, 1 to max_reaction bytes
4editthe message edited (§6.3)as text: the message’s new text
5retractionthe message retracted (§6.4)empty

A kind outside the table is unknown_kind. A body that is not UTF-8 is bad_utf8; one longer than its bound is text_too_long; an empty body where the table asks for one, and a body beside a retraction, are body_invalid. A text with a ref that is not zero, and any other kind with a zero ref, are ref_invalid.

6. References

A reply, a reaction, an edit and a retraction each name an earlier message of the conversation by its link hash (§2), its target. The target is a message accepted before it in the verification order (§11.3): otherwise ref_unknown, which includes a message the verifier refused, and one later in the log. A target that a retraction accepted before has retracted is ref_retracted, for every kind.

6.1 Threads

A reply’s target is the root of its thread: a text. A target of another kind is thread_not_root, so a thread is one level deep, and every reply of a thread names the same root. A thread is its root and its replies, in the verification order.

6.2 Reactions

A reaction’s target is a text or a reply: otherwise target_kind. Its body is the reaction, a short string the sink shows as it is. A member’s second reaction with the same body to one target, while its first is not retracted, is reaction_repeated. Removing a reaction is retracting it (§6.4).

6.3 Edits

An edit’s target is a text or a reply (target_kind) whose author is the edit’s (edit_not_author): a member edits only what it wrote. The target’s current text is the body of the last edit accepted for it, or its own body when there is none.

A verifier keeps the target and every edit of it, each verified with its own receipt and signature, and states the target’s current text and its number of edits. The original stays in the conversation, verified: an edit changes what a sink shows, and nothing a member holds. A sink MUST show a message with its current text, and MUST show that it was edited.

6.4 Retractions

A retraction’s target is a text, a reply or a reaction (target_kind) whose author is the retraction’s (retract_not_author).

A retraction states; it removes nothing from verification. The verifier keeps the target in the conversation, verified as it was, with its receipt, its link hash and its place in its author’s chain, and states it retracted, naming the retraction by its link hash. A retracted message has no current text: a sink MUST NOT show its body, its edits or (for a reaction) the reaction, and MAY show that a message was retracted. Its link hash still chains the author’s track, so the messages after it verify, and a reference to it is ref_retracted (§6). The ciphertext stays in the history store until its retention ends (§9.4): a retraction is the author’s statement, and a member that opened the message before keeps what it opened.

7. Membership and epochs

7.1 The group

A conversation’s group is confidentiality §5’s: one MLS group (RFC 9420) at a time, whose group ID is the room id and a generation, whose credentials are the principals’ room keys (confidentiality §5.2), whose one external sender is the room’s clock (§5.3 there), which proposes every Add and Remove the lifecycle calls for (§5.4 there) and holds no epoch’s secret; each epoch’s per-sender keys are derived by the exporter (§5.7 there). The room service carries the group’s messages in one order, as confidentiality §15.2 has it, in the conversation’s log (§9.1).

7.2 Who commits

In a conversation, any member that a commit does not remove may commit the clock’s pending proposals, and the log orders the commits: the first commit for an epoch that the clock’s checks pass is carried, every other for that epoch is refused, and a member applies a commit only when the log returns it (confidentiality §15.2). This replaces confidentiality §5.5’s rule of the lowest leaf; every other check of §5.5 holds: a commit holds by reference the clock’s first k proposals for the epoch, each once (proposal_not_clock, commit_mismatch), and carries an UpdatePath. A commit by a sender that is not a member of the epoch, or that the commit removes, is commit_not_committer.

Why: confidentiality §5.5’s rule heals by removing a committer that stops responding, through a leave for its lost connection. A conversation records no such leave (§3.4), so a lowest member that is offline would hold every change back until it returned. Any member committing, in the log’s order, keeps every change moving while one member is online, and the log makes two commits for one epoch impossible to apply both.

7.3 The epoch statement

Each time the log carries a GroupInfo that creates the group, or a commit, the clock signs an epoch statement for the epoch it opens and the log carries it right after. Members check each against the epoch their group reached; an offline verifier, which runs no MLS, takes each epoch’s roster from it.

fieldsizemeaning
magic4WME1 (ASCII)
version11 in this revision
room32the room id
generation4u32: the group’s generation (confidentiality §5.1)
epoch8u64: the epoch it opens, below 2^40
opened_by32SHA-256 of the framed GroupInfo (epoch 0) or the framed commit that opened it, as the log carries them (confidentiality §5.1)
prev32the digest of the conversation’s previous epoch statement; 32 zero bytes for the first
roster4 + 36 × na u32 count n, then each member: its leaf index (u32, below 2^24) and its key (32), in ascending order of leaf index
sig64Ed25519 by the descriptor’s clock over the 15 bytes wai:live-epoch (ASCII) and 0x01, followed by the bytes the digest covers

A verifier takes the conversation’s epoch statements in the log’s order, and refuses at the first of these that fails, at the statement’s position:

  1. It reads (§2): truncated, malformed or unknown_version.
  2. Its room is the room id: wrong_room.
  3. Its signature verifies under the descriptor’s clock: bad_key or bad_signature.
  4. epoch is below 2^40; the roster has at least one member, its leaf indexes strictly ascending and each below 2^24, and no key twice: otherwise epoch_invalid.
  5. prev is the digest of the epoch statement before it in the log, or 32 zero bytes for the first: epoch_unlinked.
  6. The first is epoch 0 of any generation; each later is the next epoch of the same generation, or epoch 0 of a higher one: epoch_out_of_order.

A member MUST refuse an epoch statement whose generation, epoch or roster is not the one its group reached when it applied the GroupInfo or commit the statement names, and then stop reading the log: the log is not the conversation’s. The clock follows the group from its public handshake messages and checks every commit as a member does (confidentiality §5.3, §15.2), so the roster it states is the one every member computes (confidentiality §5.6).

7.4 Removal

A principal is removed at the first epoch statement after one that lists it, whose roster does not list it. The epoch it opens is the first whose keys exclude it (confidentiality §12), and from its position on the history store takes nothing of the principal’s (§9.2) and serves it nothing more (§9.3).

7.5 A member that was offline

A member keeps its group state while its device is offline, and when it returns reads the log from the position it stopped at: every handshake message in order, each commit applied as it comes, each epoch statement checked against the epoch it reaches, and each message opened with the keys of the epoch its KID names. Since the store takes a message only in the latest epoch (§9.2), every message of an epoch stands in the log before the epoch statement of the next, and a member deletes an epoch’s keys once it has read that statement (confidentiality §5.8). How a device keeps its group state between sessions is the device’s: a member that has lost it is a principal whose keys are gone, and it opens nothing it held before, as confidentiality §12 says of any member.

8. Acknowledgements

A member acknowledges that it holds, or has shown, every message of one author’s track up to a group, with a record it signs in the clear. An acknowledgement names messages by their link hash and position alone, never their content.

fieldsizemeaning
magic4WMA1 (ASCII)
version11 in this revision
room32the room id
member32the acknowledging member’s key
author32the author of the track it acknowledges
through8u64: the last group of author’s track it acknowledges
link32the link hash of author’s message at through
kind11 delivered: the member holds every message of the track up to through, opened; 2 read: it has shown them
t8u64: the room clock, in microseconds, when it was signed
sig64Ed25519 by member over the 21 bytes wai:live-message-ack (ASCII) and 0x01, followed by the bytes the digest covers

An acknowledgement is cumulative: it covers every group of the track up to through. A verifier checks each, in the log’s order, and refuses one at the first of these that fails:

  1. It reads (§2): truncated, malformed or unknown_version.
  2. Its room is the room id: wrong_room.
  3. Its signature verifies under member: bad_key or bad_signature.
  4. kind is 1 or 2: ack_invalid.
  5. member is in the roster of the latest epoch at its position: ack_not_member.
  6. author’s track holds, earlier in the log, a message at group through that passed §11.3 step 6, whose link hash is link: ack_unknown. A verifier without the epoch’s secret checks an acknowledgement as one with it does.
  7. through is at least that of the last acknowledgement of the same member, author and kind the verifier accepted: ack_regressed.

A refused acknowledgement changes nothing. For each author and each member, the verifier states the last group acknowledged delivered and the last acknowledged read. A read implies delivery: a member that has shown a message holds it.

9. The history store

9.1 What it holds

A history store keeps one log per conversation: one sequence of entries, each at a position from 0, in the order the store took them:

It holds no plaintext and no key: nothing in it opens without an epoch’s secret, which only members derive (confidentiality §5.7), and a Welcome is encrypted to the member it adds. What it holds of each message is what a relay forwards (§4.4). A store MUST NOT take a message as anything else, nor keep a plaintext a member sends it.

9.2 What it takes

A store takes an entry only after the checks a verifier of the conversation makes that need no key, against the log as it stands; a post that fails one is refused with its reason and not taken:

The store answers a post only once the entry is on stable storage: an entry it acknowledged taking survives the store’s restart. It keeps every entry at its position; a restart neither reorders nor renumbers the log.

9.3 What a principal is owed

The store serves a principal P the log from a position it names, authorised by a grant of the room for P and its proof of possession over a challenge the store issued (live-rooms §10.5.3). P is owed:

P is admitted while the clock’s last lifecycle record of it is a join. The store answers with the entries owed from that position, each with its position, and with the positions it withheld. It refuses the request not_a_member when P is not admitted and is owed no content entry at or after that position: a principal never admitted, and a removed member asking past its removal. An admitted principal with nothing new, a member’s or one waiting for its Welcome, is answered with nothing, not refused.

This is how a member proves it is owed a range: its key, by the proof; its membership in each epoch, by the clock’s epoch statements the store holds, which list it. A store MUST NOT serve a content entry to a principal the rule does not owe it, whatever grant it presents: the ciphertext would open nothing for it, but its sizes, timing and authors are the members’ metadata.

9.4 Retention

A store keeps every entry while the conversation lasts, but that it MAY drop a message older than the statement’s retention_days, counted from when the store took it, as a whole entry: its position stays, empty, and a reader is told the position was dropped. It never drops a lifecycle record, a handshake message, an epoch statement or a message’s receipts while the conversation lasts: a member’s chain of link hashes, and every reference into it, stays checkable after a message’s ciphertext is gone.

10. Calls

A call is a room of live-rooms with a call descriptor. Its media is the room’s own: each participant that enters publishes its audio and video tracks (live-rooms §6.1) under a grant, and the clock records its join and publish as in any room. A call between two principals and a call among many differ only in how many invitees the descriptor names.

10.1 The call descriptor

fieldsizemeaning
magic4WCT1 (ASCII)
version11 in this revision
room32the call’s room id
caller32the caller’s key
media1bit 0 audio, bit 1 video: what the call carries
ring_ms4u32: how long an invitation rings, in milliseconds, from 1 000 to 300 000
invitees1 + 32 × na u8 count n from 1 to 32, then each invitee’s key
sig64Ed25519 by the descriptor’s authority over the 14 bytes wai:live-call (ASCII) and 0x01, followed by the bytes the digest covers

A verifier reads the room’s descriptor (live-rooms §3.4, at descriptor), then the call descriptor, at call, in this order: it reads (§2); media is 1, 2 or 3, ring_ms within its bounds, the invitees 1 to 32, no two the same and none the caller, otherwise call_invalid; room is the room id, wrong_room; its signature verifies, bad_key or bad_signature.

10.2 The invitation

The caller rings each invitee with an invitation it signs.

fieldsizemeaning
magic4WCI1 (ASCII)
version11 in this revision
room32the call’s room id
call32the call descriptor’s digest
caller32the caller’s key
callee32the invitee rung
t8u64: the room clock, in microseconds, from which it rings
sig64Ed25519 by caller over the 21 bytes wai:live-call-invite (ASCII) and 0x01, followed by the bytes the digest covers

It rings until t + ring_ms × 1 000, its deadline. A verifier takes the invitations in order and refuses at the first of these that fails, at the invitation’s index: it reads; room, call and caller are the room id, the descriptor’s digest and its caller (invitation_mismatch); its signature verifies under caller (bad_key, bad_signature); callee is an invitee (callee_not_invited); no earlier invitation rang it (already_invited).

A callee’s device MUST verify the invitation and the call descriptor it names, and their signatures, before it rings: an invitation that does not verify is not shown.

10.3 Answers

Every invitation ends with one signed answer:

fieldsizemeaning
magic4WCA1 (ASCII)
version11 in this revision
room32the call’s room id
invitation32the invitation’s digest
signer32the key that signs it
kind11 accept, 2 decline, 3 cancel, 4 missed
t8u64: the room clock, in microseconds
sig64Ed25519 by signer over the 21 bytes wai:live-call-answer (ASCII) and 0x01, followed by the bytes the digest covers

An accept or a decline is the callee’s, a cancel the caller’s, and a missed call the room clock’s: the clock signs missed for an invitation unanswered at its deadline, so a missed call is recorded rather than inferred from silence. A verifier takes the answers in order and refuses at the first of these that fails, at the answer’s index:

  1. It reads (§2); its room is the room id (wrong_room).
  2. invitation is the digest of an invitation it holds: answer_unknown.
  3. kind is 1 to 4: answer_invalid.
  4. signer is the one its kind names: the invitation’s callee for 1 and 2, its caller for 3, the descriptor’s clock for 4: answer_wrong_signer.
  5. Its signature verifies under signer: bad_key or bad_signature.
  6. No earlier answer ended that invitation: already_answered.
  7. t is at least the invitation’s t: answer_early. An accept, a decline or a cancel is before the deadline (answer_late); a missed call is at or after it (missed_early).

Each invitation’s state is then ringing (no answer yet), accepted, declined, cancelled or missed, with the answer’s t.

10.4 The inbox

A room service that keeps calls holds, for each principal, an inbox: every invitation that names it as callee, the call descriptor each names, and every answer to them, the clock’s missed among them. It serves a principal its inbox on a proof of possession of its key over a challenge the service issued (live-rooms §10.5.3), in which the proof’s grant is 32 zero bytes: a callee holds no grant of a call it has not answered. The service takes an invitation into an inbox only when §10.2’s checks pass and the caller’s request presented the caller’s grant and proof; it takes an answer only when §10.3’s pass and the request proved the signer’s key. Nothing in an inbox is the service’s statement but the missed answers: every other record in it is signed by the principal it names.

10.5 Media

A principal enters a call’s room as a publisher under a grant (live-rooms §4), which the authority issues to the caller, and to an invitee only once it holds the invitee’s accept. Its audio and video tracks are the room’s (live-rooms §6.1), and in an end-to-end encrypted call are sealed as confidentiality has it. A call ends as a room does: when its last participant leaves.

11. Verifying a conversation

11.1 Inputs

A verifier is given the room’s descriptor, its confidentiality record, the conversation statement, and the log’s epoch statements, messages and acknowledgements in the log’s order, each with its position. It is not given the lifecycle records or the handshake messages: the epoch statements carry what it needs of them (§7.3). To open the messages it is given, for each epoch whose messages it opens, that epoch’s application secret (confidentiality §5.7); a member holds those of its own epochs. A message of an epoch whose secret it is not given is checked through step 6 below and stated sealed.

11.2 The statement and the epochs

It checks the descriptor, the record and the statement (§3.3), and every epoch statement (§7.3) at its place in the log, and refuses the conversation at the first failure, naming where: descriptor, record, statement, or the epoch statement’s position.

11.3 Each message

Each message, in the log’s order, is accepted or refused with a reason; a refusal does not stop the verification, and a refused message is not a target (§6). It runs these steps and stops at the first that fails:

  1. An epoch statement precedes it: epoch_unknown.
  2. The author is in the roster of the latest epoch: sender_not_member. A removed member’s message after its removal fails here.
  3. The property list reads and holds one Key ID inside the Immutable Properties (confidentiality §6 step 1): properties_malformed, key_id_missing.
  4. The KID’s epoch (confidentiality §5.7) is the latest epoch’s number: epoch_stale. Its leaf index is in that epoch’s roster (key_id_unknown), and the member there is the author (sender_not_owner).
  5. Its Group ID is the group after the author’s last message that passed this step and step 6, or 0 for its first: message_gap. A refused post takes no group (§9.2), so its author posts that group again.
  6. Its receipts-track object reads, holds one group receipt and one object receipt of that group (malformed), in the labelled form (receipt_unlabelled), signed by the author’s key, with track_alias 5, both naming the Group ID, the object receipt Object ID 0 (moqt_mismatch); and it verifies by jwp-receipts §3 against the sealed payload, with the reason that section names: the group’s signature, the content hash, the object’s proof, the group’s total, and the chain, the group receipt’s parent the link hash of the group receipt of the author’s last message that passed this step, and group 0’s none. The verifier keeps that receipt alone (jwp-receipts §3 step 3): a parent where none is held, and a parent that is not its link hash, are parent_not_held; none where one is held is chain_break.
  7. With the epoch’s secret, it derives the author’s track base key for the namespace [R, author, "messages"] (confidentiality §5.7) and the key and salt of the object, Track Name 0, under the KID (§4.5 there), and opens the sealed payload with the nonce and AAD of Object ID 0 at priority 64 (§4.6, §4.7, §4.10 there): bad_tag. The plaintext reads (§4.4 there): plaintext_malformed; where it carries WAI_INNER_DIGEST, the digest is the original payload’s (§6 step 11 there): inner_digest_mismatch. Without the secret, the message is sealed, and steps 8 to 13 are not run.
  8. The original payload reads as a message record (§2: truncated, malformed, unknown_version) and its signature verifies under author (bad_key, bad_signature).
  9. Its room, author and group are the room id, the track’s owner and the Group ID: message_mismatch.
  10. Its t is at least that of the author’s last message that passed this step: time_reversed.
  11. Its kind and body keep §5.3: unknown_kind, bad_utf8, text_too_long, body_invalid, ref_invalid.
  12. Its target keeps §6 for its kind: ref_unknown, ref_retracted, thread_not_root, target_kind, reaction_repeated, edit_not_author, retract_not_author.
  13. It is accepted, and applied: a text is a message of the conversation; a reply joins its root’s thread; a reaction is added to its target’s; an edit sets its target’s current text; a retraction marks its target retracted, and a retracted reaction leaves its target’s reactions.

A message refused at steps 1 to 6 does not advance its author’s track: the next is checked against the last message that passed step 6, its group and its group receipt. One refused at steps 7 to 12 has a receipt that verified, and its group and link hash chain the track as any other.

11.4 Each acknowledgement

Each acknowledgement, in the log’s order, by §8.

11.5 The outcome

A verification that refuses states the reason and where (§11.2). One that does not states:

12. Reasons

The codes this extension adds, each with where it is raised:

reasonraised at
conversation_invalid§3.3 step 2
not_encrypted§3.3
epoch_invalid§7.3 step 4
epoch_unlinked§7.3 step 5
epoch_out_of_order§7.3 step 6
epoch_unknown§9.2, §11.3 step 1
sender_not_member§9.2, §11.3 step 2
epoch_stale§4.2, §9.2, §11.3 step 4
message_gap§4.2, §9.2, §11.3 step 5
receipt_unlabelled§4.3, §11.3 step 6
message_mismatch§5.1, §11.3 step 9
body_invalid§5.3, §11.3 step 11
ref_invalid§5.3, §11.3 step 11
ref_unknown§6, §11.3 step 12
ref_retracted§6, §6.4, §11.3 step 12
thread_not_root§6.1
target_kind§6.2, §6.3, §6.4
reaction_repeated§6.2
edit_not_author§6.3
retract_not_author§6.4
ack_invalid§8 step 4
ack_not_member§8 step 5
ack_unknown§8 step 6
ack_regressed§8 step 7
not_a_member§9.3
call_invalid§10.1
invitation_mismatch§10.2
callee_not_invited§10.2
already_invited§10.2
answer_unknown§10.3 step 2
answer_invalid§10.3 step 3
answer_wrong_signer§10.3 step 4
already_answered§10.3 step 6
answer_early§10.3 step 7
answer_late§10.3 step 7
missed_early§10.3 step 7

It also raises codes other extensions define: truncated, malformed, unknown_version, wrong_room, bad_key and bad_signature for a record that does not read, is of another room or does not verify; unknown_kind, bad_utf8, text_too_long and time_reversed as live-events §9 names them, for a message’s kind, body and t; live-rooms §11’s for a descriptor; confidentiality §13’s for the confidentiality record (§3.3), for a message’s Key ID and opening (properties_malformed, key_id_missing, key_id_unknown, sender_not_owner, bad_tag, plaintext_malformed, inner_digest_mismatch), and for a commit (commit_not_committer, proposal_not_clock, commit_mismatch, §7.2); and jwp-receipts §3.3’s for a message’s receipts (§11.3 step 6).

13. Conformance

live-conformance/messaging/ holds the corpus, in one file, vectors.json, written from fixed seeds by wai_live_messaging_vectors gen. Its keys and epoch secrets are derived from fixed seeds; the epoch secrets stand where an MLS exporter gives them (confidentiality §14’s boundary: the corpus runs no MLS), and every message is sealed under them by confidentiality §4, so a verifier opens every message it is given a secret for. It has:

A conforming verifier gives every vector the outcome the corpus states. wai_live_messaging_vectors verify and the test tests/live_messaging.rs check that the file regenerates byte for byte, that wai-rs gives every vector its outcome, and that §12 lists exactly the reference’s codes. live-conformance/live_messaging_verify.py is the second implementation, written from this text: it shares no code with wai-rs; it reads descriptors with the live-rooms corpus’s own second implementation (live_rooms_verify.py), checks confidentiality records, reads Key IDs and opens sealed objects with the confidentiality corpus’s (e2ee_verify.py), verifies signatures with WAI’s strict rule written from RFC 8032 (claim-conformance/_ed25519.py) and computes BLAKE3 with claim-conformance/_blake3.py; it gives every vector the same outcome, and requires every mutated copy of the corpus to fail.

The room service joule-moq-room keeps conversations and calls ([messaging], [call]), and its tests run them with members keying through real MLS groups (joule-moq-mls) and sealing with joule-moq’s secure objects: three members exchange messages, one is offline across a restart of the service and catches up from its history store, a thread, a reaction, an edit and a retraction are verified by wai-rs, a removed member opens nothing sealed after its removal and is refused what it is not owed, and a call rings, is accepted and carries audio and video, while another is missed.

14. Security and privacy considerations