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