wai.live.e2ee: end-to-end encrypted rooms on MoQ secure objects and MLS keying (L10) #345

Merged
dcharlot merged 13 commits from wai/e2ee into main 2026-10-20 03:13:01 -04:00
37 changed files with 11674 additions and 358 deletions

View file

@ -39,6 +39,10 @@ on:
# joule-moq-ingest's tests/room_corpus.rs holds its reading of room descriptors
# and grants to the live-rooms corpus.
- 'wai/live-conformance/rooms/**'
# joule-moq's tests/secure_objects_vectors.rs and joule-moq-mls's tests/corpus.rs
# read the end-to-end encrypted rooms corpus, and the latter that extension's §13.
- 'wai/live-conformance/e2ee/**'
- 'wai/extensions/confidentiality.md'
- '.github/workflows/joule-code-conformance.yml'
push:
branches:
@ -55,6 +59,10 @@ on:
# joule-moq-ingest's tests/room_corpus.rs holds its reading of room descriptors
# and grants to the live-rooms corpus.
- 'wai/live-conformance/rooms/**'
# joule-moq's tests/secure_objects_vectors.rs and joule-moq-mls's tests/corpus.rs
# read the end-to-end encrypted rooms corpus, and the latter that extension's §13.
- 'wai/live-conformance/e2ee/**'
- 'wai/extensions/confidentiality.md'
- '.github/workflows/joule-code-conformance.yml'
jobs:
@ -114,6 +122,22 @@ jobs:
working-directory: joule-code/joule-code-rs
run: cargo test -p joule-moq --features quic --all-targets
# End-to-end encrypted rooms (WAI extensions/confidentiality.md). joule-moq's sealed
# objects, named so they stay built and tested whatever the workspace's other members
# enable: RFC 9605's and draft-ietf-moq-secure-objects-01's published vectors, and
# 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.
- name: Sealed objects and the room's MLS binding
working-directory: joule-code/joule-code-rs
run: |
set -e
cargo test -p joule-moq --features secure-objects --lib secure_objects
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
# 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
# (tests/draft22_vectors.rs) holds the crate to the same corpus and fails if
@ -149,7 +173,9 @@ jobs:
run: cargo test
# The MOQT draft-22 relay. Every test is on loopback: two publishers and three
# subscribers over native QUIC, WebTransport over HTTP/3 and the WebSocket fallback;
# subscribers over native QUIC, WebTransport over HTTP/3 and the WebSocket fallback; a
# room's sealed objects, forwarded byte for byte and checked at the door on their
# receipts over the sealed bytes, opened by members and by no one else;
# a slow subscriber whose late layers time out while every receipt still arrives; a
# late joiner served by FETCH and a fill from the cache, and a FETCH forwarded
# upstream; GOAWAY on shutdown; tokens by alias; Request IDs and the stream limit;

View file

@ -95,6 +95,9 @@ on:
# reports, live_moderation_verify.py reads its §9.5, §9.6 and §10 tables, and the
# registry's wire identifiers test reads its sections.
- 'wai/extensions/live-moderation.md'
# e2ee_verify.py reads this extension's §13 table, and the registry's wire identifiers
# test reads its sections.
- 'wai/extensions/confidentiality.md'
- 'wai/wai-web/**'
# The audio provenance check below reads the corpora above with this tool.
- 'wai/tools/wai_audio_provenance_check.py'
@ -763,7 +766,8 @@ jobs:
# live_compose_verify.py (the live-compose corpus), live_record_verify.py (the
# live-recording corpus), timed_text_verify.py (the timed-text corpus),
# live_metering_verify.py (the live-metering corpus), energy_steering_verify.py (the
# energy-steering corpus), and live_moderation_verify.py (the live-moderation corpus).
# energy-steering corpus), and live_moderation_verify.py (the live-moderation corpus), and
# e2ee_verify.py (the end-to-end encrypted rooms corpus).
# Each exits non-zero on any failure or any difference, and the step fails with it.
# One platform: what they check is the reading of the text, not the architecture.
independent-verifiers:
@ -1055,6 +1059,20 @@ jobs:
(cd wai-rs && cargo test --no-default-features --features live_moderation --lib live_moderation::)
(cd wai-rs && cargo test --no-default-features --features live_moderation --test live_moderation)
python3 -I live-conformance/live_moderation_verify.py live-conformance/moderation
# wai.live.e2ee (extensions/confidentiality.md): the corpus's second implementation,
# e2ee_verify.py, written from the extension's text and the documents it reads and
# sharing no code with the reference (joule-moq's secure_objects and joule-moq-mls,
# whose own checks run in joule-code-conformance.yml). Standard library only, run
# isolated (-I): AES, AES-GCM and RFC 9605's counter mode written from their
# specifications for test vectors only, HKDF and HMAC from the standard library,
# BLAKE3 and strict Ed25519 from claim-conformance. It checks every vector RFC 9605
# and draft-ietf-moq-secure-objects-01 publish, every record, key schedule, sealed
# object and room of vectors.json (taking each epoch's application secret as given,
# §14), and requires every mutated copy of the corpus to fail.
- name: end-to-end encrypted rooms · e2ee_verify.py
working-directory: wai
shell: bash
run: python3 -I live-conformance/e2ee_verify.py live-conformance/e2ee
# The quantum corpus's second implementation (quantum-sim extension), integers only:
# it derives the circuit/1 phase table by its recurrence and the circuit2 table by an
# interval-certified procedure, and requires both literal tables and their digests,

File diff suppressed because it is too large Load diff

View file

@ -17,6 +17,7 @@ members = [
"crates/joule-code-identity",
"crates/jwp",
"crates/joule-moq",
"crates/joule-moq-mls",
"crates/joule-moq-relay",
"crates/joule-moq-ingest",
"crates/joule-moq-web",

View file

@ -0,0 +1,27 @@
[package]
name = "joule-moq-mls"
version = "0.1.0"
description = "End-to-end encrypted WAI live rooms: one MLS group (RFC 9420) per room, its membership driven by the room's grants and lifecycle, the room's clock as the group's external sender and a committer every member computes alike, and each epoch's per-sender keys for joule-moq's sealed objects (draft-ietf-moq-secure-objects-01). With the room's confidentiality record and the corpus generator of WAI live-conformance/e2ee."
edition = "2024"
rust-version = "1.97"
license = "Apache-2.0"
repository = "https://git.transaction.science/Transaction-Science/open-standards"
[[bin]]
name = "joule_moq_e2ee_vectors"
path = "src/bin/joule_moq_e2ee_vectors.rs"
[dependencies]
joule-moq = { path = "../joule-moq", default-features = false, features = ["secure-objects"] }
# MLS (RFC 9420), MIT, pinned exactly: the group, its key schedule and exporter, external
# proposals, and the public view of a group a non-member keeps.
openmls = "=0.9.1"
openmls_rust_crypto = "=0.6.0"
openmls_traits = "=0.6.0"
tls_codec = "0.5"
ed25519-dalek = "2"
sha2 = "0.10"
blake3 = "1"
hex = "0.4"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View file

@ -0,0 +1,57 @@
//! The corpus of WAI `live-conformance/e2ee/` (confidentiality §14).
//!
//! joule_moq_e2ee_vectors gen <corpus-dir> write vectors.json from the fixed seeds
//! joule_moq_e2ee_vectors verify <corpus-dir> vectors.json regenerates byte for byte, and
//! the reference gives every vector its outcome
use joule_moq_mls::corpus;
use std::process::ExitCode;
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().collect();
let (Some(mode), Some(dir)) = (args.get(1), args.get(2)) else {
eprintln!("usage: joule_moq_e2ee_vectors gen|verify <corpus-dir>");
return ExitCode::from(2);
};
let path = std::path::Path::new(dir).join(corpus::FILE);
let v = corpus::generate();
let bytes = corpus::to_bytes(&v);
match mode.as_str() {
"gen" => {
if let Err(e) = std::fs::write(&path, &bytes) {
eprintln!("{}: {e}", path.display());
return ExitCode::FAILURE;
}
println!("wrote {}", path.display());
ExitCode::SUCCESS
}
"verify" => {
let on_disk = match std::fs::read(&path) {
Ok(b) => b,
Err(e) => {
eprintln!("{}: {e}", path.display());
return ExitCode::FAILURE;
}
};
if on_disk != bytes {
eprintln!("{} does not regenerate byte for byte", path.display());
return ExitCode::FAILURE;
}
let parsed: serde_json::Value = serde_json::from_slice(&on_disk).expect("JSON");
match corpus::check(&parsed) {
Ok(()) => {
println!("{}: every vector regenerates and gives its stated outcome", path.display());
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("{e}");
ExitCode::FAILURE
}
}
}
_ => {
eprintln!("usage: joule_moq_e2ee_vectors gen|verify <corpus-dir>");
ExitCode::from(2)
}
}
}

View file

@ -0,0 +1,841 @@
//! The corpus of WAI `live-conformance/e2ee/` (confidentiality §14): `vectors.json`, written
//! from fixed seeds by [`generate`], and the reference's reading of every vector,
//! [`check`], which the generator also uses to state each outcome.
//!
//! The corpus starts where the MLS layer hands over: each epoch's application secret is
//! stated (drawn from a seed, standing for `MLS-Exporter("wai live e2ee", room_id, 32)`),
//! and everything after it is checked: rosters and committers from the transcript, track
//! base keys, KIDs, keys, salts, nonces, AADs, every seal and open, and every refusal. The
//! crate's tests run real MLS groups through the same model and keyring.
use crate::model::{Change, Event, From as By, Principal, Proposal, Room, Roster};
use crate::record::{Record, MLS_SUITE};
use ed25519_dalek::SigningKey;
use joule_moq::draft22::kvp::{KeyValuePair, KvValue, Properties};
use joule_moq::secure_objects::room::{derive, seal_with, Incoming, Keyring, NotOpened, Outgoing};
use joule_moq::secure_objects::{aad, ctr, nonce, object_properties, Plaintext, Refusal, Suite};
use joule_moq::{TrackName, TrackNamespace};
use serde_json::{json, Map, Value};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
/// The file the generator writes.
pub const FILE: &str = "vectors.json";
fn sha(parts: &[&[u8]]) -> [u8; 32] {
let mut h = Sha256::new();
for p in parts {
h.update(p);
}
h.finalize().into()
}
/// A key drawn from the corpus's seed for `label`.
pub fn key(label: &str) -> SigningKey {
SigningKey::from_bytes(&sha(&[b"wai:e2ee/corpus/key\x00", label.as_bytes()]))
}
/// The application secret standing for epoch `epoch` of room `label`'s generation `g`.
pub fn secret(label: &str, g: u32, epoch: u64) -> [u8; 32] {
sha(&[b"wai:e2ee/corpus/secret\x00", label.as_bytes(), &g.to_be_bytes(), &epoch.to_be_bytes()])
}
/// `n` bytes drawn for `label`.
pub fn bytes(label: &str, n: usize) -> Vec<u8> {
let mut out = Vec::with_capacity(n + 32);
let mut i = 0u32;
while out.len() < n {
out.extend_from_slice(&sha(&[b"wai:e2ee/corpus/bytes\x00", label.as_bytes(), &i.to_be_bytes()]));
i += 1;
}
out.truncate(n);
out
}
fn h(b: &[u8]) -> String {
hex::encode(b)
}
fn unhex(v: &Value) -> Result<Vec<u8>, String> {
hex::decode(v.as_str().ok_or("not a string")?).map_err(|e| e.to_string())
}
fn unhex32(v: &Value) -> Result<[u8; 32], String> {
unhex(v)?.try_into().map_err(|_| "not 32 bytes".to_string())
}
fn num(v: &Value) -> Result<u64, String> {
v.as_u64().ok_or_else(|| format!("not a number: {v}"))
}
fn ns_strings(ns: &TrackNamespace) -> Value {
Value::Array(ns.0.iter().map(|f| Value::String(String::from_utf8(f.clone()).expect("ASCII"))).collect())
}
fn ns_of(v: &Value) -> Result<TrackNamespace, String> {
let a = v.as_array().ok_or("namespace not an array")?;
Ok(TrackNamespace::from_strs(a.iter().map(|f| f.as_str().unwrap_or_default())))
}
/// A room track's namespace: the room id, the owner, the media kind (live-rooms §6.1).
pub fn room_ns(room: &[u8; 32], owner: &Principal, kind: &str) -> TrackNamespace {
TrackNamespace::from_strs([h(room), h(owner), kind.to_string()])
}
fn roster_json(r: &Roster) -> Value {
Value::Object(r.iter().map(|(l, p)| (l.to_string(), Value::String(h(p)))).collect())
}
fn roster_of(v: &Value) -> Result<Roster, String> {
let o = v.as_object().ok_or("roster not an object")?;
o.iter().map(|(k, p)| Ok((k.parse::<u32>().map_err(|e| e.to_string())?, unhex32(p)?))).collect()
}
fn props_json(p: &[KeyValuePair]) -> Value {
Value::Array(
p.iter()
.map(|k| match &k.value {
KvValue::Varint(v) => json!({"type": k.ty, "varint": v}),
KvValue::Bytes(b) => json!({"type": k.ty, "bytes": h(b)}),
})
.collect(),
)
}
fn props_of(v: &Value) -> Result<Vec<KeyValuePair>, String> {
let a = v.as_array().ok_or("properties not an array")?;
a.iter()
.map(|k| {
let ty = num(&k["type"])?;
Ok(match k.get("varint") {
Some(x) => KeyValuePair::varint(ty, num(x)?),
None => KeyValuePair::bytes(ty, unhex(&k["bytes"])?),
})
})
.collect()
}
/// Sort every object's keys, so the file's bytes do not depend on how a JSON map orders.
fn canonical(v: Value) -> Value {
match v {
Value::Object(m) => {
let sorted: BTreeMap<String, Value> = m.into_iter().map(|(k, v)| (k, canonical(v))).collect();
Value::Object(sorted.into_iter().collect::<Map<String, Value>>())
}
Value::Array(a) => Value::Array(a.into_iter().map(canonical).collect()),
x => x,
}
}
fn outcome_of(r: Result<joule_moq::secure_objects::room::Opened, NotOpened>) -> Value {
match r {
Ok(o) => json!({"outcome": "opened", "payload_blake3": h(blake3::hash(&o.plaintext.payload).as_bytes())}),
Err(NotOpened::Refused(r)) => json!({"outcome": r.code()}),
Err(NotOpened::Pending(e)) => json!({"outcome": "pending", "epoch": e}),
}
}
// ── Records ─────────────────────────────────────────────────────────────────
const ROOM_LABEL: &str = "lesson";
fn the_room() -> [u8; 32] {
sha(&[b"wai:e2ee/corpus/room\x00", ROOM_LABEL.as_bytes()])
}
fn records() -> Vec<Value> {
let auth = key("authority");
let a = auth.verifying_key().to_bytes();
let room = the_room();
let good = Record::sign(room, 4, MLS_SUITE, &auth).to_bytes();
let mut identity = [0u8; 32];
identity[0] = 1;
let mut v = vec![
("accepted, AES_128_GCM_SHA256_128", good.clone(), a, 0u8),
("accepted, AES_256_GCM_SHA512_128", Record::sign(room, 5, MLS_SUITE, &auth).to_bytes(), a, 0),
("fewer than four bytes", good[..3].to_vec(), a, 0),
("magic only", good[..4].to_vec(), a, 0),
("cut inside the signature", good[..100].to_vec(), a, 0),
("another magic", [b"WRD1".as_slice(), &good[4..]].concat(), a, 0),
("a byte after the signature", [good.as_slice(), &[0]].concat(), a, 0),
("version 2", { let mut b = good.clone(); b[4] = 2; b }, a, 0),
("another room's", Record::sign([0x11; 32], 4, MLS_SUITE, &auth).to_bytes(), a, 0),
("a counter-mode suite", Record::sign(room, 1, MLS_SUITE, &auth).to_bytes(), a, 0),
("an unregistered suite", Record::sign(room, 6, MLS_SUITE, &auth).to_bytes(), a, 0),
("another MLS suite", Record::sign(room, 4, 3, &auth).to_bytes(), a, 0),
("a room that records", good.clone(), a, 1),
("a room always recorded", good.clone(), a, 2),
("signed by another key", Record::sign(room, 4, MLS_SUITE, &key("intruder")).to_bytes(), a, 0),
("the identity as the authority", good.clone(), identity, 0),
("a changed suite under the old signature", { let mut b = good.clone(); b[37] = 5; b }, a, 0),
];
v.drain(..)
.map(|(name, rec, authority, mode)| {
json!({"name": name, "record": h(&rec), "room": h(&room), "authority": h(&authority), "recording_mode": mode})
})
.collect()
}
fn check_record(v: &Value) -> Result<Value, String> {
let r = Record::check(&unhex(&v["record"])?, &unhex32(&v["room"])?, &unhex32(&v["authority"])?, num(&v["recording_mode"])? as u8);
Ok(match r {
Ok((_, s)) => json!({"outcome": "accept", "suite": s.id()}),
Err(e) => json!({"outcome": e.code()}),
})
}
// ── Key schedule ────────────────────────────────────────────────────────────
fn key_schedule() -> Vec<Value> {
let room = the_room();
let mut out = Vec::new();
for (suite, epoch, leaf, owner, kind, track) in [
(Suite::AesGcm128Sha256, 0u64, 0u32, "a", "video", "0"),
(Suite::AesGcm128Sha256, 0, 0, "a", "video", "1"),
(Suite::AesGcm128Sha256, 1, 1, "b", "audio", "0"),
(Suite::AesGcm128Sha256, 2, 2, "c", "screen", "0"),
(Suite::AesGcm256Sha512, 7, 3, "d", "video", "2"),
(Suite::AesGcm256Sha512, (1 << 40) - 1, (1 << 24) - 1, "e", "audio", "0"),
] {
let s = secret(ROOM_LABEL, 0, epoch);
let ns = room_ns(&room, &key(owner).verifying_key().to_bytes(), kind);
let name = TrackName::from_str(track);
let d = derive(suite, &s, epoch, leaf, &ns, &name).expect("in range");
out.push(json!({
"suite": suite.id(), "application_secret": h(&s), "epoch": epoch, "leaf": leaf,
"namespace": ns_strings(&ns), "track": track,
"track_base_key": h(&d.track_base_key), "kid": d.kid, "serialized_full_track_name": h(&d.ftn),
"moq_key": h(&d.keys.key), "moq_salt": h(&d.keys.salt),
}));
}
out
}
fn check_key_schedule(v: &Value) -> Result<(), String> {
let suite = Suite::from_id(num(&v["suite"])? as u16).ok_or("suite")?;
let ns = ns_of(&v["namespace"])?;
let name = TrackName::from_str(v["track"].as_str().ok_or("track")?);
let d = derive(suite, &unhex32(&v["application_secret"])?, num(&v["epoch"])?, num(&v["leaf"])? as u32, &ns, &name).ok_or("out of range")?;
let got = json!({"track_base_key": h(&d.track_base_key), "kid": d.kid, "serialized_full_track_name": h(&d.ftn), "moq_key": h(&d.keys.key), "moq_salt": h(&d.keys.salt)});
for (k, x) in got.as_object().unwrap() {
if &v[k] != x {
return Err(format!("{k}: {} != {x}", v[k]));
}
}
Ok(())
}
// ── Single objects ──────────────────────────────────────────────────────────
/// A receiver holding one epoch of a room, at which every leaf of `roster` is a member.
fn context(receiver: &Principal, epoch: u64, s: &[u8; 32], roster: &Roster) -> Value {
json!({"receiver": h(receiver), "epochs": [{"epoch": epoch, "application_secret": h(s), "roster": roster_json(roster)}]})
}
fn keyring_of(suite: Suite, ctx: &Value) -> Result<Keyring, String> {
let mut k = Keyring::new(suite, unhex32(&ctx["receiver"])?);
for e in ctx["epochs"].as_array().ok_or("epochs")? {
k.enter(num(&e["epoch"])?, unhex32(&e["application_secret"])?, roster_of(&e["roster"])?);
}
Ok(k)
}
fn object_json(ns: &TrackNamespace, track: &str, group: u64, object: u64, priority: u8, props: &Properties, payload: &[u8]) -> Value {
json!({
"namespace": ns_strings(ns), "track": track, "group": group, "object": object, "priority": priority,
"properties": h(&props.to_bytes().expect("encodes")), "payload": h(payload),
})
}
fn open_json(ring: &mut Keyring, o: &Value) -> Result<Value, String> {
let ns = ns_of(&o["namespace"])?;
let name = TrackName::from_str(o["track"].as_str().ok_or("track")?);
let props = Properties::decode(&unhex(&o["properties"])?, "object properties").map_err(|_| "properties").ok();
let payload = unhex(&o["payload"])?;
let Some(props) = props else { return Ok(json!({"outcome": Refusal::PropertiesMalformed.code()})) };
let inc = Incoming { ns: &ns, name: &name, group: num(&o["group"])?, object: num(&o["object"])?, priority: num(&o["priority"])? as u8, properties: &props, payload: &payload };
Ok(outcome_of(ring.open(&inc, None)))
}
fn objects() -> Vec<Value> {
let room = the_room();
let (a, b) = (key("a").verifying_key().to_bytes(), key("b").verifying_key().to_bytes());
let roster: Roster = [(0, a), (1, b)].into();
let mut out = Vec::new();
struct Case<'a> {
name: &'a str,
suite: Suite,
kind: &'a str,
track: &'a str,
group: u64,
object: u64,
priority: u8,
payload: Vec<u8>,
digest: bool,
padding: Option<u16>,
entries: Vec<(u16, Vec<u8>)>,
immutable: Vec<KeyValuePair>,
}
let cases = [
Case { name: "a video frame with its inner digest", suite: Suite::AesGcm128Sha256, kind: "video", track: "0", group: 12, object: 0, priority: 64, payload: bytes("frame", 1200), digest: true, padding: None, entries: vec![], immutable: vec![] },
Case { name: "a frame of speech, no list", suite: Suite::AesGcm128Sha256, kind: "audio", track: "0", group: 3, object: 41, priority: 16, payload: bytes("speech", 60), digest: false, padding: None, entries: vec![], immutable: vec![] },
Case { name: "padded, with an entry of another type", suite: Suite::AesGcm128Sha256, kind: "screen", track: "0", group: 1, object: 2, priority: 128, payload: bytes("screen", 300), digest: true, padding: Some(200), entries: vec![(0x0100, b"for-eyes-only".to_vec())], immutable: vec![] },
Case { name: "a refinement layer with the layer properties", suite: Suite::AesGcm256Sha512, kind: "video", track: "1", group: 12, object: 3, priority: 96, payload: bytes("layer", 700), digest: true, padding: None, entries: vec![], immutable: vec![KeyValuePair::varint(0x3a40, 1), KeyValuePair::varint(0x3a42, 3)] },
Case { name: "an envelope, AES_256_GCM_SHA512_128", suite: Suite::AesGcm256Sha512, kind: "audio", track: "0", group: u32::MAX as u64 + 7, object: u32::MAX as u64, priority: 0, payload: bytes("envelope", 64), digest: true, padding: Some(0), entries: vec![], immutable: vec![KeyValuePair::varint(0x3a44, 1)] },
Case { name: "an empty payload", suite: Suite::AesGcm128Sha256, kind: "video", track: "0", group: 0, object: 0, priority: 255, payload: vec![], digest: true, padding: None, entries: vec![], immutable: vec![] },
];
for c in cases {
let (epoch, leaf, sender) = (2u64, 1u32, b);
let s = secret(ROOM_LABEL, 0, epoch);
let ns = room_ns(&room, &sender, c.kind);
let name = TrackName::from_str(c.track);
let d = derive(c.suite, &s, epoch, leaf, &ns, &name).expect("in range");
let mut pt = if c.digest { Plaintext::with_digest(c.payload.clone()) } else { Plaintext { payload: c.payload.clone(), ..Default::default() } };
pt.padding = c.padding;
pt.other = c.entries.clone();
let o = Outgoing { ns: &ns, name: &name, group: c.group, object: c.object, priority: c.priority, plaintext: &pt, immutable: &c.immutable, mutable: &[] };
let sealed = seal_with(c.suite, &d, &o).expect("seals");
let a_ = aad(c.group, c.object, c.priority, &sealed.sip).unwrap();
let n = nonce(&d.keys.salt, &ctr(c.group, c.object).unwrap());
out.push(json!({
"name": c.name, "suite": c.suite.id(),
"input": {
"application_secret": h(&s), "epoch": epoch, "leaf": leaf, "namespace": ns_strings(&ns), "track": c.track,
"group": c.group, "object": c.object, "priority": c.priority, "payload": h(&c.payload), "inner_digest": c.digest,
"padding": c.padding, "entries": Value::Array(c.entries.iter().map(|(t, v)| json!({"type": t, "value": h(v)})).collect()),
"immutable": props_json(&c.immutable),
},
"derived": {
"track_base_key": h(&d.track_base_key), "kid": d.kid, "serialized_full_track_name": h(&d.ftn),
"moq_key": h(&d.keys.key), "moq_salt": h(&d.keys.salt), "immutable_properties": h(&sealed.sip),
"plaintext": h(&pt.encode()), "aad": h(&a_), "nonce": h(&n),
},
"object": object_json(&ns, c.track, c.group, c.object, c.priority, &sealed.properties, &sealed.payload),
"content_hash": h(blake3::hash(&sealed.payload).as_bytes()),
"context": context(&a, epoch, &s, &roster),
}));
}
// Negatives, each a control of one defect, opened by A in the same epoch.
let epoch = 2u64;
let s = secret(ROOM_LABEL, 0, epoch);
let ns = room_ns(&room, &b, "video");
let name = TrackName::from_str("0");
let d = derive(Suite::AesGcm128Sha256, &s, epoch, 1, &ns, &name).unwrap();
let pt = Plaintext::with_digest(bytes("neg", 80));
let good = seal_with(Suite::AesGcm128Sha256, &d, &Outgoing { ns: &ns, name: &name, group: 5, object: 1, priority: 64, plaintext: &pt, immutable: &[], mutable: &[] }).unwrap();
let seal_raw = |raw: &[u8], group: u64, object: u64| {
let at = joule_moq::secure_objects::Location { ftn: &d.ftn, group, object, priority: 64 };
joule_moq::secure_objects::seal_object(Suite::AesGcm128Sha256, &d.keys, at, &good.sip, raw).unwrap()
};
let no_kid = object_properties(&Properties(vec![KeyValuePair::varint(0x3a40, 0)]).to_bytes().unwrap(), &[]);
let two_kids = object_properties(&Properties(vec![KeyValuePair::varint(0x3a4a, d.kid), KeyValuePair::varint(0x3a4a, d.kid)]).to_bytes().unwrap(), &[]);
let mut outside = good.properties.clone();
outside.push(KeyValuePair::varint(0x3a4a, d.kid));
let garbage = object_properties(&[0x0b, 0x05, 0x01], &[]);
let mut bad_list = Plaintext { payload: bytes("neg", 80), ..Default::default() }.encode();
bad_list.extend_from_slice(&[0x00, 0x0b, 0x00]);
let lie = Plaintext { payload: bytes("neg", 80), inner_digest: Some(*blake3::hash(b"something else").as_bytes()), ..Default::default() };
let big = 1u64 << 32;
// (what is wrong, group, object, publisher priority, properties, payload)
type Negative<'a> = (&'a str, u64, u64, u8, Properties, Vec<u8>);
let negs: Vec<Negative> = vec![
("no Key ID", 5, 1, 64, no_kid, good.payload.clone()),
("no Immutable Properties", 5, 1, 64, Properties::new(), good.payload.clone()),
("two Key IDs", 5, 1, 64, two_kids, good.payload.clone()),
("a Key ID outside Immutable Properties", 5, 1, 64, outside, good.payload.clone()),
("Immutable Properties that do not parse", 5, 1, 64, garbage, good.payload.clone()),
("an Object ID past 32 bits", 5, big, 64, good.properties.clone(), good.payload.clone()),
("another priority", 5, 1, 65, good.properties.clone(), good.payload.clone()),
("another object", 5, 2, 64, good.properties.clone(), good.payload.clone()),
("a changed tag", 5, 1, 64, good.properties.clone(), { let mut p = good.payload.clone(); *p.last_mut().unwrap() ^= 0x80; p }),
("cut short of a tag", 5, 1, 64, good.properties.clone(), good.payload[..10].to_vec()),
("an Encrypted Properties List of another type", 6, 0, 64, good.properties.clone(), seal_raw(&bad_list, 6, 0)),
("a payload length past the plaintext", 6, 1, 64, good.properties.clone(), seal_raw(&[0x40, 0x01], 6, 1)),
("an inner digest of other bytes", 6, 2, 64, good.properties.clone(), seal_raw(&lie.encode(), 6, 2)),
];
let roster: Roster = [(0, a), (1, b)].into();
for (n, g, o, p, props, payload) in negs {
out.push(json!({"name": n, "suite": 4, "object": object_json(&ns, "0", g, o, p, &props, &payload), "context": context(&a, epoch, &s, &roster)}));
}
out
}
fn check_object(v: &Value) -> Result<Value, String> {
let suite = Suite::from_id(num(&v["suite"])? as u16).ok_or("suite")?;
if let Some(input) = v.get("input") {
// Seal from the inputs and require every derived value and the object itself.
let ns = ns_of(&input["namespace"])?;
let track = input["track"].as_str().ok_or("track")?;
let name = TrackName::from_str(track);
let (epoch, leaf) = (num(&input["epoch"])?, num(&input["leaf"])? as u32);
let d = derive(suite, &unhex32(&input["application_secret"])?, epoch, leaf, &ns, &name).ok_or("range")?;
let payload = unhex(&input["payload"])?;
let mut pt = if input["inner_digest"].as_bool() == Some(true) { Plaintext::with_digest(payload) } else { Plaintext { payload, ..Default::default() } };
pt.padding = input["padding"].as_u64().map(|p| p as u16);
for e in input["entries"].as_array().ok_or("entries")? {
pt.other.push((num(&e["type"])? as u16, unhex(&e["value"])?));
}
let imm = props_of(&input["immutable"])?;
let (g, o, p) = (num(&input["group"])?, num(&input["object"])?, num(&input["priority"])? as u8);
let sealed = seal_with(suite, &d, &Outgoing { ns: &ns, name: &name, group: g, object: o, priority: p, plaintext: &pt, immutable: &imm, mutable: &[] }).map_err(|e| e.to_string())?;
let derived = json!({
"track_base_key": h(&d.track_base_key), "kid": d.kid, "serialized_full_track_name": h(&d.ftn),
"moq_key": h(&d.keys.key), "moq_salt": h(&d.keys.salt), "immutable_properties": h(&sealed.sip),
"plaintext": h(&pt.encode()), "aad": h(&aad(g, o, p, &sealed.sip).unwrap()), "nonce": h(&nonce(&d.keys.salt, &ctr(g, o).unwrap())),
});
if derived != v["derived"] {
return Err(format!("derived values differ: {derived}"));
}
if object_json(&ns, track, g, o, p, &sealed.properties, &sealed.payload) != v["object"] {
return Err("the sealed object differs".into());
}
if h(blake3::hash(&sealed.payload).as_bytes()) != v["content_hash"] {
return Err("the content hash differs".into());
}
}
let mut ring = keyring_of(suite, &v["context"])?;
open_json(&mut ring, &v["object"])
}
// ── Rooms ───────────────────────────────────────────────────────────────────
/// A room replayed step by step (§5, §6): the model, each principal's keyring, and each
/// epoch's roster.
struct Replay {
suite: Suite,
model: Room,
rings: BTreeMap<Principal, Keyring>,
rosters: Vec<Value>,
}
impl Replay {
fn new(suite: Suite, room: [u8; 32], clock: Principal) -> Self {
Replay { suite, model: Room::new(room, clock), rings: BTreeMap::new(), rosters: Vec::new() }
}
fn enter_all(&mut self, secret: [u8; 32]) {
let g = self.model.group().expect("a group").clone();
self.rosters.push(json!({"generation": g.generation, "epoch": g.epoch, "roster": roster_json(&g.roster)}));
for p in g.roster.values() {
self.rings.entry(*p).or_insert_with(|| Keyring::new(self.suite, *p)).enter(g.epoch, secret, g.roster.clone());
}
}
/// Apply one step; a delivery's outcomes are returned, a refusal stops the room.
fn step(&mut self, s: &Value) -> Result<Option<Vec<Value>>, Refusal> {
fn bad<E>(_: E) -> Refusal {
Refusal::Malformed
}
if let Some(e) = s.get("event") {
let ev: Event = serde_json::from_value(e.clone()).map_err(bad)?;
self.model.event(&ev);
return Ok(None);
}
if let Some(c) = s.get("create") {
let ext: Vec<Principal> = c["external_senders"].as_array().ok_or(Refusal::Malformed)?.iter().map(unhex32).collect::<Result<_, _>>().map_err(bad)?;
self.model.create(unhex32(&c["by"]).map_err(bad)?, &unhex(&c["group_id"]).map_err(bad)?, &ext)?;
self.enter_all(unhex32(&c["application_secret"]).map_err(bad)?);
return Ok(None);
}
if let Some(p) = s.get("proposal") {
let p: Proposal = serde_json::from_value(p.clone()).map_err(bad)?;
self.model.propose(p)?;
return Ok(None);
}
if let Some(c) = s.get("commit") {
let holds: Vec<usize> = c["holds"].as_array().ok_or(Refusal::Malformed)?.iter().map(|x| x.as_u64().map(|x| x as usize).ok_or(Refusal::Malformed)).collect::<Result<_, _>>()?;
let done = self.model.commit(num(&c["sender"]).map_err(bad)? as u32, &holds)?;
for (_, p) in &done.removed {
if let Some(r) = self.rings.get_mut(p) {
r.removed_at(done.epoch);
}
}
self.enter_all(unhex32(&c["application_secret"]).map_err(bad)?);
return Ok(None);
}
if let Some(o) = s.get("object") {
let mut out = Vec::new();
for d in s["deliveries"].as_array().ok_or(Refusal::Malformed)? {
let who = unhex32(&d["receiver"]).map_err(bad)?;
let ring = self.rings.entry(who).or_insert_with(|| Keyring::new(self.suite, who));
let mut r = open_json(ring, o).map_err(bad)?;
r["receiver"] = Value::String(h(&who));
out.push(r);
}
return Ok(Some(out));
}
Err(Refusal::Malformed)
}
}
/// Replay a room: its outcome, each epoch's roster, and each delivery's outcome.
fn replay(v: &Value) -> Result<Value, String> {
let suite = Suite::from_id(num(&v["suite"])? as u16).ok_or("suite")?;
let mut r = Replay::new(suite, unhex32(&v["room"])?, unhex32(&v["clock"])?);
let mut deliveries = Vec::new();
let mut outcome = json!("accept");
for (i, s) in v["steps"].as_array().ok_or("steps")?.iter().enumerate() {
match r.step(s) {
Ok(Some(d)) => deliveries.push(json!({"step": i, "deliveries": d})),
Ok(None) => {}
Err(e) => {
outcome = json!({"refuse": e.code(), "at": i});
break;
}
}
}
Ok(json!({"outcome": outcome, "rosters": r.rosters, "deliveries": deliveries}))
}
/// Builds a room's steps while replaying them, so each object is sealed by its sender in
/// the epoch it holds.
struct Builder {
label: &'static str,
room: [u8; 32],
clock: Principal,
suite: Suite,
generation: u32,
steps: Vec<Value>,
r: Replay,
}
impl Builder {
fn new(label: &'static str, suite: Suite) -> Self {
let room = the_room();
let clock = key("clock").verifying_key().to_bytes();
Builder { label, room, clock, suite, generation: 0, steps: Vec::new(), r: Replay::new(suite, room, clock) }
}
fn push(&mut self, s: Value) {
self.r.step(&s).expect("the builder's steps are good");
self.steps.push(s);
}
fn push_raw(&mut self, s: Value) {
self.steps.push(s);
}
fn event(&mut self, ev: Event) {
self.push(json!({"event": serde_json::to_value(ev).unwrap()}));
}
fn epoch(&self) -> u64 {
self.r.model.group().map(|g| g.epoch).unwrap_or(0)
}
fn create(&mut self, by: &Principal) {
let gid = Room::group_id_of(&self.room, self.generation);
let s = secret(self.label, self.generation, 0);
self.push(json!({"create": {"by": h(by), "group_id": h(&gid), "external_senders": [h(&self.clock)], "application_secret": h(&s)}}));
}
fn add(&mut self, p: &Principal) {
let pr = Proposal { from: By::Clock, epoch: self.epoch(), change: Change::Add { identity: *p, signature_key: *p } };
self.push(json!({"proposal": serde_json::to_value(pr).unwrap()}));
}
fn remove(&mut self, leaf: u32) {
let pr = Proposal { from: By::Clock, epoch: self.epoch(), change: Change::Remove { leaf } };
self.push(json!({"proposal": serde_json::to_value(pr).unwrap()}));
}
fn commit(&mut self, sender: u32, holds: &[usize]) {
let s = secret(self.label, self.generation, self.epoch() + 1);
self.push(json!({"commit": {"sender": sender, "holds": holds, "application_secret": h(&s)}}));
}
/// Seal an object by `who` in its current epoch.
#[allow(clippy::too_many_arguments)]
fn seal(&self, who: &Principal, owner: &Principal, kind: &str, group: u64, object: u64, digest: bool, label: &str) -> Value {
let ring = &self.r.rings[who];
let ns = room_ns(&self.room, owner, kind);
let name = TrackName::from_str("0");
let payload = bytes(label, 90);
let pt = if digest { Plaintext::with_digest(payload) } else { Plaintext { payload, ..Default::default() } };
let o = Outgoing { ns: &ns, name: &name, group, object, priority: 64, plaintext: &pt, immutable: &[], mutable: &[] };
let s = ring.seal(&o).expect("a member").expect("seals");
object_json(&ns, "0", group, object, 64, &s.properties, &s.payload)
}
fn deliver(&mut self, object: &Value, to: &[&Principal]) {
let d: Vec<Value> = to.iter().map(|p| json!({"receiver": h(*p)})).collect();
self.push(json!({"object": object, "deliveries": d}));
}
fn finish(self, name: &str) -> Value {
json!({"name": name, "room": h(&self.room), "clock": h(&self.clock), "suite": self.suite.id(), "steps": self.steps})
}
}
fn principals() -> [Principal; 5] {
["a", "b", "c", "d", "e"].map(|l| key(l).verifying_key().to_bytes())
}
fn join(p: &Principal) -> Event {
Event::Join { subject: *p, keyed: true }
}
/// The accepted room (§14): three members joining in turn, each publishing, the third
/// removed, a fourth joining late, the first leaving and the second committing.
fn lesson() -> Value {
let [a, b, c, d, e] = principals();
let mut x = Builder::new(ROOM_LABEL, Suite::AesGcm128Sha256);
x.event(join(&a));
x.create(&a);
// Every epoch's keys are used: A alone at epoch 0 opens its own object.
let a0 = x.seal(&a, &a, "screen", 0, 0, true, "a0");
x.deliver(&a0, &[&a, &b]);
x.event(join(&b));
x.add(&b);
x.commit(0, &[0]);
let a1 = x.seal(&a, &a, "screen", 1, 0, true, "a1");
x.deliver(&a1, &[&b]);
x.event(join(&c));
x.add(&c);
x.commit(0, &[0]);
x.event(Event::Join { subject: e, keyed: false });
// Epoch 2: everyone publishes, everyone else opens.
let a2 = x.seal(&a, &a, "video", 0, 0, true, "a2");
let b2 = x.seal(&b, &b, "audio", 0, 0, false, "b2");
let c2 = x.seal(&c, &c, "video", 0, 0, true, "c2");
x.deliver(&a2, &[&b, &c, &e]);
x.deliver(&b2, &[&a, &c]);
x.deliver(&c2, &[&a, &b]);
let c_late = x.seal(&c, &c, "video", 1, 0, true, "c-late");
// C is removed (its grant revoked): epoch 3 excludes it.
x.event(Event::Leave { subject: c, reason: 2 });
x.remove(2);
x.commit(0, &[0]);
x.deliver(&c_late, &[&a, &b]);
let a3 = x.seal(&a, &a, "video", 1, 0, true, "a3");
x.deliver(&a3, &[&b, &c]);
// D joins late and takes C's leaf.
x.event(join(&d));
x.add(&d);
x.commit(0, &[0]);
let d4 = x.seal(&d, &d, "video", 0, 0, true, "d4");
let a4 = x.seal(&a, &a, "video", 2, 0, true, "a4");
x.deliver(&d4, &[&a, &b]);
x.deliver(&a4, &[&d, &c]);
x.deliver(&a2, &[&d]);
x.deliver(&a3, &[&d]);
// A leaves: B, at the lowest leaf left, commits.
x.event(Event::Leave { subject: a, reason: 1 });
x.remove(0);
x.commit(1, &[0]);
let b5 = x.seal(&b, &b, "audio", 1, 0, false, "b5");
x.deliver(&b5, &[&d, &d, &a]);
let mut tampered = x.seal(&b, &b, "audio", 1, 1, false, "b5-1");
let mut pl = hex::decode(tampered["payload"].as_str().unwrap()).unwrap();
pl[3] ^= 0x01;
tampered["payload"] = json!(h(&pl));
x.deliver(&tampered, &[&d]);
let forged = x.seal(&d, &b, "video", 7, 0, true, "forged");
x.deliver(&forged, &[&b]);
// A KID naming a leaf no member holds in epoch 5 (A's, blank since it left).
let ghost = {
let ns = room_ns(&x.room, &b, "audio");
let name = TrackName::from_str("0");
let s = secret(ROOM_LABEL, 0, 5);
let dd = derive(Suite::AesGcm128Sha256, &s, 5, 0, &ns, &name).unwrap();
let pt = Plaintext::with_digest(bytes("ghost", 40));
let so = seal_with(Suite::AesGcm128Sha256, &dd, &Outgoing { ns: &ns, name: &name, group: 2, object: 0, priority: 64, plaintext: &pt, immutable: &[], mutable: &[] }).unwrap();
object_json(&ns, "0", 2, 0, 64, &so.properties, &so.payload)
};
x.deliver(&ghost, &[&d]);
x.finish("three members, a removal, a late joiner, a change of committer")
}
/// The refused rooms (§13), each a control of one defect, cut at the step it refuses.
fn refused_rooms() -> Vec<Value> {
let [a, b, c, d, _] = principals();
let base = |x: &mut Builder| {
x.event(join(&a));
x.create(&a);
x.event(join(&b));
x.add(&b);
x.commit(0, &[0]);
};
let mut out = Vec::new();
// A commit by a member that is not the committer.
let mut x = Builder::new("refused-committer", Suite::AesGcm128Sha256);
base(&mut x);
x.event(join(&c));
x.add(&c);
x.push_raw(json!({"commit": {"sender": 1, "holds": [0], "application_secret": h(&secret("refused-committer", 0, 2))}}));
out.push(x.finish("a commit by a member that is not the committer"));
// After A leaves, A commits its own removal.
let mut x = Builder::new("refused-leaver", Suite::AesGcm128Sha256);
base(&mut x);
x.event(Event::Leave { subject: a, reason: 1 });
x.remove(0);
x.push_raw(json!({"commit": {"sender": 0, "holds": [0], "application_secret": h(&secret("refused-leaver", 0, 2))}}));
out.push(x.finish("the member being removed commits"));
// A commit that skips the clock's first proposal.
let mut x = Builder::new("refused-skip", Suite::AesGcm128Sha256);
base(&mut x);
x.event(join(&c));
x.add(&c);
x.event(join(&d));
x.add(&d);
x.push_raw(json!({"commit": {"sender": 0, "holds": [1], "application_secret": h(&secret("refused-skip", 0, 2))}}));
out.push(x.finish("a commit that skips the clock's first proposal"));
// A commit that holds a proposal twice.
let mut x = Builder::new("refused-twice", Suite::AesGcm128Sha256);
base(&mut x);
x.event(join(&c));
x.add(&c);
x.push_raw(json!({"commit": {"sender": 0, "holds": [0, 0], "application_secret": h(&secret("refused-twice", 0, 2))}}));
out.push(x.finish("a commit that holds one proposal twice"));
// A commit holding a member's own proposal.
let mut x = Builder::new("refused-member", Suite::AesGcm128Sha256);
base(&mut x);
let own = Proposal { from: By::Member(1), epoch: 1, change: Change::Remove { leaf: 0 } };
x.push(json!({"proposal": serde_json::to_value(own).unwrap()}));
x.push_raw(json!({"commit": {"sender": 1, "holds": [0], "application_secret": h(&secret("refused-member", 0, 2))}}));
out.push(x.finish("a commit holding a member's own proposal"));
// The clock removes a member that did not leave.
let mut x = Builder::new("refused-remove", Suite::AesGcm128Sha256);
base(&mut x);
x.event(join(&c));
x.add(&c);
x.commit(0, &[0]);
x.event(Event::Leave { subject: c, reason: 3 });
x.push_raw(json!({"proposal": serde_json::to_value(Proposal { from: By::Clock, epoch: 2, change: Change::Remove { leaf: 1 } }).unwrap()}));
out.push(x.finish("the clock removes a member that did not leave"));
// The clock adds a principal under a grant that is not keyed.
let mut x = Builder::new("refused-unkeyed", Suite::AesGcm128Sha256);
base(&mut x);
x.event(Event::Join { subject: c, keyed: false });
x.push_raw(json!({"proposal": serde_json::to_value(Proposal { from: By::Clock, epoch: 1, change: Change::Add { identity: c, signature_key: c } }).unwrap()}));
out.push(x.finish("the clock adds a principal whose grant is not keyed"));
// A proposal for an epoch that has ended.
let mut x = Builder::new("refused-stale", Suite::AesGcm128Sha256);
base(&mut x);
x.event(join(&c));
x.push_raw(json!({"proposal": serde_json::to_value(Proposal { from: By::Clock, epoch: 0, change: Change::Add { identity: c, signature_key: c } }).unwrap()}));
out.push(x.finish("a proposal for an epoch that has ended"));
// An Add whose credential's identity is not its signature key.
let mut x = Builder::new("refused-credential", Suite::AesGcm128Sha256);
base(&mut x);
x.event(join(&c));
x.push_raw(json!({"proposal": serde_json::to_value(Proposal { from: By::Clock, epoch: 1, change: Change::Add { identity: c, signature_key: d } }).unwrap()}));
out.push(x.finish("an Add whose credential's identity is not its signature key"));
// The wrong creator, another group ID, another external sender.
for (label, by, gid_gen, ext) in [("refused-creator", b, 0u32, None), ("refused-group-id", a, 1, None), ("refused-external", a, 0, Some(a))] {
let mut x = Builder::new(label, Suite::AesGcm128Sha256);
x.event(join(&a));
x.event(join(&b));
let gid = Room::group_id_of(&x.room, gid_gen);
let ext = ext.unwrap_or(x.clock);
x.push_raw(json!({"create": {"by": h(&by), "group_id": h(&gid), "external_senders": [h(&ext)], "application_secret": h(&secret(label, 0, 0))}}));
out.push(x.finish(match label {
"refused-creator" => "a group created by a principal the clock does not owe it to",
"refused-group-id" => "a group whose ID names another generation",
_ => "a group whose external sender is not the clock",
}));
}
out
}
// ── The file ────────────────────────────────────────────────────────────────
/// The whole corpus, every outcome stated by [`check`].
pub fn generate() -> Value {
let mut recs = records();
for r in &mut recs {
let o = check_record(r).expect("reads");
merge(r, o);
}
let ks = key_schedule();
let mut objs = objects();
for o in &mut objs {
let res = check_object(o).expect("checks");
merge(o, res);
}
let mut rooms = vec![lesson()];
rooms.extend(refused_rooms());
for r in &mut rooms {
let res = replay(r).expect("replays");
merge(r, res);
}
canonical(json!({
"corpus": "WAI live-conformance/e2ee",
"spec": "wai/extensions/confidentiality.md",
"generator": "joule_moq_e2ee_vectors gen (joule-code/joule-code-rs/crates/joule-moq-mls)",
"seeds": "every key, application secret and payload is SHA-256 of a fixed label: keys wai:e2ee/corpus/key, secrets wai:e2ee/corpus/secret, bytes wai:e2ee/corpus/bytes, the room id wai:e2ee/corpus/room",
"codes": Refusal::ALL.iter().map(|r| r.code()).collect::<Vec<_>>(),
"records": recs,
"key_schedule": ks,
"objects": objs,
"rooms": rooms,
}))
}
fn merge(v: &mut Value, extra: Value) {
if let (Value::Object(m), Value::Object(e)) = (v, extra) {
m.extend(e);
}
}
/// The corpus as bytes: canonical JSON, two-space indent, a final newline.
pub fn to_bytes(v: &Value) -> Vec<u8> {
let mut s = serde_json::to_string_pretty(v).expect("serialises");
s.push('\n');
s.into_bytes()
}
/// Check every vector of `v` with the reference: each outcome, roster and delivery as
/// stated, every key schedule value, and every code of §13 raised somewhere.
pub fn check(v: &Value) -> Result<(), String> {
let mut seen = std::collections::BTreeSet::new();
let mut note = |o: &Value| {
if let Some(c) = o.as_str() {
seen.insert(c.to_string());
}
if let Some(c) = o.get("refuse").and_then(Value::as_str) {
seen.insert(c.to_string());
}
};
for r in v["records"].as_array().ok_or("records")? {
let got = check_record(r)?;
if got["outcome"] != r["outcome"] || got.get("suite") != r.get("suite") {
return Err(format!("record {}: {got}", r["name"]));
}
note(&r["outcome"]);
}
for k in v["key_schedule"].as_array().ok_or("key_schedule")? {
check_key_schedule(k)?;
}
for o in v["objects"].as_array().ok_or("objects")? {
let got = check_object(o).map_err(|e| format!("object {}: {e}", o["name"]))?;
if got["outcome"] != o["outcome"] || got.get("payload_blake3") != o.get("payload_blake3") {
return Err(format!("object {}: {got}", o["name"]));
}
note(&o["outcome"]);
}
for r in v["rooms"].as_array().ok_or("rooms")? {
let got = replay(r)?;
for k in ["outcome", "rosters", "deliveries"] {
if got[k] != r[k] {
return Err(format!("room {}: {k} {}", r["name"], got[k]));
}
}
note(&r["outcome"]);
for d in r["deliveries"].as_array().ok_or("deliveries")? {
for x in d["deliveries"].as_array().ok_or("deliveries")? {
note(&x["outcome"]);
}
}
}
for c in Refusal::ALL {
if !seen.contains(c.code()) {
return Err(format!("no vector raises {}", c.code()));
}
}
let codes: Vec<&str> = Refusal::ALL.iter().map(|r| r.code()).collect();
if v["codes"] != json!(codes) {
return Err("the codes listed are not the reference's".into());
}
Ok(())
}

