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