//! The event envelope (spec §9.1) and how it is encoded and hashed (§9.5). //! //! An event's body is deterministic CBOR with integer keys, through //! `oxy-core`'s encoder, exactly as Oxygen encodes its objects (oxygen spec //! §5.1); its hash is `BLAKE3("spectra1 event " || len || 0x00 || body)`, the //! domain-separated form of oxygen spec §5.2. The hash and the proof are not //! in the body: signatures sit outside what they sign (oxygen spec §5.4). //! //! The envelope is frozen from the first release: fields may be added at new //! keys, never changed or removed, so a hub merges logs from any version. use crate::cbor::{self, Value}; use crate::hlc::Hlc; /// The envelope's version, key 0 of the body. pub const ENVELOPE_VERSION: u64 = 1; /// The field names of the envelope, in order: the frozen set (spec §9.1). pub const ENVELOPE_FIELDS: &[&str] = &["seq", "id", "at_ms", "hlc", "prev", "hash", "auth", "kind", "actor", "session", "project", "subject", "payload", "trace"]; /// How the event's cause was authenticated (spec §8.5). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Method { /// Made by Spectra itself (presence timers, checkpoints of its own); /// covered by the next checkpoint's signature. System = 0, /// A bearer token: possession only. Token = 1, /// A request signed with a key on disk. Signature = 2, /// A passkey assertion with user presence. Passkey = 3, /// A Radar session opened by a link the owner signed with their CLI key: /// the proof is that signed request (spec §8.3). Session = 4, } impl Method { pub fn from_u8(n: u8) -> Option { Some(match n { 0 => Method::System, 1 => Method::Token, 2 => Method::Signature, 3 => Method::Passkey, 4 => Method::Session, _ => return None, }) } pub fn name(self) -> &'static str { match self { Method::System => "system", Method::Token => "token", Method::Signature => "signature", Method::Passkey => "passkey", Method::Session => "session", } } } /// Who caused an event and the proof of it. `key` and the assurance level /// are hashed; `proof` is carried beside the hash. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Auth { pub key: Option<[u8; 32]>, pub method: Method, /// 0 for system events, else 1 to 3 (spec §8.5). pub assurance: u8, pub proof: Option>, } impl Auth { /// An event Spectra makes itself. pub fn system() -> Auth { Auth { key: None, method: Method::System, assurance: 0, proof: None } } } /// An event before it is appended: what the caller decides. #[derive(Clone, Debug)] pub struct NewEvent { pub kind: String, pub actor: String, pub session: Option, pub project: Option, pub subject: Option, pub payload: serde_json::Value, pub trace: Option, pub auth: Auth, } impl NewEvent { /// A system event of `kind` with `payload`. pub fn system(kind: &str, payload: serde_json::Value) -> NewEvent { NewEvent { kind: kind.into(), actor: "system".into(), session: None, project: None, subject: None, payload, trace: None, auth: Auth::system(), } } pub fn subject(mut self, s: impl Into) -> NewEvent { self.subject = Some(s.into()); self } pub fn project(mut self, p: impl Into) -> NewEvent { self.project = Some(p.into()); self } pub fn session(mut self, s: impl Into) -> NewEvent { self.session = Some(s.into()); self } pub fn by(mut self, actor: impl Into, auth: Auth) -> NewEvent { self.actor = actor.into(); self.auth = auth; self } } /// An event in the ledger. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Event { pub seq: u64, pub id: String, pub at_ms: u64, pub hlc: Hlc, pub prev: [u8; 32], pub hash: [u8; 32], pub auth: Auth, pub kind: String, pub actor: String, pub session: Option, pub project: Option, pub subject: Option, /// Canonical JSON (sorted keys, no whitespace), hashed as stored; /// `None` once pruned to a stub. pub payload: Option>, pub trace: Option, } impl Event { /// The payload as JSON (`null` for a stub). pub fn json(&self) -> serde_json::Value { self.payload.as_deref().and_then(|b| serde_json::from_slice(b).ok()).unwrap_or(serde_json::Value::Null) } /// The canonical body: every envelope field but `hash` and the proof. pub fn body(&self) -> Vec { let opt = |s: &Option| s.as_ref().map(|t| Value::Text(t.clone())).unwrap_or(Value::Null); cbor::encode(&Value::map([ (0, Value::Uint(ENVELOPE_VERSION)), (1, Value::Uint(self.seq)), (2, Value::Text(self.id.clone())), (3, Value::Uint(self.at_ms)), (4, Value::Array(vec![Value::Uint(self.hlc.wall_ms), Value::Uint(self.hlc.logical as u64)])), (5, Value::Bytes(self.prev.to_vec())), ( 6, Value::map([ (0, self.auth.key.map(|k| Value::Bytes(k.to_vec())).unwrap_or(Value::Null)), (1, Value::Uint(self.auth.method as u64)), (2, Value::Uint(self.auth.assurance as u64)), ]), ), (7, Value::Text(self.kind.clone())), (8, Value::Text(self.actor.clone())), (9, opt(&self.session)), (10, opt(&self.project)), (11, opt(&self.subject)), (12, self.payload.as_ref().map(|p| Value::Bytes(p.clone())).unwrap_or(Value::Null)), (13, opt(&self.trace)), ])) } /// The hash this event's fields give; equal to `hash` unless edited. pub fn computed_hash(&self) -> [u8; 32] { hash_body(&self.body()) } } /// `BLAKE3("spectra1 event " || decimal(len) || 0x00 || body)`. pub fn hash_body(body: &[u8]) -> [u8; 32] { let mut h = blake3::Hasher::new(); h.update(b"spectra1 event "); h.update(body.len().to_string().as_bytes()); h.update(&[0]); h.update(body); *h.finalize().as_bytes() } /// Plain BLAKE3 of some bytes (a request body's hash in a proof). pub fn hash_body_plain(b: &[u8]) -> Box<[u8; 32]> { Box::new(*blake3::hash(b).as_bytes()) } /// JSON with object keys sorted at every level and no whitespace, so equal /// payloads are equal bytes whatever built them. pub fn canonical_json(v: &serde_json::Value) -> Vec { fn sort(v: &serde_json::Value) -> serde_json::Value { match v { serde_json::Value::Object(m) => { let mut keys: Vec<&String> = m.keys().collect(); keys.sort(); let mut out = serde_json::Map::new(); for k in keys { out.insert(k.clone(), sort(&m[k])); } serde_json::Value::Object(out) } serde_json::Value::Array(a) => serde_json::Value::Array(a.iter().map(sort).collect()), other => other.clone(), } } serde_json::to_vec(&sort(v)).expect("JSON values always serialise") } #[cfg(test)] mod tests { use super::*; fn sample() -> Event { Event { seq: 7, id: "01J0000000000000000000000Z".into(), at_ms: 1_700_000_000_000, hlc: Hlc { wall_ms: 1_700_000_000_000, logical: 2 }, prev: [9; 32], hash: [0; 32], auth: Auth { key: Some([1; 32]), method: Method::Signature, assurance: 2, proof: Some(vec![5, 5]) }, kind: "message.posted".into(), actor: "agent:claude-oxygen".into(), session: None, project: Some("oxygen".into()), subject: Some("m1".into()), payload: Some(canonical_json(&serde_json::json!({"b": 1, "a": [{"z": 1, "y": 2}]}))), trace: None, } } #[test] fn every_field_but_the_proof_changes_the_hash() { let e = sample(); let h = e.computed_hash(); let mut p = e.clone(); p.auth.proof = None; assert_eq!(p.computed_hash(), h, "the proof is outside the hash"); type Edit = Box; let edits: Vec = vec![ Box::new(|e| e.seq += 1), Box::new(|e| e.id.push('X')), Box::new(|e| e.at_ms += 1), Box::new(|e| e.hlc.logical += 1), Box::new(|e| e.prev[0] ^= 1), Box::new(|e| e.auth.key = None), Box::new(|e| e.auth.assurance = 3), Box::new(|e| e.auth.method = Method::Token), Box::new(|e| e.kind.push('x')), Box::new(|e| e.actor.push('x')), Box::new(|e| e.session = Some("s".into())), Box::new(|e| e.project = None), Box::new(|e| e.subject = None), Box::new(|e| e.payload = Some(b"{}".to_vec())), Box::new(|e| e.trace = Some("t".into())), ]; for edit in edits { let mut x = e.clone(); edit(&mut x); assert_ne!(x.computed_hash(), h); } } #[test] fn canonical_json_sorts_keys_at_every_level() { let a = canonical_json(&serde_json::json!({"b": 1, "a": {"d": 1, "c": 2}})); assert_eq!(a, br#"{"a":{"c":2,"d":1},"b":1}"#); } #[test] fn the_envelope_is_frozen() { assert_eq!( ENVELOPE_FIELDS.join(","), "seq,id,at_ms,hlc,prev,hash,auth,kind,actor,session,project,subject,payload,trace", "the envelope is frozen at 0.1: add fields at new keys, never change these (spec §9.1)" ); assert_eq!(ENVELOPE_VERSION, 1); } }