View file

@ -0,0 +1,539 @@
//! A room's MLS group (WAI confidentiality §5): a member, which holds a leaf and the
//! epochs' keys, and the clock, the group's one external sender, which holds neither and
//! follows the group from its public handshake messages.
//!
//! Every MLS message travels as the keying draft §3.4 frames it: a one-byte type, then the
//! `MLSMessage` (RFC 9420 §6). Before applying a proposal or a commit, a member checks it
//! against the room's lifecycle with [`crate::model::Room`]; a commit that fails is never
//! merged, so its epoch never exists for that member.
use crate::model::{self, Change, Event, From as By, Owed, Principal, Proposal as Proposed, Room, Roster};
use ed25519_dalek::{Signer as _, SigningKey};
use joule_moq::secure_objects::room::{Incoming, Keyring, NotOpened, Opened, Outgoing, SealedObject, EXPORTER_LABEL};
use joule_moq::secure_objects::{Refusal, Suite};
use openmls::prelude::*;
use openmls::prelude::hash_ref::ProposalRef;
use openmls_rust_crypto::OpenMlsRustCrypto;
use openmls_traits::signatures::{Signer, SignerError};
use openmls_traits::types::SignatureScheme;
use openmls_traits::OpenMlsProvider;
use std::collections::{BTreeMap, BTreeSet};
use tls_codec::{Deserialize as _, Serialize as _};
/// The room's MLS ciphersuite (§3.3 step 4): Ed25519 signatures, the principals' scheme.
pub const CIPHERSUITE: Ciphersuite = Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519;
/// The keying draft §3.4's type byte before each MLS message.
pub mod frame {
pub const PROPOSAL: u8 = 0x01;
pub const COMMIT: u8 = 0x02;
pub const WELCOME: u8 = 0x03;
pub const KEY_PACKAGE: u8 = 0x04;
/// Not of the keying draft: the creator's GroupInfo, with its ratchet tree, which the
/// clock reads to follow the group (§5.1).
pub const GROUP_INFO: u8 = 0x05;
}
/// Why the binding did not do what it was asked.
#[derive(Debug)]
pub enum Error {
/// Refused, with the extension's reason.
Refused(Refusal),
/// MLS refused it (a message that does not verify or parse, a state it cannot apply).
Mls(String),
/// Not now: the room's state does not call for it.
NotNow(&'static str),
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::Refused(r) => write!(f, "refused: {r}"),
Error::Mls(m) => write!(f, "MLS: {m}"),
Error::NotNow(m) => write!(f, "not now: {m}"),
}
}
}
impl std::error::Error for Error {}
impl std::convert::From<Refusal> for Error {
fn from(r: Refusal) -> Self {
Error::Refused(r)
}
}
fn mls<E: std::fmt::Debug>(e: E) -> Error {
Error::Mls(format!("{e:?}"))
}
/// A principal's or the clock's Ed25519 room key, as MLS signs with it (§5.2).
pub struct RoomKey(pub SigningKey);
impl Signer for RoomKey {
fn sign(&self, payload: &[u8]) -> Result<Vec<u8>, SignerError> {
Ok(self.0.sign(payload).to_bytes().to_vec())
}
fn signature_scheme(&self) -> SignatureScheme {
SignatureScheme::ED25519
}
}
impl RoomKey {
pub fn principal(&self) -> Principal {
self.0.verifying_key().to_bytes()
}
}
/// The credential of a principal (§5.2): a BasicCredential whose identity is its key, and
/// that key as the leaf's signature key.
pub fn credential(p: &Principal) -> CredentialWithKey {
CredentialWithKey { credential: BasicCredential::new(p.to_vec()).into(), signature_key: p.to_vec().into() }
}
/// The `external_senders` extension of a room's group (§5.3): the clock alone.
pub fn external_senders(clock: &Principal) -> ExternalSendersExtension {
vec![ExternalSender::new(clock.to_vec().into(), BasicCredential::new(clock.to_vec()).into())]
}
/// The identity of a credential, where it is a BasicCredential of 32 bytes.
fn identity(c: &Credential) -> Option<Principal> {
BasicCredential::try_from(c.clone()).ok()?.identity().try_into().ok()
}
/// A credential and signature key read as §5.2 binds them: `credential_mismatch` unless the
/// identity is the 32-byte signature key.
fn bound(c: &Credential, signature_key: &[u8]) -> Result<Principal, Refusal> {
let id = identity(c).ok_or(Refusal::CredentialMismatch)?;
if id.as_slice() != signature_key {
return Err(Refusal::CredentialMismatch);
}
Ok(id)
}
fn roster_of<'a>(members: impl Iterator<Item = Member> + 'a) -> Result<Roster, Refusal> {
members.map(|m| Ok((m.index.u32(), bound(&m.credential, &m.signature_key)?))).collect()
}
/// Frame an MLS message.
pub fn framed(ty: u8, m: &MlsMessageOut) -> Result<Vec<u8>, Error> {
let mut b = vec![ty];
b.extend_from_slice(&m.tls_serialize_detached().map_err(mls)?);
Ok(b)
}
/// Read a framed MLS message.
pub fn unframe(b: &[u8]) -> Result<(u8, MlsMessageIn), Error> {
let (&ty, rest) = b.split_first().ok_or(Error::Mls("an empty frame".into()))?;
Ok((ty, MlsMessageIn::tls_deserialize_exact(rest).map_err(mls)?))
}
/// What a proposal of the group does, read from MLS: its sender, epoch and change.
fn read_proposal(q: &QueuedProposal, epoch: u64) -> Result<Proposed, Refusal> {
let from = match q.sender() {
Sender::External(i) if *i == SenderExtensionIndex::new(0) => By::Clock,
Sender::Member(l) => By::Member(l.u32()),
_ => return Err(Refusal::ProposalNotClock),
};
let change = match q.proposal() {
Proposal::Add(a) => {
let leaf = a.key_package().leaf_node();
let id = identity(leaf.credential()).ok_or(Refusal::CredentialMismatch)?;
let key: Principal = leaf.signature_key().as_slice().try_into().map_err(|_| Refusal::CredentialMismatch)?;
Change::Add { identity: id, signature_key: key }
}
Proposal::Remove(r) => Change::Remove { leaf: r.removed().u32() },
_ => return Err(Refusal::ProposalMismatch),
};
Ok(Proposed { from, epoch, change })
}
/// The checks §5.5 makes of a commit before it is applied, given the group's roster, its
/// pending proposals' references, and the model. Returns the indexes it holds.
fn check_commit(model: &Room, refs: &[ProposalRef], sender: &Sender, staged: &StagedCommit) -> Result<(u32, Vec<usize>), Refusal> {
let g = model.group().ok_or(Refusal::CommitMismatch)?;
let Sender::Member(s) = sender else { return Err(Refusal::CommitNotCommitter) };
let removed: BTreeSet<u32> = staged.remove_proposals().map(|r| r.remove_proposal().removed().u32()).collect();
if model::committer(&g.roster, &removed) != Some(s.u32()) {
return Err(Refusal::CommitNotCommitter);
}
let mut holds = Vec::new();
for q in staged.queued_proposals() {
let clock = matches!(q.sender(), Sender::External(i) if *i == SenderExtensionIndex::new(0));
let at = refs.iter().position(|r| r == q.proposal_reference_ref());
match (clock, at) {
(true, Some(i)) if model.pending()[i].from == By::Clock => holds.push(i),
_ => return Err(Refusal::ProposalNotClock),
}
}
Ok((s.u32(), holds))
}
/// 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.
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),
}
/// A commit a member made, with the Welcome for those it adds.
#[derive(Clone, Debug)]
pub struct Commit {
pub commit: Vec<u8>,
pub welcome: Option<Vec<u8>>,
pub epoch: u64,
}
/// A principal of a room as a member of its group.
pub struct RoomMember {
provider: OpenMlsRustCrypto,
key: RoomKey,
room: [u8; 32],
model: Room,
group: Option<MlsGroup>,
/// For each of the model's pending proposals, its MLS reference.
refs: Vec<ProposalRef>,
keyring: Keyring,
}
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 }
}
pub fn principal(&self) -> Principal {
self.key.principal()
}
pub fn keyring(&self) -> &Keyring {
&self.keyring
}
pub fn keyring_mut(&mut self) -> &mut Keyring {
&mut self.keyring
}
pub fn model(&self) -> &Room {
&self.model
}
pub fn epoch(&self) -> Option<u64> {
self.group.as_ref().map(|g| g.epoch().as_u64())
}
/// 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() {
self.group = None;
self.refs.clear();
}
}
/// A KeyPackage for the clock to propose this principal with (the keying draft §4.7).
pub fn key_package(&self) -> Result<Vec<u8>, Error> {
let kp = KeyPackage::builder().build(CIPHERSUITE, &self.provider, &self.key, credential(&self.principal())).map_err(mls)?;
framed(frame::KEY_PACKAGE, &MlsMessageOut::from(kp.key_package().clone()))
}
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"))
}
fn enter(&mut self) -> Result<u64, Error> {
let g = self.group.as_ref().expect("a group");
let epoch = g.epoch().as_u64();
let roster = roster_of(g.members())?;
if self.model.group().map(|m| &m.roster) != Some(&roster) {
return Err(Error::Mls("the group's roster is not the room's".into()));
}
let secret = self.export(g)?;
self.keyring.enter(epoch, secret, roster);
Ok(epoch)
}
/// 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.
pub fn create(&mut self) -> Result<Vec<u8>, Error> {
if self.model.next_owed() != Some(Owed::Create(self.principal())) {
return Err(Error::NotNow("the room does not owe this principal a group"));
}
let gid = self.model.group_id();
let config = MlsGroupCreateConfig::builder()
.ciphersuite(CIPHERSUITE)
.wire_format_policy(PURE_PLAINTEXT_WIRE_FORMAT_POLICY)
.use_ratchet_tree_extension(true)
.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)
}
fn join(&mut self, welcome: Welcome) -> Result<u64, Error> {
let config = MlsGroupJoinConfig::builder().wire_format_policy(PURE_PLAINTEXT_WIRE_FORMAT_POLICY).use_ratchet_tree_extension(true).build();
let g = StagedWelcome::new_from_welcome(&self.provider, &config, welcome, None).map_err(mls)?.into_group(&self.provider).map_err(mls)?;
if g.extensions().external_senders() != Some(&external_senders(&self.model.clock())) {
return Err(Refusal::GroupMismatch.into());
}
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.group = Some(g);
self.enter()
}
/// A framed MLS message from the room's `mls` tracks (§5.4, §5.5).
pub fn receive(&mut self, framed_msg: &[u8]) -> Result<Received, Error> {
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 self.join(w).map(Received::Joined);
}
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();
let processed = g.process_message(&self.provider, pm).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());
if p.from == By::Clock {
g.store_pending_proposal(self.provider.storage(), *q).map_err(mls)?;
}
Ok(Received::Proposal)
}
ProcessedMessageContent::StagedCommitMessage(staged) => {
let (s, holds) = check_commit(&self.model, &self.refs, &sender, &staged)?;
let removed = staged.self_removed();
self.model.commit(s, &holds)?;
self.refs.clear();
g.merge_staged_commit(&self.provider, *staged).map_err(mls)?;
let now = self.model.group().expect("a group").epoch;
if removed {
self.keyring.removed_at(now);
self.group = None;
return Ok(Received::Removed(now));
}
self.enter().map(Received::Epoch)
}
_ => Err(Error::Mls("not a proposal or a commit".into())),
}
}
/// Whether this member is the committer of the clock's pending proposals.
pub fn is_committer(&self) -> bool {
let (Some(g), Some(me)) = (self.model.group(), self.keyring.my_leaf()) else { return false };
let removed: BTreeSet<u32> = 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)
}
/// 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.
pub fn commit(&mut self, force: bool) -> Result<Commit, Error> {
if !self.is_committer() {
return Err(Error::NotNow("not the committer"));
}
if !force && !self.model.pending().iter().any(|p| p.from == By::Clock) {
return Err(Error::NotNow("nothing pending"));
}
let g = self.group.as_mut().ok_or(Error::NotNow("not a member"))?;
let bundle = g
.commit_builder()
.consume_proposal_store(true)
.force_self_update(true)
.load_psks(self.provider.storage())
.map_err(mls)?
.build(self.provider.rand(), self.provider.crypto(), &self.key, |_| true)
.map_err(mls)?
.stage_commit(&self.provider)
.map_err(mls)?;
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 })
}
/// Seal an object under this member's current epoch (§4.9).
pub fn seal(&self, o: &Outgoing<'_>) -> Result<SealedObject, Error> {
self.keyring.seal(o).ok_or(Error::NotNow("no epoch to seal under"))?.map_err(Error::Refused)
}
/// Open an object through the receiving checks (§6).
pub fn open(&mut self, o: &Incoming<'_>) -> Result<Opened, NotOpened> {
self.keyring.open(o, None)
}
}
/// The room's clock as the group's external sender (§5.3): it proposes every change the
/// room's lifecycle calls for and follows the group from its public messages, holding no
/// epoch's secret.
pub struct Clock {
provider: OpenMlsRustCrypto,
key: RoomKey,
model: Room,
public: Option<PublicGroup>,
refs: Vec<ProposalRef>,
key_packages: BTreeMap<Principal, KeyPackage>,
outbox: Vec<Vec<u8>>,
}
impl Clock {
pub fn new(room: [u8; 32], key: SigningKey) -> Self {
let key = RoomKey(key);
let model = Room::new(room, key.principal());
Clock { provider: OpenMlsRustCrypto::default(), key, model, public: None, refs: Vec::new(), key_packages: BTreeMap::new(), outbox: Vec::new() }
}
pub fn principal(&self) -> Principal {
self.key.principal()
}
pub fn model(&self) -> &Room {
&self.model
}
/// The framed proposals made since the last call, in order, for the clock's `mls` track.
pub fn take_outbox(&mut self) -> Vec<Vec<u8>> {
std::mem::take(&mut self.outbox)
}
/// A lifecycle event the clock recorded.
pub fn event(&mut self, ev: &Event) -> Result<(), Error> {
self.model.event(ev);
if self.model.group().is_none() {
self.public = None;
self.refs.clear();
}
self.flush()
}
/// A KeyPackage a joining principal published: kept for its Add (§5.4).
pub fn key_package(&mut self, framed_kp: &[u8]) -> Result<Principal, Error> {
let (_, msg) = unframe(framed_kp)?;
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())?;
self.key_packages.insert(p, kp);
self.flush()?;
Ok(p)
}
/// 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()])?;
self.public = Some(pg);
self.refs.clear();
self.flush()
}
fn process_own(&mut self, out: MlsMessageOut, p: Proposed) -> Result<(), Error> {
let bytes = framed(frame::PROPOSAL, &out)?;
let (_, msg) = unframe(&bytes)?;
let pg = self.public.as_mut().expect("a group");
let processed = pg.process_message(self.provider.crypto(), msg.try_into_protocol_message().map_err(mls)?).map_err(mls)?;
let ProcessedMessageContent::ProposalMessage(q) = processed.into_content() else { return Err(Error::Mls("not a proposal".into())) };
self.model.propose(p)?;
self.refs.push(q.proposal_reference_ref().clone());
pg.add_proposal(self.provider.storage(), *q).map_err(mls)?;
self.outbox.push(bytes);
Ok(())
}
/// Propose everything owed that can be proposed now.
fn flush(&mut self) -> Result<(), Error> {
while let Some(owed) = self.model.next_owed() {
let Some(pg) = &self.public else { return Ok(()) };
let (gid, epoch) = (pg.group_id().clone(), pg.group_context().epoch());
match owed {
Owed::Create(_) => return Ok(()),
Owed::Add(p) => {
let Some(kp) = self.key_packages.get(&p).cloned() else { return Ok(()) };
let out = ExternalProposal::new_add::<OpenMlsRustCrypto>(kp, gid, epoch, &self.key, SenderExtensionIndex::new(0)).map_err(mls)?;
let change = Change::Add { identity: p, signature_key: p };
self.process_own(out, Proposed { from: By::Clock, epoch: epoch.as_u64(), change })?;
}
Owed::Remove(_, leaf) => {
let out = ExternalProposal::new_remove::<OpenMlsRustCrypto>(LeafNodeIndex::new(leaf), gid, epoch, &self.key, SenderExtensionIndex::new(0)).map_err(mls)?;
self.process_own(out, Proposed { from: By::Clock, epoch: epoch.as_u64(), change: Change::Remove { leaf } })?;
}
}
}
Ok(())
}
/// A framed message from a member's `mls` track: a proposal is kept; a commit is
/// checked as a member checks it and followed, and what it left out proposed again.
pub fn observe(&mut self, framed_msg: &[u8]) -> Result<(), Error> {
let (ty, msg) = unframe(framed_msg)?;
if ty == frame::WELCOME {
return Ok(());
}
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();
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())),
}
}
}
#[cfg(test)]
#[path = "group_tests.rs"]
mod tests;

View file

