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/// Outcome of verifying a checkpoint against a trust root store.
36///
37/// The bool-returning [`Checkpoint::verify`] collapses this to
38/// `Valid`-or-not, which conflates two situations that demand opposite
39/// reactions: a *genuinely bad* signature (distrust the hub) versus a
40/// cryptographically fine signature from a signer you simply have not pinned
41/// (pin the root, if you decide to trust it). Reporting the second as
42/// "INVALID" is a mislabel. Callers that need to tell them apart -- `resolve`,
43/// and the browser `verify_merkle_proof` -- use [`Checkpoint::verify_detailed`].
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum CheckpointVerifyOutcome {
46    /// Signature verifies AND the signer is pinned under `HubCheckpoint`.
47    Valid,
48    /// The signature is cryptographically valid, but the signer's public key
49    /// is not pinned in the provided trust roots. Not a failure of the math --
50    /// the verifier just has not decided to trust this signer. Pinning remains
51    /// the caller's explicit decision (trust-on-first-use). Carries the served
52    /// public key so a caller can offer a ready-to-run pin.
53    SignerNotPinned { public_key: String },
54    /// The checkpoint is malformed (bad key/signature encoding, unknown
55    /// canonical/merkle version) or the signature does not verify. A genuine
56    /// failure regardless of trust configuration.
57    Invalid { reason: String },
58}
59
60/// A signed snapshot of the Merkle tree at a point in time.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct Checkpoint {
63    pub index: u64,
64    /// Root hash in `sha256:<hex>` format.
65    pub root: String,
66    pub tree_size: usize,
67    pub height: usize,
68    /// RFC 3339 timestamp.
69    pub signed_at: String,
70    /// Key ID of the signer.
71    pub signer: String,
72    /// Base64url-encoded public key bytes.
73    pub public_key: String,
74    /// Base64url-encoded Ed25519 signature of the canonical form.
75    pub signature: String,
76    /// Merkle algorithm used. Missing = v1 (sha256-duplicate-last).
77    ///
78    /// Currently this string is fully derived from `merkle_version`
79    /// (`v1 → "sha256-duplicate-last"`, `v2 → "sha256-rfc9162"`) so it
80    /// is informationally redundant with `merkle_version`. It is still
81    /// bound into the v3 canonical to lock the on-wire value: even
82    /// redundant fields become tampering surface once they're displayed
83    /// or fed into downstream tooling. Removable in a future canonical
84    /// (v4) once a deprecation window has passed.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub algorithm: Option<String>,
87    /// Merkle format version byte (RFC 9162 domain separation). Absent
88    /// on v0.10.2-and-earlier checkpoints — `#[serde(default)]` fills it
89    /// with `1` so legacy checkpoints continue to verify under v1
90    /// hashing. New checkpoints emit `2`.
91    #[serde(default = "super::tree::default_merkle_version_v1")]
92    pub merkle_version: u8,
93    /// Optional ZK chain proof result (added when proof is ready).
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub zk_proof: Option<ChainProofSummary>,
96    /// Canonical signing format version (independent of `merkle_version`).
97    /// Pre-v0.10.4 checkpoints don't carry this; `#[serde(default)]` fills
98    /// it with `2` so v0.10.3-era checkpoints continue to verify under the
99    /// v2 canonical. v0.10.4+ checkpoints emit `3`. Pre-v0.10.3 checkpoints
100    /// have `merkle_version == 1`, which overrides this and dispatches the
101    /// legacy v1 canonical regardless. This field is itself bound into the
102    /// v3 canonical to prevent a downgrade-by-relabel attack.
103    #[serde(default = "default_canonical_version_v2")]
104    pub canonical_version: u8,
105}
106
107/// Summary of a RISC Zero chain proof, embedded in a Merkle checkpoint.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct ChainProofSummary {
110    pub image_id: String,
111    pub all_signatures_valid: bool,
112    pub chain_intact: bool,
113    pub approval_nonces_matched: bool,
114    pub artifact_count: u64,
115    pub public_key_digest: String,
116    pub proved_at: String,
117}
118
119/// Errors from checkpoint creation.
120#[derive(Debug)]
121pub enum CheckpointError {
122    EmptyTree,
123    Signing(SignerError),
124}
125
126impl std::fmt::Display for CheckpointError {
127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        match self {
129            Self::EmptyTree => write!(f, "cannot checkpoint an empty tree"),
130            Self::Signing(e) => write!(f, "checkpoint signing failed: {}", e),
131        }
132    }
133}
134
135impl std::error::Error for CheckpointError {}
136impl From<SignerError> for CheckpointError {
137    fn from(e: SignerError) -> Self {
138        Self::Signing(e)
139    }
140}
141
142impl Checkpoint {
143    /// Build the canonical string for signing/verification.
144    ///
145    /// Three formats coexist by design. Dispatch is governed entirely by
146    /// the (trusted) `canonical_version` argument — never inferred from
147    /// wire-controllable field presence — *with one exception*: a
148    /// `merkle_version == 1` checkpoint always uses the v1 legacy form
149    /// regardless of `canonical_version`, because pre-v0.10.3 checkpoints
150    /// never carried `canonical_version` and were signed under the bare
151    /// pipe-delimited bytes.
152    ///
153    /// * **v1 (`merkle_version == 1`):** the original pre-v0.10.3 form,
154    ///   `"{index}|{root}|{tree_size}|{height}|{signer}|{signed_at}"`. Old
155    ///   checkpoints in the wild were signed under this exact string and
156    ///   must continue to verify byte-identically.
157    ///
158    /// * **v2 (`canonical_version == 2`, `merkle_version >= 2`):** v0.10.3
159    ///   form,
160    ///   `"v2|{merkle_version}|{index}|{root}|{tree_size}|{height}|{signer}|{signed_at}"`.
161    ///   Binds `merkle_version` to close the v1/v2 hashing downgrade.
162    ///   `algorithm` and `zk_proof` are NOT bound in v2; they were
163    ///   wire-mutable in v0.10.3, which is the v0.10.4 audit finding this
164    ///   v3 form closes.
165    ///
166    /// * **v3 (`canonical_version == 3`, `merkle_version >= 2`):** v0.10.4
167    ///   form,
168    ///   `"v3|{canonical_version}|{merkle_version}|{algorithm_or_empty}|{zk_proof_digest_or_empty}|{index}|{root}|{tree_size}|{height}|{signer}|{signed_at}"`.
169    ///   - `canonical_version` is itself bound to prevent downgrade-by-
170    ///     relabel: an attacker flipping `canonical_version: 3 → 2` on
171    ///     the wire breaks the signature because the bytes recanonicalize
172    ///     differently under v2 dispatch.
173    ///   - `algorithm_or_empty` is the verbatim algorithm string, or empty
174    ///     when the field is `None`. Currently redundant with
175    ///     `merkle_version` but bound to lock the on-wire value.
176    ///   - `zk_proof_digest_or_empty` is the hex-encoded SHA-256 of the
177    ///     sorted-key JSON serialization of `zk_proof`, or empty for `None`.
178    ///     Hash-of-canonical-JSON rather than direct embedding because
179    ///     `ChainProofSummary` is a multi-field struct that doesn't
180    ///     compose with pipe-delimiting.
181    ///
182    /// **Breaking change note:** any third-party verifier that reproduces
183    /// the canonical string outside this Rust core (hand-rolled JS/Go/Python
184    /// checkers) must mirror this dispatch. The `verify-js` package
185    /// consumes WASM and inherits the change automatically.
186    #[allow(clippy::too_many_arguments)]
187    pub(crate) fn canonical_for_signing(
188        canonical_version: u8,
189        merkle_version: u8,
190        algorithm: Option<&str>,
191        zk_proof: Option<&ChainProofSummary>,
192        index: u64,
193        root: &str,
194        tree_size: usize,
195        height: usize,
196        signer: &str,
197        signed_at: &str,
198    ) -> String {
199        // Legacy v1 path is forced by merkle_version, not canonical_version.
200        // Pre-v0.10.3 checkpoints never carried canonical_version and were
201        // signed under the bare pipe-delimited bytes.
202        if merkle_version == MERKLE_VERSION_V1 {
203            return format!(
204                "{}|{}|{}|{}|{}|{}",
205                index, root, tree_size, height, signer, signed_at,
206            );
207        }
208
209        match canonical_version {
210            CANONICAL_VERSION_V2 => format!(
211                "v2|{}|{}|{}|{}|{}|{}|{}",
212                merkle_version, index, root, tree_size, height, signer, signed_at,
213            ),
214            // v3 (and any unrecognized newer version we treat as v3 here;
215            // the dispatcher in `verify` rejects unknown canonical_versions
216            // up front, so this branch is only reached for known v3).
217            _ => {
218                let algo_field = algorithm.unwrap_or("");
219                let zk_digest = zk_proof.map(zk_proof_digest_hex).unwrap_or_default();
220                format!(
221                    "v3|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
222                    canonical_version,
223                    merkle_version,
224                    algo_field,
225                    zk_digest,
226                    index,
227                    root,
228                    tree_size,
229                    height,
230                    signer,
231                    signed_at,
232                )
233            }
234        }
235    }
236
237    /// Create a signed checkpoint from the current tree state.
238    ///
239    /// New checkpoints are signed under canonical v3, which binds
240    /// `merkle_version`, `algorithm`, and `zk_proof` in addition to the
241    /// v2-bound fields. `zk_proof` is `None` at create time; if the
242    /// daemon later attaches a ZK proof summary it must re-sign (which
243    /// today it doesn't — see `update_checkpoint_with_proof`; that path
244    /// is now considered tamper-surface and will be fixed in a follow-up).
245    pub fn create(
246        index: u64,
247        tree: &MerkleTree,
248        signer: &dyn Signer,
249    ) -> Result<Self, CheckpointError> {
250        let root_bytes = tree.root().ok_or(CheckpointError::EmptyTree)?;
251        let root = format!("sha256:{}", hex::encode(root_bytes));
252
253        let secs = std::time::SystemTime::now()
254            .duration_since(std::time::UNIX_EPOCH)
255            .unwrap_or_default()
256            .as_secs();
257        let signed_at = unix_to_rfc3339(secs);
258
259        // New v0.10.4 checkpoints emit canonical v3 unless the tree is
260        // v1 (in which case canonical_for_signing forces the legacy form
261        // and the canonical_version field is informational only).
262        let canonical_version = if tree.version() == MERKLE_VERSION_V1 {
263            CANONICAL_VERSION_V1
264        } else {
265            CANONICAL_VERSION_V3
266        };
267        let algorithm = Some(super::tree::MERKLE_ALGORITHM_V2.to_string());
268        let zk_proof: Option<ChainProofSummary> = None;
269
270        let canonical = Self::canonical_for_signing(
271            canonical_version,
272            tree.version(),
273            algorithm.as_deref(),
274            zk_proof.as_ref(),
275            index,
276            &root,
277            tree.len(),
278            tree.height(),
279            signer.key_id(),
280            &signed_at,
281        );
282        let sig_bytes = signer.sign(canonical.as_bytes())?;
283        let signature = URL_SAFE_NO_PAD.encode(&sig_bytes);
284        let public_key = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
285
286        Ok(Self {
287            index,
288            root,
289            tree_size: tree.len(),
290            height: tree.height(),
291            signed_at,
292            signer: signer.key_id().to_string(),
293            public_key,
294            signature,
295            algorithm,
296            merkle_version: tree.version(),
297            zk_proof,
298            canonical_version,
299        })
300    }
301
302    /// The exact canonical string this checkpoint's signature is computed
303    /// over, as bytes. Reproduces what `verify` reconstructs internally, so a
304    /// remote party (e.g. the hub, AUD-18) can be handed these bytes and run
305    /// `ed25519.Verify(public_key, canonical_signing_bytes, signature)` without
306    /// re-implementing the versioned canonical dispatch in another language.
307    /// The structured fields (root / tree_size / signer / signed_at) are all
308    /// present inside the returned string, pipe-delimited, so a verifier can
309    /// cross-check that the values it stores match the values that were signed.
310    pub fn canonical_signing_string(&self) -> String {
311        Self::canonical_for_signing(
312            self.canonical_version,
313            self.merkle_version,
314            self.algorithm.as_deref(),
315            self.zk_proof.as_ref(),
316            self.index,
317            &self.root,
318            self.tree_size,
319            self.height,
320            &self.signer,
321            &self.signed_at,
322        )
323    }
324
325    /// Verify the checkpoint signature AND require the embedded public key
326    /// to be present in `trust` under kind `HubCheckpoint`. Returns `false`
327    /// on any failure (bad encoding, wrong key size, invalid signature,
328    /// untrusted issuer, no trust configured). Never panics.
329    ///
330    /// Trust pinning is mandatory. A self-signed checkpoint (an attacker
331    /// minting their own keypair, embedding the pubkey, and signing the
332    /// canonical bytes) used to satisfy this function -- it now does not,
333    /// because `trust.contains` rejects unknown issuers.
334    pub fn verify(&self, trust: &TrustRootStore) -> bool {
335        matches!(self.verify_detailed(trust), CheckpointVerifyOutcome::Valid)
336    }
337
338    /// Verify the checkpoint and distinguish the three outcomes a caller may
339    /// want to react to differently (see [`CheckpointVerifyOutcome`]).
340    ///
341    /// The check order matters: structural validity (key/version/signature
342    /// encoding) and the signature math are evaluated *before* the trust pin,
343    /// so a genuinely bad signature reports `Invalid` while a valid signature
344    /// from an unpinned signer reports `SignerNotPinned`. `verify` collapses
345    /// both non-`Valid` arms to `false`, so its security contract is
346    /// unchanged: a self-signed forgery (valid math, unpinned key) is
347    /// `SignerNotPinned`, never `Valid`.
348    pub fn verify_detailed(&self, trust: &TrustRootStore) -> CheckpointVerifyOutcome {
349        use CheckpointVerifyOutcome::*;
350
351        let pub_bytes = match URL_SAFE_NO_PAD.decode(&self.public_key) {
352            Ok(b) => b,
353            Err(_) => {
354                return Invalid {
355                    reason: "public_key is not valid base64url".into(),
356                }
357            }
358        };
359        let pub_array: [u8; 32] = match pub_bytes.as_slice().try_into() {
360            Ok(a) => a,
361            Err(_) => {
362                return Invalid {
363                    reason: "public_key is not 32 bytes".into(),
364                }
365            }
366        };
367        let vk = match VerifyingKey::from_bytes(&pub_array) {
368            Ok(k) => k,
369            Err(_) => {
370                return Invalid {
371                    reason: "public_key is not a valid Ed25519 point".into(),
372                }
373            }
374        };
375
376        // Reject unknown canonical_versions up front. Pre-v0.10.3
377        // checkpoints have merkle_version == 1 which forces the legacy
378        // v1 canonical regardless of this field; for newer checkpoints
379        // canonical_version must be 2 or 3. Anything else is either a
380        // misconfigured signer or a future format this verifier doesn't
381        // understand — fail closed in both cases.
382        if self.merkle_version != MERKLE_VERSION_V1
383            && self.canonical_version != CANONICAL_VERSION_V2
384            && self.canonical_version != CANONICAL_VERSION_V3
385        {
386            return Invalid {
387                reason: "unknown canonical/merkle version".into(),
388            };
389        }
390
391        let canonical = Self::canonical_for_signing(
392            self.canonical_version,
393            self.merkle_version,
394            self.algorithm.as_deref(),
395            self.zk_proof.as_ref(),
396            self.index,
397            &self.root,
398            self.tree_size,
399            self.height,
400            &self.signer,
401            &self.signed_at,
402        );
403
404        let sig_bytes = match URL_SAFE_NO_PAD.decode(&self.signature) {
405            Ok(b) => b,
406            Err(_) => {
407                return Invalid {
408                    reason: "signature is not valid base64url".into(),
409                }
410            }
411        };
412        let sig_array: [u8; 64] = match sig_bytes.as_slice().try_into() {
413            Ok(a) => a,
414            Err(_) => {
415                return Invalid {
416                    reason: "signature is not 64 bytes".into(),
417                }
418            }
419        };
420        let sig = Signature::from_bytes(&sig_array);
421
422        // Signature math first: a bad signature is a genuine failure
423        // regardless of whether the signer would have been pinned.
424        if vk.verify_strict(canonical.as_bytes(), &sig).is_err() {
425            return Invalid {
426                reason: "signature does not verify".into(),
427            };
428        }
429
430        // Signature is cryptographically valid. Trust pin: the embedded
431        // pubkey must be a configured root. An unpinned signer is reported
432        // distinctly (not `Invalid`) so a caller can offer to pin it --
433        // pinning stays the caller's explicit decision, and this arm is
434        // still not `Valid`, so the self-signed loophole remains closed.
435        if !trust.contains(&vk, TrustRootKind::HubCheckpoint) {
436            return SignerNotPinned {
437                public_key: self.public_key.clone(),
438            };
439        }
440
441        Valid
442    }
443}
444
445/// SHA-256 digest of the canonical (sorted-key) JSON serialization of a
446/// `ChainProofSummary`, hex-encoded. Used to fold the multi-field zk_proof
447/// struct into the pipe-delimited v3 canonical signing string.
448///
449/// We use `serde_json::to_value` to materialize the value, then
450/// re-serialize via `BTreeMap` to force sorted keys. `serde_json` writes
451/// struct fields in declaration order by default, which is stable in
452/// practice but is a Rust-source-level invariant rather than a wire-format
453/// one. Sorted-key JSON is the format-level invariant (akin to RFC 8785's
454/// `keys_in_alphabetical_order` rule) and is what any third-party
455/// verifier must reproduce.
456///
457/// Caller's contract: pass `Some(&summary)` for present, omit entirely
458/// (the canonical writes an empty field) for `None`. We do not call this
459/// for `None` so the sentinel can't collide with a real digest.
460fn zk_proof_digest_hex(summary: &ChainProofSummary) -> String {
461    let value = serde_json::to_value(summary).expect("ChainProofSummary serializes to JSON value");
462    // Re-serialize through BTreeMap to enforce sorted keys at every level.
463    // For ChainProofSummary specifically this is a flat object of scalars,
464    // but doing it through the generic walker keeps the function honest
465    // if the struct grows nested fields later.
466    let canonical = canonical_json_string(&value);
467    hex::encode(Sha256::digest(canonical.as_bytes()))
468}
469
470/// Sorted-key canonical JSON. Compact (no whitespace). For object keys
471/// the ordering is bytewise on the UTF-8 representation, matching what
472/// `BTreeMap<String, _>` produces. Arrays preserve order. Numbers,
473/// booleans, strings, and null serialize as serde_json's default
474/// (which is JSON-spec compliant; we do not need RFC 8785's full
475/// numeric normalization for `ChainProofSummary` because every numeric
476/// field there is an integer).
477fn canonical_json_string(value: &serde_json::Value) -> String {
478    use std::collections::BTreeMap;
479    match value {
480        serde_json::Value::Object(map) => {
481            let sorted: BTreeMap<&String, String> = map
482                .iter()
483                .map(|(k, v)| (k, canonical_json_string(v)))
484                .collect();
485            let mut out = String::from("{");
486            let mut first = true;
487            for (k, v) in sorted {
488                if !first {
489                    out.push(',');
490                }
491                first = false;
492                // Re-serialize the key as a JSON string to handle escapes.
493                let key_json = serde_json::to_string(k).expect("string serializes to JSON");
494                out.push_str(&key_json);
495                out.push(':');
496                out.push_str(&v);
497            }
498            out.push('}');
499            out
500        }
501        serde_json::Value::Array(items) => {
502            let mut out = String::from("[");
503            let mut first = true;
504            for item in items {
505                if !first {
506                    out.push(',');
507                }
508                first = false;
509                out.push_str(&canonical_json_string(item));
510            }
511            out.push(']');
512            out
513        }
514        other => serde_json::to_string(other).expect("scalar serializes to JSON"),
515    }
516}
517
518// ---------------------------------------------------------------------------
519// Trust-pin tests
520// ---------------------------------------------------------------------------
521
522#[cfg(test)]
523mod trust_pin_tests {
524    use super::*;
525    use crate::attestation::{Ed25519Signer, Signer};
526    use crate::merkle::MerkleTree;
527    use crate::trust::{encode_ed25519_pubkey, TrustRoot, TrustRootKind, TrustRootStore};
528
529    fn signer_and_tree() -> (Ed25519Signer, MerkleTree) {
530        let mut tree = MerkleTree::new();
531        tree.append("art_alpha");
532        tree.append("art_beta");
533        let signer = Ed25519Signer::generate("key_test").unwrap();
534        (signer, tree)
535    }
536
537    fn trust_with(signer: &Ed25519Signer) -> TrustRootStore {
538        use ed25519_dalek::VerifyingKey;
539        let pk_bytes: [u8; 32] = signer.public_key_bytes().try_into().unwrap();
540        let vk = VerifyingKey::from_bytes(&pk_bytes).unwrap();
541        TrustRootStore::with_roots(vec![TrustRoot {
542            key_id: signer.key_id().to_string(),
543            public_key: encode_ed25519_pubkey(&vk),
544            kind: TrustRootKind::HubCheckpoint,
545            label: "trusted hub".into(),
546            added_at: "2026-05-15T00:00:00Z".into(),
547        }])
548    }
549
550    /// The headline case from the audit: a checkpoint signed by a key
551    /// the operator never trusted MUST NOT verify, even though the
552    /// signature math is internally consistent.
553    #[test]
554    fn verify_rejects_unknown_pubkey() {
555        let (signer, tree) = signer_and_tree();
556        let cp = Checkpoint::create(1, &tree, &signer).unwrap();
557
558        // Different signer's key is the only one in the store.
559        let other = Ed25519Signer::generate("other").unwrap();
560        let trust = trust_with(&other);
561
562        assert!(
563            !cp.verify(&trust),
564            "unknown issuer must be rejected even with valid signature"
565        );
566    }
567
568    /// Happy path: the issuer is pinned, the signature math is good,
569    /// verify returns true.
570    #[test]
571    fn verify_accepts_trusted_pubkey() {
572        let (signer, tree) = signer_and_tree();
573        let cp = Checkpoint::create(1, &tree, &signer).unwrap();
574        let trust = trust_with(&signer);
575        assert!(
576            cp.verify(&trust),
577            "trusted issuer + good signature must verify"
578        );
579    }
580
581    /// No trust configured at all (empty store) is the operator's
582    /// fresh-install state. Verification must fail closed: a verifier
583    /// without a trust set cannot vouch for anyone.
584    #[test]
585    fn verify_rejects_with_no_trust_configured() {
586        let (signer, tree) = signer_and_tree();
587        let cp = Checkpoint::create(1, &tree, &signer).unwrap();
588        let trust = TrustRootStore::empty();
589        assert!(
590            !cp.verify(&trust),
591            "empty trust store must reject all checkpoints"
592        );
593    }
594
595    /// Trust pinning is kind-scoped: a key trusted for AgentCert is
596    /// NOT trusted for a Merkle checkpoint. This is the firewall
597    /// between certificate issuance and journal anchoring.
598    #[test]
599    fn verify_rejects_pubkey_pinned_for_wrong_kind() {
600        let (signer, tree) = signer_and_tree();
601        let cp = Checkpoint::create(1, &tree, &signer).unwrap();
602
603        use ed25519_dalek::VerifyingKey;
604        let pk_bytes: [u8; 32] = signer.public_key_bytes().try_into().unwrap();
605        let vk = VerifyingKey::from_bytes(&pk_bytes).unwrap();
606        let mismatched = TrustRootStore::with_roots(vec![TrustRoot {
607            key_id: signer.key_id().to_string(),
608            public_key: encode_ed25519_pubkey(&vk),
609            kind: TrustRootKind::AgentCert, // wrong kind!
610            label: "trusted for agent certs only".into(),
611            added_at: "2026-05-15T00:00:00Z".into(),
612        }]);
613        assert!(
614            !cp.verify(&mismatched),
615            "kind discrimination must keep AgentCert roots out of checkpoint trust"
616        );
617    }
618
619    /// Forge attempt -- attacker re-signs with a non-trusted key.
620    /// The signature is internally valid (sig was made over canonical
621    /// bytes by the embedded pubkey) but the pubkey is unknown to the
622    /// operator. Pre-pin this passed; post-pin it must not.
623    #[test]
624    fn verify_rejects_attacker_self_signed_forgery() {
625        // Attacker mints their own keypair, builds a checkpoint over
626        // their own canonical bytes, embeds their own pubkey, signs.
627        let (attacker_signer, tree) = signer_and_tree();
628        let forgery = Checkpoint::create(99, &tree, &attacker_signer).unwrap();
629
630        // Honest operator has trusted a DIFFERENT issuer.
631        let honest = Ed25519Signer::generate("honest_hub").unwrap();
632        let trust = trust_with(&honest);
633
634        assert!(
635            !forgery.verify(&trust),
636            "self-signed forgery must not verify against operator's trust set"
637        );
638    }
639
640    // ── verify_detailed: distinguish unpinned-signer from bad-signature ──
641
642    #[test]
643    fn verify_detailed_valid_when_pinned() {
644        let (signer, tree) = signer_and_tree();
645        let cp = Checkpoint::create(1, &tree, &signer).unwrap();
646        assert_eq!(
647            cp.verify_detailed(&trust_with(&signer)),
648            CheckpointVerifyOutcome::Valid
649        );
650    }
651
652    /// A valid signature from a signer the operator has not pinned is
653    /// `SignerNotPinned` (offer to pin), NOT `Invalid` (distrust). This is
654    /// the mislabel the CLI `resolve` fix corrected, now available to the
655    /// browser verifier.
656    #[test]
657    fn verify_detailed_unpinned_signer_is_not_invalid() {
658        let (signer, tree) = signer_and_tree();
659        let cp = Checkpoint::create(1, &tree, &signer).unwrap();
660
661        let out = cp.verify_detailed(&TrustRootStore::empty());
662        match out {
663            CheckpointVerifyOutcome::SignerNotPinned { public_key } => {
664                assert_eq!(
665                    public_key, cp.public_key,
666                    "the served pubkey must be surfaced so the caller can offer a pin"
667                );
668            }
669            other => panic!("unpinned signer must be SignerNotPinned, got {other:?}"),
670        }
671    }
672
673    /// The security-critical case: a self-signed forgery has internally
674    /// consistent signature math but an unpinned key. It must be
675    /// `SignerNotPinned` -- never `Valid` (loophole stays closed) and never
676    /// `Invalid` (that would be the mislabel).
677    #[test]
678    fn verify_detailed_self_signed_forgery_is_unpinned_not_invalid() {
679        let (attacker, tree) = signer_and_tree();
680        let forgery = Checkpoint::create(99, &tree, &attacker).unwrap();
681        let honest = Ed25519Signer::generate("honest_hub").unwrap();
682
683        let out = forgery.verify_detailed(&trust_with(&honest));
684        assert!(
685            matches!(out, CheckpointVerifyOutcome::SignerNotPinned { .. }),
686            "self-signed forgery (valid math, unpinned key) must be SignerNotPinned, got {out:?}"
687        );
688        assert_ne!(out, CheckpointVerifyOutcome::Valid);
689    }
690
691    /// A genuinely bad signature is `Invalid` regardless of trust, and the
692    /// signature is checked before the pin, so this reports `Invalid` even
693    /// when the signer IS pinned.
694    #[test]
695    fn verify_detailed_bad_signature_is_invalid() {
696        let (signer, tree) = signer_and_tree();
697        let mut cp = Checkpoint::create(1, &tree, &signer).unwrap();
698        // A well-formed (64-byte) but wrong signature.
699        cp.signature = URL_SAFE_NO_PAD.encode([0u8; 64]);
700        assert!(matches!(
701            cp.verify_detailed(&trust_with(&signer)),
702            CheckpointVerifyOutcome::Invalid { .. }
703        ));
704    }
705
706    /// Malformed signature encoding is `Invalid`, not a panic.
707    #[test]
708    fn verify_detailed_malformed_signature_is_invalid() {
709        let (signer, tree) = signer_and_tree();
710        let mut cp = Checkpoint::create(1, &tree, &signer).unwrap();
711        cp.signature = "not base64url!!!".into();
712        assert!(matches!(
713            cp.verify_detailed(&trust_with(&signer)),
714            CheckpointVerifyOutcome::Invalid { .. }
715        ));
716    }
717}
718
719// ---------------------------------------------------------------------------
720// v0.10.4 canonical v3 tests
721//
722// These pin the fix for the second canonical break: v0.10.3's v2 form bound
723// merkle_version but left `algorithm` and `zk_proof` wire-mutable. v3 binds
724// both, plus the canonical_version itself (to prevent downgrade-by-relabel).
725// ---------------------------------------------------------------------------
726
727#[cfg(test)]
728mod canonical_v3_tests {
729    use super::*;
730    use crate::attestation::{Ed25519Signer, Signer};
731    use crate::merkle::tree::{
732        MerkleTree, MERKLE_ALGORITHM_V2, MERKLE_VERSION_V1, MERKLE_VERSION_V2,
733    };
734    use crate::trust::{encode_ed25519_pubkey, TrustRoot, TrustRootKind, TrustRootStore};
735
736    fn signer_and_tree() -> (Ed25519Signer, MerkleTree) {
737        let mut tree = MerkleTree::new();
738        tree.append("art_alpha");
739        tree.append("art_beta");
740        let signer = Ed25519Signer::generate("key_test").unwrap();
741        (signer, tree)
742    }
743
744    fn trust_with(signer: &Ed25519Signer) -> TrustRootStore {
745        use ed25519_dalek::VerifyingKey;
746        let pk_bytes: [u8; 32] = signer.public_key_bytes().try_into().unwrap();
747        let vk = VerifyingKey::from_bytes(&pk_bytes).unwrap();
748        TrustRootStore::with_roots(vec![TrustRoot {
749            key_id: signer.key_id().to_string(),
750            public_key: encode_ed25519_pubkey(&vk),
751            kind: TrustRootKind::HubCheckpoint,
752            label: "trusted hub".into(),
753            added_at: "2026-05-15T00:00:00Z".into(),
754        }])
755    }
756
757    fn sample_zk_proof() -> ChainProofSummary {
758        ChainProofSummary {
759            image_id: "sha256:beef".into(),
760            all_signatures_valid: true,
761            chain_intact: true,
762            approval_nonces_matched: true,
763            artifact_count: 7,
764            public_key_digest: "sha256:cafe".into(),
765            proved_at: "2026-05-17T01:23:45Z".into(),
766        }
767    }
768
769    /// Sanity: a freshly-created checkpoint is v3.
770    #[test]
771    fn fresh_checkpoint_is_v3() {
772        let (signer, tree) = signer_and_tree();
773        let cp = Checkpoint::create(1, &tree, &signer).unwrap();
774        assert_eq!(cp.canonical_version, CANONICAL_VERSION_V3);
775        assert_eq!(cp.merkle_version, MERKLE_VERSION_V2);
776        assert!(cp.algorithm.is_some());
777    }
778
779    /// The headline v0.10.4 audit fix: mutating `algorithm` on the wire
780    /// of a v3-signed checkpoint must invalidate the signature.
781    #[test]
782    fn algorithm_tamper_detected() {
783        let (signer, tree) = signer_and_tree();
784        let trust = trust_with(&signer);
785        let mut cp = Checkpoint::create(1, &tree, &signer).unwrap();
786        assert!(cp.verify(&trust), "baseline must verify");
787
788        cp.algorithm = Some("sha256-attacker".into());
789        assert!(
790            !cp.verify(&trust),
791            "algorithm field mutation on the wire must break the v3 signature"
792        );
793
794        // Also: clearing the field to None must break it.
795        let mut cp2 = Checkpoint::create(1, &tree, &signer).unwrap();
796        cp2.algorithm = None;
797        assert!(
798            !cp2.verify(&trust),
799            "removing algorithm on the wire must break the v3 signature"
800        );
801    }
802
803    /// Same fix for `zk_proof`: an attacker attaching, swapping, or
804    /// removing a ChainProofSummary on the wire must invalidate the
805    /// signature.
806    #[test]
807    fn zk_proof_tamper_detected() {
808        let (signer, tree) = signer_and_tree();
809        let trust = trust_with(&signer);
810
811        // Case A: attacker attaches a fabricated proof to a checkpoint
812        // that was signed with zk_proof: None.
813        let mut cp_attach = Checkpoint::create(1, &tree, &signer).unwrap();
814        assert!(
815            cp_attach.zk_proof.is_none(),
816            "fresh checkpoint must have no proof"
817        );
818        cp_attach.zk_proof = Some(sample_zk_proof());
819        assert!(
820            !cp_attach.verify(&trust),
821            "attaching a zk_proof on the wire must break the v3 signature"
822        );
823
824        // Case B: sign a checkpoint, then mutate a field inside the
825        // proof on the wire. Needs a small re-sign helper because
826        // Checkpoint::create only sets zk_proof to None.
827        let (signer_b, tree_b) = signer_and_tree();
828        let trust_b = trust_with(&signer_b);
829        let mut cp_swap =
830            checkpoint_signed_with_proof(&signer_b, &tree_b, 1, Some(sample_zk_proof()));
831        assert!(
832            cp_swap.verify(&trust_b),
833            "freshly signed v3+proof must verify"
834        );
835
836        // Mutate one field on the embedded proof.
837        let mut tampered = sample_zk_proof();
838        tampered.chain_intact = false;
839        cp_swap.zk_proof = Some(tampered);
840        assert!(
841            !cp_swap.verify(&trust_b),
842            "mutating a zk_proof field on the wire must break the v3 signature"
843        );
844
845        // Case C: strip the proof entirely.
846        let mut cp_strip =
847            checkpoint_signed_with_proof(&signer_b, &tree_b, 1, Some(sample_zk_proof()));
848        cp_strip.zk_proof = None;
849        assert!(
850            !cp_strip.verify(&trust_b),
851            "stripping zk_proof on the wire must break the v3 signature"
852        );
853    }
854
855    /// v0.10.3-era v2 checkpoints (no canonical_version field on disk;
856    /// algorithm present, zk_proof absent) must continue to verify under
857    /// v0.10.4 code. This is the legacy-compat guarantee.
858    #[test]
859    fn v2_legacy_checkpoint_still_verifies() {
860        let (signer, tree) = signer_and_tree();
861        let trust = trust_with(&signer);
862
863        let cp_v2 = sign_legacy_v2(&signer, &tree, 1);
864        assert_eq!(cp_v2.canonical_version, CANONICAL_VERSION_V2);
865        assert_eq!(cp_v2.merkle_version, MERKLE_VERSION_V2);
866        assert!(
867            cp_v2.verify(&trust),
868            "v0.10.3-era v2-canonical checkpoint must still verify"
869        );
870
871        // And the wire form (no canonical_version field at all) round-trips
872        // through #[serde(default)] back to canonical_version: 2.
873        let mut json = serde_json::to_value(&cp_v2).unwrap();
874        json.as_object_mut().unwrap().remove("canonical_version");
875        let reparsed: Checkpoint = serde_json::from_value(json).unwrap();
876        assert_eq!(reparsed.canonical_version, CANONICAL_VERSION_V2);
877        assert!(
878            reparsed.verify(&trust),
879            "v2 checkpoint deserialized without canonical_version field must verify"
880        );
881    }
882
883    /// Pre-v0.10.3 v1 checkpoints (legacy hashing, no canonical tag,
884    /// no merkle_version on the wire) must continue to verify under
885    /// v0.10.4 code.
886    #[test]
887    fn v1_legacy_checkpoint_still_verifies() {
888        let signer = Ed25519Signer::generate("legacy_key").unwrap();
889        let trust = trust_with(&signer);
890
891        // Build a v1 tree so the canonical dispatch forces the legacy
892        // form. canonical_version field is informational only for v1.
893        let cp_v1 = sign_legacy_v1(&signer, 99, "sha256:legacy_root", 4, 2);
894        assert_eq!(cp_v1.merkle_version, MERKLE_VERSION_V1);
895        assert!(
896            cp_v1.verify(&trust),
897            "pre-v0.10.3 v1-canonical checkpoint must still verify"
898        );
899
900        // And the wire form without the v1-vintage missing-fields still
901        // round-trips and verifies.
902        let mut json = serde_json::to_value(&cp_v1).unwrap();
903        json.as_object_mut().unwrap().remove("canonical_version");
904        json.as_object_mut().unwrap().remove("merkle_version");
905        json.as_object_mut().unwrap().remove("algorithm");
906        let reparsed: Checkpoint = serde_json::from_value(json).unwrap();
907        assert_eq!(reparsed.merkle_version, MERKLE_VERSION_V1);
908        assert!(
909            reparsed.verify(&trust),
910            "pre-v0.10.3 v1 checkpoint stripped of new fields must verify"
911        );
912    }
913
914    /// Cross-version downgrade: an attacker takes a legitimately
915    /// v3-signed checkpoint, relabels it as canonical_version: 2 on
916    /// the wire (and strips the new bindings to make the v2 canonical
917    /// reproducible), and tries to verify. Must fail — the signature
918    /// covers v3-canonical bytes, not v2-canonical bytes.
919    #[test]
920    fn cross_version_downgrade_v3_to_v2_rejected() {
921        let (signer, tree) = signer_and_tree();
922        let trust = trust_with(&signer);
923        let mut cp = Checkpoint::create(1, &tree, &signer).unwrap();
924        assert_eq!(cp.canonical_version, CANONICAL_VERSION_V3);
925        assert!(cp.verify(&trust), "baseline v3 must verify");
926
927        // Attacker downgrade: flip the canonical_version tag.
928        cp.canonical_version = CANONICAL_VERSION_V2;
929        assert!(
930            !cp.verify(&trust),
931            "v3->v2 canonical_version downgrade must fail (signature covers v3 bytes)"
932        );
933
934        // And the attacker can't recover by also stripping algorithm
935        // (since v2 doesn't bind it, they might hope the v2 canonical
936        // matches the original v3 signature anyway — it must not).
937        let (signer2, tree2) = signer_and_tree();
938        let trust2 = trust_with(&signer2);
939        let mut cp2 = Checkpoint::create(1, &tree2, &signer2).unwrap();
940        cp2.canonical_version = CANONICAL_VERSION_V2;
941        cp2.algorithm = None;
942        assert!(
943            !cp2.verify(&trust2),
944            "v3->v2 downgrade + strip algorithm must still fail"
945        );
946    }
947
948    /// Unknown canonical_version (a future format this verifier doesn't
949    /// understand) must fail closed.
950    #[test]
951    fn unknown_canonical_version_rejected() {
952        let (signer, tree) = signer_and_tree();
953        let trust = trust_with(&signer);
954        let mut cp = Checkpoint::create(1, &tree, &signer).unwrap();
955        cp.canonical_version = 99;
956        assert!(
957            !cp.verify(&trust),
958            "unknown canonical_version must fail closed (no silent fallback)"
959        );
960    }
961
962    // ── test helpers ─────────────────────────────────────────────────
963
964    /// Sign a v3 checkpoint with a chosen zk_proof. Mirrors
965    /// `Checkpoint::create` but lets the test supply zk_proof so it
966    /// can be tampered with after the fact.
967    fn checkpoint_signed_with_proof(
968        signer: &Ed25519Signer,
969        tree: &MerkleTree,
970        index: u64,
971        zk_proof: Option<ChainProofSummary>,
972    ) -> Checkpoint {
973        let root_bytes = tree.root().expect("non-empty tree");
974        let root = format!("sha256:{}", hex::encode(root_bytes));
975        let signed_at = "2026-05-17T00:00:00Z".to_string();
976        let algorithm = Some(MERKLE_ALGORITHM_V2.to_string());
977
978        let canonical = Checkpoint::canonical_for_signing(
979            CANONICAL_VERSION_V3,
980            tree.version(),
981            algorithm.as_deref(),
982            zk_proof.as_ref(),
983            index,
984            &root,
985            tree.len(),
986            tree.height(),
987            signer.key_id(),
988            &signed_at,
989        );
990        let sig_bytes = signer.sign(canonical.as_bytes()).unwrap();
991        Checkpoint {
992            index,
993            root,
994            tree_size: tree.len(),
995            height: tree.height(),
996            signed_at,
997            signer: signer.key_id().to_string(),
998            public_key: URL_SAFE_NO_PAD.encode(signer.public_key_bytes()),
999            signature: URL_SAFE_NO_PAD.encode(&sig_bytes),
1000            algorithm,
1001            merkle_version: tree.version(),
1002            zk_proof,
1003            canonical_version: CANONICAL_VERSION_V3,
1004        }
1005    }
1006
1007    /// Sign a checkpoint under the v0.10.3-era v2 canonical (no
1008    /// algorithm/zk_proof binding, no canonical_version on the wire).
1009    /// Used to verify legacy compat.
1010    fn sign_legacy_v2(signer: &Ed25519Signer, tree: &MerkleTree, index: u64) -> Checkpoint {
1011        let root_bytes = tree.root().expect("non-empty tree");
1012        let root = format!("sha256:{}", hex::encode(root_bytes));
1013        let signed_at = "2026-05-17T00:00:00Z".to_string();
1014
1015        // Reproduce the v0.10.3 v2 canonical byte-for-byte. Note: in v2
1016        // the canonical function takes neither algorithm nor zk_proof.
1017        let canonical = Checkpoint::canonical_for_signing(
1018            CANONICAL_VERSION_V2,
1019            tree.version(),
1020            None, // ignored under v2 dispatch
1021            None, // ignored under v2 dispatch
1022            index,
1023            &root,
1024            tree.len(),
1025            tree.height(),
1026            signer.key_id(),
1027            &signed_at,
1028        );
1029        // Sanity: v2 canonical must NOT include algorithm even if
1030        // we passed Some() here — the v2 branch ignores it.
1031        assert!(canonical.starts_with("v2|"));
1032
1033        let sig_bytes = signer.sign(canonical.as_bytes()).unwrap();
1034        Checkpoint {
1035            index,
1036            root,
1037            tree_size: tree.len(),
1038            height: tree.height(),
1039            signed_at,
1040            signer: signer.key_id().to_string(),
1041            public_key: URL_SAFE_NO_PAD.encode(signer.public_key_bytes()),
1042            signature: URL_SAFE_NO_PAD.encode(&sig_bytes),
1043            // v0.10.3-era checkpoints had algorithm present even though
1044            // it wasn't bound — that's the on-wire shape we need to
1045            // reproduce.
1046            algorithm: Some(MERKLE_ALGORITHM_V2.to_string()),
1047            merkle_version: MERKLE_VERSION_V2,
1048            zk_proof: None,
1049            canonical_version: CANONICAL_VERSION_V2,
1050        }
1051    }
1052
1053    /// Sign a pre-v0.10.3 v1 checkpoint using the bare legacy canonical.
1054    /// The tree must NOT be exercised through MerkleTree::new (which is
1055    /// v2 by default); instead we construct the canonical directly.
1056    fn sign_legacy_v1(
1057        signer: &Ed25519Signer,
1058        index: u64,
1059        root: &str,
1060        tree_size: usize,
1061        height: usize,
1062    ) -> Checkpoint {
1063        let signed_at = "2026-04-01T00:00:00Z".to_string();
1064        // Bare legacy canonical.
1065        let canonical = Checkpoint::canonical_for_signing(
1066            CANONICAL_VERSION_V1,
1067            MERKLE_VERSION_V1,
1068            None,
1069            None,
1070            index,
1071            root,
1072            tree_size,
1073            height,
1074            signer.key_id(),
1075            &signed_at,
1076        );
1077        assert_eq!(
1078            canonical,
1079            format!(
1080                "{}|{}|{}|{}|{}|{}",
1081                index,
1082                root,
1083                tree_size,
1084                height,
1085                signer.key_id(),
1086                signed_at
1087            ),
1088            "v1 canonical must remain byte-identical to legacy"
1089        );
1090
1091        let sig_bytes = signer.sign(canonical.as_bytes()).unwrap();
1092        Checkpoint {
1093            index,
1094            root: root.to_string(),
1095            tree_size,
1096            height,
1097            signed_at,
1098            signer: signer.key_id().to_string(),
1099            public_key: URL_SAFE_NO_PAD.encode(signer.public_key_bytes()),
1100            signature: URL_SAFE_NO_PAD.encode(&sig_bytes),
1101            algorithm: None,
1102            merkle_version: MERKLE_VERSION_V1,
1103            zk_proof: None,
1104            // Pre-v0.10.4 checkpoints have no canonical_version on the
1105            // wire; serde would default it to 2, but merkle_version == 1
1106            // forces v1 dispatch anyway. We set it to 1 here for clarity.
1107            canonical_version: CANONICAL_VERSION_V1,
1108        }
1109    }
1110}