1use chrono::{DateTime, Utc};
26use serde::{Deserialize, Serialize};
27use sha2::{Digest, Sha256};
28use subtle::ConstantTimeEq;
29use uuid::Uuid;
30
31use crate::hash::ChainVerificationResult;
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum WriteOp {
37 Remember,
38 Share,
39}
40
41impl WriteOp {
42 fn as_bytes(self) -> &'static [u8] {
43 match self {
44 WriteOp::Remember => b"remember",
45 WriteOp::Share => b"share",
46 }
47 }
48
49 pub fn as_str(self) -> &'static str {
50 match self {
51 WriteOp::Remember => "remember",
52 WriteOp::Share => "share",
53 }
54 }
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum WriteFlag {
63 OpaqueReasoningPayload,
68}
69
70impl WriteFlag {
71 pub fn as_str(self) -> &'static str {
72 match self {
73 WriteFlag::OpaqueReasoningPayload => "opaque_reasoning_payload",
74 }
75 }
76
77 pub fn from_name(s: &str) -> Option<Self> {
82 match s {
83 "opaque_reasoning_payload" => Some(WriteFlag::OpaqueReasoningPayload),
84 _ => None,
85 }
86 }
87}
88
89pub fn flags_to_storage(flags: &[WriteFlag]) -> String {
93 let mut names: Vec<&'static str> = flags.iter().map(|f| f.as_str()).collect();
94 names.sort_unstable();
95 names.dedup();
96 names.join(",")
97}
98
99pub fn flags_from_storage(s: &str) -> Vec<WriteFlag> {
102 let mut out: Vec<WriteFlag> = s
103 .split(',')
104 .map(|t| t.trim())
105 .filter(|t| !t.is_empty())
106 .filter_map(WriteFlag::from_name)
107 .collect();
108 out.sort_unstable();
109 out.dedup();
110 out
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
115pub struct WriteProvenance {
116 pub id: Uuid,
117 pub memory_id: Uuid,
118 pub principal: String,
119 pub capability_id: Option<Uuid>,
121 pub session_id: Option<String>,
123 pub op: WriteOp,
124 pub authored_at: DateTime<Utc>,
125 #[serde(default)]
128 pub flags: Vec<WriteFlag>,
129 pub prev_hash: Option<Vec<u8>>,
131 pub content_hash: Vec<u8>,
132}
133
134#[allow(clippy::too_many_arguments)]
140pub fn compute_provenance_hash(
141 memory_id: &Uuid,
142 principal: &str,
143 capability_id: &Option<Uuid>,
144 session_id: &Option<String>,
145 op: WriteOp,
146 authored_at: &DateTime<Utc>,
147 flags: &[WriteFlag],
148 prev_hash: Option<&[u8]>,
149) -> Vec<u8> {
150 let mut h = Sha256::new();
151 h.update(memory_id.as_bytes());
152 h.update(principal.as_bytes());
153 if let Some(cid) = capability_id {
154 h.update(cid.as_bytes());
155 }
156 if let Some(sid) = session_id {
157 h.update(sid.as_bytes());
158 }
159 h.update(op.as_bytes());
160 h.update(authored_at.to_rfc3339().as_bytes());
161 let flag_repr = flags_to_storage(flags);
164 if !flag_repr.is_empty() {
165 h.update(flag_repr.as_bytes());
166 }
167 if let Some(p) = prev_hash {
168 h.update(p);
169 }
170 h.finalize().to_vec()
171}
172
173impl WriteProvenance {
174 #[allow(clippy::too_many_arguments)]
178 pub fn new(
179 memory_id: Uuid,
180 principal: impl Into<String>,
181 capability_id: Option<Uuid>,
182 session_id: Option<String>,
183 op: WriteOp,
184 flags: Vec<WriteFlag>,
185 prev_hash: Option<Vec<u8>>,
186 ) -> Self {
187 let principal = principal.into();
188 let authored_at = Utc::now();
189 let mut flags = flags;
190 flags.sort_unstable();
191 flags.dedup();
192 let content_hash = compute_provenance_hash(
193 &memory_id,
194 &principal,
195 &capability_id,
196 &session_id,
197 op,
198 &authored_at,
199 &flags,
200 prev_hash.as_deref(),
201 );
202 Self {
203 id: Uuid::now_v7(),
204 memory_id,
205 principal,
206 capability_id,
207 session_id,
208 op,
209 authored_at,
210 flags,
211 prev_hash,
212 content_hash,
213 }
214 }
215
216 pub fn content_hash_valid(&self) -> bool {
219 let expected = compute_provenance_hash(
220 &self.memory_id,
221 &self.principal,
222 &self.capability_id,
223 &self.session_id,
224 self.op,
225 &self.authored_at,
226 &self.flags,
227 self.prev_hash.as_deref(),
228 );
229 bool::from(expected.ct_eq(&self.content_hash))
230 }
231}
232
233pub fn verify_provenance_chain(records: &[WriteProvenance]) -> ChainVerificationResult {
237 let mut verified = 0;
238 for (i, rec) in records.iter().enumerate() {
239 if !rec.content_hash_valid() {
240 return ChainVerificationResult {
241 valid: false,
242 total_records: records.len(),
243 verified_records: verified,
244 first_broken_at: Some(rec.id),
245 error_message: Some(format!("provenance content hash mismatch at {}", rec.id)),
246 };
247 }
248 let expected_prev = if i == 0 {
249 None
250 } else {
251 Some(records[i - 1].content_hash.as_slice())
252 };
253 if rec.prev_hash.as_deref() != expected_prev {
254 return ChainVerificationResult {
255 valid: false,
256 total_records: records.len(),
257 verified_records: verified,
258 first_broken_at: Some(rec.id),
259 error_message: Some(format!("provenance chain link broken at {}", rec.id)),
260 };
261 }
262 verified += 1;
263 }
264 ChainVerificationResult {
265 valid: true,
266 total_records: records.len(),
267 verified_records: verified,
268 first_broken_at: None,
269 error_message: None,
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 fn chain(n: usize, principal: &str) -> Vec<WriteProvenance> {
278 let mut out: Vec<WriteProvenance> = Vec::new();
279 for _ in 0..n {
280 let prev = out.last().map(|r| r.content_hash.clone());
281 out.push(WriteProvenance::new(
282 Uuid::now_v7(),
283 principal,
284 None,
285 Some("sess-1".to_string()),
286 WriteOp::Remember,
287 Vec::new(),
288 prev,
289 ));
290 }
291 out
292 }
293
294 #[test]
295 fn valid_chain_verifies() {
296 let recs = chain(5, "alice");
297 let r = verify_provenance_chain(&recs);
298 assert!(r.valid);
299 assert_eq!(r.verified_records, 5);
300 assert!(r.first_broken_at.is_none());
301 }
302
303 #[test]
304 fn empty_chain_is_valid() {
305 assert!(verify_provenance_chain(&[]).valid);
306 }
307
308 #[test]
309 fn mutating_a_field_breaks_the_chain() {
310 let mut recs = chain(3, "alice");
311 recs[1].principal = "mallory".to_string(); let r = verify_provenance_chain(&recs);
313 assert!(!r.valid);
314 assert_eq!(r.first_broken_at, Some(recs[1].id));
315 assert!(r.error_message.unwrap().contains("content hash mismatch"));
316 }
317
318 #[test]
319 fn deleting_a_record_breaks_the_link() {
320 let mut recs = chain(4, "alice");
321 recs.remove(2); let r = verify_provenance_chain(&recs);
323 assert!(!r.valid);
324 assert!(r.error_message.unwrap().contains("chain link broken"));
325 }
326
327 #[test]
328 fn capability_and_session_are_hashed() {
329 let cid = Uuid::now_v7();
330 let a = WriteProvenance::new(
331 Uuid::now_v7(),
332 "p",
333 Some(cid),
334 Some("s".to_string()),
335 WriteOp::Share,
336 Vec::new(),
337 None,
338 );
339 let mut b = a.clone();
341 b.capability_id = Some(Uuid::now_v7());
342 assert!(!b.content_hash_valid());
343 }
344
345 #[test]
346 fn flags_are_hashed_and_tamper_evident() {
347 let mid = Uuid::now_v7();
350 let no_flags = WriteProvenance::new(mid, "p", None, None, WriteOp::Remember, vec![], None);
351 assert!(no_flags.content_hash_valid());
352
353 let flagged = WriteProvenance::new(
355 mid,
356 "p",
357 None,
358 None,
359 WriteOp::Remember,
360 vec![WriteFlag::OpaqueReasoningPayload],
361 None,
362 );
363 assert!(flagged.content_hash_valid());
364
365 let mut stripped = flagged.clone();
367 stripped.flags.clear();
368 assert!(
369 !stripped.content_hash_valid(),
370 "removing a recorded flag must break the content hash"
371 );
372 }
373
374 #[test]
375 fn flags_storage_roundtrips_sorted_deduped() {
376 let f = vec![
377 WriteFlag::OpaqueReasoningPayload,
378 WriteFlag::OpaqueReasoningPayload,
379 ];
380 let s = flags_to_storage(&f);
381 assert_eq!(s, "opaque_reasoning_payload");
382 assert_eq!(
383 flags_from_storage(&s),
384 vec![WriteFlag::OpaqueReasoningPayload]
385 );
386 assert!(flags_from_storage("").is_empty());
387 assert!(flags_from_storage("unknown_future_flag").is_empty());
388 }
389}