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    /// 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}