treeship_core/merkle/checkpoint.rs
1use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
2use ed25519_dalek::{Signature, Verifier, VerifyingKey};
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5
6use crate::attestation::{Signer, SignerError};
7use crate::statements::unix_to_rfc3339;
8use crate::trust::{TrustRootKind, TrustRootStore};
9
10use super::tree::{MerkleTree, MERKLE_VERSION_V1};
11
12/// Canonical signing format versions. The merkle version (the bytes the
13/// tree is hashed under, see `MERKLE_VERSION_V1`/`MERKLE_VERSION_V2`) and
14/// the canonical signing version (the bytes the checkpoint's signature
15/// covers) are independent.
16///
17/// - `1` — legacy pre-v0.10.3 form, `"{index}|{root}|...|{signed_at}"`.
18/// No merkle_version, algorithm, or zk_proof in the canonical.
19/// - `2` — v0.10.3, `"v2|{merkle_version}|{index}|..."`. Binds
20/// merkle_version to close the v1/v2 hashing downgrade.
21/// - `3` — v0.10.4, also binds `algorithm`, `zk_proof_digest`, and the
22/// canonical_version itself. Closes wire-mutation on those fields.
23pub const CANONICAL_VERSION_V1: u8 = 1;
24pub const CANONICAL_VERSION_V2: u8 = 2;
25pub const CANONICAL_VERSION_V3: u8 = 3;
26
27/// Default canonical_version for `#[serde(default)]` so v0.10.3-era
28/// checkpoints (signed under v2) continue to verify when loaded by
29/// v0.10.4+ code. Pre-v0.10.3 checkpoints have `merkle_version == 1`
30/// which overrides this and forces v1 dispatch.
31pub fn default_canonical_version_v2() -> u8 {
32 CANONICAL_VERSION_V2
33}
34
35/// A signed snapshot of the Merkle tree at a point in time.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct Checkpoint {
38 pub index: u64,
39 /// Root hash in `sha256:<hex>` format.
40 pub root: String,
41 pub tree_size: usize,
42 pub height: usize,
43 /// RFC 3339 timestamp.
44 pub signed_at: String,
45 /// Key ID of the signer.
46 pub signer: String,
47 /// Base64url-encoded public key bytes.
48 pub public_key: String,
49 /// Base64url-encoded Ed25519 signature of the canonical form.
50 pub signature: String,
51 /// Merkle algorithm used. Missing = v1 (sha256-duplicate-last).
52 ///
53 /// Currently this string is fully derived from `merkle_version`
54 /// (`v1 → "sha256-duplicate-last"`, `v2 → "sha256-rfc9162"`) so it
55 /// is informationally redundant with `merkle_version`. It is still
56 /// bound into the v3 canonical to lock the on-wire value: even
57 /// redundant fields become tampering surface once they're displayed
58 /// or fed into downstream tooling. Removable in a future canonical
59 /// (v4) once a deprecation window has passed.
60 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub algorithm: Option<String>,
62 /// Merkle format version byte (RFC 9162 domain separation). Absent
63 /// on v0.10.2-and-earlier checkpoints — `#[serde(default)]` fills it
64 /// with `1` so legacy checkpoints continue to verify under v1
65 /// hashing. New checkpoints emit `2`.
66 #[serde(default = "super::tree::default_merkle_version_v1")]
67 pub merkle_version: u8,
68 /// Optional ZK chain proof result (added when proof is ready).
69 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub zk_proof: Option<ChainProofSummary>,
71 /// Canonical signing format version (independent of `merkle_version`).
72 /// Pre-v0.10.4 checkpoints don't carry this; `#[serde(default)]` fills
73 /// it with `2` so v0.10.3-era checkpoints continue to verify under the
74 /// v2 canonical. v0.10.4+ checkpoints emit `3`. Pre-v0.10.3 checkpoints
75 /// have `merkle_version == 1`, which overrides this and dispatches the
76 /// legacy v1 canonical regardless. This field is itself bound into the
77 /// v3 canonical to prevent a downgrade-by-relabel attack.
78 #[serde(default = "default_canonical_version_v2")]
79 pub canonical_version: u8,
80}
81
82/// Summary of a RISC Zero chain proof, embedded in a Merkle checkpoint.
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct ChainProofSummary {
85 pub image_id: String,
86 pub all_signatures_valid: bool,
87 pub chain_intact: bool,
88 pub approval_nonces_matched: bool,
89 pub artifact_count: u64,
90 pub public_key_digest: String,
91 pub proved_at: String,
92}
93
94/// Errors from checkpoint creation.
95#[derive(Debug)]
96pub enum CheckpointError {
97 EmptyTree,
98 Signing(SignerError),
99}
100
101impl std::fmt::Display for CheckpointError {
102 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103 match self {
104 Self::EmptyTree => write!(f, "cannot checkpoint an empty tree"),
105 Self::Signing(e) => write!(f, "checkpoint signing failed: {}", e),
106 }
107 }
108}
109
110impl std::error::Error for CheckpointError {}
111impl From<SignerError> for CheckpointError {
112 fn from(e: SignerError) -> Self {
113 Self::Signing(e)
114 }
115}
116
117impl Checkpoint {
118 /// Build the canonical string for signing/verification.
119 ///
120 /// Three formats coexist by design. Dispatch is governed entirely by
121 /// the (trusted) `canonical_version` argument — never inferred from
122 /// wire-controllable field presence — *with one exception*: a
123 /// `merkle_version == 1` checkpoint always uses the v1 legacy form
124 /// regardless of `canonical_version`, because pre-v0.10.3 checkpoints
125 /// never carried `canonical_version` and were signed under the bare
126 /// pipe-delimited bytes.
127 ///
128 /// * **v1 (`merkle_version == 1`):** the original pre-v0.10.3 form,
129 /// `"{index}|{root}|{tree_size}|{height}|{signer}|{signed_at}"`. Old
130 /// checkpoints in the wild were signed under this exact string and
131 /// must continue to verify byte-identically.
132 ///
133 /// * **v2 (`canonical_version == 2`, `merkle_version >= 2`):** v0.10.3
134 /// form,
135 /// `"v2|{merkle_version}|{index}|{root}|{tree_size}|{height}|{signer}|{signed_at}"`.
136 /// Binds `merkle_version` to close the v1/v2 hashing downgrade.
137 /// `algorithm` and `zk_proof` are NOT bound in v2; they were
138 /// wire-mutable in v0.10.3, which is the v0.10.4 audit finding this
139 /// v3 form closes.
140 ///
141 /// * **v3 (`canonical_version == 3`, `merkle_version >= 2`):** v0.10.4
142 /// form,
143 /// `"v3|{canonical_version}|{merkle_version}|{algorithm_or_empty}|{zk_proof_digest_or_empty}|{index}|{root}|{tree_size}|{height}|{signer}|{signed_at}"`.
144 /// - `canonical_version` is itself bound to prevent downgrade-by-
145 /// relabel: an attacker flipping `canonical_version: 3 → 2` on
146 /// the wire breaks the signature because the bytes recanonicalize
147 /// differently under v2 dispatch.
148 /// - `algorithm_or_empty` is the verbatim algorithm string, or empty
149 /// when the field is `None`. Currently redundant with
150 /// `merkle_version` but bound to lock the on-wire value.
151 /// - `zk_proof_digest_or_empty` is the hex-encoded SHA-256 of the
152 /// sorted-key JSON serialization of `zk_proof`, or empty for `None`.
153 /// Hash-of-canonical-JSON rather than direct embedding because
154 /// `ChainProofSummary` is a multi-field struct that doesn't
155 /// compose with pipe-delimiting.
156 ///
157 /// **Breaking change note:** any third-party verifier that reproduces
158 /// the canonical string outside this Rust core (hand-rolled JS/Go/Python
159 /// checkers) must mirror this dispatch. The `verify-js` package
160 /// consumes WASM and inherits the change automatically.
161 #[allow(clippy::too_many_arguments)]
162 pub(crate) fn canonical_for_signing(
163 canonical_version: u8,
164 merkle_version: u8,
165 algorithm: Option<&str>,
166 zk_proof: Option<&ChainProofSummary>,
167 index: u64,
168 root: &str,
169 tree_size: usize,
170 height: usize,
171 signer: &str,
172 signed_at: &str,
173 ) -> String {
174 // Legacy v1 path is forced by merkle_version, not canonical_version.
175 // Pre-v0.10.3 checkpoints never carried canonical_version and were
176 // signed under the bare pipe-delimited bytes.
177 if merkle_version == MERKLE_VERSION_V1 {
178 return format!(
179 "{}|{}|{}|{}|{}|{}",
180 index, root, tree_size, height, signer, signed_at,
181 );
182 }
183
184 match canonical_version {
185 CANONICAL_VERSION_V2 => format!(
186 "v2|{}|{}|{}|{}|{}|{}|{}",
187 merkle_version, index, root, tree_size, height, signer, signed_at,
188 ),
189 // v3 (and any unrecognized newer version we treat as v3 here;
190 // the dispatcher in `verify` rejects unknown canonical_versions
191 // up front, so this branch is only reached for known v3).
192 _ => {
193 let algo_field = algorithm.unwrap_or("");
194 let zk_digest = zk_proof
195 .map(zk_proof_digest_hex)
196 .unwrap_or_default();
197 format!(
198 "v3|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
199 canonical_version,
200 merkle_version,
201 algo_field,
202 zk_digest,
203 index,
204 root,
205 tree_size,
206 height,
207 signer,
208 signed_at,
209 )
210 }
211 }
212 }
213
214 /// Create a signed checkpoint from the current tree state.
215 ///
216 /// New checkpoints are signed under canonical v3, which binds
217 /// `merkle_version`, `algorithm`, and `zk_proof` in addition to the
218 /// v2-bound fields. `zk_proof` is `None` at create time; if the
219 /// daemon later attaches a ZK proof summary it must re-sign (which
220 /// today it doesn't — see `update_checkpoint_with_proof`; that path
221 /// is now considered tamper-surface and will be fixed in a follow-up).
222 pub fn create(
223 index: u64,
224 tree: &MerkleTree,
225 signer: &dyn Signer,
226 ) -> Result<Self, CheckpointError> {
227 let root_bytes = tree.root().ok_or(CheckpointError::EmptyTree)?;
228 let root = format!("sha256:{}", hex::encode(root_bytes));
229
230 let secs = std::time::SystemTime::now()
231 .duration_since(std::time::UNIX_EPOCH)
232 .unwrap_or_default()
233 .as_secs();
234 let signed_at = unix_to_rfc3339(secs);
235
236 // New v0.10.4 checkpoints emit canonical v3 unless the tree is
237 // v1 (in which case canonical_for_signing forces the legacy form
238 // and the canonical_version field is informational only).
239 let canonical_version = if tree.version() == MERKLE_VERSION_V1 {
240 CANONICAL_VERSION_V1
241 } else {
242 CANONICAL_VERSION_V3
243 };
244 let algorithm = Some(super::tree::MERKLE_ALGORITHM_V2.to_string());
245 let zk_proof: Option<ChainProofSummary> = None;
246
247 let canonical = Self::canonical_for_signing(
248 canonical_version,
249 tree.version(),
250 algorithm.as_deref(),
251 zk_proof.as_ref(),
252 index,
253 &root,
254 tree.len(),
255 tree.height(),
256 signer.key_id(),
257 &signed_at,
258 );
259 let sig_bytes = signer.sign(canonical.as_bytes())?;
260 let signature = URL_SAFE_NO_PAD.encode(&sig_bytes);
261 let public_key = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
262
263 Ok(Self {
264 index,
265 root,
266 tree_size: tree.len(),
267 height: tree.height(),
268 signed_at,
269 signer: signer.key_id().to_string(),
270 public_key,
271 signature,
272 algorithm,
273 merkle_version: tree.version(),
274 zk_proof,
275 canonical_version,
276 })
277 }
278
279 /// Verify the checkpoint signature AND require the embedded public key
280 /// to be present in `trust` under kind `HubCheckpoint`. Returns `false`
281 /// on any failure (bad encoding, wrong key size, invalid signature,
282 /// untrusted issuer, no trust configured). Never panics.
283 ///
284 /// Trust pinning is mandatory. A self-signed checkpoint (an attacker
285 /// minting their own keypair, embedding the pubkey, and signing the
286 /// canonical bytes) used to satisfy this function -- it now does not,
287 /// because `trust.contains` rejects unknown issuers.
288 pub fn verify(&self, trust: &TrustRootStore) -> bool {
289 let pub_bytes = match URL_SAFE_NO_PAD.decode(&self.public_key) {
290 Ok(b) => b,
291 Err(_) => return false,
292 };
293 let pub_array: [u8; 32] = match pub_bytes.as_slice().try_into() {
294 Ok(a) => a,
295 Err(_) => return false,
296 };
297 let vk = match VerifyingKey::from_bytes(&pub_array) {
298 Ok(k) => k,
299 Err(_) => return false,
300 };
301
302 // Trust pin: the embedded pubkey must be a configured root.
303 // An empty store or no matching root rejects -- closes the
304 // self-signed loophole.
305 if !trust.contains(&vk, TrustRootKind::HubCheckpoint) {
306 return false;
307 }
308
309 // Reject unknown canonical_versions up front. Pre-v0.10.3
310 // checkpoints have merkle_version == 1 which forces the legacy
311 // v1 canonical regardless of this field; for newer checkpoints
312 // canonical_version must be 2 or 3. Anything else is either a
313 // misconfigured signer or a future format this verifier doesn't
314 // understand — fail closed in both cases.
315 if self.merkle_version != MERKLE_VERSION_V1
316 && self.canonical_version != CANONICAL_VERSION_V2
317 && self.canonical_version != CANONICAL_VERSION_V3
318 {
319 return false;
320 }
321
322 let canonical = Self::canonical_for_signing(
323 self.canonical_version,
324 self.merkle_version,
325 self.algorithm.as_deref(),
326 self.zk_proof.as_ref(),
327 self.index,
328 &self.root,
329 self.tree_size,
330 self.height,
331 &self.signer,
332 &self.signed_at,
333 );
334
335 let sig_bytes = match URL_SAFE_NO_PAD.decode(&self.signature) {
336 Ok(b) => b,
337 Err(_) => return false,
338 };
339 let sig_array: [u8; 64] = match sig_bytes.as_slice().try_into() {
340 Ok(a) => a,
341 Err(_) => return false,
342 };
343 let sig = Signature::from_bytes(&sig_array);
344
345 vk.verify(canonical.as_bytes(), &sig).is_ok()
346 }
347}
348
349/// SHA-256 digest of the canonical (sorted-key) JSON serialization of a
350/// `ChainProofSummary`, hex-encoded. Used to fold the multi-field zk_proof
351/// struct into the pipe-delimited v3 canonical signing string.
352///
353/// We use `serde_json::to_value` to materialize the value, then
354/// re-serialize via `BTreeMap` to force sorted keys. `serde_json` writes
355/// struct fields in declaration order by default, which is stable in
356/// practice but is a Rust-source-level invariant rather than a wire-format
357/// one. Sorted-key JSON is the format-level invariant (akin to RFC 8785's
358/// `keys_in_alphabetical_order` rule) and is what any third-party
359/// verifier must reproduce.
360///
361/// Caller's contract: pass `Some(&summary)` for present, omit entirely
362/// (the canonical writes an empty field) for `None`. We do not call this
363/// for `None` so the sentinel can't collide with a real digest.
364fn zk_proof_digest_hex(summary: &ChainProofSummary) -> String {
365 let value = serde_json::to_value(summary)
366 .expect("ChainProofSummary serializes to JSON value");
367 // Re-serialize through BTreeMap to enforce sorted keys at every level.
368 // For ChainProofSummary specifically this is a flat object of scalars,
369 // but doing it through the generic walker keeps the function honest
370 // if the struct grows nested fields later.
371 let canonical = canonical_json_string(&value);
372 hex::encode(Sha256::digest(canonical.as_bytes()))
373}
374
375/// Sorted-key canonical JSON. Compact (no whitespace). For object keys
376/// the ordering is bytewise on the UTF-8 representation, matching what
377/// `BTreeMap<String, _>` produces. Arrays preserve order. Numbers,
378/// booleans, strings, and null serialize as serde_json's default
379/// (which is JSON-spec compliant; we do not need RFC 8785's full
380/// numeric normalization for `ChainProofSummary` because every numeric
381/// field there is an integer).
382fn canonical_json_string(value: &serde_json::Value) -> String {
383 use std::collections::BTreeMap;
384 match value {
385 serde_json::Value::Object(map) => {
386 let sorted: BTreeMap<&String, String> = map
387 .iter()
388 .map(|(k, v)| (k, canonical_json_string(v)))
389 .collect();
390 let mut out = String::from("{");
391 let mut first = true;
392 for (k, v) in sorted {
393 if !first {
394 out.push(',');
395 }
396 first = false;
397 // Re-serialize the key as a JSON string to handle escapes.
398 let key_json = serde_json::to_string(k)
399 .expect("string serializes to JSON");
400 out.push_str(&key_json);
401 out.push(':');
402 out.push_str(&v);
403 }
404 out.push('}');
405 out
406 }
407 serde_json::Value::Array(items) => {
408 let mut out = String::from("[");
409 let mut first = true;
410 for item in items {
411 if !first {
412 out.push(',');
413 }
414 first = false;
415 out.push_str(&canonical_json_string(item));
416 }
417 out.push(']');
418 out
419 }
420 other => serde_json::to_string(other)
421 .expect("scalar serializes to JSON"),
422 }
423}
424
425// ---------------------------------------------------------------------------
426// Trust-pin tests
427// ---------------------------------------------------------------------------
428
429#[cfg(test)]
430mod trust_pin_tests {
431 use super::*;
432 use crate::attestation::{Ed25519Signer, Signer};
433 use crate::merkle::MerkleTree;
434 use crate::trust::{encode_ed25519_pubkey, TrustRoot, TrustRootKind, TrustRootStore};
435
436 fn signer_and_tree() -> (Ed25519Signer, MerkleTree) {
437 let mut tree = MerkleTree::new();
438 tree.append("art_alpha");
439 tree.append("art_beta");
440 let signer = Ed25519Signer::generate("key_test").unwrap();
441 (signer, tree)
442 }
443
444 fn trust_with(signer: &Ed25519Signer) -> TrustRootStore {
445 use ed25519_dalek::VerifyingKey;
446 let pk_bytes: [u8; 32] = signer.public_key_bytes().try_into().unwrap();
447 let vk = VerifyingKey::from_bytes(&pk_bytes).unwrap();
448 TrustRootStore::with_roots(vec![TrustRoot {
449 key_id: signer.key_id().to_string(),
450 public_key: encode_ed25519_pubkey(&vk),
451 kind: TrustRootKind::HubCheckpoint,
452 label: "trusted hub".into(),
453 added_at: "2026-05-15T00:00:00Z".into(),
454 }])
455 }
456
457 /// The headline case from the audit: a checkpoint signed by a key
458 /// the operator never trusted MUST NOT verify, even though the
459 /// signature math is internally consistent.
460 #[test]
461 fn verify_rejects_unknown_pubkey() {
462 let (signer, tree) = signer_and_tree();
463 let cp = Checkpoint::create(1, &tree, &signer).unwrap();
464
465 // Different signer's key is the only one in the store.
466 let other = Ed25519Signer::generate("other").unwrap();
467 let trust = trust_with(&other);
468
469 assert!(!cp.verify(&trust),
470 "unknown issuer must be rejected even with valid signature");
471 }
472
473 /// Happy path: the issuer is pinned, the signature math is good,
474 /// verify returns true.
475 #[test]
476 fn verify_accepts_trusted_pubkey() {
477 let (signer, tree) = signer_and_tree();
478 let cp = Checkpoint::create(1, &tree, &signer).unwrap();
479 let trust = trust_with(&signer);
480 assert!(cp.verify(&trust), "trusted issuer + good signature must verify");
481 }
482
483 /// No trust configured at all (empty store) is the operator's
484 /// fresh-install state. Verification must fail closed: a verifier
485 /// without a trust set cannot vouch for anyone.
486 #[test]
487 fn verify_rejects_with_no_trust_configured() {
488 let (signer, tree) = signer_and_tree();
489 let cp = Checkpoint::create(1, &tree, &signer).unwrap();
490 let trust = TrustRootStore::empty();
491 assert!(!cp.verify(&trust),
492 "empty trust store must reject all checkpoints");
493 }
494
495 /// Trust pinning is kind-scoped: a key trusted for AgentCert is
496 /// NOT trusted for a Merkle checkpoint. This is the firewall
497 /// between certificate issuance and journal anchoring.
498 #[test]
499 fn verify_rejects_pubkey_pinned_for_wrong_kind() {
500 let (signer, tree) = signer_and_tree();
501 let cp = Checkpoint::create(1, &tree, &signer).unwrap();
502
503 use ed25519_dalek::VerifyingKey;
504 let pk_bytes: [u8; 32] = signer.public_key_bytes().try_into().unwrap();
505 let vk = VerifyingKey::from_bytes(&pk_bytes).unwrap();
506 let mismatched = TrustRootStore::with_roots(vec![TrustRoot {
507 key_id: signer.key_id().to_string(),
508 public_key: encode_ed25519_pubkey(&vk),
509 kind: TrustRootKind::AgentCert, // wrong kind!
510 label: "trusted for agent certs only".into(),
511 added_at: "2026-05-15T00:00:00Z".into(),
512 }]);
513 assert!(!cp.verify(&mismatched),
514 "kind discrimination must keep AgentCert roots out of checkpoint trust");
515 }
516
517 /// Forge attempt -- attacker re-signs with a non-trusted key.
518 /// The signature is internally valid (sig was made over canonical
519 /// bytes by the embedded pubkey) but the pubkey is unknown to the
520 /// operator. Pre-pin this passed; post-pin it must not.
521 #[test]
522 fn verify_rejects_attacker_self_signed_forgery() {
523 // Attacker mints their own keypair, builds a checkpoint over
524 // their own canonical bytes, embeds their own pubkey, signs.
525 let (attacker_signer, tree) = signer_and_tree();
526 let forgery = Checkpoint::create(99, &tree, &attacker_signer).unwrap();
527
528 // Honest operator has trusted a DIFFERENT issuer.
529 let honest = Ed25519Signer::generate("honest_hub").unwrap();
530 let trust = trust_with(&honest);
531
532 assert!(!forgery.verify(&trust),
533 "self-signed forgery must not verify against operator's trust set");
534 }
535}
536
537// ---------------------------------------------------------------------------
538// v0.10.4 canonical v3 tests
539//
540// These pin the fix for the second canonical break: v0.10.3's v2 form bound
541// merkle_version but left `algorithm` and `zk_proof` wire-mutable. v3 binds
542// both, plus the canonical_version itself (to prevent downgrade-by-relabel).
543// ---------------------------------------------------------------------------
544
545#[cfg(test)]
546mod canonical_v3_tests {
547 use super::*;
548 use crate::attestation::{Ed25519Signer, Signer};
549 use crate::merkle::tree::{MerkleTree, MERKLE_ALGORITHM_V2, MERKLE_VERSION_V1, MERKLE_VERSION_V2};
550 use crate::trust::{encode_ed25519_pubkey, TrustRoot, TrustRootKind, TrustRootStore};
551
552 fn signer_and_tree() -> (Ed25519Signer, MerkleTree) {
553 let mut tree = MerkleTree::new();
554 tree.append("art_alpha");
555 tree.append("art_beta");
556 let signer = Ed25519Signer::generate("key_test").unwrap();
557 (signer, tree)
558 }
559
560 fn trust_with(signer: &Ed25519Signer) -> TrustRootStore {
561 use ed25519_dalek::VerifyingKey;
562 let pk_bytes: [u8; 32] = signer.public_key_bytes().try_into().unwrap();
563 let vk = VerifyingKey::from_bytes(&pk_bytes).unwrap();
564 TrustRootStore::with_roots(vec![TrustRoot {
565 key_id: signer.key_id().to_string(),
566 public_key: encode_ed25519_pubkey(&vk),
567 kind: TrustRootKind::HubCheckpoint,
568 label: "trusted hub".into(),
569 added_at: "2026-05-15T00:00:00Z".into(),
570 }])
571 }
572
573 fn sample_zk_proof() -> ChainProofSummary {
574 ChainProofSummary {
575 image_id: "sha256:beef".into(),
576 all_signatures_valid: true,
577 chain_intact: true,
578 approval_nonces_matched: true,
579 artifact_count: 7,
580 public_key_digest: "sha256:cafe".into(),
581 proved_at: "2026-05-17T01:23:45Z".into(),
582 }
583 }
584
585 /// Sanity: a freshly-created checkpoint is v3.
586 #[test]
587 fn fresh_checkpoint_is_v3() {
588 let (signer, tree) = signer_and_tree();
589 let cp = Checkpoint::create(1, &tree, &signer).unwrap();
590 assert_eq!(cp.canonical_version, CANONICAL_VERSION_V3);
591 assert_eq!(cp.merkle_version, MERKLE_VERSION_V2);
592 assert!(cp.algorithm.is_some());
593 }
594
595 /// The headline v0.10.4 audit fix: mutating `algorithm` on the wire
596 /// of a v3-signed checkpoint must invalidate the signature.
597 #[test]
598 fn algorithm_tamper_detected() {
599 let (signer, tree) = signer_and_tree();
600 let trust = trust_with(&signer);
601 let mut cp = Checkpoint::create(1, &tree, &signer).unwrap();
602 assert!(cp.verify(&trust), "baseline must verify");
603
604 cp.algorithm = Some("sha256-attacker".into());
605 assert!(
606 !cp.verify(&trust),
607 "algorithm field mutation on the wire must break the v3 signature"
608 );
609
610 // Also: clearing the field to None must break it.
611 let mut cp2 = Checkpoint::create(1, &tree, &signer).unwrap();
612 cp2.algorithm = None;
613 assert!(
614 !cp2.verify(&trust),
615 "removing algorithm on the wire must break the v3 signature"
616 );
617 }
618
619 /// Same fix for `zk_proof`: an attacker attaching, swapping, or
620 /// removing a ChainProofSummary on the wire must invalidate the
621 /// signature.
622 #[test]
623 fn zk_proof_tamper_detected() {
624 let (signer, tree) = signer_and_tree();
625 let trust = trust_with(&signer);
626
627 // Case A: attacker attaches a fabricated proof to a checkpoint
628 // that was signed with zk_proof: None.
629 let mut cp_attach = Checkpoint::create(1, &tree, &signer).unwrap();
630 assert!(cp_attach.zk_proof.is_none(), "fresh checkpoint must have no proof");
631 cp_attach.zk_proof = Some(sample_zk_proof());
632 assert!(
633 !cp_attach.verify(&trust),
634 "attaching a zk_proof on the wire must break the v3 signature"
635 );
636
637 // Case B: sign a checkpoint, then mutate a field inside the
638 // proof on the wire. Needs a small re-sign helper because
639 // Checkpoint::create only sets zk_proof to None.
640 let (signer_b, tree_b) = signer_and_tree();
641 let trust_b = trust_with(&signer_b);
642 let mut cp_swap = checkpoint_signed_with_proof(
643 &signer_b, &tree_b, 1, Some(sample_zk_proof()),
644 );
645 assert!(cp_swap.verify(&trust_b), "freshly signed v3+proof must verify");
646
647 // Mutate one field on the embedded proof.
648 let mut tampered = sample_zk_proof();
649 tampered.chain_intact = false;
650 cp_swap.zk_proof = Some(tampered);
651 assert!(
652 !cp_swap.verify(&trust_b),
653 "mutating a zk_proof field on the wire must break the v3 signature"
654 );
655
656 // Case C: strip the proof entirely.
657 let mut cp_strip = checkpoint_signed_with_proof(
658 &signer_b, &tree_b, 1, Some(sample_zk_proof()),
659 );
660 cp_strip.zk_proof = None;
661 assert!(
662 !cp_strip.verify(&trust_b),
663 "stripping zk_proof on the wire must break the v3 signature"
664 );
665 }
666
667 /// v0.10.3-era v2 checkpoints (no canonical_version field on disk;
668 /// algorithm present, zk_proof absent) must continue to verify under
669 /// v0.10.4 code. This is the legacy-compat guarantee.
670 #[test]
671 fn v2_legacy_checkpoint_still_verifies() {
672 let (signer, tree) = signer_and_tree();
673 let trust = trust_with(&signer);
674
675 let cp_v2 = sign_legacy_v2(&signer, &tree, 1);
676 assert_eq!(cp_v2.canonical_version, CANONICAL_VERSION_V2);
677 assert_eq!(cp_v2.merkle_version, MERKLE_VERSION_V2);
678 assert!(
679 cp_v2.verify(&trust),
680 "v0.10.3-era v2-canonical checkpoint must still verify"
681 );
682
683 // And the wire form (no canonical_version field at all) round-trips
684 // through #[serde(default)] back to canonical_version: 2.
685 let mut json = serde_json::to_value(&cp_v2).unwrap();
686 json.as_object_mut().unwrap().remove("canonical_version");
687 let reparsed: Checkpoint = serde_json::from_value(json).unwrap();
688 assert_eq!(reparsed.canonical_version, CANONICAL_VERSION_V2);
689 assert!(
690 reparsed.verify(&trust),
691 "v2 checkpoint deserialized without canonical_version field must verify"
692 );
693 }
694
695 /// Pre-v0.10.3 v1 checkpoints (legacy hashing, no canonical tag,
696 /// no merkle_version on the wire) must continue to verify under
697 /// v0.10.4 code.
698 #[test]
699 fn v1_legacy_checkpoint_still_verifies() {
700 let signer = Ed25519Signer::generate("legacy_key").unwrap();
701 let trust = trust_with(&signer);
702
703 // Build a v1 tree so the canonical dispatch forces the legacy
704 // form. canonical_version field is informational only for v1.
705 let cp_v1 = sign_legacy_v1(&signer, 99, "sha256:legacy_root", 4, 2);
706 assert_eq!(cp_v1.merkle_version, MERKLE_VERSION_V1);
707 assert!(
708 cp_v1.verify(&trust),
709 "pre-v0.10.3 v1-canonical checkpoint must still verify"
710 );
711
712 // And the wire form without the v1-vintage missing-fields still
713 // round-trips and verifies.
714 let mut json = serde_json::to_value(&cp_v1).unwrap();
715 json.as_object_mut().unwrap().remove("canonical_version");
716 json.as_object_mut().unwrap().remove("merkle_version");
717 json.as_object_mut().unwrap().remove("algorithm");
718 let reparsed: Checkpoint = serde_json::from_value(json).unwrap();
719 assert_eq!(reparsed.merkle_version, MERKLE_VERSION_V1);
720 assert!(
721 reparsed.verify(&trust),
722 "pre-v0.10.3 v1 checkpoint stripped of new fields must verify"
723 );
724 }
725
726 /// Cross-version downgrade: an attacker takes a legitimately
727 /// v3-signed checkpoint, relabels it as canonical_version: 2 on
728 /// the wire (and strips the new bindings to make the v2 canonical
729 /// reproducible), and tries to verify. Must fail — the signature
730 /// covers v3-canonical bytes, not v2-canonical bytes.
731 #[test]
732 fn cross_version_downgrade_v3_to_v2_rejected() {
733 let (signer, tree) = signer_and_tree();
734 let trust = trust_with(&signer);
735 let mut cp = Checkpoint::create(1, &tree, &signer).unwrap();
736 assert_eq!(cp.canonical_version, CANONICAL_VERSION_V3);
737 assert!(cp.verify(&trust), "baseline v3 must verify");
738
739 // Attacker downgrade: flip the canonical_version tag.
740 cp.canonical_version = CANONICAL_VERSION_V2;
741 assert!(
742 !cp.verify(&trust),
743 "v3->v2 canonical_version downgrade must fail (signature covers v3 bytes)"
744 );
745
746 // And the attacker can't recover by also stripping algorithm
747 // (since v2 doesn't bind it, they might hope the v2 canonical
748 // matches the original v3 signature anyway — it must not).
749 let (signer2, tree2) = signer_and_tree();
750 let trust2 = trust_with(&signer2);
751 let mut cp2 = Checkpoint::create(1, &tree2, &signer2).unwrap();
752 cp2.canonical_version = CANONICAL_VERSION_V2;
753 cp2.algorithm = None;
754 assert!(
755 !cp2.verify(&trust2),
756 "v3->v2 downgrade + strip algorithm must still fail"
757 );
758 }
759
760 /// Unknown canonical_version (a future format this verifier doesn't
761 /// understand) must fail closed.
762 #[test]
763 fn unknown_canonical_version_rejected() {
764 let (signer, tree) = signer_and_tree();
765 let trust = trust_with(&signer);
766 let mut cp = Checkpoint::create(1, &tree, &signer).unwrap();
767 cp.canonical_version = 99;
768 assert!(
769 !cp.verify(&trust),
770 "unknown canonical_version must fail closed (no silent fallback)"
771 );
772 }
773
774 // ── test helpers ─────────────────────────────────────────────────
775
776 /// Sign a v3 checkpoint with a chosen zk_proof. Mirrors
777 /// `Checkpoint::create` but lets the test supply zk_proof so it
778 /// can be tampered with after the fact.
779 fn checkpoint_signed_with_proof(
780 signer: &Ed25519Signer,
781 tree: &MerkleTree,
782 index: u64,
783 zk_proof: Option<ChainProofSummary>,
784 ) -> Checkpoint {
785 let root_bytes = tree.root().expect("non-empty tree");
786 let root = format!("sha256:{}", hex::encode(root_bytes));
787 let signed_at = "2026-05-17T00:00:00Z".to_string();
788 let algorithm = Some(MERKLE_ALGORITHM_V2.to_string());
789
790 let canonical = Checkpoint::canonical_for_signing(
791 CANONICAL_VERSION_V3,
792 tree.version(),
793 algorithm.as_deref(),
794 zk_proof.as_ref(),
795 index,
796 &root,
797 tree.len(),
798 tree.height(),
799 signer.key_id(),
800 &signed_at,
801 );
802 let sig_bytes = signer.sign(canonical.as_bytes()).unwrap();
803 Checkpoint {
804 index,
805 root,
806 tree_size: tree.len(),
807 height: tree.height(),
808 signed_at,
809 signer: signer.key_id().to_string(),
810 public_key: URL_SAFE_NO_PAD.encode(signer.public_key_bytes()),
811 signature: URL_SAFE_NO_PAD.encode(&sig_bytes),
812 algorithm,
813 merkle_version: tree.version(),
814 zk_proof,
815 canonical_version: CANONICAL_VERSION_V3,
816 }
817 }
818
819 /// Sign a checkpoint under the v0.10.3-era v2 canonical (no
820 /// algorithm/zk_proof binding, no canonical_version on the wire).
821 /// Used to verify legacy compat.
822 fn sign_legacy_v2(
823 signer: &Ed25519Signer,
824 tree: &MerkleTree,
825 index: u64,
826 ) -> Checkpoint {
827 let root_bytes = tree.root().expect("non-empty tree");
828 let root = format!("sha256:{}", hex::encode(root_bytes));
829 let signed_at = "2026-05-17T00:00:00Z".to_string();
830
831 // Reproduce the v0.10.3 v2 canonical byte-for-byte. Note: in v2
832 // the canonical function takes neither algorithm nor zk_proof.
833 let canonical = Checkpoint::canonical_for_signing(
834 CANONICAL_VERSION_V2,
835 tree.version(),
836 None, // ignored under v2 dispatch
837 None, // ignored under v2 dispatch
838 index,
839 &root,
840 tree.len(),
841 tree.height(),
842 signer.key_id(),
843 &signed_at,
844 );
845 // Sanity: v2 canonical must NOT include algorithm even if
846 // we passed Some() here — the v2 branch ignores it.
847 assert!(canonical.starts_with("v2|"));
848
849 let sig_bytes = signer.sign(canonical.as_bytes()).unwrap();
850 Checkpoint {
851 index,
852 root,
853 tree_size: tree.len(),
854 height: tree.height(),
855 signed_at,
856 signer: signer.key_id().to_string(),
857 public_key: URL_SAFE_NO_PAD.encode(signer.public_key_bytes()),
858 signature: URL_SAFE_NO_PAD.encode(&sig_bytes),
859 // v0.10.3-era checkpoints had algorithm present even though
860 // it wasn't bound — that's the on-wire shape we need to
861 // reproduce.
862 algorithm: Some(MERKLE_ALGORITHM_V2.to_string()),
863 merkle_version: MERKLE_VERSION_V2,
864 zk_proof: None,
865 canonical_version: CANONICAL_VERSION_V2,
866 }
867 }
868
869 /// Sign a pre-v0.10.3 v1 checkpoint using the bare legacy canonical.
870 /// The tree must NOT be exercised through MerkleTree::new (which is
871 /// v2 by default); instead we construct the canonical directly.
872 fn sign_legacy_v1(
873 signer: &Ed25519Signer,
874 index: u64,
875 root: &str,
876 tree_size: usize,
877 height: usize,
878 ) -> Checkpoint {
879 let signed_at = "2026-04-01T00:00:00Z".to_string();
880 // Bare legacy canonical.
881 let canonical = Checkpoint::canonical_for_signing(
882 CANONICAL_VERSION_V1,
883 MERKLE_VERSION_V1,
884 None,
885 None,
886 index,
887 root,
888 tree_size,
889 height,
890 signer.key_id(),
891 &signed_at,
892 );
893 assert_eq!(
894 canonical,
895 format!(
896 "{}|{}|{}|{}|{}|{}",
897 index, root, tree_size, height, signer.key_id(), signed_at
898 ),
899 "v1 canonical must remain byte-identical to legacy"
900 );
901
902 let sig_bytes = signer.sign(canonical.as_bytes()).unwrap();
903 Checkpoint {
904 index,
905 root: root.to_string(),
906 tree_size,
907 height,
908 signed_at,
909 signer: signer.key_id().to_string(),
910 public_key: URL_SAFE_NO_PAD.encode(signer.public_key_bytes()),
911 signature: URL_SAFE_NO_PAD.encode(&sig_bytes),
912 algorithm: None,
913 merkle_version: MERKLE_VERSION_V1,
914 zk_proof: None,
915 // Pre-v0.10.4 checkpoints have no canonical_version on the
916 // wire; serde would default it to 2, but merkle_version == 1
917 // forces v1 dispatch anyway. We set it to 1 here for clarity.
918 canonical_version: CANONICAL_VERSION_V1,
919 }
920 }
921}