Skip to main content

cortex_core/
tibet.rs

1use chrono::{DateTime, Utc};
2use serde::{Serialize, Deserialize};
3
4use crate::crypto::{ContentHash, KeyPair};
5use crate::error::{CortexError, CortexResult};
6
7/// TIBET provenance token — who did what, when, why
8#[derive(Clone, Debug, Serialize, Deserialize)]
9pub struct TibetToken {
10    pub token_id: String,
11    pub parent_id: Option<String>,
12    pub timestamp: DateTime<Utc>,
13
14    // ERIN — what's IN the action
15    pub erin: ContentHash,
16
17    // ERAAN — what's attached (dependencies, references)
18    pub eraan: Vec<String>,
19
20    // EROMHEEN — context around it
21    pub eromheen: Eromheen,
22
23    // ERACHTER — intent behind it
24    pub erachter: String,
25
26    // Signature over the token
27    pub signature: Option<Vec<u8>>,
28}
29
30/// Context surrounding a TIBET action
31#[derive(Clone, Debug, Serialize, Deserialize)]
32pub struct Eromheen {
33    pub actor: String,
34    pub jis_level: u8,
35    pub chunks_accessed: usize,
36    pub chunks_denied: usize,
37    pub airlock_session_ms: Option<f64>,
38}
39
40/// Full provenance chain — append-only audit trail
41#[derive(Clone, Debug, Serialize, Deserialize)]
42pub struct Provenance {
43    pub chain: Vec<TibetToken>,
44}
45
46impl TibetToken {
47    pub fn new(
48        erin: ContentHash,
49        erachter: impl Into<String>,
50        actor: impl Into<String>,
51        jis_level: u8,
52    ) -> Self {
53        let id = format!("tibet_{}", Utc::now().timestamp_nanos_opt().unwrap_or(0));
54        Self {
55            token_id: id,
56            parent_id: None,
57            timestamp: Utc::now(),
58            erin,
59            eraan: Vec::new(),
60            eromheen: Eromheen {
61                actor: actor.into(),
62                jis_level,
63                chunks_accessed: 0,
64                chunks_denied: 0,
65                airlock_session_ms: None,
66            },
67            erachter: erachter.into(),
68            signature: None,
69        }
70    }
71
72    pub fn with_parent(mut self, parent_id: impl Into<String>) -> Self {
73        self.parent_id = Some(parent_id.into());
74        self
75    }
76
77    pub fn with_access_stats(mut self, accessed: usize, denied: usize) -> Self {
78        self.eromheen.chunks_accessed = accessed;
79        self.eromheen.chunks_denied = denied;
80        self
81    }
82
83    pub fn with_airlock_time(mut self, ms: f64) -> Self {
84        self.eromheen.airlock_session_ms = Some(ms);
85        self
86    }
87
88    /// Serialize the signable portion (everything except signature)
89    pub fn signable_bytes(&self) -> Vec<u8> {
90        let mut token = self.clone();
91        token.signature = None;
92        serde_json::to_vec(&token).unwrap_or_default()
93    }
94
95    /// Sign this token with an Ed25519 keypair.
96    /// Sets the signature field in-place and returns a reference to self.
97    pub fn sign(mut self, keypair: &KeyPair) -> Self {
98        let bytes = self.signable_bytes();
99        self.signature = Some(keypair.sign(&bytes));
100        self
101    }
102
103    /// Verify this token's Ed25519 signature against a public key.
104    /// Returns Ok(()) if valid, Err if missing or invalid.
105    pub fn verify_signature(&self, keypair: &KeyPair) -> CortexResult<()> {
106        match &self.signature {
107            Some(sig) => keypair.verify(&self.signable_bytes(), sig),
108            None => Err(CortexError::SignatureInvalid),
109        }
110    }
111
112    /// Check if this token is signed
113    pub fn is_signed(&self) -> bool {
114        self.signature.is_some()
115    }
116}
117
118impl Provenance {
119    pub fn new() -> Self {
120        Self { chain: Vec::new() }
121    }
122
123    pub fn append(&mut self, token: TibetToken) {
124        self.chain.push(token);
125    }
126
127    pub fn latest(&self) -> Option<&TibetToken> {
128        self.chain.last()
129    }
130
131    pub fn len(&self) -> usize {
132        self.chain.len()
133    }
134
135    pub fn is_empty(&self) -> bool {
136        self.chain.is_empty()
137    }
138
139    /// Verify the chain is unbroken: each token's parent_id matches the previous token_id
140    pub fn verify_chain(&self) -> bool {
141        for window in self.chain.windows(2) {
142            let parent = &window[0];
143            let child = &window[1];
144            match &child.parent_id {
145                Some(pid) if pid == &parent.token_id => continue,
146                _ => return false,
147            }
148        }
149        true
150    }
151
152    /// Verify both chain integrity AND all token signatures.
153    /// Every token in the chain must be signed and valid.
154    pub fn verify_signatures(&self, keypair: &KeyPair) -> bool {
155        if !self.verify_chain() {
156            return false;
157        }
158        self.chain.iter().all(|t| t.verify_signature(keypair).is_ok())
159    }
160}
161
162impl Default for Provenance {
163    fn default() -> Self {
164        Self::new()
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn test_token_creation() {
174        let hash = ContentHash("sha256:abc123".into());
175        let token = TibetToken::new(hash, "test query", "user@test.com", 2);
176        assert!(token.token_id.starts_with("tibet_"));
177        assert_eq!(token.eromheen.jis_level, 2);
178        assert_eq!(token.erachter, "test query");
179    }
180
181    #[test]
182    fn test_provenance_chain() {
183        let h1 = ContentHash("sha256:aaa".into());
184        let h2 = ContentHash("sha256:bbb".into());
185
186        let t1 = TibetToken::new(h1, "first", "actor", 0);
187        let t1_id = t1.token_id.clone();
188        let t2 = TibetToken::new(h2, "second", "actor", 0).with_parent(t1_id);
189
190        let mut prov = Provenance::new();
191        prov.append(t1);
192        prov.append(t2);
193
194        assert_eq!(prov.len(), 2);
195        assert!(prov.verify_chain());
196    }
197
198    #[test]
199    fn test_broken_chain() {
200        let h1 = ContentHash("sha256:aaa".into());
201        let h2 = ContentHash("sha256:bbb".into());
202
203        let t1 = TibetToken::new(h1, "first", "actor", 0);
204        let t2 = TibetToken::new(h2, "second", "actor", 0).with_parent("wrong_parent");
205
206        let mut prov = Provenance::new();
207        prov.append(t1);
208        prov.append(t2);
209
210        assert!(!prov.verify_chain());
211    }
212
213    #[test]
214    fn test_token_sign_verify() {
215        let kp = KeyPair::generate();
216        let hash = ContentHash::compute(b"test data");
217        let token = TibetToken::new(hash, "signed action", "actor@test", 2).sign(&kp);
218
219        assert!(token.is_signed());
220        assert!(token.verify_signature(&kp).is_ok());
221    }
222
223    #[test]
224    fn test_unsigned_token_fails_verify() {
225        let kp = KeyPair::generate();
226        let hash = ContentHash::compute(b"test");
227        let token = TibetToken::new(hash, "unsigned", "actor", 0);
228
229        assert!(!token.is_signed());
230        assert!(token.verify_signature(&kp).is_err());
231    }
232
233    #[test]
234    fn test_wrong_key_fails_verify() {
235        let kp1 = KeyPair::generate();
236        let kp2 = KeyPair::generate();
237        let hash = ContentHash::compute(b"data");
238        let token = TibetToken::new(hash, "action", "actor", 0).sign(&kp1);
239
240        assert!(token.verify_signature(&kp2).is_err());
241    }
242
243    #[test]
244    fn test_signed_chain_verify() {
245        let kp = KeyPair::generate();
246        let h1 = ContentHash::compute(b"first");
247        let h2 = ContentHash::compute(b"second");
248        let h3 = ContentHash::compute(b"third");
249
250        let t1 = TibetToken::new(h1, "first", "actor", 0).sign(&kp);
251        let t1_id = t1.token_id.clone();
252        let t2 = TibetToken::new(h2, "second", "actor", 0)
253            .with_parent(&t1_id)
254            .sign(&kp);
255        let t2_id = t2.token_id.clone();
256        let t3 = TibetToken::new(h3, "third", "actor", 0)
257            .with_parent(&t2_id)
258            .sign(&kp);
259
260        let mut prov = Provenance::new();
261        prov.append(t1);
262        prov.append(t2);
263        prov.append(t3);
264
265        assert!(prov.verify_chain());
266        assert!(prov.verify_signatures(&kp));
267    }
268
269    #[test]
270    fn test_mixed_signed_unsigned_chain_fails() {
271        let kp = KeyPair::generate();
272        let h1 = ContentHash::compute(b"first");
273        let h2 = ContentHash::compute(b"second");
274
275        let t1 = TibetToken::new(h1, "first", "actor", 0).sign(&kp);
276        let t1_id = t1.token_id.clone();
277        // t2 unsigned — should fail verify_signatures
278        let t2 = TibetToken::new(h2, "second", "actor", 0).with_parent(&t1_id);
279
280        let mut prov = Provenance::new();
281        prov.append(t1);
282        prov.append(t2);
283
284        assert!(prov.verify_chain()); // chain structure OK
285        assert!(!prov.verify_signatures(&kp)); // but signatures fail
286    }
287}