use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use uuid::Uuid;
use crate::hash::ChainVerificationResult;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WriteOp {
Remember,
Share,
}
impl WriteOp {
fn as_bytes(self) -> &'static [u8] {
match self {
WriteOp::Remember => b"remember",
WriteOp::Share => b"share",
}
}
pub fn as_str(self) -> &'static str {
match self {
WriteOp::Remember => "remember",
WriteOp::Share => "share",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WriteFlag {
OpaqueReasoningPayload,
}
impl WriteFlag {
pub fn as_str(self) -> &'static str {
match self {
WriteFlag::OpaqueReasoningPayload => "opaque_reasoning_payload",
}
}
pub fn from_name(s: &str) -> Option<Self> {
match s {
"opaque_reasoning_payload" => Some(WriteFlag::OpaqueReasoningPayload),
_ => None,
}
}
}
pub fn flags_to_storage(flags: &[WriteFlag]) -> String {
let mut names: Vec<&'static str> = flags.iter().map(|f| f.as_str()).collect();
names.sort_unstable();
names.dedup();
names.join(",")
}
pub fn flags_from_storage(s: &str) -> Vec<WriteFlag> {
let mut out: Vec<WriteFlag> = s
.split(',')
.map(|t| t.trim())
.filter(|t| !t.is_empty())
.filter_map(WriteFlag::from_name)
.collect();
out.sort_unstable();
out.dedup();
out
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct WriteProvenance {
pub id: Uuid,
pub memory_id: Uuid,
pub principal: String,
pub capability_id: Option<Uuid>,
pub session_id: Option<String>,
pub op: WriteOp,
pub authored_at: DateTime<Utc>,
#[serde(default)]
pub flags: Vec<WriteFlag>,
pub prev_hash: Option<Vec<u8>>,
pub content_hash: Vec<u8>,
}
#[allow(clippy::too_many_arguments)]
pub fn compute_provenance_hash(
memory_id: &Uuid,
principal: &str,
capability_id: &Option<Uuid>,
session_id: &Option<String>,
op: WriteOp,
authored_at: &DateTime<Utc>,
flags: &[WriteFlag],
prev_hash: Option<&[u8]>,
) -> Vec<u8> {
let mut h = Sha256::new();
h.update(memory_id.as_bytes());
h.update(principal.as_bytes());
if let Some(cid) = capability_id {
h.update(cid.as_bytes());
}
if let Some(sid) = session_id {
h.update(sid.as_bytes());
}
h.update(op.as_bytes());
h.update(authored_at.to_rfc3339().as_bytes());
let flag_repr = flags_to_storage(flags);
if !flag_repr.is_empty() {
h.update(flag_repr.as_bytes());
}
if let Some(p) = prev_hash {
h.update(p);
}
h.finalize().to_vec()
}
impl WriteProvenance {
#[allow(clippy::too_many_arguments)]
pub fn new(
memory_id: Uuid,
principal: impl Into<String>,
capability_id: Option<Uuid>,
session_id: Option<String>,
op: WriteOp,
flags: Vec<WriteFlag>,
prev_hash: Option<Vec<u8>>,
) -> Self {
let principal = principal.into();
let authored_at = Utc::now();
let mut flags = flags;
flags.sort_unstable();
flags.dedup();
let content_hash = compute_provenance_hash(
&memory_id,
&principal,
&capability_id,
&session_id,
op,
&authored_at,
&flags,
prev_hash.as_deref(),
);
Self {
id: Uuid::now_v7(),
memory_id,
principal,
capability_id,
session_id,
op,
authored_at,
flags,
prev_hash,
content_hash,
}
}
pub fn content_hash_valid(&self) -> bool {
let expected = compute_provenance_hash(
&self.memory_id,
&self.principal,
&self.capability_id,
&self.session_id,
self.op,
&self.authored_at,
&self.flags,
self.prev_hash.as_deref(),
);
bool::from(expected.ct_eq(&self.content_hash))
}
}
pub fn verify_provenance_chain(records: &[WriteProvenance]) -> ChainVerificationResult {
let mut verified = 0;
for (i, rec) in records.iter().enumerate() {
if !rec.content_hash_valid() {
return ChainVerificationResult {
valid: false,
total_records: records.len(),
verified_records: verified,
first_broken_at: Some(rec.id),
error_message: Some(format!("provenance content hash mismatch at {}", rec.id)),
};
}
let expected_prev = if i == 0 {
None
} else {
Some(records[i - 1].content_hash.as_slice())
};
if rec.prev_hash.as_deref() != expected_prev {
return ChainVerificationResult {
valid: false,
total_records: records.len(),
verified_records: verified,
first_broken_at: Some(rec.id),
error_message: Some(format!("provenance chain link broken at {}", rec.id)),
};
}
verified += 1;
}
ChainVerificationResult {
valid: true,
total_records: records.len(),
verified_records: verified,
first_broken_at: None,
error_message: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn chain(n: usize, principal: &str) -> Vec<WriteProvenance> {
let mut out: Vec<WriteProvenance> = Vec::new();
for _ in 0..n {
let prev = out.last().map(|r| r.content_hash.clone());
out.push(WriteProvenance::new(
Uuid::now_v7(),
principal,
None,
Some("sess-1".to_string()),
WriteOp::Remember,
Vec::new(),
prev,
));
}
out
}
#[test]
fn valid_chain_verifies() {
let recs = chain(5, "alice");
let r = verify_provenance_chain(&recs);
assert!(r.valid);
assert_eq!(r.verified_records, 5);
assert!(r.first_broken_at.is_none());
}
#[test]
fn empty_chain_is_valid() {
assert!(verify_provenance_chain(&[]).valid);
}
#[test]
fn mutating_a_field_breaks_the_chain() {
let mut recs = chain(3, "alice");
recs[1].principal = "mallory".to_string(); let r = verify_provenance_chain(&recs);
assert!(!r.valid);
assert_eq!(r.first_broken_at, Some(recs[1].id));
assert!(r.error_message.unwrap().contains("content hash mismatch"));
}
#[test]
fn deleting_a_record_breaks_the_link() {
let mut recs = chain(4, "alice");
recs.remove(2); let r = verify_provenance_chain(&recs);
assert!(!r.valid);
assert!(r.error_message.unwrap().contains("chain link broken"));
}
#[test]
fn capability_and_session_are_hashed() {
let cid = Uuid::now_v7();
let a = WriteProvenance::new(
Uuid::now_v7(),
"p",
Some(cid),
Some("s".to_string()),
WriteOp::Share,
Vec::new(),
None,
);
let mut b = a.clone();
b.capability_id = Some(Uuid::now_v7());
assert!(!b.content_hash_valid());
}
#[test]
fn flags_are_hashed_and_tamper_evident() {
let mid = Uuid::now_v7();
let no_flags = WriteProvenance::new(mid, "p", None, None, WriteOp::Remember, vec![], None);
assert!(no_flags.content_hash_valid());
let flagged = WriteProvenance::new(
mid,
"p",
None,
None,
WriteOp::Remember,
vec![WriteFlag::OpaqueReasoningPayload],
None,
);
assert!(flagged.content_hash_valid());
let mut stripped = flagged.clone();
stripped.flags.clear();
assert!(
!stripped.content_hash_valid(),
"removing a recorded flag must break the content hash"
);
}
#[test]
fn flags_storage_roundtrips_sorted_deduped() {
let f = vec![
WriteFlag::OpaqueReasoningPayload,
WriteFlag::OpaqueReasoningPayload,
];
let s = flags_to_storage(&f);
assert_eq!(s, "opaque_reasoning_payload");
assert_eq!(
flags_from_storage(&s),
vec![WriteFlag::OpaqueReasoningPayload]
);
assert!(flags_from_storage("").is_empty());
assert!(flags_from_storage("unknown_future_flag").is_empty());
}
}