use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::attestation::{Signer, SignerError};
use crate::statements::unix_to_rfc3339;
use crate::trust::{TrustRootKind, TrustRootStore};
use super::tree::{MerkleTree, MERKLE_VERSION_V1};
pub const CANONICAL_VERSION_V1: u8 = 1;
pub const CANONICAL_VERSION_V2: u8 = 2;
pub const CANONICAL_VERSION_V3: u8 = 3;
pub fn default_canonical_version_v2() -> u8 {
CANONICAL_VERSION_V2
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Checkpoint {
pub index: u64,
pub root: String,
pub tree_size: usize,
pub height: usize,
pub signed_at: String,
pub signer: String,
pub public_key: String,
pub signature: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub algorithm: Option<String>,
#[serde(default = "super::tree::default_merkle_version_v1")]
pub merkle_version: u8,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub zk_proof: Option<ChainProofSummary>,
#[serde(default = "default_canonical_version_v2")]
pub canonical_version: u8,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChainProofSummary {
pub image_id: String,
pub all_signatures_valid: bool,
pub chain_intact: bool,
pub approval_nonces_matched: bool,
pub artifact_count: u64,
pub public_key_digest: String,
pub proved_at: String,
}
#[derive(Debug)]
pub enum CheckpointError {
EmptyTree,
Signing(SignerError),
}
impl std::fmt::Display for CheckpointError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::EmptyTree => write!(f, "cannot checkpoint an empty tree"),
Self::Signing(e) => write!(f, "checkpoint signing failed: {}", e),
}
}
}
impl std::error::Error for CheckpointError {}
impl From<SignerError> for CheckpointError {
fn from(e: SignerError) -> Self {
Self::Signing(e)
}
}
impl Checkpoint {
#[allow(clippy::too_many_arguments)]
pub(crate) fn canonical_for_signing(
canonical_version: u8,
merkle_version: u8,
algorithm: Option<&str>,
zk_proof: Option<&ChainProofSummary>,
index: u64,
root: &str,
tree_size: usize,
height: usize,
signer: &str,
signed_at: &str,
) -> String {
if merkle_version == MERKLE_VERSION_V1 {
return format!(
"{}|{}|{}|{}|{}|{}",
index, root, tree_size, height, signer, signed_at,
);
}
match canonical_version {
CANONICAL_VERSION_V2 => format!(
"v2|{}|{}|{}|{}|{}|{}|{}",
merkle_version, index, root, tree_size, height, signer, signed_at,
),
_ => {
let algo_field = algorithm.unwrap_or("");
let zk_digest = zk_proof
.map(zk_proof_digest_hex)
.unwrap_or_default();
format!(
"v3|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
canonical_version,
merkle_version,
algo_field,
zk_digest,
index,
root,
tree_size,
height,
signer,
signed_at,
)
}
}
}
pub fn create(
index: u64,
tree: &MerkleTree,
signer: &dyn Signer,
) -> Result<Self, CheckpointError> {
let root_bytes = tree.root().ok_or(CheckpointError::EmptyTree)?;
let root = format!("sha256:{}", hex::encode(root_bytes));
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let signed_at = unix_to_rfc3339(secs);
let canonical_version = if tree.version() == MERKLE_VERSION_V1 {
CANONICAL_VERSION_V1
} else {
CANONICAL_VERSION_V3
};
let algorithm = Some(super::tree::MERKLE_ALGORITHM_V2.to_string());
let zk_proof: Option<ChainProofSummary> = None;
let canonical = Self::canonical_for_signing(
canonical_version,
tree.version(),
algorithm.as_deref(),
zk_proof.as_ref(),
index,
&root,
tree.len(),
tree.height(),
signer.key_id(),
&signed_at,
);
let sig_bytes = signer.sign(canonical.as_bytes())?;
let signature = URL_SAFE_NO_PAD.encode(&sig_bytes);
let public_key = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
Ok(Self {
index,
root,
tree_size: tree.len(),
height: tree.height(),
signed_at,
signer: signer.key_id().to_string(),
public_key,
signature,
algorithm,
merkle_version: tree.version(),
zk_proof,
canonical_version,
})
}
pub fn verify(&self, trust: &TrustRootStore) -> bool {
let pub_bytes = match URL_SAFE_NO_PAD.decode(&self.public_key) {
Ok(b) => b,
Err(_) => return false,
};
let pub_array: [u8; 32] = match pub_bytes.as_slice().try_into() {
Ok(a) => a,
Err(_) => return false,
};
let vk = match VerifyingKey::from_bytes(&pub_array) {
Ok(k) => k,
Err(_) => return false,
};
if !trust.contains(&vk, TrustRootKind::HubCheckpoint) {
return false;
}
if self.merkle_version != MERKLE_VERSION_V1
&& self.canonical_version != CANONICAL_VERSION_V2
&& self.canonical_version != CANONICAL_VERSION_V3
{
return false;
}
let canonical = Self::canonical_for_signing(
self.canonical_version,
self.merkle_version,
self.algorithm.as_deref(),
self.zk_proof.as_ref(),
self.index,
&self.root,
self.tree_size,
self.height,
&self.signer,
&self.signed_at,
);
let sig_bytes = match URL_SAFE_NO_PAD.decode(&self.signature) {
Ok(b) => b,
Err(_) => return false,
};
let sig_array: [u8; 64] = match sig_bytes.as_slice().try_into() {
Ok(a) => a,
Err(_) => return false,
};
let sig = Signature::from_bytes(&sig_array);
vk.verify(canonical.as_bytes(), &sig).is_ok()
}
}
fn zk_proof_digest_hex(summary: &ChainProofSummary) -> String {
let value = serde_json::to_value(summary)
.expect("ChainProofSummary serializes to JSON value");
let canonical = canonical_json_string(&value);
hex::encode(Sha256::digest(canonical.as_bytes()))
}
fn canonical_json_string(value: &serde_json::Value) -> String {
use std::collections::BTreeMap;
match value {
serde_json::Value::Object(map) => {
let sorted: BTreeMap<&String, String> = map
.iter()
.map(|(k, v)| (k, canonical_json_string(v)))
.collect();
let mut out = String::from("{");
let mut first = true;
for (k, v) in sorted {
if !first {
out.push(',');
}
first = false;
let key_json = serde_json::to_string(k)
.expect("string serializes to JSON");
out.push_str(&key_json);
out.push(':');
out.push_str(&v);
}
out.push('}');
out
}
serde_json::Value::Array(items) => {
let mut out = String::from("[");
let mut first = true;
for item in items {
if !first {
out.push(',');
}
first = false;
out.push_str(&canonical_json_string(item));
}
out.push(']');
out
}
other => serde_json::to_string(other)
.expect("scalar serializes to JSON"),
}
}
#[cfg(test)]
mod trust_pin_tests {
use super::*;
use crate::attestation::{Ed25519Signer, Signer};
use crate::merkle::MerkleTree;
use crate::trust::{encode_ed25519_pubkey, TrustRoot, TrustRootKind, TrustRootStore};
fn signer_and_tree() -> (Ed25519Signer, MerkleTree) {
let mut tree = MerkleTree::new();
tree.append("art_alpha");
tree.append("art_beta");
let signer = Ed25519Signer::generate("key_test").unwrap();
(signer, tree)
}
fn trust_with(signer: &Ed25519Signer) -> TrustRootStore {
use ed25519_dalek::VerifyingKey;
let pk_bytes: [u8; 32] = signer.public_key_bytes().try_into().unwrap();
let vk = VerifyingKey::from_bytes(&pk_bytes).unwrap();
TrustRootStore::with_roots(vec![TrustRoot {
key_id: signer.key_id().to_string(),
public_key: encode_ed25519_pubkey(&vk),
kind: TrustRootKind::HubCheckpoint,
label: "trusted hub".into(),
added_at: "2026-05-15T00:00:00Z".into(),
}])
}
#[test]
fn verify_rejects_unknown_pubkey() {
let (signer, tree) = signer_and_tree();
let cp = Checkpoint::create(1, &tree, &signer).unwrap();
let other = Ed25519Signer::generate("other").unwrap();
let trust = trust_with(&other);
assert!(!cp.verify(&trust),
"unknown issuer must be rejected even with valid signature");
}
#[test]
fn verify_accepts_trusted_pubkey() {
let (signer, tree) = signer_and_tree();
let cp = Checkpoint::create(1, &tree, &signer).unwrap();
let trust = trust_with(&signer);
assert!(cp.verify(&trust), "trusted issuer + good signature must verify");
}
#[test]
fn verify_rejects_with_no_trust_configured() {
let (signer, tree) = signer_and_tree();
let cp = Checkpoint::create(1, &tree, &signer).unwrap();
let trust = TrustRootStore::empty();
assert!(!cp.verify(&trust),
"empty trust store must reject all checkpoints");
}
#[test]
fn verify_rejects_pubkey_pinned_for_wrong_kind() {
let (signer, tree) = signer_and_tree();
let cp = Checkpoint::create(1, &tree, &signer).unwrap();
use ed25519_dalek::VerifyingKey;
let pk_bytes: [u8; 32] = signer.public_key_bytes().try_into().unwrap();
let vk = VerifyingKey::from_bytes(&pk_bytes).unwrap();
let mismatched = TrustRootStore::with_roots(vec![TrustRoot {
key_id: signer.key_id().to_string(),
public_key: encode_ed25519_pubkey(&vk),
kind: TrustRootKind::AgentCert, label: "trusted for agent certs only".into(),
added_at: "2026-05-15T00:00:00Z".into(),
}]);
assert!(!cp.verify(&mismatched),
"kind discrimination must keep AgentCert roots out of checkpoint trust");
}
#[test]
fn verify_rejects_attacker_self_signed_forgery() {
let (attacker_signer, tree) = signer_and_tree();
let forgery = Checkpoint::create(99, &tree, &attacker_signer).unwrap();
let honest = Ed25519Signer::generate("honest_hub").unwrap();
let trust = trust_with(&honest);
assert!(!forgery.verify(&trust),
"self-signed forgery must not verify against operator's trust set");
}
}
#[cfg(test)]
mod canonical_v3_tests {
use super::*;
use crate::attestation::{Ed25519Signer, Signer};
use crate::merkle::tree::{MerkleTree, MERKLE_ALGORITHM_V2, MERKLE_VERSION_V1, MERKLE_VERSION_V2};
use crate::trust::{encode_ed25519_pubkey, TrustRoot, TrustRootKind, TrustRootStore};
fn signer_and_tree() -> (Ed25519Signer, MerkleTree) {
let mut tree = MerkleTree::new();
tree.append("art_alpha");
tree.append("art_beta");
let signer = Ed25519Signer::generate("key_test").unwrap();
(signer, tree)
}
fn trust_with(signer: &Ed25519Signer) -> TrustRootStore {
use ed25519_dalek::VerifyingKey;
let pk_bytes: [u8; 32] = signer.public_key_bytes().try_into().unwrap();
let vk = VerifyingKey::from_bytes(&pk_bytes).unwrap();
TrustRootStore::with_roots(vec![TrustRoot {
key_id: signer.key_id().to_string(),
public_key: encode_ed25519_pubkey(&vk),
kind: TrustRootKind::HubCheckpoint,
label: "trusted hub".into(),
added_at: "2026-05-15T00:00:00Z".into(),
}])
}
fn sample_zk_proof() -> ChainProofSummary {
ChainProofSummary {
image_id: "sha256:beef".into(),
all_signatures_valid: true,
chain_intact: true,
approval_nonces_matched: true,
artifact_count: 7,
public_key_digest: "sha256:cafe".into(),
proved_at: "2026-05-17T01:23:45Z".into(),
}
}
#[test]
fn fresh_checkpoint_is_v3() {
let (signer, tree) = signer_and_tree();
let cp = Checkpoint::create(1, &tree, &signer).unwrap();
assert_eq!(cp.canonical_version, CANONICAL_VERSION_V3);
assert_eq!(cp.merkle_version, MERKLE_VERSION_V2);
assert!(cp.algorithm.is_some());
}
#[test]
fn algorithm_tamper_detected() {
let (signer, tree) = signer_and_tree();
let trust = trust_with(&signer);
let mut cp = Checkpoint::create(1, &tree, &signer).unwrap();
assert!(cp.verify(&trust), "baseline must verify");
cp.algorithm = Some("sha256-attacker".into());
assert!(
!cp.verify(&trust),
"algorithm field mutation on the wire must break the v3 signature"
);
let mut cp2 = Checkpoint::create(1, &tree, &signer).unwrap();
cp2.algorithm = None;
assert!(
!cp2.verify(&trust),
"removing algorithm on the wire must break the v3 signature"
);
}
#[test]
fn zk_proof_tamper_detected() {
let (signer, tree) = signer_and_tree();
let trust = trust_with(&signer);
let mut cp_attach = Checkpoint::create(1, &tree, &signer).unwrap();
assert!(cp_attach.zk_proof.is_none(), "fresh checkpoint must have no proof");
cp_attach.zk_proof = Some(sample_zk_proof());
assert!(
!cp_attach.verify(&trust),
"attaching a zk_proof on the wire must break the v3 signature"
);
let (signer_b, tree_b) = signer_and_tree();
let trust_b = trust_with(&signer_b);
let mut cp_swap = checkpoint_signed_with_proof(
&signer_b, &tree_b, 1, Some(sample_zk_proof()),
);
assert!(cp_swap.verify(&trust_b), "freshly signed v3+proof must verify");
let mut tampered = sample_zk_proof();
tampered.chain_intact = false;
cp_swap.zk_proof = Some(tampered);
assert!(
!cp_swap.verify(&trust_b),
"mutating a zk_proof field on the wire must break the v3 signature"
);
let mut cp_strip = checkpoint_signed_with_proof(
&signer_b, &tree_b, 1, Some(sample_zk_proof()),
);
cp_strip.zk_proof = None;
assert!(
!cp_strip.verify(&trust_b),
"stripping zk_proof on the wire must break the v3 signature"
);
}
#[test]
fn v2_legacy_checkpoint_still_verifies() {
let (signer, tree) = signer_and_tree();
let trust = trust_with(&signer);
let cp_v2 = sign_legacy_v2(&signer, &tree, 1);
assert_eq!(cp_v2.canonical_version, CANONICAL_VERSION_V2);
assert_eq!(cp_v2.merkle_version, MERKLE_VERSION_V2);
assert!(
cp_v2.verify(&trust),
"v0.10.3-era v2-canonical checkpoint must still verify"
);
let mut json = serde_json::to_value(&cp_v2).unwrap();
json.as_object_mut().unwrap().remove("canonical_version");
let reparsed: Checkpoint = serde_json::from_value(json).unwrap();
assert_eq!(reparsed.canonical_version, CANONICAL_VERSION_V2);
assert!(
reparsed.verify(&trust),
"v2 checkpoint deserialized without canonical_version field must verify"
);
}
#[test]
fn v1_legacy_checkpoint_still_verifies() {
let signer = Ed25519Signer::generate("legacy_key").unwrap();
let trust = trust_with(&signer);
let cp_v1 = sign_legacy_v1(&signer, 99, "sha256:legacy_root", 4, 2);
assert_eq!(cp_v1.merkle_version, MERKLE_VERSION_V1);
assert!(
cp_v1.verify(&trust),
"pre-v0.10.3 v1-canonical checkpoint must still verify"
);
let mut json = serde_json::to_value(&cp_v1).unwrap();
json.as_object_mut().unwrap().remove("canonical_version");
json.as_object_mut().unwrap().remove("merkle_version");
json.as_object_mut().unwrap().remove("algorithm");
let reparsed: Checkpoint = serde_json::from_value(json).unwrap();
assert_eq!(reparsed.merkle_version, MERKLE_VERSION_V1);
assert!(
reparsed.verify(&trust),
"pre-v0.10.3 v1 checkpoint stripped of new fields must verify"
);
}
#[test]
fn cross_version_downgrade_v3_to_v2_rejected() {
let (signer, tree) = signer_and_tree();
let trust = trust_with(&signer);
let mut cp = Checkpoint::create(1, &tree, &signer).unwrap();
assert_eq!(cp.canonical_version, CANONICAL_VERSION_V3);
assert!(cp.verify(&trust), "baseline v3 must verify");
cp.canonical_version = CANONICAL_VERSION_V2;
assert!(
!cp.verify(&trust),
"v3->v2 canonical_version downgrade must fail (signature covers v3 bytes)"
);
let (signer2, tree2) = signer_and_tree();
let trust2 = trust_with(&signer2);
let mut cp2 = Checkpoint::create(1, &tree2, &signer2).unwrap();
cp2.canonical_version = CANONICAL_VERSION_V2;
cp2.algorithm = None;
assert!(
!cp2.verify(&trust2),
"v3->v2 downgrade + strip algorithm must still fail"
);
}
#[test]
fn unknown_canonical_version_rejected() {
let (signer, tree) = signer_and_tree();
let trust = trust_with(&signer);
let mut cp = Checkpoint::create(1, &tree, &signer).unwrap();
cp.canonical_version = 99;
assert!(
!cp.verify(&trust),
"unknown canonical_version must fail closed (no silent fallback)"
);
}
fn checkpoint_signed_with_proof(
signer: &Ed25519Signer,
tree: &MerkleTree,
index: u64,
zk_proof: Option<ChainProofSummary>,
) -> Checkpoint {
let root_bytes = tree.root().expect("non-empty tree");
let root = format!("sha256:{}", hex::encode(root_bytes));
let signed_at = "2026-05-17T00:00:00Z".to_string();
let algorithm = Some(MERKLE_ALGORITHM_V2.to_string());
let canonical = Checkpoint::canonical_for_signing(
CANONICAL_VERSION_V3,
tree.version(),
algorithm.as_deref(),
zk_proof.as_ref(),
index,
&root,
tree.len(),
tree.height(),
signer.key_id(),
&signed_at,
);
let sig_bytes = signer.sign(canonical.as_bytes()).unwrap();
Checkpoint {
index,
root,
tree_size: tree.len(),
height: tree.height(),
signed_at,
signer: signer.key_id().to_string(),
public_key: URL_SAFE_NO_PAD.encode(signer.public_key_bytes()),
signature: URL_SAFE_NO_PAD.encode(&sig_bytes),
algorithm,
merkle_version: tree.version(),
zk_proof,
canonical_version: CANONICAL_VERSION_V3,
}
}
fn sign_legacy_v2(
signer: &Ed25519Signer,
tree: &MerkleTree,
index: u64,
) -> Checkpoint {
let root_bytes = tree.root().expect("non-empty tree");
let root = format!("sha256:{}", hex::encode(root_bytes));
let signed_at = "2026-05-17T00:00:00Z".to_string();
let canonical = Checkpoint::canonical_for_signing(
CANONICAL_VERSION_V2,
tree.version(),
None, None, index,
&root,
tree.len(),
tree.height(),
signer.key_id(),
&signed_at,
);
assert!(canonical.starts_with("v2|"));
let sig_bytes = signer.sign(canonical.as_bytes()).unwrap();
Checkpoint {
index,
root,
tree_size: tree.len(),
height: tree.height(),
signed_at,
signer: signer.key_id().to_string(),
public_key: URL_SAFE_NO_PAD.encode(signer.public_key_bytes()),
signature: URL_SAFE_NO_PAD.encode(&sig_bytes),
algorithm: Some(MERKLE_ALGORITHM_V2.to_string()),
merkle_version: MERKLE_VERSION_V2,
zk_proof: None,
canonical_version: CANONICAL_VERSION_V2,
}
}
fn sign_legacy_v1(
signer: &Ed25519Signer,
index: u64,
root: &str,
tree_size: usize,
height: usize,
) -> Checkpoint {
let signed_at = "2026-04-01T00:00:00Z".to_string();
let canonical = Checkpoint::canonical_for_signing(
CANONICAL_VERSION_V1,
MERKLE_VERSION_V1,
None,
None,
index,
root,
tree_size,
height,
signer.key_id(),
&signed_at,
);
assert_eq!(
canonical,
format!(
"{}|{}|{}|{}|{}|{}",
index, root, tree_size, height, signer.key_id(), signed_at
),
"v1 canonical must remain byte-identical to legacy"
);
let sig_bytes = signer.sign(canonical.as_bytes()).unwrap();
Checkpoint {
index,
root: root.to_string(),
tree_size,
height,
signed_at,
signer: signer.key_id().to_string(),
public_key: URL_SAFE_NO_PAD.encode(signer.public_key_bytes()),
signature: URL_SAFE_NO_PAD.encode(&sig_bytes),
algorithm: None,
merkle_version: MERKLE_VERSION_V1,
zk_proof: None,
canonical_version: CANONICAL_VERSION_V1,
}
}
}