Skip to main content

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}