use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
pub const CHAIN_HASH_LEN: usize = 32;
pub const CHAIN_SALT_LEN: usize = 16;
const HMAC_BLOCK_SIZE: usize = 64;
fn hmac_sha256(key: &[u8], message: &[u8]) -> [u8; CHAIN_HASH_LEN] {
let mut key_block = [0u8; HMAC_BLOCK_SIZE];
let normalized: Vec<u8> = if key.len() > HMAC_BLOCK_SIZE {
Sha256::digest(key).to_vec()
} else {
key.to_vec()
};
key_block[..normalized.len()].copy_from_slice(&normalized);
let mut inner = Sha256::new();
let mut outer = Sha256::new();
for byte in &key_block {
inner.update([byte ^ 0x36]);
outer.update([byte ^ 0x5c]);
}
inner.update(message);
outer.update(inner.finalize());
let mut bytes = [0u8; CHAIN_HASH_LEN];
bytes.copy_from_slice(&outer.finalize());
bytes
}
fn hex_encode(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
out.push_str(&format!("{b:02x}"));
}
out
}
fn hex_decode(raw: &str) -> Option<Vec<u8>> {
if !raw.len().is_multiple_of(2) {
return None;
}
(0..raw.len())
.step_by(2)
.map(|i| u8::from_str_radix(&raw[i..i + 2], 16).ok())
.collect()
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ArchiveChainEntry {
pub index: u64,
pub salt: String,
pub prev_hash: String,
pub event: String,
pub hmac: String,
}
pub struct ArchiveChain {
key: Vec<u8>,
salt: [u8; CHAIN_SALT_LEN],
prev_hash: [u8; CHAIN_HASH_LEN],
entries: Vec<ArchiveChainEntry>,
}
impl ArchiveChain {
pub fn new(key: &[u8]) -> Self {
Self {
key: key.to_vec(),
salt: rand::random(),
prev_hash: [0u8; CHAIN_HASH_LEN],
entries: Vec::new(),
}
}
pub fn append(&mut self, canonical_event: &str) -> u64 {
let index = self.entries.len() as u64;
let mut message =
Vec::with_capacity(CHAIN_SALT_LEN + CHAIN_HASH_LEN * 2 + canonical_event.len());
message.extend_from_slice(&self.salt);
message.extend_from_slice(&self.prev_hash);
message.extend_from_slice(canonical_event.as_bytes());
let hmac = hmac_sha256(&self.key, &message);
let entry = ArchiveChainEntry {
index,
salt: hex_encode(&self.salt),
prev_hash: hex_encode(&self.prev_hash),
event: canonical_event.to_string(),
hmac: hex_encode(&hmac),
};
self.prev_hash = hmac;
self.entries.push(entry);
index
}
pub fn entries(&self) -> &[ArchiveChainEntry] {
&self.entries
}
pub fn verify(&self) -> bool {
Self::verify_entries(self.entries(), &self.key)
}
pub fn verify_entries(entries: &[ArchiveChainEntry], key: &[u8]) -> bool {
let mut prev_hash = [0u8; CHAIN_HASH_LEN];
let mut salt: Option<[u8; CHAIN_SALT_LEN]> = None;
for (expected_index, entry) in entries.iter().enumerate() {
if entry.index != expected_index as u64 {
return false;
}
let entry_salt = hex_decode(&entry.salt).and_then(|v| {
let bytes: [u8; CHAIN_SALT_LEN] = v.try_into().ok()?;
Some(bytes)
});
let Some(entry_salt) = entry_salt else {
return false;
};
match salt {
None => salt = Some(entry_salt),
Some(s) if s != entry_salt => return false,
Some(_) => {}
}
let Some(prev) = hex_decode(&entry.prev_hash) else {
return false;
};
if prev != prev_hash {
return false;
}
let mut message =
Vec::with_capacity(CHAIN_SALT_LEN + CHAIN_HASH_LEN * 2 + entry.event.len());
message.extend_from_slice(&entry_salt);
message.extend_from_slice(&prev_hash);
message.extend_from_slice(entry.event.as_bytes());
let expected = hmac_sha256(key, &message);
let Some(actual) = hex_decode(&entry.hmac) else {
return false;
};
if !bool::from(expected.ct_eq(&actual)) {
return false;
}
prev_hash = expected;
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
fn event(kind: &str, file: &str) -> String {
serde_json::json!({ "kind": kind, "file": file }).to_string()
}
#[test]
fn test_chain_append_and_verify_roundtrip() {
let mut chain = ArchiveChain::new(b"key-1");
chain.append(&event("rotate", "app.001.log"));
chain.append(&event("rotate", "app.002.log"));
chain.append(&event("cleanup", "app.000.log"));
assert_eq!(chain.entries().len(), 3);
assert!(chain.verify());
assert_eq!(chain.entries()[2].index, 2);
assert_eq!(chain.entries()[0].salt.len(), CHAIN_SALT_LEN * 2);
}
#[test]
fn test_verify_detects_tampered_event() {
let mut chain = ArchiveChain::new(b"key-1");
chain.append(&event("rotate", "app.001.log"));
chain.append(&event("rotate", "app.002.log"));
let mut entries = chain.entries().to_vec();
entries[0].event = event("rotate", "evil.log");
assert!(
!ArchiveChain::verify_entries(&entries, b"key-1"),
"tampered event must be detected"
);
}
#[test]
fn test_verify_detects_deletion_reorder_and_forgery() {
let mut chain = ArchiveChain::new(b"key-1");
chain.append(&event("a", "1"));
chain.append(&event("b", "2"));
chain.append(&event("c", "3"));
let mut entries = chain.entries().to_vec();
entries.remove(1);
assert!(
!ArchiveChain::verify_entries(&entries, b"key-1"),
"deletion must break the chain"
);
let mut entries = chain.entries().to_vec();
entries.swap(0, 1);
assert!(
!ArchiveChain::verify_entries(&entries, b"key-1"),
"reorder must break the chain"
);
let mut entries = chain.entries().to_vec();
entries.push(ArchiveChainEntry {
index: 3,
salt: entries[0].salt.clone(),
prev_hash: entries[2].hmac.clone(),
event: event("forged", "4"),
hmac: hex_encode(&[0u8; 32]),
});
assert!(
!ArchiveChain::verify_entries(&entries, b"key-1"),
"forgery must be detected"
);
}
#[test]
fn test_verify_rejects_wrong_key_and_salt_swap() {
let mut chain = ArchiveChain::new(b"key-1");
chain.append(&event("a", "1"));
assert!(!ArchiveChain::verify_entries(chain.entries(), b"wrong-key"));
let mut entries = chain.entries().to_vec();
entries[0].salt = hex_encode(&[9u8; CHAIN_SALT_LEN]);
assert!(
!ArchiveChain::verify_entries(&entries, b"key-1"),
"salt swap must be detected"
);
}
#[test]
fn test_chains_are_unpredictable_across_instances() {
let mut a = ArchiveChain::new(b"k");
let mut b = ArchiveChain::new(b"k");
a.append(&event("a", "1"));
b.append(&event("a", "1"));
assert_ne!(
a.entries()[0].salt,
b.entries()[0].salt,
"chain-start salt must be random per instance"
);
}
#[test]
fn test_manifest_jsonl_roundtrip() {
let mut chain = ArchiveChain::new(b"key-1");
chain.append(&event("rotate", "app.001.log"));
chain.append(&event("rotate", "app.002.log"));
let jsonl: String = chain
.entries()
.iter()
.map(|e| serde_json::to_string(e).unwrap())
.collect::<Vec<_>>()
.join("\n");
let parsed: Vec<ArchiveChainEntry> = jsonl
.lines()
.map(serde_json::from_str)
.collect::<Result<_, _>>()
.unwrap();
assert!(ArchiveChain::verify_entries(&parsed, b"key-1"));
}
}