| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 6 | |
|
| 7 | |
|
| 8 | |
|
| 9 | |
|
| 10 | |
|
| 11 | |
|
| 12 | | use crate::cbor::{self, Value}; |
| 13 | | use crate::hlc::Hlc; |
| 14 | |
|
| 15 | |
|
| 16 | | pub const ENVELOPE_VERSION: u64 = 1; |
| 17 | |
|
| 18 | |
|
| 19 | | pub const ENVELOPE_FIELDS: &[&str] = |
| 20 | | &["seq", "id", "at_ms", "hlc", "prev", "hash", "auth", "kind", "actor", "session", "project", "subject", "payload", "trace"]; |
| 21 | |
|
| 22 | |
|
| 23 | | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 24 | | pub enum Method { |
| 25 | | |
| 26 | | |
| 27 | | System = 0, |
| 28 | | |
| 29 | | Token = 1, |
| 30 | | |
| 31 | | Signature = 2, |
| 32 | | |
| 33 | | Passkey = 3, |
| 34 | | |
| 35 | | |
| 36 | | Session = 4, |
| 37 | | } |
| 38 | |
|
| 39 | | impl Method { |
| 40 | 42230× | pub fn from_u8(n: u8) -> Option<Method> { |
| 41 | 42230× | Some(match n { |
| 42 | 41034× | 0 => Method::System, |
| 43 | 0× | 1 => Method::Token, |
| 44 | 1192× | 2 => Method::Signature, |
| 45 | 0× | 3 => Method::Passkey, |
| 46 | 4× | 4 => Method::Session, |
| 47 | 0× | _ => return None, |
| 48 | | }) |
| 49 | 42230× | } |
| 50 | 69× | pub fn name(self) -> &'static str { |
| 51 | 69× | match self { |
| 52 | 22× | Method::System => "system", |
| 53 | 0× | Method::Token => "token", |
| 54 | 46× | Method::Signature => "signature", |
| 55 | 0× | Method::Passkey => "passkey", |
| 56 | 1× | Method::Session => "session", |
| 57 | | } |
| 58 | 69× | } |
| 59 | | } |
| 60 | |
|
| 61 | |
|
| 62 | |
|
| 63 | | #[derive(Clone, Debug, PartialEq, Eq)] |
| 64 | | pub struct Auth { |
| 65 | | pub key: Option<[u8; 32]>, |
| 66 | | pub method: Method, |
| 67 | | |
| 68 | | pub assurance: u8, |
| 69 | | pub proof: Option<Vec<u8>>, |
| 70 | | } |
| 71 | |
|
| 72 | | impl Auth { |
| 73 | | |
| 74 | 12275× | pub fn system() -> Auth { |
| 75 | 12275× | Auth { key: None, method: Method::System, assurance: 0, proof: None } |
| 76 | 12275× | } |
| 77 | | } |
| 78 | |
|
| 79 | |
|
| 80 | | #[derive(Clone, Debug)] |
| 81 | | pub struct NewEvent { |
| 82 | | pub kind: String, |
| 83 | | pub actor: String, |
| 84 | | pub session: Option<String>, |
| 85 | | pub project: Option<String>, |
| 86 | | pub subject: Option<String>, |
| 87 | | pub payload: serde_json::Value, |
| 88 | | pub trace: Option<String>, |
| 89 | | pub auth: Auth, |
| 90 | | } |
| 91 | |
|
| 92 | | impl NewEvent { |
| 93 | | |
| 94 | 12273× | pub fn system(kind: &str, payload: serde_json::Value) -> NewEvent { |
| 95 | 12273× | NewEvent { |
| 96 | 12273× | kind: kind.into(), |
| 97 | 12273× | actor: "system".into(), |
| 98 | 12273× | session: None, |
| 99 | 12273× | project: None, |
| 100 | 12273× | subject: None, |
| 101 | 12273× | payload, |
| 102 | 12273× | trace: None, |
| 103 | 12273× | auth: Auth::system(), |
| 104 | 12273× | } |
| 105 | 12273× | } |
| 106 | 1818× | pub fn subject(mut self, s: impl Into<String>) -> NewEvent { |
| 107 | 1818× | self.subject = Some(s.into()); |
| 108 | 1818× | self |
| 109 | 1818× | } |
| 110 | 23× | pub fn project(mut self, p: impl Into<String>) -> NewEvent { |
| 111 | 23× | self.project = Some(p.into()); |
| 112 | 23× | self |
| 113 | 23× | } |
| 114 | 19× | pub fn session(mut self, s: impl Into<String>) -> NewEvent { |
| 115 | 19× | self.session = Some(s.into()); |
| 116 | 19× | self |
| 117 | 19× | } |
| 118 | 445× | pub fn by(mut self, actor: impl Into<String>, auth: Auth) -> NewEvent { |
| 119 | 445× | self.actor = actor.into(); |
| 120 | 445× | self.auth = auth; |
| 121 | 445× | self |
| 122 | 445× | } |
| 123 | | } |
| 124 | |
|
| 125 | |
|
| 126 | | #[derive(Clone, Debug, PartialEq, Eq)] |
| 127 | | pub struct Event { |
| 128 | | pub seq: u64, |
| 129 | | pub id: String, |
| 130 | | pub at_ms: u64, |
| 131 | | pub hlc: Hlc, |
| 132 | | pub prev: [u8; 32], |
| 133 | | pub hash: [u8; 32], |
| 134 | | pub auth: Auth, |
| 135 | | pub kind: String, |
| 136 | | pub actor: String, |
| 137 | | pub session: Option<String>, |
| 138 | | pub project: Option<String>, |
| 139 | | pub subject: Option<String>, |
| 140 | | |
| 141 | | |
| 142 | | pub payload: Option<Vec<u8>>, |
| 143 | | pub trace: Option<String>, |
| 144 | | } |
| 145 | |
|
| 146 | | impl Event { |
| 147 | | |
| 148 | 171964× | pub fn json(&self) -> serde_json::Value { |
| 149 | 171964× | self.payload.as_deref().and_then(|b| serde_json::from_slice(b).ok()).unwrap_or(serde_json::Value::Null) |
| 150 | 171964× | } |
| 151 | |
|
| 152 | | |
| 153 | 23454× | pub fn body(&self) -> Vec<u8> { |
| 154 | 93816× | let opt = |s: &Option<String>| s.as_ref().map(|t| Value::Text(t.clone())).unwrap_or(Value::Null); |
| 155 | 23454× | cbor::encode(&Value::map([ |
| 156 | 23454× | (0, Value::Uint(ENVELOPE_VERSION)), |
| 157 | 23454× | (1, Value::Uint(self.seq)), |
| 158 | 23454× | (2, Value::Text(self.id.clone())), |
| 159 | 23454× | (3, Value::Uint(self.at_ms)), |
| 160 | 23454× | (4, Value::Array(vec![Value::Uint(self.hlc.wall_ms), Value::Uint(self.hlc.logical as u64)])), |
| 161 | 23454× | (5, Value::Bytes(self.prev.to_vec())), |
| 162 | | ( |
| 163 | | 6, |
| 164 | 23454× | Value::map([ |
| 165 | 23454× | (0, self.auth.key.map(|k| Value::Bytes(k.to_vec())).unwrap_or(Value::Null)), |
| 166 | 23454× | (1, Value::Uint(self.auth.method as u64)), |
| 167 | 23454× | (2, Value::Uint(self.auth.assurance as u64)), |
| 168 | | ]), |
| 169 | | ), |
| 170 | 23454× | (7, Value::Text(self.kind.clone())), |
| 171 | 23454× | (8, Value::Text(self.actor.clone())), |
| 172 | 23454× | (9, opt(&self.session)), |
| 173 | 23454× | (10, opt(&self.project)), |
| 174 | 23454× | (11, opt(&self.subject)), |
| 175 | 23454× | (12, self.payload.as_ref().map(|p| Value::Bytes(p.clone())).unwrap_or(Value::Null)), |
| 176 | 23454× | (13, opt(&self.trace)), |
| 177 | | ])) |
| 178 | 23454× | } |
| 179 | |
|
| 180 | | |
| 181 | 23454× | pub fn computed_hash(&self) -> [u8; 32] { |
| 182 | 23454× | hash_body(&self.body()) |
| 183 | 23454× | } |
| 184 | | } |
| 185 | |
|
| 186 | |
|
| 187 | 23454× | pub fn hash_body(body: &[u8]) -> [u8; 32] { |
| 188 | 23454× | let mut h = blake3::Hasher::new(); |
| 189 | 23454× | h.update(b"spectra1 event "); |
| 190 | 23454× | h.update(body.len().to_string().as_bytes()); |
| 191 | 23454× | h.update(&[0]); |
| 192 | 23454× | h.update(body); |
| 193 | 23454× | *h.finalize().as_bytes() |
| 194 | 23454× | } |
| 195 | |
|
| 196 | |
|
| 197 | 139× | pub fn hash_body_plain(b: &[u8]) -> Box<[u8; 32]> { |
| 198 | 139× | Box::new(*blake3::hash(b).as_bytes()) |
| 199 | 139× | } |
| 200 | |
|
| 201 | |
|
| 202 | |
|
| 203 | 12496× | pub fn canonical_json(v: &serde_json::Value) -> Vec<u8> { |
| 204 | 40039× | fn sort(v: &serde_json::Value) -> serde_json::Value { |
| 205 | 40039× | match v { |
| 206 | 12543× | serde_json::Value::Object(m) => { |
| 207 | 12543× | let mut keys: Vec<&String> = m.keys().collect(); |
| 208 | 12543× | keys.sort(); |
| 209 | 12543× | let mut out = serde_json::Map::new(); |
| 210 | 26998× | for k in keys { |
| 211 | 26998× | out.insert(k.clone(), sort(&m[k])); |
| 212 | 26998× | } |
| 213 | 12543× | serde_json::Value::Object(out) |
| 214 | | } |
| 215 | 408× | serde_json::Value::Array(a) => serde_json::Value::Array(a.iter().map(sort).collect()), |
| 216 | 27088× | other => other.clone(), |
| 217 | | } |
| 218 | 40039× | } |
| 219 | 12496× | serde_json::to_vec(&sort(v)).expect("JSON values always serialise") |
| 220 | 12496× | } |
| 221 | |
|
| 222 | | #[cfg(test)] |
| 223 | | mod tests { |
| 224 | | use super::*; |
| 225 | |
|
| 226 | 1× | fn sample() -> Event { |
| 227 | 1× | Event { |
| 228 | 1× | seq: 7, |
| 229 | 1× | id: "01J0000000000000000000000Z".into(), |
| 230 | 1× | at_ms: 1_700_000_000_000, |
| 231 | 1× | hlc: Hlc { wall_ms: 1_700_000_000_000, logical: 2 }, |
| 232 | 1× | prev: [9; 32], |
| 233 | 1× | hash: [0; 32], |
| 234 | 1× | auth: Auth { key: Some([1; 32]), method: Method::Signature, assurance: 2, proof: Some(vec![5, 5]) }, |
| 235 | 1× | kind: "message.posted".into(), |
| 236 | 1× | actor: "agent:claude-oxygen".into(), |
| 237 | 1× | session: None, |
| 238 | 1× | project: Some("oxygen".into()), |
| 239 | 1× | subject: Some("m1".into()), |
| 240 | 1× | payload: Some(canonical_json(&serde_json::json!({"b": 1, "a": [{"z": 1, "y": 2}]}))), |
| 241 | 1× | trace: None, |
| 242 | 1× | } |
| 243 | 1× | } |
| 244 | |
|
| 245 | | #[test] |
| 246 | 1× | fn every_field_but_the_proof_changes_the_hash() { |
| 247 | 1× | let e = sample(); |
| 248 | 1× | let h = e.computed_hash(); |
| 249 | 1× | let mut p = e.clone(); |
| 250 | 1× | p.auth.proof = None; |
| 251 | 1× | assert_eq!(p.computed_hash(), h, "the proof is outside the hash"); |
| 252 | | type Edit = Box<dyn Fn(&mut Event)>; |
| 253 | 1× | let edits: Vec<Edit> = vec![ |
| 254 | 1× | Box::new(|e| e.seq += 1), |
| 255 | 1× | Box::new(|e| e.id.push('X')), |
| 256 | 1× | Box::new(|e| e.at_ms += 1), |
| 257 | 1× | Box::new(|e| e.hlc.logical += 1), |
| 258 | 1× | Box::new(|e| e.prev[0] ^= 1), |
| 259 | 1× | Box::new(|e| e.auth.key = None), |
| 260 | 1× | Box::new(|e| e.auth.assurance = 3), |
| 261 | 1× | Box::new(|e| e.auth.method = Method::Token), |
| 262 | 1× | Box::new(|e| e.kind.push('x')), |
| 263 | 1× | Box::new(|e| e.actor.push('x')), |
| 264 | 1× | Box::new(|e| e.session = Some("s".into())), |
| 265 | 1× | Box::new(|e| e.project = None), |
| 266 | 1× | Box::new(|e| e.subject = None), |
| 267 | 1× | Box::new(|e| e.payload = Some(b"{}".to_vec())), |
| 268 | 1× | Box::new(|e| e.trace = Some("t".into())), |
| 269 | | ]; |
| 270 | 15× | for edit in edits { |
| 271 | 15× | let mut x = e.clone(); |
| 272 | 15× | edit(&mut x); |
| 273 | 15× | assert_ne!(x.computed_hash(), h); |
| 274 | | } |
| 275 | 1× | } |
| 276 | |
|
| 277 | | #[test] |
| 278 | 1× | fn canonical_json_sorts_keys_at_every_level() { |
| 279 | 1× | let a = canonical_json(&serde_json::json!({"b": 1, "a": {"d": 1, "c": 2}})); |
| 280 | 1× | assert_eq!(a, br#"{"a":{"c":2,"d":1},"b":1}"#); |
| 281 | 1× | } |
| 282 | |
|
| 283 | | #[test] |
| 284 | 1× | fn the_envelope_is_frozen() { |
| 285 | 1× | assert_eq!( |
| 286 | 1× | ENVELOPE_FIELDS.join(","), |
| 287 | | "seq,id,at_ms,hlc,prev,hash,auth,kind,actor,session,project,subject,payload,trace", |
| 288 | | "the envelope is frozen at 0.1: add fields at new keys, never change these (spec §9.1)" |
| 289 | | ); |
| 290 | 1× | assert_eq!(ENVELOPE_VERSION, 1); |
| 291 | 1× | } |
| 292 | | } |