OXYGENHUB

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

161 of 171 lines run, 50 of 61 functions · run #8 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 {
3741257×
    pub fn from_u8(n: u8) -> Option<Method> {
3841257×
        Some(match n {
3940658×
            0 => Method::System,
400×
            1 => Method::Token,
41599×
            2 => Method::Signature,
420×
            3 => Method::Passkey,
430×
            _ => return None,
44
        })
4541257×
    }
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.
6911666×
    pub fn system() -> Auth {
7011666×
        Auth { key: None, method: Method::System, assurance: 0, proof: None }
7111666×
    }
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`.
8911665×
    pub fn system(kind: &str, payload: serde_json::Value) -> NewEvent {
9011665×
        NewEvent {
9111665×
            kind: kind.into(),
9211665×
            actor: "system".into(),
9311665×
            session: None,
9411665×
            project: None,
9511665×
            subject: None,
9611665×
            payload,
9711665×
            trace: None,
9811665×
            auth: Auth::system(),
9911665×
        }
10011665×
    }
1011414×
    pub fn subject(mut self, s: impl Into<String>) -> NewEvent {
1021414×
        self.subject = Some(s.into());
1031414×
        self
1041414×
    }
10510×
    pub fn project(mut self, p: impl Into<String>) -> NewEvent {
10610×
        self.project = Some(p.into());
10710×
        self
10810×
    }
1098×
    pub fn session(mut self, s: impl Into<String>) -> NewEvent {
1108×
        self.session = Some(s.into());
1118×
        self
1128×
    }
113211×
    pub fn by(mut self, actor: impl Into<String>, auth: Auth) -> NewEvent {
114211×
        self.actor = actor.into();
115211×
        self.auth = auth;
116211×
        self
117211×
    }
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).
14388358×
    pub fn json(&self) -> serde_json::Value {
14488358×
        self.payload.as_deref().and_then(|b| serde_json::from_slice(b).ok()).unwrap_or(serde_json::Value::Null)
14588358×
    }
146
147
    /// The canonical body: every envelope field but `hash` and the proof.
14822346×
    pub fn body(&self) -> Vec<u8> {
14989384×
        let opt = |s: &Option<String>| s.as_ref().map(|t| Value::Text(t.clone())).unwrap_or(Value::Null);
15022346×
        cbor::encode(&Value::map([
15122346×
            (0, Value::Uint(ENVELOPE_VERSION)),
15222346×
            (1, Value::Uint(self.seq)),
15322346×
            (2, Value::Text(self.id.clone())),
15422346×
            (3, Value::Uint(self.at_ms)),
15522346×
            (4, Value::Array(vec![Value::Uint(self.hlc.wall_ms), Value::Uint(self.hlc.logical as u64)])),
15622346×
            (5, Value::Bytes(self.prev.to_vec())),
157
            (
158
                6,
15922346×
                Value::map([
16022346×
                    (0, self.auth.key.map(|k| Value::Bytes(k.to_vec())).unwrap_or(Value::Null)),
16122346×
                    (1, Value::Uint(self.auth.method as u64)),
16222346×
                    (2, Value::Uint(self.auth.assurance as u64)),
163
                ]),
164
            ),
16522346×
            (7, Value::Text(self.kind.clone())),
16622346×
            (8, Value::Text(self.actor.clone())),
16722346×
            (9, opt(&self.session)),
16822346×
            (10, opt(&self.project)),
16922346×
            (11, opt(&self.subject)),
17022346×
            (12, self.payload.as_ref().map(|p| Value::Bytes(p.clone())).unwrap_or(Value::Null)),
17122346×
            (13, opt(&self.trace)),
172
        ]))
17322346×
    }
174
175
    /// The hash this event's fields give; equal to `hash` unless edited.
17622346×
    pub fn computed_hash(&self) -> [u8; 32] {
17722346×
        hash_body(&self.body())
17822346×
    }
179
}
180
181
/// `BLAKE3("spectra1 event " || decimal(len) || 0x00 || body)`.
18222346×
pub fn hash_body(body: &[u8]) -> [u8; 32] {
18322346×
    let mut h = blake3::Hasher::new();
18422346×
    h.update(b"spectra1 event ");
18522346×
    h.update(body.len().to_string().as_bytes());
18622346×
    h.update(&[0]);
18722346×
    h.update(body);
18822346×
    *h.finalize().as_bytes()
18922346×
}
190
191
/// Plain BLAKE3 of some bytes (a request body's hash in a proof).
19212×
pub fn hash_body_plain(b: &[u8]) -> Box<[u8; 32]> {
19312×
    Box::new(*blake3::hash(b).as_bytes())
19412×
}
195
196
/// JSON with object keys sorted at every level and no whitespace, so equal
197
/// payloads are equal bytes whatever built them.
19811789×
pub fn canonical_json(v: &serde_json::Value) -> Vec<u8> {
19936569×
    fn sort(v: &serde_json::Value) -> serde_json::Value {
20036569×
        match v {
20111802×
            serde_json::Value::Object(m) => {
20211802×
                let mut keys: Vec<&String> = m.keys().collect();
20311802×
                keys.sort();
20411802×
                let mut out = serde_json::Map::new();
20524536×
                for k in keys {
20624536×
                    out.insert(k.clone(), sort(&m[k]));
20724536×
                }
20811802×
                serde_json::Value::Object(out)
209
            }
210231×
            serde_json::Value::Array(a) => serde_json::Value::Array(a.iter().map(sort).collect()),
21124536×
            other => other.clone(),
212
        }
21336569×
    }
21411789×
    serde_json::to_vec(&sort(v)).expect("JSON values always serialise")
21511789×
}
216
217
#[cfg(test)]
218
mod tests {
219
    use super::*;
220
2211×
    fn sample() -> Event {
2221×
        Event {
2231×
            seq: 7,
2241×
            id: "01J0000000000000000000000Z".into(),
2251×
            at_ms: 1_700_000_000_000,
2261×
            hlc: Hlc { wall_ms: 1_700_000_000_000, logical: 2 },
2271×
            prev: [9; 32],
2281×
            hash: [0; 32],
2291×
            auth: Auth { key: Some([1; 32]), method: Method::Signature, assurance: 2, proof: Some(vec![5, 5]) },
2301×
            kind: "message.posted".into(),
2311×
            actor: "agent:claude-oxygen".into(),
2321×
            session: None,
2331×
            project: Some("oxygen".into()),
2341×
            subject: Some("m1".into()),
2351×
            payload: Some(canonical_json(&serde_json::json!({"b": 1, "a": [{"z": 1, "y": 2}]}))),
2361×
            trace: None,
2371×
        }
2381×
    }
239
240
    #[test]
2411×
    fn every_field_but_the_proof_changes_the_hash() {
2421×
        let e = sample();
2431×
        let h = e.computed_hash();
2441×
        let mut p = e.clone();
2451×
        p.auth.proof = None;
2461×
        assert_eq!(p.computed_hash(), h, "the proof is outside the hash");
247
        type Edit = Box<dyn Fn(&mut Event)>;
2481×
        let edits: Vec<Edit> = vec![
2491×
            Box::new(|e| e.seq += 1),
2501×
            Box::new(|e| e.id.push('X')),
2511×
            Box::new(|e| e.at_ms += 1),
2521×
            Box::new(|e| e.hlc.logical += 1),
2531×
            Box::new(|e| e.prev[0] ^= 1),
2541×
            Box::new(|e| e.auth.key = None),
2551×
            Box::new(|e| e.auth.assurance = 3),
2561×
            Box::new(|e| e.auth.method = Method::Token),
2571×
            Box::new(|e| e.kind.push('x')),
2581×
            Box::new(|e| e.actor.push('x')),
2591×
            Box::new(|e| e.session = Some("s".into())),
2601×
            Box::new(|e| e.project = None),
2611×
            Box::new(|e| e.subject = None),
2621×
            Box::new(|e| e.payload = Some(b"{}".to_vec())),
2631×
            Box::new(|e| e.trace = Some("t".into())),
264
        ];
26515×
        for edit in edits {
26615×
            let mut x = e.clone();
26715×
            edit(&mut x);
26815×
            assert_ne!(x.computed_hash(), h);
269
        }
2701×
    }
271
272
    #[test]
2731×
    fn canonical_json_sorts_keys_at_every_level() {
2741×
        let a = canonical_json(&serde_json::json!({"b": 1, "a": {"d": 1, "c": 2}}));
2751×
        assert_eq!(a, br#"{"a":{"c":2,"d":1},"b":1}"#);
2761×
    }
277
278
    #[test]
2791×
    fn the_envelope_is_frozen() {
2801×
        assert_eq!(
2811×
            ENVELOPE_FIELDS.join(","),
282
            "seq,id,at_ms,hlc,prev,hash,auth,kind,actor,session,project,subject,payload,trace",
283
            "the envelope is frozen at 0.1: add fields at new keys, never change these (spec §9.1)"
284
        );
2851×
        assert_eq!(ENVELOPE_VERSION, 1);
2861×
    }
287
}