185 lines
7.7 KiB
Rust
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[..]);
|
|
}
|
|
}
|