@ -0,0 +1,333 @@
//! Rooms run on real MLS groups: three members each sending and every other opening, a
//! removed member, a late joiner, a change of committer, and every way a member or the
//! clock can misbehave, refused by the others. Every MLS message crosses as its framed
//! bytes, and every sealed object as a -22 subgroup stream, encoded and decoded.
use super::*;
use crate::model::Event;
use joule_moq::draft22::data::{ObjectStatus as WireStatus, SubgroupCodec, SubgroupHeader, SubgroupId, SubgroupObject};
use joule_moq::draft22::kvp::Properties;
use joule_moq::draft22::{decode_uni_stream, GroupOrder, StreamHeader, UniEvent};
use joule_moq::secure_objects::room::{split_key_id, Incoming, NotOpened, Outgoing};
use joule_moq::secure_objects::Plaintext;
use joule_moq::{TrackName, TrackNamespace};
const SUITE: Suite = Suite::AesGcm128Sha256;
fn sk(label: &str) -> SigningKey {
SigningKey::from_bytes(&crate::corpus::bytes(label, 32).try_into().unwrap())
}
/// A room's clock and principals, the tracks between them simulated: each MLS message is
/// delivered, as its bytes, to every party that reads it.
struct Net {
room: [u8; 32],
clock_key: SigningKey,
clock: Clock,
members: Vec<RoomMember>,
}
impl Net {
fn new(names: &[&str]) -> Net {
let room = [0x52; 32];
let clock_key = sk("clock");
let clock = Clock::new(room, clock_key.clone());
let cp = clock.principal();
let members = names.iter().map(|n| RoomMember::new(room, cp, sk(n), SUITE)).collect();
Net { room, clock_key, clock, members }
}
fn p(&self, i: usize) -> Principal {
self.members[i].principal()
}
fn event(&mut self, ev: Event) {
self.clock.event(&ev).unwrap();
for m in &mut self.members {
m.event(&ev);
}
self.pump();
}
/// The clock's proposals, to every member in the group.
fn pump(&mut self) {
for msg in self.clock.take_outbox() {
for m in self.members.iter_mut().filter(|m| m.epoch().is_some()) {
assert_eq!(m.receive(&msg).unwrap(), Received::Proposal);
}
}
}
/// Principal `i` joins under a keyed grant: it creates the group, or publishes its
/// KeyPackage for the clock's Add.
fn join(&mut self, i: usize) {
self.event(Event::Join { subject: self.p(i), keyed: true });
if self.members[i].model().next_owed() == Some(Owed::Create(self.p(i))) {
let info = self.members[i].create().unwrap();
self.clock.created(&info).unwrap();
} else {
let kp = self.members[i].key_package().unwrap();
self.clock.key_package(&kp).unwrap();
}
self.pump();
}
fn leave(&mut self, i: usize, reason: u8) {
self.event(Event::Leave { subject: self.p(i), reason });
}
/// The committer commits the clock's proposals; everyone else applies it, and those it
/// adds join by its Welcome. Returns the committer's index.
fn commit(&mut self) -> usize {
let c = (0..self.members.len()).find(|i| self.members[*i].is_committer()).expect("a committer");
let out = self.members[c].commit(false).unwrap();
self.clock.observe(&out.commit).unwrap();
for (j, m) in self.members.iter_mut().enumerate() {
if j != c && m.epoch().is_some() {
let r = m.receive(&out.commit).unwrap();
assert!(matches!(r, Received::Epoch(e) | Received::Removed(e) if e == out.epoch), "{r:?}");
}
}
if let Some(w) = &out.welcome {
let roster = self.clock.model().group().unwrap().roster.clone();
for m in self.members.iter_mut().filter(|m| m.epoch().is_none() && roster.values().any(|p| *p == m.principal())) {
assert_eq!(m.receive(w).unwrap(), Received::Joined(out.epoch));
}
}
c
}
fn ns(&self, owner: &Principal, kind: &str) -> TrackNamespace {
TrackNamespace::from_strs([hex::encode(self.room), hex::encode(owner), kind.to_string()])
}
/// Principal `i` seals `payload` as object (`g`, 0) of its own `kind` track, and the
/// object crosses as a -22 subgroup stream.
fn send(&self, i: usize, owner: usize, kind: &str, g: u64, payload: &[u8]) -> Vec<u8> {
let ns = self.ns(&self.p(owner), kind);
let name = TrackName::from_str("0");
let pt = Plaintext::with_digest(payload.to_vec());
let s = self.members[i].seal(&Outgoing { ns: &ns, name: &name, group: g, object: 0, priority: 64, plaintext: &pt, immutable: &[], mutable: &[] }).unwrap();
let sh = SubgroupHeader { track_alias: 1, group_id: g, subgroup_id: SubgroupId::Zero, publisher_priority: Some(64), properties: true, end_of_group: true, first_object: true };
let mut wire = Vec::new();
sh.encode(&mut wire);
SubgroupCodec::new(sh).encode_object(&SubgroupObject { object_id: 0, properties: s.properties, status: WireStatus::Normal, payload: s.payload }, &mut wire).unwrap();
wire
}
/// Member `j` reads a stream `send` made, for `owner`'s `kind` track.
fn recv(&mut self, j: usize, owner: usize, kind: &str, wire: &[u8]) -> Result<Vec<u8>, NotOpened> {
let ns = self.ns(&self.p(owner), kind);
let name = TrackName::from_str("0");
let ev = decode_uni_stream(wire, GroupOrder::Ascending).unwrap();
let [UniEvent::Header(StreamHeader::Subgroup(sh)), UniEvent::SubgroupObject(o)] = ev.as_slice() else { panic!("one object") };
let props: &Properties = &o.properties;
let inc = Incoming { ns: &ns, name: &name, group: sh.group_id, object: o.object_id, priority: sh.publisher_priority.unwrap(), properties: props, payload: &o.payload };
self.members[j].open(&inc).map(|op| op.plaintext.payload)
}
}
fn refused(r: Result<Vec<u8>, NotOpened>) -> Refusal {
match r {
Err(NotOpened::Refused(x)) => x,
other => panic!("not refused: {other:?}"),
}
}
fn err_refusal(r: Result<Received, Error>) -> Refusal {
match r {
Err(Error::Refused(x)) => x,
other => panic!("not refused: {other:?}"),
}
}
#[test]
fn three_members_each_send_and_every_other_opens() {
let mut n = Net::new(&["a", "b", "c"]);
n.join(0);
n.join(1);
assert_eq!(n.commit(), 0);
n.join(2);
assert_eq!(n.commit(), 0);
for m in &n.members {
assert_eq!(m.epoch(), Some(2));
}
for i in 0..3 {
let payload = format!("frame from member {i}").into_bytes();
let wire = n.send(i, i, "video", 10 + i as u64, &payload);
for j in 0..3 {
assert_eq!(n.recv(j, i, "video", &wire).unwrap(), payload, "{j} opens {i}");
}
// The same location again, from a relay replaying it.
assert_eq!(refused(n.recv((i + 1) % 3, i, "video", &wire)), Refusal::Replayed);
}
// Each sender's KID names the epoch and its own leaf.
let wire = n.send(1, 1, "audio", 1, b"x");
let ev = decode_uni_stream(&wire, GroupOrder::Ascending).unwrap();
let UniEvent::SubgroupObject(o) = &ev[1] else { panic!() };
let (kid, _) = joule_moq::secure_objects::key_id_of(&o.properties).unwrap();
assert_eq!(split_key_id(kid), (2, 1));
}
#[test]
fn a_removed_member_cannot_open_the_next_epoch_and_a_late_joiner_cannot_open_before_its_join() {
let mut n = Net::new(&["a", "b", "c", "d"]);
n.join(0);
n.join(1);
n.commit();
n.join(2);
n.commit();
let before = n.send(0, 0, "video", 1, b"epoch two");
let c_late = n.send(2, 2, "video", 1, b"sealed by c before its removal");
// C's grant is revoked: the clock records its leave, the clock removes it, A commits.
n.leave(2, 2);
assert_eq!(n.commit(), 0);
assert_eq!(n.members[2].epoch(), None);
let after = n.send(0, 0, "video", 2, b"epoch three");
assert_eq!(n.recv(1, 0, "video", &after).unwrap(), b"epoch three");
assert_eq!(refused(n.recv(2, 0, "video", &after)), Refusal::RemovedBeforeEpoch);
// C kept epoch 2's keys and sealed under them; no one who applied its removal takes it.
assert_eq!(refused(n.recv(0, 2, "video", &c_late)), Refusal::SenderRemoved);
assert_eq!(refused(n.recv(1, 2, "video", &c_late)), Refusal::SenderRemoved);
// D joins late, at C's old leaf.
n.join(3);
assert_eq!(n.commit(), 0);
assert_eq!(n.members[3].keyring().my_leaf(), Some(2));
let now = n.send(0, 0, "video", 3, b"epoch four");
assert_eq!(n.recv(3, 0, "video", &now).unwrap(), b"epoch four");
assert_eq!(refused(n.recv(3, 0, "video", &before)), Refusal::EpochNotJoined);
assert_eq!(refused(n.recv(3, 0, "video", &after)), Refusal::EpochNotJoined);
// A leaves: B, at the lowest leaf left, is the committer now.
n.leave(0, 1);
assert!(!n.members[3].is_committer());
assert_eq!(n.commit(), 1);
let b5 = n.send(1, 1, "audio", 1, b"epoch five");
assert_eq!(n.recv(3, 1, "audio", &b5).unwrap(), b"epoch five");
assert_eq!(refused(n.recv(0, 1, "audio", &b5)), Refusal::RemovedBeforeEpoch);
// D seals under its own KID on B's track: not the track's owner.
let forged = n.send(3, 1, "audio", 2, b"not b");
assert_eq!(refused(n.recv(1, 1, "audio", &forged)), Refusal::SenderNotOwner);
// The clock followed every epoch and never held a key: its model's roster is the group's.
assert_eq!(n.clock.model().group().unwrap().roster, n.members[1].model().group().unwrap().roster);
}
/// A room of A and B at epoch 1, with C's Add pending.
fn pending_add() -> Net {
let mut n = Net::new(&["a", "b", "c", "d"]);
n.join(0);
n.join(1);
n.commit();
n.join(2);
n
}
#[test]
fn a_commit_by_a_member_that_is_not_the_committer_is_refused() {
let mut n = pending_add();
let rogue = {
let b = &mut n.members[1];
let g = b.group.as_mut().unwrap();
let bundle = g.commit_builder().consume_proposal_store(true).force_self_update(true).load_psks(b.provider.storage()).unwrap().build(b.provider.rand(), b.provider.crypto(), &b.key, |_| true).unwrap().stage_commit(&b.provider).unwrap();
let (c, _, _) = bundle.into_messages();
g.clear_pending_commit(b.provider.storage()).unwrap();
framed(frame::COMMIT, &c).unwrap()
};
assert_eq!(err_refusal(n.members[0].receive(&rogue)), Refusal::CommitNotCommitter);
assert!(matches!(n.clock.observe(&rogue), Err(Error::Refused(Refusal::CommitNotCommitter))));
// Nothing was applied: the true committer's commit goes through.
assert_eq!(n.commit(), 0);
assert_eq!(n.members[2].epoch(), Some(2));
}
#[test]
fn a_commit_holding_a_members_own_proposal_is_refused() {
let mut n = pending_add();
let rogue = {
let a = &mut n.members[0];
let g = a.group.as_mut().unwrap();
let bundle = g
.commit_builder()
.consume_proposal_store(true)
.propose_removals([LeafNodeIndex::new(1)])
.force_self_update(true)
.load_psks(a.provider.storage())
.unwrap()
.build(a.provider.rand(), a.provider.crypto(), &a.key, |_| true)
.unwrap()
.stage_commit(&a.provider)
.unwrap();
let (c, _, _) = bundle.into_messages();
g.clear_pending_commit(a.provider.storage()).unwrap();
framed(frame::COMMIT, &c).unwrap()
};
assert_eq!(err_refusal(n.members[1].receive(&rogue)), Refusal::ProposalNotClock);
}
#[test]
fn a_commit_that_leaves_out_the_clocks_first_proposal_is_refused() {
let mut n = pending_add();
n.join(3);
let rogue = {
let a = &mut n.members[0];
let g = a.group.as_mut().unwrap();
let first = g.pending_proposals().next().unwrap().proposal_reference_ref().clone();
g.remove_pending_proposal(a.provider.storage(), &first).unwrap();
let (c, _, _) = g.commit_to_pending_proposals(&a.provider, &a.key).unwrap();
g.clear_pending_commit(a.provider.storage()).unwrap();
framed(frame::COMMIT, &c).unwrap()
};
assert_eq!(err_refusal(n.members[1].receive(&rogue)), Refusal::CommitMismatch);
}
#[test]
fn proposals_the_room_does_not_call_for_are_refused() {
let mut n = pending_add();
let g = n.members[0].group.as_ref().unwrap();
let (gid, epoch) = (g.group_id().clone(), g.epoch());
let clock = RoomKey(n.clock_key.clone());
// A Remove of B, who did not leave, signed by the clock's key.
let out = ExternalProposal::new_remove::<OpenMlsRustCrypto>(LeafNodeIndex::new(1), gid.clone(), epoch, &clock, SenderExtensionIndex::new(0)).unwrap();
let msg = framed(frame::PROPOSAL, &out).unwrap();
assert_eq!(err_refusal(n.members[0].receive(&msg)), Refusal::ProposalMismatch);
// An Add of D, whose credential names another principal than its signature key.
let d = RoomKey(sk("d"));
let liar = CredentialWithKey { credential: BasicCredential::new(n.p(1).to_vec()).into(), signature_key: d.principal().to_vec().into() };
let provider = OpenMlsRustCrypto::default();
let kp = KeyPackage::builder().build(CIPHERSUITE, &provider, &d, liar).unwrap();
let out = ExternalProposal::new_add::<OpenMlsRustCrypto>(kp.key_package().clone(), gid, epoch, &clock, SenderExtensionIndex::new(0)).unwrap();
let msg = framed(frame::PROPOSAL, &out).unwrap();
assert_eq!(err_refusal(n.members[0].receive(&msg)), Refusal::CredentialMismatch);
// The clock itself refuses to propose such a KeyPackage.
let framed_kp = framed(frame::KEY_PACKAGE, &MlsMessageOut::from(kp.key_package().clone())).unwrap();
assert!(matches!(n.clock.key_package(&framed_kp), Err(Error::Refused(Refusal::CredentialMismatch))));
}
#[test]
fn a_group_whose_external_sender_is_not_the_clock_is_refused() {
let mut n = Net::new(&["a", "b"]);
let (a, b) = (n.p(0), n.p(1));
n.event(Event::Join { subject: a, keyed: true });
n.event(Event::Join { subject: b, keyed: true });
// A builds the room's group with another external sender, and adds B.
let b_kp = n.members[1].key_package().unwrap();
let (info, welcome) = {
let a = &mut n.members[0];
let other = sk("not the clock").verifying_key().to_bytes();
let config = MlsGroupCreateConfig::builder()
.ciphersuite(CIPHERSUITE)
.wire_format_policy(PURE_PLAINTEXT_WIRE_FORMAT_POLICY)
.use_ratchet_tree_extension(true)
.with_group_context_extensions(Extensions::single(Extension::ExternalSenders(external_senders(&other))).unwrap())
.build();
let mut g = MlsGroup::new_with_group_id(&a.provider, &a.key, &config, GroupId::from_slice(&a.model.group_id()), credential(&a.principal())).unwrap();
let info = framed(frame::GROUP_INFO, &g.export_group_info(a.provider.crypto(), &a.key, true).unwrap()).unwrap();
let (_, msg) = unframe(&b_kp).unwrap();
let MlsMessageBodyIn::KeyPackage(kp) = msg.extract() else { panic!() };
let kp = kp.validate(a.provider.crypto(), ProtocolVersion::Mls10).unwrap();
let (_, w, _) = g.add_members(&a.provider, &a.key, &[kp]).unwrap();
g.merge_pending_commit(&a.provider).unwrap();
(info, framed(frame::WELCOME, &w).unwrap())
};
assert!(matches!(n.clock.created(&info), Err(Error::Refused(Refusal::GroupMismatch))));
assert_eq!(err_refusal(n.members[1].receive(&welcome)), Refusal::GroupMismatch);
}

View file

@ -0,0 +1,33 @@
//! **joule-moq-mls** — end-to-end encrypted WAI live rooms (WAI
//! `extensions/confidentiality.md`).
//!
//! A room's media objects are sealed by `joule-moq`'s `secure_objects` under keys this crate
//! derives from one MLS group (RFC 9420) per room:
//!
//! - [`record`]: the room's confidentiality record, `WRE1` (`wai.live.e2ee`, §3): the
//! authority's signed statement that the room is end-to-end encrypted, with which suites,
//! and never recorded.
//! - [`model`]: how the group follows the room (§5.4 to §5.6), as pure state: what the
//! clock owes after each lifecycle event, who commits, what a commit holds, the roster it
//! leaves. Members check every proposal and commit against it before applying them.
//! - [`group`]: the MLS binding. A [`group::RoomMember`] holds a leaf whose credential is
//! its principal's Ed25519 room key, checks and applies the group's messages, commits when
//! the rule names it, and enters each epoch's application secret
//! (`MLS-Exporter("wai live e2ee", room_id, 32)`) into its keyring; a [`group::Clock`] is
//! the group's one external sender, proposes every Add and Remove the room's lifecycle
//! calls for, and follows the group from its public handshake messages, holding no key.
//! - [`corpus`]: WAI `live-conformance/e2ee/vectors.json`, written from fixed seeds and
//! checked by the same code.
//!
//! What this crate does not do: carry MLS messages (a room's tracks or a room service do),
//! or decide who is present (the room's clock does, and this crate reads its events).
pub mod corpus;
pub mod group;
pub mod model;
pub mod record;
pub mod strict;
pub use group::{Clock, Commit, Error, Received, RoomMember, CIPHERSUITE};
pub use model::{Event, Room};
pub use record::Record;

View file

@ -0,0 +1,414 @@
//! How a room's MLS group follows the room (WAI confidentiality §5.4 to §5.6), as pure
//! state: what the clock owes the group after each lifecycle event, whether a proposal is
//! the one it owes, who commits, whether a commit holds what it must, and the roster the
//! commit leaves. Members check every proposal and commit against it before applying them,
//! the clock proposes from it, and a verifier replays a room's transcript through it; none
//! of them needs MLS for this.
use joule_moq::secure_objects::Refusal;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
pub use joule_moq::secure_objects::room::{Principal, Roster};
/// A lifecycle event of live-rooms §7.2, reduced to what decides who holds keys. A grant
/// is **keyed** when it may subscribe or may publish any media kind.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum Event {
Join {
#[serde(with = "hex32")]
subject: Principal,
keyed: bool,
},
Leave {
#[serde(with = "hex32")]
subject: Principal,
reason: u8,
},
GrantExchange {
#[serde(with = "hex32")]
subject: Principal,
keyed_before: bool,
keyed_after: bool,
},
}
/// Who sent a proposal: the clock (the group's one external sender) or a member.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum From {
Clock,
Member(u32),
}
/// What a proposal does.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Change {
/// An Add of a KeyPackage whose leaf has this credential identity and signature key.
Add {
#[serde(with = "hex32")]
identity: Principal,
#[serde(with = "hex32")]
signature_key: Principal,
},
/// A Remove of a leaf.
Remove { leaf: u32 },
}
/// A proposal: its sender, the epoch it is for, and what it does.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Proposal {
pub from: From,
pub epoch: u64,
pub change: Change,
}
/// What the clock owes the group next.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Owed {
/// No group: this principal creates one.
Create(Principal),
/// An Add of this principal.
Add(Principal),
/// A Remove of this principal, at this leaf.
Remove(Principal, u32),
}
/// The committer of a commit that ends an epoch with `roster`, where the commit removes
/// `removed`: the lowest leaf index not removed (§5.5).
pub fn committer(roster: &Roster, removed: &BTreeSet<u32>) -> Option<u32> {
roster.keys().copied().find(|l| !removed.contains(l))
}
/// The roster a commit leaves (§5.6, RFC 9420 §12.3): the removes blank their leaves, then
/// each add, in the commit's order, takes the lowest blank leaf, which is the leftmost
/// blank leaf of the tree or, when none is blank, the next at its right edge.
pub fn apply(roster: &Roster, removed: &BTreeSet<u32>, added: &[Principal]) -> (Roster, Vec<u32>) {
let mut r: Roster = roster.iter().filter(|(l, _)| !removed.contains(l)).map(|(l, p)| (*l, *p)).collect();
let mut leaves = Vec::new();
for p in added {
let leaf = (0u32..).find(|i| !r.contains_key(i)).expect("a free index");
r.insert(leaf, *p);
leaves.push(leaf);
}
(r, leaves)
}
/// The group as it stands.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Group {
pub generation: u32,
pub epoch: u64,
pub roster: Roster,
}
/// What a commit did.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Committed {
pub epoch: u64,
pub removed: Vec<(u32, Principal)>,
pub added: Vec<(u32, Principal)>,
}
/// A room's membership as its group should follow it.
#[derive(Clone, Debug)]
pub struct Room {
room: [u8; 32],
clock: Principal,
/// The generation of the current group, or of the next when there is none.
generation: u32,
group: Option<Group>,
seq: u64,
/// Principals present under a keyed grant, by the event that made them so.
keyed: BTreeMap<Principal, u64>,
/// Principals that stopped being keyed while the group may still hold them.
gone: BTreeMap<Principal, u64>,
/// The proposals for the current epoch, in the order they arrived.
pending: Vec<Proposal>,
}
impl Room {
pub fn new(room: [u8; 32], clock: Principal) -> Self {
Room { room, clock, generation: 0, group: None, seq: 0, keyed: BTreeMap::new(), gone: BTreeMap::new(), pending: Vec::new() }
}
pub fn room(&self) -> [u8; 32] {
self.room
}
pub fn clock(&self) -> Principal {
self.clock
}
/// The group ID of generation `generation` (§5.1): the room id and the generation as
/// 4 bytes big-endian.
pub fn group_id_of(room: &[u8; 32], generation: u32) -> Vec<u8> {
[room.as_slice(), &generation.to_be_bytes()].concat()
}
pub fn group_id(&self) -> Vec<u8> {
Self::group_id_of(&self.room, self.generation)
}
pub fn group(&self) -> Option<&Group> {
self.group.as_ref()
}
pub fn pending(&self) -> &[Proposal] {
&self.pending
}
/// A lifecycle event (§5.4).
pub fn event(&mut self, ev: &Event) {
self.seq += 1;
let (subject, keyed_now) = match *ev {
Event::Join { subject, keyed } => (subject, keyed.then_some(true)),
Event::Leave { subject, .. } => (subject, Some(false)),
Event::GrantExchange { subject, keyed_before, keyed_after } => (subject, (keyed_before != keyed_after).then_some(keyed_after)),
};
match keyed_now {
Some(true) => {
self.keyed.insert(subject, self.seq);
self.gone.remove(&subject);
}
Some(false) if self.keyed.remove(&subject).is_some() => {
self.gone.insert(subject, self.seq);
}
Some(false) | None => {}
}
// A group no keyed principal holds a leaf of can never commit again: it ends, and the
// next keyed principal starts the next generation.
if let Some(g) = &self.group
&& !g.roster.values().any(|p| self.keyed.contains_key(p))
{
self.group = None;
self.generation += 1;
self.pending.clear();
self.gone.clear();
}
}
/// The membership the group will have once its pending clock proposals are committed.
fn reflected(&self) -> BTreeSet<Principal> {
let Some(g) = &self.group else { return BTreeSet::new() };
let mut s: BTreeSet<Principal> = g.roster.values().copied().collect();
for p in self.pending.iter().filter(|p| p.from == From::Clock) {
match p.change {
Change::Add { identity, .. } => {
s.insert(identity);
}
Change::Remove { leaf } => {
if let Some(x) = g.roster.get(&leaf) {
s.remove(x);
}
}
}
}
s
}
/// What the clock owes next (§5.4): the earliest change, in the order of the events that
/// call for it, that can be proposed now. An Add can be proposed into any group; a
/// Remove only of a principal that holds a leaf.
pub fn next_owed(&self) -> Option<Owed> {
let Some(g) = &self.group else {
return self.keyed.iter().min_by_key(|(p, s)| (**s, **p)).map(|(p, _)| Owed::Create(*p));
};
let reflected = self.reflected();
let mut owed: Vec<(u64, Owed)> = Vec::new();
for (p, s) in &self.keyed {
if !reflected.contains(p) {
owed.push((*s, Owed::Add(*p)));
}
}
for p in &reflected {
if !self.keyed.contains_key(p)
&& let Some((leaf, _)) = g.roster.iter().find(|(_, x)| *x == p)
{
owed.push((self.gone.get(p).copied().unwrap_or(0), Owed::Remove(*p, *leaf)));
}
}
owed.sort_by_key(|(s, o)| (*s, matches!(o, Owed::Add(_))));
owed.first().map(|(_, o)| *o)
}
/// The group's creation (§5.4): by the principal the clock owes it to, with the room's
/// group ID and the clock as its one external sender.
pub fn create(&mut self, by: Principal, group_id: &[u8], external_senders: &[Principal]) -> Result<(), Refusal> {
if group_id != self.group_id().as_slice() || external_senders != [self.clock] {
return Err(Refusal::GroupMismatch);
}
if self.next_owed() != Some(Owed::Create(by)) {
return Err(Refusal::ProposalMismatch);
}
self.group = Some(Group { generation: self.generation, epoch: 0, roster: [(0, by)].into() });
Ok(())
}
/// A member joined by a Welcome into a group whose state it now holds: take that state.
/// What it holds before is unknown to it, and is checked by the members who were there.
pub fn joined(&mut self, group_id: &[u8], external_senders: &[Principal], epoch: u64, roster: Roster) -> Result<(), Refusal> {
let generation = group_id
.strip_prefix(self.room.as_slice())
.and_then(|g| <[u8; 4]>::try_from(g).ok())
.map(u32::from_be_bytes)
.ok_or(Refusal::GroupMismatch)?;
if generation < self.generation || external_senders != [self.clock] {
return Err(Refusal::GroupMismatch);
}
self.generation = generation;
self.group = Some(Group { generation, epoch, roster });
self.pending.clear();
Ok(())
}
/// A proposal received (§5.4, §5.5). A member's is kept, and refused if a commit holds
/// it; the clock's must be for the current epoch, carry a credential whose identity is
/// its signature key, and be the change the clock owes.
pub fn propose(&mut self, p: Proposal) -> Result<(), Refusal> {
let Some(g) = &self.group else { return Err(Refusal::ProposalMismatch) };
if p.from == From::Clock {
if p.epoch != g.epoch {
return Err(Refusal::ProposalMismatch);
}
if let Change::Add { identity, signature_key } = p.change
&& identity != signature_key
{
return Err(Refusal::CredentialMismatch);
}
let ok = match (p.change, self.next_owed()) {
(Change::Add { identity, .. }, Some(Owed::Add(q))) => identity == q,
(Change::Remove { leaf }, Some(Owed::Remove(_, l))) => leaf == l,
_ => false,
};
if !ok {
return Err(Refusal::ProposalMismatch);
}
}
self.pending.push(p);
Ok(())
}
/// A commit (§5.5): `sender` is its sender's leaf, and `holds` the indexes in
/// [`Room::pending`] of the proposals it holds, in the commit's order. Checked in the
/// order of §5.5, then applied.
pub fn commit(&mut self, sender: u32, holds: &[usize]) -> Result<Committed, Refusal> {
let Some(g) = &self.group else { return Err(Refusal::CommitMismatch) };
let held: Vec<Proposal> = holds.iter().map(|i| self.pending.get(*i).copied().ok_or(Refusal::CommitMismatch)).collect::<Result<_, _>>()?;
let removed: BTreeSet<u32> = held.iter().filter_map(|p| if let Change::Remove { leaf } = p.change { Some(leaf) } else { None }).collect();
if committer(&g.roster, &removed) != Some(sender) {
return Err(Refusal::CommitNotCommitter);
}
if held.iter().any(|p| p.from != From::Clock) {
return Err(Refusal::ProposalNotClock);
}
// The clock's proposals it holds are the first k the clock sent for the epoch, each
// once, for some k.
let clock: Vec<usize> = (0..self.pending.len()).filter(|i| self.pending[*i].from == From::Clock).collect();
let mut sorted = holds.to_vec();
sorted.sort_unstable();
if sorted != clock[..sorted.len().min(clock.len())] || sorted.len() > clock.len() {
return Err(Refusal::CommitMismatch);
}
if removed.iter().any(|l| !g.roster.contains_key(l)) {
return Err(Refusal::CommitMismatch);
}
let added: Vec<Principal> = held.iter().filter_map(|p| if let Change::Add { identity, .. } = p.change { Some(identity) } else { None }).collect();
let (roster, leaves) = apply(&g.roster, &removed, &added);
let out = Committed {
epoch: g.epoch + 1,
removed: removed.iter().map(|l| (*l, g.roster[l])).collect(),
added: leaves.into_iter().zip(added).collect(),
};
for (_, p) in &out.removed {
self.gone.remove(p);
}
self.group = Some(Group { generation: g.generation, epoch: out.epoch, roster });
self.pending.clear();
Ok(out)
}
}
/// Hexadecimal for 32-byte keys in the corpus.
pub mod hex32 {
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(v: &[u8; 32], s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&hex::encode(v))
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 32], D::Error> {
let s = String::deserialize(d)?;
let v = hex::decode(&s).map_err(serde::de::Error::custom)?;
v.try_into().map_err(|_| serde::de::Error::custom("32 bytes"))
}
}
#[cfg(test)]
mod tests {
use super::*;
const R: [u8; 32] = [9; 32];
const K: Principal = [0xc; 32];
const A: Principal = [0xa; 32];
const B: Principal = [0xb; 32];
const C: Principal = [0xcc; 32];
fn add(epoch: u64, p: Principal) -> Proposal {
Proposal { from: From::Clock, epoch, change: Change::Add { identity: p, signature_key: p } }
}
#[test]
fn a_room_grows_shrinks_and_reuses_a_leaf() {
let mut m = Room::new(R, K);
m.event(&Event::Join { subject: A, keyed: true });
m.event(&Event::Join { subject: [0xe; 32], keyed: false });
assert_eq!(m.next_owed(), Some(Owed::Create(A)));
assert_eq!(m.create(A, &m.group_id(), &[A]), Err(Refusal::GroupMismatch));
assert_eq!(m.create(B, &m.group_id(), &[K]), Err(Refusal::ProposalMismatch));
m.create(A, &m.group_id(), &[K]).unwrap();
m.event(&Event::Join { subject: B, keyed: true });
m.event(&Event::Join { subject: C, keyed: true });
assert_eq!(m.propose(add(0, C)), Err(Refusal::ProposalMismatch), "B is owed first");
assert_eq!(m.propose(add(1, B)), Err(Refusal::ProposalMismatch), "another epoch");
let lie = Proposal { from: From::Clock, epoch: 0, change: Change::Add { identity: B, signature_key: C } };
assert_eq!(m.propose(lie), Err(Refusal::CredentialMismatch));
m.propose(add(0, B)).unwrap();
m.propose(add(0, C)).unwrap();
// Only A commits; it may hold the first proposal alone, never the second alone.
assert_eq!(m.clone().commit(1, &[0, 1]), Err(Refusal::CommitNotCommitter));
assert_eq!(m.clone().commit(0, &[1]), Err(Refusal::CommitMismatch));
let c = m.clone().commit(0, &[0]).unwrap();
assert_eq!(c.added, vec![(1, B)]);
let c = m.commit(0, &[1, 0]).unwrap();
assert_eq!((c.epoch, c.added), (1, vec![(1, C), (2, B)]), "adds take leaves in the commit's order");
// B leaves: removed, and only then can a new joiner take its leaf.
m.event(&Event::Leave { subject: B, reason: 1 });
assert_eq!(m.next_owed(), Some(Owed::Remove(B, 2)));
m.propose(Proposal { from: From::Clock, epoch: 1, change: Change::Remove { leaf: 2 } }).unwrap();
// A member's own proposal cannot ride a commit.
m.propose(Proposal { from: From::Member(1), epoch: 1, change: Change::Remove { leaf: 0 } }).unwrap();
assert_eq!(m.clone().commit(1, &[0, 1]), Err(Refusal::ProposalNotClock), "it would remove A, so C commits");
let c = m.commit(0, &[0]).unwrap();
assert_eq!(c.removed, vec![(2, B)]);
let d: Principal = [0xd; 32];
m.event(&Event::Join { subject: d, keyed: true });
m.propose(add(2, d)).unwrap();
let c = m.commit(0, &[0]).unwrap();
assert_eq!(c.added, vec![(2, d)]);
// A leaves: C, at the next lowest leaf, commits.
m.event(&Event::Leave { subject: A, reason: 4 });
m.propose(Proposal { from: From::Clock, epoch: 3, change: Change::Remove { leaf: 0 } }).unwrap();
assert_eq!(m.clone().commit(0, &[0]), Err(Refusal::CommitNotCommitter));
assert_eq!(m.commit(1, &[0]).unwrap().epoch, 4);
// Everyone leaves: the group ends, and the next joiner starts generation 1.
m.event(&Event::Leave { subject: C, reason: 1 });
m.event(&Event::Leave { subject: d, reason: 1 });
assert!(m.group().is_none());
m.event(&Event::Join { subject: B, keyed: true });
assert_eq!(m.next_owed(), Some(Owed::Create(B)));
assert_eq!(&m.group_id()[32..], &1u32.to_be_bytes());
}
}

View file

