From 636145c9c3df1a608be6eeb36b503f9cc3d1b127 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 08:28:18 -0400 Subject: [PATCH 01/14] joule-moq secure_objects: a keyring holds the locations it opened by epoch, and forgets them with the epoch's keys An object presented again carries the same KID, so its epoch's set holds it; a set goes when its epoch's keys are deleted, after which every object of that epoch is refused epoch_not_joined anyway. A page in a long room keeps memory bounded by the epochs it holds. Co-Authored-By: Claude Opus 5.5 --- .../joule-moq/src/secure_objects/room.rs | 37 +++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/secure_objects/room.rs b/joule-code/joule-code-rs/crates/joule-moq/src/secure_objects/room.rs index 5ea1635ea..62701e866 100644 --- a/joule-code/joule-code-rs/crates/joule-moq/src/secure_objects/room.rs +++ b/joule-code/joule-code-rs/crates/joule-moq/src/secure_objects/room.rs @@ -12,7 +12,7 @@ use crate::draft22::kvp::{KeyValuePair, Properties}; use crate::track::{TrackName, TrackNamespace}; use hkdf::Hkdf; use sha2::Sha256; -use std::collections::{BTreeMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; /// The MLS exporter label of the room's application secret (confidentiality §5.7). The /// exporter's context is the room id, and its length 32. @@ -167,12 +167,16 @@ pub struct Keyring { removed: Option, secrets: BTreeMap, rosters: BTreeMap, - opened: HashSet<(Vec, u64, u64)>, + /// The locations opened, by the epoch they were sealed in: an object presented again + /// carries the same KID, so its epoch's set holds it, and a set goes with its epoch's + /// keys (§6 step 8). Each Full Track Name is held once, by its index in `tracks`. + opened: BTreeMap>, + tracks: HashMap, u32>, } impl Keyring { pub fn new(suite: Suite, me: Principal) -> Self { - Keyring { suite, me, joined: None, removed: None, secrets: BTreeMap::new(), rosters: BTreeMap::new(), opened: HashSet::new() } + Keyring { suite, me, joined: None, removed: None, secrets: BTreeMap::new(), rosters: BTreeMap::new(), opened: BTreeMap::new(), tracks: HashMap::new() } } pub fn suite(&self) -> Suite { @@ -196,9 +200,26 @@ impl Keyring { self.removed = Some(epoch); } - /// Delete `epoch`'s keys (confidentiality §5.8); its roster is kept, to check senders. + /// Delete `epoch`'s keys (confidentiality §5.8), and the locations opened under them: + /// an object of that epoch is refused `epoch_not_joined` from now on. Its roster is kept, + /// to check senders. pub fn forget(&mut self, epoch: u64) { self.secrets.remove(&epoch); + self.opened.remove(&epoch); + } + + /// The epochs whose keys the member holds. + pub fn held(&self) -> Vec { + self.secrets.keys().copied().collect() + } + + /// The first epoch the member held, and the epoch its removal opened. + pub fn joined_at(&self) -> Option { + self.joined + } + + pub fn removed(&self) -> Option { + self.removed } /// The latest epoch the member entered. @@ -267,8 +288,8 @@ impl Keyring { } // 8: a location opened before. let ftn = serialize_full_track_name(o.ns, o.name); - let place = (ftn.clone(), o.group, o.object); - if self.opened.contains(&place) { + let track = self.tracks.get(&ftn).copied(); + if track.is_some_and(|t| self.opened.get(&epoch).is_some_and(|s| s.contains(&(t, o.group, o.object)))) { return Err(Refusal::Replayed.into()); } // 9 to 11. @@ -277,7 +298,9 @@ impl Keyring { let pt = open_object(self.suite, &keys, Location { ftn: &ftn, group: o.group, object: o.object, priority: o.priority }, &sip, o.payload)?; let plaintext = Plaintext::decode(&pt)?; plaintext.check_digest()?; - self.opened.insert(place); + let next = self.tracks.len() as u32; + let t = *self.tracks.entry(ftn).or_insert(next); + self.opened.entry(epoch).or_default().insert((t, o.group, o.object)); Ok(Opened { epoch, leaf, sender, plaintext }) } } -- 2.54.0 (Apple Git-157) From 67e36498caca66f25892c79eb57c83f5dc8623f1 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 08:28:19 -0400 Subject: [PATCH 02/14] =?UTF-8?q?joule-moq-mls:=20a=20member=20behind=20an?= =?UTF-8?q?=20ordering=20carrier=20=E2=80=94=20stages=20its=20create=20and?= =?UTF-8?q?=20commit,=20follows=20the=20group=20before=20its=20Welcome;=20?= =?UTF-8?q?feature=20js=20for=20wasm32?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A room service that carries the room's messages in one order (confidentiality §5.1) is how a browser member is reached. A member that applied its own commit before the carrier ordered it could fork from a commit another member made for the same epoch, so: - stage_create / stage_commit hold the member's GroupInfo or commit until the carrier returns it (receive applies it then, checked as every member checks it); another's first, or a refusal, withdraws it. create and commit still apply at once. - before its Welcome a principal follows the group from its public handshake messages, as the clock does (the code is now shared), so its model knows whether a group exists and who is in it; a Welcome for another and a GroupInfo of a group it holds are ignored. - feature js: openmls's js (getrandom 0.4 wasm_js, web-time) and getrandom 0.2's js for the crypto provider's generator; the crate builds for wasm32-unknown-unknown. Tests: a room carried in one order (create, follow, commit, Welcome, removal), two commits for one epoch where the carrier's first wins, a commit overtaken by another, and a staged create the carrier refuses. Co-Authored-By: Claude Opus 5.5 --- joule-code/joule-code-rs/Cargo.lock | 5 + .../crates/joule-moq-mls/Cargo.toml | 10 + .../crates/joule-moq-mls/src/group.rs | 293 ++++++++++++++---- .../crates/joule-moq-mls/src/group_tests.rs | 268 ++++++++++++++++ 4 files changed, 524 insertions(+), 52 deletions(-) diff --git a/joule-code/joule-code-rs/Cargo.lock b/joule-code/joule-code-rs/Cargo.lock index 81fce2b67..507bc7b07 100644 --- a/joule-code/joule-code-rs/Cargo.lock +++ b/joule-code/joule-code-rs/Cargo.lock @@ -2085,6 +2085,7 @@ version = "0.1.0" dependencies = [ "blake3", "ed25519-dalek", + "getrandom 0.2.17", "hex", "joule-moq", "openmls 0.9.1", @@ -2130,6 +2131,7 @@ dependencies = [ "ed25519-dalek", "hex", "joule-moq", + "joule-moq-mls", "serde_json", "wasm-bindgen", ] @@ -2740,6 +2742,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88ea97f8931047590bda498a9502e8fe6c9bb2bb04547a9a463618783481e0ec" dependencies = [ + "getrandom 0.4.3", "log", "openmls_serialization_helpers", "openmls_traits 0.6.0", @@ -2748,6 +2751,7 @@ dependencies = [ "serde_bytes", "thiserror 2.0.20", "tls_codec 0.5.0", + "web-time", "zeroize", ] @@ -4459,6 +4463,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ "js-sys", + "serde", "wasm-bindgen", ] diff --git a/joule-code/joule-code-rs/crates/joule-moq-mls/Cargo.toml b/joule-code/joule-code-rs/crates/joule-moq-mls/Cargo.toml index 112abcd8d..22dc54bf4 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-mls/Cargo.toml +++ b/joule-code/joule-code-rs/crates/joule-moq-mls/Cargo.toml @@ -25,3 +25,13 @@ blake3 = "1" hex = "0.4" serde = { version = "1", features = ["derive"] } serde_json = "1" +# For wasm32-unknown-unknown (feature `js`): getrandom 0.2's browser backend, which the +# crypto provider's own generator draws from (openmls's `js` gives getrandom 0.4 its own). +getrandom_02 = { package = "getrandom", version = "0.2", features = ["js"], optional = true } + +[features] +# A member in a browser: secure randomness and the clock from the page's JavaScript APIs +# (openmls's `js`: getrandom 0.4's `wasm_js` backend and `web-time`), and the same for the +# crypto provider's generator. Nothing else changes, and the crate builds for +# wasm32-unknown-unknown with it. +js = ["openmls/js", "dep:getrandom_02"] diff --git a/joule-code/joule-code-rs/crates/joule-moq-mls/src/group.rs b/joule-code/joule-code-rs/crates/joule-moq-mls/src/group.rs index 1b4ab45ff..2769ce565 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-mls/src/group.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-mls/src/group.rs @@ -170,17 +170,75 @@ fn check_commit(model: &Room, refs: &[ProposalRef], sender: &Sender, staged: &St Ok((s.u32(), holds)) } +/// A group followed from its creator's GroupInfo (§5.1), as the clock and a principal not +/// yet a member follow it: epoch 0, the creator alone at leaf 0, the clock its one external +/// sender (`group_mismatch` otherwise). The model records the creation. +fn public_from_info(provider: &OpenMlsRustCrypto, model: &mut Room, msg: MlsMessageIn) -> Result { + let MlsMessageBodyIn::GroupInfo(vgi) = msg.extract() else { return Err(Error::Mls("not a GroupInfo".into())) }; + let tree = vgi.extensions().ratchet_tree().map(|t| t.ratchet_tree().clone()).ok_or(Error::Mls("no ratchet tree".into()))?; + let (pg, _) = PublicGroup::from_external(provider.crypto(), provider.storage(), tree, vgi, ProposalStore::new()).map_err(mls)?; + if pg.group_context().extensions().external_senders() != Some(&external_senders(&model.clock())) { + return Err(Refusal::GroupMismatch.into()); + } + let roster = roster_of(pg.members())?; + let by = *roster.get(&0).ok_or(Refusal::GroupMismatch)?; + if roster.len() != 1 || pg.group_context().epoch().as_u64() != 0 { + return Err(Refusal::GroupMismatch.into()); + } + model.create(by, pg.group_id().as_slice(), &[model.clock()])?; + Ok(pg) +} + +/// One proposal or commit of a group followed from outside (§5.4, §5.5): checked as a member +/// checks it, then applied to the model and the public view. A commit returns what it did. +fn follow_message(provider: &OpenMlsRustCrypto, model: &mut Room, pg: &mut PublicGroup, refs: &mut Vec, msg: MlsMessageIn) -> Result, Error> { + let epoch = pg.group_context().epoch().as_u64(); + let processed = pg.process_message(provider.crypto(), msg.try_into_protocol_message().map_err(mls)?).map_err(mls)?; + let sender = processed.sender().clone(); + match processed.into_content() { + ProcessedMessageContent::ProposalMessage(q) => { + let p = read_proposal(&q, epoch)?; + model.propose(p)?; + refs.push(q.proposal_reference_ref().clone()); + pg.add_proposal(provider.storage(), *q).map_err(mls)?; + Ok(None) + } + ProcessedMessageContent::StagedCommitMessage(staged) => { + let (s, holds) = check_commit(model, refs, &sender, &staged)?; + let c = model.commit(s, &holds)?; + refs.clear(); + pg.merge_commit(provider.storage(), *staged).map_err(mls)?; + if model.group().map(|g| &g.roster) != Some(&roster_of(pg.members())?) { + return Err(Error::Mls("the group's roster is not the room's".into())); + } + Ok(Some(c)) + } + _ => Err(Error::Mls("not a proposal or a commit".into())), + } +} + /// What a member made of a message it received. #[derive(Clone, Debug, PartialEq, Eq)] pub enum Received { /// A proposal, kept. Proposal, - /// A commit, applied: the member is in this epoch. + /// A commit, applied: the member is in this epoch. A member's own staged commit, + /// returned by the carrier in its order, is applied so too. Epoch(u64), /// A commit that removes this member: it holds nothing from this epoch on. Removed(u64), /// A Welcome: the member joined at this epoch. Joined(u64), + /// The member's own staged GroupInfo, returned by the carrier in its order: it created + /// the group, and holds epoch 0. + Created, + /// A GroupInfo, proposal or commit of a group the principal follows from outside, + /// before a Welcome makes it a member: checked as the clock checks it, and applied to + /// the room's model. + Followed, + /// Not for this principal: another's Welcome, a GroupInfo of a group it already + /// holds, or a message of a group it neither holds nor follows (after its removal). + Ignored, } /// A commit a member made, with the Welcome for those it adds. @@ -191,6 +249,25 @@ pub struct Commit { pub epoch: u64, } +/// A create or a commit a member made and handed to a carrier that orders the group's +/// messages (a room service, §5.1), not yet applied: the member applies it when the carrier +/// returns it in its order, and drops it when another's comes first or the carrier refuses +/// it. Two members never both apply a commit for one epoch, whatever the timing. +enum Staged { + /// The group, made and not yet the member's: its framed GroupInfo. + Create { group: Box, frame: Vec }, + /// A commit pending in the member's group: its framed bytes. + Commit { frame: Vec }, +} + +impl Staged { + fn frame(&self) -> &[u8] { + match self { + Staged::Create { frame, .. } | Staged::Commit { frame } => frame, + } + } +} + /// A principal of a room as a member of its group. pub struct RoomMember { provider: OpenMlsRustCrypto, @@ -198,8 +275,16 @@ pub struct RoomMember { room: [u8; 32], model: Room, group: Option, + /// Before it holds a leaf: the group followed from its public handshake messages, as + /// the clock follows it, so the principal's model knows the group's roster and epoch. + /// Its state lives in a store of its own, apart from the group the Welcome gives. + public: Option, + outside: OpenMlsRustCrypto, /// For each of the model's pending proposals, its MLS reference. refs: Vec, + staged: Option, + /// The reference of the KeyPackage this principal published: the Welcome for it names it. + key_package: Option, keyring: Keyring, } @@ -207,7 +292,7 @@ impl RoomMember { pub fn new(room: [u8; 32], clock: Principal, key: SigningKey, suite: Suite) -> Self { let key = RoomKey(key); let keyring = Keyring::new(suite, key.principal()); - RoomMember { provider: OpenMlsRustCrypto::default(), key, room, model: Room::new(room, clock), group: None, refs: Vec::new(), keyring } + RoomMember { provider: OpenMlsRustCrypto::default(), key, room, model: Room::new(room, clock), group: None, public: None, outside: OpenMlsRustCrypto::default(), refs: Vec::new(), staged: None, key_package: None, keyring } } pub fn principal(&self) -> Principal { @@ -230,21 +315,41 @@ impl RoomMember { self.group.as_ref().map(|g| g.epoch().as_u64()) } + /// Whether a create or a commit of this member waits for its carrier's order. + pub fn staged(&self) -> bool { + self.staged.is_some() + } + + /// Whether this principal follows the group from outside, before a Welcome. + pub fn following(&self) -> bool { + self.public.is_some() + } + /// A lifecycle event of the room, as every member reads it from the clock's events. pub fn event(&mut self, ev: &Event) { self.model.event(ev); - if self.model.group().is_none() && self.group.is_some() { + if self.model.group().is_none() && (self.group.is_some() || self.public.is_some()) { self.group = None; + self.public = None; self.refs.clear(); + self.withdraw(); } } /// A KeyPackage for the clock to propose this principal with (the keying draft §4.7). - pub fn key_package(&self) -> Result, Error> { + /// Its private keys stay in this member's memory, for the Welcome that names it. + pub fn key_package(&mut self) -> Result, Error> { let kp = KeyPackage::builder().build(CIPHERSUITE, &self.provider, &self.key, credential(&self.principal())).map_err(mls)?; + self.key_package = Some(kp.key_package().hash_ref(self.provider.crypto()).map_err(mls)?); framed(frame::KEY_PACKAGE, &MlsMessageOut::from(kp.key_package().clone())) } + /// Whether the room owes this principal the group's creation now (§5.4): no group + /// exists, it holds and follows none, and it is the first present under a keyed grant. + pub fn owes_create(&self) -> bool { + self.group.is_none() && self.public.is_none() && self.staged.is_none() && self.model.next_owed() == Some(Owed::Create(self.principal())) + } + fn export(&self, g: &MlsGroup) -> Result<[u8; 32], Error> { let s = g.export_secret(self.provider.crypto(), EXPORTER_LABEL, &self.room, 32).map_err(mls)?; Ok(s.try_into().expect("32 bytes")) @@ -264,8 +369,18 @@ impl RoomMember { /// Create the group, when the clock owes it to this principal (§5.4): epoch 0, this /// principal at leaf 0. Returns the GroupInfo, with its ratchet tree, for the clock. + /// Applied at once: for a member whose messages reach every party as it sends them. pub fn create(&mut self) -> Result, Error> { - if self.model.next_owed() != Some(Owed::Create(self.principal())) { + let info = self.stage_create()?; + self.confirm()?; + Ok(info) + } + + /// Make the group the room owes this principal, and hold it until the carrier returns + /// its GroupInfo (then [`RoomMember::receive`] applies it) or refuses it + /// ([`RoomMember::withdraw`]). Returns the GroupInfo for the carrier. + pub fn stage_create(&mut self) -> Result, Error> { + if !self.owes_create() { return Err(Error::NotNow("the room does not owe this principal a group")); } let gid = self.model.group_id(); @@ -276,11 +391,58 @@ impl RoomMember { .with_group_context_extensions(Extensions::single(Extension::ExternalSenders(external_senders(&self.model.clock()))).map_err(mls)?) .build(); let g = MlsGroup::new_with_group_id(&self.provider, &self.key, &config, GroupId::from_slice(&gid), credential(&self.principal())).map_err(mls)?; - self.model.create(self.principal(), &gid, &[self.model.clock()])?; - let info = g.export_group_info(self.provider.crypto(), &self.key, true).map_err(mls)?; - self.group = Some(g); - self.enter()?; - framed(frame::GROUP_INFO, &info) + let info = framed(frame::GROUP_INFO, &g.export_group_info(self.provider.crypto(), &self.key, true).map_err(mls)?)?; + self.staged = Some(Staged::Create { group: Box::new(g), frame: info.clone() }); + Ok(info) + } + + /// Drop a staged create or commit: the carrier refused it, or ordered another first. + pub fn withdraw(&mut self) { + match self.staged.take() { + Some(Staged::Commit { .. }) => { + if let Some(g) = self.group.as_mut() { + let _ = g.clear_pending_commit(self.provider.storage()); + } + } + Some(Staged::Create { mut group, .. }) => { + let _ = group.delete(self.provider.storage()); + } + None => {} + } + } + + /// Apply the staged create or commit, checked as every other member checks it. + fn confirm(&mut self) -> Result { + match self.staged.take() { + Some(Staged::Create { group, .. }) => { + self.model.create(self.principal(), group.group_id().as_slice(), &[self.model.clock()])?; + self.group = Some(*group); + self.public = None; + self.refs.clear(); + self.enter()?; + Ok(Received::Created) + } + Some(Staged::Commit { .. }) => { + let g = self.group.as_mut().ok_or(Error::NotNow("not a member"))?; + let staged = g.pending_commit().ok_or(Error::NotNow("no commit pending"))?; + let me = Sender::Member(g.own_leaf_index()); + let (s, holds) = check_commit(&self.model, &self.refs, &me, staged)?; + self.model.commit(s, &holds)?; + self.refs.clear(); + g.merge_pending_commit(&self.provider).map_err(mls)?; + self.enter().map(Received::Epoch) + } + None => Err(Error::NotNow("nothing staged")), + } + } + + /// Follow the group from the creator's GroupInfo, as the clock does (§5.1). + fn follow_info(&mut self, msg: MlsMessageIn) -> Result { + self.outside = OpenMlsRustCrypto::default(); + let pg = public_from_info(&self.outside, &mut self.model, msg)?; + self.public = Some(pg); + self.refs.clear(); + Ok(Received::Followed) } fn join(&mut self, welcome: Welcome) -> Result { @@ -292,20 +454,49 @@ impl RoomMember { let roster = roster_of(g.members())?; self.model.joined(g.group_id().as_slice(), &[self.model.clock()], g.epoch().as_u64(), roster)?; self.refs.clear(); + self.public = None; self.group = Some(g); self.enter() } - /// A framed MLS message from the room's `mls` tracks (§5.4, §5.5). + /// A framed MLS message from the room's `mls` tracks, or from a room service that + /// carries them in one order (§5.1, §5.4, §5.5). A principal reads every message of the + /// room: before its Welcome it follows the group from outside, and a member's own staged + /// create or commit, returned in the carrier's order, is applied then. pub fn receive(&mut self, framed_msg: &[u8]) -> Result { + if self.staged.as_ref().is_some_and(|s| s.frame() == framed_msg) { + return self.confirm(); + } let (ty, msg) = unframe(framed_msg)?; if ty == frame::WELCOME { let MlsMessageBodyIn::Welcome(w) = msg.extract() else { return Err(Error::Mls("not a Welcome".into())) }; if self.group.is_some() { - return Err(Error::NotNow("already a member")); + return Ok(Received::Ignored); + } + if let Some(mine) = &self.key_package + && !w.secrets().iter().any(|s| &s.new_member() == mine) + { + return Ok(Received::Ignored); } return self.join(w).map(Received::Joined); } + if ty == frame::GROUP_INFO { + if self.group.is_some() || self.public.is_some() { + return Ok(Received::Ignored); + } + // Another's group came first: this principal's own is never the room's. + self.withdraw(); + return self.follow_info(msg); + } + if self.group.is_none() { + let Some(pg) = self.public.as_mut() else { return Ok(Received::Ignored) }; + follow_message(&self.outside, &mut self.model, pg, &mut self.refs, msg)?; + return Ok(Received::Followed); + } + if ty == frame::COMMIT && matches!(self.staged, Some(Staged::Commit { .. })) { + // Another's commit came first in the carrier's order: this member's is void. + self.withdraw(); + } let pm = msg.try_into_protocol_message().map_err(mls)?; let g = self.group.as_mut().ok_or(Error::NotNow("not a member"))?; let epoch = g.epoch().as_u64(); @@ -341,18 +532,39 @@ impl RoomMember { /// Whether this member is the committer of the clock's pending proposals. pub fn is_committer(&self) -> bool { + if self.group.is_none() { + return false; + } let (Some(g), Some(me)) = (self.model.group(), self.keyring.my_leaf()) else { return false }; let removed: BTreeSet = self.model.pending().iter().filter(|p| p.from == By::Clock).filter_map(|p| if let Change::Remove { leaf } = p.change { Some(leaf) } else { None }).collect(); model::committer(&g.roster, &removed) == Some(me) } + /// Whether the clock has proposals pending for the current epoch. + pub fn clock_pending(&self) -> bool { + self.model.pending().iter().any(|p| p.from == By::Clock) + } + /// Commit the clock's pending proposals, when this member is the committer (§5.5), with - /// an UpdatePath that renews its own leaf. `force` commits with none pending. + /// an UpdatePath that renews its own leaf. `force` commits with none pending. Applied at + /// once: for a member whose messages reach every party as it sends them. pub fn commit(&mut self, force: bool) -> Result { + let c = self.stage_commit(force)?; + self.confirm()?; + Ok(c) + } + + /// Make the commit [`RoomMember::commit`] makes, checked as every member will check it, + /// and hold it until the carrier returns it (then [`RoomMember::receive`] applies it) or + /// refuses it ([`RoomMember::withdraw`]). + pub fn stage_commit(&mut self, force: bool) -> Result { + if self.staged.is_some() { + return Err(Error::NotNow("a commit is staged")); + } if !self.is_committer() { return Err(Error::NotNow("not the committer")); } - if !force && !self.model.pending().iter().any(|p| p.from == By::Clock) { + if !force && !self.clock_pending() { return Err(Error::NotNow("nothing pending")); } let g = self.group.as_mut().ok_or(Error::NotNow("not a member"))?; @@ -369,12 +581,14 @@ impl RoomMember { let (commit, welcome, _) = bundle.into_messages(); let staged = g.pending_commit().expect("a staged commit"); let me = Sender::Member(g.own_leaf_index()); - let (s, holds) = check_commit(&self.model, &self.refs, &me, staged)?; - self.model.commit(s, &holds)?; - self.refs.clear(); - g.merge_pending_commit(&self.provider).map_err(mls)?; - let epoch = self.enter()?; - Ok(Commit { commit: framed(frame::COMMIT, &commit)?, welcome: welcome.map(|w| framed(frame::WELCOME, &w)).transpose()?, epoch }) + if let Err(r) = check_commit(&self.model, &self.refs, &me, staged) { + let _ = g.clear_pending_commit(self.provider.storage()); + return Err(r.into()); + } + let epoch = g.epoch().as_u64() + 1; + let out = Commit { commit: framed(frame::COMMIT, &commit)?, welcome: welcome.map(|w| framed(frame::WELCOME, &w)).transpose()?, epoch }; + self.staged = Some(Staged::Commit { frame: out.commit.clone() }); + Ok(out) } /// Seal an object under this member's current epoch (§4.9). @@ -445,18 +659,7 @@ impl Clock { /// The creator's GroupInfo: the clock starts following the group (§5.1). pub fn created(&mut self, framed_info: &[u8]) -> Result<(), Error> { let (_, msg) = unframe(framed_info)?; - let MlsMessageBodyIn::GroupInfo(vgi) = msg.extract() else { return Err(Error::Mls("not a GroupInfo".into())) }; - let tree = vgi.extensions().ratchet_tree().map(|t| t.ratchet_tree().clone()).ok_or(Error::Mls("no ratchet tree".into()))?; - let (pg, _) = PublicGroup::from_external(self.provider.crypto(), self.provider.storage(), tree, vgi, ProposalStore::new()).map_err(mls)?; - if pg.group_context().extensions().external_senders() != Some(&external_senders(&self.principal())) { - return Err(Refusal::GroupMismatch.into()); - } - let roster = roster_of(pg.members())?; - let by = *roster.get(&0).ok_or(Refusal::GroupMismatch)?; - if roster.len() != 1 || pg.group_context().epoch().as_u64() != 0 { - return Err(Refusal::GroupMismatch.into()); - } - self.model.create(by, pg.group_id().as_slice(), &[self.principal()])?; + let pg = public_from_info(&self.provider, &mut self.model, msg)?; self.public = Some(pg); self.refs.clear(); self.flush() @@ -504,32 +707,18 @@ impl Clock { if ty == frame::WELCOME { return Ok(()); } + if ty == frame::GROUP_INFO { + return self.created(framed_msg); + } let pg = self.public.as_mut().ok_or(Error::NotNow("no group"))?; - let epoch = pg.group_context().epoch().as_u64(); - let processed = pg.process_message(self.provider.crypto(), msg.try_into_protocol_message().map_err(mls)?).map_err(mls)?; - let sender = processed.sender().clone(); - match processed.into_content() { - ProcessedMessageContent::ProposalMessage(q) => { - let p = read_proposal(&q, epoch)?; - self.model.propose(p)?; - self.refs.push(q.proposal_reference_ref().clone()); - pg.add_proposal(self.provider.storage(), *q).map_err(mls)?; - Ok(()) - } - ProcessedMessageContent::StagedCommitMessage(staged) => { - let (s, holds) = check_commit(&self.model, &self.refs, &sender, &staged)?; - let c = self.model.commit(s, &holds)?; - self.refs.clear(); + match follow_message(&self.provider, &mut self.model, pg, &mut self.refs, msg)? { + None => Ok(()), + Some(c) => { for (_, p) in &c.added { self.key_packages.remove(p); } - pg.merge_commit(self.provider.storage(), *staged).map_err(mls)?; - if self.model.group().map(|g| &g.roster) != Some(&roster_of(pg.members())?) { - return Err(Error::Mls("the group's roster is not the room's".into())); - } self.flush() } - _ => Err(Error::Mls("not a proposal or a commit".into())), } } } diff --git a/joule-code/joule-code-rs/crates/joule-moq-mls/src/group_tests.rs b/joule-code/joule-code-rs/crates/joule-moq-mls/src/group_tests.rs index 4b73b8f64..bbbf567ca 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-mls/src/group_tests.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-mls/src/group_tests.rs @@ -331,3 +331,271 @@ fn a_group_whose_external_sender_is_not_the_clock_is_refused() { assert!(matches!(n.clock.created(&info), Err(Error::Refused(Refusal::GroupMismatch)))); assert_eq!(err_refusal(n.members[1].receive(&welcome)), Refusal::GroupMismatch); } + +/// A room service that carries the room's messages in one order (§5.1): the clock's +/// lifecycle events, its proposals, and every GroupInfo, commit and Welcome a principal +/// posts, appended as the clock takes them. A post the clock refuses is not appended. +struct Log { + clock: Clock, + entries: Vec, +} + +#[derive(Clone)] +enum Entry { + Event(Event), + Mls(Vec), +} + +impl Log { + fn new(room: [u8; 32]) -> Log { + Log { clock: Clock::new(room, sk("clock")), entries: Vec::new() } + } + + fn outbox(&mut self) { + for m in self.clock.take_outbox() { + self.entries.push(Entry::Mls(m)); + } + } + + fn event(&mut self, ev: Event) { + self.clock.event(&ev).unwrap(); + self.entries.push(Entry::Event(ev)); + self.outbox(); + } + + fn key_package(&mut self, kp: &[u8]) { + self.clock.key_package(kp).unwrap(); + self.outbox(); + } + + /// A GroupInfo, or a commit and its Welcome: checked by the clock, then carried. + fn post(&mut self, frames: &[Vec]) -> Result<(), Error> { + self.clock.observe(&frames[0])?; + for f in frames { + self.entries.push(Entry::Mls(f.clone())); + } + self.outbox(); + Ok(()) + } +} + +/// A principal reading the log in order, as a page does. +struct Reader { + m: RoomMember, + at: usize, + seen: Vec, +} + +impl Reader { + fn new(room: [u8; 32], clock: Principal, name: &str) -> Reader { + Reader { m: RoomMember::new(room, clock, sk(name), SUITE), at: 0, seen: Vec::new() } + } + + /// Read to the end of the log; `act` lets it create and commit when the room calls on it. + fn read(&mut self, log: &mut Log, act: bool) { + loop { + while self.at < log.entries.len() { + let e = log.entries[self.at].clone(); + self.at += 1; + match e { + Entry::Event(ev) => self.m.event(&ev), + Entry::Mls(f) => { + let r = self.m.receive(&f).unwrap_or_else(|e| panic!("{e}")); + self.seen.push(r); + } + } + if act { + self.act(log); + } + } + // What it read earlier may call on it now (its own staged commit withdrawn). + let n = log.entries.len(); + if act { + self.act(log); + } + if log.entries.len() == n { + return; + } + } + } + + fn act(&mut self, log: &mut Log) { + if self.m.owes_create() { + let info = self.m.stage_create().unwrap(); + if log.post(&[info]).is_err() { + self.m.withdraw(); + } + } else if self.m.is_committer() && self.m.clock_pending() && !self.m.staged() { + let c = self.m.stage_commit(false).unwrap(); + let frames: Vec> = std::iter::once(c.commit).chain(c.welcome).collect(); + if log.post(&frames).is_err() { + self.m.withdraw(); + } + } + } +} + +fn read_all(log: &mut Log, rs: &mut [Reader]) { + for _ in 0..4 { + for r in rs.iter_mut() { + r.read(log, true); + } + } +} + +fn roster_names(r: &Reader) -> Vec { + r.m.model().group().map(|g| g.roster.keys().copied().collect()).unwrap_or_default() +} + +#[test] +fn a_room_carried_in_one_order_follows_creates_commits_and_welcomes_through_it() { + let room = [0x63; 32]; + let mut log = Log::new(room); + let cp = log.clock.principal(); + let mut rs: Vec = ["a", "b", "c", "d"].iter().map(|n| Reader::new(room, cp, n)).collect(); + let p: Vec = rs.iter().map(|r| r.m.principal()).collect(); + // Each principal publishes its KeyPackage before its join, as a page does. + for r in rs.iter_mut() { + let kp = r.m.key_package().unwrap(); + log.key_package(&kp); + } + log.event(Event::Join { subject: p[0], keyed: true }); + read_all(&mut log, &mut rs); + // A created the group only once the log returned its GroupInfo; the others follow it. + assert_eq!(rs[0].m.epoch(), Some(0)); + assert!(rs[0].seen.contains(&Received::Created)); + assert!(rs[1].m.following() && rs[2].m.following() && rs[3].m.following()); + log.event(Event::Join { subject: p[1], keyed: true }); + read_all(&mut log, &mut rs); + assert_eq!((rs[0].m.epoch(), rs[1].m.epoch()), (Some(1), Some(1))); + assert!(rs[1].seen.contains(&Received::Joined(1)) && !rs[1].m.following()); + // C and D follow every commit from outside, and ignore the Welcome that is not theirs. + assert!(rs[2].seen.contains(&Received::Followed) && rs[2].seen.contains(&Received::Ignored)); + assert_eq!(roster_names(&rs[2]), vec![0, 1]); + log.event(Event::Join { subject: p[2], keyed: true }); + log.event(Event::Join { subject: p[3], keyed: true }); + read_all(&mut log, &mut rs); + for r in &rs { + assert_eq!(r.m.epoch(), rs[0].m.epoch()); + assert_eq!(r.m.model().group().unwrap().roster, log.clock.model().group().unwrap().roster); + assert!(!r.m.staged()); + } + // Everyone opens everyone's sealed objects. + let e = rs[0].m.epoch().unwrap(); + let ns = |o: &Principal| TrackNamespace::from_strs([hex::encode(room), hex::encode(o), "video".to_string()]); + let name = TrackName::from_str("0"); + for i in 0..4 { + let pt = Plaintext::with_digest(format!("from {i}").into_bytes()); + let n = ns(&p[i]); + let s = rs[i].m.seal(&Outgoing { ns: &n, name: &name, group: 1, object: 0, priority: 9, plaintext: &pt, immutable: &[], mutable: &[] }).unwrap(); + assert_eq!(split_key_id(s.kid).0, e); + for (j, r) in rs.iter_mut().enumerate().filter(|(j, _)| *j != i) { + let inc = Incoming { ns: &n, name: &name, group: 1, object: 0, priority: 9, properties: &s.properties, payload: &s.payload }; + assert_eq!(r.m.open(&inc).unwrap().plaintext, pt, "{j} opens {i}"); + } + } + // A leaves: B commits A's removal, and A, reading on, is told it was removed. + log.event(Event::Leave { subject: p[0], reason: 1 }); + read_all(&mut log, &mut rs); + assert!(rs[0].seen.iter().any(|r| matches!(r, Received::Removed(_)))); + assert_eq!(rs[0].m.epoch(), None); + assert_eq!(rs[1].m.epoch(), Some(e + 1)); +} + +#[test] +fn of_two_commits_for_one_epoch_the_carriers_first_is_applied_by_everyone() { + let room = [0x64; 32]; + let mut log = Log::new(room); + let cp = log.clock.principal(); + let mut rs: Vec = ["a", "b", "c"].iter().map(|n| Reader::new(room, cp, n)).collect(); + let p: Vec = rs.iter().map(|r| r.m.principal()).collect(); + for r in rs.iter_mut() { + let kp = r.m.key_package().unwrap(); + log.key_package(&kp); + } + log.event(Event::Join { subject: p[0], keyed: true }); + read_all(&mut log, &mut rs); + log.event(Event::Join { subject: p[1], keyed: true }); + read_all(&mut log, &mut rs); + assert_eq!(rs[1].m.epoch(), Some(1)); + // C joins: A, the committer, stages its commit of C's Add but has not posted it when A + // leaves. B, reading the clock's Remove of A, is the committer of the two together. + log.event(Event::Join { subject: p[2], keyed: true }); + rs[0].read(&mut log, false); + let a_commit = rs[0].m.stage_commit(false).unwrap(); + log.event(Event::Leave { subject: p[0], reason: 4 }); + rs[1].read(&mut log, true); + assert!(rs[1].m.staged() || rs[1].m.epoch() == Some(2)); + // B's reached the carrier first; A's, for the same epoch, is refused there. + let late: Vec> = std::iter::once(a_commit.commit.clone()).chain(a_commit.welcome.clone()).collect(); + assert!(log.post(&late).is_err()); + rs[0].m.withdraw(); + read_all(&mut log, &mut rs); + assert_eq!(rs[0].m.epoch(), None, "A was removed by B's commit"); + assert_eq!((rs[1].m.epoch(), rs[2].m.epoch()), (Some(2), Some(2))); + assert_eq!(rs[2].m.model().group().unwrap().roster, log.clock.model().group().unwrap().roster); + // A's commit, carried anyway, is refused by those who applied B's: it is for an ended epoch. + assert!(rs[1].m.receive(&a_commit.commit).is_err()); +} + +#[test] +fn a_member_whose_commit_another_overtakes_drops_its_own_and_applies_the_other() { + let room = [0x65; 32]; + let mut log = Log::new(room); + let cp = log.clock.principal(); + let mut rs: Vec = ["a", "b", "c"].iter().map(|n| Reader::new(room, cp, n)).collect(); + let p: Vec = rs.iter().map(|r| r.m.principal()).collect(); + for r in rs.iter_mut() { + let kp = r.m.key_package().unwrap(); + log.key_package(&kp); + } + for i in 0..2 { + log.event(Event::Join { subject: p[i], keyed: true }); + read_all(&mut log, &mut rs); + } + // C joins: A stages the commit of C's Add. A leaves before A posts it, and B, reading + // the clock's Remove of A, stages a commit of both. + log.event(Event::Join { subject: p[2], keyed: true }); + rs[0].read(&mut log, false); + let a_commit = rs[0].m.stage_commit(false).unwrap(); + log.event(Event::Leave { subject: p[0], reason: 1 }); + rs[1].read(&mut log, false); + let b_commit = rs[1].m.stage_commit(false).unwrap(); + // A's reaches the carrier first; B, reading on, drops its own and applies A's. + let frames: Vec> = std::iter::once(a_commit.commit).chain(a_commit.welcome).collect(); + log.post(&frames).unwrap(); + rs[1].read(&mut log, false); + assert!(!rs[1].m.staged(), "B's own commit was withdrawn"); + assert_eq!(rs[1].seen.iter().rev().find(|r| matches!(r, Received::Epoch(_))), Some(&Received::Epoch(2))); + assert!(log.post(&[b_commit.commit]).is_err(), "the carrier refuses a commit for an ended epoch"); + // The clock proposed A's removal again for epoch 2, and B, the committer now, commits it. + read_all(&mut log, &mut rs); + assert_eq!(rs[0].m.epoch(), None); + assert_eq!((rs[1].m.epoch(), rs[2].m.epoch()), (Some(3), Some(3))); + assert_eq!(rs[2].m.model().group().unwrap().roster, log.clock.model().group().unwrap().roster); +} + +#[test] +fn a_staged_create_the_carrier_refuses_is_withdrawn_and_the_next_principal_creates() { + let room = [0x66; 32]; + let mut log = Log::new(room); + let cp = log.clock.principal(); + let mut rs: Vec = ["a", "b"].iter().map(|n| Reader::new(room, cp, n)).collect(); + let p: Vec = rs.iter().map(|r| r.m.principal()).collect(); + for r in rs.iter_mut() { + let kp = r.m.key_package().unwrap(); + log.key_package(&kp); + } + log.event(Event::Join { subject: p[0], keyed: true }); + log.event(Event::Join { subject: p[1], keyed: true }); + rs[0].read(&mut log, false); + let info = rs[0].m.stage_create().unwrap(); + // A leaves before its GroupInfo reaches the carrier: the room no longer owes it a group. + log.event(Event::Leave { subject: p[0], reason: 4 }); + assert!(matches!(log.post(&[info]), Err(Error::Refused(Refusal::ProposalMismatch)))); + rs[0].m.withdraw(); + read_all(&mut log, &mut rs); + assert_eq!(rs[1].m.epoch(), Some(0)); + assert_eq!(rs[0].m.epoch(), None); + assert_eq!(rs[0].m.model().group().unwrap().roster, [(0, p[1])].into()); +} -- 2.54.0 (Apple Git-157) From d01fbd9033321e997f57b00659f4fb140a25eb2f Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 08:28:26 -0400 Subject: [PATCH 03/14] joule-moq-web: feature e2ee, the member of an end-to-end encrypted room for a page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E2eeMember wraps joule-moq-mls's RoomMember: KeyPackage, lifecycle events and MLS messages in the carrier's order, staged create and commit, seal of an encoded frame (inner digest, envelope flag in the Immutable Properties, delivery timeout beside them) and open through the §6 checks, with the reason or the epoch to wait for. TrackSigner signs a sealed object's receipt over its sealed bytes (signSealed, no WAI_RECEIPT), and UniItem gives an object's property list as it was carried. e2eeCheckRecord checks WRE1. The e2ee build is a second wasm (joule_moq_web_e2ee) that a page loads for such a room alone; [profile.wasm] (release, LTO, one codegen unit, opt-level s) keeps it smaller. Co-Authored-By: Claude Opus 5.5 --- joule-code/joule-code-rs/Cargo.toml | 9 + .../crates/joule-moq-web/Cargo.toml | 9 + .../crates/joule-moq-web/src/e2ee.rs | 323 ++++++++++++++++++ .../crates/joule-moq-web/src/e2ee_tests.rs | 240 +++++++++++++ .../crates/joule-moq-web/src/lib.rs | 43 ++- 5 files changed, 622 insertions(+), 2 deletions(-) create mode 100644 joule-code/joule-code-rs/crates/joule-moq-web/src/e2ee.rs create mode 100644 joule-code/joule-code-rs/crates/joule-moq-web/src/e2ee_tests.rs diff --git a/joule-code/joule-code-rs/Cargo.toml b/joule-code/joule-code-rs/Cargo.toml index af6d8f1f3..658e26466 100644 --- a/joule-code/joule-code-rs/Cargo.toml +++ b/joule-code/joule-code-rs/Cargo.toml @@ -50,3 +50,12 @@ serde_json = "1" base64 = "0.22" hex = "0.4" thiserror = "2" + +# The browser builds of joule-moq-web (wasm32-unknown-unknown): release, with link-time +# optimisation across crates and one codegen unit, optimised for size, since a page loads +# every byte. No crate built for the host uses it. +[profile.wasm] +inherits = "release" +lto = true +codegen-units = 1 +opt-level = "s" diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/Cargo.toml b/joule-code/joule-code-rs/crates/joule-moq-web/Cargo.toml index 1b3bcd378..afd8aa4cb 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/Cargo.toml +++ b/joule-code/joule-code-rs/crates/joule-moq-web/Cargo.toml @@ -18,3 +18,12 @@ serde_json = "1" hex = "0.4" # Pinned to the wasm-bindgen CLI the build runs: the two must match exactly. wasm-bindgen = "=0.2.127" +# The member of an end-to-end encrypted room (feature `e2ee`): MLS and the room's keys. +joule-moq-mls = { path = "../joule-moq-mls", optional = true } + +[features] +# The member of an end-to-end encrypted room (WAI confidentiality): its MLS group, sealing +# and opening. Built as a second wasm, `joule_moq_web_e2ee`, that a page loads for such a +# room alone, so a page in any other room loads none of it. `joule-moq-mls/js` takes the +# browser's random generator and clock; on the host it changes nothing. +e2ee = ["dep:joule-moq-mls", "joule-moq-mls/js", "joule-moq/secure-objects"] diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/src/e2ee.rs b/joule-code/joule-code-rs/crates/joule-moq-web/src/e2ee.rs new file mode 100644 index 000000000..99d81bff2 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-web/src/e2ee.rs @@ -0,0 +1,323 @@ +//! The member of an end-to-end encrypted room (WAI `extensions/confidentiality.md`), for a +//! page: feature `e2ee`, built as a wasm of its own (`joule_moq_web_e2ee`) that a page loads +//! for such a room alone. +//! +//! - [`check_record`]: the room's confidentiality record (`WRE1`, §3) checked against the +//! room id, the descriptor's authority and its recording mode. +//! - [`E2eeMember`]: `joule-moq-mls`'s [`RoomMember`], whose MLS state and every key it +//! derives live in the page's memory alone and are never written anywhere. It reads the +//! room's lifecycle and its MLS messages in the order the room's carrier gives them (§5.1), +//! follows the group from outside until a Welcome makes it a member, creates the group or +//! commits when the rule names it (§5.4, §5.5), staging each until the carrier returns it, +//! seals each encoded frame before it is published (§4.9), and opens each object it +//! receives through the receiving checks in their order (§6), so that a payload that does +//! not open never reaches a decoder (§10). + +use crate::{js, namespace_of}; +use joule_moq::carriage::prop as wai_prop; +use joule_moq::draft22::kvp::{prop, KeyValuePair, Properties}; +use joule_moq::secure_objects::room::{split_key_id, Incoming, NotOpened, Outgoing}; +use joule_moq::secure_objects::{Plaintext, Suite}; +use joule_moq::TrackName; +use joule_moq_mls::group::Error as MlsError; +use joule_moq_mls::{Event, Received, Record, RoomMember}; +use serde_json::{json, Value}; +use wasm_bindgen::prelude::*; + +fn key32(b: &[u8], what: &str) -> Result<[u8; 32], JsError> { + b.try_into().map_err(|_| JsError::new(&format!("{what} is 32 bytes"))) +} + +/// The confidentiality record `record` checked as §3.3 orders it, for the room `room` +/// whose descriptor names `authority` and the recording `mode`: `{"ok": true, "suite"}` or +/// `{"ok": false, "reason"}` with the extension's reason. +pub fn record_outcome(record: &[u8], room: &[u8; 32], authority: &[u8; 32], mode: u8) -> Value { + match Record::check(record, room, authority, mode) { + Ok((r, _)) => json!({"ok": true, "suite": r.suite, "mls_suite": r.mls_suite}), + Err(r) => json!({"ok": false, "reason": r.code()}), + } +} + +#[wasm_bindgen(js_name = e2eeCheckRecord)] +pub fn check_record(record: &[u8], room: &[u8], authority: &[u8], mode: u8) -> Result { + Ok(record_outcome(record, &key32(room, "a room id")?, &key32(authority, "an authority's key")?, mode).to_string()) +} + +fn error_json(e: &MlsError) -> Value { + match e { + MlsError::Refused(r) => json!({"ok": false, "reason": r.code()}), + MlsError::Mls(m) => json!({"ok": false, "error": "mls", "detail": m}), + MlsError::NotNow(m) => json!({"ok": false, "error": "not_now", "detail": m}), + } +} + +/// A sealed object, for a page to sign and send: its properties encoded (MOQT §8.3), its +/// payload, and its KID. +#[wasm_bindgen] +pub struct SealedOut { + properties: Vec, + payload: Vec, + kid: u64, +} + +#[wasm_bindgen] +impl SealedOut { + /// The object's property list: the Immutable Properties with the Key ID and the + /// envelope flag, and the delivery timeout beside them. + pub fn properties(&mut self) -> Vec { + std::mem::take(&mut self.properties) + } + /// The sealed payload, ciphertext and tag, moved out. + pub fn payload(&mut self) -> Vec { + std::mem::take(&mut self.payload) + } + #[wasm_bindgen(getter)] + pub fn kid(&self) -> u64 { + self.kid + } +} + +/// What opening an object gave: [`OpenOut::json`] says how it went, and when it opened, +/// [`OpenOut::payload`] is the original payload. +#[wasm_bindgen] +pub struct OpenOut { + json: String, + payload: Vec, +} + +#[wasm_bindgen] +impl OpenOut { + /// `{"ok": true, "epoch", "leaf", "sender", "digest"}` (`digest`: whether the plaintext + /// carried `WAI_INNER_DIGEST`, checked); `{"ok": false, "reason"}`, refused with the + /// extension's reason; or `{"ok": false, "pending": }`, sealed in an epoch this + /// member has not reached, to be held until it applies the commit that opens it (§5.8). + #[wasm_bindgen(getter)] + pub fn json(&self) -> String { + self.json.clone() + } + /// The original payload, moved out; empty unless it opened. + pub fn payload(&mut self) -> Vec { + std::mem::take(&mut self.payload) + } +} + +/// A commit staged for the carrier: the commit and, when it adds members, their Welcome. +#[wasm_bindgen] +pub struct StagedCommit { + commit: Vec, + welcome: Option>, + epoch: u64, +} + +#[wasm_bindgen] +impl StagedCommit { + pub fn commit(&self) -> Vec { + self.commit.clone() + } + pub fn welcome(&self) -> Option> { + self.welcome.clone() + } + /// The epoch the commit opens. + #[wasm_bindgen(getter)] + pub fn epoch(&self) -> u64 { + self.epoch + } +} + +/// A principal of an end-to-end encrypted room, as a member of its group. +#[wasm_bindgen] +pub struct E2eeMember { + m: RoomMember, +} + +impl E2eeMember { + pub fn member(&self) -> &RoomMember { + &self.m + } + + pub fn member_mut(&mut self) -> &mut RoomMember { + &mut self.m + } + + /// Seal `payload` as object (`group`, `object`) of the track `name` in the namespace + /// `ns` (§4.9), under this member's latest epoch: with `WAI_INNER_DIGEST` when `digest`, + /// the envelope flag in the Immutable Properties when `envelope`, and an + /// OBJECT_DELIVERY_TIMEOUT beside them when `timeout_ms` is not 0. + pub fn seal_object(&self, ns: &joule_moq::TrackNamespace, name: &TrackName, group: u64, object: u64, priority: u8, payload: &[u8], digest: bool, envelope: bool, timeout_ms: u64) -> Result<(Properties, Vec, u64), String> { + let pt = if digest { Plaintext::with_digest(payload.to_vec()) } else { Plaintext { payload: payload.to_vec(), ..Default::default() } }; + let immutable: Vec = if envelope { vec![KeyValuePair::varint(wai_prop::WAI_ENVELOPE, 1)] } else { vec![] }; + let mutable: Vec = if timeout_ms > 0 { vec![KeyValuePair::varint(prop::OBJECT_DELIVERY_TIMEOUT, timeout_ms)] } else { vec![] }; + let o = Outgoing { ns, name, group, object, priority, plaintext: &pt, immutable: &immutable, mutable: &mutable }; + let s = self.m.seal(&o).map_err(|e| e.to_string())?; + Ok((s.properties, s.payload, s.kid)) + } + + /// Open an object through the checks of §6, in their order. + pub fn open_object(&mut self, ns: &joule_moq::TrackNamespace, name: &TrackName, group: u64, object: u64, priority: u8, properties: &Properties, payload: &[u8]) -> (Value, Vec) { + let inc = Incoming { ns, name, group, object, priority, properties, payload }; + match self.m.open(&inc) { + Ok(o) => (json!({"ok": true, "epoch": o.epoch, "leaf": o.leaf, "sender": hex::encode(o.sender), "digest": o.plaintext.inner_digest.is_some()}), o.plaintext.payload), + Err(NotOpened::Refused(r)) => (json!({"ok": false, "reason": r.code()}), Vec::new()), + Err(NotOpened::Pending(e)) => (json!({"ok": false, "pending": e}), Vec::new()), + } + } + + /// The member as a page shows it and a test reads it. + pub fn state_json(&self) -> Value { + let k = self.m.keyring(); + let latest = k.latest(); + let roster = latest.and_then(|e| k.roster(e)).map(|r| r.iter().map(|(l, p)| (l.to_string(), json!(hex::encode(p)))).collect::>()); + let g = self.m.model().group(); + json!({ + "epoch": self.m.epoch(), + "joined": k.joined_at(), + "removed": k.removed(), + "held": k.held(), + "leaf": k.my_leaf().filter(|_| self.m.epoch().is_some()), + "roster": roster, + "group": g.map(|g| json!({ + "generation": g.generation, + "epoch": g.epoch, + "roster": g.roster.iter().map(|(l, p)| (l.to_string(), json!(hex::encode(p)))).collect::>(), + })), + "pending": self.m.model().pending().len(), + "staged": self.m.staged(), + "following": self.m.following(), + "committer": self.m.is_committer(), + }) + } +} + +#[wasm_bindgen] +impl E2eeMember { + /// The principal of the 32-byte Ed25519 `seed` in the room `room`, whose clock's key is + /// `clock`, sealing under the record's `suite` (`0x0004` or `0x0005`). + #[wasm_bindgen(constructor)] + pub fn new(room: &[u8], clock: &[u8], seed: &[u8], suite: u16) -> Result { + let suite = match Suite::from_id(suite) { + Some(s @ (Suite::AesGcm128Sha256 | Suite::AesGcm256Sha512)) => s, + _ => return Err(JsError::new("a room's suite is 0x0004 or 0x0005")), + }; + let key = ed25519_dalek::SigningKey::from_bytes(&key32(seed, "a seed")?); + Ok(E2eeMember { m: RoomMember::new(key32(room, "a room id")?, key32(clock, "a clock's key")?, key, suite) }) + } + + /// A KeyPackage for the clock (the keying draft §4.7), framed; its private keys stay + /// here. + #[wasm_bindgen(js_name = keyPackage)] + pub fn key_package(&mut self) -> Result, JsError> { + self.m.key_package().map_err(js) + } + + /// A lifecycle event of the room, in `joule-moq-mls`'s form: + /// `{"event": "join", "subject": , "keyed": bool}`, + /// `{"event": "leave", "subject", "reason"}` or + /// `{"event": "grant_exchange", "subject", "keyed_before", "keyed_after"}`. + pub fn event(&mut self, ev: &str) -> Result<(), JsError> { + let ev: Event = serde_json::from_str(ev).map_err(js)?; + self.m.event(&ev); + Ok(()) + } + + /// A framed MLS message from the room's carrier, in its order: `{"ok": true, + /// "received": "proposal" | "epoch" | "removed" | "joined" | "created" | "followed" | + /// "ignored", "epoch"}`, or why it was refused. + pub fn receive(&mut self, frame: &[u8]) -> String { + let v = match self.m.receive(frame) { + Ok(r) => { + let (what, epoch) = match r { + Received::Proposal => ("proposal", None), + Received::Epoch(e) => ("epoch", Some(e)), + Received::Removed(e) => ("removed", Some(e)), + Received::Joined(e) => ("joined", Some(e)), + Received::Created => ("created", Some(0)), + Received::Followed => ("followed", None), + Received::Ignored => ("ignored", None), + }; + json!({"ok": true, "received": what, "epoch": epoch}) + } + Err(e) => error_json(&e), + }; + v.to_string() + } + + /// Whether the room owes this principal the group's creation now (§5.4). + #[wasm_bindgen(js_name = owesCreate)] + pub fn owes_create(&self) -> bool { + self.m.owes_create() + } + + /// The group made and held until the carrier returns its GroupInfo: the GroupInfo. + #[wasm_bindgen(js_name = stageCreate)] + pub fn stage_create(&mut self) -> Result, JsError> { + self.m.stage_create().map_err(js) + } + + /// Whether this member should commit now: it is the committer (§5.5), the clock has + /// proposals pending, and nothing of its own is staged. + #[wasm_bindgen(js_name = shouldCommit)] + pub fn should_commit(&self) -> bool { + !self.m.staged() && self.m.is_committer() && self.m.clock_pending() + } + + /// The commit of the clock's pending proposals, held until the carrier returns it. + #[wasm_bindgen(js_name = stageCommit)] + pub fn stage_commit(&mut self) -> Result { + let c = self.m.stage_commit(false).map_err(js)?; + Ok(StagedCommit { commit: c.commit, welcome: c.welcome, epoch: c.epoch }) + } + + /// Drop what is staged: the carrier refused it. + pub fn withdraw(&mut self) { + self.m.withdraw(); + } + + pub fn staged(&self) -> bool { + self.m.staged() + } + + /// The member's current epoch, or nothing while it is not a member. + pub fn epoch(&self) -> Option { + self.m.epoch() + } + + /// Delete `epoch`'s keys (§5.8): its objects are refused `epoch_not_joined` from now on. + pub fn forget(&mut self, epoch: u64) { + self.m.keyring_mut().forget(epoch); + } + + /// Seal for a page: `ns` a JSON array of the namespace's fields. + #[allow(clippy::too_many_arguments)] + pub fn seal(&self, ns: &str, name: &str, group: u64, object: u64, priority: u8, payload: &[u8], digest: bool, envelope: bool, timeout_ms: u64) -> Result { + let ns = namespace_of(&serde_json::from_str(ns).map_err(js)?).map_err(js)?; + let (p, payload, kid) = self.seal_object(&ns, &TrackName::from_str(name), group, object, priority, payload, digest, envelope, timeout_ms).map_err(js)?; + Ok(SealedOut { properties: p.to_bytes().map_err(|e| js(e.to_string()))?, payload, kid }) + } + + /// Open for a page: `properties` as [`crate::UniItem::properties`] gives them. + #[allow(clippy::too_many_arguments)] + pub fn open(&mut self, ns: &str, name: &str, group: u64, object: u64, priority: u8, properties: &[u8], payload: &[u8]) -> Result { + let ns = namespace_of(&serde_json::from_str(ns).map_err(js)?).map_err(js)?; + // A property list that does not read is §6 step 1's. + let Ok(props) = Properties::decode(properties, "properties") else { + return Ok(OpenOut { json: json!({"ok": false, "reason": "properties_malformed"}).to_string(), payload: Vec::new() }); + }; + let (v, payload) = self.open_object(&ns, &TrackName::from_str(name), group, object, priority, &props, payload); + Ok(OpenOut { json: v.to_string(), payload }) + } + + pub fn state(&self) -> String { + self.state_json().to_string() + } +} + +/// The epoch and leaf index a KID names (§5.7), as `[epoch, leaf]`. +#[wasm_bindgen(js_name = e2eeSplitKid)] +pub fn split_kid(kid: u64) -> Vec { + let (e, l) = split_key_id(kid); + vec![e, l as u64] +} + +#[cfg(test)] +#[path = "e2ee_tests.rs"] +mod tests; diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/src/e2ee_tests.rs b/joule-code/joule-code-rs/crates/joule-moq-web/src/e2ee_tests.rs new file mode 100644 index 000000000..8c6d5b00b --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-web/src/e2ee_tests.rs @@ -0,0 +1,240 @@ +//! The e2ee surface on the host, driven as a page drives it: two members and the room's +//! clock exchange every message through one ordered log, each member seals what it publishes, +//! signs the receipt over the sealed bytes, and the other reads the stream as the page reads +//! it, verifies the receipt and opens the object; every check of §6 an object can fail is +//! refused with its reason. + +use super::*; +use crate::{Signer, UniDecoder, Verifier}; +use ed25519_dalek::SigningKey; +use joule_moq_mls::Clock; + +const ROOM: [u8; 32] = [0x71; 32]; + +fn seed(i: u8) -> [u8; 32] { + [i; 32] +} + +fn principal(i: u8) -> [u8; 32] { + SigningKey::from_bytes(&seed(i)).verifying_key().to_bytes() +} + +enum Entry { + Event(String), + Mls(Vec), +} + +/// The room service, reduced: its clock and its log. +struct Service { + clock: Clock, + log: Vec, +} + +impl Service { + fn outbox(&mut self) { + for m in self.clock.take_outbox() { + self.log.push(Entry::Mls(m)); + } + } + + fn event(&mut self, json: &str) { + let ev: Event = serde_json::from_str(json).unwrap(); + self.clock.event(&ev).unwrap(); + self.log.push(Entry::Event(json.to_string())); + self.outbox(); + } + + fn post(&mut self, frames: &[Vec]) -> bool { + if self.clock.observe(&frames[0]).is_err() { + return false; + } + for f in frames { + self.log.push(Entry::Mls(f.clone())); + } + self.outbox(); + true + } +} + +/// A page's member reading the log from where it stopped, creating and committing when +/// the room calls on it. +fn pump(svc: &mut Service, m: &mut E2eeMember, at: &mut usize) { + loop { + while *at < svc.log.len() { + match &svc.log[*at] { + Entry::Event(j) => m.event(j).unwrap(), + Entry::Mls(f) => { + let f = f.clone(); + let v: Value = serde_json::from_str(&m.receive(&f)).unwrap(); + assert_eq!(v["ok"], true, "{v}"); + } + } + *at += 1; + } + let n = svc.log.len(); + if m.owes_create() { + let info = m.stage_create().unwrap(); + if !svc.post(&[info]) { + m.withdraw(); + } + } else if m.should_commit() { + let c = m.stage_commit().unwrap(); + let frames: Vec> = std::iter::once(c.commit()).chain(c.welcome()).collect(); + if !svc.post(&frames) { + m.withdraw(); + } + } + if svc.log.len() == n { + return; + } + } +} + +fn ns_json(owner: &[u8; 32], kind: &str) -> String { + json!([hex::encode(ROOM), hex::encode(owner), kind]).to_string() +} + +/// A media stream as the page's publisher writes it: sealed, its receipt over the sealed +/// bytes; read back as the page's subscriber reads it. +fn publish(m: &E2eeMember, signer: &mut Signer, owner: u8, g: u64, envelope: &[u8]) -> (Vec, Vec) { + let mut s = m.seal(&ns_json(&principal(owner), "video"), "0", g, 0, 96, envelope, true, true, 2000).unwrap(); + let props = Properties::decode(&s.properties(), "p").unwrap(); + let signed = signer.sign_sealed(g, &s.payload(), props, "2026-10-20T00:00:00Z", "2026-10-20T00:00:00Z").unwrap(); + (signed.receipts_stream, signed.media_stream) +} + +/// The object of a media stream, as `UniDecoder` gives it to the page. +fn read(stream: &[u8]) -> (u64, u8, Vec, Vec) { + let mut d = UniDecoder::new(1 << 20); + d.push(stream); + let h = d.next().unwrap().unwrap(); + let hj: Value = serde_json::from_str(&h.json()).unwrap(); + let mut o = d.next().unwrap().unwrap(); + assert_eq!(o.kind(), "object"); + let props = o.properties(); + assert_eq!(hj["header"], "subgroup"); + (hj["group_id"].as_u64().unwrap(), hj["publisher_priority"].as_u64().unwrap() as u8, props, o.payload()) +} + +fn open(m: &mut E2eeMember, owner: u8, g: u64, obj: u64, prio: u8, props: &[u8], payload: &[u8]) -> (Value, Vec) { + let mut out = m.open(&ns_json(&principal(owner), "video"), "0", g, obj, prio, props, payload).unwrap(); + (serde_json::from_str(&out.json()).unwrap(), out.payload()) +} + +#[test] +fn two_pages_key_seal_sign_open_and_refuse_through_one_log() { + let clock = SigningKey::from_bytes(&[0xc0; 32]); + let mut svc = Service { clock: Clock::new(ROOM, clock.clone()), log: Vec::new() }; + let cp = clock.verifying_key().to_bytes(); + let mut a = E2eeMember::new(&ROOM, &cp, &seed(1), 4).unwrap(); + let mut b = E2eeMember::new(&ROOM, &cp, &seed(2), 4).unwrap(); + let mut c = E2eeMember::new(&ROOM, &cp, &seed(3), 4).unwrap(); + let (mut at_a, mut at_b, mut at_c) = (0, 0, 0); + for m in [&mut a, &mut b] { + let kp = m.key_package().unwrap(); + svc.clock.key_package(&kp).unwrap(); + } + let join = |p: [u8; 32]| json!({"event": "join", "subject": hex::encode(p), "keyed": true}).to_string(); + svc.event(&join(principal(1))); + pump(&mut svc, &mut a, &mut at_a); + assert_eq!(a.epoch(), Some(0)); + svc.event(&join(principal(2))); + for _ in 0..2 { + pump(&mut svc, &mut a, &mut at_a); + pump(&mut svc, &mut b, &mut at_b); + } + assert_eq!((a.epoch(), b.epoch()), (Some(1), Some(1))); + let sa: Value = serde_json::from_str(&a.state()).unwrap(); + assert_eq!(sa["roster"].as_object().unwrap().len(), 2); + + // A publishes; B reads the stream, verifies the receipt over the sealed bytes, opens. + let mut signer = Signer::new(seed(1), 2, 20, 21, 96, "a".into(), Some(2000)); + let mut verifier = Verifier::new(principal(1), 2).unwrap(); + let envelope = b"WAI1 an envelope".to_vec(); + let (rs, ms) = publish(&a, &mut signer, 1, 0, &envelope); + let mut rd = UniDecoder::new(1 << 20); + rd.push(&rs); + rd.next().unwrap(); + let mut ro = rd.next().unwrap().unwrap(); + assert_eq!(verifier.receipts(0, &ro.payload(), 0)["ok"], true); + let (g, prio, props, payload) = read(&ms); + assert_eq!(verifier.object(g, 0, &payload)["ok"], true, "the receipt is over the sealed bytes"); + assert!(!payload.windows(4).any(|w| w == b"WAI1"), "a relay sees no plaintext"); + // The Key ID rides inside the Immutable Properties, the delivery timeout outside. + let p = Properties::decode(&props, "p").unwrap(); + assert_eq!(p.find(prop::OBJECT_DELIVERY_TIMEOUT).unwrap().and_then(|k| k.as_varint()), Some(2000)); + assert!(p.0.iter().all(|k| k.ty != wai_prop::WAI_RECEIPT)); + // Each way §6 refuses, on copies of the object as a relay could present them, before + // the object itself opens; then the same object again is a replay. + let mut flipped = payload.clone(); + flipped[3] ^= 1; + assert_eq!(open(&mut b, 1, g, 0, prio, &props, &flipped).0["reason"], "bad_tag"); + assert_eq!(open(&mut b, 1, g, 0, prio + 1, &props, &payload).0["reason"], "bad_tag"); + assert_eq!(open(&mut b, 1, g, 1 << 32, prio, &props, &payload).0["reason"], "object_id_too_large"); + assert_eq!(open(&mut b, 1, g, 0, prio, &[0xff], &payload).0["reason"], "properties_malformed"); + let bare = Properties::new().to_bytes().unwrap(); + assert_eq!(open(&mut b, 1, g, 0, prio, &bare, &payload).0["reason"], "key_id_missing"); + let mut twice = p.clone(); + twice.push(KeyValuePair::varint(joule_moq::secure_objects::KEY_ID_PROPERTY, 1)); + assert_eq!(open(&mut b, 1, g, 0, prio, &twice.to_bytes().unwrap(), &payload).0["reason"], "properties_malformed"); + // A's KID on B's own track: not its owner. + let mut out = b.open(&ns_json(&principal(2), "video"), "0", g, 0, prio, &props, &payload).unwrap(); + assert_eq!(serde_json::from_str::(&out.json()).unwrap()["reason"], "sender_not_owner"); + assert!(out.payload().is_empty()); + // A KID naming an empty leaf, and one naming an epoch B has not reached. + let kid_of = |e: u64, l: u32| { + let sip = joule_moq::secure_objects::immutable_properties(joule_moq::secure_objects::room::key_id(e, l).unwrap(), &[]); + joule_moq::secure_objects::object_properties(&sip, &[]).to_bytes().unwrap() + }; + assert_eq!(open(&mut b, 1, g, 0, prio, &kid_of(1, 7), &payload).0["reason"], "key_id_unknown"); + assert_eq!(open(&mut b, 1, g, 0, prio, &kid_of(9, 0), &payload).0["pending"], 9); + + let (v, pt) = open(&mut b, 1, g, 0, prio, &props, &payload); + assert_eq!((v["ok"].clone(), v["epoch"].clone(), v["digest"].clone()), (json!(true), json!(1), json!(true))); + assert_eq!(pt, envelope); + + assert_eq!(open(&mut b, 1, g, 0, prio, &props, &payload).0["reason"], "replayed"); + + // C joins late: it follows the group from outside, joins by its Welcome, and opens + // nothing sealed before its join. + let kp = c.key_package().unwrap(); + svc.clock.key_package(&kp).unwrap(); + svc.event(&join(principal(3))); + for _ in 0..3 { + pump(&mut svc, &mut a, &mut at_a); + pump(&mut svc, &mut b, &mut at_b); + pump(&mut svc, &mut c, &mut at_c); + } + assert_eq!((a.epoch(), b.epoch(), c.epoch()), (Some(2), Some(2), Some(2))); + assert_eq!(open(&mut c, 1, g, 0, prio, &props, &payload).0["reason"], "epoch_not_joined"); + let (_, ms2) = publish(&a, &mut signer, 1, 1, &envelope); + let (g2, prio2, props2, payload2) = read(&ms2); + assert_eq!(open(&mut c, 1, g2, 0, prio2, &props2, &payload2).1, envelope); + + // B leaves: A commits its removal; B, reading on, is told so and opens no later epoch. + svc.event(&json!({"event": "leave", "subject": hex::encode(principal(2)), "reason": 1}).to_string()); + for _ in 0..2 { + pump(&mut svc, &mut a, &mut at_a); + pump(&mut svc, &mut b, &mut at_b); + pump(&mut svc, &mut c, &mut at_c); + } + assert_eq!((a.epoch(), b.epoch(), c.epoch()), (Some(3), None, Some(3))); + let (_, ms3) = publish(&a, &mut signer, 1, 2, &envelope); + let (g3, prio3, props3, payload3) = read(&ms3); + assert_eq!(open(&mut b, 1, g3, 0, prio3, &props3, &payload3).0["reason"], "removed_before_epoch"); + assert_eq!(open(&mut c, 1, g3, 0, prio3, &props3, &payload3).1, envelope); + // Keys deleted (§5.8): that epoch is no longer joined. + c.forget(2); + assert_eq!(serde_json::from_str::(&b.state()).unwrap()["removed"], 3); + assert_eq!(open(&mut c, 1, g2 + 10, 0, prio2, &props2, &payload2).0["reason"], "epoch_not_joined"); +} + +#[test] +fn the_record_is_checked_as_the_extension_orders_it() { + let auth = SigningKey::from_bytes(&[0xa0; 32]); + let ap = auth.verifying_key().to_bytes(); + let r = Record::sign(ROOM, 4, 1, &auth).to_bytes(); + assert_eq!(record_outcome(&r, &ROOM, &ap, 0), json!({"ok": true, "suite": 4, "mls_suite": 1})); + assert_eq!(record_outcome(&r, &ROOM, &ap, 1)["reason"], "recording_refused"); + assert_eq!(record_outcome(&r, &[0; 32], &ap, 0)["reason"], "wrong_room"); +} diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/src/lib.rs b/joule-code/joule-code-rs/crates/joule-moq-web/src/lib.rs index 7dc8711c6..c78a423cf 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/src/lib.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-web/src/lib.rs @@ -366,6 +366,7 @@ pub struct UniItem { kind: String, json: String, payload: Vec, + properties: Option, } #[wasm_bindgen] @@ -382,6 +383,13 @@ impl UniItem { pub fn payload(&mut self) -> Vec { std::mem::take(&mut self.payload) } + + /// An object's property list as the stream carried it, encoded (MOQT §8.3): what a + /// member opens a sealed object with, its Immutable Properties byte for byte. Empty for + /// an object with none, and for an item that is not an object. + pub fn properties(&self) -> Vec { + self.properties.as_ref().and_then(|p| p.to_bytes().ok()).unwrap_or_default() + } } fn item_of(e: UniEvent) -> UniItem { @@ -395,6 +403,7 @@ fn item_of(e: UniEvent) -> UniItem { }) .to_string(), payload: o.payload, + properties: Some(o.properties), }, UniEvent::FetchEntry(FetchEntry::Object(o)) => UniItem { kind: "fetch_object".into(), @@ -406,6 +415,7 @@ fn item_of(e: UniEvent) -> UniItem { }) .to_string(), payload: o.payload, + properties: o.properties, }, other => { let v = json::uni_event(&other); @@ -416,7 +426,7 @@ fn item_of(e: UniEvent) -> UniItem { UniEvent::Padding(_) => "padding", _ => "other", }; - UniItem { kind: kind.into(), json: v.to_string(), payload: Vec::new() } + UniItem { kind: kind.into(), json: v.to_string(), payload: Vec::new(), properties: None } } } } @@ -532,6 +542,19 @@ impl Signer { /// object of the same group; and the media object carrying its `WAI_RECEIPT` property /// (moq-streaming-format §5) and the `WAI_ENVELOPE` flag. pub fn sign(&mut self, group: u64, payload: &[u8], started: &str, ended: &str) -> Result { + self.sign_with(group, payload, None, started, ended) + } + + /// Group `group` of a sealed media track (WAI confidentiality §4, §8.1): `payload` is + /// the sealed object's payload, ciphertext and tag, and the receipt is over those bytes; + /// `properties` are the object's as its seal made them (the Immutable Properties with + /// its Key ID and envelope flag, the delivery timeout beside them). A sealed object + /// carries no `WAI_RECEIPT` (§4.3): its receipt rides the receipts track alone. + pub fn sign_sealed(&mut self, group: u64, payload: &[u8], properties: Properties, started: &str, ended: &str) -> Result { + self.sign_with(group, payload, Some(properties), started, ended) + } + + fn sign_with(&mut self, group: u64, payload: &[u8], sealed: Option, started: &str, ended: &str) -> Result { let hash = ObjectReceipt::hash_payload(payload); let mut b = ReceiptBuilder::new(TrackAlias(self.receipt_alias), GroupId(group)); b.add_labelled_object(hash, 0, self.origin.clone(), started, UNMETERED.to_vec()).map_err(|e| e.to_string())?; @@ -561,7 +584,10 @@ impl Signer { mrl_total_layers: 0, wai_envelope: true, }; - let props = object_properties(&header, &ObjectExtras { receipt: None, wai_receipt: Some(wai), delivery_timeout_ms: self.object_timeout_ms }).map_err(|e| e.to_string())?; + let props = match sealed { + Some(p) => p, + None => object_properties(&header, &ObjectExtras { receipt: None, wai_receipt: Some(wai), delivery_timeout_ms: self.object_timeout_ms }).map_err(|e| e.to_string())?, + }; let sh = SubgroupHeader { track_alias: self.media_alias, group_id: group, subgroup_id: SubgroupId::Zero, publisher_priority: Some(self.priority), properties: true, end_of_group: true, first_object: true }; let mut media_stream = Vec::with_capacity(payload.len() + 256); sh.encode(&mut media_stream); @@ -633,6 +659,16 @@ impl TrackSigner { let s = self.inner.sign(group, payload, started, ended).map_err(js)?; Ok(SignedGroup { receipts_stream: s.receipts_stream, media_stream: s.media_stream, content_hash: hex::encode(s.content_hash) }) } + + /// [`Signer::sign_sealed`] for a page: `properties` as a sealed object's + /// [`e2ee::SealedOut::properties`] gives them. + #[cfg(feature = "e2ee")] + #[wasm_bindgen(js_name = signSealed)] + pub fn sign_sealed(&mut self, group: u64, payload: &[u8], properties: &[u8], started: &str, ended: &str) -> Result { + let props = Properties::decode(properties, "properties").map_err(|e| JsError::new(&wire_error(&e)))?; + let s = self.inner.sign_sealed(group, payload, props, started, ended).map_err(js)?; + Ok(SignedGroup { receipts_stream: s.receipts_stream, media_stream: s.media_stream, content_hash: hex::encode(s.content_hash) }) + } } /// The public key of a 32-byte Ed25519 seed. @@ -903,5 +939,8 @@ pub fn header_of(bytes: &[u8]) -> Option { } } +#[cfg(feature = "e2ee")] +pub mod e2ee; + #[cfg(test)] mod tests; -- 2.54.0 (Apple Git-157) From 80f8bcc99bb2e740535813aa525f356e2e6c1a92 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 08:30:25 -0400 Subject: [PATCH 04/14] joule-moq-mls: Clock::key_package_if keeps a KeyPackage only from a principal the room admitted Co-Authored-By: Claude Opus 5.5 --- .../crates/joule-moq-mls/src/group.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/joule-code/joule-code-rs/crates/joule-moq-mls/src/group.rs b/joule-code/joule-code-rs/crates/joule-moq-mls/src/group.rs index 2769ce565..0cd70b5db 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-mls/src/group.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-mls/src/group.rs @@ -647,10 +647,22 @@ impl Clock { /// A KeyPackage a joining principal published: kept for its Add (§5.4). pub fn key_package(&mut self, framed_kp: &[u8]) -> Result { - let (_, msg) = unframe(framed_kp)?; + self.key_package_if(framed_kp, |_| true) + } + + /// [`Clock::key_package`] for a principal `allowed` admits (a room service keeps one + /// only from a principal it granted): `NotNow`, and nothing kept, for any other. + pub fn key_package_if(&mut self, framed_kp: &[u8], allowed: impl FnOnce(&Principal) -> bool) -> Result { + let (ty, msg) = unframe(framed_kp)?; + if ty != frame::KEY_PACKAGE { + return Err(Error::Mls("not a KeyPackage frame".into())); + } let MlsMessageBodyIn::KeyPackage(kp) = msg.extract() else { return Err(Error::Mls("not a KeyPackage".into())) }; let kp = kp.validate(self.provider.crypto(), ProtocolVersion::Mls10).map_err(mls)?; let p = bound(kp.leaf_node().credential(), kp.leaf_node().signature_key().as_slice())?; + if !allowed(&p) { + return Err(Error::NotNow("a principal the room did not admit")); + } self.key_packages.insert(p, kp); self.flush()?; Ok(p) -- 2.54.0 (Apple Git-157) From 44b16d2382f48e4eb8f1c1e721b38ac307f14c97 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 08:34:41 -0400 Subject: [PATCH 05/14] =?UTF-8?q?joule-moq-room:=20end-to-end=20encrypted?= =?UTF-8?q?=20rooms=20=E2=80=94=20the=20record,=20the=20clock=20as=20the?= =?UTF-8?q?=20MLS=20group's=20external=20sender,=20and=20the=20room's=20lo?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [room] e2ee = true gives every room a WRE1 record the authority signs (suite 0x0004, recording mode 0 as the descriptor already states) and a joule-moq-mls Clock. The service is the room's carrier of MLS messages (confidentiality §5.1): one log per room holding the clock's lifecycle records as signed, its proposals, and every GroupInfo, commit and Welcome a member posts, in the order the clock took them. - GET /mls?room&from[&wait_ms]: the log from an entry, held up to 8 s while nothing is new; - POST /mls/key-package: kept for the principal's Add, from a principal the room granted; - POST /mls/post: a GroupInfo, or a commit and its Welcome, checked by the clock as every member checks it; refused (409) and not carried otherwise; - GET /room carries the record. Tests: the record and the log on a room (unit), and the service on a relay with two members keying through HTTP as a page does: create, Add, Welcome, refused posts not carried, each opens the other's sealed object, a leave removed by the next commit. Co-Authored-By: Claude Opus 5.5 --- .../crates/joule-moq-room/Cargo.lock | 1149 ++++++++++++++++- .../crates/joule-moq-room/Cargo.toml | 3 + .../crates/joule-moq-room/src/e2ee.rs | 221 ++++ .../crates/joule-moq-room/src/epoch.rs | 13 +- .../crates/joule-moq-room/src/http.rs | 124 +- .../crates/joule-moq-room/src/lib.rs | 27 +- .../joule-moq-room/tests/room_service.rs | 186 ++- 7 files changed, 1702 insertions(+), 21 deletions(-) create mode 100644 joule-code/joule-code-rs/crates/joule-moq-room/src/e2ee.rs diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.lock b/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.lock index e6a1552ef..01a3ddfde 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.lock +++ b/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.lock @@ -37,6 +37,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + [[package]] name = "android_system_properties" version = "0.1.6" @@ -114,6 +123,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" @@ -126,6 +141,26 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.119", +] + [[package]] name = "bit-vec" version = "0.9.1" @@ -211,7 +246,16 @@ dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", ] [[package]] @@ -226,6 +270,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20" version = "0.10.1" @@ -237,6 +292,19 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.45" @@ -286,8 +354,26 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common 0.1.7", "inout", + "zeroize", ] +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "combine" version = "4.6.8" @@ -332,6 +418,17 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-models" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee3344d349528f449816cfa85e558c439bdca5a6d8a738cfb21e69f0b2b1952f" +dependencies = [ + "hax-lib", + "pastey", + "rand 0.10.2", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -350,12 +447,60 @@ dependencies = [ "libc", ] +[[package]] +name = "crabgrind" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7459e07732f6de74001fcf73a3fdfbb0f368e4fd8e2392f4a2206a065e6e95" +dependencies = [ + "bindgen", + "cc", + "pkg-config", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + [[package]] name = "crunchy" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -363,6 +508,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -372,7 +518,9 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ + "getrandom 0.4.3", "hybrid-array", + "rand_core 0.10.1", ] [[package]] @@ -384,6 +532,15 @@ dependencies = [ "cipher", ] +[[package]] +name = "ctutils" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +dependencies = [ + "cmov", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -394,12 +551,26 @@ dependencies = [ "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest 0.10.7", - "fiat-crypto", + "fiat-crypto 0.2.9", "rustc_version", "subtle", "zeroize", ] +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "curve25519-dalek-derive", + "fiat-crypto 0.3.0", + "rustc_version", + "subtle", +] + [[package]] name = "curve25519-dalek-derive" version = "0.1.1" @@ -424,6 +595,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" +dependencies = [ + "const-oid 0.10.2", "zeroize", ] @@ -454,6 +636,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid 0.9.6", "crypto-common 0.1.7", "subtle", ] @@ -467,6 +650,7 @@ dependencies = [ "block-buffer 0.12.1", "const-oid 0.10.2", "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -480,15 +664,29 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature 2.2.0", + "spki 0.7.3", +] + [[package]] name = "ed25519" version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "pkcs8", + "pkcs8 0.10.2", "serde", - "signature", + "signature 2.2.0", ] [[package]] @@ -497,7 +695,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ - "curve25519-dalek", + "curve25519-dalek 4.1.3", "ed25519", "rand_core 0.6.4", "serde", @@ -506,6 +704,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf 0.12.4", + "pem-rfc7468", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "equivalent" version = "1.0.3" @@ -540,12 +765,28 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "find-msvc-tools" version = "0.1.11" @@ -663,6 +904,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -714,6 +956,23 @@ dependencies = [ "polyval", ] +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "half" version = "2.7.1" @@ -731,6 +990,43 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hax-lib" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a33cb227f97ee5c419064c31d7c55a7d895f28d8019ccdadc311cc036500c4" +dependencies = [ + "hax-lib-macros", + "num-bigint", + "num-traits", +] + +[[package]] +name = "hax-lib-macros" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28da835a5f153e9f3d0cc1f42ee605a1cfa9093c93348193793d60a1b0908b51" +dependencies = [ + "hax-lib-macros-types", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hax-lib-macros-types" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed327e9a28d6ee018a4a9ba842d4ee27e378c8a591d2beb6f42f32b24a02fcb" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "serde_json", + "uuid", +] + [[package]] name = "heck" version = "0.5.0" @@ -749,7 +1045,16 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", +] + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", ] [[package]] @@ -761,6 +1066,84 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "hpke-rs" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812de62ab573b876c6c40d7553bae9402ae3bad95b64af06f964388eecb9b7b1" +dependencies = [ + "hpke-rs-crypto", + "hpke-rs-libcrux", + "hpke-rs-rust-crypto", + "libcrux-sha3", + "log", + "serde", + "subtle", + "tls_codec", + "zeroize", +] + +[[package]] +name = "hpke-rs-crypto" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c461da2e0c9c93d875b597f0c78214fb0d2d5c57834b2ef2a2fa057fa20e24" +dependencies = [ + "rand 0.10.2", + "zeroize", +] + +[[package]] +name = "hpke-rs-libcrux" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c1ace95ef11fbbd84527eada5fc1304f66720fc4c008db30d888d62d3c52613" +dependencies = [ + "hpke-rs-crypto", + "libcrux-aead", + "libcrux-ecdh", + "libcrux-hkdf", + "libcrux-kem", + "libcrux-traits", + "rand 0.10.2", + "rand_chacha 0.10.0", + "rand_core 0.10.1", + "zeroize", +] + +[[package]] +name = "hpke-rs-rust-crypto" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a606ac19851da841862ef5f39d26d6227bfcfb4047417801a66c1f6e6652d71" +dependencies = [ + "aes-gcm", + "chacha20poly1305", + "hkdf 0.13.0", + "hpke-rs-crypto", + "k256", + "ml-kem", + "p256", + "p384", + "rand 0.10.2", + "rand_chacha 0.10.0", + "rand_core 0.10.1", + "sha2 0.11.1", + "subtle", + "x-wing", + "x25519-dalek 2.0.1", + "zeroize", +] + [[package]] name = "httlib-huffman" version = "0.3.4" @@ -818,6 +1201,7 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ + "ctutils", "typenum", ] @@ -1002,6 +1386,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1080,8 +1473,8 @@ dependencies = [ "ctr", "ed25519-dalek", "futures", - "hkdf", - "hmac", + "hkdf 0.12.4", + "hmac 0.12.1", "jwp", "rand_core 0.6.4", "serde", @@ -1093,6 +1486,23 @@ dependencies = [ "uuid", ] +[[package]] +name = "joule-moq-mls" +version = "0.1.0" +dependencies = [ + "blake3", + "ed25519-dalek", + "hex", + "joule-moq", + "openmls", + "openmls_rust_crypto", + "openmls_traits", + "serde", + "serde_json", + "sha2 0.10.9", + "tls_codec", +] + [[package]] name = "joule-moq-relay" version = "0.1.0" @@ -1131,6 +1541,7 @@ dependencies = [ "hyper", "hyper-util", "joule-moq", + "joule-moq-mls", "joule-moq-relay", "rand_core 0.6.4", "serde", @@ -1172,6 +1583,36 @@ dependencies = [ "zstd", ] +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "elliptic-curve", +] + +[[package]] +name = "keccak" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", +] + +[[package]] +name = "kem" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd" +dependencies = [ + "crypto-common 0.2.2", + "rand_core 0.10.1", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1184,6 +1625,235 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libcrux-aead" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22acbe68be84b7b41aaba6e39b87036fc4324dee628d94773750bf3d7e906d69" +dependencies = [ + "libcrux-aes", + "libcrux-chacha20poly1305", + "libcrux-secrets", + "libcrux-traits", +] + +[[package]] +name = "libcrux-aes" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57cc95b11dbc797b1e169467b1fc4205c4e6ee2ce516a035ad7342ce5f6ae971" +dependencies = [ + "libcrux-intrinsics", + "libcrux-platform", + "libcrux-secrets", + "libcrux-traits", +] + +[[package]] +name = "libcrux-chacha20poly1305" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537e6eee5cdc9a980014d058784b0be09614f0596df789b114f7833aa9f35d75" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-poly1305", + "libcrux-secrets", + "libcrux-traits", +] + +[[package]] +name = "libcrux-curve25519" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4f3cfd5e31cb9745e290ee061222c380ec42884654b09fc7d12a5b0ed63e028" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-secrets", + "libcrux-traits", +] + +[[package]] +name = "libcrux-ecdh" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a227590b89ff55ce7cd97b5a7468c82d231db8f3b890bb4d4fb6d271e7524d7" +dependencies = [ + "libcrux-curve25519", + "libcrux-p256", + "rand 0.10.2", + "tls_codec", +] + +[[package]] +name = "libcrux-hacl-rs" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66106db376ae249af86911aee048cf73ea1f394d6653026fc988dd22cf2a4ace" +dependencies = [ + "libcrux-macros", +] + +[[package]] +name = "libcrux-hkdf" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc94e651dca4d47edbcdd88bb2428ce16a2bd86b7a4e7170da87b505478f9839" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-hmac", + "libcrux-secrets", +] + +[[package]] +name = "libcrux-hmac" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "500ad9b32c715161b594172ca1fb11503a1c033b393ff4c0c33505ce51c1943c" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-sha2", + "libcrux-traits", +] + +[[package]] +name = "libcrux-intrinsics" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98a0c574d4eb81d0814bc2b91e4a433d7e0b35851b1d62eb5dd95c064f1f76d0" +dependencies = [ + "core-models", + "hax-lib", +] + +[[package]] +name = "libcrux-kem" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "541a7377fb35060892e0620982e224e47419f10da8c212453bf642dafe529691" +dependencies = [ + "libcrux-curve25519", + "libcrux-ecdh", + "libcrux-ml-kem", + "libcrux-p256", + "libcrux-sha3", + "libcrux-traits", + "rand 0.10.2", +] + +[[package]] +name = "libcrux-macros" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd6aa2dcd5be681662001b81d493f1569c6d49a32361f470b0c955465cd0338" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "libcrux-ml-kem" +version = "0.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8160f7d64fd2716b4fd05cc886a042f8dcda18d9206c0d506e2c67bdf97daa" +dependencies = [ + "hax-lib", + "libcrux-intrinsics", + "libcrux-platform", + "libcrux-secrets", + "libcrux-sha3", + "libcrux-traits", + "rand 0.10.2", + "tls_codec", +] + +[[package]] +name = "libcrux-p256" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3400732702d578be622257b98cec85e9b0cd34a67f1f06d3ebe9ae34963cdf02" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-secrets", + "libcrux-sha2", + "libcrux-traits", +] + +[[package]] +name = "libcrux-platform" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9e21d7ed31a92ac539bd69a8c970b183ee883872d2d19ce27036e24cb8ecc4" +dependencies = [ + "libc", +] + +[[package]] +name = "libcrux-poly1305" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a9144949845813a0b8787d08cbba47baabe59c83f3043e558e6e92385c40cc" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", +] + +[[package]] +name = "libcrux-secrets" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79054fc9037cb70d6a546cf094cea7d7df06af5e49d230ba24103d27ccc886f1" +dependencies = [ + "crabgrind", + "hax-lib", +] + +[[package]] +name = "libcrux-sha2" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e46e1be0b77098cc6ce82137864936ecad3a1b9fd4303dd51202ca140dd1e" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-traits", +] + +[[package]] +name = "libcrux-sha3" +version = "0.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c09f5c39afae0528e1f70a3c1e3c6ee649ec1f19dda26fc9b6ea91108fb879ef" +dependencies = [ + "hax-lib", + "libcrux-intrinsics", + "libcrux-platform", + "libcrux-traits", +] + +[[package]] +name = "libcrux-traits" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fa7a21e8c2e8baa8b40f0b176740f2ca4baadd79d1b00e48d6b1e363c42085a" +dependencies = [ + "libcrux-secrets", + "rand 0.10.2", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libm" version = "0.2.16" @@ -1246,6 +1916,47 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ml-dsa" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d" +dependencies = [ + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", + "hybrid-array", + "module-lattice", + "pkcs8 0.11.0", + "shake", + "signature 3.0.0", +] + +[[package]] +name = "ml-kem" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9ba01ebcf90f6aff8e9b9f55bb69b19e6250e4ade5787d0040eaa27dd226238" +dependencies = [ + "hybrid-array", + "kem", + "module-lattice", + "rand_core 0.10.1", + "sha3", + "shake", +] + +[[package]] +name = "module-lattice" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" +dependencies = [ + "ctutils", + "hybrid-array", + "num-traits", +] + [[package]] name = "nom" version = "7.1.3" @@ -1326,12 +2037,121 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openmls" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88ea97f8931047590bda498a9502e8fe6c9bb2bb04547a9a463618783481e0ec" +dependencies = [ + "log", + "openmls_serialization_helpers", + "openmls_traits", + "rayon", + "serde", + "serde_bytes", + "thiserror", + "tls_codec", + "zeroize", +] + +[[package]] +name = "openmls_memory_storage" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce927945a16eaa19302ed5ccef052f01ba68f2ba7ae67e4ac4ff2fc661445364" +dependencies = [ + "log", + "openmls_traits", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "openmls_rust_crypto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36bf3fe824ead6e81d22f49a9cd7feffca3494979fac14c1248e19516e3625f1" +dependencies = [ + "aes-gcm", + "chacha20poly1305", + "ed25519-dalek", + "hkdf 0.13.0", + "hmac 0.13.0", + "hpke-rs", + "hpke-rs-crypto", + "hpke-rs-rust-crypto", + "ml-dsa", + "openmls_memory_storage", + "openmls_traits", + "p256", + "p384", + "rand_chacha 0.3.1", + "rand_core 0.10.1", + "rand_core 0.6.4", + "sha2 0.11.1", + "thiserror", + "tls_codec", +] + +[[package]] +name = "openmls_serialization_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e2cf2193541e83ce1a89aaa44c2fd4783abd54853823075a32b60ef032284c0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "thiserror", +] + +[[package]] +name = "openmls_traits" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac13edad8698eb1917cbc1dab99591fa1594d84320904fe3fc5121854f95831c" +dependencies = [ + "serde", + "tls_codec", +] + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pem" version = "3.0.6" @@ -1342,6 +2162,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1360,8 +2189,18 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", - "spki", + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.2", + "spki 0.8.1", ] [[package]] @@ -1370,6 +2209,17 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "polyval" version = "0.6.2" @@ -1412,6 +2262,47 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -1506,7 +2397,7 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.5", ] @@ -1516,11 +2407,21 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20", + "chacha20 0.10.1", "getrandom 0.4.3", "rand_core 0.10.1", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -1531,6 +2432,16 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_chacha" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb" +dependencies = [ + "ppv-lite86", + "rand_core 0.10.1", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -1564,6 +2475,26 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "rcgen" version = "0.14.9" @@ -1577,6 +2508,45 @@ dependencies = [ "yasna", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -1726,6 +2696,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der 0.7.10", + "generic-array", + "pkcs8 0.10.2", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -1765,6 +2749,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -1857,6 +2851,28 @@ dependencies = [ "cc", ] +[[package]] +name = "sha3" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" +dependencies = [ + "digest 0.11.3", + "keccak", + "sponge-cursor", +] + +[[package]] +name = "shake" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" +dependencies = [ + "digest 0.11.3", + "keccak", + "sponge-cursor", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1866,6 +2882,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "shlex" version = "2.0.1" @@ -1888,9 +2910,20 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ + "digest 0.10.7", "rand_core 0.6.4", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + [[package]] name = "simd_cesu8" version = "1.2.0" @@ -1942,9 +2975,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", + "der 0.7.10", ] +[[package]] +name = "spki" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" +dependencies = [ + "base64ct", + "der 0.8.2", +] + +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2098,6 +3147,29 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tls_codec" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18cc98286004cea38f717e2b03d990fc774fbfd38a82720de40e5c94365067c8" +dependencies = [ + "serde", + "serde_bytes", + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "674f41dd95f76cdeb005c83f9444e20cf43daebef37cde9e49a9ca6f4e87b423" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tokio" version = "1.53.1" @@ -2347,7 +3419,7 @@ version = "0.3.0" dependencies = [ "blake3", "cbindgen", - "curve25519-dalek", + "curve25519-dalek 4.1.3", "ed25519-dalek", "num-bigint", "pkg-config", @@ -2660,6 +3732,41 @@ dependencies = [ "url", ] +[[package]] +name = "x-wing" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01576e56599d01d75c5efcd5b30fa5de29e776f5a225e43fbf8c92c7302d4c8b" +dependencies = [ + "kem", + "ml-kem", + "sha3", + "shake", + "x25519-dalek 3.0.0", +] + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek 4.1.3", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "x25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e8131a03190127fb2263afc72b322ecadae46b6ff8c6f399ff5d02f5559af6" +dependencies = [ + "curve25519-dalek 5.0.0", + "rand_core 0.10.1", +] + [[package]] name = "x509-parser" version = "0.18.1" @@ -2757,6 +3864,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "zerotrie" diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.toml b/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.toml index fc70ad04e..6b881276d 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.toml +++ b/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.toml @@ -22,6 +22,9 @@ path = "src/main.rs" [dependencies] joule-moq = { path = "../joule-moq" } joule-moq-relay = { path = "../joule-moq-relay" } +# End-to-end encrypted rooms: the confidentiality record and the clock as the MLS group's +# external sender (WAI confidentiality §3, §5). +joule-moq-mls = { path = "../joule-moq-mls" } # The room's records (live-rooms, live-events, live-compose): no codec chain. wai = { path = "../../../../wai/wai-rs", default-features = false, features = ["live_compose", "timed_text"] } # The HTTP service (MIT), the versions joule-moq-ingest pins. diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/src/e2ee.rs b/joule-code/joule-code-rs/crates/joule-moq-room/src/e2ee.rs new file mode 100644 index 000000000..f46e9efb7 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-room/src/e2ee.rs @@ -0,0 +1,221 @@ +//! End-to-end encrypted rooms (WAI `extensions/confidentiality.md`): the service's part. +//! +//! A room the service keeps with `e2ee = true` has a confidentiality record (`WRE1`, §3), +//! signed by the room's authority, and one MLS group whose external sender is the room's +//! clock (§5.3). The clock holds no key: it proposes every Add and Remove the room's +//! lifecycle calls for (§5.4) and follows the group from its public handshake messages. +//! +//! The service is the room's carrier of MLS messages (§5.1, "a room service passing them +//! directly"): one log per room, in which the clock's lifecycle records (as signed on its +//! events track), its proposals, and every GroupInfo, commit and Welcome a member posts +//! stand in the order the clock took them. Every member reads the whole log in that order, +//! so every member applies the same messages in the same order; a post the clock refuses +//! (a commit by a member that is not the committer, one for an epoch already ended, a +//! GroupInfo the room does not owe) is never carried. Every message is signed by its +//! sender's key, and members check each as the clock does, so the log can withhold or +//! delay a message, and cannot forge or alter one. + +use crate::epoch::Lifecycle; +use crate::keys::Keys; +use joule_moq_mls::group::{frame, Error}; +use joule_moq_mls::model::Principal; +use joule_moq_mls::record::MLS_SUITE; +use joule_moq_mls::{Clock, Event, Record}; +use serde_json::{json, Value}; + +/// The secure-objects suite of the service's rooms: `AES_128_GCM_SHA256_128` (§3.3 step +/// 3, the mandatory one). +pub const SUITE: u16 = 0x0004; +/// Entries one answer carries at most. +pub const PAGE: usize = 256; +/// A frame a member may post: a GroupInfo, a commit or a Welcome, at most this long. +pub const MAX_FRAME: usize = 48 * 1024; + +/// One entry of a room's log. +#[derive(Clone, Debug)] +pub enum Entry { + /// A lifecycle record of the clock, as it signed it (live-events §3). + Event(Vec), + /// A framed MLS message (the keying draft §3.4): the clock's proposal, a GroupInfo, a + /// commit or a Welcome. + Mls(Vec), +} + +/// A room's confidentiality record, its clock as the group's external sender, and its log. +pub struct RoomMls { + pub record: Vec, + pub clock: Clock, + pub log: Vec, +} + +/// Why a post was not carried. +#[derive(Debug)] +pub enum Refused { + /// Not a frame a member posts, or too long. + Malformed(&'static str), + /// The clock refused it: the extension's reason where there is one. + Clock(String), +} + +impl RoomMls { + /// A new room's record, signed by its authority, and a clock with no group yet. + pub fn new(keys: &Keys, room: [u8; 32]) -> RoomMls { + RoomMls { record: Record::sign(room, SUITE, MLS_SUITE, &keys.authority).to_bytes(), clock: Clock::new(room, keys.clock.clone()), log: Vec::new() } + } + + fn outbox(&mut self) { + for m in self.clock.take_outbox() { + self.log.push(Entry::Mls(m)); + } + } + + /// A lifecycle record the clock signed for `subject`: carried, and, for a `join` or a + /// `leave`, applied to the clock's view of who holds keys, with the proposals it then + /// owes after it. Every grant this service issues may subscribe, so every `join` is under + /// a keyed grant. + pub fn lifecycle(&mut self, record: &[u8], subject: Principal, what: &Lifecycle) -> Result<(), Error> { + self.log.push(Entry::Event(record.to_vec())); + let ev = match *what { + Lifecycle::Join { .. } => Some(Event::Join { subject, keyed: true }), + Lifecycle::Leave { reason } => Some(Event::Leave { subject, reason }), + Lifecycle::Publish { .. } => None, + }; + let r = match ev { + Some(ev) => self.clock.event(&ev), + None => Ok(()), + }; + self.outbox(); + r + } + + /// A KeyPackage posted by a principal `granted` admits: kept for its Add. + pub fn key_package(&mut self, framed: &[u8], granted: impl FnOnce(&Principal) -> bool) -> Result { + let p = self.clock.key_package_if(framed, granted)?; + self.outbox(); + Ok(p) + } + + /// A member's post: a GroupInfo alone, or a commit and, when it adds members, their + /// Welcome. Checked by the clock as every member will check it, then carried; returns + /// the sequence number of its first entry. + pub fn post(&mut self, frames: &[Vec]) -> Result { + let types: Vec = frames.iter().map(|f| f.first().copied().unwrap_or(0)).collect(); + match types.as_slice() { + [frame::GROUP_INFO] | [frame::COMMIT] | [frame::COMMIT, frame::WELCOME] => {} + _ => return Err(Refused::Malformed("a GroupInfo, or a commit and its Welcome")), + } + if frames.iter().any(|f| f.len() > MAX_FRAME) { + return Err(Refused::Malformed("a frame too long")); + } + self.clock.observe(&frames[0]).map_err(|e| Refused::Clock(match e { + Error::Refused(r) => r.code().to_string(), + other => other.to_string(), + }))?; + let at = self.log.len(); + for f in frames { + self.log.push(Entry::Mls(f.clone())); + } + self.outbox(); + Ok(at) + } + + /// The log from `from`, at most [`PAGE`] entries, each with its sequence number. + pub fn entries(&self, from: usize) -> Value { + let end = self.log.len().min(from.saturating_add(PAGE)); + let items: Vec = (from.min(end)..end) + .map(|i| match &self.log[i] { + Entry::Event(r) => json!({"seq": i, "event": hex::encode(r)}), + Entry::Mls(f) => json!({"seq": i, "mls": hex::encode(f)}), + }) + .collect(); + json!({"entries": items, "next": end, "end": self.log.len()}) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::epoch::{Epoch, Settings}; + use ed25519_dalek::SigningKey; + use joule_moq_mls::RoomMember; + use joule_moq::secure_objects::Suite; + + fn settings() -> Settings { + Settings { max_publishers: 4, max_present: 16, publisher_grant_s: 600, viewer_grant_s: 900, join_wait_s: 60, max_grants: 8, e2ee: true } + } + + #[test] + fn a_room_with_a_record_and_a_log_two_members_key_through() { + let keys = Keys::generate(); + let now_s = 1_800_000_000; + let mut e = Epoch::new(&keys, settings(), 1, now_s * 1_000_000); + let room = e.id; + // The record checks against the room's descriptor: its authority, recording mode 0. + let d = wai::live_room::Descriptor::parse(&e.descriptor).unwrap(); + let mls = e.mls.as_mut().unwrap(); + let (r, suite) = Record::check(&mls.record, &room, &d.authority, d.recording.mode).unwrap(); + assert_eq!((r.suite, suite), (SUITE, Suite::AesGcm128Sha256)); + let cp = keys.clock.verifying_key().to_bytes(); + let ka = SigningKey::from_bytes(&[1; 32]); + let kb = SigningKey::from_bytes(&[2; 32]); + let (pa, pb) = (ka.verifying_key().to_bytes(), kb.verifying_key().to_bytes()); + let mut a = RoomMember::new(room, cp, ka, Suite::AesGcm128Sha256); + let mut b = RoomMember::new(room, cp, kb, Suite::AesGcm128Sha256); + // Only a principal the room granted may leave a KeyPackage. + let kp = b.key_package().unwrap(); + assert!(mls.key_package(&kp, |_| false).is_err()); + mls.key_package(&kp, |p| *p == pb).unwrap(); + let mut chain = crate::epoch::Chain::default(); + let mut emit = |mls: &mut RoomMls, who: Principal, what: Lifecycle| { + let rec = chain.sign(&keys, room, 1_000_000, who, &what); + mls.lifecycle(&rec, who, &what).unwrap(); + }; + emit(mls, pa, Lifecycle::Join { grant: [0; 32] }); + emit(mls, pa, Lifecycle::Publish { media: 2, profile: 1 }); + // A reads the log: the room owes it the group; its GroupInfo is carried once. + let read = |m: &mut RoomMember, mls: &RoomMls, at: &mut usize| { + while *at < mls.log.len() { + match &mls.log[*at] { + Entry::Event(r) => { + let ev = wai::live_events::Event::parse(r).unwrap(); + let wai::live_events::Payload::Lifecycle { subject, .. } = wai::live_events::parse_payload(ev.kind, &ev.payload, 1024).unwrap() else { panic!() }; + match wai::live_events::kind_name(ev.kind).unwrap() { + "join" => m.event(&Event::Join { subject, keyed: true }), + "leave" => m.event(&Event::Leave { subject, reason: 1 }), + _ => {} + } + } + Entry::Mls(f) => { + m.receive(f).unwrap(); + } + } + *at += 1; + } + }; + let (mut at_a, mut at_b) = (0, 0); + read(&mut a, mls, &mut at_a); + let info = a.stage_create().unwrap(); + assert!(matches!(mls.post(std::slice::from_ref(&info)), Ok(_))); + assert!(matches!(mls.post(&[info]), Err(Refused::Clock(_))), "a second GroupInfo is not owed"); + read(&mut a, mls, &mut at_a); + assert_eq!(a.epoch(), Some(0)); + // B joins: the clock proposes its Add, A commits through the log, B joins by Welcome. + emit(mls, pb, Lifecycle::Join { grant: [0; 32] }); + read(&mut a, mls, &mut at_a); + read(&mut b, mls, &mut at_b); + assert!(b.following()); + let c = a.stage_commit(false).unwrap(); + let frames: Vec> = std::iter::once(c.commit.clone()).chain(c.welcome.clone()).collect(); + assert!(matches!(mls.post(&[c.welcome.clone().unwrap()]), Err(Refused::Malformed(_)))); + mls.post(&frames).unwrap(); + assert!(matches!(mls.post(&frames), Err(Refused::Clock(_))), "a commit for an ended epoch"); + read(&mut a, mls, &mut at_a); + read(&mut b, mls, &mut at_b); + assert_eq!((a.epoch(), b.epoch()), (Some(1), Some(1))); + // The log as a page reads it. + let v = mls.entries(0); + assert_eq!(v["end"].as_u64().unwrap() as usize, mls.log.len()); + assert!(v["entries"][0]["event"].is_string()); + assert_eq!(mls.entries(mls.log.len())["entries"].as_array().unwrap().len(), 0); + } +} diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/src/epoch.rs b/joule-code/joule-code-rs/crates/joule-moq-room/src/epoch.rs index df5dcec3f..25a67324a 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/src/epoch.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-room/src/epoch.rs @@ -7,6 +7,7 @@ //! authority and clock. Each room's grants and clock chain hold one stretch of company, and //! stay small. +use crate::e2ee::RoomMls; use crate::keys::Keys; use serde_json::{json, Value}; use std::collections::HashMap; @@ -44,6 +45,9 @@ pub struct Settings { pub join_wait_s: u64, /// Grants one room takes; past it, a new room starts once this one is empty. pub max_grants: usize, + /// Every room end-to-end encrypted (WAI confidentiality): a `WRE1` record and an MLS + /// group whose external sender is the clock ([`crate::e2ee`]). + pub e2ee: bool, } /// The room's camera: one layer of 320 × 240 at 15 frames a second, VP9 first and AV1 @@ -163,6 +167,9 @@ pub struct Epoch { next_order: u32, pub joined_ever: bool, pub settings: Settings, + /// An end-to-end encrypted room's record, its clock as the MLS group's external sender, + /// and the log that carries the group's messages. + pub mls: Option, } impl Epoch { @@ -209,6 +216,7 @@ impl Epoch { members: HashMap::new(), next_order: 0, joined_ever: false, + mls: settings.e2ee.then(|| RoomMls::new(keys, id)), settings, } } @@ -293,6 +301,9 @@ impl Epoch { "present": self.present(), "publishers": self.publishers(now_s), "max_publishers": self.settings.max_publishers, + // The confidentiality record (WAI confidentiality §3) of an end-to-end encrypted + // room, which a page checks against the descriptor before it enters. + "e2ee": self.mls.as_ref().map(|m| json!({"record": hex::encode(&m.record), "log": m.log.len()})), }) } } @@ -344,7 +355,7 @@ mod tests { use super::*; fn settings() -> Settings { - Settings { max_publishers: 2, max_present: 16, publisher_grant_s: 600, viewer_grant_s: 900, join_wait_s: 60, max_grants: 3 } + Settings { max_publishers: 2, max_present: 16, publisher_grant_s: 600, viewer_grant_s: 900, join_wait_s: 60, max_grants: 3, e2ee: false } } #[test] diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/src/http.rs b/joule-code/joule-code-rs/crates/joule-moq-room/src/http.rs index 27a0ce416..39ad61f2a 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/src/http.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-room/src/http.rs @@ -7,10 +7,20 @@ //! | `GET /room` | the room: its id, `t0_us`, the signed descriptor and composition descriptor (hex), the publishers' grants (hex), how many publish and may | //! | `POST /grant` `{"principal": <64 hex>, "role": "publisher" \| "viewer"}` | `{"grant": , "digest", "exp", "role", "room": }`; `409` `{"error": "full"}` when the room has its publishers, `409` `{"error": "busy"}` when it has taken its grants, `429` `{"error": "rate", "retry_after_s"}` | //! +//! +//! An end-to-end encrypted room (`[room] e2ee = true`, [`crate::e2ee`]) has its log too: +//! +//! | Request | Answer | +//! |---|---| +//! | `GET /mls?room=&from=[&wait_ms=]` | the room's log from entry `n` (`{"room", "entries": [{"seq", "event" \| "mls": }], "next", "end"}`), held up to 8 s (or `wait_ms`, at most 8 s) while it has nothing past `n`; `410` `{"error": "room"}` for a room since replaced, `404` for a room that is not end-to-end encrypted | +//! | `POST /mls/key-package` `{"room", "key_package": }` | `{"ok", "principal"}`: kept for the principal's Add; `403` for a principal the room has not granted | +//! | `POST /mls/post` `{"room", "frames": []}` | a GroupInfo, or a commit and its Welcome, checked by the clock and carried: `{"ok", "seq"}`; `409` `{"error": }` when the clock refuses it, which then is not carried | +//! //! Every answer is JSON and `Cache-Control: no-store`. A grant request's body is at most -//! 1 KiB. +//! 1 KiB, a log post's 128 KiB. -use crate::epoch::{Refusal, Role}; +use crate::e2ee::Refused; +use crate::epoch::{MemberState, Refusal, Role}; use crate::{now_s, Service}; use bytes::Bytes; use http_body_util::{BodyExt, Full, Limited}; @@ -27,6 +37,10 @@ use std::sync::Arc; use std::time::Duration; const MAX_BODY: usize = 1024; +/// A log post's body: a commit and a Welcome for a room of many members, in hexadecimal. +const MAX_MLS_BODY: usize = 128 * 1024; +/// How long `GET /mls` holds a request while the log has nothing new. +const MLS_WAIT: Duration = Duration::from_secs(8); /// Serve `listener` until the task is dropped. pub async fn serve(svc: Arc, listener: tokio::net::TcpListener) { @@ -83,7 +97,17 @@ async fn handle(svc: Arc, peer: SocketAddr, req: Request) -> }; grant(&svc, &client, &body) } - (_, "/") | (_, "/health") | (_, "/room") | (_, "/grant") => reply(StatusCode::METHOD_NOT_ALLOWED, json!({"error": "method"})), + (Method::GET, "/mls") => mls_log(&svc, req.uri().query().unwrap_or("")).await, + (Method::POST, "/mls/key-package") | (Method::POST, "/mls/post") => { + let body = match Limited::new(req.into_body(), MAX_MLS_BODY).collect().await { + Ok(b) => b.to_bytes(), + Err(_) => return Ok(reply(StatusCode::PAYLOAD_TOO_LARGE, json!({"error": "body"}))), + }; + let r = if path == "/mls/post" { mls_post(&svc, &body) } else { mls_key_package(&svc, &body) }; + svc.mls_changed.notify_waiters(); + r + } + (_, "/") | (_, "/health") | (_, "/room") | (_, "/grant") | (_, "/mls") | (_, "/mls/key-package") | (_, "/mls/post") => reply(StatusCode::METHOD_NOT_ALLOWED, json!({"error": "method"})), _ => reply(StatusCode::NOT_FOUND, json!({"error": "not found"})), }) } @@ -137,3 +161,97 @@ pub fn grant(svc: &Arc, client: &str, body: &[u8]) -> Response Option<[u8; 32]> { + hex::decode(v).ok().and_then(|b| b.try_into().ok()) +} + +/// The room's log from `from`, once it holds anything past it or after [`MLS_WAIT`]. +async fn mls_log(svc: &Arc, query: &str) -> Response> { + let mut room = None; + let mut from = 0usize; + let mut wait = MLS_WAIT; + for (k, v) in query.split('&').filter_map(|kv| kv.split_once('=')) { + match k { + "room" => room = room_of(v), + "from" => from = v.parse().unwrap_or(0), + "wait_ms" => wait = v.parse().map(Duration::from_millis).unwrap_or(MLS_WAIT).min(MLS_WAIT), + _ => {} + } + } + let Some(room) = room else { return reply(StatusCode::BAD_REQUEST, json!({"error": "room: 64 hexadecimal digits"})) }; + let deadline = tokio::time::Instant::now() + wait; + loop { + let notified = svc.mls_changed.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + { + let st = svc.state.lock().unwrap(); + if st.epoch.id != room { + return reply(StatusCode::GONE, json!({"error": "room", "room": hex::encode(st.epoch.id)})); + } + let Some(m) = st.epoch.mls.as_ref() else { return reply(StatusCode::NOT_FOUND, json!({"error": "not end-to-end encrypted"})) }; + if m.log.len() > from || tokio::time::Instant::now() >= deadline { + let mut v = m.entries(from); + v["room"] = json!(hex::encode(room)); + return reply(StatusCode::OK, v); + } + } + let _ = tokio::time::timeout_at(deadline, notified).await; + } +} + +/// The body of a log request: its room, which must be the current one, and the JSON. +fn mls_body(svc: &Arc, body: &[u8]) -> Result>> { + let Ok(v) = serde_json::from_slice::(body) else { return Err(reply(StatusCode::BAD_REQUEST, json!({"error": "not JSON"}))) }; + let Some(room) = v["room"].as_str().and_then(room_of) else { return Err(reply(StatusCode::BAD_REQUEST, json!({"error": "room: 64 hexadecimal digits"}))) }; + let st = svc.state.lock().unwrap(); + if st.epoch.id != room { + return Err(reply(StatusCode::GONE, json!({"error": "room", "room": hex::encode(st.epoch.id)}))); + } + if st.epoch.mls.is_none() { + return Err(reply(StatusCode::NOT_FOUND, json!({"error": "not end-to-end encrypted"}))); + } + Ok(v) +} + +/// A KeyPackage of a principal the room granted to publish, kept for its Add. +fn mls_key_package(svc: &Arc, body: &[u8]) -> Response> { + let v = match mls_body(svc, body) { + Ok(v) => v, + Err(r) => return r, + }; + let Some(kp) = v["key_package"].as_str().and_then(|h| hex::decode(h).ok()) else { return reply(StatusCode::BAD_REQUEST, json!({"error": "key_package: hexadecimal"})) }; + let mut st = svc.state.lock().unwrap(); + let ep = &mut st.epoch; + let members = &ep.members; + let Some(m) = ep.mls.as_mut() else { return reply(StatusCode::NOT_FOUND, json!({"error": "not end-to-end encrypted"})) }; + match m.key_package(&kp, |p| members.get(p).is_some_and(|x| x.state != MemberState::Left)) { + Ok(p) => reply(StatusCode::OK, json!({"ok": true, "principal": hex::encode(p)})), + Err(joule_moq_mls::Error::NotNow(_)) => reply(StatusCode::FORBIDDEN, json!({"error": "not granted"})), + Err(e) => reply(StatusCode::BAD_REQUEST, json!({"error": e.to_string()})), + } +} + +/// A GroupInfo, or a commit and its Welcome: checked by the clock, then carried. +fn mls_post(svc: &Arc, body: &[u8]) -> Response> { + let v = match mls_body(svc, body) { + Ok(v) => v, + Err(r) => return r, + }; + let frames: Option>> = v["frames"].as_array().map(|a| a.iter().map(|f| f.as_str().and_then(|h| hex::decode(h).ok())).collect()).unwrap_or(None); + let Some(frames) = frames.filter(|f| !f.is_empty()) else { return reply(StatusCode::BAD_REQUEST, json!({"error": "frames: hexadecimal"})) }; + let mut st = svc.state.lock().unwrap(); + let Some(m) = st.epoch.mls.as_mut() else { return reply(StatusCode::NOT_FOUND, json!({"error": "not end-to-end encrypted"})) }; + match m.post(&frames) { + Ok(at) => { + tracing::info!(seq = at, frames = frames.len(), "a member's MLS post carried"); + reply(StatusCode::OK, json!({"ok": true, "seq": at})) + } + Err(Refused::Malformed(why)) => reply(StatusCode::BAD_REQUEST, json!({"error": why})), + Err(Refused::Clock(why)) => { + tracing::info!(reason = %why, "a member's MLS post refused"); + reply(StatusCode::CONFLICT, json!({"error": why})) + } + } +} diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/src/lib.rs b/joule-code/joule-code-rs/crates/joule-moq-room/src/lib.rs index 9f58ac726..18ae6e07c 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/src/lib.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-room/src/lib.rs @@ -19,6 +19,7 @@ //! Every room the service keeps is an [`epoch::Epoch`]: when everyone has left, the next //! publisher starts a new one. +pub mod e2ee; pub mod epoch; pub mod http; pub mod keys; @@ -74,11 +75,13 @@ pub struct RoomConfig { pub viewer_grant_s: u64, pub join_wait_s: u64, pub max_grants: usize, + /// Every room end-to-end encrypted (WAI confidentiality). + pub e2ee: bool, } impl Default for RoomConfig { fn default() -> Self { - RoomConfig { max_publishers: 4, max_present: 16, publisher_grant_s: 1_200, viewer_grant_s: 1_800, join_wait_s: 90, max_grants: 64 } + RoomConfig { max_publishers: 4, max_present: 16, publisher_grant_s: 1_200, viewer_grant_s: 1_800, join_wait_s: 90, max_grants: 64, e2ee: false } } } @@ -104,7 +107,7 @@ impl Config { pub fn settings(&self) -> Settings { let r = &self.room; - Settings { max_publishers: r.max_publishers, max_present: r.max_present, publisher_grant_s: r.publisher_grant_s, viewer_grant_s: r.viewer_grant_s, join_wait_s: r.join_wait_s, max_grants: r.max_grants } + Settings { max_publishers: r.max_publishers, max_present: r.max_present, publisher_grant_s: r.publisher_grant_s, viewer_grant_s: r.viewer_grant_s, join_wait_s: r.join_wait_s, max_grants: r.max_grants, e2ee: r.e2ee } } } @@ -151,6 +154,8 @@ pub struct Service { next_generation: AtomicU64, /// Signalled after each lifecycle event, for tests. pub changed: Notify, + /// Signalled whenever an end-to-end encrypted room's log grows. + pub mls_changed: Notify, } impl Service { @@ -169,6 +174,7 @@ impl Service { next_alias: AtomicU64::new(1), next_generation: AtomicU64::new(2), changed: Notify::new(), + mls_changed: Notify::new(), }) } @@ -256,12 +262,29 @@ impl Service { return Err(e); } tracing::info!(room = %hex::encode(room), subject = %hex::encode(&subject[..6]), event = ?what, "lifecycle"); + self.mls_lifecycle(generation, &rec, subject, what); } drop(clock); self.changed.notify_waiters(); Ok(()) } + /// In an end-to-end encrypted room, a lifecycle record is carried on the room's log too, + /// and its clock proposes what it calls for (WAI confidentiality §5.4). + fn mls_lifecycle(&self, generation: u64, rec: &[u8], subject: [u8; 32], what: &Lifecycle) { + let mut st = self.state.lock().unwrap(); + if st.epoch.generation != generation { + return; + } + if let Some(m) = st.epoch.mls.as_mut() { + if let Err(e) = m.lifecycle(rec, subject, what) { + tracing::warn!(error = %e, "the room's MLS clock"); + } + drop(st); + self.mls_changed.notify_waiters(); + } + } + /// Watch the publisher `principal` of the room of `generation` until `until_s`: when /// its video track appears it has joined, and when the track ends it has left. pub async fn watch(self: Arc, generation: u64, principal: [u8; 32], until_s: u64) { diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/tests/room_service.rs b/joule-code/joule-code-rs/crates/joule-moq-room/tests/room_service.rs index dc4b34165..661ec1fd5 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/tests/room_service.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-room/tests/room_service.rs @@ -8,7 +8,12 @@ //! verifies as WAI live-rooms §9 verifies a room; //! - the grant alone, without the proof, opens no session; //! - publishers are bounded, a viewer is not counted among them, grants are bounded per -//! client, and a new room starts once the last publisher has left. +//! client, and a new room starts once the last publisher has left; +//! - an end-to-end encrypted room (WAI confidentiality): its record checks against its +//! descriptor, members key through the service's log as a page does (KeyPackages, the +//! clock's proposals, a GroupInfo, commits and Welcomes, each checked by the clock before +//! it is carried), a post the clock refuses is not carried, and a member that leaves is +//! removed by the next commit. use ed25519_dalek::SigningKey; use joule_moq::draft22::data::FetchEntry; @@ -238,3 +243,182 @@ async fn run() { cv.close(0, "done"); server.shutdown().await; } + +// ------------------------------------------------------------------- end-to-end encryption + +/// A member as a page is one: it reads the room's log in order and creates or commits +/// through it when the room calls on it. +struct Member { + m: joule_moq_mls::RoomMember, + at: usize, + seen: Vec, +} + +async fn post(addr: std::net::SocketAddr, room: &str, frames: &[Vec]) -> (u16, Value) { + let body = serde_json::json!({"room": room, "frames": frames.iter().map(hex::encode).collect::>()}); + call(addr, "POST", "/mls/post", Some(&body), "10.1.1.1").await +} + +/// Read the log to its end and act, until nothing is left to read or do. +async fn sync(addr: std::net::SocketAddr, room: &str, p: &mut Member) { + loop { + let (st, v) = call(addr, "GET", &format!("/mls?room={room}&from={}&wait_ms=0", p.at), None, "10.1.1.1").await; + assert_eq!(st, 200, "{v}"); + let entries = v["entries"].as_array().unwrap().clone(); + for e in &entries { + assert_eq!(e["seq"].as_u64(), Some(p.at as u64)); + if let Some(h) = e["event"].as_str() { + let ev = wai::live_events::Event::parse(&hex::decode(h).unwrap()).unwrap(); + ev.verify_signature().unwrap(); + if let Ok(wai::live_events::Payload::Lifecycle { subject, .. }) = wai::live_events::parse_payload(ev.kind, &ev.payload, 1024) { + match wai::live_events::kind_name(ev.kind) { + Some("join") => p.m.event(&joule_moq_mls::Event::Join { subject, keyed: true }), + Some("leave") => p.m.event(&joule_moq_mls::Event::Leave { subject, reason: 1 }), + _ => {} + } + } + } else { + let f = hex::decode(e["mls"].as_str().unwrap()).unwrap(); + p.seen.push(p.m.receive(&f).expect("every carried message applies")); + } + p.at += 1; + } + let mut acted = false; + if p.m.owes_create() { + let info = p.m.stage_create().unwrap(); + if post(addr, room, &[info]).await.0 != 200 { + p.m.withdraw(); + } + acted = true; + } else if !p.m.staged() && p.m.is_committer() && p.m.clock_pending() { + let c = p.m.stage_commit(false).unwrap(); + let frames: Vec> = std::iter::once(c.commit).chain(c.welcome).collect(); + if post(addr, room, &frames).await.0 != 200 { + p.m.withdraw(); + } + acted = true; + } + if entries.is_empty() && !acted { + return; + } + } +} + +async fn until_epochs(addr: std::net::SocketAddr, room: &str, ms: &mut [&mut Member], want: &[Option]) { + for _ in 0..200 { + for m in ms.iter_mut() { + sync(addr, room, m).await; + } + if ms.iter().map(|m| m.m.epoch()).collect::>() == want { + return; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + panic!("epochs {:?}, not {want:?}", ms.iter().map(|m| m.m.epoch()).collect::>()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn an_end_to_end_encrypted_room_keys_its_members_through_the_service() { + timeout(Duration::from_secs(240), run_e2ee()).await.expect("the test finishes in time"); +} + +async fn run_e2ee() { + use joule_moq::secure_objects::room::{Incoming, Outgoing}; + use joule_moq::secure_objects::{Plaintext, Suite}; + let keys = Keys::generate(); + let server = relay(&keys).await; + let cfg = Config::from_toml(&format!( + "listen = \"127.0.0.1:0\"\nkeys = \"/nonexistent\"\nrelay_url = \"ws://{}{}\"\n[room]\nmax_publishers = 3\njoin_wait_s = 30\ne2ee = true\n", + server.websocket_addr.unwrap(), + server.relay().config().path + )) + .unwrap(); + let svc = Service::new(Keys::from_toml(&keys.to_toml()).unwrap(), &cfg); + let listener = tokio::net::TcpListener::bind(cfg.listen).await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(svc.clone().run_clock()); + tokio::spawn(http::serve(svc.clone(), listener)); + timeout(STEP, async { + while !svc.connected.load(Ordering::Acquire) { + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await + .expect("the clock's session opens"); + + // The room's record checks against its descriptor: its authority, recording mode 0. + let (_, r) = call(addr, "GET", "/room", None, "10.0.0.9").await; + let room = r["room"].as_str().unwrap().to_string(); + let room_id: [u8; 32] = hex::decode(&room).unwrap().try_into().unwrap(); + let d = wai::live_room::Descriptor::parse(&hex::decode(r["descriptor"].as_str().unwrap()).unwrap()).unwrap(); + let record = hex::decode(r["e2ee"]["record"].as_str().expect("a confidentiality record")).unwrap(); + let (_, suite) = joule_moq_mls::Record::check(&record, &room_id, &d.authority, d.recording.mode).expect("the record checks"); + assert_eq!(suite, Suite::AesGcm128Sha256); + let clock = keys.clock.verifying_key().to_bytes(); + let member = |k: &SigningKey| Member { m: joule_moq_mls::RoomMember::new(room_id, clock, k.clone(), suite), at: 0, seen: Vec::new() }; + + // Alice and Bob are granted, leave their KeyPackages, and publish. + let (alice, bob) = (key(0x61), key(0x62)); + let (mut ma, mut mb) = (member(&alice), member(&bob)); + let mut held = Vec::new(); + let mut sessions = Vec::new(); + for (k, m, client) in [(&alice, &mut ma, "10.0.1.1"), (&bob, &mut mb, "10.0.1.2")] { + let (st, g) = ask(addr, k, "publisher", client).await; + assert_eq!(st, 200, "{g}"); + let kp = m.m.key_package().unwrap(); + let (st, v) = call(addr, "POST", "/mls/key-package", Some(&serde_json::json!({"room": room, "key_package": hex::encode(kp)})), client).await; + assert_eq!((st, v["principal"].as_str()), (200, Some(pubhex(k).as_str()))); + let c = session(&server, with_grant(&server, &hex::decode(g["grant"].as_str().unwrap()).unwrap(), k)).await; + held.push(publish_media(&c, &room, k).await); + sessions.push(c); + // Alice, the first present, creates the group through the log before Bob enters. + if sessions.len() == 1 { + until_epochs(addr, &room, &mut [m], &[Some(0)]).await; + } + } + // A KeyPackage of a principal the room did not grant is not kept. + let mut stranger = member(&key(0x69)); + let kp = stranger.m.key_package().unwrap(); + let (st, _) = call(addr, "POST", "/mls/key-package", Some(&serde_json::json!({"room": room, "key_package": hex::encode(kp)})), "10.0.1.9").await; + assert_eq!(st, 403); + + // Alice creates the group through the log; Bob follows it, and joins by her commit's Welcome. + until_epochs(addr, &room, &mut [&mut ma, &mut mb], &[Some(1), Some(1)]).await; + assert!(ma.seen.contains(&joule_moq_mls::Received::Created)); + assert!(mb.seen.contains(&joule_moq_mls::Received::Joined(1))); + // Posts the clock refuses are not carried: a second GroupInfo, a commit for an epoch + // already ended, a Welcome alone. + let (_, log) = call(addr, "GET", &format!("/mls?room={room}&from=0&wait_ms=0"), None, "10.1.1.1").await; + let mls: Vec> = log["entries"].as_array().unwrap().iter().filter_map(|e| e["mls"].as_str()).map(|h| hex::decode(h).unwrap()).collect(); + let info = mls.iter().find(|f| f[0] == joule_moq_mls::group::frame::GROUP_INFO).unwrap().clone(); + let commit = mls.iter().find(|f| f[0] == joule_moq_mls::group::frame::COMMIT).unwrap().clone(); + let welcome = mls.iter().find(|f| f[0] == joule_moq_mls::group::frame::WELCOME).unwrap().clone(); + let end = log["end"].as_u64().unwrap(); + assert_eq!(post(addr, &room, &[info]).await.0, 409); + assert_eq!(post(addr, &room, &[commit]).await.0, 409); + assert_eq!(post(addr, &room, &[welcome]).await.0, 400); + let (_, after) = call(addr, "GET", &format!("/mls?room={room}&from=0&wait_ms=0"), None, "10.1.1.1").await; + assert_eq!(after["end"].as_u64(), Some(end), "nothing refused was carried"); + + // Each opens what the other seals. + let ns = TrackNamespace::from_strs([room.as_str(), pubhex(&alice).as_str(), "video"]); + let name = TrackName::from_str("0"); + let pt = Plaintext::with_digest(b"a frame".to_vec()); + let s = ma.m.seal(&Outgoing { ns: &ns, name: &name, group: 1, object: 0, priority: 96, plaintext: &pt, immutable: &[], mutable: &[] }).unwrap(); + let got = mb.m.open(&Incoming { ns: &ns, name: &name, group: 1, object: 0, priority: 96, properties: &s.properties, payload: &s.payload }).unwrap(); + assert_eq!(got.plaintext, pt); + + // Bob leaves: the clock records it and proposes his removal; Alice commits it. + drop(held.pop()); + sessions.pop().unwrap().close(0, "done"); + until_epochs(addr, &room, &mut [&mut ma, &mut mb], &[Some(2), None]).await; + assert!(mb.seen.contains(&joule_moq_mls::Received::Removed(2))); + + // A room since replaced, or never there, has no log here. + let (st, _) = call(addr, "GET", &format!("/mls?room={}&from=0&wait_ms=0", "00".repeat(32)), None, "10.1.1.1").await; + assert_eq!(st, 410); + for c in sessions { + c.close(0, "done"); + } + server.shutdown().await; +} -- 2.54.0 (Apple Git-157) From 8e075cba2d387aa609ed39b91cc1309b206020a4 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 08:43:46 -0400 Subject: [PATCH 06/14] joule-moq-web: the room page in an end-to-end encrypted room, and the e2ee scenario (WIP) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page checks the room's record, loads the e2ee build, leaves a KeyPackage, follows the room service's log (web/room/e2ee.js), seals each frame before it is signed and published, and opens each object through §6 before it is decoded; objects of an epoch not yet reached wait. It shows the room is end-to-end encrypted, its epoch and its roster; no watching and no seeking back in such a room. The session hands each object's property list to a sink. wai-live-web's roomDescriptor gives the recording mode the record is checked against. Co-Authored-By: Claude Opus 5.5 --- .../crates/joule-moq-web/e2e/room.e2e.mjs | 209 ++++++++++++++- .../crates/joule-moq-web/e2e/run.sh | 9 +- .../crates/joule-moq-web/src/e2ee.rs | 34 +++ .../crates/joule-moq-web/src/e2ee_tests.rs | 5 + .../crates/joule-moq-web/web/moq/session.js | 10 +- .../crates/joule-moq-web/web/room/app.js | 244 ++++++++++++++++-- .../crates/joule-moq-web/web/room/e2ee.js | 221 ++++++++++++++++ .../crates/joule-moq-web/web/room/index.html | 2 + wai/wai-live-web/src/lib.rs | 3 + 9 files changed, 711 insertions(+), 26 deletions(-) create mode 100644 joule-code/joule-code-rs/crates/joule-moq-web/web/room/e2ee.js diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs index d34386fb2..00c26ffde 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs +++ b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs @@ -4,7 +4,7 @@ // // node e2e/room.e2e.mjs --relay --wai --web // --corpus [--browser ] -// [--only compose,webtransport,websocket,goaway,dvr,service] [--runs N] [--between ] +// [--only compose,webtransport,websocket,goaway,dvr,service,e2ee] [--runs N] [--between ] // [--out ] [--room ] [--base ] // // Scenarios: @@ -37,7 +37,19 @@ // typed human caption show their labels, a late joiner gets earlier groups, a // viewer watches, a grant with no proof or a proof by another key is refused // the session, a publisher past the room's places is told it is full, and the -// room records a participant leaving. +// room records a participant leaving; +// e2ee an end-to-end encrypted room (WAI confidentiality) kept by a room service: +// the page says so before anyone enters; each page checks the room's record, +// keys through the service's log (its KeyPackage, the clock's proposals, a +// GroupInfo, commits, Welcomes), seals every frame before it is signed and +// opens every object before it is decoded; media flows both ways and decodes, +// the page shows its epoch and roster, a subscriber outside the group (a +// viewer) receives exactly the sealed bytes and opens nothing, every change a +// relay could make to an object is refused with its reason, an object changed +// on the wire is refused bad_tag, a late joiner opens nothing sealed before +// its join, a member that stops publishing is removed and opens no later +// epoch while still receiving, an object it sealed before is refused +// sender_removed, and the relay's DVR store keeps nothing of the room. // // With `--base ` the service scenario runs against a deployment (the page at // /room/, its service and relay behind the same origin): nothing is started here, @@ -57,7 +69,7 @@ const BASE = args.base ? args.base.replace(/\/$/, "") : null; const WAI = need("wai"); const [RELAY, WEB, CORPUS] = BASE ? [null, null, null] : [need("relay"), need("web"), need("corpus")]; const ROOM = args.room ? path.resolve(args.room) : null; -const ONLY = (args.only || (BASE ? "service" : `compose,webtransport,websocket,goaway,dvr${ROOM ? ",service" : ""}`)).split(","); +const ONLY = (args.only || (BASE ? "service" : `compose,webtransport,websocket,goaway,dvr${ROOM ? ",service,e2ee" : ""}`)).split(","); const RUNS = Number(args.runs || 1); const OUT = args.out ? path.resolve(args.out) : tmpdir("out"); fs.mkdirSync(OUT, { recursive: true }); @@ -627,6 +639,195 @@ async function scenarioDvr(run) { check(`${label}: the relay served the seek back from its DVR window`, fromWindow > 0, `${fromWindow} objects from the window over ${lines.length} sessions`); } +// ------------------------------------------------------------------------------------ e2ee + +// The length a sealed video object has (confidentiality §4.4, §4.8): the payload's varint +// length, the payload, the Encrypted Properties List with WAI_INNER_DIGEST (2 + 1 + 2 + 1 + +// 32 bytes), and the 16-byte tag. +const vlen = (n) => (n < 64 ? 1 : n < 16384 ? 2 : n < 2 ** 30 ? 4 : 8); +const sealedLen = (n) => vlen(n) + n + 38 + 16; +const WAI1 = Buffer.from("WAI1").toString("hex"); +const sum = (o) => Object.values(o || {}).reduce((a, b) => a + b, 0); + +async function scenarioE2ee(run) { + const label = "e2ee"; + const dir = path.join(OUT, `run${run}-e2ee`); + fs.mkdirSync(dir, { recursive: true }); + if (!ROOM) throw new Error("--room is needed for the e2ee scenario"); + const k = roomKeys(ROOM, dir); + const door = ["audio", "video"].map((m) => ({ prefix: ["*", "*", m], trust_field: 1, hold_ms: 2000 })); + // A DVR window over every track: the relay keeps nothing of a room whose objects are sealed. + const relay = await startRelay(RELAY, { no_quic: true, auth: true, door, extra: k.lines, summary_out: path.join(dir, "summaries.jsonl"), limits: { max_session_ms: 600000, max_ingress_kbps: 3000 }, dvr: { path: path.join(dir, "dvr-store"), max_age_s: 900, max_bytes: 256 * 1024 * 1024 } }, dir, "relay"); + const svc = await startRoomService(ROOM, { keys: k.keys, relayWs: relay.wsUrl, room: { max_publishers: 4, e2ee: true } }, dir); + const srv = await serveSameOrigin(WEB, { service: svc.url, relayPort: relay.ws }); + const base = `${srv.url}/room/?service=/room-api`; + const url = (extra = {}) => { const u = new URL(base); for (const [kk, v] of Object.entries(extra)) u.searchParams.set(kk, v); return u.toString(); }; + const browser = await launch(dir); + const pages = []; + const open = async (name, extra) => { + const p = await page(browser, url(extra), name, dir); + pages.push(p); + await until(`${name}'s page ready`, STEP, () => p.evaluate(() => window.roomTest && window.roomTest.state && window.roomTest.state.phase === "idle"), 200); + return p; + }; + const enter = async (name, extra, role, phase) => { + const p = await open(name, extra); + if (role === "publisher") await p.click("#join"); else await p.evaluate((r) => { window.roomTest.enter(r).catch(() => {}); }, role); + const s = await waitPhase(p, phase); + return { p, principal: s.me.principal, room: s.room }; + }; + const snap = async (p) => { const s = await p.snap(); if (s.errors.length) throw new Error(`${p.name}: ${s.errors[0]}`); return s; }; + const ep = (s) => s.e2ee && s.e2ee.member.epoch; + try { + // Before anyone enters, the page says the room is end-to-end encrypted, and offers no + // watching and no seeking back. + const first = await open("alice", {}); + const banner = await until("the page shows the room is end-to-end encrypted", STEP, () => first.evaluate(() => { + const e = document.getElementById("e2ee"); + return e && !e.hidden && { text: e.textContent, watch: document.getElementById("watch").hidden, dvr: document.getElementById("dvr").hidden }; + }), 200); + check(`${label}: the page says the room is end-to-end encrypted before entering, with no watching and no seeking back`, /End-to-end encrypted/.test(banner.text) && banner.watch && banner.dvr, banner.text.replace(/\s+/g, " ").slice(0, 160)); + await first.click("#join"); + const sa0 = await waitPhase(first, "publishing"); + const A = { p: first, principal: sa0.me.principal, room: sa0.room }; + const alice = A.p; + // Alice is the first present: the room owes her the group, which she creates through + // the service's log. + await until(`${label} alice creates the group`, STEP, async () => ep(await snap(alice)) === 0, 250); + const B = await enter("bob", {}, "publisher", "publishing"); + const bob = B.p; + const keyed = await until(`${label} bob joins the group by alice's commit`, STEP, async () => { + const [x, y] = [await snap(alice), await snap(bob)]; + return ep(x) != null && ep(x) === ep(y) && ep(y) >= 1 && [x, y]; + }, 250); + const sb0 = keyed[1]; + check(`${label}: the room's record checks, and bob joins the group alice created, by her commit's Welcome`, sb0.e2ee.suite === 4 && sb0.e2ee.mls.joined === 1 && keyed[0].e2ee.mls.created === 1 && keyed[0].e2ee.posts.ok >= 2, + `suite ${sb0.e2ee.suite}; alice: ${JSON.stringify(keyed[0].e2ee.mls)}, ${keyed[0].e2ee.posts.ok} posts carried; bob: ${JSON.stringify(sb0.e2ee.mls)}, epoch ${ep(sb0)}`); + + // Media both ways: every object verified against its receipt (over the sealed bytes), + // opened, and only then decoded. + for (const [p, other] of [[alice, B.principal], [bob, A.principal]]) { + await until(`${label} ${p.name} decodes the other's media`, STEP * 2, async () => { + const s = await snap(p); + const t = s.subs[other]; + return t && t.video.decoded >= 30 && t.audio.decoded >= 50 && t.video.e2ee && t.video.e2ee.opened >= 30; + }, 500); + } + for (const [p, other] of [[alice, B.principal], [bob, A.principal]]) { + const s = await snap(p); + mediaChecks(`${label} ${p.name}`, s, other, { minVideo: 30, minAudio: 50 }); + const v = s.subs[other].video.e2ee, a = s.subs[other].audio.e2ee; + const others = Object.keys({ ...v.refused, ...a.refused }).filter((r) => r !== "epoch_not_joined"); + check(`${label}: ${p.name} opens every object of the other's before it decodes it, and refuses none but those sealed before its join`, v.opened >= 30 && a.opened >= 50 && others.length === 0 && s.subs[other].video.decoded <= v.opened && s.subs[other].audio.decoded <= a.opened * 3, + `video ${v.opened} opened (by epoch ${JSON.stringify(v.by_epoch)}), ${s.subs[other].video.decoded} decoded, refused ${JSON.stringify(v.refused)}, ${v.held} held, ${v.held_expired} expired; audio ${a.opened} opened, refused ${JSON.stringify(a.refused)}`); + } + const sa = await snap(alice); + check(`${label}: alice sealed every frame she sent once keyed, and sent nothing before`, sa.e2ee.sealed.video >= 30 && sa.e2ee.sealed.audio >= 50 && Math.abs(sa.published.video - sa.e2ee.sealed.video) <= 3 && Math.abs(sa.published.audio - sa.e2ee.sealed.audio) <= 3, + `sealed ${JSON.stringify(sa.e2ee.sealed)} (by epoch ${JSON.stringify(sa.e2ee.sealed_by_epoch)}); published ${sa.published.video} video, ${sa.published.audio} audio; ${JSON.stringify(sa.e2ee.unkeyed)} encoded before her group existed, not sent`); + const dom = sa.e2ee.dom || ""; + check(`${label}: the page shows the room is end-to-end encrypted, its epoch and its two members`, new RegExp(`epoch ${ep(sa)}`).test(dom) && /2 members/.test(dom) && /you/.test(dom), dom.replace(/\s+/g, " ").slice(0, 200)); + const seek = await alice.evaluate(() => window.roomTest.seekBack(5).then(() => "seeked", (e) => String(e.message || e))); + check(`${label}: there is no seeking back in such a room`, /no seeking back/.test(seek), seek); + + // A subscriber outside the group (a viewer, never a member): it receives the sealed + // bytes exactly as alice sealed them, and opens nothing. + const V = await enter("viewer", {}, "viewer", "watching"); + const sv = await until(`${label} the viewer receives alice's sealed video`, STEP * 2, async () => { + const s = await snap(V.p); + const t = s.subs[A.principal]; + return t && t.video.received >= 15 && t.video.e2ee && t.video.e2ee.samples.length >= 5 && s; + }, 500); + const tv = sv.subs[A.principal].video; + const sent = await alice.evaluate((g) => window.roomTest.e2eeSent(g), tv.e2ee.samples.map((x) => x.group)); + const same = tv.e2ee.samples.filter((x) => sent.some((y) => y.group === x.group && y.sealed_head === x.sealed_head && y.sealed_len === x.sealed_len)); + const sizes = sent.every((y) => y.sealed_len === sealedLen(y.plain_len) && y.plain_head.startsWith(WAI1) && !y.sealed_head.includes(WAI1) && y.sealed_head !== y.plain_head); + check(`${label}: a subscriber outside the group receives exactly the bytes alice sealed: no envelope, the AEAD's length, nothing it can open`, same.length >= 3 && same.length === sent.length && sizes && tv.decoded === 0 && tv.e2ee.opened === 0 && Object.keys(tv.e2ee.refused).every((r) => r === "epoch_not_joined"), + `${same.length} of ${tv.e2ee.samples.length} sampled groups byte for byte as sealed (${sent.length} of them still on alice's record); ${tv.received} received, ${tv.decoded} decoded, ${tv.e2ee.opened} opened, refused ${JSON.stringify(tv.e2ee.refused)}; e.g. plain ${sent[0] ? sent[0].plain_head.slice(0, 16) : "-"}… sealed ${sent[0] ? sent[0].sealed_head.slice(0, 16) : "-"}…`); + + // Every change a relay could make to an object, presented to bob before he opens it, + // then the object as it came, then once more. + const ts = await bob.evaluate((P) => window.roomTest.e2eeTamperSuite(P), A.principal); + const want = { payload_byte_changed: "bad_tag", priority_changed: "bad_tag", moved_to_another_group: "bad_tag", key_id_removed: "key_id_missing", key_id_outside_too: "properties_malformed", object_id_past_32_bits: "object_id_too_large", kid_of_an_empty_leaf: "key_id_unknown", kid_of_this_member: "sender_not_owner", as_sent: "opened", again: "replayed" }; + const wrong = Object.entries(want).filter(([kk, v]) => ts.outcomes[kk] !== v); + check(`${label}: each change a relay could make to an object is refused with the extension's reason, and the object itself opens once`, wrong.length === 0, `group ${ts.group}, epoch ${ts.epoch}: ${Object.entries(ts.outcomes).map(([kk, v]) => `${kk} ${v}`).join(", ")}`); + // On the wire: alice's next video object has a byte changed after sealing, its receipt + // over the bytes as they travel, so the relay's door passes it; bob refuses it. + const badBefore = ((await snap(bob)).subs[A.principal].video.e2ee.refused.bad_tag || 0); + await alice.evaluate(() => window.roomTest.e2eeTamperNext()); + const tampered = await until(`${label} alice sends a changed object`, STEP, async () => { const s = await snap(alice); return s.e2ee.tampered.length === 1 && s.e2ee.tampered[0]; }, 200); + const badAfter = await until(`${label} bob refuses it`, STEP, async () => { const n = (await snap(bob)).subs[A.principal].video.e2ee.refused.bad_tag || 0; return n > badBefore && n; }, 250); + check(`${label}: an object changed on the wire is refused bad_tag and never decoded`, badAfter === badBefore + 1, `group ${tampered}: bad_tag ${badBefore} → ${badAfter}`); + + // Carol joins late and fetches the earlier groups: nothing sealed before her join opens. + const C = await enter("carol", { fetch: "1" }, "publisher", "publishing"); + const carol = C.p; + const three = await until(`${label} carol joins`, STEP * 2, async () => { + const ss = [await snap(alice), await snap(bob), await snap(carol)]; + return ss.every((x) => ep(x) != null && ep(x) === ep(ss[0])) && ss; + }, 250); + const joinedAt = three[2].e2ee.joined; + check(`${label}: carol joins at a new epoch, and every page holds the same roster`, joinedAt > ep(sa) && three.every((x) => Object.keys(x.e2ee.member.roster).length === 3) && JSON.stringify(three[0].e2ee.member.roster) === JSON.stringify(three[2].e2ee.member.roster), + `epoch ${joinedAt}; roster ${JSON.stringify(three[2].e2ee.member.roster).slice(0, 220)}`); + const late = await until(`${label} carol's fetch of the earlier groups is accounted for`, STEP * 2, async () => { + const s = await snap(carol); + const t = s.subs[A.principal] && s.subs[A.principal].video; + return t && t.fetched >= 15 && t.e2ee && t.e2ee.fetched_opened + sum(t.e2ee.fetched_refused) >= t.fetched - 2 && t.decoded > 0 && t; + }, 500); + const fe = late.e2ee; + const early = Object.keys(fe.fetched_epochs).map(Number).filter((e) => e < joinedAt); + check(`${label}: a late joiner opens nothing sealed before its join: every earlier group it fetched is refused epoch_not_joined`, (fe.fetched_refused.epoch_not_joined || 0) >= 10 && early.length === 0 && Object.keys(fe.fetched_refused).every((r) => r === "epoch_not_joined"), + `${late.fetched} fetched (groups ${late.first_fetched_group}..${late.last_fetched_group}): ${fe.fetched_opened} opened (epochs ${JSON.stringify(fe.fetched_epochs)}), refused ${JSON.stringify(fe.fetched_refused)}; live ${late.decoded} decoded`); + + // Carol keeps back an object sealed under her epoch, then stops publishing and stays: + // the clock records her leave and proposes her removal, and a member commits it. + const kept = await carol.evaluate(() => window.roomTest.e2eeSealAsMe(4000000000)); + const ended = await carol.evaluate(() => window.roomTest.e2eeStopPublishing()); + const removed = await until(`${label} carol is removed`, STEP, async () => { + const ss = [await snap(alice), await snap(bob), await snap(carol)]; + return ss[2].e2ee.removed != null && ep(ss[0]) === ss[2].e2ee.removed && ep(ss[1]) === ep(ss[0]) && ss; + }, 250); + const re = removed[2].e2ee.removed; + check(`${label}: a member that stops publishing is removed by the next commit, and keeps its session`, ended === 4 && removed[2].phase === "publishing" && Object.keys(removed[0].e2ee.member.roster).length === 2 && removed[2].e2ee.member.epoch == null, + `${ended} tracks ended; epoch ${re}; alice's roster ${Object.keys(removed[0].e2ee.member.roster).length}; carol ${removed[2].phase}`); + // At once, while alice still holds the epoch carol sealed it in (§5.8). + const sr = await alice.evaluate((o) => window.roomTest.e2eeTryOpen(o), kept); + check(`${label}: an object carol sealed before her removal is refused sender_removed by a member who applied it`, sr.reason === "sender_removed", `sealed in epoch ${kept && kept.epoch}: ${JSON.stringify(sr)}`); + const cut = await until(`${label} carol receives the new epoch and opens none of it`, STEP * 2, async () => { + const s = await snap(carol); + const t = s.subs[A.principal].video; + return (t.e2ee.refused.removed_before_epoch || 0) >= 10 && t; + }, 500); + const after = Object.keys(cut.e2ee.by_epoch).map(Number).filter((e) => e >= re); + check(`${label}: a removed member still receives the room's objects and opens none of a later epoch`, after.length === 0 && (cut.e2ee.refused.removed_before_epoch || 0) >= 10, + `refused ${JSON.stringify(cut.e2ee.refused)}; opened by epoch ${JSON.stringify(cut.e2ee.by_epoch)}`); + for (const [p, other] of [[alice, B.principal], [bob, A.principal]]) { + const t = await until(`${label} ${p.name} opens the other's objects of epoch ${re}`, STEP, async () => { const x = (await snap(p)).subs[other].video; return (x.e2ee.by_epoch[re] || 0) >= 10 && x; }, 500); + check(`${label}: ${p.name} opens the other's objects of the epoch that excludes carol`, true, `${t.e2ee.by_epoch[re]} opened in epoch ${re}`); + } + + // The relay: its door checked every object against its receipt, and its DVR store keeps + // nothing of the room. + const sealedLine = relay.lines.find((l) => l.includes("a room's objects are sealed") && l.includes(A.room)); + check(`${label}: the relay's DVR store keeps nothing of the room`, !!sealedLine, (sealedLine || "no line").replace(/^.*INFO /, "").slice(0, 200)); + const refusedAtDoor = relay.lines.filter((l) => l.includes("refused at the door")).length; + check(`${label}: the relay's door verified every object against its receipt over the sealed bytes, and refused none`, refusedAtDoor === 0, `${refusedAtDoor} refused`); + for (const p of pages) { + const s = await p.snap(); + fs.writeFileSync(path.join(dir, `${p.name}.snapshot.json`), JSON.stringify(s, null, 1)); + check(`${label}: ${p.name} reports no error`, s.errors.length === 0 && !(s.e2ee && s.e2ee.stopped), [...s.errors.slice(0, 2), s.e2ee && s.e2ee.stopped].filter(Boolean).join(" | ")); + } + } finally { + for (const p of pages) { try { p.dump(); } catch { /* closed */ } } + const bounded = (x) => Promise.race([Promise.resolve(x).catch(() => {}), sleep(15000)]); + for (const p of pages) await bounded(p.evaluate(() => window.roomTest.leave && window.roomTest.leave())); + await bounded(browser.close()); + await bounded(srv.close()); + await bounded(svc.stop()); + await bounded(relay.stop()); + killStrays(); + } +} + // ------------------------------------------------------------------------------------- main let passedRuns = 0; @@ -639,7 +840,7 @@ for (let run = 1; run <= RUNS; run++) { const before = failed; const t = Date.now(); console.log(`== run ${run} of ${RUNS}`); - for (const [name, f] of [["compose", scenarioCompose], ["webtransport", (r) => scenarioRoom(r, "webtransport")], ["websocket", (r) => scenarioRoom(r, "websocket")], ["goaway", scenarioGoaway], ["dvr", scenarioDvr], ["service", scenarioService]]) { + for (const [name, f] of [["compose", scenarioCompose], ["webtransport", (r) => scenarioRoom(r, "webtransport")], ["websocket", (r) => scenarioRoom(r, "websocket")], ["goaway", scenarioGoaway], ["dvr", scenarioDvr], ["service", scenarioService], ["e2ee", scenarioE2ee]]) { if (!ONLY.includes(name)) continue; try { await f(run); diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/run.sh b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/run.sh index a4434a775..1f648e6df 100755 --- a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/run.sh +++ b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/run.sh @@ -1,12 +1,15 @@ #!/bin/bash # Build what the browser MoQ client's tests need, and run them. # -# e2e/run.sh [--only compose,webtransport,websocket,goaway,service] [--runs N] +# e2e/run.sh [--only compose,webtransport,websocket,goaway,dvr,service,e2ee] [--runs N] # # 1. joule-moq-relay, joule-moq-room (its own workspace) and wai_live_web (native, # release: the test room and the host's composition); # 2. joule-moq-web and wai-live-web for wasm32-unknown-unknown, through wasm-bindgen's web -# target into web/pkg/, each with its own --out-name; +# target into web/pkg/, each with its own --out-name; and joule-moq-web again with its +# feature e2ee (the member of an end-to-end encrypted room), under the workspace's `wasm` +# profile and with no name section, as joule_moq_web_e2ee, which a page loads for such a +# room alone; # 3. the browser-automation package from e2e/package-lock.json (`npm ci --ignore-scripts`: # no install script runs), outside the tree unless PLAYWRIGHT_HOST names one installed; # 4. the Node unit tests of the page's own modules; @@ -46,6 +49,8 @@ command -v wasm-bindgen >/dev/null || { echo "wasm-bindgen CLI 0.2.127 is needed wasm-bindgen --version | grep -q ' 0\.2\.127$' || { echo "the wasm-bindgen CLI must be 0.2.127, the version both crates pin"; exit 2; } (cd "$JC" && cargo build $LOCKED --release -p joule-moq-web --lib --target wasm32-unknown-unknown) wasm-bindgen --target web --out-dir "$PKG" --out-name joule_moq_web "$TARGET/wasm32-unknown-unknown/release/joule_moq_web.wasm" +(cd "$JC" && cargo build $LOCKED --profile wasm -p joule-moq-web --lib --target wasm32-unknown-unknown --features e2ee) +wasm-bindgen --target web --remove-name-section --remove-producers-section --out-dir "$PKG" --out-name joule_moq_web_e2ee "$TARGET/wasm32-unknown-unknown/wasm/joule_moq_web.wasm" (cd "$WLW" && cargo build $LOCKED --release --lib --target wasm32-unknown-unknown) wasm-bindgen --target web --out-dir "$PKG" --out-name wai_live_web "$TARGET/wasm32-unknown-unknown/release/wai_live_web.wasm" ls -l "$PKG"/*.wasm diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/src/e2ee.rs b/joule-code/joule-code-rs/crates/joule-moq-web/src/e2ee.rs index 99d81bff2..18fc5ded8 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/src/e2ee.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-web/src/e2ee.rs @@ -311,6 +311,40 @@ impl E2eeMember { } } +/// An object's property list changed as a relay could change it, for a test that plays +/// that relay: `kind` is `drop_key_id` (the Immutable Properties removed, so no Key ID), +/// `key_id_outside` (a Key ID added outside them as well), or `key_id` (the Key ID inside +/// them replaced by `kid`, every other property kept). +pub fn altered_properties(properties: &[u8], kind: &str, kid: u64) -> Result, String> { + let mut p = Properties::decode(properties, "properties").map_err(|e| e.to_string())?; + match kind { + "drop_key_id" => p.0.retain(|k| k.ty != prop::IMMUTABLE_PROPERTIES), + "key_id_outside" => { + p.push(KeyValuePair::varint(joule_moq::secure_objects::KEY_ID_PROPERTY, kid)); + } + "key_id" => { + let inner = p.immutable().map_err(|e| e.to_string())?.unwrap_or_default(); + let others: Vec = inner.0.into_iter().filter(|k| k.ty != joule_moq::secure_objects::KEY_ID_PROPERTY).collect(); + let sip = joule_moq::secure_objects::immutable_properties(kid, &others); + let mutable: Vec = p.0.into_iter().filter(|k| k.ty != prop::IMMUTABLE_PROPERTIES).collect(); + p = joule_moq::secure_objects::object_properties(&sip, &mutable); + } + other => return Err(format!("no alteration `{other}`")), + } + p.to_bytes().map_err(|e| e.to_string()) +} + +#[wasm_bindgen(js_name = e2eeAlteredProperties)] +pub fn altered_properties_js(properties: &[u8], kind: &str, kid: u64) -> Result, JsError> { + altered_properties(properties, kind, kid).map_err(js) +} + +/// The KID of a member at leaf `leaf` in `epoch` (§5.7). +#[wasm_bindgen(js_name = e2eeKid)] +pub fn kid(epoch: u64, leaf: u32) -> Option { + joule_moq::secure_objects::room::key_id(epoch, leaf) +} + /// The epoch and leaf index a KID names (§5.7), as `[epoch, leaf]`. #[wasm_bindgen(js_name = e2eeSplitKid)] pub fn split_kid(kid: u64) -> Vec { diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/src/e2ee_tests.rs b/joule-code/joule-code-rs/crates/joule-moq-web/src/e2ee_tests.rs index 8c6d5b00b..9e9ef07b6 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/src/e2ee_tests.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-web/src/e2ee_tests.rs @@ -174,6 +174,11 @@ fn two_pages_key_seal_sign_open_and_refuse_through_one_log() { assert_eq!(open(&mut b, 1, g, 0, prio, &[0xff], &payload).0["reason"], "properties_malformed"); let bare = Properties::new().to_bytes().unwrap(); assert_eq!(open(&mut b, 1, g, 0, prio, &bare, &payload).0["reason"], "key_id_missing"); + // The alterations a test page plays a relay with. + assert_eq!(open(&mut b, 1, g, 0, prio, &altered_properties(&props, "drop_key_id", 0).unwrap(), &payload).0["reason"], "key_id_missing"); + assert_eq!(open(&mut b, 1, g, 0, prio, &altered_properties(&props, "key_id_outside", 1 << 24).unwrap(), &payload).0["reason"], "properties_malformed"); + assert_eq!(open(&mut b, 1, g, 0, prio, &altered_properties(&props, "key_id", (1 << 24) | 5).unwrap(), &payload).0["reason"], "key_id_unknown"); + assert_eq!(open(&mut b, 1, g, 0, prio, &altered_properties(&props, "key_id", (1 << 24) | 1).unwrap(), &payload).0["reason"], "sender_not_owner", "B's KID on A's track"); let mut twice = p.clone(); twice.push(KeyValuePair::varint(joule_moq::secure_objects::KEY_ID_PROPERTY, 1)); assert_eq!(open(&mut b, 1, g, 0, prio, &twice.to_bytes().unwrap(), &payload).0["reason"], "properties_malformed"); diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/web/moq/session.js b/joule-code/joule-code-rs/crates/joule-moq-web/web/moq/session.js index 01a86a50a..12a49fdd6 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/web/moq/session.js +++ b/joule-code/joule-code-rs/crates/joule-moq-web/web/moq/session.js @@ -54,8 +54,11 @@ class Queue { } export class MoqSession { - constructor(transport, moq, { token, grant, implementation = "joule-moq-web", log = () => {}, maxPayload = 4 << 20 } = {}) { + constructor(transport, moq, { token, grant, implementation = "joule-moq-web", log = () => {}, maxPayload = 4 << 20, properties = false } = {}) { this.t = transport; + // Each object's property list, encoded, as its fourth argument to a sink: what a + // member of an end-to-end encrypted room opens an object with. + this.properties = properties; this.moq = moq; this.token = token; // { record, seed }: a room grant's record and its principal's 32-byte seed. @@ -192,7 +195,8 @@ export class MoqSession { } else if (k === "object" || k === "fetch_object") { this.counters.objects++; const payload = item.payload(); - sink.object && sink.object(j, payload, k); + const props = this.properties ? item.properties() : null; + sink.object && sink.object(j, payload, k, props); } else if (k === "end_of_range") { sink.endOfRange && sink.endOfRange(j.end_of_range); } @@ -336,7 +340,7 @@ export class MoqSession { this.fetches.set(id, { order: (params && params.group_order) || 1, stream: () => ({ - object: (j, payload) => { entries.objects++; onObject(j, payload); }, + object: (j, payload, k, props) => { entries.objects++; onObject(j, payload, props); }, endOfRange: (e) => entries.ranges.push(e), end: () => finished("fin"), reset: (code) => finished(`reset ${code}`), diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js index 8706ba89f..d5f9de1c5 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js +++ b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js @@ -27,15 +27,34 @@ // its own, and returns to live; `roomTest.seekBack(seconds)` does the same) // audioFrameMs= the Opus frame duration, 20 by default // +// An end-to-end encrypted room (WAI confidentiality): the room service names the room's +// confidentiality record (`GET /room`'s `e2ee`). The page checks it, loads the build of the +// codec with the room's MLS member (`joule_moq_web_e2ee`), leaves a KeyPackage, and follows +// the room's log (./e2ee.js). It seals each encoded frame before it is signed and published, +// and opens every object it receives through the receiving checks in their order before +// any decoder sees it; an object that does not open is counted with its reason and never +// decoded. Such a room has no seeking back: the relay keeps nothing of it. +// // Everything a test reads is on `window.roomTest`. -import initMoq, * as moq from "../pkg/joule_moq_web.js"; import initWai, * as wai from "../pkg/wai_live_web.js"; +import { E2ee } from "./e2ee.js"; import { connect } from "../moq/transport.js"; import { MoqSession, RequestRefused, withTimeout } from "../moq/session.js"; import * as media from "../moq/media.js"; import { Compositor } from "./compose.js"; +// The codec's wasm: the plain build, or, for an end-to-end encrypted room, the build with +// the member of the room's MLS group, loaded for such a room alone. +let moq = null; +async function loadMoq(sealed) { + if (moq) return moq; + const m = await import(sealed ? "../pkg/joule_moq_web_e2ee.js" : "../pkg/joule_moq_web.js"); + await m.default(); + moq = m; + return m; +} + const q = new URLSearchParams(location.search); const $ = (id) => document.getElementById(id); const hex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); @@ -83,6 +102,8 @@ const state = { // Seeking back into the relay's DVR window: "live", "seeking" (fetching and verifying) // or "playing", and each run's counts. dvr: { mode: "live", runs: [] }, + // An end-to-end encrypted room: the record's suite, and what this page sealed. + e2ee: null, }; window.roomTest = { state, logLines }; @@ -166,7 +187,7 @@ class EventsTrack { // --------------------------------------------------------------------------------- room -let fx, me, roomId, t0, room, compositor, session, transportOpts, eventsTrack, service; +let fx, me, roomId, t0, room, compositor, session, transportOpts, eventsTrack, service, e2ee; const subscribed = new Map(); // principal -> subscription state const signers = {}; const published = []; // the PUBLISH requests held open @@ -195,7 +216,7 @@ function readGrants() { } async function boot() { - await initMoq(); + await loadMoq(false); await initWai(); fx = await (await fetch(q.get("fixture"))).json(); const role = q.get("role") || "participant"; @@ -227,10 +248,11 @@ async function enter(role) { if (state.mode) throw new Error("this page has entered already"); state.mode = "service"; state.phase = "entering"; - await initMoq(); - await initWai(); let r = await api("/room"); if (r.status !== 200) throw new Error(`the room service answered ${r.status}`); + const sealed = !!r.body.e2ee; + await loadMoq(sealed); + await initWai(); let stream = null; if (role === "publisher") { // The camera and microphone first: a room place is asked for only once they are on. @@ -254,14 +276,37 @@ async function enter(role) { if (r.status !== 200) throw new Error(`the room service answered ${r.status}${r.body && r.body.error ? ` (${r.body.error})` : ""}`); const v = r.body.room; const d = JSON.parse(wai.roomDescriptor(unhex(v.descriptor))); + if (sealed !== !!v.e2ee) throw new Error("the room the grant is for is not end-to-end encrypted as the room was"); fx = { room: v.room, t0_us: v.t0_us, descriptor: v.descriptor, compose: v.compose, grants: v.grants, clock: { principal: d.clock }, participants: [] }; me = { name: "you", seed: hex(seed), principal, grant: r.body.grant, grant_digest: r.body.digest, role }; state.grant = { role, exp: r.body.exp, digest: r.body.digest }; + if (sealed) { + // The room's confidentiality record (confidentiality §3.3), checked against the room + // the grant is for: its id, its authority and its recording mode. + const c = JSON.parse(moq.e2eeCheckRecord(unhex(v.e2ee.record), unhex(v.room), unhex(d.authority), d.recording_mode)); + if (!c.ok) throw new Error(`the room's confidentiality record is refused: ${c.reason}`); + state.e2ee = { suite: c.suite, record: v.e2ee.record.slice(0, 16), tampered: [] }; + e2ee = new E2ee({ + moq, wai, api, log, room: v.room, clock: d.clock, seed, suite: c.suite, role, + grantOf: (digest) => (state.grants || []).find((g) => g.digest === digest), + refreshGrants: () => refreshGrants(), + onRecord: (bytes) => { records = records.then(() => onRecord(bytes)).catch(fail); }, + onChange: onKeys, + }); + if ($("dvr")) $("dvr").hidden = true; + status("Leaving a key package with the room…"); + if (role === "publisher") await e2ee.keyPackage(); + } const ws = q.get("ws") || `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/moq`; transportOpts = { webtransport: null, websocket: ws, hashes: [], force: "websocket" }; window.roomTest.role = ROLE_OF[role]; await begin(ROLE_OF[role], stream); pollRoom(); + if (e2ee) { + e2ee.follow(() => state.phase !== "left").catch(fail); + status(role === "publisher" ? "You are in the room. It is end-to-end encrypted: your media is sealed before it leaves this page, and sent once the room's group has keyed you." : "Watching. This room is end-to-end encrypted, and a viewer is not a member of its group: it opens nothing."); + renderE2ee(); + } return state.phase; } @@ -314,7 +359,7 @@ async function openSession(opts) { // A participant presents its room grant with a proof by its key (live-rooms §10.5.2); // the clock, a room key with no grant, presents its own token. const id = me.grant ? { grant: { record: unhex(me.grant), seed: unhex(me.seed) } } : { token: unhex(me.token) }; - const s = await MoqSession.connect(t, moq, { ...id, implementation: "joule-moq-web/room", log }); + const s = await MoqSession.connect(t, moq, { ...id, implementation: "joule-moq-web/room", log, properties: !!e2ee }); state.transport = { kind: t.kind, protocol: t.protocol }; state.sessions++; s.onGoaway = (g) => onGoaway(s, g); @@ -486,7 +531,7 @@ async function startPublishing(given) { async function publishTracks(s) { const P = me.principal; - const pub = async (ns, name, alias, props) => published.push(await s.publish(ns, name, alias, props)); + const pub = async (ns, name, alias, props) => published.push(Object.assign(await s.publish(ns, name, alias, props), { ns, name })); await pub([fx.room, P], "catalog", ALIAS.catalog, { publisher_priority: PRIORITY.catalog }); for (const m of ["audio", "video"]) { await pub([fx.room, P, m], "0_receipts", ALIAS[`${m}Receipts`], { publisher_priority: PRIORITY[m] }); @@ -518,9 +563,33 @@ async function pump(readable, each) { // moq-streaming-format §4.1), then the object, each on a stream of its own. async function sendGroup(m, g, envelope) { const s = session; + if (state.stoppedPublishing) return; try { const now = new Date().toISOString(); - const sg = signers[m].sign(BigInt(g), envelope, now, now); + let sg; + if (e2ee) { + // Sealed before it is signed (confidentiality §4.9, §8.1): the receipt is over the + // sealed bytes. Until the room's group has keyed this page, nothing is sent. + const sl = e2ee.seal([fx.room, me.principal, m], g, PRIORITY[m], envelope, m === "video", m === "video" ? VIDEO_TIMEOUT_MS : AUDIO_TIMEOUT_MS); + if (!sl) { e2ee.stats.unkeyed[m]++; return; } + if (m === "video" && state.e2ee.tamperNext) { + // A test plays a relay that changes a byte: signed as it travels, so the relay's + // door passes it, and every member refuses it bad_tag. + state.e2ee.tamperNext = false; + sl.payload[Math.min(5, sl.payload.length - 1)] ^= 1; + state.e2ee.tampered.push(g); + } + if (m === "video") { + // What this page sealed, by group, the last few hundred: what a test compares with + // what a subscriber outside the group received. + e2eeSent.set(g, { group: g, epoch: sl.epoch, plain_len: envelope.length, plain_head: hex(envelope.slice(0, 64)), sealed_len: sl.payload.length, sealed_head: hex(sl.payload.slice(0, 64)) }); + if (e2eeSent.size > 400) e2eeSent.delete(e2eeSent.keys().next().value); + } + e2ee.stats.sealed[m]++; + sg = signers[m].signSealed(BigInt(g), sl.payload, sl.props, now, now); + } else { + sg = signers[m].sign(BigInt(g), envelope, now, now); + } const r = sg.receiptsStream(); const md = sg.mediaStream(); sg.free(); @@ -650,18 +719,20 @@ async function subscribeMedia(sub, tr) { T.chain[r.chain] = (T.chain[r.chain] || 0) + 1; if (!st.seen.has(group)) st.receiptsAt.set(group, performance.now()); const held = st.pending.get(group); - if (held) { st.pending.delete(group); onObject(group, held.payload, held.fetched); } + if (held) { st.pending.delete(group); onObject(group, held.payload, held.fetched, held.meta); } }; - const onObject = (group, payload, fetched) => { + // `meta`: the object's Object ID, Publisher Priority and property list, which an end-to- + // end encrypted room's objects are opened with. + const onObject = (group, payload, fetched, meta) => { const r = JSON.parse(verifier.object(BigInt(group), 0n, payload)); if (!r.ok && r.reason === "no_receipt") { - if (!st.pending.has(group)) { st.pending.set(group, { payload, at: performance.now(), fetched }); T.no_receipt++; } + if (!st.pending.has(group)) { st.pending.set(group, { payload, at: performance.now(), fetched, meta }); T.no_receipt++; } return; } st.receiptsAt.delete(group); if (!r.ok) { T.refused++; log(`${m} group ${group} of ${P.slice(0, 8)} refused: ${r.reason}`); return; } if (fetched) T.fetched_verified++; else T.verified++; - decodeEnvelope(P, st, T, group, payload, fetched); + deliver(P, st, T, group, payload, fetched, meta); }; st.onObject = onObject; st.onReceipts = onReceipts; @@ -673,13 +744,13 @@ async function subscribeMedia(sub, tr) { }; const mh = { stream: (h) => ({ - object: (j, p) => { + object: (j, p, k, props) => { if (st.seen.has(h.group_id)) return; st.seen.add(h.group_id); T.received++; if (T.first_live_group == null) T.first_live_group = h.group_id; if (T.last_live_group == null || h.group_id > T.last_live_group) T.last_live_group = h.group_id; - onObject(h.group_id, p, false); + onObject(h.group_id, p, false, { object: j.object_id, priority: h.publisher_priority, props }); }, reset: () => { T.reset++; }, }), @@ -719,17 +790,82 @@ async function lateJoin(sub, st, T, ns, tr) { if (r.ok) { T.receipts++; T.chain[r.chain] = (T.chain[r.chain] || 0) + 1; } else T.receipts_refused++; } const objs = []; - const r2 = await fetchAgain(tr.name, (j, p) => objs.push([j.group_id, p])); - for (const [g, p] of objs) { + const r2 = await fetchAgain(tr.name, (j, p, props) => objs.push([j.group_id, p, { object: j.object_id, priority: j.publisher_priority, props }])); + for (const [g, p, meta] of objs) { T.fetched++; if (T.first_fetched_group == null) T.first_fetched_group = g; T.last_fetched_group = g; st.seen.add(g); - st.onObject(g, p, true); + st.onObject(g, p, true, meta); } log(`FETCH of ${st.m} of ${sub.principal.slice(0, 8)}: ${rec.length} receipts (${r1.how}), ${objs.length} objects (${r2.how})`); } +// An object verified against its receipt, on its way to a decoder. In an end-to-end +// encrypted room it is opened first, through the receiving checks in their order +// (confidentiality §6); one that does not open never reaches a decoder (§10). One sealed in +// an epoch this page has not reached, or sent before this page's Welcome, waits (§5.8). +const E2EE_HOLD_MS = 15000; +const e2eeSent = new Map(); +const E2EE_HOLD_MAX = 600; + +function deliver(P, st, T, group, payload, fetched, meta) { + if (!e2ee) return decodeEnvelope(P, st, T, group, payload, fetched); + const E = T.e2ee || (T.e2ee = { opened: 0, refused: {}, held: 0, held_expired: 0, by_epoch: {}, fetched_opened: 0, fetched_refused: {}, fetched_epochs: {}, samples: [] }); + if (st.intercept) { + const f = st.intercept; + st.intercept = null; + return f({ group, payload, meta, fetched }); + } + const v = e2ee.open(st.ns, st.tr.name, group, meta.object || 0, meta.priority, meta.props, payload); + if (E.samples.length < 8 && st.m === "video" && !fetched && !v.ok) E.samples.push({ group, sealed_len: payload.length, sealed_head: hex(payload.slice(0, 64)), outcome: v.reason || `pending ${v.pending}` }); + if (v.ok) { + if (fetched) E.fetched_opened++; else E.opened++; + E.by_epoch[v.epoch] = (E.by_epoch[v.epoch] || 0) + 1; + if (fetched) E.fetched_epochs[v.epoch] = (E.fetched_epochs[v.epoch] || 0) + 1; + return decodeEnvelope(P, st, T, group, v.payload, fetched); + } + if (v.pending != null || (v.reason === "epoch_not_joined" && e2ee.awaitingWelcome)) { + st.sealedHeld = st.sealedHeld || []; + if (st.sealedHeld.length >= E2EE_HOLD_MAX) { E.held_expired++; return; } + st.sealedHeld.push({ group, payload, fetched, meta, at: performance.now() }); + E.held++; + return; + } + const into = fetched ? E.fetched_refused : E.refused; + into[v.reason] = (into[v.reason] || 0) + 1; + if ((into[v.reason] || 0) <= 3) log(`${st.m} group ${group} of ${P.slice(0, 8)} does not open: ${v.reason}`); +} + +// The room's group moved: what waited is opened again, in its order; the view says so. +function onKeys() { + for (const sub of subscribed.values()) { + for (const st of Object.values(sub.tracks)) { + if (!st.sealedHeld || !st.sealedHeld.length) continue; + const T = tally(sub.principal)[st.m]; + const held = st.sealedHeld.sort((a, b) => a.group - b.group); + st.sealedHeld = []; + const now = performance.now(); + for (const h of held) { + if (now - h.at > E2EE_HOLD_MS) { T.e2ee.held_expired++; continue; } + deliver(sub.principal, st, T, h.group, h.payload, h.fetched, h.meta); + } + } + } + renderE2ee(); +} + +function renderE2ee() { + const el = $("e2ee"); + if (!el || !e2ee) return; + el.hidden = false; + const s = e2ee.state(); + const roster = s.roster ? Object.entries(s.roster).map(([leaf, p]) => `${p === me.principal ? "you" : nameOf(p)} (leaf ${leaf})`) : []; + const where = e2ee.stats.removed != null ? `removed at epoch ${e2ee.stats.removed}` : s.epoch != null ? `epoch ${s.epoch}` : e2ee.role === "publisher" ? "waiting for the room's group" : "not a member"; + $("e2ee-state").textContent = `${where}${roster.length ? ` · ${roster.length} member${roster.length === 1 ? "" : "s"}: ${roster.join(", ")}` : ""}`; + el.dataset.epoch = s.epoch != null ? String(s.epoch) : ""; +} + function makeDecoder(P, tr, T, st) { if (tr.role === "audio") { const d = new AudioDecoder({ @@ -911,6 +1047,7 @@ async function fetchVerified(st, m, from, to, c) { } async function seekBack(seconds = 10) { + if (e2ee) throw new Error("an end-to-end encrypted room has no seeking back: the relay keeps nothing of it"); if (state.dvr.mode !== "live") return state.dvr.runs[state.dvr.runs.length - 1]; const sub = subscribedVideo(); if (!sub) throw new Error("no participant's video to seek back into"); @@ -1124,6 +1261,7 @@ window.roomTest.snapshot = () => ({ audioState: compositor && compositor.ac && compositor.ac.state, session: session && { counters: session.counters, transport: session.t.stats ? session.t.stats() : {} }, verifiers: Object.fromEntries([...subscribed.entries()].map(([P, s]) => [P, Object.fromEntries(Object.entries(s.tracks).map(([m, st]) => [m, JSON.parse(st.verifier.stats())]))])), + e2ee: e2ee && { ...state.e2ee, ...e2ee.stats, at: e2ee.at, stopped: e2ee.stopped, member: e2ee.state(), dom: $("e2ee") && !$("e2ee").hidden ? $("e2ee").textContent : null }, }); // A participant's grant presented as a bare token, with no proof: the relay must refuse // the session (live-rooms §10.5.2, UNAUTHORIZED). Resolves with how the session ended. @@ -1153,6 +1291,70 @@ window.roomTest.tryWrongKey = async () => { return { transport: t.kind, setup, closed }; }; +// End-to-end encryption, for the tests. +window.roomTest.e2eeSent = (groups) => groups.map((g) => e2eeSent.get(g)).filter(Boolean); +// The next video group is sealed and then has a byte changed, as a relay could change it. +window.roomTest.e2eeTamperNext = () => { state.e2ee.tamperNext = true; }; +// An object sealed under this page's latest epoch on its own video track, not sent. +window.roomTest.e2eeSealAsMe = (group) => { + const ns = [fx.room, me.principal, "video"]; + const sl = e2ee.seal(ns, group, PRIORITY.video, enc.encode("sealed by a member, kept back"), true, VIDEO_TIMEOUT_MS); + return sl && { ns, group, priority: PRIORITY.video, props: hex(sl.props), payload: hex(sl.payload), epoch: sl.epoch }; +}; +// An object as another page sealed it, opened here through the receiving checks. +window.roomTest.e2eeTryOpen = (o) => { + const v = e2ee.open(o.ns, "0", o.group, 0, o.priority, unhex(o.props), unhex(o.payload)); + delete v.payload; + return v; +}; +// The next video object received from `P`, before it is opened, presented again with each +// change a relay could make to it, then as it came, then once more: each outcome. +window.roomTest.e2eeTamperSuite = (P) => new Promise((resolve, reject) => { + const sub = subscribed.get(P); + const st = sub && sub.tracks.video; + if (!st) return reject(new Error(`no video of ${P.slice(0, 8)}`)); + const t = setTimeout(() => { st.intercept = null; reject(new Error("no object arrived")); }, 20000); + st.intercept = (x) => { clearTimeout(t); resolve(tamperSuite(st, x)); }; +}); +// Stop publishing and stay: PUBLISH_DONE on each media track, the session and every +// subscription kept. The room's clock records the leave; the page keeps receiving. +window.roomTest.e2eeStopPublishing = async () => { + state.stoppedPublishing = true; + if (state.capture && state.capture.stream) for (const t of state.capture.stream.getTracks()) t.stop(); + let n = 0; + for (const r of published) { + if (!r.ns || !["audio", "video"].includes(r.ns[2])) continue; + await r.writer.write(moq.encodeControl(JSON.stringify({ message: "PUBLISH_DONE", status: 2, reason: "stopped publishing" }))); + n++; + } + return n; +}; + +function tamperSuite(st, x) { + const { group, payload, meta } = x; + const s = e2ee.state(); + const open = (props, pl, o = {}) => { + const v = e2ee.open(st.ns, st.tr.name, o.group != null ? o.group : group, o.object != null ? o.object : meta.object || 0, o.priority != null ? o.priority : meta.priority, props, pl); + return v.ok ? "opened" : v.reason || `pending ${v.pending}`; + }; + const alter = (kind, kid = 0n) => moq.e2eeAlteredProperties(meta.props, kind, kid); + const flipped = payload.slice(); + flipped[flipped.length - 1] ^= 1; + const out = { + payload_byte_changed: open(meta.props, flipped), + priority_changed: open(meta.props, payload, { priority: (meta.priority + 1) & 255 }), + moved_to_another_group: open(meta.props, payload, { group: group + 1000000 }), + key_id_removed: open(alter("drop_key_id"), payload), + key_id_outside_too: open(alter("key_id_outside", 1n), payload), + object_id_past_32_bits: open(meta.props, payload, { object: 2 ** 32 }), + kid_of_an_empty_leaf: open(alter("key_id", moq.e2eeKid(BigInt(s.epoch), 1048575)), payload), + kid_of_this_member: open(alter("key_id", moq.e2eeKid(BigInt(s.epoch), s.leaf)), payload), + as_sent: open(meta.props, payload), + again: open(meta.props, payload), + }; + return { group, epoch: s.epoch, outcomes: out }; +} + window.roomTest.roomJson = () => room.roomJson(); window.roomTest.compose = () => fx && fx.compose; window.roomTest.frameAt = (t) => JSON.parse(room.frame(unhex(fx.compose), BigInt(t))); @@ -1190,6 +1392,14 @@ if (service) { if ($("watch")) $("watch").addEventListener("click", go("viewer")); if ($("leave")) $("leave").addEventListener("click", () => { $("leave").disabled = true; if ($("caption-form")) $("caption-form").hidden = true; leave(); }); if ($("caption-form")) $("caption-form").addEventListener("submit", sendTypedCaption); + // Before entering: an end-to-end encrypted room says so, and offers no watching, since a + // viewer is not a member of its group and opens nothing, and no seeking back. + api("/room").then((r) => { + if (r.status !== 200 || !r.body || !r.body.e2ee) return; + if ($("e2ee")) { $("e2ee").hidden = false; $("e2ee-state").textContent = "only the room's members open its media; the relay and the room service hold no key"; } + if ($("watch")) $("watch").hidden = true; + if ($("dvr")) $("dvr").hidden = true; + }).catch(() => {}); state.phase = "idle"; } else { boot().catch(fail); diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/web/room/e2ee.js b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/e2ee.js new file mode 100644 index 000000000..9826b208b --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/e2ee.js @@ -0,0 +1,221 @@ +// The member of an end-to-end encrypted room (WAI extensions/confidentiality.md), in a page. +// +// The room service is the room's carrier of MLS messages (confidentiality §5.1): its log +// holds the clock's lifecycle records and every proposal, GroupInfo, commit and Welcome, in +// one order. The member reads it from its first entry. It checks every lifecycle record as +// the clock's (its signature, its room, its place in the clock's chain) before the room's +// MLS model takes it, and hands every MLS message to the wasm member +// (joule_moq_web_e2ee's E2eeMember), which checks each as §5 requires. When the room calls +// on it, it creates the group or commits (§5.4, §5.5), and applies what it made only once +// the log returns it in its order. The member's MLS state and every key it derives live in +// the wasm member's memory alone; nothing of them is written to storage or sent anywhere. + +const hex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); +const unhex = (h) => new Uint8Array((h.match(/../g) || []).map((x) => parseInt(x, 16))); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// An epoch's keys are deleted this long after the next epoch opens (§5.8): objects of an +// epoch still in flight open meanwhile, and nothing of it opens after. +export const FORGET_AFTER_MS = 15000; +// A post whose answer was lost is dropped after this, if the log has not returned it. +const STAGED_MAX_MS = 15000; + +export class E2ee { + // o: { moq, wai, api, log, room, clock, seed, suite, role, grantOf, refreshGrants, + // onRecord, onChange } + constructor(o) { + this.moq = o.moq; + this.wai = o.wai; + this.api = o.api; + this.log = o.log; + this.room = o.room; + this.clock = o.clock; + this.role = o.role; + this.grantOf = o.grantOf; + this.refreshGrants = o.refreshGrants; + this.onRecord = o.onRecord; + this.onChange = o.onChange || (() => {}); + this.suite = o.suite; + this.member = new o.moq.E2eeMember(unhex(o.room), unhex(o.clock), o.seed, o.suite); + this.at = 0; // the next entry of the log + this.chain = { seq: 0, prev: "00".repeat(32) }; // the clock's chain as the log carried it + this.stopped = null; + this.posting = false; + this.stagedAt = null; + this.stats = { + log: 0, events: 0, mls: {}, refused: {}, posts: { ok: 0, refused: 0 }, + epochs: [], joined: null, removed: null, forgotten: [], + sealed: { audio: 0, video: 0 }, sealed_by_epoch: {}, unkeyed: { audio: 0, video: 0 }, + }; + } + + // The KeyPackage the clock proposes this principal with (the keying draft §4.7), left + // with the room service before the page publishes. + async keyPackage() { + const kp = this.member.keyPackage(); + const r = await this.api("/mls/key-package", { room: this.room, key_package: hex(kp) }); + if (r.status !== 200) throw new Error(`the room kept no KeyPackage: ${r.status}${r.body && r.body.error ? ` (${r.body.error})` : ""}`); + } + + stop(why) { + this.stopped = why; + this.log(`end-to-end encryption: ${why}`); + } + + // Read the room's log to its end, again and again, while `alive()`. + async follow(alive) { + while (alive() && !this.stopped) { + let r; + try { r = await this.api(`/mls?room=${this.room}&from=${this.at}`); } catch { await sleep(1000); continue; } + if (r.status === 410) { this.stop("the room was replaced"); break; } + if (r.status !== 200 || !r.body) { await sleep(1000); continue; } + for (const e of r.body.entries) { + if (e.seq !== this.at) return this.stop(`the log skipped from ${this.at} to ${e.seq}`); + if (e.event) await this.onEvent(unhex(e.event)); else this.onMls(unhex(e.mls)); + if (this.stopped) return; + this.at++; + this.stats.log++; + } + if (this.member.staged() && this.stagedAt != null && performance.now() - this.stagedAt > STAGED_MAX_MS) { + this.member.withdraw(); + this.stagedAt = null; + this.log("a post the log never returned: dropped"); + } + await this.act(); + this.onChange(); + } + } + + // A lifecycle record on the log: the clock's, in its chain's order, then the room's + // model takes what it says of who holds keys (§5.4). + async onEvent(bytes) { + const v = JSON.parse(this.wai.eventRead(bytes)); + if (!v.ok) return this.stop(`a lifecycle record on the log does not verify: ${v.reason}`); + if (v.principal !== this.clock || v.room !== this.room) return this.stop("a record on the log is not the room clock's"); + if (v.seq !== this.chain.seq || v.prev !== this.chain.prev) return this.stop(`the clock's chain breaks on the log at ${v.seq}`); + this.chain = { seq: v.seq + 1, prev: v.digest }; + this.stats.events++; + this.onRecord(bytes); + const p = v.payload || {}; + let ev = null; + if (v.kind_name === "join") { + let g = this.grantOf(p.detail.grant); + if (!g) { await this.refreshGrants(); g = this.grantOf(p.detail.grant); } + if (!g) return this.stop(`a join under a grant the room does not list (${String(p.detail.grant).slice(0, 16)})`); + // A keyed grant may subscribe or may publish (§2). + ev = { event: "join", subject: p.subject, keyed: !!g.subscribe || g.publish !== 0 }; + } else if (v.kind_name === "leave") { + ev = { event: "leave", subject: p.subject, reason: p.detail.reason }; + } else if (v.kind_name === "grant_exchange") { + return this.stop("a grant exchange, which this page does not follow"); + } + if (ev) this.member.event(JSON.stringify(ev)); + } + + // An MLS message on the log, checked and applied by the wasm member. + onMls(frame) { + const v = JSON.parse(this.member.receive(frame)); + if (!v.ok) { + const why = v.reason || v.error; + this.stats.refused[why] = (this.stats.refused[why] || 0) + 1; + this.log(`an MLS message refused: ${why}${v.detail ? ` (${v.detail})` : ""}`); + return; + } + this.stats.mls[v.received] = (this.stats.mls[v.received] || 0) + 1; + if (v.received === "epoch" || v.received === "joined" || v.received === "created") { + this.stagedAt = null; + this.entered(v.epoch, v.received); + } else if (v.received === "removed") { + this.stats.removed = v.epoch; + this.log(`removed from the room's group by the commit that opens epoch ${v.epoch}: no later epoch opens here`); + } + } + + entered(epoch, how) { + if (this.stats.joined == null) this.stats.joined = epoch; + const last = this.stats.epochs[this.stats.epochs.length - 1]; + this.stats.epochs.push({ epoch, how, at: Math.round(performance.now()) }); + this.log(`epoch ${epoch} (${how})`); + if (last) { + setTimeout(() => { + this.member.forget(BigInt(last.epoch)); + this.stats.forgotten.push(last.epoch); + }, FORGET_AFTER_MS); + } + } + + // Create the group, or commit, when the room calls on this member; the log's order + // decides whether it is applied. + async act() { + if (this.role !== "publisher" || this.stats.removed != null || this.posting) return; + const m = this.member; + let frames = null; + let what = ""; + if (m.owesCreate()) { + frames = [m.stageCreate()]; + what = "GroupInfo"; + } else if (m.shouldCommit()) { + const c = m.stageCommit(); + const w = c.welcome(); + frames = [c.commit(), ...(w ? [w] : [])]; + what = `commit opening epoch ${c.epoch}`; + c.free(); + } + if (!frames) return; + this.posting = true; + this.stagedAt = performance.now(); + try { + const r = await this.api("/mls/post", { room: this.room, frames: frames.map(hex) }); + if (r.status === 200) { + this.stats.posts.ok++; + this.log(`posted the ${what}`); + } else { + this.stats.posts.refused++; + m.withdraw(); + this.stagedAt = null; + this.log(`the ${what} was not carried: ${r.status}${r.body && r.body.error ? ` (${r.body.error})` : ""}`); + } + } catch (e) { + // The answer was lost: the log says whether it was carried. + this.log(`posting the ${what}: ${e.message || e}`); + } finally { + this.posting = false; + } + } + + get joined() { + return this.member.epoch() !== undefined; + } + + // Whether an object refused epoch_not_joined may yet open: a publisher that has not + // joined the group, and was not removed from it, holds it until its Welcome. + get awaitingWelcome() { + return this.role === "publisher" && !this.joined && this.stats.removed == null && !this.stopped; + } + + // Seal an encoded frame's envelope as object 0 of group `group` of this member's track + // in namespace `ns` (§4.9), or null while it holds no epoch. + seal(ns, group, priority, envelope, digest, timeoutMs) { + if (!this.joined) return null; + const s = this.member.seal(JSON.stringify(ns), "0", BigInt(group), 0n, priority, envelope, digest, true, BigInt(timeoutMs)); + const kid = s.kid; + const out = { props: s.properties(), payload: s.payload(), kid, epoch: Number(kid >> 24n) }; + s.free(); + this.stats.sealed_by_epoch[out.epoch] = (this.stats.sealed_by_epoch[out.epoch] || 0) + 1; + return out; + } + + // Open an object through the receiving checks (§6): { ok, payload, epoch, leaf } or + // { ok: false, reason } or { ok: false, pending: }. + open(ns, name, group, object, priority, props, payload) { + const o = this.member.open(JSON.stringify(ns), name, BigInt(group), BigInt(object), priority, props || new Uint8Array(0), payload); + const v = JSON.parse(o.json); + if (v.ok) v.payload = o.payload(); + o.free(); + return v; + } + + state() { + return JSON.parse(this.member.state()); + } +} diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/web/room/index.html b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/index.html index d9a4c727d..8cd120331 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/web/room/index.html +++ b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/index.html @@ -32,6 +32,8 @@

