OXYGENHUB

Coverage / crates/spectra-core/src/event.rs 94.0%

158 of 168 lines run, 46 of 57 functions · run #6 on main · run missed

1
//! The event envelope (spec §9.1) and how it is encoded and hashed (§9.5).
2
//!
3
//! An event's body is deterministic CBOR with integer keys, through
4
//! `oxy-core`'s encoder, exactly as Oxygen encodes its objects (oxygen spec
5
//! §5.1); its hash is `BLAKE3("spectra1 event " || len || 0x00 || body)`, the
6
//! domain-separated form of oxygen spec §5.2. The hash and the proof are not
7
//! in the body: signatures sit outside what they sign (oxygen spec §5.4).
8
//!
9
//! The envelope is frozen from the first release: fields may be added at new
10
//! keys, never changed or removed, so a hub merges logs from any version.
11
12
use crate::hlc::Hlc;
13
use oxy_core::cbor::{self, Value};
14
15
/// The envelope's version, key 0 of the body.
16
pub const ENVELOPE_VERSION: u64 = 1;
17
18
/// The field names of the envelope, in order: the frozen set (spec §9.1).
19
pub const ENVELOPE_FIELDS: &[&str] =
20
    &["seq", "id", "at_ms", "hlc", "prev", "hash", "auth", "kind", "actor", "session", "project", "subject", "payload", "trace"];
21
22
/// How the event's cause was authenticated (spec §8.5).
23
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24
pub enum Method {
25
    /// Made by Spectra itself (presence timers, checkpoints of its own);
26
    /// covered by the next checkpoint's signature.
27
    System = 0,
28
    /// A bearer token: possession only.
29
    Token = 1,
30
    /// A request signed with a key on disk.
31
    Signature = 2,
32
    /// A passkey assertion with user presence.
33
    Passkey = 3,
34
}
35
36
impl Method {
3740905×
    pub fn from_u8(n: u8) -> Option<Method> {
3840905×
        Some(match n {
3940567×
            0 => Method::System,
400×
            1 => Method::Token,
41338×
            2 => Method::Signature,
420×
            3 => Method::Passkey,
430×
            _ => return None,
44
        })
4540905×
    }
460×
    pub fn name(self) -> &'static str {
470×
        match self {
480×
            Method::System => "system",
490×
            Method::Token => "token",
500×
            Method::Signature => "signature",
510×
            Method::Passkey => "passkey",
52
        }
530×
    }
