OXYGENHUB
# 5d695d869fbf spectra / crates / spectra-core / src / event.rs
9.5 KiB · Rust · last changed The ledger: hash-chained events in the frozen envelope, signed checkpoints, admin verify, check and rebuild (#3) 2 days ago source blame history raw
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 {
37
    pub fn from_u8(n: u8) -> Option<Method> {
38
        Some(match n {
39
            0 => Method::System,
40
            1 => Method::Token,
41
            2 => Method::Signature,
42
            3 => Method::Passkey,
43
            _ => return None,
44
        })
45
    }
46
    pub fn name(self) -> &'static str {
47
        match self {
48
            Method::System => "system",
49
            Method::Token => "token",
50
            Method::Signature => "signature",
51
            Method::Passkey => "passkey",
52
        }
53
    }
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.
69
    pub fn system() -> Auth {
70
        Auth { key: None, method: Method::System, assurance: 0, proof: None }
71
    }
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`.
89
    pub fn system(kind: &str, payload: serde_json::Value) -> NewEvent {
90
        NewEvent {
91
            kind: kind.into(),
92
            actor: "system".into(),
93
            session: None,
94
            project: None,
95
            subject: None,
96
            payload,
97
            trace: None,
98
            auth: Auth::system(),
99
        }
100
    }
101
    pub fn subject(mut self, s: impl Into<String>) -> NewEvent {
102
        self.subject = Some(s.into());
103
        self
104
    }
105
    pub fn project(mut self, p: impl Into<String>) -> NewEvent {
106
        self.project = Some(p.into());
107
        self
108
    }
109
    pub fn session(mut self, s: impl Into<String>) -> NewEvent {
110
        self.session = Some(s.into());
111
        self
112
    }
113
    pub fn by(mut self, actor: impl Into<String>, auth: Auth) -> NewEvent {
114
        self.actor = actor.into();
115
        self.auth = auth;
116
        self
117
    }
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).
143
    pub fn json(&self) -> serde_json::Value {
144
        self.payload.as_deref().and_then(|b| serde_json::from_slice(b).ok()).unwrap_or(serde_json::Value::Null)
145
    }
146
147
    /// The canonical body: every envelope field but `hash` and the proof.
148
    pub fn body(&self) -> Vec<u8> {
149
        let opt = |s: &Option<String>| s.as_ref().map(|t| Value::Text(t.clone())).unwrap_or(Value::Null);
150
        cbor::encode(&Value::map([
151
            (0, Value::Uint(ENVELOPE_VERSION)),
152
            (1, Value::Uint(self.seq)),
153
            (2, Value::Text(self.id.clone())),
154
            (3, Value::Uint(self.at_ms)),
155
            (4, Value::Array(vec![Value::Uint(self.hlc.wall_ms), Value::Uint(self.hlc.logical as u64)])),
156
            (5, Value::Bytes(self.prev.to_vec())),
157
            (
158
                6,
159
                Value::map([
160
                    (0, self.auth.key.map(|k| Value::Bytes(k.to_vec())).unwrap_or(Value::Null)),
161
                    (1, Value::Uint(self.auth.method as u64)),
162
                    (2, Value::Uint(self.auth.assurance as u64)),
163
                ]),
164
            ),
165
            (7, Value::Text(self.kind.clone())),
166
            (8, Value::Text(self.actor.clone())),
167
            (9, opt(&self.session)),
168
            (10, opt(&self.project)),
169
            (11, opt(&self.subject)),
170
            (12, self.payload.as_ref().map(|p| Value::Bytes(p.clone())).unwrap_or(Value::Null)),
171
            (13, opt(&self.trace)),
172
        ]))
173
    }
174
175
    /// The hash this event's fields give; equal to `hash` unless edited.
176
    pub fn computed_hash(&self) -> [u8; 32] {
177
        hash_body(&self.body())
178
    }
179
}
180
181
/// `BLAKE3("spectra1 event " || decimal(len) || 0x00 || body)`.
182
pub fn hash_body(body: &[u8]) -> [u8; 32] {
183
    let mut h = blake3::Hasher::new();
184
    h.update(b"spectra1 event ");
185
    h.update(body.len().to_string().as_bytes());
186
    h.update(&[0]);
187
    h.update(body);
188
    *h.finalize().as_bytes()
189
}
190
191
/// JSON with object keys sorted at every level and no whitespace, so equal
192
/// payloads are equal bytes whatever built them.
193
pub fn canonical_json(v: &serde_json::Value) -> Vec<u8> {
194
    fn sort(v: &serde_json::Value) -> serde_json::Value {
195
        match v {
196
            serde_json::Value::Object(m) => {
197
                let mut keys: Vec<&String> = m.keys().collect();
198
                keys.sort();
199
                let mut out = serde_json::Map::new();
200
                for k in keys {
201
                    out.insert(k.clone(), sort(&m[k]));
202
                }
203
                serde_json::Value::Object(out)
204
            }
205
            serde_json::Value::Array(a) => serde_json::Value::Array(a.iter().map(sort).collect()),
206
            other => other.clone(),
207
        }
208
    }
209
    serde_json::to_vec(&sort(v)).expect("JSON values always serialise")
210
}
211
212
#[cfg(test)]
213
mod tests {
214
    use super::*;
215
216
    fn sample() -> Event {
217
        Event {
218
            seq: 7,
219
            id: "01J0000000000000000000000Z".into(),
220
            at_ms: 1_700_000_000_000,
221
            hlc: Hlc { wall_ms: 1_700_000_000_000, logical: 2 },
222
            prev: [9; 32],
223
            hash: [0; 32],
224
            auth: Auth { key: Some([1; 32]), method: Method::Signature, assurance: 2, proof: Some(vec![5, 5]) },
225
            kind: "message.posted".into(),
226
            actor: "agent:claude-oxygen".into(),
227
            session: None,
228
            project: Some("oxygen".into()),
229
            subject: Some("m1".into()),
230
            payload: Some(canonical_json(&serde_json::json!({"b": 1, "a": [{"z": 1, "y": 2}]}))),
231
            trace: None,
232
        }
233
    }
234
235
    #[test]
236
    fn every_field_but_the_proof_changes_the_hash() {
237
        let e = sample();
238
        let h = e.computed_hash();
239
        let mut p = e.clone();
240
        p.auth.proof = None;
241
        assert_eq!(p.computed_hash(), h, "the proof is outside the hash");
242
        type Edit = Box<dyn Fn(&mut Event)>;
243
        let edits: Vec<Edit> = vec![
244
            Box::new(|e| e.seq += 1),
245
            Box::new(|e| e.id.push('X')),
246
            Box::new(|e| e.at_ms += 1),
247
            Box::new(|e| e.hlc.logical += 1),
248
            Box::new(|e| e.prev[0] ^= 1),
249
            Box::new(|e| e.auth.key = None),
250
            Box::new(|e| e.auth.assurance = 3),
251
            Box::new(|e| e.auth.method = Method::Token),
252
            Box::new(|e| e.kind.push('x')),
253
            Box::new(|e| e.actor.push('x')),
254
            Box::new(|e| e.session = Some("s".into())),
255
            Box::new(|e| e.project = None),
256
            Box::new(|e| e.subject = None),
257
            Box::new(|e| e.payload = Some(b"{}".to_vec())),
258
            Box::new(|e| e.trace = Some("t".into())),
259
        ];
260
        for edit in edits {
261
            let mut x = e.clone();
262
            edit(&mut x);
263
            assert_ne!(x.computed_hash(), h);
264
        }
265
    }
266
267
    #[test]
268
    fn canonical_json_sorts_keys_at_every_level() {
269
        let a = canonical_json(&serde_json::json!({"b": 1, "a": {"d": 1, "c": 2}}));
270
        assert_eq!(a, br#"{"a":{"c":2,"d":1},"b":1}"#);
271
    }
272
273
    #[test]
274
    fn the_envelope_is_frozen() {
275
        assert_eq!(
276
            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
        );
280
        assert_eq!(ENVELOPE_VERSION, 1);
281
    }
282
}