@ -0,0 +1,133 @@
//! The room's confidentiality record, `WRE1` (`wai.live.e2ee`, WAI confidentiality §3).
use crate::strict;
use ed25519_dalek::{Signer, SigningKey};
use joule_moq::secure_objects::{Refusal, Suite};
pub const MAGIC: &[u8; 4] = b"WRE1";
pub const VERSION: u8 = 1;
/// A record's length: magic, version, room, two suites, signature.
pub const LEN: usize = 4 + 1 + 32 + 2 + 2 + 64;
/// What the signature covers before the record (§3.2): 18 bytes and 0x01.
pub const DOMAIN: &[u8] = b"wai:live-room-e2ee\x01";
/// The MLS ciphersuite of a room: `MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519`.
pub const MLS_SUITE: u16 = 0x0001;
/// The registered string of the record (SPEC §5).
pub const CAPABILITY: &str = "wai.live.e2ee";
/// A confidentiality record.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Record {
pub room: [u8; 32],
pub suite: u16,
pub mls_suite: u16,
pub sig: [u8; 64],
}
impl Record {
/// The bytes the signature follows: `magic` through `mls_suite`.
pub fn body(room: &[u8; 32], suite: u16, mls_suite: u16) -> Vec<u8> {
let mut b = MAGIC.to_vec();
b.push(VERSION);
b.extend_from_slice(room);
b.extend_from_slice(&suite.to_le_bytes());
b.extend_from_slice(&mls_suite.to_le_bytes());
b
}
/// A record signed by the room's authority.
pub fn sign(room: [u8; 32], suite: u16, mls_suite: u16, authority: &SigningKey) -> Record {
let msg = [DOMAIN, &Self::body(&room, suite, mls_suite)].concat();
Record { room, suite, mls_suite, sig: authority.sign(&msg).to_bytes() }
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut b = Self::body(&self.room, self.suite, self.mls_suite);
b.extend_from_slice(&self.sig);
b
}
/// §3.3 step 1: read the bytes as one record.
pub fn read(b: &[u8]) -> Result<Record, Refusal> {
if b.len() < 4 {
return Err(Refusal::Truncated);
}
if &b[..4] != MAGIC {
return Err(Refusal::Malformed);
}
let Some(&v) = b.get(4) else { return Err(Refusal::Truncated) };
if v != VERSION {
return Err(Refusal::UnknownVersion);
}
if b.len() < LEN {
return Err(Refusal::Truncated);
}
if b.len() > LEN {
return Err(Refusal::Malformed);
}
Ok(Record {
room: b[5..37].try_into().expect("32"),
suite: u16::from_le_bytes([b[37], b[38]]),
mls_suite: u16::from_le_bytes([b[39], b[40]]),
sig: b[41..105].try_into().expect("64"),
})
}
/// §3.3 in full, given the room id, the descriptor's authority and its recording
/// `mode`: the record, with its suite, or the first refusal.
pub fn check(b: &[u8], room: &[u8; 32], authority: &[u8; 32], recording_mode: u8) -> Result<(Record, Suite), Refusal> {
let r = Self::read(b)?;
if &r.room != room {
return Err(Refusal::WrongRoom);
}
let suite = match Suite::from_id(r.suite) {
Some(s @ (Suite::AesGcm128Sha256 | Suite::AesGcm256Sha512)) => s,
_ => return Err(Refusal::SuiteUnsupported),
};
if r.mls_suite != MLS_SUITE {
return Err(Refusal::MlsSuiteUnsupported);
}
if recording_mode != 0 {
return Err(Refusal::RecordingRefused);
}
let msg = [DOMAIN, &b[..LEN - 64]].concat();
strict::verify(authority, &msg, &r.sig)?;
Ok((r, suite))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_record_and_each_refusal() {
let auth = SigningKey::from_bytes(&[1; 32]);
let a = auth.verifying_key().to_bytes();
let room = [7; 32];
let good = Record::sign(room, 4, 1, &auth).to_bytes();
assert_eq!(good.len(), LEN);
assert_eq!(Record::check(&good, &room, &a, 0).unwrap().1, Suite::AesGcm128Sha256);
assert_eq!(Record::check(&good[..3], &room, &a, 0).err(), Some(Refusal::Truncated));
assert_eq!(Record::check(&good[..4], &room, &a, 0).err(), Some(Refusal::Truncated));
assert_eq!(Record::check(&good[..60], &room, &a, 0).err(), Some(Refusal::Truncated));
assert_eq!(Record::check(&[good.as_slice(), &[0]].concat(), &room, &a, 0).err(), Some(Refusal::Malformed));
let mut magic = good.clone();
magic[0] = b'X';
assert_eq!(Record::check(&magic, &room, &a, 0).err(), Some(Refusal::Malformed));
let mut ver = good.clone();
ver[4] = 2;
assert_eq!(Record::check(&ver, &room, &a, 0).err(), Some(Refusal::UnknownVersion));
assert_eq!(Record::check(&good, &[8; 32], &a, 0).err(), Some(Refusal::WrongRoom));
let ctr = Record::sign(room, 1, 1, &auth).to_bytes();
assert_eq!(Record::check(&ctr, &room, &a, 0).err(), Some(Refusal::SuiteUnsupported));
let mls = Record::sign(room, 5, 2, &auth).to_bytes();
assert_eq!(Record::check(&mls, &room, &a, 0).err(), Some(Refusal::MlsSuiteUnsupported));
assert_eq!(Record::check(&good, &room, &a, 1).err(), Some(Refusal::RecordingRefused));
let other = SigningKey::from_bytes(&[2; 32]).verifying_key().to_bytes();
assert_eq!(Record::check(&good, &room, &other, 0).err(), Some(Refusal::BadSignature));
let mut identity = [0u8; 32];
identity[0] = 1;
assert_eq!(Record::check(&good, &room, &identity, 0).err(), Some(Refusal::BadKey));
}
}

View file

@ -0,0 +1,73 @@
//! Ed25519 verified under WAI's strict rule (SPEC §7 *Signatures*): steps 1 and 2 are
//! the key's (`bad_key`), steps 3 to 5 the signature's (`bad_signature`).
use ed25519_dalek::{Signature, VerifyingKey};
use joule_moq::secure_objects::Refusal;
/// The order of the base point, L, little-endian (RFC 8032 §5.1).
const L: [u8; 32] = [
0xed, 0xd3, 0xf5, 0x5c, 0x1a, 0x63, 0x12, 0x58, 0xd6, 0x9c, 0xf7, 0xa2, 0xde, 0xf9, 0xde, 0x14, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0x10,
];
fn below_l(s: &[u8]) -> bool {
for (a, l) in s.iter().zip(L.iter()).rev() {
if a != l {
return a < l;
}
}
false
}
/// Steps 1 and 2: the key is its point's one encoding and not of small order.
pub fn key(bytes: &[u8; 32]) -> Result<VerifyingKey, Refusal> {
let k = VerifyingKey::from_bytes(bytes).map_err(|_| Refusal::BadKey)?;
if k.to_edwards().compress().to_bytes() != *bytes || k.is_weak() {
return Err(Refusal::BadKey);
}
Ok(k)
}
/// Steps 1 to 5 over `msg`.
pub fn verify(key_bytes: &[u8; 32], msg: &[u8], sig: &[u8; 64]) -> Result<(), Refusal> {
let k = key(key_bytes)?;
if !below_l(&sig[32..]) {
return Err(Refusal::BadSignature);
}
// verify_strict refuses an R of small order and holds the cofactorless equation; an R
// that is not its point's canonical encoding never equals the recomputed one.
k.verify_strict(msg, &Signature::from_bytes(sig)).map_err(|_| Refusal::BadSignature)
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::{Signer, SigningKey};
#[test]
fn the_rule_refuses_keys_and_signatures_apart() {
let sk = SigningKey::from_bytes(&[3; 32]);
let pk = sk.verifying_key().to_bytes();
let sig = sk.sign(b"m").to_bytes();
verify(&pk, b"m", &sig).unwrap();
assert_eq!(verify(&pk, b"n", &sig), Err(Refusal::BadSignature));
// The identity, of small order: (R, S) = (B, 1) would verify over every message.
let mut identity = [0u8; 32];
identity[0] = 1;
assert_eq!(verify(&identity, b"m", &sig), Err(Refusal::BadKey));
// A non-canonical y (y = p, which reduces to 0).
let mut p = [0xffu8; 32];
p[0] = 0xed;
p[31] = 0x7f;
assert_eq!(key(&p).err(), Some(Refusal::BadKey));
// S + L, the same equation, refused for S ≥ L.
let mut big = sig;
let mut carry = 0u16;
for i in 0..32 {
let v = big[32 + i] as u16 + L[i] as u16 + carry;
big[32 + i] = v as u8;
carry = v >> 8;
}
assert_eq!(verify(&pk, b"m", &big), Err(Refusal::BadSignature));
}
}

View file

@ -0,0 +1,53 @@
//! WAI `live-conformance/e2ee/` (confidentiality §14): the committed `vectors.json`
//! regenerates byte for byte from its fixed seeds, the reference gives every vector its
//! stated outcome, and §13 of the extension lists exactly the reference's codes.
use joule_moq::secure_objects::Refusal;
use joule_moq_mls::corpus;
const DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../../../wai/live-conformance/e2ee/");
const SPEC: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../../../wai/extensions/confidentiality.md");
#[test]
fn the_committed_corpus_regenerates_byte_for_byte() {
let on_disk = std::fs::read(format!("{DIR}{}", corpus::FILE)).expect("vectors.json");
assert!(on_disk == corpus::to_bytes(&corpus::generate()), "vectors.json is not what the generator writes; run joule_moq_e2ee_vectors gen");
}
#[test]
fn the_reference_gives_every_vector_its_outcome() {
let v: serde_json::Value = serde_json::from_slice(&std::fs::read(format!("{DIR}{}", corpus::FILE)).unwrap()).unwrap();
corpus::check(&v).unwrap();
}
#[test]
fn a_mutated_corpus_fails() {
let v: serde_json::Value = serde_json::from_slice(&std::fs::read(format!("{DIR}{}", corpus::FILE)).unwrap()).unwrap();
for (path, value) in [
("/records/0/outcome", serde_json::json!("bad_signature")),
("/objects/0/derived/nonce", serde_json::json!("00")),
("/objects/6/outcome", serde_json::json!("opened")),
("/rooms/0/deliveries/0/deliveries/0/outcome", serde_json::json!("bad_tag")),
("/rooms/0/rosters/1/roster/1", serde_json::json!("00")),
("/rooms/1/outcome/refuse", serde_json::json!("commit_mismatch")),
("/key_schedule/0/moq_key", serde_json::json!("00")),
] {
let mut m = v.clone();
*m.pointer_mut(path).unwrap_or_else(|| panic!("{path}")) = value;
assert!(corpus::check(&m).is_err(), "{path} mutated and still checks");
}
}
#[test]
fn section_13_lists_exactly_the_references_codes() {
let spec = std::fs::read_to_string(SPEC).unwrap();
let start = spec.find("## 13. Reasons").expect("§13");
let end = spec[start..].find("\n## ").map(|e| start + e).unwrap_or(spec.len());
let mut listed = Vec::new();
for line in spec[start..end].lines().filter(|l| l.starts_with("| `")) {
let cell = line.split('|').nth(1).unwrap();
listed.extend(cell.split('`').skip(1).step_by(2).map(str::to_string));
}
let codes: Vec<String> = Refusal::ALL.iter().map(|r| r.code().to_string()).collect();
assert_eq!(listed, codes, "§13's table and Refusal::ALL, in order");
}

View file

@ -41,3 +41,5 @@ thiserror = "2"
[dev-dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
# tests/sealed_objects.rs seals a room's objects end to end; the relay itself never opens one.
joule-moq = { path = "../joule-moq", features = ["secure-objects"] }

View file

@ -0,0 +1,143 @@
//! A room's sealed objects (WAI `extensions/confidentiality.md`) through this relay, which
//! has no notion of them: three members each publish sealed video with its receipts over
//! the sealed bytes, the door holds each publisher's namespace to its own key and checks
//! every object against its receipt, and every member opens what the others sent while a
//! subscriber outside the group verifies every receipt and opens nothing. The relay
//! forwards each object's payload and properties byte for byte.
mod common;
use common::*;
use ed25519_dalek::SigningKey;
use joule_moq::draft22::data::{ObjectStatus as WireStatus, SubgroupHeader, SubgroupId, SubgroupObject};
use joule_moq::draft22::Parameters;
use joule_moq::receipt::{ObjectReceipt, ReceiptBuilder};
use joule_moq::secure_objects::room::{Incoming, Keyring, NotOpened, Outgoing, Roster};
use joule_moq::secure_objects::{Plaintext, Refusal, Suite};
use joule_moq::{GroupId, ObjectId, TrackAlias, TrackName};
use joule_moq_relay::client::{Client, ClientOptions};
use joule_moq_relay::config::DoorRule;
const GROUPS: u64 = 6;
const EPOCH: u64 = 3;
const ROOM: [u8; 32] = [0x5e; 32];
/// Publish group `g` of `t`: the receipts over the sealed payload first, then the sealed
/// object, whose Immutable Properties carry its Key ID and no receipt (confidentiality §4.3).
async fn publish_sealed(c: &Client, key: &SigningKey, t: &Tracks, g: u64, properties: joule_moq::draft22::kvp::Properties, payload: &[u8]) {
let hash = ObjectReceipt::hash_payload(payload);
let mut b = ReceiptBuilder::new(TrackAlias(t.receipt_alias), GroupId(g));
b.add_labelled_object(hash, 0, "sealed-publisher", "2026-10-19T00:00:00Z", vec![0]).unwrap();
let receipt = ObjectReceipt {
track_alias: TrackAlias(t.receipt_alias),
group_id: GroupId(g),
object_id: ObjectId(0),
content_hash: hash,
joules_micro: 0,
origin: "sealed-publisher".into(),
merkle_proof: b.proof_for(0).unwrap(),
energy_class: Some(vec![0]),
};
let group = b.finalize_labelled(key, "sealed-publisher", "2026-10-19T00:00:01Z", None).unwrap();
let bundle = joule_moq::carriage::ReceiptBundle { group, objects: vec![receipt] };
let rh = SubgroupHeader { track_alias: t.receipts_alias, group_id: g, subgroup_id: SubgroupId::Zero, publisher_priority: Some(t.priority), properties: false, end_of_group: true, first_object: true };
c.send_subgroup(rh, &[SubgroupObject::normal(0, bundle.to_payload().unwrap())]).await.expect("the receipts");
let mh = SubgroupHeader { track_alias: t.media_alias, group_id: g, subgroup_id: SubgroupId::Zero, publisher_priority: Some(t.priority), properties: true, end_of_group: true, first_object: true };
c.send_subgroup(mh, &[SubgroupObject { object_id: 0, properties, status: WireStatus::Normal, payload: payload.to_vec() }]).await.expect("the object");
}
async fn room_of_three(via: Via) {
let keys: Vec<SigningKey> = [0xa1u8, 0xb2, 0xc3].iter().map(|b| SigningKey::from_bytes(&[*b; 32])).collect();
let principals: Vec<[u8; 32]> = keys.iter().map(|k| k.verifying_key().to_bytes()).collect();
let roster: Roster = principals.iter().enumerate().map(|(i, p)| (i as u32, *p)).collect();
let secret = [0x77; 32];
let rings: Vec<Keyring> = principals
.iter()
.map(|p| {
let mut k = Keyring::new(Suite::AesGcm128Sha256, *p);
k.enter(EPOCH, secret, roster.clone());
k
})
.collect();
let room = hex::encode(ROOM);
let server = relay(|c| {
c.receipts.door = vec![DoorRule { prefix: vec![room.clone(), "*".into(), "*".into()], trust: vec![], trust_field: Some(1), hold_ms: 5_000 }];
})
.await;
let tracks: Vec<Tracks> = principals.iter().enumerate().map(|(i, p)| Tracks::new(ns(&[&room, &hex::encode(p), "video"]), "0", 10 * (i as u64 + 1), 7 + i as u64, 64)).collect();
let mut pubs = Vec::new();
let mut held = Vec::new();
for t in &tracks {
let (c, e) = connect(&server, via, ClientOptions::default()).await;
held.push(publish_tracks(&c, t, None).await);
pubs.push((c, e));
}
// The three members, and one subscriber outside the group.
let mut subs = Vec::new();
let mut views = Vec::new();
for _ in 0..4 {
let (s, e) = connect(&server, via, ClientOptions::default()).await;
let mut v = Vec::new();
for t in &tracks {
v.push(subscribe_both(&s, t, Parameters::new()).await);
}
views.push(v);
subs.push((s, e));
}
// Every member seals and publishes its groups.
let mut sent = Vec::new();
for (i, t) in tracks.iter().enumerate() {
let name = TrackName::from_str("0");
for g in 0..GROUPS {
let pt = Plaintext::with_digest(payload(&format!("member {i}"), g, 700));
let s = rings[i].seal(&Outgoing { ns: &t.ns, name: &name, group: g, object: 0, priority: t.priority, plaintext: &pt, immutable: &[], mutable: &[] }).unwrap().unwrap();
publish_sealed(&pubs[i].0, &keys[i], t, g, s.properties.clone(), &s.payload).await;
sent.push((i, g, s));
}
}
let reads = futures::future::join_all(subs.iter().map(|(s, _)| collect(s, STEP * 2, |got| got.len() as u64 >= 2 * 3 * GROUPS)));
let got = reads.await;
for (j, (g, v)) in got.iter().zip(&views).enumerate() {
let mut ring = if j < 3 { rings[j].clone() } else { Keyring::new(Suite::AesGcm128Sha256, [0x0f; 32]) };
for (i, t) in tracks.iter().enumerate() {
// Every receipt verifies over the sealed bytes the relay forwarded.
let (objects, bundles, _, _) = verify(g, &v[i], &keys[i]);
assert_eq!((objects, bundles), (GROUPS as usize, GROUPS as usize), "subscriber {j}, publisher {i}");
let name = TrackName::from_str("0");
for x in g.iter().filter(|x| x.header.track_alias == v[i].media_alias) {
let o = &x.objects[0];
let (_, _, s) = sent.iter().find(|(k, gg, _)| *k == i && *gg == x.header.group_id).unwrap();
assert_eq!(o.payload, s.payload, "the sealed payload, byte for byte");
assert_eq!(o.properties, s.properties, "the properties, the Key ID among them, byte for byte");
let inc = Incoming { ns: &t.ns, name: &name, group: x.header.group_id, object: 0, priority: x.header.publisher_priority.unwrap(), properties: &o.properties, payload: &o.payload };
match ring.open(&inc, None) {
Ok(op) if j < 3 => assert_eq!(op.plaintext.payload, payload(&format!("member {i}"), x.header.group_id, 700)),
Err(NotOpened::Refused(Refusal::EpochNotJoined)) if j == 3 => {}
other => panic!("subscriber {j}, publisher {i}: {other:?}"),
}
}
}
}
assert_eq!(server.relay().stats().refused_at_door, 0, "every sealed object passed the door on its receipt");
drop(held);
for (s, _) in &subs {
s.close(0, "done");
}
for (p, _) in &pubs {
p.close(0, "done");
}
server.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn sealed_objects_over_websocket() {
tokio::time::timeout(std::time::Duration::from_secs(120), room_of_three(Via::WebSocket)).await.expect("the room finishes in time");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn sealed_objects_over_quic() {
tokio::time::timeout(std::time::Duration::from_secs(120), room_of_three(Via::Quic)).await.expect("the room finishes in time");
}

View file

@ -25,6 +25,14 @@ uuid = { version = "1", features = ["v4", "v5", "v7", "serde", "js"], o
chrono = { version = "0.4", features = ["serde"], optional = true }
rand_core_06 = { package = "rand_core", version = "0.6", features = ["getrandom"], optional = true }
futures = { version = "0.3", optional = true }
# End-to-end sealed objects (feature `secure-objects`): RustCrypto's AES-GCM, AES-CTR,
# HMAC and HKDF, all Apache-2.0 OR MIT, at the versions this workspace's lock already
# holds. No default features: nothing here draws randomness.
aes-gcm = { version = "0.10", default-features = false, features = ["aes", "alloc"], optional = true }
aes = { version = "0.8", optional = true }
ctr = { version = "0.9", optional = true }
hmac = { version = "0.12", optional = true }
hkdf = { version = "0.12", optional = true }
[features]
default = ["runtime"]
@ -35,6 +43,9 @@ default = ["runtime"]
# (`joule-moq-web` builds it so).
runtime = ["dep:jwp", "dep:async-trait", "dep:tracing", "dep:tokio", "dep:uuid", "dep:chrono", "dep:rand_core_06", "dep:futures"]
quic = ["runtime", "dep:quinn", "dep:rustls", "dep:rcgen"]
# Sealing and opening objects end to end (WAI confidentiality §4, §6): no runtime, so it
# builds for wasm32-unknown-unknown beside the codec.
secure-objects = ["dep:aes-gcm", "dep:aes", "dep:ctr", "dep:hmac", "dep:hkdf"]
[dependencies.quinn]
version = "0.11"

View file

@ -41,6 +41,11 @@
//! - A native QUIC transport behind the `quic` feature (`transport_quic`): -22
//! control streams, request streams, subgroup and fetch streams and datagrams
//! over quinn.
//! - End-to-end sealed objects behind the `secure-objects` feature
//! (`secure_objects`): `draft-ietf-moq-secure-objects-01` on -22 objects, as WAI
//! `extensions/confidentiality.md` places it; RFC 9605 SFrame for its vectors; and a
//! room member's keys and receiving checks. No runtime and no native dependency, so
//! it builds for `wasm32-unknown-unknown` with the codec.
//!
//! The endpoint traits and the mock are the default feature `runtime`, on tokio.
//! Without it the crate is the codec, the carriage, the receipts and the session's
@ -81,6 +86,8 @@ pub mod messages;
#[cfg(feature = "runtime")]
pub mod mock;
pub mod receipt;
#[cfg(feature = "secure-objects")]
pub mod secure_objects;
pub mod session;
pub mod staged;
pub mod track;

View file

@ -0,0 +1,192 @@
//! The AEADs and KDFs of the ciphersuites secure objects shares with SFrame (RFC 9605
//! §4.5, `draft-ietf-moq-secure-objects-01` §7.2): AES-GCM, and RFC 9605 §4.5.1's
//! AES-CTR with HMAC-SHA-256 and a truncated tag.
use aes::cipher::{KeyIvInit, StreamCipher};
use aes_gcm::aead::{Aead, KeyInit, Payload};
use hkdf::Hkdf;
use hmac::{Hmac, Mac};
use sha2::{Sha256, Sha512};
/// A ciphersuite of the SFrame registry (RFC 9605 §8.1), which secure objects §7.2 uses.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Suite {
/// `0x0001` `AES_128_CTR_HMAC_SHA256_80`
AesCtr128HmacSha256_80,
/// `0x0002` `AES_128_CTR_HMAC_SHA256_64`
AesCtr128HmacSha256_64,
/// `0x0003` `AES_128_CTR_HMAC_SHA256_32`
AesCtr128HmacSha256_32,
/// `0x0004` `AES_128_GCM_SHA256_128`, mandatory to implement (secure objects §7.2).
AesGcm128Sha256,
/// `0x0005` `AES_256_GCM_SHA512_128`
AesGcm256Sha512,
}
impl Suite {
pub const ALL: [Suite; 5] =
[Suite::AesCtr128HmacSha256_80, Suite::AesCtr128HmacSha256_64, Suite::AesCtr128HmacSha256_32, Suite::AesGcm128Sha256, Suite::AesGcm256Sha512];
pub fn from_id(id: u16) -> Option<Suite> {
Suite::ALL.into_iter().find(|s| s.id() == id)
}
pub fn id(self) -> u16 {
match self {
Suite::AesCtr128HmacSha256_80 => 1,
Suite::AesCtr128HmacSha256_64 => 2,
Suite::AesCtr128HmacSha256_32 => 3,
Suite::AesGcm128Sha256 => 4,
Suite::AesGcm256Sha512 => 5,
}
}
/// The hash's output size.
pub fn nh(self) -> usize {
match self {
Suite::AesGcm256Sha512 => 64,
_ => 32,
}
}
/// For the counter-mode suites, the AES key's size within the AEAD key.
pub fn nka(self) -> Option<usize> {
match self {
Suite::AesGcm128Sha256 | Suite::AesGcm256Sha512 => None,
_ => Some(16),
}
}
/// The AEAD key's size: for the counter-mode suites, the AES key and the HMAC key.
pub fn nk(self) -> usize {
match self {
Suite::AesGcm128Sha256 => 16,
Suite::AesGcm256Sha512 => 32,
_ => 48,
}
}
/// The nonce's size.
pub fn nn(self) -> usize {
12
}
/// The tag's size.
pub fn nt(self) -> usize {
match self {
Suite::AesCtr128HmacSha256_80 => 10,
Suite::AesCtr128HmacSha256_64 => 8,
Suite::AesCtr128HmacSha256_32 => 4,
Suite::AesGcm128Sha256 | Suite::AesGcm256Sha512 => 16,
}
}
/// HKDF-Extract (RFC 5869 §2.2) with the suite's hash.
pub fn hkdf_extract(self, salt: &[u8], ikm: &[u8]) -> Vec<u8> {
match self {
Suite::AesGcm256Sha512 => Hkdf::<Sha512>::extract(Some(salt), ikm).0.to_vec(),
_ => Hkdf::<Sha256>::extract(Some(salt), ikm).0.to_vec(),
}
}
/// HKDF-Expand (RFC 5869 §2.3) with the suite's hash, from a PRK of at least the
/// hash's size, as HKDF-Extract gives.
pub fn hkdf_expand(self, prk: &[u8], info: &[u8], len: usize) -> Vec<u8> {
let mut out = vec![0u8; len];
match self {
Suite::AesGcm256Sha512 => Hkdf::<Sha512>::from_prk(prk).expect("a PRK of the hash's size").expand(info, &mut out),
_ => Hkdf::<Sha256>::from_prk(prk).expect("a PRK of the hash's size").expand(info, &mut out),
}
.expect("an output HKDF can give");
out
}
}
/// `AEAD.Encrypt(key, nonce, aad, pt)`: the ciphertext with the tag after it (RFC 5116).
/// `key` is `nk()` bytes and `nonce` 12.
pub fn seal(suite: Suite, key: &[u8], nonce: &[u8; 12], aad: &[u8], pt: &[u8]) -> Vec<u8> {
assert_eq!(key.len(), suite.nk(), "the key is the suite's size");
match suite {
Suite::AesGcm128Sha256 => aes_gcm::Aes128Gcm::new_from_slice(key)
.expect("16 bytes")
.encrypt(aes_gcm::Nonce::from_slice(nonce), Payload { msg: pt, aad })
.expect("GCM seals any length this crate carries"),
Suite::AesGcm256Sha512 => aes_gcm::Aes256Gcm::new_from_slice(key)
.expect("32 bytes")
.encrypt(aes_gcm::Nonce::from_slice(nonce), Payload { msg: pt, aad })
.expect("GCM seals any length this crate carries"),
_ => {
let (enc, auth) = key.split_at(16);
let mut ct = pt.to_vec();
ctr(enc, nonce, &mut ct);
let tag = ctr_tag(suite, auth, nonce, aad, &ct);
ct.extend_from_slice(&tag);
ct
}
}
}
/// `AEAD.Decrypt(key, nonce, aad, ct)`: the plaintext, or `None` for any failure, alike
/// whatever the cause.
pub fn open(suite: Suite, key: &[u8], nonce: &[u8; 12], aad: &[u8], ct: &[u8]) -> Option<Vec<u8>> {
if key.len() != suite.nk() || ct.len() < suite.nt() {
return None;
}
match suite {
Suite::AesGcm128Sha256 => aes_gcm::Aes128Gcm::new_from_slice(key).ok()?.decrypt(aes_gcm::Nonce::from_slice(nonce), Payload { msg: ct, aad }).ok(),
Suite::AesGcm256Sha512 => aes_gcm::Aes256Gcm::new_from_slice(key).ok()?.decrypt(aes_gcm::Nonce::from_slice(nonce), Payload { msg: ct, aad }).ok(),
_ => {
let (enc, auth) = key.split_at(16);
let (inner, tag) = ct.split_at(ct.len() - suite.nt());
let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(auth).expect("HMAC takes any key");
mac.update(&ctr_auth_data(suite, nonce, aad, inner));
mac.verify_truncated_left(tag).ok()?;
let mut pt = inner.to_vec();
ctr(enc, nonce, &mut pt);
Some(pt)
}
}
}
/// AES-128 in counter mode from the nonce followed by four zero bytes (RFC 9605 §4.5.1).
fn ctr(enc_key: &[u8], nonce: &[u8; 12], buf: &mut [u8]) {
let mut iv = [0u8; 16];
iv[..12].copy_from_slice(nonce);
let mut c = ctr::Ctr32BE::<aes::Aes128>::new_from_slices(enc_key, &iv).expect("a 16-byte key and IV");
c.apply_keystream(buf);
}
/// What RFC 9605 §4.5.1's `compute_tag` MACs: the lengths of the AAD, the ciphertext and
/// the tag as 8-byte big-endian integers, then the nonce, the AAD and the ciphertext.
fn ctr_auth_data(suite: Suite, nonce: &[u8; 12], aad: &[u8], ct: &[u8]) -> Vec<u8> {
let mut d = Vec::with_capacity(24 + 12 + aad.len() + ct.len());
d.extend_from_slice(&(aad.len() as u64).to_be_bytes());
d.extend_from_slice(&(ct.len() as u64).to_be_bytes());
d.extend_from_slice(&(suite.nt() as u64).to_be_bytes());
d.extend_from_slice(nonce);
d.extend_from_slice(aad);
d.extend_from_slice(ct);
d
}
/// RFC 9605 §4.5.1's tag: HMAC-SHA-256 over [`ctr_auth_data`], truncated to `nt()` bytes.
pub fn ctr_tag(suite: Suite, auth_key: &[u8], nonce: &[u8; 12], aad: &[u8], ct: &[u8]) -> Vec<u8> {
let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(auth_key).expect("HMAC takes any key");
mac.update(&ctr_auth_data(suite, nonce, aad, ct));
mac.finalize().into_bytes()[..suite.nt()].to_vec()
}
/// HMAC-SHA-256 of `parts` concatenated, under `key`: for the corpus's statement of how
/// secure objects' Appendix B computes its counter-mode tags (WAI confidentiality §11.2).
pub fn hmac_sha256(key: &[u8], parts: &[&[u8]]) -> [u8; 32] {
let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(key).expect("HMAC takes any key");
for p in parts {
mac.update(p);
}
mac.finalize().into_bytes().into()
}
/// AES-128-CTR from `nonce ‖ 0x00000000` over `buf`, as the counter-mode suites encrypt.
pub fn ctr_keystream(enc_key: &[u8], nonce: &[u8; 12], buf: &mut [u8]) {
ctr(enc_key, nonce, buf)
}

View file

@ -0,0 +1,479 @@
//! End-to-end sealed objects: `draft-ietf-moq-secure-objects-01` on the objects of
//! `draft-ietf-moq-transport-22`, as WAI `extensions/confidentiality.md` §4 places it.
//!
//! The publisher seals each object's payload, and any Encrypted Properties, under a key
//! and salt derived from its track's base key, its Key ID and its Full Track Name; the
//! AAD binds the Group ID, Object ID, Publisher Priority and Immutable Properties, the
//! Key ID among them. A relay forwards the sealed object as any other: everything it
//! reads stays readable, and nothing it reads lets it open the payload.
//!
//! - [`aead`]: the ciphersuites' AEADs and KDFs (AES-GCM; RFC 9605's AES-CTR with HMAC).
//! - [`sframe`]: RFC 9605 itself, whose Appendix C vectors check those primitives.
//! - This module: the Serialized Full Track Name, the Key ID property, the plaintext and
//! its Encrypted Properties List, key and salt, nonce, AAD, and seal and open of one
//! object.
//! - [`room`]: a room's keys (the epoch's application secret, each namespace's base key,
//! the KID) and a member's receiving checks, in the order of confidentiality §6.
//!
//! Where secure objects and MOQT -22 disagree (the Key ID's code point, the property
//! forms, the counter-mode suites' tag), this module takes WAI confidentiality §11's
//! reading; each is named where it applies.
pub mod aead;
pub mod room;
pub mod sframe;
pub use aead::Suite;
use crate::draft22::io::{put_varint, Reader};
use crate::draft22::kvp::{prop as wire_prop, KeyValuePair, KvValue, Properties};
use crate::track::{TrackName, TrackNamespace};
/// The Key ID property's type on a -22 object (confidentiality §4.3, §11.1): from the
/// range -22 §8.4 sets aside for applications, since -22 §16.8 gives secure objects'
/// `0x2` to OBJECT_DELIVERY_TIMEOUT. Even, so its value is one varint.
pub const KEY_ID_PROPERTY: u64 = 0x3a4a;
/// The Encrypted Properties List's type at the head of the list in the plaintext
/// (secure objects §3.3.3 step 4, §4.2).
pub const ENCRYPTED_LIST: u16 = 0x000a;
/// The Padding property's type (secure objects §4.3).
pub const PADDING: u16 = 0x0032;
/// `WAI_INNER_DIGEST` (confidentiality §4.4): BLAKE3 of the original payload, sealed with it.
pub const INNER_DIGEST: u16 = 0x3a4b;
/// The largest Object ID a nonce can hold (secure objects §3.6).
pub const MAX_OBJECT_ID: u64 = u32::MAX as u64;
/// Why a receiver refuses a sealed object, a record or a transcript: the codes of WAI
/// confidentiality §13.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Refusal {
// The confidentiality record (§3.3).
Truncated,
Malformed,
UnknownVersion,
WrongRoom,
SuiteUnsupported,
MlsSuiteUnsupported,
RecordingRefused,
BadKey,
BadSignature,
// The MLS binding (§5).
GroupMismatch,
CredentialMismatch,
ProposalNotClock,
ProposalMismatch,
CommitNotCommitter,
CommitMismatch,
// A sealed object (§6).
PropertiesMalformed,
KeyIdMissing,
ObjectIdTooLarge,
EpochNotJoined,
RemovedBeforeEpoch,
KeyIdUnknown,
SenderNotOwner,
SenderRemoved,
Replayed,
BadTag,
PlaintextMalformed,
InnerDigestMismatch,
}
impl Refusal {
/// Every code, in the order of confidentiality §13.
pub const ALL: [Refusal; 27] = [
Refusal::Truncated,
Refusal::Malformed,
Refusal::UnknownVersion,
Refusal::WrongRoom,
Refusal::SuiteUnsupported,
Refusal::MlsSuiteUnsupported,
Refusal::RecordingRefused,
Refusal::BadKey,
Refusal::BadSignature,
Refusal::GroupMismatch,
Refusal::CredentialMismatch,
Refusal::ProposalNotClock,
Refusal::ProposalMismatch,
Refusal::CommitNotCommitter,
Refusal::CommitMismatch,
Refusal::PropertiesMalformed,
Refusal::KeyIdMissing,
Refusal::ObjectIdTooLarge,
Refusal::EpochNotJoined,
Refusal::RemovedBeforeEpoch,
Refusal::KeyIdUnknown,
Refusal::SenderNotOwner,
Refusal::SenderRemoved,
Refusal::Replayed,
Refusal::BadTag,
Refusal::PlaintextMalformed,
Refusal::InnerDigestMismatch,
];
pub fn code(self) -> &'static str {
match self {
Refusal::Truncated => "truncated",
Refusal::Malformed => "malformed",
Refusal::UnknownVersion => "unknown_version",
Refusal::WrongRoom => "wrong_room",
Refusal::SuiteUnsupported => "suite_unsupported",
Refusal::MlsSuiteUnsupported => "mls_suite_unsupported",
Refusal::RecordingRefused => "recording_refused",
Refusal::BadKey => "bad_key",
Refusal::BadSignature => "bad_signature",
Refusal::GroupMismatch => "group_mismatch",
Refusal::CredentialMismatch => "credential_mismatch",
Refusal::ProposalNotClock => "proposal_not_clock",
Refusal::ProposalMismatch => "proposal_mismatch",
Refusal::CommitNotCommitter => "commit_not_committer",
Refusal::CommitMismatch => "commit_mismatch",
Refusal::PropertiesMalformed => "properties_malformed",
Refusal::KeyIdMissing => "key_id_missing",
Refusal::ObjectIdTooLarge => "object_id_too_large",
Refusal::EpochNotJoined => "epoch_not_joined",
Refusal::RemovedBeforeEpoch => "removed_before_epoch",
Refusal::KeyIdUnknown => "key_id_unknown",
Refusal::SenderNotOwner => "sender_not_owner",
Refusal::SenderRemoved => "sender_removed",
Refusal::Replayed => "replayed",
Refusal::BadTag => "bad_tag",
Refusal::PlaintextMalformed => "plaintext_malformed",
Refusal::InnerDigestMismatch => "inner_digest_mismatch",
}
}
pub fn from_code(code: &str) -> Option<Refusal> {
Refusal::ALL.into_iter().find(|r| r.code() == code)
}
}
impl std::fmt::Display for Refusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.code())
}
}
impl std::error::Error for Refusal {}
// ── Names ───────────────────────────────────────────────────────────────────
/// A Track Namespace as -22 §8.7 encodes it, every varint shortest.
pub fn serialize_namespace(ns: &TrackNamespace) -> Vec<u8> {
let mut out = Vec::new();
put_varint(&mut out, ns.0.len() as u64);
for f in &ns.0 {
put_varint(&mut out, f.len() as u64);
out.extend_from_slice(f);
}
out
}
/// The Serialized Full Track Name (secure objects §3.3.1; confidentiality §4.2).
pub fn serialize_full_track_name(ns: &TrackNamespace, name: &TrackName) -> Vec<u8> {
let mut out = serialize_namespace(ns);
put_varint(&mut out, name.0.len() as u64);
out.extend_from_slice(&name.0);
out
}
// ── Keys, nonce, AAD ────────────────────────────────────────────────────────
/// `moq_key` and `moq_salt` for one track and Key ID.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct KeySalt {
pub key: Vec<u8>,
pub salt: [u8; 12],
}
/// The labels of secure objects §3.7: `(moq_key_label, moq_salt_label)`.
pub fn labels(suite: Suite, key_id: u64, ftn: &[u8]) -> (Vec<u8>, Vec<u8>) {
let tail = [ftn, &suite.id().to_be_bytes(), &key_id.to_be_bytes()].concat();
([b"MOQ 1.0 Secure Objects Secret key ".as_slice(), &tail].concat(), [b"MOQ 1.0 Secret salt ".as_slice(), &tail].concat())
}
/// `derive_key_salt` of secure objects §3.7, with the suite's hash.
pub fn derive_key_salt(suite: Suite, key_id: u64, track_base_key: &[u8], ftn: &[u8]) -> KeySalt {
let secret = suite.hkdf_extract(&[], track_base_key);
let (kl, sl) = labels(suite, key_id, ftn);
KeySalt { key: suite.hkdf_expand(&secret, &kl, suite.nk()), salt: suite.hkdf_expand(&secret, &sl, 12).try_into().expect("12 bytes") }
}
/// The 96-bit CTR of secure objects §3.6: the Group ID's 8 bytes, the Object ID's 4.
pub fn ctr(group: u64, object: u64) -> Result<[u8; 12], Refusal> {
let object = u32::try_from(object).map_err(|_| Refusal::ObjectIdTooLarge)?;
let mut c = [0u8; 12];
c[..8].copy_from_slice(&group.to_be_bytes());
c[8..].copy_from_slice(&object.to_be_bytes());
Ok(c)
}
/// The nonce: `moq_salt XOR CTR`.
pub fn nonce(salt: &[u8; 12], ctr: &[u8; 12]) -> [u8; 12] {
std::array::from_fn(|i| salt[i] ^ ctr[i])
}
/// `SECURE_OBJECT_AAD` of secure objects §3.5: Group ID (8), Object ID (4), Publisher
/// Priority (1), then the Serialized Immutable Properties as the object carries them.
pub fn aad(group: u64, object: u64, priority: u8, sip: &[u8]) -> Result<Vec<u8>, Refusal> {
let mut a = ctr(group, object)?.to_vec();
a.push(priority);
a.extend_from_slice(sip);
Ok(a)
}
// ── The plaintext ───────────────────────────────────────────────────────────
/// What the AEAD seals (confidentiality §4.4): the original payload, and the Encrypted
/// Properties List when it has an entry.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Plaintext {
pub payload: Vec<u8>,
/// `WAI_INNER_DIGEST`: BLAKE3 of `payload`.
pub inner_digest: Option<[u8; 32]>,
/// Entries of other types, in order, each `(type, value)`; a reader skips them.
pub other: Vec<(u16, Vec<u8>)>,
/// Padding, the last entry: its length in zero bytes.
pub padding: Option<u16>,
}
impl Plaintext {
/// A payload with its inner digest, as a publisher seals a video frame.
pub fn with_digest(payload: Vec<u8>) -> Self {
let d = *blake3::hash(&payload).as_bytes();
Plaintext { payload, inner_digest: Some(d), other: Vec::new(), padding: None }
}
pub fn encode(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(self.payload.len() + 48);
put_varint(&mut out, self.payload.len() as u64);
out.extend_from_slice(&self.payload);
let mut entries = Vec::new();
if let Some(d) = &self.inner_digest {
entries.extend_from_slice(&INNER_DIGEST.to_be_bytes());
put_varint(&mut entries, 32);
entries.extend_from_slice(d);
}
for (ty, v) in &self.other {
entries.extend_from_slice(&ty.to_be_bytes());
put_varint(&mut entries, v.len() as u64);
entries.extend_from_slice(v);
}
if let Some(n) = self.padding {
entries.extend_from_slice(&PADDING.to_be_bytes());
entries.extend_from_slice(&n.to_be_bytes());
entries.resize(entries.len() + n as usize, 0);
}
if !entries.is_empty() {
out.extend_from_slice(&ENCRYPTED_LIST.to_be_bytes());
put_varint(&mut out, entries.len() as u64);
out.extend_from_slice(&entries);
}
out
}
/// Read a plaintext (confidentiality §4.4); `plaintext_malformed` where it does not read.
pub fn decode(b: &[u8]) -> Result<Self, Refusal> {
let bad = |_| Refusal::PlaintextMalformed;
let mut r = Reader::new(b);
let payload = r.length_prefixed(None, "payload").map_err(bad)?.to_vec();
let mut p = Plaintext { payload, ..Default::default() };
if r.is_empty() {
return Ok(p);
}
if r.u16("list type").map_err(bad)? != ENCRYPTED_LIST {
return Err(Refusal::PlaintextMalformed);
}
let n = r.varint("list length").map_err(bad)?;
if n != r.remaining() as u64 {
return Err(Refusal::PlaintextMalformed);
}
while !r.is_empty() {
let ty = r.u16("entry type").map_err(bad)?;
if ty == PADDING {
let len = r.u16("padding length").map_err(bad)?;
r.bytes(len as u64, "padding").map_err(bad)?;
if !r.is_empty() {
return Err(Refusal::PlaintextMalformed);
}
p.padding = Some(len);
break;
}
let v = r.length_prefixed(None, "entry").map_err(bad)?;
if ty == INNER_DIGEST {
if p.inner_digest.is_some() || v.len() != 32 {
return Err(Refusal::PlaintextMalformed);
}
p.inner_digest = Some(v.try_into().expect("32 bytes"));
} else {
p.other.push((ty, v.to_vec()));
}
}
Ok(p)
}
/// `inner_digest_mismatch` where the plaintext carries an inner digest that is not
/// BLAKE3 of its payload.
pub fn check_digest(&self) -> Result<(), Refusal> {
match &self.inner_digest {
Some(d) if d != blake3::hash(&self.payload).as_bytes() => Err(Refusal::InnerDigestMismatch),
_ => Ok(()),
}
}
}
// ── The Key ID property ─────────────────────────────────────────────────────
/// The Serialized Immutable Properties a publisher writes: the Key ID property and
/// `extra` (properties fixed before sealing), as -22 §8.3 encodes a list.
pub fn immutable_properties(key_id: u64, extra: &[KeyValuePair]) -> Vec<u8> {
let mut inner = Properties::new();
for p in extra.iter().filter(|p| p.ty != KEY_ID_PROPERTY) {
inner.push(p.clone());
}
inner.push(KeyValuePair::varint(KEY_ID_PROPERTY, key_id));
inner.to_bytes().expect("a list of varints and short byte values encodes")
}
/// An object's property list whose Immutable Properties are `sip` exactly, beside
/// `mutable` (none of them a Key ID or Immutable Properties).
pub fn object_properties(sip: &[u8], mutable: &[KeyValuePair]) -> Properties {
let mut p = Properties::new();
for m in mutable.iter().filter(|m| m.ty != KEY_ID_PROPERTY && m.ty != wire_prop::IMMUTABLE_PROPERTIES) {
p.push(m.clone());
}
p.push(KeyValuePair::bytes(wire_prop::IMMUTABLE_PROPERTIES, sip.to_vec()));
p
}
/// The Key ID and the Serialized Immutable Properties of an object (confidentiality §6
/// step 1): `properties_malformed` where the list does not read, a Key ID appears twice,
/// outside Immutable Properties, or is not a varint; `key_id_missing` where none appears.
pub fn key_id_of(props: &Properties) -> Result<(u64, Vec<u8>), Refusal> {
let outside = props.0.iter().filter(|p| p.ty == KEY_ID_PROPERTY).count();
let imm: Vec<&KeyValuePair> = props.0.iter().filter(|p| p.ty == wire_prop::IMMUTABLE_PROPERTIES).collect();
if outside > 0 || imm.len() > 1 {
return Err(Refusal::PropertiesMalformed);
}
let Some(imm) = imm.first() else { return Err(Refusal::KeyIdMissing) };
let KvValue::Bytes(sip) = &imm.value else { return Err(Refusal::PropertiesMalformed) };
let inner = Properties::decode(sip, "immutable properties").map_err(|_| Refusal::PropertiesMalformed)?;
if inner.0.iter().any(|p| p.ty == wire_prop::IMMUTABLE_PROPERTIES) {
return Err(Refusal::PropertiesMalformed);
}
let kids: Vec<&KeyValuePair> = inner.0.iter().filter(|p| p.ty == KEY_ID_PROPERTY).collect();
match kids.as_slice() {
[] => Err(Refusal::KeyIdMissing),
[k] => Ok((k.as_varint().ok_or(Refusal::PropertiesMalformed)?, sip.clone())),
_ => Err(Refusal::PropertiesMalformed),
}
}
// ── Seal and open ───────────────────────────────────────────────────────────
/// The fields of one object that its seal and open bind.
#[derive(Clone, Copy, Debug)]
pub struct Location<'a> {
/// The Serialized Full Track Name.
pub ftn: &'a [u8],
pub group: u64,
pub object: u64,
pub priority: u8,
}
/// Seal `pt` (an encoded [`Plaintext`]) as the object at `at` whose Serialized Immutable
/// Properties are `sip` (confidentiality §4.9): the object's payload.
pub fn seal_object(suite: Suite, keys: &KeySalt, at: Location<'_>, sip: &[u8], pt: &[u8]) -> Result<Vec<u8>, Refusal> {
let aad = aad(at.group, at.object, at.priority, sip)?;
let n = nonce(&keys.salt, &ctr(at.group, at.object)?);
Ok(aead::seal(suite, &keys.key, &n, &aad, pt))
}
/// Open the payload of the object at `at` (confidentiality §4.10): the plaintext's bytes,
/// or `bad_tag`.
pub fn open_object(suite: Suite, keys: &KeySalt, at: Location<'_>, sip: &[u8], sealed: &[u8]) -> Result<Vec<u8>, Refusal> {
let aad = aad(at.group, at.object, at.priority, sip)?;
let n = nonce(&keys.salt, &ctr(at.group, at.object)?);
aead::open(suite, &keys.key, &n, &aad, sealed).ok_or(Refusal::BadTag)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_plaintext_round_trips_and_its_malformations_are_refused() {
let mut p = Plaintext::with_digest(b"frame".to_vec());
p.other.push((0x0100, b"for-eyes-only".to_vec()));
p.padding = Some(5);
let b = p.encode();
assert_eq!(Plaintext::decode(&b).unwrap(), p);
p.check_digest().unwrap();
let bare = Plaintext { payload: b"x".to_vec(), ..Default::default() };
assert_eq!(bare.encode(), vec![1, b'x'], "no list when there is no entry");
// A list type other than 0x000A, a list length that is not the rest, padding
// before another entry, two inner digests, a short digest.
let mut wrong_type = bare.encode();
wrong_type.extend_from_slice(&[0x00, 0x0b, 0x00]);
let mut wrong_len = bare.encode();
wrong_len.extend_from_slice(&[0x00, 0x0a, 0x05, 0x00, 0x32, 0x00, 0x00]);
let mut pad_first = bare.encode();
pad_first.extend_from_slice(&[0x00, 0x0a, 0x07, 0x00, 0x32, 0x00, 0x00, 0x01, 0x00, 0x00]);
let two = {
let mut e = Vec::new();
for _ in 0..2 {
e.extend_from_slice(&INNER_DIGEST.to_be_bytes());
e.push(32);
e.extend_from_slice(&[0; 32]);
}
let mut b = bare.encode();
b.extend_from_slice(&ENCRYPTED_LIST.to_be_bytes());
put_varint(&mut b, e.len() as u64);
b.extend_from_slice(&e);
b
};
let mut short = bare.encode();
short.extend_from_slice(&[0x00, 0x0a, 0x04, 0x3a, 0x4b, 0x01, 0x00]);
for b in [wrong_type, wrong_len, pad_first, two, short, vec![5, 1]] {
assert_eq!(Plaintext::decode(&b), Err(Refusal::PlaintextMalformed), "{b:02x?}");
}
let lie = Plaintext { inner_digest: Some([0; 32]), ..bare };
assert_eq!(lie.check_digest(), Err(Refusal::InnerDigestMismatch));
}
#[test]
fn the_key_id_lives_inside_immutable_properties_once() {
let sip = immutable_properties(0x0100_0002, &[KeyValuePair::varint(0x3a40, 1)]);
let p = object_properties(&sip, &[KeyValuePair::varint(wire_prop::OBJECT_DELIVERY_TIMEOUT, 50)]);
assert_eq!(key_id_of(&p).unwrap(), (0x0100_0002, sip.clone()));
assert_eq!(key_id_of(&Properties::new()), Err(Refusal::KeyIdMissing));
let mut outside = p.clone();
outside.push(KeyValuePair::varint(KEY_ID_PROPERTY, 1));
assert_eq!(key_id_of(&outside), Err(Refusal::PropertiesMalformed));
let mut twice_inner = Properties::new();
twice_inner.push(KeyValuePair::varint(KEY_ID_PROPERTY, 1)).push(KeyValuePair::varint(KEY_ID_PROPERTY, 2));
let twice = object_properties(&twice_inner.to_bytes().unwrap(), &[]);
assert_eq!(key_id_of(&twice), Err(Refusal::PropertiesMalformed));
let garbage = object_properties(&[0x01], &[]);
assert_eq!(key_id_of(&garbage), Err(Refusal::PropertiesMalformed));
let empty = object_properties(&[], &[]);
assert_eq!(key_id_of(&empty), Err(Refusal::KeyIdMissing));
}
#[test]
fn every_code_is_listed_once() {
let mut codes: Vec<&str> = Refusal::ALL.iter().map(|r| r.code()).collect();
codes.sort();
codes.dedup();
assert_eq!(codes.len(), Refusal::ALL.len());
for r in Refusal::ALL {
assert_eq!(Refusal::from_code(r.code()), Some(r));
}
}
#[test]
fn an_object_id_past_32_bits_has_no_nonce() {
assert_eq!(ctr(1, 1 << 32), Err(Refusal::ObjectIdTooLarge));
assert_eq!(ctr(u64::MAX, u32::MAX as u64).unwrap(), [0xff; 12]);
}
}

