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};
4
5use crate::attestation::{Signer, SignerError};
6use crate::statements::unix_to_rfc3339;
7use crate::trust::{TrustRootKind, TrustRootStore};
8
9use super::tree::{MerkleTree, MERKLE_VERSION_V1};
10
11/// A signed snapshot of the Merkle tree at a point in time.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct Checkpoint {
14    pub index: u64,
15    /// Root hash in `sha256:<hex>` format.
16    pub root: String,
17    pub tree_size: usize,
18    pub height: usize,
19    /// RFC 3339 timestamp.
20    pub signed_at: String,
21    /// Key ID of the signer.
22    pub signer: String,
23    /// Base64url-encoded public key bytes.
24    pub public_key: String,
25    /// Base64url-encoded Ed25519 signature of the canonical form.
26    pub signature: String,
27    /// Merkle algorithm used. Missing = v1 (sha256-duplicate-last).
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub algorithm: Option<String>,
30    /// Merkle format version byte (RFC 9162 domain separation). Absent
31    /// on v0.10.2-and-earlier checkpoints — `#[serde(default)]` fills it
32    /// with `1` so legacy checkpoints continue to verify under v1
33    /// hashing. New checkpoints emit `2`.
34    #[serde(default = "super::tree::default_merkle_version_v1")]
35    pub merkle_version: u8,
36    /// Optional ZK chain proof result (added when proof is ready).
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub zk_proof: Option<ChainProofSummary>,
39}
40
41/// Summary of a RISC Zero chain proof, embedded in a Merkle checkpoint.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct ChainProofSummary {
44    pub image_id: String,
45    pub all_signatures_valid: bool,
46    pub chain_intact: bool,
47    pub approval_nonces_matched: bool,
48    pub artifact_count: u64,
49    pub public_key_digest: String,
50    pub proved_at: String,
51}
52
53/// Errors from checkpoint creation.
54#[derive(Debug)]
55pub enum CheckpointError {
56    EmptyTree,
57    Signing(SignerError),
58}
59
60impl std::fmt::Display for CheckpointError {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        match self {
63            Self::EmptyTree => write!(f, "cannot checkpoint an empty tree"),
64            Self::Signing(e) => write!(f, "checkpoint signing failed: {}", e),
65        }
66    }
67}
68
69impl std::error::Error for CheckpointError {}
70impl From<SignerError> for CheckpointError {
71    fn from(e: SignerError) -> Self {
72        Self::Signing(e)
73    }
74}
75
76impl Checkpoint {
77    /// Build the canonical string for signing/verification.
78    ///
79    /// Two formats coexist by design:
80    ///
81    /// * **Legacy (`merkle_version == 1`):** the original pre-v0.10.3 form,
82    ///   `"{index}|{root}|{tree_size}|{height}|{signer}|{signed_at}"`. Old
83    ///   checkpoints in the wild were signed under this exact string and
84    ///   must continue to verify byte-identically.
85    ///
86    /// * **v2 and later (`merkle_version >= 2`):** prefixed with the
87    ///   canonical-format tag and the merkle version,
88    ///   `"v2|{merkle_version}|{index}|{root}|{tree_size}|{height}|{signer}|{signed_at}"`.
89    ///   Binding `merkle_version` *into the signature* is what closes the
90    ///   downgrade vector: an attacker can no longer take a v2-signed
91    ///   checkpoint and reinterpret it as v1 to dispatch verification
92    ///   through the weaker hashing.
93    ///
94    /// The `v2|` literal is a canonical-format version (not the merkle
95    /// version). Future canonical revisions would use `v3|`, `v4|`, etc.
96    /// — keeping the merkle version negotiable independently.
97    ///
98    /// **Breaking change note:** any third-party verifier that reproduces
99    /// the canonical string outside this Rust core (e.g. a hand-rolled
100    /// JavaScript checker) must mirror this dispatch. The `verify-js`
101    /// package consumes WASM and inherits the change automatically.
102    pub(crate) fn canonical_for_signing(
103        merkle_version: u8,
104        index: u64,
105        root: &str,
106        tree_size: usize,
107        height: usize,
108        signer: &str,
109        signed_at: &str,
110    ) -> String {
111        if merkle_version == MERKLE_VERSION_V1 {
112            // Legacy format. Reproduced byte-identically so pre-v0.10.3
113            // checkpoints still verify.
114            format!(
115                "{}|{}|{}|{}|{}|{}",
116                index, root, tree_size, height, signer, signed_at,
117            )
118        } else {
119            // v2+ canonical. `v2|` is the canonical-format tag; the
120            // following field is the actual merkle version byte, bound
121            // into the signature.
122            format!(
123                "v2|{}|{}|{}|{}|{}|{}|{}",
124                merkle_version, index, root, tree_size, height, signer, signed_at,
125            )
126        }
127    }
128
129    /// Create a signed checkpoint from the current tree state.
130    ///
131    /// The canonical signing string is built by [`Self::canonical_for_signing`]
132    /// and binds `merkle_version` for v2+ trees.
133    pub fn create(
134        index: u64,
135        tree: &MerkleTree,
136        signer: &dyn Signer,
137    ) -> Result<Self, CheckpointError> {
138        let root_bytes = tree.root().ok_or(CheckpointError::EmptyTree)?;
139        let root = format!("sha256:{}", hex::encode(root_bytes));
140
141        let secs = std::time::SystemTime::now()
142            .duration_since(std::time::UNIX_EPOCH)
143            .unwrap_or_default()
144            .as_secs();
145        let signed_at = unix_to_rfc3339(secs);
146
147        let canonical = Self::canonical_for_signing(
148            tree.version(),
149            index,
150            &root,
151            tree.len(),
152            tree.height(),
153            signer.key_id(),
154            &signed_at,
155        );
156        let sig_bytes = signer.sign(canonical.as_bytes())?;
157        let signature = URL_SAFE_NO_PAD.encode(&sig_bytes);
158        let public_key = URL_SAFE_NO_PAD.encode(signer.public_key_bytes());
159
160        Ok(Self {
161            index,
162            root,
163            tree_size: tree.len(),
164            height: tree.height(),
165            signed_at,
166            signer: signer.key_id().to_string(),
167            public_key,
168            signature,
169            algorithm: Some(super::tree::MERKLE_ALGORITHM_V2.to_string()),
170            merkle_version: tree.version(),
171            zk_proof: None,
172        })
173    }
174
175    /// Verify the checkpoint signature AND require the embedded public key
176    /// to be present in `trust` under kind `HubCheckpoint`. Returns `false`
177    /// on any failure (bad encoding, wrong key size, invalid signature,
178    /// untrusted issuer, no trust configured). Never panics.
179    ///
180    /// Trust pinning is mandatory. A self-signed checkpoint (an attacker
181    /// minting their own keypair, embedding the pubkey, and signing the
182    /// canonical bytes) used to satisfy this function -- it now does not,
183    /// because `trust.contains` rejects unknown issuers.
184    pub fn verify(&self, trust: &TrustRootStore) -> bool {
185        let pub_bytes = match URL_SAFE_NO_PAD.decode(&self.public_key) {
186            Ok(b) => b,
187            Err(_) => return false,
188        };
189        let pub_array: [u8; 32] = match pub_bytes.as_slice().try_into() {
190            Ok(a) => a,
191            Err(_) => return false,
192        };
193        let vk = match VerifyingKey::from_bytes(&pub_array) {
194            Ok(k) => k,
195            Err(_) => return false,
196        };
197
198        // Trust pin: the embedded pubkey must be a configured root.
199        // An empty store or no matching root rejects -- closes the
200        // self-signed loophole.
201        if !trust.contains(&vk, TrustRootKind::HubCheckpoint) {
202            return false;
203        }
204
205        let canonical = Self::canonical_for_signing(
206            self.merkle_version,
207            self.index,
208            &self.root,
209            self.tree_size,
210            self.height,
211            &self.signer,
212            &self.signed_at,
213        );
214
215        let sig_bytes = match URL_SAFE_NO_PAD.decode(&self.signature) {
216            Ok(b) => b,
217            Err(_) => return false,
218        };
219        let sig_array: [u8; 64] = match sig_bytes.as_slice().try_into() {
220            Ok(a) => a,
221            Err(_) => return false,
222        };
223        let sig = Signature::from_bytes(&sig_array);
224
225        vk.verify(canonical.as_bytes(), &sig).is_ok()
226    }
227}
228
229// ---------------------------------------------------------------------------
230// Trust-pin tests
231// ---------------------------------------------------------------------------
232
233#[cfg(test)]
234mod trust_pin_tests {
235    use super::*;
236    use crate::attestation::{Ed25519Signer, Signer};
237    use crate::merkle::MerkleTree;
238    use crate::trust::{encode_ed25519_pubkey, TrustRoot, TrustRootKind, TrustRootStore};
239
240    fn signer_and_tree() -> (Ed25519Signer, MerkleTree) {
241        let mut tree = MerkleTree::new();
242        tree.append("art_alpha");
243        tree.append("art_beta");
244        let signer = Ed25519Signer::generate("key_test").unwrap();
245        (signer, tree)
246    }
247
248    fn trust_with(signer: &Ed25519Signer) -> TrustRootStore {
249        use ed25519_dalek::VerifyingKey;
250        let pk_bytes: [u8; 32] = signer.public_key_bytes().try_into().unwrap();
251        let vk = VerifyingKey::from_bytes(&pk_bytes).unwrap();
252        TrustRootStore::with_roots(vec![TrustRoot {
253            key_id:     signer.key_id().to_string(),
254            public_key: encode_ed25519_pubkey(&vk),
255            kind:       TrustRootKind::HubCheckpoint,
256            label:      "trusted hub".into(),
257            added_at:   "2026-05-15T00:00:00Z".into(),
258        }])
259    }
260
261    /// The headline case from the audit: a checkpoint signed by a key
262    /// the operator never trusted MUST NOT verify, even though the
263    /// signature math is internally consistent.
264    #[test]
265    fn verify_rejects_unknown_pubkey() {
266        let (signer, tree) = signer_and_tree();
267        let cp = Checkpoint::create(1, &tree, &signer).unwrap();
268
269        // Different signer's key is the only one in the store.
270        let other = Ed25519Signer::generate("other").unwrap();
271        let trust = trust_with(&other);
272
273        assert!(!cp.verify(&trust),
274                "unknown issuer must be rejected even with valid signature");
275    }
276
277    /// Happy path: the issuer is pinned, the signature math is good,
278    /// verify returns true.
279    #[test]
280    fn verify_accepts_trusted_pubkey() {
281        let (signer, tree) = signer_and_tree();
282        let cp = Checkpoint::create(1, &tree, &signer).unwrap();
283        let trust = trust_with(&signer);
284        assert!(cp.verify(&trust), "trusted issuer + good signature must verify");
285    }
286
287    /// No trust configured at all (empty store) is the operator's
288    /// fresh-install state. Verification must fail closed: a verifier
289    /// without a trust set cannot vouch for anyone.
290    #[test]
291    fn verify_rejects_with_no_trust_configured() {
292        let (signer, tree) = signer_and_tree();
293        let cp = Checkpoint::create(1, &tree, &signer).unwrap();
294        let trust = TrustRootStore::empty();
295        assert!(!cp.verify(&trust),
296                "empty trust store must reject all checkpoints");
297    }
298
299    /// Trust pinning is kind-scoped: a key trusted for AgentCert is
300    /// NOT trusted for a Merkle checkpoint. This is the firewall
301    /// between certificate issuance and journal anchoring.
302    #[test]
303    fn verify_rejects_pubkey_pinned_for_wrong_kind() {
304        let (signer, tree) = signer_and_tree();
305        let cp = Checkpoint::create(1, &tree, &signer).unwrap();
306
307        use ed25519_dalek::VerifyingKey;
308        let pk_bytes: [u8; 32] = signer.public_key_bytes().try_into().unwrap();
309        let vk = VerifyingKey::from_bytes(&pk_bytes).unwrap();
310        let mismatched = TrustRootStore::with_roots(vec![TrustRoot {
311            key_id:     signer.key_id().to_string(),
312            public_key: encode_ed25519_pubkey(&vk),
313            kind:       TrustRootKind::AgentCert, // wrong kind!
314            label:      "trusted for agent certs only".into(),
315            added_at:   "2026-05-15T00:00:00Z".into(),
316        }]);
317        assert!(!cp.verify(&mismatched),
318                "kind discrimination must keep AgentCert roots out of checkpoint trust");
319    }
320
321    /// Forge attempt -- attacker re-signs with a non-trusted key.
322    /// The signature is internally valid (sig was made over canonical
323    /// bytes by the embedded pubkey) but the pubkey is unknown to the
324    /// operator. Pre-pin this passed; post-pin it must not.
325    #[test]
326    fn verify_rejects_attacker_self_signed_forgery() {
327        // Attacker mints their own keypair, builds a checkpoint over
328        // their own canonical bytes, embeds their own pubkey, signs.
329        let (attacker_signer, tree) = signer_and_tree();
330        let forgery = Checkpoint::create(99, &tree, &attacker_signer).unwrap();
331
332        // Honest operator has trusted a DIFFERENT issuer.
333        let honest = Ed25519Signer::generate("honest_hub").unwrap();
334        let trust = trust_with(&honest);
335
336        assert!(!forgery.verify(&trust),
337                "self-signed forgery must not verify against operator's trust set");
338    }
339}