use chrono::{DateTime, Utc};
use hmac::{Hmac, KeyInit, Mac};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use thiserror::Error;
use uuid::Uuid;
use crate::model::memory::MemoryRecord;
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RecordRef {
pub id: Uuid,
pub content_hash: Vec<u8>,
pub prev_hash: Option<Vec<u8>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ReadProvenance {
pub read_id: Uuid,
pub agent_id: String,
pub query_hash: Vec<u8>,
pub derived_from: Vec<RecordRef>,
pub hmac: Vec<u8>,
pub hmac_key_id: String,
pub ts: DateTime<Utc>,
}
#[derive(Debug, Error)]
pub enum ProvenanceError {
#[error("HMAC mismatch — receipt was tampered or wrong key")]
HmacMismatch,
#[error(
"record {id} content_hash mismatch — source record was modified after provenance was signed"
)]
RecordContentHashMismatch { id: Uuid },
#[error("missing record {id} — verifier wasn't given the source record cited by the receipt")]
MissingRecord { id: Uuid },
#[error("unknown HMAC key id {key_id} — verifier doesn't have this key in its keystore")]
UnknownKey { key_id: String },
#[error("HMAC engine init failed: {0}")]
HmacInit(String),
#[error("query hash mismatch")]
QueryHashMismatch,
}
#[derive(Debug, Clone)]
pub struct ProvenanceSigner {
key_id: String,
key: Vec<u8>,
}
impl ProvenanceSigner {
pub fn new(key_id: impl Into<String>, key: &[u8]) -> Self {
Self {
key_id: key_id.into(),
key: key.to_vec(),
}
}
pub fn key_id(&self) -> &str {
&self.key_id
}
pub fn sign(
&self,
agent_id: impl Into<String>,
query: &str,
records: &[MemoryRecord],
) -> Result<ReadProvenance, ProvenanceError> {
let read_id = Uuid::now_v7();
let query_hash = sha256(query.as_bytes());
let derived_from: Vec<RecordRef> = records
.iter()
.map(|r| RecordRef {
id: r.id,
content_hash: r.content_hash.clone(),
prev_hash: r.prev_hash.clone(),
})
.collect();
let hmac = self.compute_hmac(&read_id, &query_hash, &derived_from)?;
Ok(ReadProvenance {
read_id,
agent_id: agent_id.into(),
query_hash,
derived_from,
hmac,
hmac_key_id: self.key_id.clone(),
ts: Utc::now(),
})
}
fn compute_hmac(
&self,
read_id: &Uuid,
query_hash: &[u8],
derived_from: &[RecordRef],
) -> Result<Vec<u8>, ProvenanceError> {
let mut mac = <HmacSha256 as KeyInit>::new_from_slice(&self.key)
.map_err(|e: hmac::digest::InvalidLength| ProvenanceError::HmacInit(e.to_string()))?;
mac.update(read_id.as_bytes());
mac.update(query_hash);
for r in derived_from {
mac.update(r.id.as_bytes());
mac.update(&r.content_hash);
if let Some(prev) = &r.prev_hash {
mac.update(prev);
}
}
Ok(mac.finalize().into_bytes().to_vec())
}
}
pub fn verify_read_provenance(
provenance: &ReadProvenance,
records: &[MemoryRecord],
keystore: &dyn ProvenanceKeystore,
) -> Result<(), ProvenanceError> {
let signer =
keystore
.lookup(&provenance.hmac_key_id)
.ok_or_else(|| ProvenanceError::UnknownKey {
key_id: provenance.hmac_key_id.clone(),
})?;
for r in &provenance.derived_from {
let actual = records
.iter()
.find(|m| m.id == r.id)
.ok_or(ProvenanceError::MissingRecord { id: r.id })?;
if actual.content_hash != r.content_hash {
return Err(ProvenanceError::RecordContentHashMismatch { id: r.id });
}
}
let expected = signer.compute_hmac(
&provenance.read_id,
&provenance.query_hash,
&provenance.derived_from,
)?;
if !constant_time_eq(&expected, &provenance.hmac) {
return Err(ProvenanceError::HmacMismatch);
}
Ok(())
}
pub trait ProvenanceKeystore: Send + Sync {
fn lookup(&self, key_id: &str) -> Option<&ProvenanceSigner>;
}
impl ProvenanceKeystore for ProvenanceSigner {
fn lookup(&self, key_id: &str) -> Option<&ProvenanceSigner> {
if key_id == self.key_id {
Some(self)
} else {
None
}
}
}
fn sha256(bytes: &[u8]) -> Vec<u8> {
let mut h = Sha256::new();
h.update(bytes);
h.finalize().to_vec()
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut acc = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
acc |= x ^ y;
}
acc == 0
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hash::compute_content_hash;
use crate::model::memory::MemoryRecord;
fn record(id: Uuid, agent: &str, content: &str) -> MemoryRecord {
let mut r = MemoryRecord::new(agent.to_string(), content.to_string());
r.id = id;
r.content_hash = compute_content_hash(content, agent, &r.created_at);
r
}
fn signer() -> ProvenanceSigner {
ProvenanceSigner::new("mnemo-prov-test", &[7u8; 32])
}
#[test]
fn sign_then_verify_round_trips() {
let s = signer();
let r1 = record(Uuid::now_v7(), "a", "hello");
let r2 = record(Uuid::now_v7(), "a", "world");
let prov = s
.sign("a", "greeting query", &[r1.clone(), r2.clone()])
.unwrap();
verify_read_provenance(&prov, &[r1, r2], &s).expect("should verify");
}
#[test]
fn tampering_a_source_record_fails_verification() {
let s = signer();
let r1 = record(Uuid::now_v7(), "a", "original content");
let prov = s.sign("a", "q", std::slice::from_ref(&r1)).unwrap();
let mut tampered = r1.clone();
tampered.content_hash = vec![0xFF; 32];
let err = verify_read_provenance(&prov, &[tampered], &s).unwrap_err();
assert!(matches!(
err,
ProvenanceError::RecordContentHashMismatch { .. }
));
}
#[test]
fn tampering_the_hmac_fails_verification() {
let s = signer();
let r1 = record(Uuid::now_v7(), "a", "x");
let mut prov = s.sign("a", "q", std::slice::from_ref(&r1)).unwrap();
prov.hmac[0] ^= 0xFF;
let err = verify_read_provenance(&prov, &[r1], &s).unwrap_err();
assert!(matches!(err, ProvenanceError::HmacMismatch));
}
#[test]
fn missing_source_record_fails_verification() {
let s = signer();
let r1 = record(Uuid::now_v7(), "a", "x");
let prov = s.sign("a", "q", &[r1]).unwrap();
let err = verify_read_provenance(&prov, &[], &s).unwrap_err();
assert!(matches!(err, ProvenanceError::MissingRecord { .. }));
}
#[test]
fn unknown_key_id_fails_verification() {
let s = signer();
let r1 = record(Uuid::now_v7(), "a", "x");
let mut prov = s.sign("a", "q", std::slice::from_ref(&r1)).unwrap();
prov.hmac_key_id = "rotated-out".into();
let err = verify_read_provenance(&prov, &[r1], &s).unwrap_err();
assert!(matches!(err, ProvenanceError::UnknownKey { .. }));
}
#[test]
fn rotated_key_still_verifies_via_keystore_lookup() {
let active = ProvenanceSigner::new("mnemo-prov-2026-05", &[1u8; 32]);
let archived = ProvenanceSigner::new("mnemo-prov-2026-04", &[2u8; 32]);
let r1 = record(Uuid::now_v7(), "a", "old read");
let prov = archived.sign("a", "q", std::slice::from_ref(&r1)).unwrap();
struct Pair<'a>(&'a ProvenanceSigner, &'a ProvenanceSigner);
impl<'a> ProvenanceKeystore for Pair<'a> {
fn lookup(&self, id: &str) -> Option<&ProvenanceSigner> {
if self.0.key_id() == id {
Some(self.0)
} else if self.1.key_id() == id {
Some(self.1)
} else {
None
}
}
}
verify_read_provenance(&prov, &[r1], &Pair(&active, &archived)).unwrap();
}
}