use super::{AttestationError, compute_entry_hash, genesis_hash, leaf_hash, params_digest};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogEntry {
pub seq: u64,
pub ts_ms: u64,
pub op: String,
pub params: String,
pub prev_hash: [u8; 32],
pub entry_hash: [u8; 32],
}
#[derive(Debug, Clone)]
pub struct SessionLog {
session_id: [u8; 16],
entries: Vec<LogEntry>,
}
impl SessionLog {
pub fn new(session_id: [u8; 16]) -> Self {
Self {
session_id,
entries: Vec::new(),
}
}
pub fn append(&mut self, ts_ms: u64, op: &str, params_json: &str) -> u64 {
let seq = self.entries.len() as u64;
let prev_hash = self.head_hash();
let digest = params_digest(params_json);
let entry_hash = compute_entry_hash(seq, ts_ms, op, &digest, &prev_hash);
self.entries.push(LogEntry {
seq,
ts_ms,
op: op.to_string(),
params: params_json.to_string(),
prev_hash,
entry_hash,
});
seq
}
pub fn head_hash(&self) -> [u8; 32] {
match self.entries.last() {
Some(entry) => entry.entry_hash,
None => genesis_hash(&self.session_id),
}
}
pub fn entries(&self) -> &[LogEntry] {
&self.entries
}
pub(crate) fn session_id_bytes(&self) -> [u8; 16] {
self.session_id
}
pub fn verify_chain(&self) -> Result<(), AttestationError> {
let mut prev = genesis_hash(&self.session_id);
for (index, entry) in self.entries.iter().enumerate() {
let expected_seq = index as u64;
if entry.seq != expected_seq {
return Err(AttestationError::ChainBroken { seq: expected_seq });
}
if entry.prev_hash != prev {
return Err(AttestationError::ChainBroken { seq: entry.seq });
}
let digest = params_digest(&entry.params);
let recomputed =
compute_entry_hash(entry.seq, entry.ts_ms, &entry.op, &digest, &entry.prev_hash);
if recomputed != entry.entry_hash {
return Err(AttestationError::ChainBroken { seq: entry.seq });
}
prev = entry.entry_hash;
}
Ok(())
}
pub fn merkle_root(&self) -> [u8; 32] {
let leaves: Vec<[u8; 32]> = self
.entries
.iter()
.map(|entry| leaf_hash(&entry.entry_hash))
.collect();
super::merkle::merkle_root(&leaves, &self.session_id)
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
fn populated() -> SessionLog {
let mut log = SessionLog::new([1u8; 16]);
for i in 0..5u64 {
log.append(1_000 + i, "op", "{}");
}
log
}
#[test]
fn well_formed_chain_verifies() {
assert!(populated().verify_chain().is_ok());
}
#[test]
fn tampered_params_reported_at_exact_seq() {
for k in 0..5usize {
let mut log = populated();
log.entries[k].params = "tampered".to_string();
match log.verify_chain() {
Err(AttestationError::ChainBroken { seq }) => assert_eq!(seq, k as u64),
other => panic!("expected ChainBroken at {k}, got {other:?}"),
}
}
}
#[test]
fn broken_prev_link_reported() {
let mut log = populated();
log.entries[2].prev_hash = [0u8; 32];
match log.verify_chain() {
Err(AttestationError::ChainBroken { seq }) => assert_eq!(seq, 2),
other => panic!("expected ChainBroken at 2, got {other:?}"),
}
}
#[test]
fn genesis_head_differs_by_session() {
assert_ne!(
SessionLog::new([1u8; 16]).head_hash(),
SessionLog::new([2u8; 16]).head_hash()
);
}
}