open-standards/joule-code/joule-code-rs/crates/joule-moq-relay/src/events.rs
Transaction Science dd75aa38bf joule-moq-relay: the events reader's test flips a payload byte
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 11:35:46 -04:00

185 lines
7.7 KiB
Rust

//! The events of a WAI room as the relay reads them: what it needs to apply moderation
//! decisions to what it keeps (WAI `live-moderation.md` §6.3).
//!
//! A room's events ride each principal's events track, an MSF event timeline (WAI
//! `live-events.md` §8.1): every object is a JSON array of records
//! `{"t":…,"data":{"wev1":"<base64url>"}}`, each record a `WEV1` event (§3.1) signed by
//! its principal (§3.2). The relay reads `decision` events (§4.11) from the tracks it
//! already forwards, and the digests of the events it keeps, so that a decision that
//! restricts one withholds it.
//!
//! Nothing here is a room verifier: a record is read and its signature checked, and the
//! relay decides whose decisions it honours (`crate::dvr`).
use base64::Engine;
use serde_json::value::RawValue;
/// The domain of an event's signature (live-events §3.2).
pub const EVENT_DOMAIN: &[u8] = b"wai:live-event\x01";
/// The `decision` kind (live-events §4.11).
pub const KIND_DECISION: u8 = 0x0B;
/// The verdicts of a decision.
pub const ALLOW: u8 = 1;
pub const DENY: u8 = 2;
pub const REDACT: u8 = 3;
/// One event record, read (live-events §3.1).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Event {
pub room: [u8; 32],
pub principal: [u8; 32],
pub seq: u64,
pub t: u64,
pub kind: u8,
pub payload: Vec<u8>,
/// SHA-256 over the record from `magic` through `payload` (§3.2).
pub digest: [u8; 32],
sig: [u8; 64],
body_len: usize,
}
fn sha256(b: &[u8]) -> [u8; 32] {
ring::digest::digest(&ring::digest::SHA256, b).as_ref().try_into().expect("32 bytes")
}
impl Event {
/// Read exactly one record (§3.3); `None` for anything that does not read.
pub fn read(b: &[u8]) -> Option<Event> {
if b.len() < 118 || &b[..4] != b"WEV1" || b[4] != 1 {
return None;
}
let a = match b[117] {
0 => 118,
1 => 166,
_ => return None,
};
if b.len() < a + 3 {
return None;
}
let kind = b[a];
let len = u16::from_le_bytes([b[a + 1], b[a + 2]]) as usize;
let body_len = a + 3 + len;
if b.len() != body_len + 64 {
return None;
}
let at32 = |i: usize| -> [u8; 32] { b[i..i + 32].try_into().expect("32") };
Some(Event {
room: at32(5),
principal: at32(37),
seq: u64::from_le_bytes(b[69..77].try_into().expect("8")),
t: u64::from_le_bytes(b[109..117].try_into().expect("8")),
kind,
payload: b[a + 3..body_len].to_vec(),
digest: sha256(&b[..body_len]),
sig: b[body_len..].try_into().expect("64"),
body_len,
})
}
/// Whether its signature verifies under its principal, under WAI's strict rule (SPEC
/// §7 *Signatures*), given the record's bytes.
pub fn verifies(&self, record: &[u8]) -> bool {
let mut m = EVENT_DOMAIN.to_vec();
m.extend_from_slice(&record[..self.body_len]);
joule_moq::receipt::verify_strict(&self.principal, &m, &self.sig).is_ok()
}
/// A decision's `verdict` and `target` (live-events §4.11), when it is one that reads.
pub fn decision(&self) -> Option<(u8, [u8; 32])> {
if self.kind != KIND_DECISION || self.payload.len() != 1 + 32 + 2 || !(ALLOW..=REDACT).contains(&self.payload[0]) {
return None;
}
Some((self.payload[0], self.payload[1..33].try_into().expect("32")))
}
}
/// The records of one object of an events track, each with the raw JSON of its entry, in
/// order; `None` when the object is not a JSON array of entries whose `data.wev1` reads as
/// base64url.
pub fn records(payload: &[u8]) -> Option<Vec<(Box<RawValue>, Vec<u8>)>> {
let entries: Vec<Box<RawValue>> = serde_json::from_slice(payload).ok()?;
let mut out = Vec::with_capacity(entries.len());
for e in entries {
let v: serde_json::Value = serde_json::from_str(e.get()).ok()?;
let b64 = v.get("data")?.get("wev1")?.as_str()?;
let rec = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(b64).ok()?;
out.push((e, rec));
}
Some(out)
}
/// The digests of the records of an events-track object that read; empty for one that
/// does not.
pub fn digests(payload: &[u8]) -> Vec<[u8; 32]> {
records(payload).map(|rs| rs.iter().filter_map(|(_, r)| Event::read(r).map(|e| e.digest)).collect()).unwrap_or_default()
}
/// What withholding leaves of an events-track object: the object unchanged when none of
/// its records is withheld; the array of the others, each entry byte for byte, when some
/// are; `None` when every one is (the object is withheld whole). A record is withheld and
/// never replaced (live-moderation §8.1): its author's chain shows the gap.
pub fn withhold(payload: &[u8], withheld: impl Fn(&[u8; 32]) -> bool) -> Option<std::borrow::Cow<'_, [u8]>> {
let Some(rs) = records(payload) else { return Some(payload.into()) };
let keep: Vec<&RawValue> = rs.iter().filter(|(_, r)| Event::read(r).is_none_or(|e| !withheld(&e.digest))).map(|(raw, _)| raw.as_ref()).collect();
if keep.len() == rs.len() {
return Some(payload.into());
}
if keep.is_empty() {
return None;
}
let joined: Vec<&str> = keep.iter().map(|r| r.get()).collect();
Some(format!("[{}]", joined.join(",")).into_bytes().into())
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use ed25519_dalek::{Signer, SigningKey};
/// A record of `kind` by `key`, as live-events §3.1 lays it out, without an anchor.
pub fn record(key: &SigningKey, room: [u8; 32], seq: u64, t: u64, kind: u8, payload: &[u8]) -> Vec<u8> {
let mut b = b"WEV1\x01".to_vec();
b.extend_from_slice(&room);
b.extend_from_slice(&key.verifying_key().to_bytes());
b.extend_from_slice(&seq.to_le_bytes());
b.extend_from_slice(&[0; 32]);
b.extend_from_slice(&t.to_le_bytes());
b.push(0);
b.push(kind);
b.extend_from_slice(&(payload.len() as u16).to_le_bytes());
b.extend_from_slice(payload);
let mut m = EVENT_DOMAIN.to_vec();
m.extend_from_slice(&b);
b.extend_from_slice(&key.sign(&m).to_bytes());
b
}
/// An MSF entry of a record (live-events §8.1).
pub fn entry(rec: &[u8], t_ms: u64) -> String {
format!("{{\"t\":{t_ms},\"data\":{{\"wev1\":\"{}\"}}}}", base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(rec))
}
#[test]
fn records_read_verify_and_withhold() {
let k = SigningKey::from_bytes(&[9; 32]);
let chat = record(&k, [1; 32], 0, 5, 0x03, b"\x02\x00hi");
let mut d = vec![REDACT];
let target = Event::read(&chat).unwrap().digest;
d.extend_from_slice(&target);
d.extend_from_slice(&7u16.to_le_bytes());
let decision = record(&k, [1; 32], 1, 9, KIND_DECISION, &d);
let e = Event::read(&decision).unwrap();
assert!(e.verifies(&decision));
assert_eq!(e.decision(), Some((REDACT, target)));
let mut forged = decision.clone();
forged[125] ^= 1;
assert!(!Event::read(&forged).unwrap().verifies(&forged), "a changed record does not verify");
// An object of two records: the withheld one is left out, the other kept byte for byte.
let obj = format!("[{},{}]", entry(&chat, 1), entry(&decision, 2)).into_bytes();
assert_eq!(digests(&obj), vec![target, e.digest]);
let kept = withhold(&obj, |x| *x == target).unwrap();
assert_eq!(&*kept, format!("[{}]", entry(&decision, 2)).as_bytes());
assert!(withhold(&obj, |_| true).is_none(), "every record withheld: the object is");
assert_eq!(&*withhold(&obj, |_| false).unwrap(), &obj[..]);
}
}