54
}
55
56
/// Who caused an event and the proof of it. `key` and the assurance level
57
/// are hashed; `proof` is carried beside the hash.
58
#[derive(Clone, Debug, PartialEq, Eq)]
59
pub struct Auth {
60
    pub key: Option<[u8; 32]>,
61
    pub method: Method,
62
    /// 0 for system events, else 1 to 3 (spec §8.5).
63
    pub assurance: u8,
64
    pub proof: Option<Vec<u8>>,
65
}
66
67
impl Auth {
68
    /// An event Spectra makes itself.
6911522×
    pub fn system() -> Auth {
7011522×
        Auth { key: None, method: Method::System, assurance: 0, proof: None }
7111522×
    }
72
}
73
74
/// An event before it is appended: what the caller decides.
75
#[derive(Clone, Debug)]
76
pub struct NewEvent {
77
    pub kind: String,
78
    pub actor: String,
79
    pub session: Option<String>,
80
    pub project: Option<String>,
81
    pub subject: Option<String>,
82
    pub payload: serde_json::Value,
83
    pub trace: Option<String>,
84
    pub auth: Auth,
85
}
86
87
impl NewEvent {
88
    /// A system event of `kind` with `payload`.
8911522×
    pub fn system(kind: &str, payload: serde_json::Value) -> NewEvent {
9011522×
        NewEvent {
9111522×
            kind: kind.into(),
9211522×
            actor: "system".into(),
9311522×
            session: None,
9411522×
            project: None,
9511522×
            subject: None,
9611522×
            payload,
9711522×
            trace: None,
9811522×
            auth: Auth::system(),
9911522×
        }
10011522×
    }
1011335×
    pub fn subject(mut self, s: impl Into<String>) -> NewEvent {
1021335×
        self.subject = Some(s.into());
1031335×
        self
1041335×
    }
1056×
    pub fn project(mut self, p: impl Into<String>) -> NewEvent {
1066×
        self.project = Some(p.into());
1076×
        self
1086×
    }
1095×
    pub fn session(mut self, s: impl Into<String>) -> NewEvent {
1105×
        self.session = Some(s.into());
1115×
        self
1125×
    }
113127×
    pub fn by(mut self, actor: impl Into<String>, auth: Auth) -> NewEvent {
114127×
        self.actor = actor.into();
115127×
        self.auth = auth;
116127×
        self
117127×
    }
118
}
119
120
/// An event in the ledger.
121
#[derive(Clone, Debug, PartialEq, Eq)]
122
pub struct Event {
123
    pub seq: u64,
124
    pub id: String,
125
    pub at_ms: u64,
126
    pub hlc: Hlc,
127
    pub prev: [u8; 32],
128
    pub hash: [u8; 32],
129
    pub auth: Auth,
130
    pub kind: String,
131
    pub actor: String,
132
    pub session: Option<String>,
133
    pub project: Option<String>,
134
    pub subject: Option<String>,
135
    /// Canonical JSON (sorted keys, no whitespace), hashed as stored;
136
    /// `None` once pruned to a stub.
137
    pub payload: Option<Vec<u8>>,
138
    pub trace: Option<String>,
139
}
140
141
impl Event {
142
    /// The payload as JSON (`null` for a stub).
14345786×
    pub fn json(&self) -> serde_json::Value {
14445786×
        self.payload.as_deref().and_then(|b| serde_json::from_slice(b).ok()).unwrap_or(serde_json::Value::Null)
14545786×
    }
146
147
    /// The canonical body: every envelope field but `hash` and the proof.
14822079×
    pub fn body(&self) -> Vec<u8> {
14988316×
        let opt = |s: &Option<String>| s.as_ref().map(|t| Value::Text(t.clone())).unwrap_or(Value::Null);
15022079×
        cbor::encode(&Value::map([
15122079×
            (0, Value::Uint(ENVELOPE_VERSION)),
15222079×
            (1, Value::Uint(self.seq)),
15322079×
            (2, Value::Text(self.id.clone())),
15422079×
            (3, Value::Uint(self.at_ms)),
15522079×
            (4, Value::Array(vec![Value::Uint(self.hlc.wall_ms), Value::Uint(self.hlc.logical as u64)])),
15622079×
            (5, Value::Bytes(self.prev.to_vec())),
157
            (
158
                6,
15922079×
                Value::map([
16022079×
                    (0, self.auth.key.map(|k| Value::Bytes(k.to_vec())).unwrap_or(Value::Null)),
16122079×
                    (1, Value::Uint(self.auth.method as u64)),
16222079×
                    (2, Value::Uint(self.auth.assurance as u64)),
163
                ]),
164
            ),
16522079×
            (7, Value::Text(self.kind.clone())),
16622079×
            (8, Value::Text(self.actor.clone())),
16722079×
            (9, opt(&self.session)),
16822079×
            (10, opt(&self.project)),
16922079×
            (11, opt(&self.subject)),
17022079×
            (12, self.payload.as_ref().map(|p| Value::Bytes(p.clone())).unwrap_or(Value::Null)),
17122079×
            (13, opt(&self.trace)),
172
        ]))
17322079×
    }
174
175
    /// The hash this event's fields give; equal to `hash` unless edited.
17622079×
    pub fn computed_hash(&self) -> [u8; 32] {
17722079×
        hash_body(&self.body())
17822079×
    }
179
}
180
181
/// `BLAKE3("spectra1 event " || decimal(len) || 0x00 || body)`.
18222079×
pub fn hash_body(body: &[u8]) -> [u8; 32] {
18322079×
    let mut h = blake3::Hasher::new();
18422079×
    h.update(b"spectra1 event ");
18522079×
    h.update(body.len().to_string().as_bytes());
18622079×
    h.update(&[0]);
18722079×
    h.update(body);
18822079×
    *h.finalize().as_bytes()
18922079×
}
190
191
/// JSON with object keys sorted at every level and no whitespace, so equal
192
/// payloads are equal bytes whatever built them.
19311581×
pub fn canonical_json(v: &serde_json::Value) -> Vec<u8> {
19435025×
    fn sort(v: &serde_json::Value) -> serde_json::Value {
19535025×
        match v {
19611585×
            serde_json::Value::Object(m) => {
19711585×
                let mut keys: Vec<&String> = m.keys().collect();
19811585×
                keys.sort();
19911585×
                let mut out = serde_json::Map::new();
20023421×
                for k in keys {
20123421×
                    out.insert(k.clone(), sort(&m[k]));
20223421×
                }
20311585×
                serde_json::Value::Object(out)
204
            }
20539×
            serde_json::Value::Array(a) => serde_json::Value::Array(a.iter().map(sort).collect()),
20623401×
            other => other.clone(),
207
        }
20835025×
    }
20911581×
    serde_json::to_vec(&sort(v)).expect("JSON values always serialise")
21011581×
}
211
212
#[cfg(test)]
213
mod tests {
214
    use super::*;
215
2161×
    fn sample() -> Event {
2171×
        Event {
2181×
            seq: 7,
2191×
            id: "01J0000000000000000000000Z".into(),
2201×
            at_ms: 1_700_000_000_000,
2211×
            hlc: Hlc { wall_ms: 1_700_000_000_000, logical: 2 },
2221×
            prev: [9; 32],
2231×
            hash: [0; 32],
2241×
            auth: Auth { key: Some([1; 32]), method: Method::Signature, assurance: 2, proof: Some(vec![5, 5]) },
2251×
            kind: "message.posted".into(),
2261×
            actor: "agent:claude-oxygen".into(),
2271×
            session: None,
2281×
            project: Some("oxygen".into()),
2291×
            subject: Some("m1".into()),
2301×
            payload: Some(canonical_json(&serde_json::json!({"b": 1, "a": [{"z": 1, "y": 2}]}))),
2311×
            trace: None,
2321×
        }
2331×
    }
234
235
    #[test]
2361×
    fn every_field_but_the_proof_changes_the_hash() {
2371×
        let e = sample();
2381×
        let h = e.computed_hash();
2391×
        let mut p = e.clone();
2401×
        p.auth.proof = None;
2411×
        assert_eq!(p.computed_hash(), h, "the proof is outside the hash");
242
        type Edit = Box<dyn Fn(&mut Event)>;
2431×
        let edits: Vec<Edit> = vec![
2441×
            Box::new(|e| e.seq += 1),
2451×
            Box::new(|e| e.id.push('X')),
2461×
            Box::new(|e| e.at_ms += 1),
2471×
            Box::new(|e| e.hlc.logical += 1),
2481×
            Box::new(|e| e.prev[0] ^= 1),
2491×
            Box::new(|e| e.auth.key = None),
2501×
            Box::new(|e| e.auth.assurance = 3),
2511×
            Box::new(|e| e.auth.method = Method::Token),
2521×
            Box::new(|e| e.kind.push('x')),
2531×
            Box::new(|e| e.actor.push('x')),
2541×
            Box::new(|e| e.session = Some("s".into())),
2551×
            Box::new(|e| e.project = None),
2561×
            Box::new(|e| e.subject = None),
2571×
            Box::new(|e| e.payload = Some(b"{}".to_vec())),
2581×
            Box::new(|e| e.trace = Some("t".into())),
259
        ];
26015×
        for edit in edits {
26115×
            let mut x = e.clone();
26215×
            edit(&mut x);
26315×
            assert_ne!(x.computed_hash(), h);
264
        }
2651×
    }
266
267
    #[test]
2681×
    fn canonical_json_sorts_keys_at_every_level() {
2691×
        let a = canonical_json(&serde_json::json!({"b": 1, "a": {"d": 1, "c": 2}}));
2701×
        assert_eq!(a, br#"{"a":{"c":2,"d":1},"b":1}"#);
2711×
    }
272
273
    #[test]
2741×
    fn the_envelope_is_frozen() {
2751×
        assert_eq!(
2761×
            ENVELOPE_FIELDS.join(","),
277
            "seq,id,at_ms,hlc,prev,hash,auth,kind,actor,session,project,subject,payload,trace",
278
            "the envelope is frozen at 0.1: add fields at new keys, never change these (spec §9.1)"
279
        );
2801×
        assert_eq!(ENVELOPE_VERSION, 1);
2811×
    }
282
}