use chrono::{DateTime, Utc};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use uuid::Uuid;
use super::envelope::{GENESIS_HASH, hash32_b64, hash32_opt_b64};
const CHECKPOINT_DOMAIN: &[u8] = b"vtc-audit-checkpoint/v1\0";
const CHECKPOINT_LINK_DOMAIN: &[u8] = b"vtc-audit-checkpoint-link/v1\0";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AuditCheckpoint {
pub checkpoint_id: Uuid,
#[serde(with = "hash32_b64")]
pub head: [u8; 32],
pub entry_count: u64,
pub head_event_id: Uuid,
pub checkpoint_at: DateTime<Utc>,
#[serde(with = "hash32_opt_b64")]
pub prev_checkpoint: Option<[u8; 32]>,
pub verification_method: String,
#[serde(with = "sig_b64")]
pub signature: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CheckpointClaim {
pub checkpoint_id: Uuid,
pub head: [u8; 32],
pub entry_count: u64,
pub head_event_id: Uuid,
pub checkpoint_at: DateTime<Utc>,
pub prev_checkpoint: Option<[u8; 32]>,
pub verification_method: String,
}
impl CheckpointClaim {
#[must_use]
pub fn signing_payload(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(256);
out.extend_from_slice(CHECKPOINT_DOMAIN);
out.extend_from_slice(self.checkpoint_id.as_bytes());
out.extend_from_slice(&self.head);
out.extend_from_slice(&self.entry_count.to_be_bytes());
out.extend_from_slice(self.head_event_id.as_bytes());
let ts = self.checkpoint_at.to_rfc3339();
out.extend_from_slice(&(ts.len() as u64).to_be_bytes());
out.extend_from_slice(ts.as_bytes());
match self.prev_checkpoint {
Some(p) => {
out.push(1);
out.extend_from_slice(&p);
}
None => out.push(0),
}
out.extend_from_slice(&(self.verification_method.len() as u64).to_be_bytes());
out.extend_from_slice(self.verification_method.as_bytes());
out
}
}
impl AuditCheckpoint {
#[must_use]
pub fn sign(claim: CheckpointClaim, signing_key: &SigningKey) -> Self {
let signature = signing_key
.sign(&claim.signing_payload())
.to_bytes()
.to_vec();
Self {
checkpoint_id: claim.checkpoint_id,
head: claim.head,
entry_count: claim.entry_count,
head_event_id: claim.head_event_id,
checkpoint_at: claim.checkpoint_at,
prev_checkpoint: claim.prev_checkpoint,
verification_method: claim.verification_method,
signature,
}
}
#[must_use]
pub fn claim(&self) -> CheckpointClaim {
CheckpointClaim {
checkpoint_id: self.checkpoint_id,
head: self.head,
entry_count: self.entry_count,
head_event_id: self.head_event_id,
checkpoint_at: self.checkpoint_at,
prev_checkpoint: self.prev_checkpoint,
verification_method: self.verification_method.clone(),
}
}
#[must_use]
pub fn verify_signature(&self, public_key: &[u8]) -> bool {
let Ok(key_bytes) = <[u8; 32]>::try_from(public_key) else {
return false;
};
let Ok(verifying) = VerifyingKey::from_bytes(&key_bytes) else {
return false;
};
let Ok(sig_bytes) = <[u8; 64]>::try_from(self.signature.as_slice()) else {
return false;
};
verifying
.verify(
&self.claim().signing_payload(),
&Signature::from_bytes(&sig_bytes),
)
.is_ok()
}
#[must_use]
pub fn link_hash(&self) -> [u8; 32] {
let mut h = Sha256::new();
h.update(CHECKPOINT_LINK_DOMAIN);
h.update(self.claim().signing_payload());
h.update((self.signature.len() as u64).to_be_bytes());
h.update(&self.signature);
h.finalize().into()
}
#[must_use]
pub fn storage_key(&self) -> Vec<u8> {
format!("{}:{}", self.checkpoint_at.to_rfc3339(), self.checkpoint_id).into_bytes()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CheckpointBreak {
BadSignature { index: usize, checkpoint_id: Uuid },
BrokenLink { index: usize, checkpoint_id: Uuid },
CountWentBackwards {
index: usize,
checkpoint_id: Uuid,
previous: u64,
claimed: u64,
},
}
pub fn verify_checkpoints<F>(
checkpoints: &[AuditCheckpoint],
mut public_key_for: F,
) -> Result<Option<&AuditCheckpoint>, CheckpointBreak>
where
F: FnMut(&str) -> Option<Vec<u8>>,
{
let mut prev_link: Option<[u8; 32]> = None;
let mut prev_count: u64 = 0;
for (index, cp) in checkpoints.iter().enumerate() {
let ok = public_key_for(&cp.verification_method).is_some_and(|pk| cp.verify_signature(&pk));
if !ok {
return Err(CheckpointBreak::BadSignature {
index,
checkpoint_id: cp.checkpoint_id,
});
}
if cp.prev_checkpoint != prev_link {
return Err(CheckpointBreak::BrokenLink {
index,
checkpoint_id: cp.checkpoint_id,
});
}
if cp.entry_count < prev_count {
return Err(CheckpointBreak::CountWentBackwards {
index,
checkpoint_id: cp.checkpoint_id,
previous: prev_count,
claimed: cp.entry_count,
});
}
prev_link = Some(cp.link_hash());
prev_count = cp.entry_count;
}
Ok(checkpoints.last())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CheckpointAudit {
NoCheckpoints,
Consistent {
checkpoint_at: DateTime<Utc>,
attested_entries: u64,
unattested_entries: u64,
},
Truncated { attested: u64, found: u64 },
HeadMismatch {
head_event_id: Uuid,
found: bool,
},
}
mod sig_b64 {
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as B64;
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(v: &[u8], s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&B64.encode(v))
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> {
let s = String::deserialize(d)?;
B64.decode(s.as_bytes()).map_err(serde::de::Error::custom)
}
}
#[must_use]
pub fn genesis_claim(
checkpoint_id: Uuid,
checkpoint_at: DateTime<Utc>,
verification_method: String,
) -> CheckpointClaim {
CheckpointClaim {
checkpoint_id,
head: GENESIS_HASH,
entry_count: 0,
head_event_id: Uuid::nil(),
checkpoint_at,
prev_checkpoint: None,
verification_method,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(seed: u8) -> SigningKey {
SigningKey::from_bytes(&[seed; 32])
}
fn claim(n: u64, prev: Option<[u8; 32]>) -> CheckpointClaim {
CheckpointClaim {
checkpoint_id: Uuid::from_u128(u128::from(n) + 1),
head: [n as u8; 32],
entry_count: n,
head_event_id: Uuid::from_u128(u128::from(n) + 1000),
checkpoint_at: DateTime::parse_from_rfc3339("2026-07-25T10:00:00Z")
.unwrap()
.with_timezone(&Utc),
prev_checkpoint: prev,
verification_method: "did:webvh:scid:vtc.example#key-0".into(),
}
}
fn chain(sk: &SigningKey, counts: &[u64]) -> Vec<AuditCheckpoint> {
let mut out: Vec<AuditCheckpoint> = Vec::new();
for &n in counts {
let prev = out.last().map(AuditCheckpoint::link_hash);
out.push(AuditCheckpoint::sign(claim(n, prev), sk));
}
out
}
fn resolver(sk: &SigningKey) -> impl FnMut(&str) -> Option<Vec<u8>> + use<'_> {
move |_vm: &str| Some(sk.verifying_key().to_bytes().to_vec())
}
#[test]
fn a_signed_checkpoint_verifies_under_its_own_key() {
let sk = key(1);
let cp = AuditCheckpoint::sign(claim(10, None), &sk);
assert!(cp.verify_signature(&sk.verifying_key().to_bytes()));
}
#[test]
fn a_checkpoint_signed_by_a_different_key_is_rejected() {
let real = key(1);
let attacker = key(2);
let forged = AuditCheckpoint::sign(claim(10, None), &attacker);
assert!(!forged.verify_signature(&real.verifying_key().to_bytes()));
}
#[test]
fn lowering_entry_count_breaks_the_signature() {
let sk = key(1);
let mut cp = AuditCheckpoint::sign(claim(500, None), &sk);
cp.entry_count = 3;
assert!(!cp.verify_signature(&sk.verifying_key().to_bytes()));
}
#[test]
fn head_cannot_be_swapped() {
let sk = key(1);
let mut cp = AuditCheckpoint::sign(claim(10, None), &sk);
cp.head = [0xAB; 32];
assert!(!cp.verify_signature(&sk.verifying_key().to_bytes()));
}
#[test]
fn a_well_formed_chain_verifies() {
let sk = key(1);
let cps = chain(&sk, &[10, 25, 40]);
let newest = verify_checkpoints(&cps, resolver(&sk)).expect("chain verifies");
assert_eq!(newest.map(|c| c.entry_count), Some(40));
}
#[test]
fn deleting_a_checkpoint_breaks_the_chain() {
let sk = key(1);
let cps = chain(&sk, &[10, 25, 40]);
let gapped = vec![cps[0].clone(), cps[2].clone()];
assert!(matches!(
verify_checkpoints(&gapped, resolver(&sk)),
Err(CheckpointBreak::BrokenLink { index: 1, .. })
));
}
#[test]
fn reordering_checkpoints_breaks_the_chain() {
let sk = key(1);
let cps = chain(&sk, &[10, 25]);
let swapped = vec![cps[1].clone(), cps[0].clone()];
assert!(matches!(
verify_checkpoints(&swapped, resolver(&sk)),
Err(CheckpointBreak::BrokenLink { .. })
));
}
#[test]
fn entry_count_may_not_go_backwards() {
let sk = key(1);
let first = AuditCheckpoint::sign(claim(40, None), &sk);
let second = AuditCheckpoint::sign(claim(10, Some(first.link_hash())), &sk);
assert!(matches!(
verify_checkpoints(&[first, second], resolver(&sk)),
Err(CheckpointBreak::CountWentBackwards {
previous: 40,
claimed: 10,
..
})
));
}
#[test]
fn an_unresolvable_signing_key_fails_verification() {
let sk = key(1);
let cps = chain(&sk, &[10]);
assert!(matches!(
verify_checkpoints(&cps, |_vm: &str| None),
Err(CheckpointBreak::BadSignature { index: 0, .. })
));
}
#[test]
fn an_empty_checkpoint_set_is_not_an_error() {
let sk = key(1);
assert_eq!(verify_checkpoints(&[], resolver(&sk)), Ok(None));
}
#[test]
fn checkpoints_round_trip_through_json() {
let sk = key(1);
let cp = AuditCheckpoint::sign(claim(7, Some([9u8; 32])), &sk);
let json = serde_json::to_vec(&cp).expect("serialize");
let back: AuditCheckpoint = serde_json::from_slice(&json).expect("deserialize");
assert_eq!(cp, back);
assert!(back.verify_signature(&sk.verifying_key().to_bytes()));
}
#[test]
fn link_hash_and_signing_payload_are_domain_separated() {
let sk = key(1);
let cp = AuditCheckpoint::sign(claim(10, None), &sk);
assert_ne!(cp.link_hash().to_vec(), cp.claim().signing_payload());
}
#[test]
fn link_hash_covers_the_signature() {
let a = AuditCheckpoint::sign(claim(10, None), &key(1));
let b = AuditCheckpoint::sign(claim(10, None), &key(2));
assert_eq!(a.claim(), b.claim(), "same claim");
assert_ne!(a.link_hash(), b.link_hash(), "different signature");
}
#[test]
fn storage_key_sorts_chronologically() {
let sk = key(1);
let mut early = claim(1, None);
early.checkpoint_at = DateTime::parse_from_rfc3339("2026-07-25T09:00:00Z")
.unwrap()
.with_timezone(&Utc);
let a = AuditCheckpoint::sign(early, &sk);
let b = AuditCheckpoint::sign(claim(2, Some(a.link_hash())), &sk);
assert!(a.storage_key() < b.storage_key());
}
}