use crate::security::canonical::to_canonical_bytes;
use core::fmt;
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use zeroize::{Zeroize, ZeroizeOnDrop};
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Digest256(#[serde(with = "hex_bytes")] [u8; 32]);
impl Digest256 {
pub const GENESIS: Self = Self([0u8; 32]);
#[must_use]
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
#[must_use]
pub fn to_hex(&self) -> String {
hex(&self.0)
}
}
impl fmt::Display for Digest256 {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.to_hex())
}
}
fn hex(bytes: &[u8]) -> String {
use core::fmt::Write as _;
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
let _ = write!(out, "{b:02x}");
}
out
}
mod hex_bytes {
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(v: &[u8; 32], s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&super::hex(v))
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 32], D::Error> {
let text = String::deserialize(d)?;
if text.len() != 64 {
return Err(serde::de::Error::custom("digest is not 64 hex characters"));
}
let mut out = [0u8; 32];
for (i, byte) in out.iter_mut().enumerate() {
*byte = u8::from_str_radix(&text[i * 2..i * 2 + 2], 16)
.map_err(|_| serde::de::Error::custom("digest is not hexadecimal"))?;
}
Ok(out)
}
}
#[derive(Zeroize, ZeroizeOnDrop)]
pub struct ChainKey {
#[zeroize(skip)]
id: String,
material: Vec<u8>,
}
impl ChainKey {
pub const MIN_KEY_BYTES: usize = 32;
pub fn new(id: impl Into<String>, material: Vec<u8>) -> Result<Self, crate::ParseError> {
let id = id.into();
if id.is_empty() {
return Err(crate::ParseError::invariant("CHAIN_KEY", "Id_valid"));
}
if material.len() < Self::MIN_KEY_BYTES {
return Err(crate::ParseError::invariant("CHAIN_KEY", "Material_length"));
}
Ok(Self { id, material })
}
#[must_use]
pub fn id(&self) -> &str {
&self.id
}
fn tag(&self, pre_image: &[u8]) -> Vec<u8> {
let mut mac =
HmacSha256::new_from_slice(&self.material).expect("HMAC accepts a key of any length");
mac.update(pre_image);
mac.finalize().into_bytes().to_vec()
}
}
impl fmt::Debug for ChainKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ChainKey")
.field("id", &self.id)
.field("material", &"<redacted>")
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Tag {
key_id: String,
#[serde(with = "hex_vec")]
mac: Vec<u8>,
}
impl Tag {
#[must_use]
pub fn key_id(&self) -> &str {
&self.key_id
}
}
mod hex_vec {
use serde::{Deserialize, Deserializer, Serializer};
#[allow(clippy::ptr_arg)]
pub fn serialize<S: Serializer>(v: &Vec<u8>, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&super::hex(v))
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> {
let text = String::deserialize(d)?;
if text.len() % 2 != 0 {
return Err(serde::de::Error::custom(
"tag has an odd number of hex digits",
));
}
(0..text.len() / 2)
.map(|i| {
u8::from_str_radix(&text[i * 2..i * 2 + 2], 16)
.map_err(|_| serde::de::Error::custom("tag is not hexadecimal"))
})
.collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChainEntry {
pub version_uid: String,
pub previous: Digest256,
pub content: Digest256,
pub digest: Digest256,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub tag: Option<Tag>,
}
impl ChainEntry {
fn pre_image(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(64 + self.version_uid.len());
out.extend_from_slice(self.previous.as_bytes());
out.extend_from_slice(self.content.as_bytes());
out.extend_from_slice(self.version_uid.as_bytes());
out
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ChainStatus {
Verified,
Broken {
at: usize,
reason: BreakReason,
},
Empty,
UnkeyedOnly,
UnknownKey {
at: usize,
key_id: String,
},
}
impl ChainStatus {
#[must_use]
pub fn is_fully_verified(&self) -> bool {
matches!(self, Self::Verified)
}
#[must_use]
pub fn is_finding(&self) -> bool {
matches!(self, Self::Broken { .. })
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum BreakReason {
PreviousMismatch,
DigestMismatch,
TagMismatch,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Chain {
entries: Vec<ChainEntry>,
#[serde(skip_serializing_if = "Option::is_none", default)]
genesis_note: Option<String>,
}
impl Chain {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn genesis_after(note: impl Into<String>) -> Self {
Self {
entries: Vec::new(),
genesis_note: Some(note.into()),
}
}
#[must_use]
pub fn genesis_note(&self) -> Option<&str> {
self.genesis_note.as_deref()
}
#[must_use]
pub fn entries(&self) -> &[ChainEntry] {
&self.entries
}
#[must_use]
pub fn entries_mut(&mut self) -> &mut [ChainEntry] {
&mut self.entries
}
#[must_use]
pub fn head(&self) -> Digest256 {
self.entries.last().map_or(Digest256::GENESIS, |e| e.digest)
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn append<T: serde::Serialize>(
&mut self,
version_uid: impl Into<String>,
content: &T,
key: Option<&ChainKey>,
) -> Result<Digest256, serde_json::Error> {
let content_digest = Digest256(Sha256::digest(to_canonical_bytes(content)?).into());
let mut entry = ChainEntry {
version_uid: version_uid.into(),
previous: self.head(),
content: content_digest,
digest: Digest256::GENESIS,
tag: None,
};
let pre_image = entry.pre_image();
entry.digest = Digest256(Sha256::digest(&pre_image).into());
if let Some(key) = key {
entry.tag = Some(Tag {
key_id: key.id().to_owned(),
mac: key.tag(&pre_image),
});
}
let digest = entry.digest;
self.entries.push(entry);
Ok(digest)
}
#[must_use]
pub fn verify(&self, keys: &[&ChainKey]) -> ChainStatus {
if self.entries.is_empty() {
return ChainStatus::Empty;
}
let mut previous = Digest256::GENESIS;
let mut any_tag = false;
for (i, entry) in self.entries.iter().enumerate() {
if entry.previous != previous {
return ChainStatus::Broken {
at: i,
reason: BreakReason::PreviousMismatch,
};
}
let pre_image = entry.pre_image();
let recomputed = Digest256(Sha256::digest(&pre_image).into());
if recomputed != entry.digest {
return ChainStatus::Broken {
at: i,
reason: BreakReason::DigestMismatch,
};
}
if let Some(tag) = &entry.tag {
any_tag = true;
let Some(key) = keys.iter().find(|k| k.id() == tag.key_id) else {
return ChainStatus::UnknownKey {
at: i,
key_id: tag.key_id.clone(),
};
};
let expected = key.tag(&pre_image);
if expected.ct_eq(&tag.mac).unwrap_u8() != 1 {
return ChainStatus::Broken {
at: i,
reason: BreakReason::TagMismatch,
};
}
}
previous = entry.digest;
}
if any_tag {
ChainStatus::Verified
} else {
ChainStatus::UnkeyedOnly
}
}
#[must_use]
pub fn checkpoint(&self) -> String {
format!(
"entries={} head={} last_version={}",
self.entries.len(),
self.head(),
self.entries.last().map_or("-", |e| e.version_uid.as_str())
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn key(id: &str, byte: u8) -> ChainKey {
ChainKey::new(id, vec![byte; 32]).unwrap()
}
#[test]
fn a_clean_chain_verifies_and_an_edited_one_does_not() {
let k = key("k1", 7);
let mut chain = Chain::new();
chain
.append("uid::sys::1", &json!({"a": 1}), Some(&k))
.unwrap();
chain
.append("uid::sys::2", &json!({"a": 2}), Some(&k))
.unwrap();
assert_eq!(chain.verify(&[&k]), ChainStatus::Verified);
let mut edited = chain.clone();
edited.entries_mut()[1].content = Digest256::GENESIS;
assert_eq!(
edited.verify(&[&k]),
ChainStatus::Broken {
at: 1,
reason: BreakReason::DigestMismatch
}
);
}
#[test]
fn removing_an_entry_from_the_middle_breaks_the_link() {
let k = key("k1", 7);
let mut chain = Chain::new();
for i in 1..=3 {
chain
.append(format!("uid::sys::{i}"), &json!({"a": i}), Some(&k))
.unwrap();
}
let mut cut = chain.clone();
cut.entries.remove(1);
assert_eq!(
cut.verify(&[&k]),
ChainStatus::Broken {
at: 1,
reason: BreakReason::PreviousMismatch
}
);
}
#[test]
fn an_unheld_key_is_reported_as_such_and_not_as_forgery() {
let writer = key("k1", 7);
let other = key("k2", 9);
let mut chain = Chain::new();
chain
.append("uid::sys::1", &json!({"a": 1}), Some(&writer))
.unwrap();
let status = chain.verify(&[&other]);
assert!(matches!(status, ChainStatus::UnknownKey { at: 0, .. }));
assert!(!status.is_finding());
}
#[test]
fn a_forged_tag_under_a_held_key_is_a_finding() {
let k = key("k1", 7);
let mut chain = Chain::new();
chain
.append("uid::sys::1", &json!({"a": 1}), Some(&k))
.unwrap();
let mut forged = chain.clone();
forged.entries_mut()[0].tag.as_mut().unwrap().mac[0] ^= 0xFF;
assert_eq!(
forged.verify(&[&k]),
ChainStatus::Broken {
at: 0,
reason: BreakReason::TagMismatch
}
);
}
#[test]
fn an_unkeyed_chain_does_not_claim_full_verification() {
let mut chain = Chain::new();
chain.append("uid::sys::1", &json!({"a": 1}), None).unwrap();
let status = chain.verify(&[]);
assert_eq!(status, ChainStatus::UnkeyedOnly);
assert!(!status.is_fully_verified());
assert!(!status.is_finding());
}
#[test]
fn key_rotation_is_additive() {
let old = key("2025", 1);
let new = key("2026", 2);
let mut chain = Chain::new();
chain
.append("uid::sys::1", &json!({"a": 1}), Some(&old))
.unwrap();
chain
.append("uid::sys::2", &json!({"a": 2}), Some(&new))
.unwrap();
assert_eq!(chain.verify(&[&old, &new]), ChainStatus::Verified);
assert!(matches!(
chain.verify(&[&new]),
ChainStatus::UnknownKey { at: 0, .. }
));
}
#[test]
fn short_keys_and_empty_ids_are_refused() {
assert!(ChainKey::new("k", vec![0u8; 31]).is_err());
assert!(ChainKey::new("", vec![0u8; 32]).is_err());
assert!(ChainKey::new("k", vec![0u8; 32]).is_ok());
}
#[test]
fn key_debug_does_not_print_the_material() {
let k = ChainKey::new("k1", vec![0xAB; 32]).unwrap();
let rendered = format!("{k:?}");
assert!(
!rendered.contains("171") && !rendered.contains("ab"),
"{rendered}"
);
assert!(rendered.contains("redacted"));
}
#[test]
fn a_checkpoint_carries_no_patient_data() {
let mut chain = Chain::new();
chain
.append(
"uid::sys::1",
&json!({"name": {"value": "ZZ-DISTINCTIVE-9999"}}),
None,
)
.unwrap();
let checkpoint = chain.checkpoint();
assert!(!checkpoint.contains("ZZ-DISTINCTIVE"), "{checkpoint}");
assert!(checkpoint.contains("entries=1"));
}
#[test]
fn the_same_content_in_a_different_key_order_chains_identically() {
let a: serde_json::Value = serde_json::from_str(r#"{"b":1,"a":2}"#).unwrap();
let b: serde_json::Value = serde_json::from_str(r#"{"a":2,"b":1}"#).unwrap();
let mut one = Chain::new();
let mut two = Chain::new();
one.append("uid::sys::1", &a, None).unwrap();
two.append("uid::sys::1", &b, None).unwrap();
assert_eq!(one.head(), two.head());
}
#[test]
fn an_empty_chain_reports_empty_rather_than_verified() {
assert_eq!(Chain::new().verify(&[]), ChainStatus::Empty);
assert!(!Chain::new().verify(&[]).is_fully_verified());
}
}