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 /// The exact canonical string this checkpoint's signature is computed
280 /// over, as bytes. Reproduces what `verify` reconstructs internally, so a
281 /// remote party (e.g. the hub, AUD-18) can be handed these bytes and run
282 /// `ed25519.Verify(public_key, canonical_signing_bytes, signature)` without
283 /// re-implementing the versioned canonical dispatch in another language.
284 /// The structured fields (root / tree_size / signer / signed_at) are all
285 /// present inside the returned string, pipe-delimited, so a verifier can
286 /// cross-check that the values it stores match the values that were signed.
287 pub fn canonical_signing_string(&self) -> String {
288 Self::canonical_for_signing(
289 self.canonical_version,
290 self.merkle_version,
291 self.algorithm.as_deref(),
292 self.zk_proof.as_ref(),
293 self.index,
294 &self.root,
295 self.tree_size,
296 self.height,
297 &self.signer,
298 &self.signed_at,
299 )
300 }
301
302 /// Verify the checkpoint signature AND require the embedded public key
303 /// to be present in `trust` under kind `HubCheckpoint`. Returns `false`
304 /// on any failure (bad encoding, wrong key size, invalid signature,
305 /// untrusted issuer, no trust configured). Never panics.
306 ///
307 /// Trust pinning is mandatory. A self-signed checkpoint (an attacker
308 /// minting their own keypair, embedding the pubkey, and signing the
309 /// canonical bytes) used to satisfy this function -- it now does not,
310 /// because `trust.contains` rejects unknown issuers.
311 pub fn verify(&self, trust: &TrustRootStore) -> bool {
312 let pub_bytes = match URL_SAFE_NO_PAD.decode(&self.public_key) {
313 Ok(b) => b,
314 Err(_) => return false,
315 };
316 let pub_array: [u8; 32] = match pub_bytes.as_slice().try_into() {
317 Ok(a) => a,
318 Err(_) => return false,
319 };
320 let vk = match VerifyingKey::from_bytes(&pub_array) {
321 Ok(k) => k,
322 Err(_) => return false,
323 };
324
325 // Trust pin: the embedded pubkey must be a configured root.
326 // An empty store or no matching root rejects -- closes the
327 // self-signed loophole.
328 if !trust.contains(&vk, TrustRootKind::HubCheckpoint) {
329 return false;
330 }
331
332 // Reject unknown canonical_versions up front. Pre-v0.10.3
333 // checkpoints have merkle_version == 1 which forces the legacy
334 // v1 canonical regardless of this field; for newer checkpoints
335 // canonical_version must be 2 or 3. Anything else is either a
336 // misconfigured signer or a future format this verifier doesn't
337 // understand — fail closed in both cases.
338 if self.merkle_version != MERKLE_VERSION_V1
339 && self.canonical_version != CANONICAL_VERSION_V2
340 && self.canonical_version != CANONICAL_VERSION_V3
341 {
342 return false;
343 }
344
345 let canonical = Self::canonical_for_signing(
346 self.canonical_version,
347 self.merkle_version,
348 self.algorithm.as_deref(),
349 self.zk_proof.as_ref(),
350 self.index,
351 &self.root,
352 self.tree_size,
353 self.height,
354 &self.signer,
355 &self.signed_at,
356 );
357
358 let sig_bytes = match URL_SAFE_NO_PAD.decode(&self.signature) {
359 Ok(b) => b,
360 Err(_) => return false,
361 };
362 let sig_array: [u8; 64] = match sig_bytes.as_slice().try_into() {
363 Ok(a) => a,
364 Err(_) => return false,
365 };
366 let sig = Signature::from_bytes(&sig_array);
367
368 vk.verify_strict(canonical.as_bytes(), &sig).is_ok()
369 }
370}
371
372/// SHA-256 digest of the canonical (sorted-key) JSON serialization of a
373/// `ChainProofSummary`, hex-encoded. Used to fold the multi-field zk_proof
374/// struct into the pipe-delimited v3 canonical signing string.
375///
376/// We use `serde_json::to_value` to materialize the value, then
377/// re-serialize via `BTreeMap` to force sorted keys. `serde_json` writes
378/// struct fields in declaration order by default, which is stable in
379/// practice but is a Rust-source-level invariant rather than a wire-format
380/// one. Sorted-key JSON is the format-level invariant (akin to RFC 8785's
381/// `keys_in_alphabetical_order` rule) and is what any third-party
382/// verifier must reproduce.
383///
384/// Caller's contract: pass `Some(&summary)` for present, omit entirely
385/// (the canonical writes an empty field) for `None`. We do not call this
386/// for `None` so the sentinel can't collide with a real digest.
387fn zk_proof_digest_hex(summary: &ChainProofSummary) -> String {
388 let value = serde_json::to_value(summary)
389 .expect("ChainProofSummary serializes to JSON value");
390 // Re-serialize through BTreeMap to enforce sorted keys at every level.
391 // For ChainProofSummary specifically this is a flat object of scalars,
392 // but doing it through the generic walker keeps the function honest
393 // if the struct grows nested fields later.
394 let canonical = canonical_json_string(&value);
395 hex::encode(Sha256::digest(canonical.as_bytes()))
396}
397
398/// Sorted-key canonical JSON. Compact (no whitespace). For object keys
399/// the ordering is bytewise on the UTF-8 representation, matching what
400/// `BTreeMap<String, _>` produces. Arrays preserve order. Numbers,
401/// booleans, strings, and null serialize as serde_json's default
402/// (which is JSON-spec compliant; we do not need RFC 8785's full
403/// numeric normalization for `ChainProofSummary` because every numeric
404/// field there is an integer).
405fn canonical_json_string(value: &serde_json::Value) -> String {
406 use std::collections::BTreeMap;
407 match value {
408 serde_json::Value::Object(map) => {
409 let sorted: BTreeMap<&String, String> = map
410 .iter()
411 .map(|(k, v)| (k, canonical_json_string(v)))
412 .collect();
413 let mut out = String::from("{");
414 let mut first = true;
415 for (k, v) in sorted {
416 if !first {
417 out.push(',');
418 }
419 first = false;
420 // Re-serialize the key as a JSON string to handle escapes.
421 let key_json = serde_json::to_string(k)
422 .expect("string serializes to JSON");
423 out.push_str(&key_json);
424 out.push(':');
425 out.push_str(&v);
426 }
427 out.push('}');
428 out
429 }
430 serde_json::Value::Array(items) => {
431 let mut out = String::from("[");
432 let mut first = true;
433 for item in items {
434 if !first {
435 out.push(',');
436 }
437 first = false;
438 out.push_str(&canonical_json_string(item));
439 }
440 out.push(']');
441 out
442 }
443 other => serde_json::to_string(other)
444 .expect("scalar serializes to JSON"),
445 }
446}
447
448// ---------------------------------------------------------------------------
449// Trust-pin tests
450// ---------------------------------------------------------------------------
451
452#[cfg(test)]
453mod trust_pin_tests {
454 use super::*;
455 use crate::attestation::{Ed25519Signer, Signer};
456 use crate::merkle::MerkleTree;
457 use crate::trust::{encode_ed25519_pubkey, TrustRoot, TrustRootKind, TrustRootStore};
458
459 fn signer_and_tree() -> (Ed25519Signer, MerkleTree) {
460 let mut tree = MerkleTree::new();
461 tree.append("art_alpha");
462 tree.append("art_beta");
463 let signer = Ed25519Signer::generate("key_test").unwrap();
464 (signer, tree)
465 }
466
467 fn trust_with(signer: &Ed25519Signer) -> TrustRootStore {
468 use ed25519_dalek::VerifyingKey;
469 let pk_bytes: [u8; 32] = signer.public_key_bytes().try_into().unwrap();
470 let vk = VerifyingKey::from_bytes(&pk_bytes).unwrap();
471 TrustRootStore::with_roots(vec![TrustRoot {
472 key_id: signer.key_id().to_string(),
473 public_key: encode_ed25519_pubkey(&vk),
474 kind: TrustRootKind::HubCheckpoint,
475 label: "trusted hub".into(),
476 added_at: "2026-05-15T00:00:00Z".into(),
477 }])
478 }
479
480 /// The headline case from the audit: a checkpoint signed by a key
481 /// the operator never trusted MUST NOT verify, even though the
482 /// signature math is internally consistent.
483 #[test]
484 fn verify_rejects_unknown_pubkey() {
485 let (signer, tree) = signer_and_tree();
486 let cp = Checkpoint::create(1, &tree, &signer).unwrap();
487
488 // Different signer's key is the only one in the store.
489 let other = Ed25519Signer::generate("other").unwrap();
490 let trust = trust_with(&other);
491
492 assert!(!cp.verify(&trust),
493 "unknown issuer must be rejected even with valid signature");
494 }
495
496 /// Happy path: the issuer is pinned, the signature math is good,
497 /// verify returns true.
498 #[test]
499 fn verify_accepts_trusted_pubkey() {
500 let (signer, tree) = signer_and_tree();
501 let cp = Checkpoint::create(1, &tree, &signer).unwrap();
502 let trust = trust_with(&signer);
503 assert!(cp.verify(&trust), "trusted issuer + good signature must verify");
504 }
505
506 /// No trust configured at all (empty store) is the operator's
507 /// fresh-install state. Verification must fail closed: a verifier
508 /// without a trust set cannot vouch for anyone.
509 #[test]
510 fn verify_rejects_with_no_trust_configured() {
511 let (signer, tree) = signer_and_tree();
512 let cp = Checkpoint::create(1, &tree, &signer).unwrap();
513 let trust = TrustRootStore::empty();
514 assert!(!cp.verify(&trust),
515 "empty trust store must reject all checkpoints");
516 }
517
518 /// Trust pinning is kind-scoped: a key trusted for AgentCert is
519 /// NOT trusted for a Merkle checkpoint. This is the firewall
520 /// between certificate issuance and journal anchoring.
521 #[test]
522 fn verify_rejects_pubkey_pinned_for_wrong_kind() {
523 let (signer, tree) = signer_and_tree();
524 let cp = Checkpoint::create(1, &tree, &signer).unwrap();
525
526 use ed25519_dalek::VerifyingKey;
527 let pk_bytes: [u8; 32] = signer.public_key_bytes().try_into().unwrap();
528 let vk = VerifyingKey::from_bytes(&pk_bytes).unwrap();
529 let mismatched = TrustRootStore::with_roots(vec![TrustRoot {
530 key_id: signer.key_id().to_string(),
531 public_key: encode_ed25519_pubkey(&vk),
532 kind: TrustRootKind::AgentCert, // wrong kind!
533 label: "trusted for agent certs only".into(),
534 added_at: "2026-05-15T00:00:00Z".into(),
535 }]);
536 assert!(!cp.verify(&mismatched),
537 "kind discrimination must keep AgentCert roots out of checkpoint trust");
538 }
539
540 /// Forge attempt -- attacker re-signs with a non-trusted key.
541 /// The signature is internally valid (sig was made over canonical
542 /// bytes by the embedded pubkey) but the pubkey is unknown to the
543 /// operator. Pre-pin this passed; post-pin it must not.
544 #[test]
545 fn verify_rejects_attacker_self_signed_forgery() {
546 // Attacker mints their own keypair, builds a checkpoint over
547 // their own canonical bytes, embeds their own pubkey, signs.
548 let (attacker_signer, tree) = signer_and_tree();
549 let forgery = Checkpoint::create(99, &tree, &attacker_signer).unwrap();
550
551 // Honest operator has trusted a DIFFERENT issuer.
552 let honest = Ed25519Signer::generate("honest_hub").unwrap();
553 let trust = trust_with(&honest);
554
555 assert!(!forgery.verify(&trust),
556 "self-signed forgery must not verify against operator's trust set");
557 }
558}
559
560// ---------------------------------------------------------------------------
561// v0.10.4 canonical v3 tests
562//
563// These pin the fix for the second canonical break: v0.10.3's v2 form bound
564// merkle_version but left `algorithm` and `zk_proof` wire-mutable. v3 binds
565// both, plus the canonical_version itself (to prevent downgrade-by-relabel).
566// ---------------------------------------------------------------------------
567
568#[cfg(test)]
569mod canonical_v3_tests {
570 use super::*;
571 use crate::attestation::{Ed25519Signer, Signer};
572 use crate::merkle::tree::{MerkleTree, MERKLE_ALGORITHM_V2, MERKLE_VERSION_V1, MERKLE_VERSION_V2};
573 use crate::trust::{encode_ed25519_pubkey, TrustRoot, TrustRootKind, TrustRootStore};
574
575 fn signer_and_tree() -> (Ed25519Signer, MerkleTree) {
576 let mut tree = MerkleTree::new();
577 tree.append("art_alpha");
578 tree.append("art_beta");
579 let signer = Ed25519Signer::generate("key_test").unwrap();
580 (signer, tree)
581 }
582
583 fn trust_with(signer: &Ed25519Signer) -> TrustRootStore {
584 use ed25519_dalek::VerifyingKey;
585 let pk_bytes: [u8; 32] = signer.public_key_bytes().try_into().unwrap();
586 let vk = VerifyingKey::from_bytes(&pk_bytes).unwrap();
587 TrustRootStore::with_roots(vec![TrustRoot {
588 key_id: signer.key_id().to_string(),
589 public_key: encode_ed25519_pubkey(&vk),
590 kind: TrustRootKind::HubCheckpoint,
591 label: "trusted hub".into(),
592 added_at: "2026-05-15T00:00:00Z".into(),
593 }])
594 }
595
596 fn sample_zk_proof() -> ChainProofSummary {
597 ChainProofSummary {
598 image_id: "sha256:beef".into(),
599 all_signatures_valid: true,
600 chain_intact: true,
601 approval_nonces_matched: true,
602 artifact_count: 7,
603 public_key_digest: "sha256:cafe".into(),
604 proved_at: "2026-05-17T01:23:45Z".into(),
605 }
606 }
607
608 /// Sanity: a freshly-created checkpoint is v3.
609 #[test]
610 fn fresh_checkpoint_is_v3() {
611 let (signer, tree) = signer_and_tree();
612 let cp = Checkpoint::create(1, &tree, &signer).unwrap();
613 assert_eq!(cp.canonical_version, CANONICAL_VERSION_V3);
614 assert_eq!(cp.merkle_version, MERKLE_VERSION_V2);
615 assert!(cp.algorithm.is_some());
616 }
617
618 /// The headline v0.10.4 audit fix: mutating `algorithm` on the wire
619 /// of a v3-signed checkpoint must invalidate the signature.
620 #[test]
621 fn algorithm_tamper_detected() {
622 let (signer, tree) = signer_and_tree();
623 let trust = trust_with(&signer);
624 let mut cp = Checkpoint::create(1, &tree, &signer).unwrap();
625 assert!(cp.verify(&trust), "baseline must verify");
626
627 cp.algorithm = Some("sha256-attacker".into());
628 assert!(
629 !cp.verify(&trust),
630 "algorithm field mutation on the wire must break the v3 signature"
631 );
632
633 // Also: clearing the field to None must break it.
634 let mut cp2 = Checkpoint::create(1, &tree, &signer).unwrap();
635 cp2.algorithm = None;
636 assert!(
637 !cp2.verify(&trust),
638 "removing algorithm on the wire must break the v3 signature"
639 );
640 }
641
642 /// Same fix for `zk_proof`: an attacker attaching, swapping, or
643 /// removing a ChainProofSummary on the wire must invalidate the
644 /// signature.
645 #[test]
646 fn zk_proof_tamper_detected() {
647 let (signer, tree) = signer_and_tree();
648 let trust = trust_with(&signer);
649
650 // Case A: attacker attaches a fabricated proof to a checkpoint
651 // that was signed with zk_proof: None.
652 let mut cp_attach = Checkpoint::create(1, &tree, &signer).unwrap();
653 assert!(cp_attach.zk_proof.is_none(), "fresh checkpoint must have no proof");
654 cp_attach.zk_proof = Some(sample_zk_proof());
655 assert!(
656 !cp_attach.verify(&trust),
657 "attaching a zk_proof on the wire must break the v3 signature"
658 );
659
660 // Case B: sign a checkpoint, then mutate a field inside the
661 // proof on the wire. Needs a small re-sign helper because
662 // Checkpoint::create only sets zk_proof to None.
663 let (signer_b, tree_b) = signer_and_tree();
664 let trust_b = trust_with(&signer_b);
665 let mut cp_swap = checkpoint_signed_with_proof(
666 &signer_b, &tree_b, 1, Some(sample_zk_proof()),
667 );
668 assert!(cp_swap.verify(&trust_b), "freshly signed v3+proof must verify");
669
670 // Mutate one field on the embedded proof.
671 let mut tampered = sample_zk_proof();
672 tampered.chain_intact = false;
673 cp_swap.zk_proof = Some(tampered);
674 assert!(
675 !cp_swap.verify(&trust_b),
676 "mutating a zk_proof field on the wire must break the v3 signature"
677 );
678
679 // Case C: strip the proof entirely.
680 let mut cp_strip = checkpoint_signed_with_proof(
681 &signer_b, &tree_b, 1, Some(sample_zk_proof()),
682 );
683 cp_strip.zk_proof = None;
684 assert!(
685 !cp_strip.verify(&trust_b),
686 "stripping zk_proof on the wire must break the v3 signature"
687 );
688 }
689
690 /// v0.10.3-era v2 checkpoints (no canonical_version field on disk;
691 /// algorithm present, zk_proof absent) must continue to verify under
692 /// v0.10.4 code. This is the legacy-compat guarantee.
693 #[test]
694 fn v2_legacy_checkpoint_still_verifies() {
695 let (signer, tree) = signer_and_tree();
696 let trust = trust_with(&signer);
697
698 let cp_v2 = sign_legacy_v2(&signer, &tree, 1);
699 assert_eq!(cp_v2.canonical_version, CANONICAL_VERSION_V2);
700 assert_eq!(cp_v2.merkle_version, MERKLE_VERSION_V2);
701 assert!(
702 cp_v2.verify(&trust),
703 "v0.10.3-era v2-canonical checkpoint must still verify"
704 );
705
706 // And the wire form (no canonical_version field at all) round-trips
707 // through #[serde(default)] back to canonical_version: 2.
708 let mut json = serde_json::to_value(&cp_v2).unwrap();
709 json.as_object_mut().unwrap().remove("canonical_version");
710 let reparsed: Checkpoint = serde_json::from_value(json).unwrap();
711 assert_eq!(reparsed.canonical_version, CANONICAL_VERSION_V2);
712 assert!(
713 reparsed.verify(&trust),
714 "v2 checkpoint deserialized without canonical_version field must verify"
715 );
716 }
717
718 /// Pre-v0.10.3 v1 checkpoints (legacy hashing, no canonical tag,
719 /// no merkle_version on the wire) must continue to verify under
720 /// v0.10.4 code.
721 #[test]
722 fn v1_legacy_checkpoint_still_verifies() {
723 let signer = Ed25519Signer::generate("legacy_key").unwrap();
724 let trust = trust_with(&signer);
725
726 // Build a v1 tree so the canonical dispatch forces the legacy
727 // form. canonical_version field is informational only for v1.
728 let cp_v1 = sign_legacy_v1(&signer, 99, "sha256:legacy_root", 4, 2);
729 assert_eq!(cp_v1.merkle_version, MERKLE_VERSION_V1);
730 assert!(
731 cp_v1.verify(&trust),
732 "pre-v0.10.3 v1-canonical checkpoint must still verify"
733 );
734
735 // And the wire form without the v1-vintage missing-fields still
736 // round-trips and verifies.
737 let mut json = serde_json::to_value(&cp_v1).unwrap();
738 json.as_object_mut().unwrap().remove("canonical_version");
739 json.as_object_mut().unwrap().remove("merkle_version");
740 json.as_object_mut().unwrap().remove("algorithm");
741 let reparsed: Checkpoint = serde_json::from_value(json).unwrap();
742 assert_eq!(reparsed.merkle_version, MERKLE_VERSION_V1);
743 assert!(
744 reparsed.verify(&trust),
745 "pre-v0.10.3 v1 checkpoint stripped of new fields must verify"
746 );
747 }
748
749 /// Cross-version downgrade: an attacker takes a legitimately
750 /// v3-signed checkpoint, relabels it as canonical_version: 2 on
751 /// the wire (and strips the new bindings to make the v2 canonical
752 /// reproducible), and tries to verify. Must fail — the signature
753 /// covers v3-canonical bytes, not v2-canonical bytes.
754 #[test]
755 fn cross_version_downgrade_v3_to_v2_rejected() {
756 let (signer, tree) = signer_and_tree();
757 let trust = trust_with(&signer);
758 let mut cp = Checkpoint::create(1, &tree, &signer).unwrap();
759 assert_eq!(cp.canonical_version, CANONICAL_VERSION_V3);
760 assert!(cp.verify(&trust), "baseline v3 must verify");
761
762 // Attacker downgrade: flip the canonical_version tag.
763 cp.canonical_version = CANONICAL_VERSION_V2;
764 assert!(
765 !cp.verify(&trust),
766 "v3->v2 canonical_version downgrade must fail (signature covers v3 bytes)"
767 );
768
769 // And the attacker can't recover by also stripping algorithm
770 // (since v2 doesn't bind it, they might hope the v2 canonical
771 // matches the original v3 signature anyway — it must not).
772 let (signer2, tree2) = signer_and_tree();
773 let trust2 = trust_with(&signer2);
774 let mut cp2 = Checkpoint::create(1, &tree2, &signer2).unwrap();
775 cp2.canonical_version = CANONICAL_VERSION_V2;
776 cp2.algorithm = None;
777 assert!(
778 !cp2.verify(&trust2),
779 "v3->v2 downgrade + strip algorithm must still fail"
780 );
781 }
782
783 /// Unknown canonical_version (a future format this verifier doesn't
784 /// understand) must fail closed.
785 #[test]
786 fn unknown_canonical_version_rejected() {
787 let (signer, tree) = signer_and_tree();
788 let trust = trust_with(&signer);
789 let mut cp = Checkpoint::create(1, &tree, &signer).unwrap();
790 cp.canonical_version = 99;
791 assert!(
792 !cp.verify(&trust),
793 "unknown canonical_version must fail closed (no silent fallback)"
794 );
795 }
796
797 // ── test helpers ─────────────────────────────────────────────────
798
799 /// Sign a v3 checkpoint with a chosen zk_proof. Mirrors
800 /// `Checkpoint::create` but lets the test supply zk_proof so it
801 /// can be tampered with after the fact.
802 fn checkpoint_signed_with_proof(
803 signer: &Ed25519Signer,
804 tree: &MerkleTree,
805 index: u64,
806 zk_proof: Option<ChainProofSummary>,
807 ) -> Checkpoint {
808 let root_bytes = tree.root().expect("non-empty tree");
809 let root = format!("sha256:{}", hex::encode(root_bytes));
810 let signed_at = "2026-05-17T00:00:00Z".to_string();
811 let algorithm = Some(MERKLE_ALGORITHM_V2.to_string());
812
813 let canonical = Checkpoint::canonical_for_signing(
814 CANONICAL_VERSION_V3,
815 tree.version(),
816 algorithm.as_deref(),
817 zk_proof.as_ref(),
818 index,
819 &root,
820 tree.len(),
821 tree.height(),
822 signer.key_id(),
823 &signed_at,
824 );
825 let sig_bytes = signer.sign(canonical.as_bytes()).unwrap();
826 Checkpoint {
827 index,
828 root,
829 tree_size: tree.len(),
830 height: tree.height(),
831 signed_at,
832 signer: signer.key_id().to_string(),
833 public_key: URL_SAFE_NO_PAD.encode(signer.public_key_bytes()),
834 signature: URL_SAFE_NO_PAD.encode(&sig_bytes),
835 algorithm,
836 merkle_version: tree.version(),
837 zk_proof,
838 canonical_version: CANONICAL_VERSION_V3,
839 }
840 }
841
842 /// Sign a checkpoint under the v0.10.3-era v2 canonical (no
843 /// algorithm/zk_proof binding, no canonical_version on the wire).
844 /// Used to verify legacy compat.
845 fn sign_legacy_v2(
846 signer: &Ed25519Signer,
847 tree: &MerkleTree,
848 index: u64,
849 ) -> Checkpoint {
850 let root_bytes = tree.root().expect("non-empty tree");
851 let root = format!("sha256:{}", hex::encode(root_bytes));
852 let signed_at = "2026-05-17T00:00:00Z".to_string();
853
854 // Reproduce the v0.10.3 v2 canonical byte-for-byte. Note: in v2
855 // the canonical function takes neither algorithm nor zk_proof.
856 let canonical = Checkpoint::canonical_for_signing(
857 CANONICAL_VERSION_V2,
858 tree.version(),
859 None, // ignored under v2 dispatch
860 None, // ignored under v2 dispatch
861 index,
862 &root,
863 tree.len(),
864 tree.height(),
865 signer.key_id(),
866 &signed_at,
867 );
868 // Sanity: v2 canonical must NOT include algorithm even if
869 // we passed Some() here — the v2 branch ignores it.
870 assert!(canonical.starts_with("v2|"));
871
872 let sig_bytes = signer.sign(canonical.as_bytes()).unwrap();
873 Checkpoint {
874 index,
875 root,
876 tree_size: tree.len(),
877 height: tree.height(),
878 signed_at,
879 signer: signer.key_id().to_string(),
880 public_key: URL_SAFE_NO_PAD.encode(signer.public_key_bytes()),
881 signature: URL_SAFE_NO_PAD.encode(&sig_bytes),
882 // v0.10.3-era checkpoints had algorithm present even though
883 // it wasn't bound — that's the on-wire shape we need to
884 // reproduce.
885 algorithm: Some(MERKLE_ALGORITHM_V2.to_string()),
886 merkle_version: MERKLE_VERSION_V2,
887 zk_proof: None,
888 canonical_version: CANONICAL_VERSION_V2,
889 }
890 }
891
892 /// Sign a pre-v0.10.3 v1 checkpoint using the bare legacy canonical.
893 /// The tree must NOT be exercised through MerkleTree::new (which is
894 /// v2 by default); instead we construct the canonical directly.
895 fn sign_legacy_v1(
896 signer: &Ed25519Signer,
897 index: u64,
898 root: &str,
899 tree_size: usize,
900 height: usize,
901 ) -> Checkpoint {
902 let signed_at = "2026-04-01T00:00:00Z".to_string();
903 // Bare legacy canonical.
904 let canonical = Checkpoint::canonical_for_signing(
905 CANONICAL_VERSION_V1,
906 MERKLE_VERSION_V1,
907 None,
908 None,
909 index,
910 root,
911 tree_size,
912 height,
913 signer.key_id(),
914 &signed_at,
915 );
916 assert_eq!(
917 canonical,
918 format!(
919 "{}|{}|{}|{}|{}|{}",
920 index, root, tree_size, height, signer.key_id(), signed_at
921 ),
922 "v1 canonical must remain byte-identical to legacy"
923 );
924
925 let sig_bytes = signer.sign(canonical.as_bytes()).unwrap();
926 Checkpoint {
927 index,
928 root: root.to_string(),
929 tree_size,
930 height,
931 signed_at,
932 signer: signer.key_id().to_string(),
933 public_key: URL_SAFE_NO_PAD.encode(signer.public_key_bytes()),
934 signature: URL_SAFE_NO_PAD.encode(&sig_bytes),
935 algorithm: None,
936 merkle_version: MERKLE_VERSION_V1,
937 zk_proof: None,
938 // Pre-v0.10.4 checkpoints have no canonical_version on the
939 // wire; serde would default it to 2, but merkle_version == 1
940 // forces v1 dispatch anyway. We set it to 1 here for clarity.
941 canonical_version: CANONICAL_VERSION_V1,
942 }
943 }
944}