1use chrono::{DateTime, Utc};
31use hmac::{Hmac, KeyInit, Mac};
32use serde::{Deserialize, Serialize};
33use sha2::{Digest, Sha256};
34use thiserror::Error;
35use uuid::Uuid;
36
37use crate::model::memory::MemoryRecord;
38
39type HmacSha256 = Hmac<Sha256>;
40
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
47pub struct RecordRef {
48 pub id: Uuid,
49 pub content_hash: Vec<u8>,
55 pub prev_hash: Option<Vec<u8>>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
66pub struct ReadProvenance {
67 pub read_id: Uuid,
68 pub agent_id: String,
69 pub query_hash: Vec<u8>,
73 pub derived_from: Vec<RecordRef>,
75 pub hmac: Vec<u8>,
77 pub hmac_key_id: String,
80 pub ts: DateTime<Utc>,
81}
82
83#[derive(Debug, Error)]
84pub enum ProvenanceError {
85 #[error("HMAC mismatch — receipt was tampered or wrong key")]
86 HmacMismatch,
87 #[error(
88 "record {id} content_hash mismatch — source record was modified after provenance was signed"
89 )]
90 RecordContentHashMismatch { id: Uuid },
91 #[error("missing record {id} — verifier wasn't given the source record cited by the receipt")]
92 MissingRecord { id: Uuid },
93 #[error("unknown HMAC key id {key_id} — verifier doesn't have this key in its keystore")]
94 UnknownKey { key_id: String },
95 #[error("HMAC engine init failed: {0}")]
96 HmacInit(String),
97 #[error("query hash mismatch")]
98 QueryHashMismatch,
99}
100
101#[derive(Debug, Clone)]
111pub struct ProvenanceSigner {
112 key_id: String,
113 key: Vec<u8>,
114}
115
116impl ProvenanceSigner {
117 pub fn new(key_id: impl Into<String>, key: &[u8]) -> Self {
123 Self {
124 key_id: key_id.into(),
125 key: key.to_vec(),
126 }
127 }
128
129 pub fn key_id(&self) -> &str {
130 &self.key_id
131 }
132
133 pub fn sign(
135 &self,
136 agent_id: impl Into<String>,
137 query: &str,
138 records: &[MemoryRecord],
139 ) -> Result<ReadProvenance, ProvenanceError> {
140 let read_id = Uuid::now_v7();
141 let query_hash = sha256(query.as_bytes());
142 let derived_from: Vec<RecordRef> = records
143 .iter()
144 .map(|r| RecordRef {
145 id: r.id,
146 content_hash: r.content_hash.clone(),
147 prev_hash: r.prev_hash.clone(),
148 })
149 .collect();
150 let hmac = self.compute_hmac(&read_id, &query_hash, &derived_from)?;
151 Ok(ReadProvenance {
152 read_id,
153 agent_id: agent_id.into(),
154 query_hash,
155 derived_from,
156 hmac,
157 hmac_key_id: self.key_id.clone(),
158 ts: Utc::now(),
159 })
160 }
161
162 fn compute_hmac(
163 &self,
164 read_id: &Uuid,
165 query_hash: &[u8],
166 derived_from: &[RecordRef],
167 ) -> Result<Vec<u8>, ProvenanceError> {
168 let mut mac = <HmacSha256 as KeyInit>::new_from_slice(&self.key)
169 .map_err(|e: hmac::digest::InvalidLength| ProvenanceError::HmacInit(e.to_string()))?;
170 mac.update(read_id.as_bytes());
171 mac.update(query_hash);
172 for r in derived_from {
173 mac.update(r.id.as_bytes());
174 mac.update(&r.content_hash);
175 if let Some(prev) = &r.prev_hash {
176 mac.update(prev);
177 }
178 }
179 Ok(mac.finalize().into_bytes().to_vec())
180 }
181}
182
183pub fn verify_read_provenance(
189 provenance: &ReadProvenance,
190 records: &[MemoryRecord],
191 keystore: &dyn ProvenanceKeystore,
192) -> Result<(), ProvenanceError> {
193 let signer =
196 keystore
197 .lookup(&provenance.hmac_key_id)
198 .ok_or_else(|| ProvenanceError::UnknownKey {
199 key_id: provenance.hmac_key_id.clone(),
200 })?;
201
202 for r in &provenance.derived_from {
205 let actual = records
206 .iter()
207 .find(|m| m.id == r.id)
208 .ok_or(ProvenanceError::MissingRecord { id: r.id })?;
209 if actual.content_hash != r.content_hash {
210 return Err(ProvenanceError::RecordContentHashMismatch { id: r.id });
211 }
212 }
213
214 let expected = signer.compute_hmac(
216 &provenance.read_id,
217 &provenance.query_hash,
218 &provenance.derived_from,
219 )?;
220 if !constant_time_eq(&expected, &provenance.hmac) {
221 return Err(ProvenanceError::HmacMismatch);
222 }
223 Ok(())
224}
225
226pub trait ProvenanceKeystore: Send + Sync {
228 fn lookup(&self, key_id: &str) -> Option<&ProvenanceSigner>;
229}
230
231impl ProvenanceKeystore for ProvenanceSigner {
233 fn lookup(&self, key_id: &str) -> Option<&ProvenanceSigner> {
234 if key_id == self.key_id {
235 Some(self)
236 } else {
237 None
238 }
239 }
240}
241
242fn sha256(bytes: &[u8]) -> Vec<u8> {
243 let mut h = Sha256::new();
244 h.update(bytes);
245 h.finalize().to_vec()
246}
247
248fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
249 if a.len() != b.len() {
250 return false;
251 }
252 let mut acc = 0u8;
253 for (x, y) in a.iter().zip(b.iter()) {
254 acc |= x ^ y;
255 }
256 acc == 0
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262 use crate::hash::compute_content_hash;
263 use crate::model::memory::MemoryRecord;
264
265 fn record(id: Uuid, agent: &str, content: &str) -> MemoryRecord {
266 let mut r = MemoryRecord::new(agent.to_string(), content.to_string());
267 r.id = id;
268 r.content_hash = compute_content_hash(content, agent, &r.created_at);
269 r
270 }
271
272 fn signer() -> ProvenanceSigner {
273 ProvenanceSigner::new("mnemo-prov-test", &[7u8; 32])
274 }
275
276 #[test]
277 fn sign_then_verify_round_trips() {
278 let s = signer();
279 let r1 = record(Uuid::now_v7(), "a", "hello");
280 let r2 = record(Uuid::now_v7(), "a", "world");
281 let prov = s
282 .sign("a", "greeting query", &[r1.clone(), r2.clone()])
283 .unwrap();
284 verify_read_provenance(&prov, &[r1, r2], &s).expect("should verify");
285 }
286
287 #[test]
288 fn tampering_a_source_record_fails_verification() {
289 let s = signer();
290 let r1 = record(Uuid::now_v7(), "a", "original content");
291 let prov = s.sign("a", "q", &[r1.clone()]).unwrap();
292 let mut tampered = r1.clone();
295 tampered.content_hash = vec![0xFF; 32];
296 let err = verify_read_provenance(&prov, &[tampered], &s).unwrap_err();
297 assert!(matches!(
298 err,
299 ProvenanceError::RecordContentHashMismatch { .. }
300 ));
301 }
302
303 #[test]
304 fn tampering_the_hmac_fails_verification() {
305 let s = signer();
306 let r1 = record(Uuid::now_v7(), "a", "x");
307 let mut prov = s.sign("a", "q", &[r1.clone()]).unwrap();
308 prov.hmac[0] ^= 0xFF;
309 let err = verify_read_provenance(&prov, &[r1], &s).unwrap_err();
310 assert!(matches!(err, ProvenanceError::HmacMismatch));
311 }
312
313 #[test]
314 fn missing_source_record_fails_verification() {
315 let s = signer();
316 let r1 = record(Uuid::now_v7(), "a", "x");
317 let prov = s.sign("a", "q", &[r1]).unwrap();
318 let err = verify_read_provenance(&prov, &[], &s).unwrap_err();
319 assert!(matches!(err, ProvenanceError::MissingRecord { .. }));
320 }
321
322 #[test]
323 fn unknown_key_id_fails_verification() {
324 let s = signer();
325 let r1 = record(Uuid::now_v7(), "a", "x");
326 let mut prov = s.sign("a", "q", &[r1.clone()]).unwrap();
327 prov.hmac_key_id = "rotated-out".into();
328 let err = verify_read_provenance(&prov, &[r1], &s).unwrap_err();
329 assert!(matches!(err, ProvenanceError::UnknownKey { .. }));
330 }
331
332 #[test]
333 fn rotated_key_still_verifies_via_keystore_lookup() {
334 let active = ProvenanceSigner::new("mnemo-prov-2026-05", &[1u8; 32]);
337 let archived = ProvenanceSigner::new("mnemo-prov-2026-04", &[2u8; 32]);
338
339 let r1 = record(Uuid::now_v7(), "a", "old read");
340 let prov = archived.sign("a", "q", &[r1.clone()]).unwrap();
341
342 struct Pair<'a>(&'a ProvenanceSigner, &'a ProvenanceSigner);
343 impl<'a> ProvenanceKeystore for Pair<'a> {
344 fn lookup(&self, id: &str) -> Option<&ProvenanceSigner> {
345 if self.0.key_id() == id {
346 Some(self.0)
347 } else if self.1.key_id() == id {
348 Some(self.1)
349 } else {
350 None
351 }
352 }
353 }
354 verify_read_provenance(&prov, &[r1], &Pair(&active, &archived)).unwrap();
355 }
356}