open-standards/joule-code/joule-code-rs/crates/joule-moq-web/src/e2ee_tests.rs
Transaction Science 8e075cba2d joule-moq-web: the room page in an end-to-end encrypted room, and the e2ee scenario (WIP)
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 <[email protected]>
2026-10-20 08:43:46 -04:00

245 lines
11 KiB
Rust

//! 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<u8>),
}
/// The room service, reduced: its clock and its log.
struct Service {
clock: Clock,
log: Vec<Entry>,
}
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<u8>]) -> 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<Vec<u8>> = 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<u8>, Vec<u8>) {
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<u8>, Vec<u8>) {
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<u8>) {
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");
// 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");
// 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::<Value>(&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::<Value>(&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");
}