1use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
2use ed25519_dalek::{Signature, Verifier, VerifyingKey};
3use serde::{Deserialize, Serialize};
4
5use crate::attestation::{Signer, SignerError};
6use crate::statements::unix_to_rfc3339;
7use crate::trust::{TrustRootKind, TrustRootStore};
8
9use super::tree::{MerkleTree, MERKLE_VERSION_V1};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct Checkpoint {
14 pub index: u64,
15 pub root: String,
17 pub tree_size: usize,
18 pub height: usize,
19 pub signed_at: String,
21 pub signer: String,
23 pub public_key: String,
25 pub signature: String,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub algorithm: Option<String>,
30 #[serde(default = "super::tree::default_merkle_version_v1")]
35 pub merkle_version: u8,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub zk_proof: Option<ChainProofSummary>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct ChainProofSummary {
44 pub image_id: String,
45 pub all_signatures_valid: bool,
46 pub chain_intact: bool,
47 pub approval_nonces_matched: bool,
48 pub artifact_count: u64,
49 pub public_key_digest: String,
50 pub proved_at: String,
51}
52
53#[derive(Debug)]
55pub enum CheckpointError {
56 EmptyTree,
57 Signing(SignerError),
58}
59
60impl std::fmt::Display for CheckpointError {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 match self {
63 Self::EmptyTree => write!(f, "cannot checkpoint an empty tree"),
64 Self::Signing(e) => write!(f, "checkpoint signing failed: {}", e),
65 }
66 }
67}
68
69impl std::error::Error for CheckpointError {}
70impl From<SignerError> for CheckpointError {
71 fn from(e: SignerError) -> Self {
72 Self::Signing(e)
73 }
74}
75
76impl Checkpoint {
77 pub(crate) fn canonical_for_signing(
103 merkle_version: u8,
104 index: u64,
105 root: &str,
106 tree_size: usize,
107 height: usize,
108 signer: &str,
109 signed_at: &str,
110 ) -> String {
111 if merkle_version == MERKLE_VERSION_V1 {
112 format!(
115 "{}|{}|{}|{}|{}|{}",
116 index, root, tree_size, height, signer, signed_at,
117 )
118 } else {
119 format!(
123 "v2|{}|{}|{}|{}|{}|{}|{}",
124 merkle_version, index, root, tree_size, height, signer, signed_at,
125 )
126 }
127 }
128
129 pub fn create(
134 index: u64,
135 tree: &MerkleTree,
136 signer: &dyn Signer,
137 ) -> Result<Self, CheckpointError> {
138 let root_bytes = tree.root().ok_or(CheckpointError::EmptyTree)?;
139 let root = format!("sha256:{}", hex::encode(root_bytes));
140
141 let secs = std::time::SystemTime::now()
142 .duration_since(std::time::UNIX_EPOCH)
143 .unwrap_or_default()
144 .as_secs();
145 let signed_at = unix_to_rfc3339(secs);
146
147 let canonical = Self::canonical_for_signing(
148 tree.version(),
149 index,
150 &root,
151 tree.len(),
152 tree.height(),
153 signer.key_id(),
154 &signed_at,
155 );
156 let sig_bytes = signer.sign(canonical.as_bytes())?;
157 let signature = URL_SAFE_NO_PAD.encode(&sig_bytes);
158 let public_key = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
159
160 Ok(Self {
161 index,
162 root,
163 tree_size: tree.len(),
164 height: tree.height(),
165 signed_at,
166 signer: signer.key_id().to_string(),
167 public_key,
168 signature,
169 algorithm: Some(super::tree::MERKLE_ALGORITHM_V2.to_string()),
170 merkle_version: tree.version(),
171 zk_proof: None,
172 })
173 }
174
175 pub fn verify(&self, trust: &TrustRootStore) -> bool {
185 let pub_bytes = match URL_SAFE_NO_PAD.decode(&self.public_key) {
186 Ok(b) => b,
187 Err(_) => return false,
188 };
189 let pub_array: [u8; 32] = match pub_bytes.as_slice().try_into() {
190 Ok(a) => a,
191 Err(_) => return false,
192 };
193 let vk = match VerifyingKey::from_bytes(&pub_array) {
194 Ok(k) => k,
195 Err(_) => return false,
196 };
197
198 if !trust.contains(&vk, TrustRootKind::HubCheckpoint) {
202 return false;
203 }
204
205 let canonical = Self::canonical_for_signing(
206 self.merkle_version,
207 self.index,
208 &self.root,
209 self.tree_size,
210 self.height,
211 &self.signer,
212 &self.signed_at,
213 );
214
215 let sig_bytes = match URL_SAFE_NO_PAD.decode(&self.signature) {
216 Ok(b) => b,
217 Err(_) => return false,
218 };
219 let sig_array: [u8; 64] = match sig_bytes.as_slice().try_into() {
220 Ok(a) => a,
221 Err(_) => return false,
222 };
223 let sig = Signature::from_bytes(&sig_array);
224
225 vk.verify(canonical.as_bytes(), &sig).is_ok()
226 }
227}
228
229#[cfg(test)]
234mod trust_pin_tests {
235 use super::*;
236 use crate::attestation::{Ed25519Signer, Signer};
237 use crate::merkle::MerkleTree;
238 use crate::trust::{encode_ed25519_pubkey, TrustRoot, TrustRootKind, TrustRootStore};
239
240 fn signer_and_tree() -> (Ed25519Signer, MerkleTree) {
241 let mut tree = MerkleTree::new();
242 tree.append("art_alpha");
243 tree.append("art_beta");
244 let signer = Ed25519Signer::generate("key_test").unwrap();
245 (signer, tree)
246 }
247
248 fn trust_with(signer: &Ed25519Signer) -> TrustRootStore {
249 use ed25519_dalek::VerifyingKey;
250 let pk_bytes: [u8; 32] = signer.public_key_bytes().try_into().unwrap();
251 let vk = VerifyingKey::from_bytes(&pk_bytes).unwrap();
252 TrustRootStore::with_roots(vec![TrustRoot {
253 key_id: signer.key_id().to_string(),
254 public_key: encode_ed25519_pubkey(&vk),
255 kind: TrustRootKind::HubCheckpoint,
256 label: "trusted hub".into(),
257 added_at: "2026-05-15T00:00:00Z".into(),
258 }])
259 }
260
261 #[test]
265 fn verify_rejects_unknown_pubkey() {
266 let (signer, tree) = signer_and_tree();
267 let cp = Checkpoint::create(1, &tree, &signer).unwrap();
268
269 let other = Ed25519Signer::generate("other").unwrap();
271 let trust = trust_with(&other);
272
273 assert!(!cp.verify(&trust),
274 "unknown issuer must be rejected even with valid signature");
275 }
276
277 #[test]
280 fn verify_accepts_trusted_pubkey() {
281 let (signer, tree) = signer_and_tree();
282 let cp = Checkpoint::create(1, &tree, &signer).unwrap();
283 let trust = trust_with(&signer);
284 assert!(cp.verify(&trust), "trusted issuer + good signature must verify");
285 }
286
287 #[test]
291 fn verify_rejects_with_no_trust_configured() {
292 let (signer, tree) = signer_and_tree();
293 let cp = Checkpoint::create(1, &tree, &signer).unwrap();
294 let trust = TrustRootStore::empty();
295 assert!(!cp.verify(&trust),
296 "empty trust store must reject all checkpoints");
297 }
298
299 #[test]
303 fn verify_rejects_pubkey_pinned_for_wrong_kind() {
304 let (signer, tree) = signer_and_tree();
305 let cp = Checkpoint::create(1, &tree, &signer).unwrap();
306
307 use ed25519_dalek::VerifyingKey;
308 let pk_bytes: [u8; 32] = signer.public_key_bytes().try_into().unwrap();
309 let vk = VerifyingKey::from_bytes(&pk_bytes).unwrap();
310 let mismatched = TrustRootStore::with_roots(vec![TrustRoot {
311 key_id: signer.key_id().to_string(),
312 public_key: encode_ed25519_pubkey(&vk),
313 kind: TrustRootKind::AgentCert, label: "trusted for agent certs only".into(),
315 added_at: "2026-05-15T00:00:00Z".into(),
316 }]);
317 assert!(!cp.verify(&mismatched),
318 "kind discrimination must keep AgentCert roots out of checkpoint trust");
319 }
320
321 #[test]
326 fn verify_rejects_attacker_self_signed_forgery() {
327 let (attacker_signer, tree) = signer_and_tree();
330 let forgery = Checkpoint::create(99, &tree, &attacker_signer).unwrap();
331
332 let honest = Ed25519Signer::generate("honest_hub").unwrap();
334 let trust = trust_with(&honest);
335
336 assert!(!forgery.verify(&trust),
337 "self-signed forgery must not verify against operator's trust set");
338 }
339}