MoQ room client

loading
+ +
diff --git a/wai/wai-live-web/src/lib.rs b/wai/wai-live-web/src/lib.rs index 21e0c3a8d..597da7271 100644 --- a/wai/wai-live-web/src/lib.rs +++ b/wai/wai-live-web/src/lib.rs @@ -393,6 +393,9 @@ pub fn room_view(b: &[u8]) -> Value { "t0_us": d.t0_us, "tick_us": d.tick_us, "max_present": d.limits.max_present, + // The recording mode (§3.7): an end-to-end encrypted room's is 0 (WAI + // confidentiality §3.3 step 5), which a page checks the room's record against. + "recording_mode": d.recording.mode, "roles": d.roles.iter().map(|r| json!({"name": r.name, "publish": r.caps.publish, "subscribe": r.caps.subscribe, "max_layer": r.caps.max_layer, "powers": r.caps.powers})).collect::>(), "profiles": d.profiles.iter().map(profile_json).collect::>(), }), -- 2.54.0 (Apple Git-157) From d916fcd80cc0c2087161c144ffad1a74f89a4f0c Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 08:47:05 -0400 Subject: [PATCH 07/14] =?UTF-8?q?confidentiality=20=C2=A715:=20a=20member?= =?UTF-8?q?=20in=20a=20browser,=20and=20a=20room=20service=20as=20the=20ca?= =?UTF-8?q?rrier=20of=20a=20room's=20MLS=20messages;=20READMEs,=20CHANGELO?= =?UTF-8?q?Gs,=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §1's scope now covers the browser and states what a browser member does not do in this revision (§15.3): watch without being a member, ingest by WHIP or WebRTC, seek back. §15.2 makes the carrier's order binding on a member's own create and commit: applied only when the log returns it, dropped when another's commit for the epoch comes first. CI: joule-moq-room and joule-moq-web run on a joule-moq-mls change; the conformance job runs joule-moq-web's tests with feature e2ee, which run only with it. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/joule-code-conformance.yml | 6 +- .github/workflows/joule-moq-room.yml | 9 ++- .github/workflows/joule-moq-web.yml | 10 ++- joule-code/CHANGELOG.md | 25 ++++++ .../crates/joule-moq-room/README.md | 19 ++++- .../crates/joule-moq-room/room.example.toml | 4 + .../crates/joule-moq-web/README.md | 16 +++- wai/CHANGELOG.md | 22 +++++ wai/extensions/confidentiality.md | 81 +++++++++++++++++-- 9 files changed, 178 insertions(+), 14 deletions(-) diff --git a/.github/workflows/joule-code-conformance.yml b/.github/workflows/joule-code-conformance.yml index 129b18f02..1d826ad95 100644 --- a/.github/workflows/joule-code-conformance.yml +++ b/.github/workflows/joule-code-conformance.yml @@ -128,7 +128,8 @@ jobs: # the receiving checks. Then joule-moq-mls: real MLS groups (a room of three each # sending, a removal, a late joiner, a change of committer, and every misbehaviour # refused), and the corpus of wai/live-conformance/e2ee regenerating byte for byte - # with every vector's outcome. The relay's own job runs tests/sealed_objects.rs. + # with every vector's outcome, and members behind a log that orders their messages + # (confidentiality §15.2). The relay's own job runs tests/sealed_objects.rs. - name: Sealed objects and the room's MLS binding working-directory: joule-code/joule-code-rs run: | @@ -137,6 +138,9 @@ jobs: cargo test -p joule-moq --features secure-objects --test secure_objects_vectors cargo test -p joule-moq-mls cargo run -q -p joule-moq-mls --bin joule_moq_e2ee_vectors verify ../../wai/live-conformance/e2ee + # The browser member's surface (joule-moq-web, feature e2ee), on the host: its + # tests run only with the feature. + cargo test -p joule-moq-web --features e2ee --lib # joule-moq's draft-ietf-moq-transport-22 vectors, read by a second decoder # written from the draft's text rather than from the crate. The step above diff --git a/.github/workflows/joule-moq-room.yml b/.github/workflows/joule-moq-room.yml index d97393977..11a59c665 100644 --- a/.github/workflows/joule-moq-room.yml +++ b/.github/workflows/joule-moq-room.yml @@ -6,10 +6,12 @@ name: joule-moq-room room service # tests/room_service.rs, the service against a relay on loopback that trusts its authority: # a grant and its proof open a session and the clock announces the publisher, the grant # alone opens none, the room verifies (live-rooms §9), a publisher past the room's places -# is refused, clients are bounded, and a new room starts when the last publisher has left. +# is refused, clients are bounded, and a new room starts when the last publisher has left; +# and an end-to-end encrypted room (WAI confidentiality), members keying through the +# service's log on a relay. # -# It builds on wai-rs's room, event and composition records and on the relay, so a change -# to either runs it. Push filters match the push's delta, so every path the job reads is +# It builds on wai-rs's room, event and composition records, on the relay and on +# joule-moq-mls, so a change to any of them runs it. Push filters match the push's delta, so every path the job reads is # listed. concurrency: @@ -21,6 +23,7 @@ on: paths: - 'joule-code/joule-code-rs/crates/joule-moq-room/**' - 'joule-code/joule-code-rs/crates/joule-moq/**' + - 'joule-code/joule-code-rs/crates/joule-moq-mls/**' - 'joule-code/joule-code-rs/crates/joule-moq-relay/**' - 'joule-code/joule-code-rs/crates/jwp/**' - 'wai/wai-rs/**' diff --git a/.github/workflows/joule-moq-web.yml b/.github/workflows/joule-moq-web.yml index 85f079ccb..db7f0f5b0 100644 --- a/.github/workflows/joule-moq-web.yml +++ b/.github/workflows/joule-moq-web.yml @@ -19,7 +19,13 @@ name: joule-moq-web browser client # service a room service (joule-moq-room) keeps the room, as in production: pages # make their own keys and enter from their buttons, the service grants and # its clock authors the lifecycle, the relay (WebSocket only, behind a -# same-origin proxy) honours the grants by the service's authority key. +# same-origin proxy) honours the grants by the service's authority key; +# e2ee an end-to-end encrypted room (WAI confidentiality) kept by a room service: +# pages key through the service's log, seal every frame and open every object +# before it is decoded; a viewer outside the group opens nothing, every change +# a relay could make is refused with its reason, a late joiner opens nothing +# sealed before its join, a member that stops publishing is removed and opens +# no later epoch, and the relay's DVR store keeps nothing of the room. # # The macos runner is the host with the browser installed; the tests drive that browser # (its executable named by BROWSER_BIN), not a bundled one. The automation package comes @@ -38,6 +44,7 @@ on: paths: - 'joule-code/joule-code-rs/crates/joule-moq-web/**' - 'joule-code/joule-code-rs/crates/joule-moq/**' + - 'joule-code/joule-code-rs/crates/joule-moq-mls/**' - 'joule-code/joule-code-rs/crates/joule-moq-relay/**' - 'joule-code/joule-code-rs/crates/joule-moq-room/**' - 'joule-code/joule-code-rs/crates/jwp/**' @@ -52,6 +59,7 @@ on: paths: - 'joule-code/joule-code-rs/crates/joule-moq-web/**' - 'joule-code/joule-code-rs/crates/joule-moq/**' + - 'joule-code/joule-code-rs/crates/joule-moq-mls/**' - 'joule-code/joule-code-rs/crates/joule-moq-relay/**' - 'joule-code/joule-code-rs/crates/joule-moq-room/**' - 'joule-code/joule-code-rs/crates/jwp/**' diff --git a/joule-code/CHANGELOG.md b/joule-code/CHANGELOG.md index 317a29b4e..865eefe8c 100644 --- a/joule-code/CHANGELOG.md +++ b/joule-code/CHANGELOG.md @@ -4,6 +4,31 @@ All notable changes to the Joule Code standard are recorded here. The format fol ## [Unreleased] +### Added — end-to-end encrypted rooms in the browser (L10b): `joule-moq-web`'s e2ee build, the room service's log + +- **`joule-moq-mls`**: a member behind a carrier that orders the room's MLS messages. + `stage_create` and `stage_commit` hold the member's GroupInfo or commit until the carrier + returns it (`receive` applies it then, checked as every member checks it); another's commit + first, or a refusal, withdraws it (`withdraw`). Before its Welcome a principal follows the + group from its handshake messages, sharing the clock's code; a Welcome for another and a + GroupInfo of a group it holds are ignored. `Clock::key_package_if` keeps a KeyPackage only + from a principal the room admitted. Feature `js` builds the crate for + `wasm32-unknown-unknown` (openmls's `js`, and getrandom 0.2's `js` for the crypto provider). +- **`joule-moq`**: a keyring holds the locations it opened by epoch, and drops them with the + epoch's keys. +- **`joule-moq-web`**, feature `e2ee`, built as a second wasm, `joule_moq_web_e2ee`, under a new + workspace profile `wasm` (release, link-time optimisation, one codegen unit, size): the + member (`E2eeMember`: KeyPackage, lifecycle events, the log's messages in order, staged + create and commit, seal and open with the reason or the epoch to wait for), the record's + check, `TrackSigner.signSealed` (the receipt over the sealed bytes, no `WAI_RECEIPT`), and + `UniItem.properties`. The room page in such a room checks the record, loads that build, + leaves a KeyPackage, follows the room's log (`web/room/e2ee.js`), seals each frame before it + is signed and published, opens each object before it is decoded, shows its epoch and roster, + and offers no watching and no seeking back. `e2e/room.e2e.mjs` gains the `e2ee` scenario. +- **`joule-moq-room`**: `[room] e2ee = true` gives each room a `WRE1` record and a clock that is + the MLS group's external sender, and the room's log: `GET /mls`, `POST /mls/key-package`, + `POST /mls/post`; `GET /room` carries the record. + ### Added — DVR on the relay (L7b): `joule-moq-relay`'s DVR store, the seek power, the room page's Seek back - **`joule-moq-relay` keeps a DVR window on disk** (`[dvr]`, `[[dvr.window]]`; WAI diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/README.md b/joule-code/joule-code-rs/crates/joule-moq-room/README.md index c2574958a..937eddfad 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/README.md +++ b/joule-code/joule-code-rs/crates/joule-moq-room/README.md @@ -21,6 +21,15 @@ A room service for WAI live rooms (`wai/extensions/live-rooms.md`) over - **serves the room.** `GET /room` gives the signed descriptor, the composition descriptor and the publishers' grants, which a page verifies the room with (live-rooms §9). +- **keeps end-to-end encrypted rooms** (`[room] e2ee = true`, WAI + `extensions/confidentiality.md`). Each room then has a confidentiality record (`WRE1`) the + authority signs, and one MLS group whose only external sender is the room's clock: the clock + proposes the Add of each publisher it records joining and the Remove of each it records + leaving, and follows the group from its public messages, holding no key. The service is the + room's carrier of MLS messages (§5.1, §15.2): one log per room holding the clock's lifecycle + records, its proposals, and every GroupInfo, commit and Welcome a member posts, in the order + the clock took them; a post the clock refuses (§5.5) is not carried. + A room lasts while anyone publishes in it. When the last publisher has left, the next one starts a new room: a new descriptor at a new `t0`, under the same authority and clock, so each room's grants and chain hold one stretch of company. @@ -68,8 +77,11 @@ trust_field = 1 | Request | Answer | |---|---| | `GET /`, `GET /health` | `{"ok", "relay": the clock's session is open, "room"}` | -| `GET /room` | the room: `room`, `t0_us`, `descriptor`, `compose`, `grants` (hex), `present`, `publishers`, `max_publishers` | +| `GET /room` | the room: `room`, `t0_us`, `descriptor`, `compose`, `grants` (hex), `present`, `publishers`, `max_publishers`, and `e2ee` (`{"record": , "log"}`, or null) | | `POST /grant` `{"principal": <64 hex>, "role": "publisher" \| "viewer"}` | `{"grant", "digest", "exp", "role", "room": }`; `409` `full` or `busy`; `429` `rate` with `retry_after_s`; `503` `relay` while the clock has no session | +| `GET /mls?room=&from=[&wait_ms=]` | an end-to-end encrypted room's log from entry `n`: `{"room", "entries": [{"seq", "event" or "mls": }], "next", "end"}`, held up to 8 s while nothing is past `n`; `410` for a room since replaced, `404` for a room that is not end-to-end encrypted | +| `POST /mls/key-package` `{"room", "key_package": }` | kept for the principal's Add: `{"ok", "principal"}`; `403` for a principal the room has not granted | +| `POST /mls/post` `{"room", "frames": []}` | a GroupInfo, or a commit and its Welcome, checked by the clock as a member checks it, then carried: `{"ok", "seq"}`; `409` `{"error": }` when the clock refuses it | Every answer is JSON with `Cache-Control: no-store`. The client's address is `CF-Connecting-IP`, else the first `X-Forwarded-For`, else the connection's: the service @@ -82,7 +94,10 @@ on `wai-rs`): the keys file, the grants checked against the descriptor and read relay's reader, the places, the limiter, and `tests/room_service.rs`, the service against a relay on loopback: a grant and its proof open a session and the clock announces the publisher, the grant alone opens none, the room verifies, a third publisher is refused, -clients are bounded, and a new room starts when the last publisher has left. +clients are bounded, and a new room starts when the last publisher has left; and an end-to-end +encrypted room on a relay, two members keying through the service as pages do (a GroupInfo, +the clock's Add, a commit and its Welcome), the posts the clock refuses not carried, each +opening what the other seals, and a member that leaves removed by the next commit. ## Licence diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/room.example.toml b/joule-code/joule-code-rs/crates/joule-moq-room/room.example.toml index 5e759a2af..a713e0261 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/room.example.toml +++ b/joule-code/joule-code-rs/crates/joule-moq-room/room.example.toml @@ -22,6 +22,10 @@ viewer_grant_s = 1800 join_wait_s = 90 # Grants one room takes; then a new room starts once it is empty. max_grants = 64 +# Every room end-to-end encrypted (WAI confidentiality): a confidentiality record, an MLS +# group whose external sender is the clock, and the room's log of MLS messages. Such a room +# is never recorded, and its viewers are not members of its group. +e2ee = false [rate] # Grants per client address: this many at once, then one more every this many seconds. diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/README.md b/joule-code/joule-code-rs/crates/joule-moq-web/README.md index dc2210251..9733ec5e2 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/README.md +++ b/joule-code/joule-code-rs/crates/joule-moq-web/README.md @@ -18,12 +18,19 @@ clock authors the room's lifecycle. |---|---|---| | `joule_moq_web` | this crate, over `joule-moq` with `default-features = false` | SETUP, every request a client sends, framed control messages and data streams read as their bytes arrive, subgroup streams written; each group signed into its receipt (`TrackSigner`) and each object verified against it (`TrackVerifier`) | | `wai_live_web` | `wai/wai-live-web`, over `wai-rs` with `live_compose` and `timed_text` | the room descriptor and grants, the lifecycle replay and the composition at an instant, the layout and the mixer, signed events and captions, the MSF event-timeline binding, the `WAI1` envelope | +| `joule_moq_web_e2ee` | this crate with its feature `e2ee`, over `joule-moq-mls` (MLS, RFC 9420) and `joule-moq`'s `secure-objects` | everything `joule_moq_web` does, and the member of an end-to-end encrypted room: the room's confidentiality record checked, its MLS group joined and followed, each frame sealed and each object opened (WAI `extensions/confidentiality.md`). A page loads it in place of `joule_moq_web` for such a room alone | `joule-moq` without its default feature `runtime` is its codec, its carriage of objects and receipts, its receipt builder and verifiers and its session's setup rules, with no tokio and no native dependency. The relay and every other dependent take the default features and build what they built before. +**Sizes.** Built as `e2e/run.sh` builds them, `joule_moq_web` is about 780 KB (260 KB +compressed); `joule_moq_web_e2ee`, under the workspace's `wasm` profile (link-time +optimisation, one codegen unit, optimised for size) and with no name section, about 2.0 MB +(650 KB compressed). The difference is MLS and its crypto provider, which a page in any other +room never loads. + **Signatures.** Receipts are verified in wasm by `joule-moq`'s `ReceiptVerifier` (its `ReceiptChains` for the chain), the same code the relay's door runs. WebCrypto's Ed25519 is not used: a receipt's verification also needs BLAKE3 and the Merkle walk, which WebCrypto @@ -43,6 +50,7 @@ does not have, and one implementation keeps the page's verdicts the relay's. | Subscribe | Per participant: its events track, its catalog, then each media track's receipts track (no delivery timeout) and the media track, audio at a higher subscriber priority than video. Each receipts-track object is admitted to the track's chain under the participant's key; each object is verified against the receipt of its position before it is decoded; an object whose receipts arrived and which did not is accounted for by inclusion. A late joiner FETCHes every earlier group of each track and its receipts track from group 0. | | Compose | The room's lifecycle (the clock's `join`, `publish`, `leave` events) replayed by `wai-live-web` gives the participant set; the `WCD1` descriptor and the set give the tiles, drawn on a canvas, re-laid out on each lifecycle event; the voices are mixed by the integer mixer, in blocks of 20 ms, into an AudioWorklet that plays them. | | Events | Every record verified (signature, chain, the grant's policy, through the room verifier); `caption` events shown with their origin (human, machine, machine-reviewed), the other kinds as a timeline. | +| End-to-end encryption | When the service's room has a confidentiality record (`GET /room`'s `e2ee`), the page says so before anyone enters, offers no watching and no seeking back, checks the record against the descriptor (its room, authority and recording mode), and loads `joule_moq_web_e2ee`. It leaves a KeyPackage with the service before it publishes, then reads the room's log (`web/room/e2ee.js`): each lifecycle record checked as the clock's, each MLS message checked and applied by the wasm member; it creates the group or commits when the room calls on it, and applies what it made only when the log returns it. Each encoded frame is sealed under the page's epoch before its receipt is signed over the sealed bytes; nothing is sent before the page holds an epoch. Each object received is verified against its receipt, then opened through the receiving checks, and only then decoded; one sealed in an epoch the page has not reached waits, and one that does not open is counted with its reason. The page shows its epoch and the members of the group. Every key is in the wasm member's memory and nowhere else. | ### What a browser needs @@ -64,7 +72,10 @@ closed. Delivery timeouts still apply at the relay, before a write. ## Tests -- `cargo test -p joule-moq-web`: the surface's pure-Rust half on the host. +- `cargo test -p joule-moq-web`: the surface's pure-Rust half on the host; with + `--features e2ee`, the member's surface too: two members and a clock key through one + ordered log as pages do, seal, sign, read the stream back, verify and open, and every + refusal an object can meet. - `node --test web/moq/capsule.test.mjs web/moq/media.test.mjs`: the binding's capsules against the bytes the relay's README states, and the payload framings. - `e2e/run.sh`: everything built (the relay, both wasm builds, the host's half of @@ -81,6 +92,7 @@ closed. Delivery timeouts still apply at the relay, before a write. | `websocket` | the same over the binding, forced | | `goaway` | the relay is stopped with GOAWAY naming a second relay; every page moves, media is verified again there, and the first relay closes no session at its timeout | | `service` | a room service keeps the room, as in production: the relay (WebSocket only, its door holding each publisher to its own key, trusting the service's authority) and the service behind a same-origin proxy; pages enter from their buttons with keys of their own. Two publish and see each other, every object verifies, the layout equals the host's, a machine caption and a typed human caption show their labels, the clock (not a page) authors the lifecycle, a late joiner gets earlier groups by FETCH, a viewer watches, a grant with no proof or with a proof by another key is refused the session, a publisher past the room's places is told it is full, and a leave is recorded | + | `e2ee` | an end-to-end encrypted room kept by a room service (`e2ee = true`), the relay keeping a DVR window: the page says so before anyone enters; two publishers key through the service's log (a GroupInfo, a commit, a Welcome), media flows both ways, every object verified against its receipt over the sealed bytes, opened, then decoded; the page shows its epoch and roster; a viewer, outside the group, receives the bytes exactly as sealed (no envelope, the AEAD's length) and opens nothing; every change a relay could make to an object is refused with the extension's reason (`bad_tag`, `key_id_missing`, `properties_malformed`, `object_id_too_large`, `key_id_unknown`, `sender_not_owner`, `replayed`) and the object opens once; an object changed on the wire is refused `bad_tag`; a late joiner's FETCH of earlier groups opens nothing sealed before its join (`epoch_not_joined`); a member that stops publishing and stays is removed by the next commit and opens nothing of the later epoch it still receives (`removed_before_epoch`), and an object it sealed before is refused `sender_removed`; the relay's DVR store keeps nothing of the room | With `--base ` the `service` scenario runs against a deployment instead: nothing is started, and the page at `/room/` is driven the same way (its production test). @@ -92,6 +104,8 @@ closed. Delivery timeouts still apply at the relay, before a write. - WebTransport for the public room: its relay is reached over the WebSocket binding through an HTTP front, and offers no UDP endpoint. - Simulcast and staged layers; publishing a screen; datagrams. +- In an end-to-end encrypted room: watching without being a member, ingest by WHIP or + WebRTC, and seeking back (WAI confidentiality §15.3). - In the fixture's room, a page holding the clock's key authors the lifecycle, driven by the test; with a room service, the service's clock does. diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index e54fe264c..3865fc963 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -6,6 +6,28 @@ wire format may change and such changes are recorded here. ## v0.4.0 — current +### Live L10b: end-to-end encrypted rooms in the browser + +Additive. No record, code point, reason or corpus vector changes. + +- **confidentiality §15, a member in a browser.** A page is a member as any other: the + reference's member, sealed objects and codec built for WebAssembly as one module a page loads + for such a room alone, every key in the page's memory and nowhere else. It seals each encoded + frame before its receipt is signed over the sealed bytes, and opens each object through §6 + before a decoder sees it; an object of an epoch not yet reached waits (§5.8). +- **§15.2, a room service as the carrier** of a room's MLS messages (§5.1): one log, in the + order the clock took them, of the clock's lifecycle records, its proposals, and every + GroupInfo, commit and Welcome a member posts; the clock checks each post as a member does and + does not carry what it refuses. A member reached through such a log **MUST** apply a + GroupInfo or commit it made only when the log returns it, and drop it when another's commit + for the epoch comes first: §5.5 names the committer from the commit's own proposals, so two + members with different views of the pending proposals can each commit. Before its Welcome a + member follows the group from its handshake messages, as the clock does. +- **§15.3** states what a browser member does not do in this revision: watch without being a + member, ingest by WHIP or WebRTC into such a room, seek back. §1's scope says so. +- **wai-live-web**: `roomDescriptor` gives the descriptor's `recording_mode`, which a page + checks a room's confidentiality record against (§3.3 step 5). + ### Live L7b: the DVR window, the seek power, and WMH1 version 2 Additive: no existing record changes its bytes, and every existing corpus vector keeps its diff --git a/wai/extensions/confidentiality.md b/wai/extensions/confidentiality.md index aba91be96..fa20512a4 100644 --- a/wai/extensions/confidentiality.md +++ b/wai/extensions/confidentiality.md @@ -14,8 +14,10 @@ > 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. The corpus is [`live-conformance/e2ee/`](../live-conformance/e2ee/), and -> its second implementation is `live-conformance/e2ee_verify.py`. +> per-sender keys; `joule-moq-web` (feature `e2ee`) builds both for a browser page, and the +> room service `joule-moq-room` carries a room's MLS messages in one order (§15). The corpus +> is [`live-conformance/e2ee/`](../live-conformance/e2ee/), and its second implementation is +> `live-conformance/e2ee_verify.py`. > Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174. > > Documents read for this revision, each pinned to the revision named: @@ -47,7 +49,8 @@ In scope: - 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). +- decrypt-then-dispatch and relay transparency (§10); +- a member in a browser, and a room service as the carrier of a room's MLS messages (§15). Sealed in this revision: the objects of every **media track** of the room (live-rooms §6.1: `audio`, `video`, `screen`, each layer). In the clear, as in a room without this extension: @@ -69,9 +72,8 @@ Out of scope: - **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). -- **The browser.** A browser member needs MLS in WebAssembly and the sealing of encoded - frames as the W3C encoded-transform interface does it; that is a later slice, and nothing - here depends on it. +- **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). @@ -784,3 +786,70 @@ epoch from the commits (§5.6), the committer of each commit and what it holds ( proposals against the room's lifecycle (§5.4), each track base key and KID, each key, salt, nonce and AAD, every seal and open, and every refusal. That the application secrets are what an MLS group of the room exports is checked by the reference's tests, which run the group. + +## 15. A member in a browser + +A browser page is a member as any other: the reference's member (`joule-moq-mls`), the +sealed objects of `joule-moq` and the codec, compiled for WebAssembly as one module that the +page loads for an end-to-end encrypted room alone (`joule-moq-web`, feature `e2ee`). Its MLS +state and every key it derives are in the page's memory and nowhere else: nothing of them is +written to the browser's storage or sent anywhere, and a page that is closed is a principal +whose connection is lost (§5.4). + +### 15.1 Sealing frames + +The page encodes its camera and microphone with W3C WebCodecs. Each encoded chunk, packed as +the room's original payload (§4.4), is sealed under the page's latest epoch (§4.9), and its +receipt is signed over the sealed bytes (§8.1) before it is published; until the page holds an +epoch, nothing is published. Each object the page receives is verified against its receipt, +then opened by §6, and its plaintext reaches a decoder only once it opened (§10). An object +whose KID names an epoch the page has not reached, or that arrives before the page's Welcome, +is held for a bounded time and opened when the page reaches that epoch (§5.8); an object that +does not open is counted with its reason and dropped. The page deletes an epoch's keys a fixed +time after the next epoch opens (§5.8). + +### 15.2 A room service as the carrier + +A room service that passes the group's messages directly (§5.1) keeps one log per room, in +which the clock's lifecycle records, as the clock signed them, its proposals, and every +GroupInfo, commit and Welcome a member posts stand in one order. The service's clock checks +each post as a member checks it (§5.4, §5.5), and a post it refuses is not carried. A member +reached through such a log: + +- reads it from its first entry, and checks each lifecycle record as the clock's: its + signature, its room, and its place in the clock's chain; +- before its Welcome, follows the group from its GroupInfo and its handshake messages as the + clock does (§5.3), so that it knows whether a group exists and who holds which leaf, and + creates the group only when §5.4 owes it one; +- leaves its KeyPackage with the service before it publishes, so the clock's Add for it is + ready when the clock records its `join`; +- **MUST** apply a GroupInfo or a commit it made only when the log returns it in the log's + order, and **MUST** drop it when the service refuses it or the log carries another commit + for the same epoch first. + +The last rule is what keeps one epoch one epoch. §5.5 names the committer from the commit's own +proposals, so two members whose views of the pending proposals differ can each be the +committer of the commit it makes: a member that has read the clock's Add and not yet a Remove +of itself, and the next member, which has read both. The log orders the two, the clock carries +the first and refuses the second, and every member applies the first. + +The log adds no trust. Every message on it is signed by its sender and checked by every +member as §5 requires, and every lifecycle record by the clock's key; the service can withhold +or delay a message, as a relay can (§12), and cannot forge or change one. + +### 15.3 What a browser member does not do in this revision + +- **Watch without being a member.** A grant that may subscribe is keyed (§2), so its principal + is a member once present. The reference room service records presence from a publisher's + tracks, so a viewer it grants is never a member and opens nothing; its page offers no + watching in such a room. +- **Ingest by WHIP or WebRTC** ([live-ingest](live-ingest.md)) into such a room. A WebRTC + sender's frames would be sealed through the W3C encoded-transform interface, which is not + built. +- **Seek back.** The relay keeps nothing of such a room (§9). + +The reference checks this section in `joule-moq-mls`'s tests (members behind a log that orders +their messages: a create and a commit applied only when the log returns them, two commits for +one epoch, a commit overtaken by another, a create the log refuses), `joule-moq-room`'s tests +(members keying through the service on a relay), `joule-moq-web`'s tests (its WebAssembly +surface on the host), and the `e2ee` scenario of `joule-moq-web/e2e/` in a browser. -- 2.54.0 (Apple Git-157) From 08038fd4a436b695b3792d33f7637be1e28fb6c0 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 08:53:18 -0400 Subject: [PATCH 08/14] joule-moq-web: a late joiner in an end-to-end encrypted room does not present again a group it received live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Its FETCH copy is the same location, which §6 step 8 refuses as a replay. Co-Authored-By: Claude Opus 5.5 --- .../joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs | 2 +- joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs index 00c26ffde..ec2ad6614 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs +++ b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs @@ -776,7 +776,7 @@ async function scenarioE2ee(run) { const fe = late.e2ee; const early = Object.keys(fe.fetched_epochs).map(Number).filter((e) => e < joinedAt); check(`${label}: a late joiner opens nothing sealed before its join: every earlier group it fetched is refused epoch_not_joined`, (fe.fetched_refused.epoch_not_joined || 0) >= 10 && early.length === 0 && Object.keys(fe.fetched_refused).every((r) => r === "epoch_not_joined"), - `${late.fetched} fetched (groups ${late.first_fetched_group}..${late.last_fetched_group}): ${fe.fetched_opened} opened (epochs ${JSON.stringify(fe.fetched_epochs)}), refused ${JSON.stringify(fe.fetched_refused)}; live ${late.decoded} decoded`); + `${late.fetched} fetched (groups ${late.first_fetched_group}..${late.last_fetched_group}; ${late.fetched_live || 0} more already received live): ${fe.fetched_opened} opened (epochs ${JSON.stringify(fe.fetched_epochs)}), refused ${JSON.stringify(fe.fetched_refused)}; live ${late.decoded} decoded`); // Carol keeps back an object sealed under her epoch, then stops publishing and stays: // the clock records her leave and proposes her removal, and a member commits it. diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js index d5f9de1c5..68433ecae 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js +++ b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js @@ -792,6 +792,10 @@ async function lateJoin(sub, st, T, ns, tr) { const objs = []; const r2 = await fetchAgain(tr.name, (j, p, props) => objs.push([j.group_id, p, { object: j.object_id, priority: j.publisher_priority, props }])); for (const [g, p, meta] of objs) { + // In an end-to-end encrypted room a group already received live was opened once: its + // copy by FETCH is the same location, which the receiving checks refuse as a replay + // (confidentiality §6 step 8). It is not presented again. + if (e2ee && st.seen.has(g)) { T.fetched_live = (T.fetched_live || 0) + 1; continue; } T.fetched++; if (T.first_fetched_group == null) T.first_fetched_group = g; T.last_fetched_group = g; -- 2.54.0 (Apple Git-157) From 6f12797c5a015d89d0c3fd1748adc48371dcacd5 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 08:57:05 -0400 Subject: [PATCH 09/14] joule-moq-mls, joule-moq-web, joule-moq-room: clippy notes on the new code Co-Authored-By: Claude Opus 5.5 --- .../crates/joule-moq-mls/src/group_tests.rs | 4 ++-- .../crates/joule-moq-room/src/e2ee.rs | 2 +- .../crates/joule-moq-room/src/http.rs | 17 +++++++++-------- .../crates/joule-moq-web/src/e2ee.rs | 2 ++ 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/joule-code/joule-code-rs/crates/joule-moq-mls/src/group_tests.rs b/joule-code/joule-code-rs/crates/joule-moq-mls/src/group_tests.rs index bbbf567ca..4cb52c532 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-mls/src/group_tests.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-mls/src/group_tests.rs @@ -549,8 +549,8 @@ fn a_member_whose_commit_another_overtakes_drops_its_own_and_applies_the_other() let kp = r.m.key_package().unwrap(); log.key_package(&kp); } - for i in 0..2 { - log.event(Event::Join { subject: p[i], keyed: true }); + for subject in p.iter().take(2) { + log.event(Event::Join { subject: *subject, keyed: true }); read_all(&mut log, &mut rs); } // C joins: A stages the commit of C's Add. A leaves before A posts it, and B, reading diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/src/e2ee.rs b/joule-code/joule-code-rs/crates/joule-moq-room/src/e2ee.rs index f46e9efb7..7bc63b846 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/src/e2ee.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-room/src/e2ee.rs @@ -195,7 +195,7 @@ mod tests { let (mut at_a, mut at_b) = (0, 0); read(&mut a, mls, &mut at_a); let info = a.stage_create().unwrap(); - assert!(matches!(mls.post(std::slice::from_ref(&info)), Ok(_))); + assert!(mls.post(std::slice::from_ref(&info)).is_ok()); assert!(matches!(mls.post(&[info]), Err(Refused::Clock(_))), "a second GroupInfo is not owed"); read(&mut a, mls, &mut at_a); assert_eq!(a.epoch(), Some(0)); diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/src/http.rs b/joule-code/joule-code-rs/crates/joule-moq-room/src/http.rs index 39ad61f2a..0629149ec 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/src/http.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-room/src/http.rs @@ -201,16 +201,17 @@ async fn mls_log(svc: &Arc, query: &str) -> Response> { } } -/// The body of a log request: its room, which must be the current one, and the JSON. -fn mls_body(svc: &Arc, body: &[u8]) -> Result>> { - let Ok(v) = serde_json::from_slice::(body) else { return Err(reply(StatusCode::BAD_REQUEST, json!({"error": "not JSON"}))) }; - let Some(room) = v["room"].as_str().and_then(room_of) else { return Err(reply(StatusCode::BAD_REQUEST, json!({"error": "room: 64 hexadecimal digits"}))) }; +/// The body of a log request: its room, which must be the current one, and the JSON; or the +/// answer that refuses it. +fn mls_body(svc: &Arc, body: &[u8]) -> Result { + let Ok(v) = serde_json::from_slice::(body) else { return Err((StatusCode::BAD_REQUEST, json!({"error": "not JSON"}))) }; + let Some(room) = v["room"].as_str().and_then(room_of) else { return Err((StatusCode::BAD_REQUEST, json!({"error": "room: 64 hexadecimal digits"}))) }; let st = svc.state.lock().unwrap(); if st.epoch.id != room { - return Err(reply(StatusCode::GONE, json!({"error": "room", "room": hex::encode(st.epoch.id)}))); + return Err((StatusCode::GONE, json!({"error": "room", "room": hex::encode(st.epoch.id)}))); } if st.epoch.mls.is_none() { - return Err(reply(StatusCode::NOT_FOUND, json!({"error": "not end-to-end encrypted"}))); + return Err((StatusCode::NOT_FOUND, json!({"error": "not end-to-end encrypted"}))); } Ok(v) } @@ -219,7 +220,7 @@ fn mls_body(svc: &Arc, body: &[u8]) -> Result, body: &[u8]) -> Response> { let v = match mls_body(svc, body) { Ok(v) => v, - Err(r) => return r, + Err((status, why)) => return reply(status, why), }; let Some(kp) = v["key_package"].as_str().and_then(|h| hex::decode(h).ok()) else { return reply(StatusCode::BAD_REQUEST, json!({"error": "key_package: hexadecimal"})) }; let mut st = svc.state.lock().unwrap(); @@ -237,7 +238,7 @@ fn mls_key_package(svc: &Arc, body: &[u8]) -> Response> { fn mls_post(svc: &Arc, body: &[u8]) -> Response> { let v = match mls_body(svc, body) { Ok(v) => v, - Err(r) => return r, + Err((status, why)) => return reply(status, why), }; let frames: Option>> = v["frames"].as_array().map(|a| a.iter().map(|f| f.as_str().and_then(|h| hex::decode(h).ok())).collect()).unwrap_or(None); let Some(frames) = frames.filter(|f| !f.is_empty()) else { return reply(StatusCode::BAD_REQUEST, json!({"error": "frames: hexadecimal"})) }; diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/src/e2ee.rs b/joule-code/joule-code-rs/crates/joule-moq-web/src/e2ee.rs index 18fc5ded8..9c105482a 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/src/e2ee.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-web/src/e2ee.rs @@ -143,6 +143,7 @@ impl E2eeMember { /// `ns` (§4.9), under this member's latest epoch: with `WAI_INNER_DIGEST` when `digest`, /// the envelope flag in the Immutable Properties when `envelope`, and an /// OBJECT_DELIVERY_TIMEOUT beside them when `timeout_ms` is not 0. + #[allow(clippy::too_many_arguments)] pub fn seal_object(&self, ns: &joule_moq::TrackNamespace, name: &TrackName, group: u64, object: u64, priority: u8, payload: &[u8], digest: bool, envelope: bool, timeout_ms: u64) -> Result<(Properties, Vec, u64), String> { let pt = if digest { Plaintext::with_digest(payload.to_vec()) } else { Plaintext { payload: payload.to_vec(), ..Default::default() } }; let immutable: Vec = if envelope { vec![KeyValuePair::varint(wai_prop::WAI_ENVELOPE, 1)] } else { vec![] }; @@ -153,6 +154,7 @@ impl E2eeMember { } /// Open an object through the checks of §6, in their order. + #[allow(clippy::too_many_arguments)] pub fn open_object(&mut self, ns: &joule_moq::TrackNamespace, name: &TrackName, group: u64, object: u64, priority: u8, properties: &Properties, payload: &[u8]) -> (Value, Vec) { let inc = Incoming { ns, name, group, object, priority, properties, payload }; match self.m.open(&inc) { -- 2.54.0 (Apple Git-157) From 9ea0b6d13aeb1a67c3c20d44808c3ed7ecfa11c9 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 08:58:55 -0400 Subject: [PATCH 10/14] =?UTF-8?q?SPEC=20=C2=A75:=20the=20confidentiality?= =?UTF-8?q?=20paragraph=20names=20a=20browser=20member=20and=20the=20carri?= =?UTF-8?q?er's=20order=20(confidentiality=20=C2=A715)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- wai/SPEC.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/wai/SPEC.md b/wai/SPEC.md index 17a837d0a..be35ccd6f 100644 --- a/wai/SPEC.md +++ b/wai/SPEC.md @@ -1021,7 +1021,11 @@ base key and KID (the epoch and its leaf index) follow, so a receiver selects th that opens an object; a receiver refuses, each with a named reason, an object of an epoch it never joined, one after its own removal, a Key ID naming no member, a sender that is not the track's owner or was removed since, a replay, a bad tag and a plaintext that does not read -or does not match its sealed inner digest. Receipts bind the sealed bytes: +or does not match its sealed inner digest. Receipts bind the sealed bytes. A browser page is +a member through the same code built for WebAssembly, sealing each encoded frame before it +publishes it and opening each object before it decodes it; a room service may carry the +group's messages in one order, and a member reached so applies its own create or commit only +when the service returns it in that order (confidentiality §15): [`extensions/confidentiality.md`](extensions/confidentiality.md). ### Payloads of the WAI-native integer codecs -- 2.54.0 (Apple Git-157) From cdf3783d224ce8cb8123fa6ec82b88b3c5f4ba93 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 09:11:52 -0400 Subject: [PATCH 11/14] joule-moq-web: in an end-to-end encrypted room a frame is numbered only when it is sent Before the room's group keys the page nothing is sent; numbering those frames left a gap below a track's first group, and a late joiner's FETCH from group 0 asked the relay for groups it never had, which it passed to the publisher. A track's groups now run from 0. Co-Authored-By: Claude Opus 5.5 --- .../crates/joule-moq-web/web/room/app.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js index 68433ecae..f80ad2e68 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js +++ b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js @@ -475,6 +475,10 @@ async function startPublishing(given) { let vgroup = 0, agroup = 0, vframes = 0; const venc = new VideoEncoder({ output: (chunk) => { + // In an end-to-end encrypted room nothing is sent before the room's group has keyed + // this page, and no group is numbered for it: a track's groups run from 0 with no gap, + // so a late joiner's FETCH from group 0 asks for nothing the relay never had. + if (unkeyed("video")) return; const g = vgroup++; const b = new Uint8Array(chunk.byteLength); chunk.copyTo(b); @@ -487,6 +491,7 @@ async function startPublishing(given) { venc.configure(vcfg); const aenc = new AudioEncoder({ output: (chunk) => { + if (unkeyed("audio")) return; const g = agroup++; const b = new Uint8Array(chunk.byteLength); chunk.copyTo(b); @@ -559,6 +564,14 @@ async function pump(readable, each) { } } +// Whether an encoded frame of `m` goes unsent: in an end-to-end encrypted room, before the +// page holds an epoch (§4.9: it seals under its current epoch). +function unkeyed(m) { + if (!e2ee || e2ee.joined) return false; + e2ee.stats.unkeyed[m]++; + return true; +} + // Group g of a media track: its receipts first (no later than the group's first object, // moq-streaming-format §4.1), then the object, each on a stream of its own. async function sendGroup(m, g, envelope) { -- 2.54.0 (Apple Git-157) From b635f6c2ffcfd4a651ac6b6ddd56f2ea6c13a6da Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 09:23:54 -0400 Subject: [PATCH 12/14] =?UTF-8?q?e2e=20e2ee:=20what=20does=20not=20open=20?= =?UTF-8?q?reaches=20no=20decoder=20=E2=80=94=20the=20viewer's=20and=20the?= =?UTF-8?q?=20tampered=20object's=20decode=20errors=20stay=200?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../crates/joule-moq-web/e2e/room.e2e.mjs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs index ec2ad6614..68a8ac5ce 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs +++ b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs @@ -741,8 +741,8 @@ async function scenarioE2ee(run) { const sent = await alice.evaluate((g) => window.roomTest.e2eeSent(g), tv.e2ee.samples.map((x) => x.group)); const same = tv.e2ee.samples.filter((x) => sent.some((y) => y.group === x.group && y.sealed_head === x.sealed_head && y.sealed_len === x.sealed_len)); const sizes = sent.every((y) => y.sealed_len === sealedLen(y.plain_len) && y.plain_head.startsWith(WAI1) && !y.sealed_head.includes(WAI1) && y.sealed_head !== y.plain_head); - check(`${label}: a subscriber outside the group receives exactly the bytes alice sealed: no envelope, the AEAD's length, nothing it can open`, same.length >= 3 && same.length === sent.length && sizes && tv.decoded === 0 && tv.e2ee.opened === 0 && Object.keys(tv.e2ee.refused).every((r) => r === "epoch_not_joined"), - `${same.length} of ${tv.e2ee.samples.length} sampled groups byte for byte as sealed (${sent.length} of them still on alice's record); ${tv.received} received, ${tv.decoded} decoded, ${tv.e2ee.opened} opened, refused ${JSON.stringify(tv.e2ee.refused)}; e.g. plain ${sent[0] ? sent[0].plain_head.slice(0, 16) : "-"}… sealed ${sent[0] ? sent[0].sealed_head.slice(0, 16) : "-"}…`); + check(`${label}: a subscriber outside the group receives exactly the bytes alice sealed: no envelope, the AEAD's length, nothing it can open`, same.length >= 3 && same.length === sent.length && sizes && tv.decoded === 0 && tv.decode_errors === 0 && tv.e2ee.opened === 0 && Object.keys(tv.e2ee.refused).every((r) => r === "epoch_not_joined"), + `${same.length} of ${tv.e2ee.samples.length} sampled groups byte for byte as sealed (${sent.length} of them still on alice's record); ${tv.received} received, ${tv.decoded} decoded, ${tv.decode_errors} handed to a decoder and failed, ${tv.e2ee.opened} opened, refused ${JSON.stringify(tv.e2ee.refused)}; e.g. plain ${sent[0] ? sent[0].plain_head.slice(0, 16) : "-"}… sealed ${sent[0] ? sent[0].sealed_head.slice(0, 16) : "-"}…`); // Every change a relay could make to an object, presented to bob before he opens it, // then the object as it came, then once more. @@ -752,11 +752,13 @@ async function scenarioE2ee(run) { check(`${label}: each change a relay could make to an object is refused with the extension's reason, and the object itself opens once`, wrong.length === 0, `group ${ts.group}, epoch ${ts.epoch}: ${Object.entries(ts.outcomes).map(([kk, v]) => `${kk} ${v}`).join(", ")}`); // On the wire: alice's next video object has a byte changed after sealing, its receipt // over the bytes as they travel, so the relay's door passes it; bob refuses it. - const badBefore = ((await snap(bob)).subs[A.principal].video.e2ee.refused.bad_tag || 0); + const vb = (await snap(bob)).subs[A.principal].video; + const badBefore = vb.e2ee.refused.bad_tag || 0; await alice.evaluate(() => window.roomTest.e2eeTamperNext()); const tampered = await until(`${label} alice sends a changed object`, STEP, async () => { const s = await snap(alice); return s.e2ee.tampered.length === 1 && s.e2ee.tampered[0]; }, 200); - const badAfter = await until(`${label} bob refuses it`, STEP, async () => { const n = (await snap(bob)).subs[A.principal].video.e2ee.refused.bad_tag || 0; return n > badBefore && n; }, 250); - check(`${label}: an object changed on the wire is refused bad_tag and never decoded`, badAfter === badBefore + 1, `group ${tampered}: bad_tag ${badBefore} → ${badAfter}`); + const va = await until(`${label} bob refuses it`, STEP, async () => { const v = (await snap(bob)).subs[A.principal].video; return (v.e2ee.refused.bad_tag || 0) > badBefore && v; }, 250); + const badAfter = va.e2ee.refused.bad_tag; + check(`${label}: an object changed on the wire is refused bad_tag and never reaches a decoder`, badAfter === badBefore + 1 && va.decode_errors === vb.decode_errors, `group ${tampered}: bad_tag ${badBefore} → ${badAfter}; decode errors ${vb.decode_errors} → ${va.decode_errors}`); // Carol joins late and fetches the earlier groups: nothing sealed before her join opens. const C = await enter("carol", { fetch: "1" }, "publisher", "publishing"); -- 2.54.0 (Apple Git-157) From 795d8afa09db45ce9024a3c735d72625e9cfbaae Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 09:31:24 -0400 Subject: [PATCH 13/14] CHANGELOG: name the browser feature by what it does Co-Authored-By: Claude Opus 5.5 --- joule-code/CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/joule-code/CHANGELOG.md b/joule-code/CHANGELOG.md index 865eefe8c..5b6827c51 100644 --- a/joule-code/CHANGELOG.md +++ b/joule-code/CHANGELOG.md @@ -13,7 +13,8 @@ All notable changes to the Joule Code standard are recorded here. The format fol group from its handshake messages, sharing the clock's code; a Welcome for another and a GroupInfo of a group it holds are ignored. `Clock::key_package_if` keeps a KeyPackage only from a principal the room admitted. Feature `js` builds the crate for - `wasm32-unknown-unknown` (openmls's `js`, and getrandom 0.2's `js` for the crypto provider). + `wasm32-unknown-unknown`: randomness and the clock from the page's own APIs, for the MLS + library and for its crypto provider's generator. - **`joule-moq`**: a keyring holds the locations it opened by epoch, and drops them with the epoch's keys. - **`joule-moq-web`**, feature `e2ee`, built as a second wasm, `joule_moq_web_e2ee`, under a new -- 2.54.0 (Apple Git-157) From ce21f66fe41789663f208c9012c432d771683e9d Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 20 Oct 2026 09:35:46 -0400 Subject: [PATCH 14/14] e2e e2ee: count what reaches the decode path by envelopes that do not read, not decoder errors A decoder reports the delta frame after a dropped one, which refers to it; that is not the dropped object reaching a decoder. Sealed bytes handed to the decode path never read as a WAI1 envelope, which this counts. Co-Authored-By: Claude Opus 5.5 --- .../joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs | 8 +++++--- .../joule-code-rs/crates/joule-moq-web/web/room/app.js | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs index 68a8ac5ce..4248e0e34 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs +++ b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs @@ -741,8 +741,8 @@ async function scenarioE2ee(run) { const sent = await alice.evaluate((g) => window.roomTest.e2eeSent(g), tv.e2ee.samples.map((x) => x.group)); const same = tv.e2ee.samples.filter((x) => sent.some((y) => y.group === x.group && y.sealed_head === x.sealed_head && y.sealed_len === x.sealed_len)); const sizes = sent.every((y) => y.sealed_len === sealedLen(y.plain_len) && y.plain_head.startsWith(WAI1) && !y.sealed_head.includes(WAI1) && y.sealed_head !== y.plain_head); - check(`${label}: a subscriber outside the group receives exactly the bytes alice sealed: no envelope, the AEAD's length, nothing it can open`, same.length >= 3 && same.length === sent.length && sizes && tv.decoded === 0 && tv.decode_errors === 0 && tv.e2ee.opened === 0 && Object.keys(tv.e2ee.refused).every((r) => r === "epoch_not_joined"), - `${same.length} of ${tv.e2ee.samples.length} sampled groups byte for byte as sealed (${sent.length} of them still on alice's record); ${tv.received} received, ${tv.decoded} decoded, ${tv.decode_errors} handed to a decoder and failed, ${tv.e2ee.opened} opened, refused ${JSON.stringify(tv.e2ee.refused)}; e.g. plain ${sent[0] ? sent[0].plain_head.slice(0, 16) : "-"}… sealed ${sent[0] ? sent[0].sealed_head.slice(0, 16) : "-"}…`); + check(`${label}: a subscriber outside the group receives exactly the bytes alice sealed: no envelope, the AEAD's length, nothing it can open`, same.length >= 3 && same.length === sent.length && sizes && tv.decoded === 0 && (tv.envelope_errors || 0) === 0 && tv.e2ee.opened === 0 && Object.keys(tv.e2ee.refused).every((r) => r === "epoch_not_joined"), + `${same.length} of ${tv.e2ee.samples.length} sampled groups byte for byte as sealed (${sent.length} of them still on alice's record); ${tv.received} received, ${tv.decoded} decoded, ${tv.envelope_errors || 0} handed to the decode path, ${tv.e2ee.opened} opened, refused ${JSON.stringify(tv.e2ee.refused)}; e.g. plain ${sent[0] ? sent[0].plain_head.slice(0, 16) : "-"}… sealed ${sent[0] ? sent[0].sealed_head.slice(0, 16) : "-"}…`); // Every change a relay could make to an object, presented to bob before he opens it, // then the object as it came, then once more. @@ -758,7 +758,9 @@ async function scenarioE2ee(run) { const tampered = await until(`${label} alice sends a changed object`, STEP, async () => { const s = await snap(alice); return s.e2ee.tampered.length === 1 && s.e2ee.tampered[0]; }, 200); const va = await until(`${label} bob refuses it`, STEP, async () => { const v = (await snap(bob)).subs[A.principal].video; return (v.e2ee.refused.bad_tag || 0) > badBefore && v; }, 250); const badAfter = va.e2ee.refused.bad_tag; - check(`${label}: an object changed on the wire is refused bad_tag and never reaches a decoder`, badAfter === badBefore + 1 && va.decode_errors === vb.decode_errors, `group ${tampered}: bad_tag ${badBefore} → ${badAfter}; decode errors ${vb.decode_errors} → ${va.decode_errors}`); + // What reaches the decode path is read as a WAI1 envelope first: sealed bytes never read + // as one. (A decoder may still report the frame after the dropped one, which refers to it.) + check(`${label}: an object changed on the wire is refused bad_tag and never reaches a decoder`, badAfter === badBefore + 1 && (va.envelope_errors || 0) === (vb.envelope_errors || 0), `group ${tampered}: bad_tag ${badBefore} → ${badAfter}; envelopes that did not read ${vb.envelope_errors || 0} → ${va.envelope_errors || 0}; decoder errors ${vb.decode_errors} → ${va.decode_errors}`); // Carol joins late and fetches the earlier groups: nothing sealed before her join opens. const C = await enter("carol", { fetch: "1" }, "publisher", "publishing"); diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js index f80ad2e68..eba2079a4 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js +++ b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js @@ -905,7 +905,7 @@ function makeDecoder(P, tr, T, st) { function decodeEnvelope(P, st, T, group, envelope, fetched) { let e; - try { e = wai.unpack(envelope); } catch (err) { T.decode_errors++; return log(`an envelope that does not read: ${err.message || err}`); } + try { e = wai.unpack(envelope); } catch (err) { T.decode_errors++; T.envelope_errors = (T.envelope_errors || 0) + 1; return log(`an envelope that does not read: ${err.message || err}`); } const manifest = JSON.parse(e.manifest); const cap = e.capability; const payload = e.payload(); -- 2.54.0 (Apple Git-157)