wai.live.e2ee: end-to-end encrypted rooms on MoQ secure objects and MLS keying (L10) #345
37 changed files with 11674 additions and 358 deletions
28
.github/workflows/joule-code-conformance.yml
vendored
28
.github/workflows/joule-code-conformance.yml
vendored
|
|
@ -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;
|
||||
|
|
|
|||
20
.github/workflows/wai-conformance.yml
vendored
20
.github/workflows/wai-conformance.yml
vendored
|
|
@ -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,
|
||||
|
|
|
|||
951
joule-code/joule-code-rs/Cargo.lock
generated
951
joule-code/joule-code-rs/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
27
joule-code/joule-code-rs/crates/joule-moq-mls/Cargo.toml
Normal file
27
joule-code/joule-code-rs/crates/joule-moq-mls/Cargo.toml
Normal 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"
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
841
joule-code/joule-code-rs/crates/joule-moq-mls/src/corpus.rs
Normal file
841
joule-code/joule-code-rs/crates/joule-moq-mls/src/corpus.rs
Normal 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(())
|
||||
}
|
||||
539
joule-code/joule-code-rs/crates/joule-moq-mls/src/group.rs
Normal file
539
joule-code/joule-code-rs/crates/joule-moq-mls/src/group.rs
Normal 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;
|
||||
333
joule-code/joule-code-rs/crates/joule-moq-mls/src/group_tests.rs
Normal file
333
joule-code/joule-code-rs/crates/joule-moq-mls/src/group_tests.rs
Normal 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);
|
||||
}
|
||||
33
joule-code/joule-code-rs/crates/joule-moq-mls/src/lib.rs
Normal file
33
joule-code/joule-code-rs/crates/joule-moq-mls/src/lib.rs
Normal 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;
|
||||
414
joule-code/joule-code-rs/crates/joule-moq-mls/src/model.rs
Normal file
414
joule-code/joule-code-rs/crates/joule-moq-mls/src/model.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
133
joule-code/joule-code-rs/crates/joule-moq-mls/src/record.rs
Normal file
133
joule-code/joule-code-rs/crates/joule-moq-mls/src/record.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
73
joule-code/joule-code-rs/crates/joule-moq-mls/src/strict.rs
Normal file
73
joule-code/joule-code-rs/crates/joule-moq-mls/src/strict.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
97
wai/SPEC.md
97
wai/SPEC.md
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
70
wai/live-conformance/e2ee/README.md
Normal file
70
wai/live-conformance/e2ee/README.md
Normal 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.
|
||||
1564
wai/live-conformance/e2ee/rfc9605.json
Normal file
1564
wai/live-conformance/e2ee/rfc9605.json
Normal file
File diff suppressed because it is too large
Load diff
220
wai/live-conformance/e2ee/secure-objects-01.json
Normal file
220
wai/live-conformance/e2ee/secure-objects-01.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
2886
wai/live-conformance/e2ee/vectors.json
Normal file
2886
wai/live-conformance/e2ee/vectors.json
Normal file
File diff suppressed because it is too large
Load diff
1064
wai/live-conformance/e2ee_verify.py
Normal file
1064
wai/live-conformance/e2ee_verify.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 ;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue