OXYGENHUB

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

168 of 173 lines run, 51 of 61 functions · run #18 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
    /// A Radar session opened by a link the owner signed with their CLI key:
35
    /// the proof is that signed request (spec §8.3).
36
    Session = 4,
37
}
38
39
impl Method {
4041958×
    pub fn from_u8(n: u8) -> Option<Method> {
4141958×
        Some(match n {
4241012×
            0 => Method::System,
430×
            1 => Method::Token,
44943×
            2 => Method::Signature,
450×
            3 => Method::Passkey,
463×
            4 => Method::Session,
470×
            _ => return None,
48
        })
4941958×
    }
5060×
    pub fn name(self) -> &'static str {
5160×
        match self {
5219×
            Method::System => "system",
530×
            Method::Token => "token",
5440×
            Method::Signature => "signature",
550×
            Method::Passkey => "passkey",
561×
            Method::Session => "session",
57
        }
5860×
    }
59
}
60
61
/// Who caused an event and the proof of it. `key` and the assurance level
62
/// are hashed; `proof` is carried beside the hash.
63
#[derive(Clone, Debug, PartialEq, Eq)]
64
pub struct Auth {
65
    pub key: Option<[u8; 32]>,
66
    pub method: Method,
67
    /// 0 for system events, else 1 to 3 (spec §8.5).
68
    pub assurance: u8,
69
    pub proof: Option<Vec<u8>>,
70
}
71
72
impl Auth {
73
    /// An event Spectra makes itself.
7412196×
    pub fn system() -> Auth {
7512196×
        Auth { key: None, method: Method::System, assurance: 0, proof: None }
7612196×
    }
77
}
78
79
/// An event before it is appended: what the caller decides.
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
    /// A system event of `kind` with `payload`.
9412195×
    pub fn system(kind: &str, payload: serde_json::Value) -> NewEvent {
9512195×
        NewEvent {
9612195×
            kind: kind.into(),
9712195×
            actor: "system".into(),
9812195×
            session: None,
9912195×
            project: None,
10012195×
            subject: None,
10112195×
            payload,
10212195×
            trace: None,
10312195×
            auth: Auth::system(),
10412195×
        }
10512195×
    }
1061805×
    pub fn subject(mut self, s: impl Into<String>) -> NewEvent {
1071805×
        self.subject = Some(s.into());
1081805×
        self
1091805×
    }
11022×
    pub fn project(mut self, p: impl Into<String>) -> NewEvent {
11122×
        self.project = Some(p.into());
11222×
        self
11322×
    }
11418×
    pub fn session(mut self, s: impl Into<String>) -> NewEvent {
11518×
        self.session = Some(s.into());
11618×
        self
11718×
    }
118379×
    pub fn by(mut self, actor: impl Into<String>, auth: Auth) -> NewEvent {
119379×
        self.actor = actor.into();
120379×
        self.auth = auth;
121379×
        self
122379×
    }
123
}
124
125
/// An event in the ledger.
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
    /// Canonical JSON (sorted keys, no whitespace), hashed as stored;
141
    /// `None` once pruned to a stub.
142
    pub payload: Option<Vec<u8>>,
143
    pub trace: Option<String>,
144
}
145
146
impl Event {
147
    /// The payload as JSON (`null` for a stub).
148132408×
    pub fn json(&self) -> serde_json::Value {
149132408×
        self.payload.as_deref().and_then(|b| serde_json::from_slice(b).ok()).unwrap_or(serde_json::Value::Null)
150132408×
    }
151
152
    /// The canonical body: every envelope field but `hash` and the proof.
15323306×
    pub fn body(&self) -> Vec<u8> {
15493224×
        let opt = |s: &Option<String>| s.as_ref().map(|t| Value::Text(t.clone())).unwrap_or(Value::Null);
15523306×
        cbor::encode(&Value::map([
15623306×
            (0, Value::Uint(ENVELOPE_VERSION)),
15723306×
            (1, Value::Uint(self.seq)),
15823306×
            (2, Value::Text(self.id.clone())),
15923306×
            (3, Value::Uint(self.at_ms)),
16023306×
            (4, Value::Array(vec![Value::Uint(self.hlc.wall_ms), Value::Uint(self.hlc.logical as u64)])),
16123306×
            (5, Value::Bytes(self.prev.to_vec())),
162
            (
163
                6,
16423306×
                Value::map([
16523306×
                    (0, self.auth.key.map(|k| Value::Bytes(k.to_vec())).unwrap_or(Value::Null)),
16623306×
                    (1, Value::Uint(self.auth.method as u64)),
16723306×
                    (2, Value::Uint(self.auth.assurance as u64)),
168
                ]),
169
            ),
17023306×
            (7, Value::Text(self.kind.clone())),
17123306×
            (8, Value::Text(self.actor.clone())),
17223306×
            (9, opt(&self.session)),
17323306×
            (10, opt(&self.project)),
17423306×
            (11, opt(&self.subject)),
17523306×
            (12, self.payload.as_ref().map(|p| Value::Bytes(p.clone())).unwrap_or(Value::Null)),
17623306×
            (13, opt(&self.trace)),
177
        ]))
17823306×
    }
179
180
    /// The hash this event's fields give; equal to `hash` unless edited.
18123306×
    pub fn computed_hash(&self) -> [u8; 32] {
18223306×
        hash_body(&self.body())
18323306×
    }
184
}
185
186
/// `BLAKE3("spectra1 event " || decimal(len) || 0x00 || body)`.
18723306×
pub fn hash_body(body: &[u8]) -> [u8; 32] {
18823306×
    let mut h = blake3::Hasher::new();
18923306×
    h.update(b"spectra1 event ");
19023306×
    h.update(body.len().to_string().as_bytes());
19123306×
    h.update(&[0]);
19223306×
    h.update(body);
19323306×
    *h.finalize().as_bytes()
19423306×
}
195
196
/// Plain BLAKE3 of some bytes (a request body's hash in a proof).
19789×
pub fn hash_body_plain(b: &[u8]) -> Box<[u8; 32]> {
19889×
    Box::new(*blake3::hash(b).as_bytes())
19989×
}
200
201
/// JSON with object keys sorted at every level and no whitespace, so equal
202
/// payloads are equal bytes whatever built them.
20312388×
pub fn canonical_json(v: &serde_json::Value) -> Vec<u8> {
20439578×
    fn sort(v: &serde_json::Value) -> serde_json::Value {
20539578×
        match v {
20612429×
            serde_json::Value::Object(m) => {
20712429×
                let mut keys: Vec<&String> = m.keys().collect();
20812429×
                keys.sort();
20912429×
                let mut out = serde_json::Map::new();
21026670×
                for k in keys {
21126670×
                    out.insert(k.clone(), sort(&m[k]));
21226670×
                }
21312429×
                serde_json::Value::Object(out)
214
            }
215429×
            serde_json::Value::Array(a) => serde_json::Value::Array(a.iter().map(sort).collect()),
21626720×
            other => other.clone(),
217
        }
21839578×
    }
21912388×
    serde_json::to_vec(&sort(v)).expect("JSON values always serialise")
22012388×
}
221
222
#[cfg(test)]
223
mod tests {
224
    use super::*;
225
2261×
    fn sample() -> Event {
2271×
        Event {
2281×
            seq: 7,
2291×
            id: "01J0000000000000000000000Z".into(),
2301×
            at_ms: 1_700_000_000_000,
2311×
            hlc: Hlc { wall_ms: 1_700_000_000_000, logical: 2 },
2321×
            prev: [9; 32],
2331×
            hash: [0; 32],
2341×
            auth: Auth { key: Some([1; 32]), method: Method::Signature, assurance: 2, proof: Some(vec![5, 5]) },
2351×
            kind: "message.posted".into(),
2361×
            actor: "agent:claude-oxygen".into(),
2371×
            session: None,
2381×
            project: Some("oxygen".into()),
2391×
            subject: Some("m1".into()),
2401×
            payload: Some(canonical_json(&serde_json::json!({"b": 1, "a": [{"z": 1, "y": 2}]}))),
2411×
            trace: None,
2421×
        }
2431×
    }
244
245
    #[test]
2461×
    fn every_field_but_the_proof_changes_the_hash() {
2471×
        let e = sample();
2481×
        let h = e.computed_hash();
2491×
        let mut p = e.clone();
2501×
        p.auth.proof = None;
2511×
        assert_eq!(p.computed_hash(), h, "the proof is outside the hash");
252
        type Edit = Box<dyn Fn(&mut Event)>;
2531×
        let edits: Vec<Edit> = vec![
2541×
            Box::new(|e| e.seq += 1),
2551×
            Box::new(|e| e.id.push('X')),
2561×
            Box::new(|e| e.at_ms += 1),
2571×
            Box::new(|e| e.hlc.logical += 1),
2581×
            Box::new(|e| e.prev[0] ^= 1),
2591×
            Box::new(|e| e.auth.key = None),
2601×
            Box::new(|e| e.auth.assurance = 3),
2611×
            Box::new(|e| e.auth.method = Method::Token),
2621×
            Box::new(|e| e.kind.push('x')),
2631×
            Box::new(|e| e.actor.push('x')),
2641×
            Box::new(|e| e.session = Some("s".into())),
2651×
            Box::new(|e| e.project = None),
2661×
            Box::new(|e| e.subject = None),
2671×
            Box::new(|e| e.payload = Some(b"{}".to_vec())),
2681×
            Box::new(|e| e.trace = Some("t".into())),
269
        ];
27015×
        for edit in edits {
27115×
            let mut x = e.clone();
27215×
            edit(&mut x);
27315×
            assert_ne!(x.computed_hash(), h);
274
        }
2751×
    }
276
277
    #[test]
2781×
    fn canonical_json_sorts_keys_at_every_level() {
2791×
        let a = canonical_json(&serde_json::json!({"b": 1, "a": {"d": 1, "c": 2}}));
2801×
        assert_eq!(a, br#"{"a":{"c":2,"d":1},"b":1}"#);
2811×
    }
282
283
    #[test]
2841×
    fn the_envelope_is_frozen() {
2851×
        assert_eq!(
2861×
            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
        );
2901×
        assert_eq!(ENVELOPE_VERSION, 1);
2911×
    }
292
}