View file

@ -0,0 +1,405 @@
//! A room's keys and a member's checks (WAI confidentiality §5.7, §5.8 and §6).
//!
//! The MLS group is outside this module: it gives each epoch's application secret (the
//! exporter's output, `MLS-Exporter("wai live e2ee", room_id, 32)`) and roster, and
//! `joule-moq-mls` feeds them in as the member enters each epoch. From them this module
//! derives each namespace's base key and each sender's KID, seals, and opens through the
//! receiving checks in the order the extension gives them.
use super::{aead::Suite, derive_key_salt, key_id_of, open_object, seal_object, serialize_full_track_name, serialize_namespace};
use super::{immutable_properties, object_properties, KeySalt, Location, Plaintext, Refusal};
use crate::draft22::kvp::{KeyValuePair, Properties};
use crate::track::{TrackName, TrackNamespace};
use hkdf::Hkdf;
use sha2::Sha256;
use std::collections::{BTreeMap, 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.
pub const EXPORTER_LABEL: &str = "wai live e2ee";
/// The prefix of a track base key's HKDF info: the keying draft §7.1's label.
pub const TRACK_LABEL: &[u8] = b"mocha_track";
/// The largest epoch a KID carries: 40 bits (the keying draft §7.2).
pub const MAX_EPOCH: u64 = (1 << 40) - 1;
/// The largest leaf index a KID carries: 24 bits.
pub const MAX_LEAF: u32 = (1 << 24) - 1;
/// A principal's Ed25519 key, the identity of its leaf.
pub type Principal = [u8; 32];
/// An epoch's roster: each occupied leaf index and its member.
pub type Roster = BTreeMap<u32, Principal>;
/// `track_base_key[E, ns]`: HKDF-Expand with SHA-256 (the MLS suite's hash) from the
/// epoch's application secret, info `mocha_track` and the serialized namespace, 32 bytes.
pub fn track_base_key(application_secret: &[u8; 32], ns: &TrackNamespace) -> [u8; 32] {
let mut info = TRACK_LABEL.to_vec();
info.extend_from_slice(&serialize_namespace(ns));
let mut out = [0u8; 32];
Hkdf::<Sha256>::from_prk(application_secret).expect("32 bytes is SHA-256's size").expand(&info, &mut out).expect("32 bytes");
out
}
/// The KID of a member at leaf index `leaf` in `epoch`: `epoch × 2^24 + leaf`; `None`
/// past 40 bits of epoch or 24 of leaf.
pub fn key_id(epoch: u64, leaf: u32) -> Option<u64> {
(epoch <= MAX_EPOCH && leaf <= MAX_LEAF).then_some((epoch << 24) | leaf as u64)
}
/// The epoch and the leaf index a KID names.
pub fn split_key_id(kid: u64) -> (u64, u32) {
(kid >> 24, (kid & MAX_LEAF as u64) as u32)
}
/// The owner a room's track names (live-rooms §6.1): the second namespace field, when it
/// is a principal's 64 lowercase hexadecimal digits; `None` for a slot's track.
pub fn track_owner(ns: &TrackNamespace) -> Option<Principal> {
let f = ns.0.get(1)?;
if f.len() != 64 || !f.iter().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(c)) {
return None;
}
let mut out = [0u8; 32];
for (i, o) in out.iter_mut().enumerate() {
let h = |c: u8| if c.is_ascii_digit() { c - b'0' } else { c - b'a' + 10 };
*o = (h(f[2 * i]) << 4) | h(f[2 * i + 1]);
}
Some(out)
}
/// Everything a seal derives, stated so a corpus can show each step.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Derivation {
pub track_base_key: [u8; 32],
pub kid: u64,
pub ftn: Vec<u8>,
pub keys: KeySalt,
}
/// The derivation for the member at `leaf` sealing on `ns`/`name` in an epoch whose
/// application secret is `secret`.
pub fn derive(suite: Suite, secret: &[u8; 32], epoch: u64, leaf: u32, ns: &TrackNamespace, name: &TrackName) -> Option<Derivation> {
let kid = key_id(epoch, leaf)?;
let base = track_base_key(secret, ns);
let ftn = serialize_full_track_name(ns, name);
let keys = derive_key_salt(suite, kid, &base, &ftn);
Some(Derivation { track_base_key: base, kid, ftn, keys })
}
/// A sealed object as its publisher sends it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SealedObject {
pub kid: u64,
/// The Serialized Immutable Properties: the value of the Immutable Properties pair.
pub sip: Vec<u8>,
/// The object's property list, the Immutable Properties pair among it.
pub properties: Properties,
/// The sealed payload: ciphertext and tag.
pub payload: Vec<u8>,
}
/// One object to seal: where it goes, what it says, and the properties it fixes.
#[derive(Clone, Debug)]
pub struct Outgoing<'a> {
pub ns: &'a TrackNamespace,
pub name: &'a TrackName,
pub group: u64,
pub object: u64,
pub priority: u8,
pub plaintext: &'a Plaintext,
/// Properties inside Immutable Properties besides the Key ID.
pub immutable: &'a [KeyValuePair],
/// Properties outside them.
pub mutable: &'a [KeyValuePair],
}
/// Seal `o` with the derivation `d` (confidentiality §4.9).
pub fn seal_with(suite: Suite, d: &Derivation, o: &Outgoing<'_>) -> Result<SealedObject, Refusal> {
let sip = immutable_properties(d.kid, o.immutable);
let at = Location { ftn: &d.ftn, group: o.group, object: o.object, priority: o.priority };
let payload = seal_object(suite, &d.keys, at, &sip, &o.plaintext.encode())?;
Ok(SealedObject { kid: d.kid, properties: object_properties(&sip, o.mutable), sip, payload })
}
/// One object as a receiver gets it.
#[derive(Clone, Copy, Debug)]
pub struct Incoming<'a> {
pub ns: &'a TrackNamespace,
pub name: &'a TrackName,
pub group: u64,
pub object: u64,
pub priority: u8,
pub properties: &'a Properties,
pub payload: &'a [u8],
}
/// An object opened.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Opened {
pub epoch: u64,
pub leaf: u32,
pub sender: Principal,
pub plaintext: Plaintext,
}
/// Why an object did not open.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NotOpened {
/// Refused, with the extension's reason.
Refused(Refusal),
/// Sealed in an epoch the receiver has not reached: held until it applies the commit
/// that opens it (confidentiality §5.8), and not a refusal.
Pending(u64),
}
impl From<Refusal> for NotOpened {
fn from(r: Refusal) -> Self {
NotOpened::Refused(r)
}
}
/// What a member holds to seal and open in one room's group.
#[derive(Clone, Debug)]
pub struct Keyring {
suite: Suite,
me: Principal,
/// The first epoch it held.
joined: Option<u64>,
/// The epoch its removal opened: it holds nothing from it on.
removed: Option<u64>,
secrets: BTreeMap<u64, [u8; 32]>,
rosters: BTreeMap<u64, Roster>,
opened: HashSet<(Vec<u8>, u64, u64)>,
}
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() }
}
pub fn suite(&self) -> Suite {
self.suite
}
pub fn me(&self) -> Principal {
self.me
}
/// Enter `epoch`: the member created the group, joined by a Welcome, or applied the
/// commit that opens it, and holds its application secret and roster.
pub fn enter(&mut self, epoch: u64, application_secret: [u8; 32], roster: Roster) {
self.joined.get_or_insert(epoch);
self.secrets.insert(epoch, application_secret);
self.rosters.insert(epoch, roster);
}
/// The member was removed by the commit that opens `epoch`.
pub fn removed_at(&mut self, epoch: u64) {
self.removed = Some(epoch);
}
/// Delete `epoch`'s keys (confidentiality §5.8); its roster is kept, to check senders.
pub fn forget(&mut self, epoch: u64) {
self.secrets.remove(&epoch);
}
/// The latest epoch the member entered.
pub fn latest(&self) -> Option<u64> {
self.rosters.keys().next_back().copied()
}
pub fn roster(&self, epoch: u64) -> Option<&Roster> {
self.rosters.get(&epoch)
}
/// The member's own leaf in its latest epoch.
pub fn my_leaf(&self) -> Option<u32> {
let r = self.rosters.get(&self.latest()?)?;
r.iter().find(|(_, p)| **p == self.me).map(|(l, _)| *l)
}
/// Seal `o` under the member's latest epoch and its own leaf. `None` when it holds no
/// epoch, or has been removed.
pub fn seal(&self, o: &Outgoing<'_>) -> Option<Result<SealedObject, Refusal>> {
if self.removed.is_some() {
return None;
}
let epoch = self.latest()?;
let d = derive(self.suite, self.secrets.get(&epoch)?, epoch, self.my_leaf()?, o.ns, o.name)?;
Some(seal_with(self.suite, &d, o))
}
/// Open `o` through the checks of confidentiality §6, in order. `owners` names the
/// principals that may publish on the track when its namespace does not (a slot's);
/// otherwise the namespace's own principal is the owner.
pub fn open(&mut self, o: &Incoming<'_>, owners: Option<&[Principal]>) -> Result<Opened, NotOpened> {
// 1, 2: the properties and the Object ID.
let (kid, sip) = key_id_of(o.properties)?;
if o.object > super::MAX_OBJECT_ID {
return Err(Refusal::ObjectIdTooLarge.into());
}
// 3, 4: the epoch.
let (epoch, leaf) = split_key_id(kid);
if self.removed.is_some_and(|x| epoch >= x) {
return Err(Refusal::RemovedBeforeEpoch.into());
}
let Some(latest) = self.latest() else { return Err(Refusal::EpochNotJoined.into()) };
if self.joined.is_none_or(|j| epoch < j) {
return Err(Refusal::EpochNotJoined.into());
}
if epoch > latest {
return Err(NotOpened::Pending(epoch));
}
let (Some(secret), Some(roster)) = (self.secrets.get(&epoch), self.rosters.get(&epoch)) else {
return Err(Refusal::EpochNotJoined.into());
};
// 5: the leaf.
let Some(sender) = roster.get(&leaf).copied() else { return Err(Refusal::KeyIdUnknown.into()) };
// 6: the owner.
let owned = match owners {
Some(list) => list.contains(&sender),
None => track_owner(o.ns) == Some(sender),
};
if !owned {
return Err(Refusal::SenderNotOwner.into());
}
// 7: the sender held its leaf in every epoch since.
if self.rosters.range(epoch..=latest).any(|(_, r)| r.get(&leaf) != Some(&sender)) {
return Err(Refusal::SenderRemoved.into());
}
// 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) {
return Err(Refusal::Replayed.into());
}
// 9 to 11.
let base = track_base_key(secret, o.ns);
let keys = derive_key_salt(self.suite, kid, &base, &ftn);
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);
Ok(Opened { epoch, leaf, sender, plaintext })
}
}
#[cfg(test)]
mod tests {
use super::*;
fn hex(k: &Principal) -> String {
k.iter().map(|b| format!("{b:02x}")).collect()
}
fn room_ns(room: &[u8; 32], who: &Principal, kind: &str) -> TrackNamespace {
TrackNamespace::from_strs([hex(room), hex(who), kind.to_string()])
}
#[test]
fn kids_carry_epoch_and_leaf() {
assert_eq!(key_id(5, 3), Some((5 << 24) | 3));
assert_eq!(split_key_id(key_id(MAX_EPOCH, MAX_LEAF).unwrap()), (MAX_EPOCH, MAX_LEAF));
assert_eq!(key_id(MAX_EPOCH + 1, 0), None);
assert_eq!(key_id(0, MAX_LEAF + 1), None);
}
#[test]
fn three_members_each_open_the_others() {
let suite = Suite::AesGcm128Sha256;
let room = [9u8; 32];
let who: [Principal; 3] = [[1; 32], [2; 32], [3; 32]];
let roster: Roster = who.iter().enumerate().map(|(i, p)| (i as u32, *p)).collect();
let secret = [0x5a; 32];
let mut rings: Vec<Keyring> = who
.iter()
.map(|p| {
let mut k = Keyring::new(suite, *p);
k.enter(4, secret, roster.clone());
k
})
.collect();
let name = TrackName::from_str("0");
for (i, p) in who.iter().enumerate() {
let ns = room_ns(&room, p, "video");
let pt = Plaintext::with_digest(format!("frame of {i}").into_bytes());
let o = Outgoing { ns: &ns, name: &name, group: 7, object: 0, priority: 64, plaintext: &pt, immutable: &[], mutable: &[] };
let s = rings[i].seal(&o).unwrap().unwrap();
assert_eq!(split_key_id(s.kid), (4, i as u32));
for (j, ring) in rings.iter_mut().enumerate() {
let inc = Incoming { ns: &ns, name: &name, group: 7, object: 0, priority: 64, properties: &s.properties, payload: &s.payload };
let got = ring.open(&inc, None);
if j == i {
// A member opens its own objects too; a sender is a receiver like any other.
assert!(got.is_ok());
continue;
}
assert_eq!(got.unwrap().plaintext, pt);
// The same location again is a replay.
assert_eq!(ring.open(&inc, None), Err(NotOpened::Refused(Refusal::Replayed)));
}
// Another priority, another track, or a changed byte: the tag fails.
let other = TrackName::from_str("1");
let mut wrong = Keyring::new(suite, who[(i + 1) % 3]);
wrong.enter(4, secret, roster.clone());
for (n, prio, payload) in [(&other, 64u8, s.payload.clone()), (&name, 65, s.payload.clone()), (&name, 64, { let mut b = s.payload.clone(); b[0] ^= 1; b })] {
let inc = Incoming { ns: &ns, name: n, group: 7, object: 0, priority: prio, properties: &s.properties, payload: &payload };
assert_eq!(wrong.open(&inc, None), Err(NotOpened::Refused(Refusal::BadTag)));
}
// A member's KID on another member's track: not its owner.
let theirs = room_ns(&room, &who[(i + 1) % 3], "video");
let o2 = Outgoing { ns: &theirs, name: &name, group: 9, object: 0, priority: 64, plaintext: &pt, immutable: &[], mutable: &[] };
let forged = rings[i].seal(&o2).unwrap().unwrap();
let inc = Incoming { ns: &theirs, name: &name, group: 9, object: 0, priority: 64, properties: &forged.properties, payload: &forged.payload };
assert_eq!(wrong.open(&inc, None), Err(NotOpened::Refused(Refusal::SenderNotOwner)));
}
}
#[test]
fn removal_late_join_and_retired_senders() {
let suite = Suite::AesGcm256Sha512;
let room = [7u8; 32];
let (a, b, c, d) = ([1u8; 32], [2u8; 32], [3u8; 32], [4u8; 32]);
let r1: Roster = [(0, a), (1, b), (2, c)].into();
let r2: Roster = [(0, a), (1, b)].into();
let r3: Roster = [(0, a), (1, b), (2, d)].into();
let mut ka = Keyring::new(suite, a);
ka.enter(1, [1; 32], r1.clone());
let mut kc = Keyring::new(suite, c);
kc.enter(1, [1; 32], r1.clone());
// C's object of epoch 1, sealed before its removal.
let name = TrackName::from_str("0");
let cns = room_ns(&room, &c, "audio");
let pt = Plaintext { payload: b"late".to_vec(), ..Default::default() };
let early = kc.seal(&Outgoing { ns: &cns, name: &name, group: 1, object: 0, priority: 1, plaintext: &pt, immutable: &[], mutable: &[] }).unwrap().unwrap();
// Epoch 2 removes C; epoch 3 adds D at C's old leaf.
ka.enter(2, [2; 32], r2.clone());
kc.removed_at(2);
ka.enter(3, [3; 32], r3.clone());
let mut kd = Keyring::new(suite, d);
kd.enter(3, [3; 32], r3.clone());
let ans = room_ns(&room, &a, "audio");
let a2 = ka.seal(&Outgoing { ns: &ans, name: &name, group: 2, object: 0, priority: 1, plaintext: &pt, immutable: &[], mutable: &[] }).unwrap().unwrap();
fn at<'a>(s: &'a SealedObject, ns: &'a TrackNamespace, name: &'a TrackName, g: u64) -> Incoming<'a> {
Incoming { ns, name, group: g, object: 0, priority: 1, properties: &s.properties, payload: &s.payload }
}
// ka sealed under epoch 3 (its latest); a removed member cannot open it.
assert_eq!(kc.open(&at(&a2, &ans, &name, 2), None), Err(NotOpened::Refused(Refusal::RemovedBeforeEpoch)));
// D opens epoch 3, and nothing before its join.
assert!(kd.open(&at(&a2, &ans, &name, 2), None).is_ok());
assert_eq!(kd.open(&at(&early, &cns, &name, 1), None), Err(NotOpened::Refused(Refusal::EpochNotJoined)));
// A, which applied C's removal, takes nothing more under C's old KID.
assert_eq!(ka.open(&at(&early, &cns, &name, 1), None), Err(NotOpened::Refused(Refusal::SenderRemoved)));
// An unoccupied leaf.
let mut ghost = early.clone();
let sip = immutable_properties(key_id(2, 2).unwrap(), &[]);
ghost.properties = object_properties(&sip, &[]);
assert_eq!(ka.open(&at(&ghost, &cns, &name, 1), None), Err(NotOpened::Refused(Refusal::KeyIdUnknown)));
// An epoch ahead is held, not refused.
let ahead = object_properties(&immutable_properties(key_id(9, 0).unwrap(), &[]), &[]);
let i = Incoming { ns: &ans, name: &name, group: 5, object: 0, priority: 1, properties: &ahead, payload: &a2.payload };
assert_eq!(ka.open(&i, None), Err(NotOpened::Pending(9)));
// Deleted keys: the epoch is no longer joined.
let mut kd2 = kd.clone();
kd2.forget(3);
assert_eq!(kd2.open(&at(&a2, &ans, &name, 2), None), Err(NotOpened::Refused(Refusal::EpochNotJoined)));
}
}

