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