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}