View file

@ -0,0 +1,125 @@
//! SFrame (RFC 9605): the header, the key schedule, and seal and open of an SFrame
//! ciphertext. Secure objects is SFrame "virtualised" onto an MoQ object (secure objects
//! §6.2): the same AEADs and the same shape of key schedule, with the KID and CTR taken
//! from the object rather than carried in a header. This module is RFC 9605 as written,
//! so its Appendix C vectors check the AEADs and the HKDF this crate's sealing shares.
use super::aead::{self, Suite};
/// The compact big-endian form of `v`: its bytes with no leading zero, at least one.
fn compact(v: u64) -> Vec<u8> {
let b = v.to_be_bytes();
let skip = b.iter().take(7).take_while(|x| **x == 0).count();
b[skip..].to_vec()
}
/// The SFrame header of RFC 9605 §4.3 for `kid` and `ctr`.
pub fn encode_header(kid: u64, ctr: u64) -> Vec<u8> {
let mut config = 0u8;
let mut tail = Vec::new();
if kid < 8 {
config |= (kid as u8) << 4;
} else {
let k = compact(kid);
config |= 0x80 | (((k.len() - 1) as u8) << 4);
tail.extend_from_slice(&k);
}
if ctr < 8 {
config |= ctr as u8;
} else {
let c = compact(ctr);
config |= 0x08 | ((c.len() - 1) as u8);
tail.extend_from_slice(&c);
}
let mut out = vec![config];
out.extend_from_slice(&tail);
out
}
/// Read an SFrame header from the front of `b`: `(kid, ctr, header length)`. `None` where
/// `b` ends inside it, or where an extended value is not in its shortest form (RFC 9605
/// §4.3: "the minimum number of bytes required").
pub fn decode_header(b: &[u8]) -> Option<(u64, u64, usize)> {
let config = *b.first()?;
let mut at = 1usize;
let mut field = |extended: bool, bits: u8| -> Option<u64> {
if !extended {
return Some(bits as u64);
}
let n = bits as usize + 1;
let v = b.get(at..at + n)?;
at += n;
if n > 1 && v[0] == 0 {
return None;
}
let x = v.iter().fold(0u64, |a, x| (a << 8) | *x as u64);
if x < 8 {
return None;
}
Some(x)
};
let kid = field(config & 0x80 != 0, (config >> 4) & 0x07)?;
let ctr = field(config & 0x08 != 0, config & 0x07)?;
Some((kid, ctr, at))
}
/// `sframe_key` and `sframe_salt` of RFC 9605 §4.4.2.
pub fn derive_key_salt(suite: Suite, kid: u64, base_key: &[u8]) -> (Vec<u8>, [u8; 12]) {
let secret = suite.hkdf_extract(&[], base_key);
let mut key_label = b"SFrame 1.0 Secret key ".to_vec();
key_label.extend_from_slice(&kid.to_be_bytes());
key_label.extend_from_slice(&suite.id().to_be_bytes());
let mut salt_label = b"SFrame 1.0 Secret salt ".to_vec();
salt_label.extend_from_slice(&kid.to_be_bytes());
salt_label.extend_from_slice(&suite.id().to_be_bytes());
let key = suite.hkdf_expand(&secret, &key_label, suite.nk());
let salt: [u8; 12] = suite.hkdf_expand(&secret, &salt_label, 12).try_into().expect("12 bytes");
(key, salt)
}
fn nonce(salt: &[u8; 12], ctr: u64) -> [u8; 12] {
let mut n = *salt;
for (i, b) in ctr.to_be_bytes().iter().enumerate() {
n[4 + i] ^= b;
}
n
}
/// RFC 9605 §4.4.3 `encrypt`: the header followed by the AEAD output.
pub fn seal(suite: Suite, kid: u64, ctr: u64, base_key: &[u8], metadata: &[u8], pt: &[u8]) -> Vec<u8> {
let (key, salt) = derive_key_salt(suite, kid, base_key);
let header = encode_header(kid, ctr);
let mut aad = header.clone();
aad.extend_from_slice(metadata);
let mut out = header;
out.extend_from_slice(&aead::seal(suite, &key, &nonce(&salt, ctr), &aad, pt));
out
}
/// RFC 9605 §4.4.4 `decrypt`, with the base key of the header's KID given by `key_of`:
/// the plaintext, or `None`.
pub fn open(suite: Suite, metadata: &[u8], ct: &[u8], key_of: impl Fn(u64) -> Option<Vec<u8>>) -> Option<Vec<u8>> {
let (kid, ctr, n) = decode_header(ct)?;
let base = key_of(kid)?;
let (key, salt) = derive_key_salt(suite, kid, &base);
let mut aad = ct[..n].to_vec();
aad.extend_from_slice(metadata);
aead::open(suite, &key, &nonce(&salt, ctr), &aad, &ct[n..])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn headers_round_trip_in_their_shortest_form() {
for (kid, ctr) in [(0u64, 0u64), (7, 7), (8, 0), (0, 8), (0x123, 0x4567), (u64::MAX, u64::MAX), (255, 256)] {
let h = encode_header(kid, ctr);
assert_eq!(decode_header(&h), Some((kid, ctr, h.len())));
}
// An extended value below 8, or with a leading zero byte, is not shortest.
assert_eq!(decode_header(&[0x08, 0x05]), None);
assert_eq!(decode_header(&[0x09, 0x00, 0x10]), None);
assert_eq!(decode_header(&[0x80 | 0x10]), None, "a KID of two bytes with none behind the config byte");
}
}

View file

@ -0,0 +1,147 @@
//! The published test vectors the sealing builds on, read from WAI's corpus
//! (`wai/live-conformance/e2ee/`): RFC 9605 Appendix C, every header, AEAD and SFrame
//! vector; and `draft-ietf-moq-secure-objects-01` Appendix B, every Full Track Name, key
//! derivation, nonce and AAD vector, and its encryption vectors, exact in the GCM suites
//! and, in the counter-mode suites, the difference WAI confidentiality §11.2 states.
#![cfg(feature = "secure-objects")]
use joule_moq::secure_objects::{aad, aead, ctr, derive_key_salt, labels, nonce, serialize_full_track_name, sframe, Suite};
use joule_moq::{TrackName, TrackNamespace};
use serde_json::Value;
const DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../../../wai/live-conformance/e2ee/");
fn load(name: &str) -> Value {
serde_json::from_str(&std::fs::read_to_string(format!("{DIR}{name}")).expect("the corpus file")).expect("JSON")
}
fn h(v: &Value) -> Vec<u8> {
let s = v.as_str().expect("a hex string");
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("hex")).collect()
}
fn n(v: &Value) -> u64 {
v.as_u64().expect("a number")
}
fn suite(v: &Value) -> Suite {
Suite::from_id(n(v) as u16).expect("a registered suite")
}
#[test]
fn rfc9605_appendix_c() {
let c = load("rfc9605.json");
let headers = c["header"].as_array().unwrap();
assert!(headers.len() > 100);
for v in headers {
let (kid, ctr, hdr) = (n(&v["kid"]), n(&v["ctr"]), h(&v["header"]));
assert_eq!(sframe::encode_header(kid, ctr), hdr, "{v}");
assert_eq!(sframe::decode_header(&hdr), Some((kid, ctr, hdr.len())), "{v}");
}
let aeads = c["aead"].as_array().unwrap();
assert_eq!(aeads.len(), 3);
for v in aeads {
let s = suite(&v["cipher_suite"]);
let key = h(&v["key"]);
assert_eq!(&key[..16], h(&v["enc_key"]).as_slice());
assert_eq!(&key[16..], h(&v["auth_key"]).as_slice());
let nonce: [u8; 12] = h(&v["nonce"]).try_into().unwrap();
let ct = aead::seal(s, &key, &nonce, &h(&v["aad"]), &h(&v["pt"]));
assert_eq!(ct, h(&v["ct"]), "{v}");
assert_eq!(aead::open(s, &key, &nonce, &h(&v["aad"]), &ct), Some(h(&v["pt"])));
let mut bad = ct.clone();
*bad.last_mut().unwrap() ^= 1;
assert_eq!(aead::open(s, &key, &nonce, &h(&v["aad"]), &bad), None);
}
let frames = c["sframe"].as_array().unwrap();
assert_eq!(frames.len(), 5, "one SFrame vector per suite");
for v in frames {
let s = suite(&v["cipher_suite"]);
let (kid, ctr) = (n(&v["kid"]), n(&v["ctr"]));
let base = h(&v["base_key"]);
assert_eq!(s.hkdf_extract(&[], &base), h(&v["sframe_secret"]));
let (key, salt) = sframe::derive_key_salt(s, kid, &base);
assert_eq!((key, salt.to_vec()), (h(&v["sframe_key"]), h(&v["sframe_salt"])), "{v}");
let ct = sframe::seal(s, kid, ctr, &base, &h(&v["metadata"]), &h(&v["pt"]));
assert_eq!(ct, h(&v["ct"]), "{v}");
let opened = sframe::open(s, &h(&v["metadata"]), &ct, |k| (k == kid).then(|| base.clone()));
assert_eq!(opened, Some(h(&v["pt"])));
assert_eq!(sframe::open(s, b"other metadata", &ct, |_| Some(base.clone())), None);
}
}
fn names(v: &Value) -> (TrackNamespace, TrackName) {
let ns = TrackNamespace::from_strs(v["namespace_tuples"].as_array().unwrap().iter().map(|t| t.as_str().unwrap()));
(ns, TrackName::from_str(v["track_name"].as_str().unwrap()))
}
#[test]
fn secure_objects_01_appendix_b() {
let c = load("secure-objects-01.json");
for v in c["full_track_name"].as_array().unwrap() {
let (ns, name) = names(v);
assert_eq!(serialize_full_track_name(&ns, &name), h(&v["serialized_full_track_name"]));
}
for v in c["key_derivation"].as_array().unwrap() {
let s = suite(&v["cipher_suite"]);
let (kid, base, ftn) = (n(&v["key_id"]), h(&v["track_base_key"]), h(&v["serialized_full_track_name"]));
assert_eq!(s.hkdf_extract(&[], &base), h(&v["moq_secret"]));
let (kl, sl) = labels(s, kid, &ftn);
assert_eq!((kl, sl), (h(&v["moq_key_label"]), h(&v["moq_salt_label"])));
let ks = derive_key_salt(s, kid, &base, &ftn);
assert_eq!((ks.key, ks.salt.to_vec()), (h(&v["moq_key"]), h(&v["moq_salt"])), "{v}");
}
// B.3's AAD holds the Key ID property, which the vector's
// `serialized_immutable_properties` leaves out, in Appendix B's form: a 16-bit type 0x0002,
// a length and the value (confidentiality §11.3).
for v in c["nonce_aad"].as_array().unwrap() {
let (g, o, p, kid) = (n(&v["group_id"]), n(&v["object_id"]), n(&v["publisher_priority"]) as u8, n(&v["key_id"]));
let c = ctr(g, o).unwrap();
assert_eq!(c.to_vec(), h(&v["ctr"]));
let salt: [u8; 12] = h(&v["moq_salt"]).try_into().unwrap();
assert_eq!(nonce(&salt, &c).to_vec(), h(&v["nonce"]));
let mut sip = vec![0x00, 0x02, 0x01, kid as u8];
sip.extend_from_slice(&h(&v["serialized_immutable_properties"]));
assert_eq!(aad(g, o, p, &sip).unwrap(), h(&v["aad"]));
}
let mut gcm = 0;
for v in c["encryption"].as_array().unwrap() {
let s = suite(&v["cipher_suite"]);
let (ns, name) = names(v);
let ftn = serialize_full_track_name(&ns, &name);
assert_eq!(ftn, h(&v["serialized_full_track_name"]));
let ks = derive_key_salt(s, n(&v["key_id"]), &h(&v["track_base_key"]), &ftn);
assert_eq!((ks.key.clone(), ks.salt.to_vec()), (h(&v["moq_key"]), h(&v["moq_salt"])));
let (g, o, p) = (n(&v["group_id"]), n(&v["object_id"]), n(&v["publisher_priority"]) as u8);
let nonce_ = nonce(&ks.salt, &ctr(g, o).unwrap());
assert_eq!(nonce_.to_vec(), h(&v["nonce"]));
let payload = h(&v["original_payload"]);
let mut pt = vec![payload.len() as u8];
pt.extend_from_slice(&payload);
pt.extend_from_slice(&h(&v["encrypted_properties_list"]));
assert_eq!(pt, h(&v["plaintext"]));
let aad_ = h(&v["aad"]);
assert_eq!(aad_[..13], aad(g, o, p, &[]).unwrap()[..], "the AAD's head: Group ID, Object ID, priority");
let expected = h(&v["ciphertext"]);
let sealed = aead::seal(s, &ks.key, &nonce_, &aad_, &pt);
match s {
Suite::AesGcm128Sha256 | Suite::AesGcm256Sha512 => {
assert_eq!(sealed, expected, "{v}");
assert_eq!(aead::open(s, &ks.key, &nonce_, &aad_, &expected), Some(pt));
gcm += 1;
}
_ => {
// Counter mode: the encryption is RFC 9605's, the tag is not (§11.2).
let nt = s.nt();
let body = expected.len() - nt;
assert_eq!(&sealed[..body], &expected[..body], "the counter-mode encryption is RFC 9605's");
assert_ne!(&sealed[body..], &expected[body..], "RFC 9605 §4.5.1's tag differs from the vector's");
let (enc_ct, auth) = (&expected[..body], &ks.key[16..]);
let alt = aead::hmac_sha256(auth, &[&aad_, &nonce_, enc_ct]);
assert_eq!(&alt[..nt], &expected[body..], "the vector's tag is HMAC(auth_key, aad ‖ nonce ‖ ct)");
assert_eq!(aead::ctr_tag(s, auth, &nonce_, &aad_, enc_ct), sealed[body..].to_vec());
}
}
}
assert_eq!(gcm, 2);
}

View file

@ -41,8 +41,8 @@ Every registered string has one kind.
`wai.asset.provenance`, the quantum-operations receipts, the delivery records
`wai.video.manifest` and its siblings; the live records `wai.live.events`,
`wai.live.room`, `wai.capture.take`, `wai.live.compose`, `wai.live.recording`,
`wai.live.metering`, `wai.live.steering` and `wai.live.moderation`; timed text,
`wai.text.timed`).
`wai.live.metering`, `wai.live.steering`, `wai.live.moderation` and `wai.live.e2ee`;
timed text, `wai.text.timed`).
It names no payload format.
A derivation or record string is not a payload capability, so it is not
@ -197,6 +197,7 @@ and a role are fixed once registered.
| `wai.image.jxl` | payload | registered | image | standalone | royalty-free | standard-tolerance | recommended | §5 Recommended modern set |
| `wai.image.png` | payload | registered | image | standalone | royalty-free | standard-defined | floor | §5 Mandatory floor |
| `wai.live.compose` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.live.e2ee` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.live.events` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.live.metering` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.live.moderation` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
@ -298,10 +299,12 @@ and a role are fixed once registered.
| `/.well-known/wai-keys` | well-known | defined | jwp-receipts §7.1 |
| `GRANT_CHALLENGE` | setup-option | proposed | live-rooms §10.5.2 |
| `GRANT_PROOF` | setup-option | proposed | live-rooms §10.5.2 |
| `SECURE_OBJECT_KEY_ID` | catalog | defined | confidentiality §4.3 |
| `WAI-Room-Challenge` | http-header | proposed | live-rooms §10.5.3 |
| `WAI-Room-Proof` | http-header | proposed | live-rooms §10.5.3 |
| `WAI1` | magic | defined | SPEC §2 |
| `WAI2` | magic | defined | SPEC §7.1 |
| `WAI_INNER_DIGEST` | catalog | defined | confidentiality §4.4 |
| `WAI_RECEIPT` | catalog | defined | moq-streaming-format §5 |
| `WCD1` | magic | defined | live-compose §3.1 |
| `WCL1` | magic | defined | live-recording §5.1 |
@ -317,6 +320,7 @@ and a role are fixed once registered.
| `WMR1` | magic | defined | live-metering §4.1 |
| `WMS1` | magic | defined | live-moderation §4.2 |
| `WRD1` | magic | defined | live-rooms §3.1 |
| `WRE1` | magic | defined | confidentiality §3.1 |
| `WRG1` | magic | defined | live-rooms §4.1 |
| `WRM1` | magic | defined | live-recording §4.1 |
| `WRP1` | magic | defined | live-rooms §10.5.1 |

View file

@ -259,7 +259,7 @@ or sends the descriptor.
## Protected content
End-to-end encryption at the application layer (`mls-sframe`, SPEC §6) keeps content
End-to-end encryption at the application layer (a room's sealed objects, SPEC §6 and [confidentiality](extensions/confidentiality.md)) keeps content
from relays and from anyone outside the group, not from a member's device: the
decrypted payload and the decoded output are in the application's memory. It is not a
protected-path robustness level ([SPEC](SPEC.md) §6.5).

View file

@ -593,8 +593,8 @@ kind:
delivery records `wai.video.manifest`, `wai.video.ssai`, `wai.video.keys`,
`wai.video.package` and `wai.video.channel`; and the live records `wai.live.events`,
`wai.live.room`, `wai.capture.take`, `wai.live.compose`, `wai.live.recording`,
`wai.live.metering`, `wai.live.steering` and `wai.live.moderation`, and timed text,
`wai.text.timed`), which names no payload format.
`wai.live.metering`, `wai.live.steering`, `wai.live.moderation` and `wai.live.e2ee`, and
timed text, `wai.text.timed`), which names no payload format.
Only payload, companion and refinement capabilities have a media class, a
fidelity tier, a replicate class and a role. A derivation or record string is not a payload
@ -768,8 +768,9 @@ receipt families that sealed a figure before the class existed carry it as an
optional signed label that leaves every issued receipt's bytes unchanged. A
`joules_micro` of `0` is the unmetered marker, never a figure of zero joules.
See [`extensions/confidentiality.md`](extensions/confidentiality.md) for the
private-channel sub-spec: MLS (RFC 9420) group keys + SFrame (RFC 9605)
object encryption, the byte-level framing behind §6.
end-to-end encrypted rooms behind §6: objects sealed as MoQ secure objects
(`draft-ietf-moq-secure-objects-01`, SFrame RFC 9605 on an MoQ object) under keys from
one MLS group (RFC 9420) per room.
See [`extensions/reconstruction-binding.md`](extensions/reconstruction-binding.md)
for the reproducible-reconstruction binding: a C2PA / JPEG-Trust hard binding
over the *deterministically decoded pixels* of a neural-coded asset (SHA-256 over
@ -999,6 +1000,24 @@ format of the database the European Commission keeps under Regulation (EU) 2022/
(Article 24(5)), with no personal data:
[`extensions/live-moderation.md`](extensions/live-moderation.md).
And for the **confidentiality** of a room — `wai.live.e2ee`: the room authority signs a
confidentiality record, `WRE1`, stating that a room is end-to-end encrypted, with which
secure-objects and MLS ciphersuites, and that it is never recorded. Every object of the
room's media tracks is sealed by its publisher as an MoQ secure object
(`draft-ietf-moq-secure-objects-01`), its Key ID among the object's Immutable Properties,
so relays route, cache, meter and receipt ciphertext alone; the keys come from one MLS
group (RFC 9420) per room, whose credentials are the principals' Ed25519 room keys, whose
one external sender is the room's clock, proposing an Add for each join under a grant that
may receive or publish and a Remove for each leave, revocation or expiry, and whose
committer every member computes alike, the member at the lowest leaf the commit does not
remove. Each epoch's application secret is an MLS export, from which each publisher's track
base key and KID (the epoch and its leaf index) follow, so a receiver selects the one key
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:
[`extensions/confidentiality.md`](extensions/confidentiality.md).
### Payloads of the WAI-native integer codecs
This revision pins a payload format to each WAI-native integer codec whose row named
@ -1415,33 +1434,36 @@ two lanes protected content travels in, and what a WAI record covers in each.
A manifest MAY carry a `security` object:
```json
"security": { "encryption": "mls-sframe", "kid": 0 }
"security": { "encryption": "<scheme>", "kid": 0 }
```
- `security.encryption` — encryption scheme id. Absent, `null`, or
`"none"` ⇒ the payload is plaintext (the default; §1–§5 unchanged).
`"mls-sframe"` ⇒ the payload is an SFrame (RFC 9605) ciphertext object
whose group key is agreed by MLS (RFC 9420), following the construction
of `draft-jennings-moq-e2ee-mls`, an individual Internet-Draft that has
since expired ([confidentiality](extensions/confidentiality.md) §1).
- `security.kid` — the SFrame key id selecting which sender/epoch key
opens the object. It is also carried in the SFrame header; the
manifest copy lets a multiplexer route without parsing the payload.
`"mls-sframe"` named the construction of `draft-jennings-moq-e2ee-mls`, an
individual Internet-Draft that has expired, under which an envelope's payload alone
was sealed. That construction is withdrawn: no revision defines it, and a sink
refuses an envelope that names it as an unsupported scheme (§6.4 step 2).
- `security.kid` — the key id of the scheme named.
`media`, `intent`, `conditioning.kind`, and
`model_requirement.capability` continue to describe the **plaintext**
codec. They are NOT encrypted and remain visible for routing —
confidentiality protects the *content*, not the *shape*.
In a room, confidentiality is at the MoQ object, not in the manifest
([confidentiality](extensions/confidentiality.md) §4): the whole object payload, a WAI
envelope or a media frame, is sealed, so an envelope travels inside the ciphertext and
carries no `security` member, and once opened it is plaintext. What a relay routes by
stays in the clear: the track's name, the object's Group ID, Object ID, priority and
Immutable Properties (the Key ID among them), and each publisher's catalog track, whose
codec, dimensions and rates describe the **plaintext** — confidentiality protects the
*content*, not the *shape*. This revision registers no scheme that seals an envelope's
payload on its own.
### 6.2 Keys are a requirement of existence
Exactly as the model behind a neural capability is never transported
(§1), the **group key is never transported in the WAI file**. Group
membership is established out of band via MLS; each member derives the
32-byte SFrame base key from the group's exporter secret under the label
`"moq-jwp sframe base key"`. A sink that is not a group member cannot
derive the key and the file is **inert at that sink** — the
confidentiality analog of a missing capability.
(§1), the **group key is never transported**. A room's MLS group is formed and
changed by the room's lifecycle; each member exports each epoch's application secret
(`MLS-Exporter("wai live e2ee", room_id, 32)`) and derives every sender's keys from it
([confidentiality](extensions/confidentiality.md) §5.7). A sink that is not a member of
the epoch holds no key, and the object is **inert at that sink** — the confidentiality
analog of a missing capability.
This is the content key. Checking a receipt needs no content key, only
the signer's verifying key, which the receipt itself carries
@ -1465,8 +1487,8 @@ are the publisher's statement, unsigned beside the signed bytes; such an
object's figure is signed only where an `energy` claim restates it
(jwp-receipts §2.4.5). A legacy group payload signs the total, but no class,
coverage or parent link.
Content secrecy (SFrame/MLS) and delivery attestation (JWP) are independent
layers over the same bytes and compose: a relay forwards the exact sealed
Content secrecy (secure objects keyed by MLS) and delivery attestation (JWP) are
independent layers over the same bytes and compose: a relay forwards the exact sealed
bytes and their receipts unaltered, signs nothing in the publisher's name
(jwp-receipts §4.3), and never learns what the bytes say. Checking the
attestation needs the publisher's verifying key, which the receipt carries,
@ -1483,23 +1505,22 @@ A confidentiality-aware sink MUST, before running §4:
clear "no key / unsupported encryption" error. It MUST NOT feed
ciphertext to a codec. (The reference dispatchers in `wai-rs` implement
no scheme and report `unsupported_encryption`, for a `WAI2` envelope
against every entry.)
3. Else recover the plaintext payload via the named scheme (for
`mls-sframe`: SFrame `open` under the member's base key), then run §4
against every entry; this revision defines no scheme of the manifest, §6.1.)
3. Else recover the plaintext payload via the named scheme, then run §4
on the recovered plaintext.
A sink that does not implement confidentiality at all MUST refuse an
envelope whose manifest carries a non-`none` `security.encryption`
cleanly, rather than mis-decode ciphertext.
Byte-level SFrame framing, the SFrame key schedule, and the MLS binding
(including per-sender keying for multi-sender channels) are specified in
[`extensions/confidentiality.md`](extensions/confidentiality.md). Its
implementation is a companion implementation, outside this tree and under
its own licence: the `moq-sframe` (SFrame) and `joule-comm-mls` (MLS
exporter → base key) crates, with browser bindings in
`joule-comm-mls-wasm`; `Track::e2e_encrypted` is the switch that selects
this path. Live: https://wai.transaction.science/private-channel
A receiver of a room's sealed objects opens each object first, through the checks of
[confidentiality](extensions/confidentiality.md) §6, and only then reads the envelope
inside and runs §4 on it; an object that does not open is never fed to a decoder. The
sealing, the key schedule, the MLS binding (per-sender keys in rooms where several
principals publish, membership following the room's grants and lifecycle) and the
receiving checks are specified there. The reference implementation is open and in this
tree: the `secure_objects` module of `joule-moq` (feature `secure-objects`) and the
`joule-moq-mls` crate.
### 6.5 Protected content: two lanes
@ -1532,9 +1553,9 @@ and decode happen inside that path.
cannot read its output, so such synthesis has no tier and no output digest, and a
record about it states at most which capability the sink declared it ran.
**Lane B: end-to-end encryption at the application layer.** The payload is sealed per
object under a group key, `mls-sframe` (§6.1–§6.4,
[confidentiality](extensions/confidentiality.md) §3–§5). A member's application opens
**Lane B: end-to-end encryption at the application layer.** Each object of a room's media
tracks is sealed under its sender's key from the room's MLS group (§6.1–§6.4,
[confidentiality](extensions/confidentiality.md) §4–§6). A member's application opens
it and runs §4 on the plaintext, so any decoder a sink has can decode it: native code,
WebAssembly, or a neural decoder.

View file

@ -1,257 +1,784 @@
# WAI Extension: Confidentiality (MLS + SFrame)
# WAI Extension: Confidentiality (end-to-end encrypted rooms)
> Status: Draft. Sub-spec of WAI v1.0. Its implementation is a companion
> implementation, outside this tree and under its own licence: the
> `moq-sframe` (SFrame object encryption) and `joule-comm-mls` (MLS group
> key agreement) crates, with browser bindings in `joule-comm-mls-wasm`.
> Live demonstration: https://wai.transaction.science/private-channel
> Status: Draft. Sub-spec of WAI v1.0, and Lane B of SPEC §6.5: end-to-end encryption at the
> application layer. Registers the record `wai.live.e2ee`: the room authority's signed
> statement, `WRE1`, that a room of [live-rooms](live-rooms.md) is end-to-end encrypted and
> with which ciphersuites. In such a room every object of a media track is sealed by its
> publisher as an **MoQ secure object** (§4), so a relay routes, caches, meters and receipts
> ciphertext alone, and only the room's current members open it. The keys come from an MLS
> group (RFC 9420) whose membership follows the room's grants and lifecycle (§5): a principal
> that joins under a grant that may receive or publish is added, a principal that leaves,
> whose grant is revoked or whose grant ends is removed, and the next epoch's keys exclude it.
> The room's clock proposes every change and a member commits it, by a rule every member
> computes alike (§5.5). A recording of such a room is refused (§9). The reference
> implementation is open and in this tree: the `secure_objects` module of `joule-moq`
> (feature `secure-objects`) seals and opens objects and holds a receiver's keys and checks;
> the `joule-moq-mls` crate binds a room to an MLS group and exports each epoch's
> per-sender keys. 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.
>
> This is Lane B of SPEC §6.5: end-to-end encryption at the application layer. The
> sink that holds the key decodes the plaintext, so the tier and the output digests it
> states hold as they do for plaintext; staged-measurement counts no encrypted object,
> and a verifier without the key checks none of a claim's rules that need a payload
> (SPEC §6.5). It is not a protected-path robustness level.
Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY**, **REQUIRED**,
**OPTIONAL** are RFC 2119 / RFC 8174.
---
> Documents read for this revision, each pinned to the revision named:
> `draft-ietf-moq-secure-objects-01` (6 July 2026, "secure objects"),
> `draft-jennings-moq-mocha-mls-keying-00` (6 July 2026, "the keying draft") and
> `draft-ietf-moq-transport-22` (1 October 2026, "MOQT"), all Internet-Drafts and none past
> Working Group Last Call; RFC 9605 (SFrame), RFC 9420 (MLS) and RFC 9750 (the MLS
> architecture); with RFC 5869 (HKDF), RFC 5116, NIST SP 800-38D (GCM) and FIPS 197 (AES)
> for the primitives. A section number of one of those documents in this text is that
> revision's. Where the documents disagree with one another, §11 says which reading this
> extension takes and why.
## 1. Scope and model
This extension makes a WAI payload **private**: only members of a group
may recover the content, while any relay or multiplexer that carries the
payload can still route, meter, and attest the bytes **without reading
them**. It is the `e2e_encrypted` path — the confidential counterpart to
the public floor, where a shared world or a broadcast film is plaintext
by design.
A room ([live-rooms](live-rooms.md)) carries each publisher's media on tracks named by the
room id, the publisher and the media kind (live-rooms §6.1), through relays that the room's
participants do not trust with the content. This extension makes those media tracks
private: the publisher seals each object before it leaves the device, every relay forwards
the sealed bytes as they are, and a member opens them after they arrive.
The construction is the `draft-jennings-moq-e2ee-mls` binding *(2026-09-30:
draft-jennings-moq-e2ee-mls is an individual Internet-Draft that has expired; the
MoQ working group's draft-ietf-moq-secure-objects is related work)*:
In scope:
- **MLS** (RFC 9420) — the group agrees a shared secret out of band and
every member derives the same **SFrame base key** from it. Membership,
not a transported key, is the requirement of existence.
- **SFrame** (RFC 9605) — each object payload is sealed under that base
key with an AEAD, so the wire carries only ciphertext.
- the room's confidentiality record (§3);
- object sealing, exactly as secure objects specifies it, on the objects of MOQT -22 (§4);
- keying: one MLS group per room, its credentials bound to the principals' room keys, its
membership driven by the room's lifecycle, the rule that says who commits, and each
epoch's per-sender keys (§5);
- what a receiver checks, in order, and the reason for each refusal (§6);
- rooms where several principals publish at once (§7);
- receipts and metering over ciphertext, and what a relay can and cannot prove (§8);
- recording and clips (§9);
- decrypt-then-dispatch and relay transparency (§10).
Confidentiality is **orthogonal to the capability** (WAI SPEC.md §5).
SFrame wraps the payload; the capability still names how to decode the
*plaintext* once recovered. A sink decrypts first, then runs capability
dispatch (SPEC.md §4) on the plaintext.
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:
In scope: the SFrame object format, its key schedule, the MLS exporter
binding, the decrypt-then-dispatch integration, and relay transparency.
- each publisher's **catalog track**, which states codecs and dimensions: the shape of what
is sent, never its content;
- each media track's **receipts track**, whose receipts commit to the sealed bytes (§8);
- every **events track** (live-events §8.1). The clock's lifecycle events must be read by
the clock itself, which is not a member (§5.3), and by every relay that admits grants; a
participant's events (chat, reactions, markers) are held to its grant's policy by relays
and by any room verifier (live-rooms §9), and sealing them would move those checks to the
members alone. Events stay signed in the clear in this revision; a participant that needs
a private message sends it on a media track.
Out of scope:
- **A transport.** Sealed objects ride in the carrier protocol exactly
as plaintext objects do; this extension does not define how they move.
- **MLS itself.** Group formation, commits, welcomes, and epoch
advancement are RFC 9420; this extension only names the ciphersuite
and the exporter call that yields the SFrame base key.
- **The receipt chain.** JWP attestation is
[`jwp-receipts.md`](jwp-receipts.md); §6 states only how the two
compose.
- **A transport.** Sealed objects ride MOQT exactly as plaintext objects do, through the
same relays, with no relay change (§10).
- **MLS itself.** Group state, the ratchet tree, commits, welcomes and the key schedule are
RFC 9420's; this extension fixes the ciphersuite, the credentials, the external sender,
who commits and what a commit holds, and the exporter call (§5).
- **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.
- **Hardware-protected content** (SPEC §6.5, Lane A). This extension is Lane B: the
plaintext is in the member's application memory (§12).
---
## 2. Terms
## 2. Ciphersuites
- **Room**, **room id**, **room authority**, **clock**, **principal**, **grant**,
**grant in effect**, **present**: as in live-rooms §2 and §9.3.
- **Keyed grant**: a grant that may subscribe (`subscribe` 1) or may publish any media kind
(`publish` not 0). A principal present under a keyed grant needs the room's keys: to open
what it receives, or to seal what it publishes.
- **Member**: a principal that holds a leaf of the room's MLS group in an epoch.
- **Epoch**, **leaf index**, **commit**, **proposal**, **Welcome**, **KeyPackage**: as in
RFC 9420. The **roster** of an epoch maps each occupied leaf index to its member.
- **Committer**: the member that commits the end of an epoch (§5.5).
- **Sealed object**: an object of a media track whose payload is a secure-objects
ciphertext and whose Immutable Properties carry its Key ID (§4).
- **KID**: the Key ID of a sealed object (§5.7).
- **vi64**: the variable-length integer of MOQT §8.1. Where this text writes one, it is the
shortest encoding of its value; where it reads one, any encoding MOQT allows is read.
- **SFrame:** cipher suite `0x0006`, `AES_256_GCM_SHA512_128`
(RFC 9605 §4.5): AEAD = AES-256-GCM (`Nk = 32`, `Nn = 12`, `Nt = 16`),
KDF = HKDF-SHA-512. The two-byte suite id `0x00 0x06` is a constant of
the key schedule (§3.2), not carried on the wire.
- **MLS:** ciphersuite `0x0001`,
`MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519` (RFC 9420 §17.1) — the
mandatory-to-implement baseline. Credentials are BasicCredential over
Ed25519.
## 3. The room's confidentiality record (`wai.live.e2ee`)
A sink MUST reject an envelope whose declared `security.encryption`
names a scheme or suite it does not implement (SPEC.md §6.4), rather
than attempt to decode ciphertext.
A room is end-to-end encrypted when its authority signs a **confidentiality record** for
it. The record names the room and the two ciphersuites; members and publishers seal and open
under it, and a verifier is given it beside the room's descriptor.
---
### 3.1 Layout
## 3. SFrame object format
Integers are little-endian, as in the records of live-rooms.
A sealed payload is `header ‖ AEAD.Seal(...)`. The manifest's payload
bytes ARE this sealed object; nothing else wraps it.
| field | size | meaning |
|---|---|---|
| `magic` | 4 | `WRE1` (ASCII) |
| `version` | 1 | 1 in this revision |
| `room` | 32 | the room id (live-rooms §3.2) |
| `suite` | 2 | u16: the secure-objects ciphersuite (secure objects §7.2) every sealed object of the room uses |
| `mls_suite` | 2 | u16: the MLS ciphersuite (RFC 9420 §17.1) of the room's group |
| `sig` | 64 | §3.2 |
### 3.1 Header (RFC 9605 §4.3)
### 3.2 Signature
The first byte is:
The **signature** is Ed25519 by the descriptor's `authority` over the 18 bytes
`wai:live-room-e2ee` (ASCII) and 0x01, followed by the record from `magic` through
`mls_suite`. It is verified strictly, as [SPEC](../SPEC.md) §7 *Signatures* requires: a key
that fails step 1 or 2 is `bad_key`, and a signature that fails a later step is
`bad_signature`.
### 3.3 Checking the record
A verifier is given the record, the room id, the descriptor's `authority` and the
descriptor's recording `mode` (live-rooms §3.7), and refuses at the first of these that
holds:
1. Fewer than 4 bytes: `truncated`. A magic other than `WRE1`: `malformed`. No byte after
the magic: `truncated`. A version other than 1: `unknown_version`. Fewer than 105 bytes:
`truncated`. More: `malformed`.
2. `room` is not the room id: `wrong_room`.
3. `suite` is not `0x0004` (`AES_128_GCM_SHA256_128`) or `0x0005`
(`AES_256_GCM_SHA512_128`): `suite_unsupported`. Secure objects makes `0x0004`
mandatory to implement; the counter-mode suites are not used in rooms in this revision
(§11.2).
4. `mls_suite` is not `0x0001` (`MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519`):
`mls_suite_unsupported`. It is the suite whose signatures are Ed25519, the scheme of the
principals' room keys (§5.2).
5. The descriptor's recording `mode` is not 0: `recording_refused` (§9).
6. The signature (§3.2): `bad_key` or `bad_signature`.
A room has at most one confidentiality record: a room id is the digest of its descriptor,
and a room whose confidentiality changed would be a new room.
## 4. Object sealing
Sealing is secure objects' construction, applied to the objects of a room's media tracks
as MOQT -22 carries them. Nothing below changes a cryptographic step of secure objects;
§4.3 and §4.4 say how its fields sit on a -22 object, and §11 where the documents leave a
choice.
### 4.1 What is protected
| protection | fields |
|---|---|
| hop by hop only (TLS of each MOQT session) | Track Alias; the object's mutable properties, those outside Immutable Properties |
| end to end, authenticated | the Full Track Name; Group ID; Object ID; Publisher Priority; the Immutable Properties, the Key ID among them |
| end to end, encrypted and authenticated | the original payload; the Encrypted Properties List |
This is secure objects' Table 1. A relay reads everything in the first two rows, which is
all it needs to route, cache, prioritise and meter (MOQT §8.4, §10.7); it can change only
the first row without the receiver noticing.
### 4.2 The Serialized Full Track Name
The Serialized Full Track Name is the object's Track Namespace as MOQT §8.7 encodes it, a
vi64 count of fields and each field as a vi64 length and its bytes, followed by its Track
Name as a vi64 length and its bytes (secure objects §3.3.1). In a room the fields are those
of live-rooms §6.1: for `<room>-<principal>-video--1`, the count 3, the 64 hexadecimal
digits of the room id, the principal's 64 digits, `video`, then the Track Name `1`.
### 4.3 The Key ID property
The Key ID is an Object Property, `SECURE_OBJECT_KEY_ID`, whose value is a vi64, the KID
(§5.7), carried inside the object's Immutable Properties (MOQT §10.7), exactly once (secure
objects §4.1). Its type in this revision is **`0x3A4A`**, from the range MOQT §8.4 sets aside for applications, and not
the `0x2` secure objects §7.1 asks for: MOQT -22 §16.8 has given `0x02` to
OBJECT_DELIVERY_TIMEOUT, so a -22 relay that read a Key ID at `0x2` would hold it for a
delivery timeout of that many milliseconds (§11.1). The type is even, so the value is one
vi64 (MOQT §8.3). A sealed object carries no Key ID property outside its Immutable
Properties.
The **Serialized Immutable Properties** that secure objects §3.5 puts in the AAD are the
value of the object's Immutable Properties property: its Key-Value-Pairs exactly as the
object carries them, which MOQT §10.7 forbids anyone after the publisher to re-encode. A
publisher writes them in the order MOQT §8.3 requires, each type the delta from the one
before, each vi64 the shortest.
The object receipt properties of [moq-streaming-format](moq-streaming-format.md) §5
(`WAI_RECEIPT`, `JWP_OBJECT_RECEIPT`) commit to the sealed bytes, which depend on the
Immutable Properties through the AAD; a sealed object therefore carries neither inside its
Immutable Properties. A sealed track's receipts ride its receipts track (live-rooms §6.1;
§8).
### 4.4 The plaintext
The AEAD's plaintext is the original payload and, optionally, the Encrypted Properties List
(secure objects §3.3.2):
```
bit 7 6 5 4 3 2 1 0
+---+-------+---+-------+
| R | LEN | X | KID/ |
|=0 | (3) | | KLEN |
+---+-------+---+-------+
pt = vi64(len(payload)) ‖ payload ‖ [ list ]
list = 0x000A (2 bytes) ‖ vi64(len(entries)) ‖ entries
entry = type (2 bytes, not 0x0032) ‖ vi64(len(value)) ‖ value
padding = 0x0032 (2 bytes) ‖ length (2 bytes) ‖ length zero bytes
```
- `R` (1 bit) — reserved, MUST be 0.
- `LEN` (3 bits) — `ctr_len − 1`; the counter is `LEN+1` bytes,
big-endian, minimal (no leading zeros; a zero counter is one `0x00`).
- `X` (1 bit) — key-id form. `X = 0`: the key id is the low 3 bits of
byte 0 (values 0–7). `X = 1`: the low 3 bits are `KLEN = kid_len − 1`
and the `kid_len`-byte big-endian minimal key id follows byte 0.
- The counter bytes follow the key id.
Types and the padding length are big-endian. The list's form is secure objects §3.3.3
step 4 and §4.2, an entry's form and the padding's are the forms of secure objects'
Appendix B and §4.3 (§11.3). The **original payload** is what the object would carry in a
room without this extension: one WAI envelope ([moq-streaming-format](moq-streaming-format.md)
§3) or one media frame.
The header is therefore `1 + (X ? kid_len : 0) + ctr_len` bytes and is
self-delimiting: `(kid, ctr)` and the header length are recoverable from
the payload alone.
This extension defines one entry:
### 3.2 Key schedule (RFC 9605 §4.4.3)
- **`WAI_INNER_DIGEST`** (type `0x3A4B`): the 32-byte BLAKE3 hash of the original payload,
the content hash [jwp-receipts](jwp-receipts.md) §2.1 computes over an object's payload.
It is the hash a receipt of the object would carry in the clear, sealed with the object, so
a member checks the plaintext against a digest the publisher committed to (§8.2) and
plaintext-level records attach to it. A publisher **SHOULD** carry it on every sealed
object of a video or screen track and **MAY** leave it out where its 35 bytes weigh on a
small object, as a frame of speech is.
From the 32-byte base key (§4), with `suite = 0x00 0x06`:
Padding (secure objects §4.3) is the last entry when present; a receiver ignores its bytes.
A receiver reads the plaintext so, and refuses with `plaintext_malformed` where it does not
read: a vi64 length and that many bytes of payload; if nothing remains, no list; otherwise
2 bytes that are `0x000A`, a vi64 length that is exactly the number of bytes left, and
entries that fill them, each read by its form, padding only as the last, at most one
`WAI_INNER_DIGEST`, of exactly 32 bytes. An entry of another type is skipped.
### 4.5 Key and salt
For the object's KID, the base key of its track (§5.7) and its Serialized Full Track Name,
with the suite's hash (`SHA-256` for `0x0004`, `SHA-512` for `0x0005`), as secure objects
§3.7 derives them:
```
secret = HKDF-Extract(salt = "", IKM = base_key) ; HMAC-SHA-512
key = HKDF-Expand(secret, "SFrame 1.0 Secret key " ‖ suite, Nk=32)
salt = HKDF-Expand(secret, "SFrame 1.0 Secret salt " ‖ suite, Nn=12)
moq_secret = HKDF-Extract("", track_base_key)
moq_key = HKDF-Expand(moq_secret, "MOQ 1.0 Secure Objects Secret key " ‖ ftn ‖ suite ‖ kid, Nk)
moq_salt = HKDF-Expand(moq_secret, "MOQ 1.0 Secret salt " ‖ ftn ‖ suite ‖ kid, Nn)
```
The label strings include their trailing space, immediately followed by
the two suite bytes.
`suite` is the record's `suite` as 2 bytes big-endian, `kid` the KID as 8 bytes big-endian,
and the label strings end in the space shown.
### 3.3 Nonce (RFC 9605 §4.4.4)
### 4.6 Nonce
The per-object nonce is `salt XOR ctr`, where `ctr` is the 64-bit
counter written big-endian and right-aligned into the 12-byte nonce
(the low 8 bytes are XORed; the high 4 bytes are `salt` unchanged).
`CTR` is the Group ID as 8 bytes big-endian followed by the Object ID as 4 bytes big-endian,
and the nonce is `moq_salt XOR CTR` (secure objects §3.6). An Object ID above 2^32 − 1 has
no nonce: such an object is not sealed, and a receiver refuses it `object_id_too_large`. A
publisher of a sealed track numbers its objects within that bound.
### 3.4 Seal / open
- **Seal:** `AES-256-GCM(key, nonce, plaintext, aad = header)` →
`ct ‖ tag`; the object is `header ‖ ct ‖ tag`. The header is the AEAD
associated data, so any edit to `(kid, ctr)` fails authentication.
- **Open:** parse the header, derive `(key, salt)`, recompute the nonce
from the parsed `ctr`, and AEAD-open with the header as AAD. A wrong
key, a tampered header, or a tampered ciphertext/tag MUST fail with a
single indistinguishable authentication error.
The sender MUST NOT reuse a `(base_key, ctr)` pair — GCM nonce reuse is
catastrophic. Counters are per base key and strictly increasing.
---
## 4. MLS binding — deriving the base key
Every group member derives the SFrame base key from the MLS group's
exporter secret (RFC 9420 §8.5):
### 4.7 AAD
```
base_key = MLS-Exporter(label = "moq-jwp sframe base key",
context,
length = 32)
aad = Group ID (8 bytes) ‖ Object ID (4 bytes) ‖ Publisher Priority (1 byte) ‖ Serialized Immutable Properties
```
- All members at the same MLS **epoch** derive the same key; a non-member
has no group state and cannot derive it. Advancing the epoch (any MLS
commit — add, remove, update) rotates the base key, giving the channel
MLS's forward secrecy and post-compromise security for free.
- **Single-sender channel:** `context = ""`. One publisher, monotonic
counter, no `(key, ctr)` collision. This is the profile the reference
`sframe_base_key()` and the live private-channel demo implement.
- **Multi-sender channel:** a sender MUST derive a per-sender base key by
binding its identity into the exporter — `context = KID` (or the
member's MLS leaf index) — so two senders never share a `(key, ctr)`
space. The SFrame `kid` then selects the sender whose base key opens
the object. A multi-sender sink MUST use per-sender derivation; it MUST
NOT seal two senders under one base key.
big-endian (secure objects §3.5). The Publisher Priority is the object's as its publisher
sent it; a publisher of a sealed track states it in every subgroup header and datagram, so
it never falls back to a track default a relay could hold differently (MOQT §10.4).
The SFrame `kid` (§3.1) names *which* base key opens an object; the MLS
epoch names *which generation* of that key. A sink resolves both before
`open`.
### 4.8 Ciphersuites
---
| `suite` | name | AEAD | hash | Nk | Nn | Nt |
|---|---|---|---|---|---|---|
| `0x0004` | `AES_128_GCM_SHA256_128` | AES-128-GCM | SHA-256 | 16 | 12 | 16 |
| `0x0005` | `AES_256_GCM_SHA512_128` | AES-256-GCM | SHA-512 | 32 | 12 | 16 |
## 5. Decrypt-then-dispatch
GCM is SP 800-38D's with a 96-bit nonce and a 128-bit tag, the tag following the ciphertext
(RFC 5116). The reference implements RFC 9605's counter-mode suites as well (§11.2), and
checks them against RFC 9605's vectors; a room does not use them in this revision.
This refines SPEC.md §6.4. Before capability dispatch (SPEC.md §4) a
sink MUST:
### 4.9 Seal
1. If the manifest has no `security` object, or `security.encryption` is
`null` / `"none"`, dispatch the payload unchanged.
2. Else, for `"mls-sframe"`: if the sink is not a member of the group
(holds no MLS state) or holds no base key for `security.kid`, the
file is **inert at this sink** — return a clear "no key" error. The
sink MUST NOT feed ciphertext to a codec.
3. Else `open` the SFrame object (§3.4) to recover the plaintext payload,
then run SPEC.md §4 on the plaintext.
To seal `payload` as object (`group`, `object`) of a track, at priority `p`, with the KID and
track base key of the publisher's current epoch (§5.7):
`media`, `intent`, `conditioning.kind`, and
`model_requirement.capability` describe the **plaintext** codec and are
NOT encrypted: confidentiality protects the *content*, not the *shape*,
so a multiplexer can still route by capability without a key.
1. Build the Immutable Properties: the Key ID property (§4.3) and any other property the
publisher fixes before sealing (the layer properties of moq-streaming-format §4.1, the
envelope flag), serialized per §4.3.
2. Build the plaintext (§4.4).
3. Derive `moq_key` and `moq_salt` (§4.5); form the nonce (§4.6) and the AAD (§4.7).
4. The object's payload is `AEAD.Encrypt(moq_key, nonce, aad, pt)`, the tag included.
---
A publisher counts the objects it seals under each `moq_key` and stops before the limit of
secure objects §6.3 for its suite; a key is one track's in one epoch, and the next epoch
gives a fresh one (§5.8).
## 6. Relay transparency and JWP composition
### 4.10 Open
A relay or multiplexer that carries a sealed payload MUST treat it as
opaque: it MUST NOT require the plaintext to route, meter, or attest, and
MUST NOT attempt capability dispatch on ciphertext.
A receiver opens an object only through the checks of §6, which end in
`AEAD.Decrypt(moq_key, nonce, aad, payload)`. A wrong key, a changed property, priority,
Group ID, Object ID or track, and a changed byte of the payload or tag all fail alike, as
`bad_tag`, and the receiver discards the object (secure objects §3.9).
Attestation composes with confidentiality because the JWP receipt chain
([`jwp-receipts.md`](jwp-receipts.md)) commits over the **ciphertext**
content hash. The publisher signs the group's Merkle root over the
objects' leaves, and a Merkle leaf commits to the object's content hash
and, for a labelled object, its figure, class and origin (jwp-receipts
§2.2); an unlabelled object's figure is signed only where an `energy`
claim restates it (jwp-receipts §2.4.5). A relay forwards the
sealed bytes and their receipts unaltered, signs nothing in the
publisher's name (jwp-receipts §4.3), and never learns what the bytes
say. Content secrecy
(SFrame/MLS) and delivery attestation (JWP) are independent layers over
the same bytes:
## 5. Keying from MLS
- SFrame answers *what does it say* — only members can.
- JWP answers *which bytes the publisher signed for, and the group's
declared energy* — anyone can check it with the publisher's verifying
key, which the receipt carries, and no content key; whose key it is,
[`jwp-receipts.md`](jwp-receipts.md) §7 confirms.
### 5.1 The group
---
Each room has one MLS group at a time, of the record's `mls_suite`. Its group ID is the 32
bytes of the room id followed by a 4-byte big-endian **generation**, 0 for the room's first
group. Handshake messages (proposals and commits) are sent as PublicMessage (RFC 9420
§6.2), which RFC 9750 §8.1.2 weighs: here membership is already public in the room's
lifecycle events, and the clock, which is not a member, follows each epoch from them. MLS protocol messages ride the tracks of the
keying draft §3 within the room's namespace, under the names live-rooms §6.1 leaves free:
each member's `mls` track and `keypackage` track, owner its principal. A deployment that
carries them otherwise, a room service passing them directly, changes nothing below.
## 7. Security considerations
### 5.2 Credentials: the principal's room key
- **Counter uniqueness** (§3.4) is the sender's obligation; a persisted
or monotonic counter per base key is REQUIRED. Epoch rotation (§4)
resets the counter space safely because it rotates the key.
- **Replay.** SFrame authenticates each object but does not by itself
reject a replayed object. Freshness is enforced above this layer — the
MLS epoch bounds a key's lifetime, and the application op-log (e.g. a
Lamport/causal clock) orders and de-duplicates. Note that a channel
which legitimately re-sends old sealed objects for late-joiner resync
MUST NOT treat re-delivery as an attack.
- **Metadata.** The manifest shape (capability, dimensions, timing) and
object sizes/timing are visible to relays by design. This extension
protects payload content, not traffic analysis.
- **Trust boundary.** A relay is untrusted for confidentiality (it never
holds a content key) and signs nothing in the JWP chain (jwp-receipts
§4.3). A relay attestation, where a deployment wants one, is a separate
receipt under the relay's own key, and no class for it is registered
yet.
A member's leaf carries a BasicCredential (RFC 9420 §5.3) whose `identity` is the 32-byte
Ed25519 key of the principal its grant names, and whose leaf `signature_key` is that same
key: the principal signs its MLS messages with the key that signs its events and its proofs
of possession (live-rooms §10.5). MLS signs with its own labels (`"MLS 1.0 "` and the
structure's name, RFC 9420 §5.1.2) and WAI with its own domains, so no signature of one can
be taken for the other.
---
A member validates a credential (RFC 9420 §5.3.1, RFC 9750 §4) against the room: an Add
whose leaf's identity is not its signature key is refused `credential_mismatch`, and one
whose principal is not the subject the clock owes an Add (§5.4) `proposal_mismatch`; a
commit that holds a proposal a member refused never applies for that member. The room authority stands as the authentication service of RFC 9750: its grants say who
the principals are, and the clock's lifecycle says which of them are present.
## 8. Conformance
### 5.3 The clock as the external sender
- A **confidentiality-aware sink** MUST implement §3 (SFrame open), §4
(MLS base-key derivation for the profiles it supports), and §5
(decrypt-then-dispatch), and MUST fail closed on a missing key or
unsupported scheme.
- A **confidentiality-aware encoder** MUST emit `security.encryption`
in the manifest whenever the payload is sealed, and MUST seal per §3
under a base key derived per §4.
- A sink that does NOT implement this extension MUST refuse an envelope
whose manifest carries a non-`none` `security.encryption` cleanly
(SPEC.md §6.4) — never mis-decode ciphertext as a codec.
- Conformance is verified against the reference `moq-sframe` /
`joule-comm-mls` crates: byte-level SFrame round-trip and rejection
vectors, MLS group derivation of a shared base key, and the end-to-end
check that a member opens what a non-member cannot — the same flow the
live `/private-channel` demo runs in-browser over the real relay.
The group's GroupContext carries the `external_senders` extension (RFC 9420 §12.1.8.1) with
exactly one entry: the descriptor's `clock` key, with a BasicCredential whose identity is
that key. The clock is never a member and holds no epoch's secret; it signs external Add and
Remove proposals (RFC 9420 §12.1.8), as PublicMessage, for the group's current epoch. A
member that joins a group whose group ID or external sender is not the room's (§5.1) refuses
it `group_mismatch`.
### 5.4 Membership follows the room
The clock turns each lifecycle event of live-rooms §7.2 that changes who holds keys into one
proposal, in the order of the events:
- **`join`** under a keyed grant: an Add of the subject's KeyPackage. When no group exists
(the room's first keyed join, or every member has left), there is nothing to add to: the
subject creates the group, of the next generation, at epoch 0, itself at leaf 0, with the
clock as its external sender.
- **`leave`** of a member, for any reason of live-rooms §7.2: a Remove of its leaf. The
clock records a `leave` when a principal leaves (reason 1), when the authority removes it,
a revocation through the key log among the ways (live-rooms §5; reason 2), when its grant's
window ends (reason 3), and when its connection is lost (reason 4). Each is a Remove, and
the epoch the commit opens excludes the principal.
- **`grant_exchange`** from a keyed grant to one that is not: a Remove; from a grant that is
not keyed to a keyed one: an Add. An exchange between keyed grants changes nothing here.
Nothing else is proposed: a principal present under a grant that is not keyed (it sends
events only) is never a member, and the clock sends no Update and no other proposal.
What the clock owes the group is the difference between the principals present under keyed
grants and the group's membership once its pending proposals are committed. It proposes, one
at a time, the earliest owed change, in the order of the lifecycle events that call for
them, that can be proposed now: an Add into any group, a Remove only of a principal that
holds a leaf. So a principal that leaves before the clock proposes its Add is never added,
and one that leaves while its Add is pending is removed once a commit has added it. When a
commit ends an epoch without some of the clock's proposals for it, those are void, as RFC
9420 makes every proposal for an ended epoch, and the same rule proposes them again for the
new epoch, before anything owed later.
A group in which no member is still present under a keyed grant can never commit again: it
ends, and the next keyed join creates the group of the next generation.
A KeyPackage reaches the clock as the keying draft §4.7 has it: the joining principal
publishes it on its `keypackage` track, signed by its room key (§5.2), and each is used
once. A Welcome reaches the new member on the committer's `mls` track (the keying draft
§4.8).
### 5.5 Who commits
**The members commit, by one rule: the committer of the commit that ends epoch *E* is the
member at the lowest leaf index of epoch *E* that the commit's proposals do not remove.**
The commit holds, by reference, the first *k* proposals the clock sent for epoch *E*, for
some *k*, each once, in any order, and no other proposal: the clock's proposals reach the
committer in order on the clock's track, so the ones it holds when it commits are always the
first. It carries an UpdatePath, so the committer's own leaf is renewed with every epoch. A
commit with no proposal is allowed, from the committer alone: it renews the epoch's keys and
nothing else.
A member checks each proposal when it arrives, and keeps it only if it passes: the clock's
must be for the current epoch, its Add's credential bound as §5.2 requires
(`credential_mismatch`), and the change the clock owes (`proposal_mismatch`). A member's own
proposal is kept but never committed. A member applies a commit only after checking it, in
this order:
- its sender is the committer: otherwise `commit_not_committer`;
- every proposal it holds is the clock's: otherwise `proposal_not_clock`;
- the proposals it holds are the clock's first *k* for the epoch, each once: otherwise
`commit_mismatch`.
A commit that fails is discarded, and its epoch never exists for that member.
Why the members, and why this rule:
- **The clock cannot commit.** In MLS only a member commits, and a member holds every
epoch's secret. The clock is the room's own service; were it the committer, the room's
host could open every object, and "only the room's current members" would include a party
no participant joined with. As an external sender it orders every membership change and
holds no key.
- **One committer, computed alike everywhere.** The keying draft §4.2 has one committer per
group so that every epoch has exactly one commit and nothing forks. Here every member
computes the committer from the epoch's tree and the commit's own proposals, with no
election and no extra message: the rule is the keying draft's failover rule (§4.4 there),
the lowest eligible member, applied at every epoch rather than only after a timeout.
- **It heals itself.** A committer that goes away is a member whose connection is lost: the
clock records its `leave` (reason 4), and the commit that removes it is, by the rule, the
next member's. A committer that stays but does not commit is, to the room, a member that
has stopped responding, and is removed the same way.
- **It can be checked.** The rule needs the tree and the commit alone, so a member checks
every commit before applying it, and a verifier checks a room's whole transcript (§13).
### 5.6 Roster
A commit applies its proposals as RFC 9420 §12.3 orders them: every Remove blanks its leaf,
then every Add, in the order the commit lists them, takes the leftmost blank leaf, or a new
leaf at the right edge when none is blank (RFC 9420 §7.7, §12.1.1): the lowest leaf index
no member holds. The roster of the new epoch follows from the
roster before it and the commit alone, so the clock, which is not a member, knows each
member's leaf index, and so does a verifier.
### 5.7 Per-epoch, per-sender keys
At each epoch *E*, every member derives the epoch's **application secret** with the MLS
exporter (RFC 9420 §8.5):
```
application_secret[E] = MLS-Exporter("wai live e2ee", room_id, 32)
```
and from it the **track base key** of each namespace, as the keying draft §7.1 derives a
track key, with HKDF-Expand of the MLS suite's hash (SHA-256):
```
track_base_key[E, ns] = HKDF-Expand(application_secret[E], "mocha_track" ‖ Serialize(ns), 32)
```
`Serialize(ns)` is the namespace as §4.2 serializes it, and `mocha_track` is ASCII with no
separator. The **KID** of an object a member at leaf index *L* seals in epoch *E* is the
keying draft's §7.2:
```
KID = E × 2^24 + L (E < 2^40, L < 2^24)
```
The base key is the namespace's, not the track's: secure objects §3.2 scopes a Key ID to a
namespace, so the tracks of one namespace (a publisher's layers of one media kind) share it,
and secure objects' own derivation (§4.5) mixes in the Full Track Name, so each layer's key
and salt are its own. Every sender's own tracks are in a namespace of their own (live-rooms
§6.1), and the KID names the sender, so a sender's key is its own in every epoch: two
members never seal under one `(moq_key, nonce)`, even in a slot where a live publisher and
its standby send on the same track (live-rooms §7.1). A receiver reads the epoch and the
leaf index from the KID and derives the one key that opens the object, with no trial
decryption.
The KID's 40 bits of epoch and 24 of leaf index fit in one vi64 (MOQT §8.1); a group whose
epoch would reach 2^40 or whose tree would exceed 2^24 leaves ends, and the next join starts
the next generation.
### 5.8 Rotation and retention
Every commit opens an epoch whose application secret, and so every key derived from it, is
new. A member derives an epoch's keys when it applies the commit that opens it, and a
publisher seals with them from then on. A receiver that holds an object whose KID names an
epoch it has not reached yet **MAY** keep it until it applies the commit (secure objects
§3.9), and opens it then.
A member keeps an epoch's keys while objects of that epoch can still arrive, and deletes
them after; for a receiver, an epoch whose keys it deleted is one it no longer joins (§6
step 4). Keeping them no longer than needed is what gives MLS's forward secrecy to the room's
media: a member's keys taken later open nothing sent before the epochs it still holds.
## 6. Receiving
A receiver is a member. For an object of a media track of the room it runs these steps in
order, and refuses at the first that fails:
1. The object's properties read (MOQT §8.3, §10.7), and its Immutable Properties hold
exactly one Key ID property and the mutable list none: `properties_malformed` for a list
that does not read or a Key ID twice, outside, or not a vi64; `key_id_missing` for none.
A media object of an end-to-end encrypted room is always sealed: one without a Key ID is
refused, never decoded as plaintext.
2. The Object ID is at most 2^32 − 1: `object_id_too_large`.
3. The KID gives the epoch *E* and leaf index *L* (§5.7).
4. The receiver was a member in epoch *E*: `epoch_not_joined` when *E* precedes the epoch it
joined at, or it deleted *E*'s keys (§5.8); `removed_before_epoch` when it was removed at
or before *E*. (*E* later than the receiver's latest epoch is held, §5.8, not refused.)
5. *L* is occupied in epoch *E*'s roster: `key_id_unknown`.
6. The member at *L* in epoch *E* is the track's owner (live-rooms §6.1: the namespace's
second field is its key; for a slot's track, a publisher the room state binds to the
slot): `sender_not_owner`. A member holds every sender's keys in its epochs, so it could
seal under another's KID; this step, and the publisher's signed receipts (§8), are what
keep a member from speaking as another.
7. The member at *L* held *L* in every epoch from *E* to the receiver's latest: otherwise
`sender_removed`. Once a receiver has applied the commit that removed a member, it takes
nothing more sealed under that member's earlier KIDs, so a removed member cannot go on
publishing under the last epoch it held.
8. The receiver has not opened an object of this Full Track Name, Group ID and Object ID
before: `replayed`. MOQT forbids two objects at one location (secure objects §6.1); one
presented again is a relay's replay, and a receiver keeps the locations it opened while it
keeps the epoch's keys.
9. `AEAD.Decrypt` (§4.10): `bad_tag`.
10. The plaintext reads (§4.4): `plaintext_malformed`.
11. Where the plaintext carries `WAI_INNER_DIGEST`, it is BLAKE3 of the original payload:
`inner_digest_mismatch`.
The original payload is then dispatched (§10). Steps 4 to 7 need only the receiver's epochs,
their rosters and the track's name, so a receiver refuses them before deriving any key, and
records a location as opened (step 8) only once the object has opened.
## 7. Multi-sender rooms
Every member may publish, each on its own tracks, at once. Nothing in a sealed object needs
coordinating between them: each sender's KID carries its leaf index (§5.7), so its key is
its own and its nonces never meet another's; a receiver selects the key from the KID alone.
Each publisher numbers its own tracks' groups and objects, as MOQT has it, and each signs its
own tracks' receipts with its own room key (§8.1).
When a commit opens epoch *E* + 1, each member moves to it when it applies the commit, at its
own pace: a publisher's objects of *E* and *E* + 1 can interleave at a receiver, and each
opens under its own epoch (§5.8). Two commits for one epoch cannot both be applied: the
rule of §5.5 names one committer, and a member discards any other's commit.
A slot (live-rooms §7.1) has one live publisher and, with redundancy, a standby; both are
members, and their objects on the slot's tracks carry their own KIDs. A takeover or failover
changes who publishes, not who is a member, and needs no commit.
## 8. Receipts and metering over ciphertext
### 8.1 What receipts bind
A sealed object's receipt is the receipt of [jwp-receipts](jwp-receipts.md), unchanged,
over the object's payload as it travels: its content hash is BLAKE3 over the sealed bytes,
ciphertext and tag. The publisher signs its group receipts with its room key, and they ride
the track's receipts track (live-rooms §6.1, moq-streaming-format §4.1), in the clear. A
relay with a receipt door ([jwp-receipts](jwp-receipts.md) §4.1) checks each sealed object
against its receipt exactly as it checks a plaintext one, and holds each publisher's tracks
to its own key (the namespace names it).
### 8.2 What a member checks besides
A member that opens an object knows the plaintext came from the sealed bytes the publisher
signed for, under a key of the publisher's leaf: the AEAD authenticates the plaintext
under the KID's key, and the receipt binds those exact bytes to the publisher's room key.
Where the object carries `WAI_INNER_DIGEST` (§4.4), the member also checks the plaintext
against the digest the publisher sealed into it, the content hash a receipt of the plaintext
would carry; records made over plaintext (a decoded output's digest, a reconstruction
binding) attach to that hash.
### 8.3 What a relay can prove, and what it cannot
A relay, and anyone holding the receipts, can prove: which sealed bytes each publisher signed
for, at which track, group and object; that the relay forwarded exactly those bytes, since a
changed byte fails the receipt; how many objects and bytes of each track it carried, received
and dropped, and the energy each receipt states, which the per-hop records of
[live-metering](live-metering.md) count from what it carried (their byte counts are of sealed
bytes, the AEAD's tag and the Key ID among them); the track names, priorities and Immutable
Properties, which the AEAD authenticates but does not hide; and the room's membership over
time, which the room's events and the group's public handshake messages state.
A relay cannot prove, or learn: what any object says; that a sealed payload opens at all, or
that its plaintext is a frame of the codec its catalog names; that a member received or
opened what it was sent; or anything a measurement over plaintext states (staged-measurement
counts no sealed object, code `encrypted`; a decoded output's tier and digest are the
member's). A moderator who reviews media is a member and reviews what it opens; a relay can
withhold an object a moderator's decision names by its receipt leaf, and cannot review one by
its content ([live-moderation](live-moderation.md)).
## 9. Recording and clips: refused
**A room with a confidentiality record is never recorded.** Its descriptor's recording
`mode` is 0 (live-rooms §3.7), and §3.3 step 5 refuses a record for a room whose mode is not
(`recording_refused`): no principal holds the record power to any effect, no device commits a
take (capture-binding needs a `record_start`), no recording manifest is signed, no clip is
cut, and a relay keeps nothing of the room's tracks beyond delivery.
The alternative was a recording of the relay's ciphertext with each epoch's keys escrowed to
the room authority. It is not taken, for these reasons:
- **Escrow makes a reader the members cannot see.** The MLS roster is where a member sees
who can open what it sends. An authority holding every epoch's keys from outside the tree
would open everything while no roster named it, and the room's claim to be end-to-end
encrypted would be false for every recorded room.
- **Escrowed keys outlive their epoch.** Forward secrecy, and a removed member being cut off,
rest on each epoch's keys being deleted (§5.8). Keys kept for a recording are kept for its
retention, and one later compromise of the escrow opens the whole session.
- **It is not what a recording is here.** A recording of [live-recording](live-recording.md)
is a package of the participants' takes, masters their devices upload to the room's store
in the clear, under a manifest the authority signs. A store of ciphertext and keys would be
a second format that live-recording's verifier does not read, and the takes would still
reach the store as plaintext. A room whose session must be recorded is not end-to-end
encrypted, and says so by having no confidentiality record.
- **It needs no new field.** Mode 0 already means "never recorded", so the rule is a check of
what the descriptor states.
A member keeps what it receives as any participant of an end-to-end encrypted system can;
that is the member's own act, outside the room's recording policy, and no record of this
family attests it.
## 10. Decrypt-then-dispatch and relay transparency
This refines SPEC §6.4. A receiver of a room's media track opens each object by §6, then
treats the original payload as it would the payload of a plaintext room: a WAI envelope is
read and dispatched by SPEC §4, a media frame decoded by the track's codec. It **MUST NOT**
feed a payload that has not opened to a decoder. The envelope inside a sealed object carries
no `security` member: the object around it was sealed, and the envelope is plaintext once
opened.
A relay forwards a sealed object as it forwards any object: it reads the track name, the
Group ID, the Object ID, the priority and the properties, applies its delivery timeouts,
caches and serves FETCH, checks receipts at a door, and meters; it never needs a key, and
the Key ID property is one more property it forwards unchanged (MOQT §8.4). Nothing in a
relay's forwarding changes for a sealed object, and the reference relay
(`joule-moq-relay`) forwards them with no change of its own.
## 11. Reading the documents together
The documents of this revision were written against different revisions of one another.
Where they disagree, this extension takes the reading below.
### 11.1 The Key ID's code point
Secure objects (§4.1, §7.1) gives the Key ID property the type `0x2`, written against MOQT
-18. MOQT -22 §16.8 gives `0x02` to OBJECT_DELIVERY_TIMEOUT, a Track and Object Property a
relay acts on, and its table of provisional registrations lists secure objects' `0x0A` and
`0x32` but no Key ID. This extension carries the Key ID at `0x3A4A` (§4.3), in the
application range of MOQT §8.4, until the two agree; every other rule of the property is
secure objects'.
### 11.2 The counter-mode suites' tag
Secure objects §7.2 says its suites' algorithms are those of RFC 9605. RFC 9605 §4.5.1
computes the counter-mode suites' tag as HMAC over the lengths of the AAD, the ciphertext and
the tag, then the nonce, the AAD and the ciphertext. The ciphertexts of secure objects'
Appendix B for suites `0x0001` and `0x0002` carry instead the HMAC of the AAD, the nonce and
the ciphertext, with no lengths; their counter-mode encryption, keys, salts, nonces and AADs
are RFC 9605's. This extension implements RFC 9605's construction and keeps the
counter-mode suites out of rooms until the text and the vectors agree; its corpus checks
both readings against those two vectors, so the difference is stated exactly.
### 11.3 Property forms
Secure objects' Appendix B serializes properties as a 16-bit type, a variable-length length
and the value, whatever the type's parity; MOQT -22 §8.3 serializes them with delta-coded
types, an even type's value a single integer. On a -22 object the Immutable Properties are
-22's (§4.3), which is what MOQT §10.7 keeps unchanged end to end; inside the plaintext, which
no relay reads, the Encrypted Properties List keeps Appendix B's form (§4.4). Padding is
secure objects §4.3's form, a 16-bit type and a 16-bit length; §4.3.1's processing steps
speak of a 1-byte type and a 4-byte length, and are not followed. Appendix B's own vectors are
checked with the property bytes they state, which the AEAD takes as they are.
### 11.4 The keying draft's track key
The keying draft §7.1 derives a track key and a track nonce from an "application secret"
and the Track Name alone. RFC 9420 gives an application one interface to its key schedule,
the exporter (§8.5), so the application secret here is an exported value (§5.7); a room's
Track Names are layer numbers that every publisher shares (live-rooms §6.1), and secure
objects §3.2 scopes a Key ID to a namespace, so the derivation is over the namespace; and
secure objects derives its own key and salt from a base key (§4.5), so the derived value is
that base key, and no nonce is derived beside it. The KID is the keying draft's §7.2 as
written.
### 11.5 Variable-length integers
Secure objects writes its lengths and its Key ID as the varints of the MOQT revision it was
written against. This extension writes them as MOQT -22's (§8.1), shortest, everywhere: in
the Serialized Full Track Name, the payload's length and the list's. Every value in secure
objects' Appendix B has the same bytes in both encodings, so those vectors hold unchanged.
## 12. Security and privacy considerations
- **Who can open.** The members of an epoch, and no one else: not a relay, not the clock,
not the room authority. A member is a principal present under a keyed grant, admitted by
the clock's proposal and a commit every member checked. The authority decides who is
present, so an authority that wants to read can admit a principal of its own, and that
principal is a member, named in the roster every member holds; the authority cannot read
without being seen to.
- **Within the group.** MLS gives every member every sender's keys (RFC 9605 §7.2:
SFrame has no per-sender authentication). A member's objects are bound to it by the
receiver's owner check (§6 step 6), by the relay's door holding each publisher's
namespace to its key, and by its signed receipts (§8.1). A grant that may publish but not
subscribe is a member, and can open what others send: subscription is a relay's
authorisation, not a line inside a group.
- **Removal.** A removed member keeps the keys of the epochs it held. It opens nothing of a
later epoch (§6 step 4), and once its removal is applied a receiver takes nothing more
sealed under its earlier KIDs (§6 step 7). Objects of earlier epochs that it received are
its own to keep.
- **Late joiners.** A member added at epoch *E* derives nothing before *E*: a Welcome carries
the epoch's secrets, not earlier ones, and objects a relay serves by FETCH from before the
join are refused `epoch_not_joined`.
- **Replay and reordering.** The AEAD binds each payload to its track, group, object,
priority and properties (secure objects §6.2); a receiver refuses a location presented
twice (§6 step 8). A relay can still drop, delay and reorder objects (secure objects §6.1);
the receipts chain, End of Group and End of Track statuses and the gap properties are how
drops are seen (secure objects §6.4, §6.5).
- **Forgery against fan-out.** A relay could try forgeries on many subscribers at once
(secure objects §6.1.1). The suites of rooms have 128-bit tags and the base keys are 256
bits, and a receiver discards a failed object indistinguishably from one it processed.
- **Metadata.** Track names, sizes, timing, priorities, the catalog and the membership are
visible by design. Padding (§4.4) hides an object's exact size from a relay where a
deployment needs it.
- **Key reuse.** The principal's room key signs its events, its proofs of possession and its
MLS messages, each under its own domain or label (§5.2). A compromise of that key lets an
attacker speak as the principal in all three until the authority revokes the grant (and,
with it, the member's leaf at the next commit).
- **The device.** The plaintext, and every key a member derives, are in the application's
memory; Lane B is not a protected-path robustness level (SPEC §6.5).
## 13. Reasons
| reason | raised at |
|---|---|
| `truncated`, `malformed`, `unknown_version` | §3.3 step 1 |
| `wrong_room` | §3.3 step 2 |
| `suite_unsupported` | §3.3 step 3 |
| `mls_suite_unsupported` | §3.3 step 4 |
| `recording_refused` | §3.3 step 5, §9 |
| `bad_key`, `bad_signature` | §3.3 step 6 |
| `group_mismatch` | §5.3 |
| `credential_mismatch` | §5.2 |
| `proposal_not_clock` | §5.5 |
| `proposal_mismatch` | §5.4, §5.5 |
| `commit_not_committer` | §5.5 |
| `commit_mismatch` | §5.5 |
| `properties_malformed` | §6 step 1 |
| `key_id_missing` | §6 step 1 |
| `object_id_too_large` | §4.6, §6 step 2 |
| `epoch_not_joined` | §6 step 4 |
| `removed_before_epoch` | §6 step 4 |
| `key_id_unknown` | §6 step 5 |
| `sender_not_owner` | §6 step 6 |
| `sender_removed` | §6 step 7 |
| `replayed` | §6 step 8 |
| `bad_tag` | §4.10, §6 step 9 |
| `plaintext_malformed` | §4.4, §6 step 10 |
| `inner_digest_mismatch` | §6 step 11 |
## 14. Conformance
[`live-conformance/e2ee/`](../live-conformance/e2ee/) holds the corpus. `vectors.json` is
written from fixed seeds by `joule_moq_e2ee_vectors gen`; beside it, `rfc9605.json` and
`secure-objects-01.json` hold the test vectors of RFC 9605 Appendix C and of secure objects'
Appendix B as those documents print them. The corpus has:
- **Published vectors.** Every RFC 9605 vector (header encoding, the counter-mode AEAD, and
SFrame encryption in all five suites), and every secure-objects vector (the Full Track
Name, key derivation, nonce and AAD, and encryption: exact in suites `0x0004` and `0x0005`,
and in `0x0001` and `0x0002` the difference of §11.2 stated).
- **Records**: an accepted confidentiality record, and one refused for each reason of §3.3.
- **Key schedule**: per-epoch application secrets and, for each sender and namespace, the
track base key, the KID, and each track's `moq_key` and `moq_salt`.
- **Objects**: sealed objects with every input and key stated (the Immutable Properties as
carried, the plaintext, the AAD, the nonce, the sealed payload and its content hash), with
and without an inner digest and padding, in both suites.
- **A room**: three members joining in turn, each publishing, the third removed (its grant
revoked), a fourth joining late at the third's old leaf, and the first leaving so that the
second commits, with the lifecycle, the clock's proposals, each commit and its committer,
each epoch's roster and application secret, and every sealed object delivered to its
receivers with the outcome §6 gives it: opened, with its payload's BLAKE3 hash, or refused
with its reason. Every epoch's keys open at least one object.
- **Refused rooms**: one transcript for each reason a proposal, a commit or a group's
creation raises, cut at the step it refuses.
- **Negatives**: one control for each reason of §13 an object, a transcript or a record
raises.
A conforming implementation gives every vector its stated outcome.
`joule_moq_e2ee_vectors verify` and the crate's tests check that the file regenerates byte for
byte, that the reference gives every vector its outcome, and that §13 lists exactly the
reference's codes. `live-conformance/e2ee_verify.py` is the second implementation, written
from this text and the documents it reads, standard library only: AES (FIPS 197) and GCM (SP
800-38D) written from their specifications for vectors only, HKDF and HMAC from Python's
standard library, BLAKE3 from `claim-conformance/_blake3.py`, and Ed25519 under WAI's strict
rule from `claim-conformance/_ed25519.py`. It requires every mutated copy of the corpus to
fail.
**The verifier's boundary.** It does not implement MLS. It takes each epoch's application
secret (§5.7) from the corpus as given and checks everything after it: the roster of each
epoch from the commits (§5.6), the committer of each commit and what it holds (§5.5), the
proposals against the room's lifecycle (§5.4), each track base key and KID, each key, salt,
nonce and AAD, every seal and open, and every refusal. That the application secrets are what
an MLS group of the room exports is checked by the reference's tests, which run the group.

View file

@ -25,9 +25,9 @@ MoQ separates two things that other stacks fuse:
into objects, maps each object to a stream of its own, and declares what a
publisher emits in a **catalog**.
WAI already references MoQ for confidentiality (MLS + SFrame; see
[confidentiality](confidentiality.md)). That binding treats MoQ as a pipe. This
one treats it as what it is — a two-layer design with an open slot at the upper
WAI's confidentiality extension seals MoQ objects end to end (secure objects keyed by
MLS; see [confidentiality](confidentiality.md)), and leaves what the objects mean to
this one. This one treats MoQ as what it is — a two-layer design with an open slot at the upper
layer, which is precisely where a container that carries capability-dispatched
objects belongs.

View file

@ -14,6 +14,7 @@ and the independent verifier of each, beside it.
| [`metering/`](metering/) | [live-metering](../extensions/live-metering.md) (`wai.live.metering`) | `live_metering_verify.py` |
| [`steering/`](steering/) | [energy-steering](../extensions/energy-steering.md) (`wai.live.steering`) | `energy_steering_verify.py` |
| [`moderation/`](moderation/) | [live-moderation](../extensions/live-moderation.md) (`wai.live.moderation`) | `live_moderation_verify.py` |
| [`e2ee/`](e2ee/) | [confidentiality](../extensions/confidentiality.md) (`wai.live.e2ee`) | `e2ee_verify.py` |
**Provenance.** Generated here: `wai_live_events_vectors gen` writes `events/`,
`wai_live_room_vectors gen` writes `rooms/`, `wai_capture_take_vectors gen` writes
@ -23,11 +24,15 @@ writes `recordings/`, `wai_timed_text_vectors gen` writes `captions/` and
`steering/` and `wai_live_moderation_vectors gen` writes `moderation/`, each from records
authored in code, from fixed seeds (`events/README.md`, `rooms/README.md`, `takes/README.md`,
`compose/README.md`, `recordings/README.md`, `captions/README.md`, `metering/README.md`,
`steering/README.md`, `moderation/README.md`). One recorded record:
`metering/relay-record.wmh` and its ledger, which the reference relay signed in its test.
`steering/README.md`, `moderation/README.md`). `joule_moq_e2ee_vectors gen` (in
`joule-code/joule-code-rs/crates/joule-moq-mls`) writes `e2ee/vectors.json` from fixed seeds,
and `e2ee/` also holds the test vectors RFC 9605 and `draft-ietf-moq-secure-objects-01`
publish, as printed (`e2ee/README.md`). One recorded record: `metering/relay-record.wmh` and
its ledger, which the reference relay signed in its test.
Each verifier is written from its extension's text, shares no code with `wai-rs`, and is
run by `.github/workflows/wai-conformance.yml` beside the reference's own check:
Each verifier is written from its extension's text, shares no code with the reference
(`wai-rs`, or for `e2ee/`, `joule-moq` and `joule-moq-mls`), and is run by
`.github/workflows/wai-conformance.yml` beside the reference's own check:
```
python3 live_events_verify.py events
@ -39,4 +44,5 @@ python3 timed_text_verify.py captions
python3 live_metering_verify.py metering
python3 energy_steering_verify.py steering
python3 live_moderation_verify.py moderation
python3 e2ee_verify.py e2ee
```

View file

@ -0,0 +1,70 @@
# live-conformance/e2ee — end-to-end encrypted rooms
The corpus of [`extensions/confidentiality.md`](../../extensions/confidentiality.md) §14:
rooms whose media objects are sealed as MoQ secure objects under keys from one MLS group per
room.
## Files
- `vectors.json`, written by `joule_moq_e2ee_vectors gen`:
- `records`: confidentiality records (`WRE1`, §3), each with the room id, the authority's
key and the descriptor's recording mode it is checked against, and its outcome: `accept`
with its `suite`, or the reason of §3.3;
- `key_schedule`: for an epoch's application secret, a sender's leaf and a track, the
track base key, the KID, the Serialized Full Track Name, `moq_key` and `moq_salt`
(§4.2, §4.5, §5.7), in both suites and at the KID's limits;
- `objects`: sealed objects. A sealed one states its `input` (the application secret,
epoch, leaf, track, Group ID, Object ID, priority, payload, whether it carries an inner
digest, padding, other encrypted entries, other Immutable Properties), every `derived`
value (track base key, KID, Full Track Name, key, salt, Immutable Properties, plaintext,
AAD, nonce), the `object` as a -22 object carries it (its property list and sealed
payload, hexadecimal), and its `content_hash`, the BLAKE3 hash a receipt commits to. The
negatives are controls of one defect each. Each is opened by the receiver its `context`
names and has its `outcome`: `opened` with the BLAKE3 hash of its original payload, or a
reason of §6;
- `rooms`: transcripts, each a list of `steps` (a lifecycle `event`, the group's `create`,
a `proposal`, a `commit` with its sender's leaf, the indexes of the epoch's proposals it
holds and the new epoch's application secret, or an `object` with its `deliveries`), and
the outcome a replay gives it (§5.4 to §5.6, §6): `accept` or `refuse` with the reason and
the step, each epoch's roster, and each delivery's outcome;
- `codes`: the reasons of §13, in its order.
- `rfc9605.json`: RFC 9605 Appendix C, its header, AES-CTR with HMAC, and SFrame encryption
vectors.
- `secure-objects-01.json`: `draft-ietf-moq-secure-objects-01` Appendix B, its Full Track
Name, key derivation, nonce and AAD, and encryption vectors.
**Equivalence.** A conforming implementation gives every record, object and delivery its
outcome, every room its outcome, rosters and deliveries, and every key schedule entry and
derived value the bytes stated; it reproduces RFC 9605's vectors, and secure objects' exactly
in suites `0x0004` and `0x0005`, with the difference §11.2 states in `0x0001` and `0x0002`.
**The boundary.** Each epoch's application secret is given, standing for
`MLS-Exporter("wai live e2ee", room_id, 32)` (§5.7); everything after it is checked. That a
real MLS group exports them, and that its rosters are the replay's, is checked by
`joule-moq-mls`'s tests, which run the groups.
**Provenance.** Generated here: `joule_moq_e2ee_vectors gen` writes `vectors.json` from rooms
authored in code, from fixed seeds: every key is an Ed25519 key whose seed is SHA-256 of
`wai:e2ee/corpus/key`, a zero byte and a label; every application secret SHA-256 of
`wai:e2ee/corpus/secret`, a zero byte, the room's label, the generation and the epoch; every
payload SHA-256 in counter mode over `wai:e2ee/corpus/bytes` and a label; the room id SHA-256
of `wai:e2ee/corpus/room` and a label. AES-GCM and Ed25519 signing are deterministic, so the
file is the same bytes on every machine. No recorded data. `rfc9605.json` and
`secure-objects-01.json` are the test vectors those documents print, copied with the
whitespace inside each value removed and each document's numbers as JSON numbers; nothing in
them is generated here.
## Checking
```
cd joule-code/joule-code-rs
cargo run -p joule-moq-mls --bin joule_moq_e2ee_vectors verify ../../wai/live-conformance/e2ee
cargo test -p joule-moq --features secure-objects
cargo test -p joule-moq-mls
cd wai
python3 -I live-conformance/e2ee_verify.py live-conformance/e2ee
```
`e2ee_verify.py` is the second implementation, written from the text with the standard
library alone; it writes AES and GCM itself, for these vectors only.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,220 @@
{
"source": "draft-ietf-moq-secure-objects-01 Appendix B (B.1 to B.4), values as printed, whitespace inside values removed",
"full_track_name": [
{
"namespace_tuples": [
"example.com"
],
"track_name": "audio",
"serialized_full_track_name": "010b6578616d706c652e636f6d05617564696f"
},
{
"namespace_tuples": [
"example.com",
"meeting-123"
],
"track_name": "video",
"serialized_full_track_name": "020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f"
},
{
"namespace_tuples": [
"example.com",
"meeting-123",
"floor-1"
],
"track_name": "slides",
"serialized_full_track_name": "030b6578616d706c652e636f6d0b6d656574696e672d31323307666c6f6f722d3106736c69646573"
}
],
"key_derivation": [
{
"cipher_suite": 1,
"key_id": 7,
"track_base_key": "7369787465656e2062797465206b6579",
"serialized_full_track_name": "020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f",
"moq_secret": "e5e76a372058231c56eedca06436c94e6f6bab38d759687367c92048faf93f18",
"moq_key_label": "4d4f5120312e3020536563757265204f626a6563747320536563726574206b657920020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f00010000000000000007",
"moq_salt_label": "4d4f5120312e30205365637265742073616c7420020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f00010000000000000007",
"moq_key": "36543f1f0d00ea1c6ecacaab3ec878772ef6e4bf7e91f29a1fa99af410802420fa92560c1850654f5ae7fa0281d3d1d9",
"moq_salt": "e905de3e41c5feb642a8ea58"
},
{
"cipher_suite": 2,
"key_id": 7,
"track_base_key": "7369787465656e2062797465206b6579",
"serialized_full_track_name": "020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f",
"moq_secret": "e5e76a372058231c56eedca06436c94e6f6bab38d759687367c92048faf93f18",
"moq_key_label": "4d4f5120312e3020536563757265204f626a6563747320536563726574206b657920020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f00020000000000000007",
"moq_salt_label": "4d4f5120312e30205365637265742073616c7420020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f00020000000000000007",
"moq_key": "12acb1032ea6b0c8ee2c1f9cfe052f0d13220782a559c50e669645951bb15ed1c93ae2ff02b411649e0aa99aec19b865",
"moq_salt": "17efc7dfcc10786fa00771b8"
},
{
"cipher_suite": 4,
"key_id": 7,
"track_base_key": "7369787465656e2062797465206b6579",
"serialized_full_track_name": "020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f",
"moq_secret": "e5e76a372058231c56eedca06436c94e6f6bab38d759687367c92048faf93f18",
"moq_key_label": "4d4f5120312e3020536563757265204f626a6563747320536563726574206b657920020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f00040000000000000007",
"moq_salt_label": "4d4f5120312e30205365637265742073616c7420020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f00040000000000000007",
"moq_key": "eb3b95606d7c4775688c121e8f06832c",
"moq_salt": "dc11a8d516c299f44b24be8d"
},
{
"cipher_suite": 5,
"key_id": 7,
"track_base_key": "615d3235362d6269742d626173652d6b65792d666f722d746573742121212121",
"serialized_full_track_name": "020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f",
"moq_secret": "f633519ec99705bae4bcc7fe0f88ccd3c0301355d97d2265773d8b80ccf23ca39d500bc2d5399315bf78bfc5ed69f8be7b6799cfb35691ffe2db6126ab9e897d",
"moq_key_label": "4d4f5120312e3020536563757265204f626a6563747320536563726574206b657920020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f00050000000000000007",
"moq_salt_label": "4d4f5120312e30205365637265742073616c7420020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f00050000000000000007",
"moq_key": "03a7b0a238bde6d398c0c8de14538c31c8f84a4356a01f5ca00d9a46d4046953",
"moq_salt": "2c82451648c8d25e94b8dbdc"
}
],
"nonce_aad": [
{
"group_id": 0,
"object_id": 0,
"publisher_priority": 0,
"key_id": 7,
"serialized_immutable_properties": "",
"moq_salt": "dc11a8d516c299f44b24be8d",
"ctr": "000000000000000000000000",
"nonce": "dc11a8d516c299f44b24be8d",
"aad": "0000000000000000000000000000020107"
},
{
"group_id": 42,
"object_id": 1,
"publisher_priority": 128,
"key_id": 7,
"serialized_immutable_properties": "",
"moq_salt": "dc11a8d516c299f44b24be8d",
"ctr": "000000000000002a00000001",
"nonce": "dc11a8d516c299de4b24be8c",
"aad": "000000000000002a000000018000020107"
},
{
"group_id": 18446744073709551615,
"object_id": 4294967295,
"publisher_priority": 255,
"key_id": 7,
"serialized_immutable_properties": "",
"moq_salt": "dc11a8d516c299f44b24be8d",
"ctr": "ffffffffffffffffffffffff",
"nonce": "23ee572ae93d660bb4db4172",
"aad": "ffffffffffffffffffffffffff00020107"
}
],
"encryption": [
{
"cipher_suite": 1,
"key_id": 7,
"track_base_key": "7369787465656e2062797465206b6579",
"namespace_tuples": [
"example.com",
"meeting-123"
],
"track_name": "video",
"serialized_full_track_name": "020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f",
"group_id": 42,
"object_id": 1,
"publisher_priority": 128,
"immutable_properties": "00040872656c61792d6f6b",
"original_payload": "68656c6c6f2066726f6d206d6f71",
"encrypted_properties_list": "000a1001000d666f722d657965732d6f6e6c79",
"plaintext": "0e68656c6c6f2066726f6d206d6f71000a1001000d666f722d657965732d6f6e6c79",
"moq_secret": "e5e76a372058231c56eedca06436c94e6f6bab38d759687367c92048faf93f18",
"moq_key_label": "4d4f5120312e3020536563757265204f626a6563747320536563726574206b657920020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f00010000000000000007",
"moq_salt_label": "4d4f5120312e30205365637265742073616c7420020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f00010000000000000007",
"moq_key": "36543f1f0d00ea1c6ecacaab3ec878772ef6e4bf7e91f29a1fa99af410802420fa92560c1850654f5ae7fa0281d3d1d9",
"moq_salt": "e905de3e41c5feb642a8ea58",
"ctr": "000000000000002a00000001",
"nonce": "e905de3e41c5fe9c42a8ea59",
"aad": "000000000000002a00000001800002010700040872656c61792d6f6b",
"ciphertext": "170654428664c29b3eab20a76e86ab2c2fc210b218b38eb556974021b7804b1d7ced925e33e3d6080e8157d4"
},
{
"cipher_suite": 2,
"key_id": 7,
"track_base_key": "7369787465656e2062797465206b6579",
"namespace_tuples": [
"example.com",
"meeting-123"
],
"track_name": "video",
"serialized_full_track_name": "020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f",
"group_id": 42,
"object_id": 1,
"publisher_priority": 128,
"immutable_properties": "00040872656c61792d6f6b",
"original_payload": "68656c6c6f2066726f6d206d6f71",
"encrypted_properties_list": "000a1001000d666f722d657965732d6f6e6c79",
"plaintext": "0e68656c6c6f2066726f6d206d6f71000a1001000d666f722d657965732d6f6e6c79",
"moq_secret": "e5e76a372058231c56eedca06436c94e6f6bab38d759687367c92048faf93f18",
"moq_key_label": "4d4f5120312e3020536563757265204f626a6563747320536563726574206b657920020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f00020000000000000007",
"moq_salt_label": "4d4f5120312e30205365637265742073616c7420020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f00020000000000000007",
"moq_key": "12acb1032ea6b0c8ee2c1f9cfe052f0d13220782a559c50e669645951bb15ed1c93ae2ff02b411649e0aa99aec19b865",
"moq_salt": "17efc7dfcc10786fa00771b8",
"ctr": "000000000000002a00000001",
"nonce": "17efc7dfcc107845a00771b9",
"aad": "000000000000002a00000001800002010700040872656c61792d6f6b",
"ciphertext": "df0fd1431b6883bcb841fa14d4588630aaf11c92363b536ecc08273a27ac4f47bd72fca55f29cd1fad9b"
},
{
"cipher_suite": 4,
"key_id": 7,
"track_base_key": "7369787465656e2062797465206b6579",
"namespace_tuples": [
"example.com",
"meeting-123"
],
"track_name": "video",
"serialized_full_track_name": "020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f",
"group_id": 42,
"object_id": 1,
"publisher_priority": 128,
"immutable_properties": "00040872656c61792d6f6b",
"original_payload": "68656c6c6f2066726f6d206d6f71",
"encrypted_properties_list": "000a1001000d666f722d657965732d6f6e6c79",
"plaintext": "0e68656c6c6f2066726f6d206d6f71000a1001000d666f722d657965732d6f6e6c79",
"moq_secret": "e5e76a372058231c56eedca06436c94e6f6bab38d759687367c92048faf93f18",
"moq_key_label": "4d4f5120312e3020536563757265204f626a6563747320536563726574206b657920020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f00040000000000000007",
"moq_salt_label": "4d4f5120312e30205365637265742073616c7420020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f00040000000000000007",
"moq_key": "eb3b95606d7c4775688c121e8f06832c",
"moq_salt": "dc11a8d516c299f44b24be8d",
"ctr": "000000000000002a00000001",
"nonce": "dc11a8d516c299de4b24be8c",
"aad": "000000000000002a00000001800002010700040872656c61792d6f6b",
"ciphertext": "facafee5c450bd140759bdffa6eef6948ae6dbfdb7ba87cf3e161a641b27f952eb4f13b18e351d4e7e344675f0dc96e0cd5e"
},
{
"cipher_suite": 5,
"key_id": 7,
"track_base_key": "615d3235362d6269742d626173652d6b65792d666f722d746573742121212121",
"namespace_tuples": [
"example.com",
"meeting-123"
],
"track_name": "video",
"serialized_full_track_name": "020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f",
"group_id": 42,
"object_id": 1,
"publisher_priority": 128,
"immutable_properties": "00040872656c61792d6f6b",
"original_payload": "68656c6c6f2066726f6d206d6f71",
"encrypted_properties_list": "000a1001000d666f722d657965732d6f6e6c79",
"plaintext": "0e68656c6c6f2066726f6d206d6f71000a1001000d666f722d657965732d6f6e6c79",
"moq_secret": "f633519ec99705bae4bcc7fe0f88ccd3c0301355d97d2265773d8b80ccf23ca39d500bc2d5399315bf78bfc5ed69f8be7b6799cfb35691ffe2db6126ab9e897d",
"moq_key_label": "4d4f5120312e3020536563757265204f626a6563747320536563726574206b657920020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f00050000000000000007",
"moq_salt_label": "4d4f5120312e30205365637265742073616c7420020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f00050000000000000007",
"moq_key": "03a7b0a238bde6d398c0c8de14538c31c8f84a4356a01f5ca00d9a46d4046953",
"moq_salt": "2c82451648c8d25e94b8dbdc",
"ctr": "000000000000002a00000001",
"nonce": "2c82451648c8d27494b8dbdd",
"aad": "000000000000002a00000001800002010700040872656c61792d6f6b",
"ciphertext": "09399b821a1452a320a8bf927c004c71f8fd34449070f99746ff6ab0ef67a4f93b7f889e64d194a7b18a7a946184fed15386"
}
]
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -616,6 +616,20 @@
"edition": null,
"registered": "2026-10-17"
},
{
"capability": "wai.live.e2ee",
"kind": "record",
"status": "registered",
"media": null,
"role": null,
"tier": null,
"licence": null,
"replicate": null,
"sets": [],
"spec_section": "§5 Interactive, temporal & computed content",
"edition": null,
"registered": "2026-10-19"
},
{
"capability": "wai.live.events",
"kind": "record",
@ -1850,6 +1864,13 @@
"section": "§10.5.2",
"status": "proposed"
},
{
"name": "SECURE_OBJECT_KEY_ID",
"kind": "catalog",
"document": "extensions/confidentiality.md",
"section": "§4.3",
"status": "defined"
},
{
"name": "WAI-Room-Challenge",
"kind": "http-header",
@ -1878,6 +1899,13 @@
"section": "§7.1",
"status": "defined"
},
{
"name": "WAI_INNER_DIGEST",
"kind": "catalog",
"document": "extensions/confidentiality.md",
"section": "§4.4",
"status": "defined"
},
{
"name": "WAI_RECEIPT",
"kind": "catalog",
@ -1983,6 +2011,13 @@
"section": "§3.1",
"status": "defined"
},
{
"name": "WRE1",
"kind": "magic",
"document": "extensions/confidentiality.md",
"section": "§3.1",
"status": "defined"
},
{
"name": "WRG1",
"kind": "magic",

View file

@ -541,6 +541,13 @@ wai:wai.live.compose a wai:Capability ;
wai:specSection "§5 Interactive, temporal & computed content" ;
wai:registeredOn "2026-10-17"^^xsd:date .
wai:wai.live.e2ee a wai:Capability ;
rdfs:label "wai.live.e2ee" ;
wai:kind wai:kind-record ;
wai:status wai:status-registered ;
wai:specSection "§5 Interactive, temporal & computed content" ;
wai:registeredOn "2026-10-19"^^xsd:date .
wai:wai.live.events a wai:Capability ;
rdfs:label "wai.live.events" ;
wai:kind wai:kind-record ;

View file

@ -218,6 +218,7 @@ pub const CAP_LIVE_METERING: &str = "wai.live.metering";
pub const CAP_LIVE_STEERING: &str = "wai.live.steering";
pub const CAP_TEXT_TIMED: &str = "wai.text.timed";
pub const CAP_LIVE_MODERATION: &str = "wai.live.moderation";
pub const CAP_LIVE_E2EE: &str = "wai.live.e2ee";
/// One row per string SPEC §5 registers that is a payload, companion or record:
/// `(string, SPEC section, edition, registered)`.
@ -304,6 +305,7 @@ const ROWS: &[(&str, &str, Option<&str>, &str)] = &[
(CAP_LIVE_STEERING, S_INTERACTIVE, None, "2026-10-18"),
(CAP_TEXT_TIMED, S_INTERACTIVE, None, "2026-10-17"),
(CAP_LIVE_MODERATION, S_INTERACTIVE, None, "2026-10-19"),
(CAP_LIVE_E2EE, S_INTERACTIVE, None, "2026-10-19"),
(CAP_AUDIO_IAMF, S_IMMERSIVE, None, "2026-06-17"),
(CAP_AUDIO_AC4, S_IMMERSIVE, None, "2026-06-17"),
(CAP_AUDIO_IVAS, S_IMMERSIVE, None, "2026-06-17"),
@ -353,7 +355,7 @@ const RECORDS: &[&str] = &[
CAP_ASSET_PROVENANCE, CAP_QUANTUM_CALIBRATION, CAP_QUANTUM_JOB,
CAP_VIDEO_MANIFEST, CAP_VIDEO_SSAI, CAP_VIDEO_KEYS, CAP_VIDEO_PACKAGE, CAP_VIDEO_CHANNEL,
CAP_LIVE_EVENTS, CAP_LIVE_ROOM, CAP_CAPTURE_TAKE, CAP_LIVE_COMPOSE, CAP_LIVE_RECORDING,
CAP_LIVE_METERING, CAP_LIVE_STEERING, CAP_TEXT_TIMED, CAP_LIVE_MODERATION,
CAP_LIVE_METERING, CAP_LIVE_STEERING, CAP_TEXT_TIMED, CAP_LIVE_MODERATION, CAP_LIVE_E2EE,
];
/// A named identifier SPEC uses that has the shape of a capability string but is
@ -507,6 +509,7 @@ const TIMED_TEXT: &str = "extensions/timed-text.md";
const LIVE_METERING: &str = "extensions/live-metering.md";
const ENERGY_STEERING: &str = "extensions/energy-steering.md";
const LIVE_MODERATION: &str = "extensions/live-moderation.md";
const CONFIDENTIALITY: &str = "extensions/confidentiality.md";
const fn wire(name: &'static str, kind: WireKind, document: &'static str, section: &'static str,
status: WireStatus) -> WireIdentifier {
@ -595,6 +598,9 @@ pub const WIRE_IDENTIFIERS: &[WireIdentifier] = &[
wire("WMP1", WireKind::Magic, LIVE_MODERATION, "§3.1", WireStatus::Defined),
wire("WMS1", WireKind::Magic, LIVE_MODERATION, "§4.2", WireStatus::Defined),
wire("WMN1", WireKind::Magic, LIVE_MODERATION, "§5.1", WireStatus::Defined),
wire("WRE1", WireKind::Magic, CONFIDENTIALITY, "§3.1", WireStatus::Defined),
wire("SECURE_OBJECT_KEY_ID", WireKind::Catalog, CONFIDENTIALITY, "§4.3", WireStatus::Defined),
wire("WAI_INNER_DIGEST", WireKind::Catalog, CONFIDENTIALITY, "§4.4", WireStatus::Defined),
];
/// A string with the shape of a capability that the reference implementation uses