Skip to main content

vela_protocol/
checkpoint.rs

1//! v0.147: signed registry checkpoints.
2//!
3//! A checkpoint is a registry operator's signed claim that at
4//! sequence N the registry held exactly the given set of entries,
5//! summarized by a content-addressed root over the canonical
6//! entry list. Consumers verify the signature against the
7//! operator's pubkey, recompute the root from the registry they
8//! hold, and assert the two agree.
9//!
10//! Forms a chain via `previous_checkpoint`; v0.148 federation
11//! cross-checks the chain across hubs.
12//!
13//! The root is a sha256 over canonical bytes of an alphabetically-
14//! sorted list of `(vfr_id, latest_snapshot_hash,
15//! latest_event_log_hash, owner_pubkey, signature)` tuples. It is
16//! NOT a Merkle tree at v0.147; per-entry inclusion proofs would
17//! require committing to a Merkle structure and a future cycle
18//! can extend the shape if needed. The flat root is sufficient
19//! for the substrate-honest "two hubs agree on registry state"
20//! claim that v0.148 federation lands on.
21
22use serde::{Deserialize, Serialize};
23use sha2::{Digest, Sha256};
24
25use crate::registry::{Registry, RegistryEntry};
26
27pub const CHECKPOINT_SCHEMA: &str = "vela.registry_checkpoint.v0.1";
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30pub struct RegistryCheckpoint {
31    pub schema: String,
32    pub checkpoint_id: String,
33    pub hub_id: String,
34    pub sequence: u64,
35    pub entry_count: u64,
36    pub registry_root: String,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub previous_checkpoint: Option<String>,
39    pub signer_pubkey: String,
40    pub signature: String,
41    pub created_at: String,
42}
43
44#[derive(Debug, Clone)]
45pub struct CheckpointDraft {
46    pub hub_id: String,
47    pub sequence: u64,
48    pub previous_checkpoint: Option<String>,
49    pub created_at: String,
50}
51
52/// Compute the registry root: sha256 over canonical bytes of an
53/// alphabetically-sorted list of `(vfr_id, latest_snapshot_hash,
54/// latest_event_log_hash, owner_pubkey, signature)` tuples. Two
55/// hubs that hold the same entries produce the same root.
56pub fn compute_registry_root(registry: &Registry) -> Result<String, String> {
57    let mut entries: Vec<&RegistryEntry> = registry.entries.iter().collect();
58    entries.sort_by(|a, b| a.vfr_id.cmp(&b.vfr_id));
59    let summary: Vec<serde_json::Value> = entries
60        .iter()
61        .map(|e| {
62            serde_json::json!({
63                "vfr_id": e.vfr_id,
64                "latest_snapshot_hash": e.latest_snapshot_hash,
65                "latest_event_log_hash": e.latest_event_log_hash,
66                "owner_pubkey": e.owner_pubkey,
67                "signature": e.signature,
68            })
69        })
70        .collect();
71    let bytes = crate::canonical::to_canonical_bytes(&summary)
72        .map_err(|e| format!("canonicalize registry summary: {e}"))?;
73    let digest = Sha256::digest(&bytes);
74    Ok(format!("sha256:{}", hex::encode(digest)))
75}
76
77impl RegistryCheckpoint {
78    /// Build a checkpoint over a registry, signing it with the
79    /// supplied hub-operator key. The signature covers the
80    /// canonical preimage of the checkpoint body with
81    /// `signature` and `checkpoint_id` zeroed; the
82    /// `checkpoint_id` is then derived from the signed preimage
83    /// (so two checkpoints with identical body + signature share
84    /// the same id, and a tampered signature surfaces as a hash
85    /// mismatch).
86    pub fn build(
87        registry: &Registry,
88        draft: CheckpointDraft,
89        signing_key: &ed25519_dalek::SigningKey,
90    ) -> Result<Self, String> {
91        let root = compute_registry_root(registry)?;
92        let mut checkpoint = RegistryCheckpoint {
93            schema: CHECKPOINT_SCHEMA.to_string(),
94            checkpoint_id: String::new(),
95            hub_id: draft.hub_id,
96            sequence: draft.sequence,
97            entry_count: registry.entries.len() as u64,
98            registry_root: root,
99            previous_checkpoint: draft.previous_checkpoint,
100            signer_pubkey: hex::encode(signing_key.verifying_key().to_bytes()),
101            signature: String::new(),
102            created_at: draft.created_at,
103        };
104        let preimage = checkpoint.preimage_bytes()?;
105        use ed25519_dalek::Signer;
106        let sig = signing_key.sign(&preimage);
107        checkpoint.signature = hex::encode(sig.to_bytes());
108        checkpoint.checkpoint_id = checkpoint.derive_id()?;
109        Ok(checkpoint)
110    }
111
112    /// Canonical preimage bytes for the signature. Excludes
113    /// `signature` and `checkpoint_id` so the preimage is
114    /// derivable from the rest of the body alone.
115    pub fn preimage_bytes(&self) -> Result<Vec<u8>, String> {
116        let mut preimage = self.clone();
117        preimage.signature = String::new();
118        preimage.checkpoint_id = String::new();
119        crate::canonical::to_canonical_bytes(&preimage)
120            .map_err(|e| format!("canonicalize checkpoint preimage: {e}"))
121    }
122
123    /// Content-addressed id over the (signed) body, including the
124    /// signature so a tampered signature surfaces as an id
125    /// mismatch.
126    pub fn derive_id(&self) -> Result<String, String> {
127        let mut preimage = self.clone();
128        preimage.checkpoint_id = String::new();
129        let bytes = crate::canonical::to_canonical_bytes(&preimage)
130            .map_err(|e| format!("canonicalize checkpoint id preimage: {e}"))?;
131        let digest = Sha256::digest(&bytes);
132        Ok(format!("vrc_{}", &hex::encode(digest)[..16]))
133    }
134
135    /// Verify the checkpoint: re-derive the id and assert match,
136    /// re-compute the registry root and assert match, verify the
137    /// Ed25519 signature against `signer_pubkey`.
138    pub fn verify(&self, registry: &Registry) -> Result<(), String> {
139        if self.schema != CHECKPOINT_SCHEMA {
140            return Err(format!(
141                "checkpoint.schema must be `{CHECKPOINT_SCHEMA}`, got `{}`",
142                self.schema
143            ));
144        }
145        let derived_id = self.derive_id()?;
146        if derived_id != self.checkpoint_id {
147            return Err(format!(
148                "checkpoint_id mismatch: stored `{}`, derived `{}`",
149                self.checkpoint_id, derived_id
150            ));
151        }
152        let derived_root = compute_registry_root(registry)?;
153        if derived_root != self.registry_root {
154            return Err(format!(
155                "registry_root mismatch: checkpoint claims `{}`, registry hashes to `{}`",
156                self.registry_root, derived_root
157            ));
158        }
159        if self.entry_count != registry.entries.len() as u64 {
160            return Err(format!(
161                "entry_count mismatch: checkpoint claims {}, registry has {}",
162                self.entry_count,
163                registry.entries.len()
164            ));
165        }
166        let pk_bytes =
167            hex::decode(&self.signer_pubkey).map_err(|e| format!("signer_pubkey not hex: {e}"))?;
168        if pk_bytes.len() != 32 {
169            return Err(format!(
170                "signer_pubkey must be 32 bytes (got {})",
171                pk_bytes.len()
172            ));
173        }
174        let pk = ed25519_dalek::VerifyingKey::from_bytes(
175            pk_bytes
176                .as_slice()
177                .try_into()
178                .map_err(|e| format!("signer_pubkey: {e}"))?,
179        )
180        .map_err(|e| format!("signer_pubkey malformed: {e}"))?;
181        let sig_bytes =
182            hex::decode(&self.signature).map_err(|e| format!("signature not hex: {e}"))?;
183        if sig_bytes.len() != 64 {
184            return Err(format!(
185                "signature must be 64 bytes (got {})",
186                sig_bytes.len()
187            ));
188        }
189        let sig = ed25519_dalek::Signature::from_bytes(
190            sig_bytes
191                .as_slice()
192                .try_into()
193                .map_err(|e| format!("signature: {e}"))?,
194        );
195        let preimage = self.preimage_bytes()?;
196        use ed25519_dalek::Verifier;
197        pk.verify(&preimage, &sig)
198            .map_err(|e| format!("checkpoint signature does not verify: {e}"))?;
199        Ok(())
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use crate::registry::{ENTRY_SCHEMA, Registry, RegistryEntry};
207
208    fn make_entry(vfr: &str) -> RegistryEntry {
209        RegistryEntry {
210            schema: ENTRY_SCHEMA.to_string(),
211            vfr_id: vfr.to_string(),
212            name: format!("{vfr}-name"),
213            owner_actor_id: "owner".to_string(),
214            owner_pubkey: "0".repeat(64),
215            latest_snapshot_hash: format!("snap-{vfr}"),
216            latest_event_log_hash: format!("log-{vfr}"),
217            network_locator: format!("file:///{vfr}"),
218            signed_publish_at: "2026-05-11T00:00:00+00:00".to_string(),
219            signature: "f".repeat(128),
220        }
221    }
222
223    fn make_registry(vfrs: &[&str]) -> Registry {
224        Registry {
225            schema: "vela.registry.v0.1".to_string(),
226            entries: vfrs.iter().map(|v| make_entry(v)).collect(),
227        }
228    }
229
230    fn make_key() -> ed25519_dalek::SigningKey {
231        use rand::rngs::OsRng;
232        ed25519_dalek::SigningKey::generate(&mut OsRng)
233    }
234
235    #[test]
236    fn registry_root_deterministic_over_same_state() {
237        let r = make_registry(&["vfr_a", "vfr_b"]);
238        let a = compute_registry_root(&r).unwrap();
239        let b = compute_registry_root(&r).unwrap();
240        assert_eq!(a, b);
241    }
242
243    #[test]
244    fn registry_root_independent_of_entry_order() {
245        let r1 = make_registry(&["vfr_a", "vfr_b", "vfr_c"]);
246        let r2 = make_registry(&["vfr_c", "vfr_a", "vfr_b"]);
247        let a = compute_registry_root(&r1).unwrap();
248        let b = compute_registry_root(&r2).unwrap();
249        assert_eq!(a, b);
250    }
251
252    #[test]
253    fn registry_root_changes_with_entry_set() {
254        let r1 = make_registry(&["vfr_a", "vfr_b"]);
255        let r2 = make_registry(&["vfr_a", "vfr_c"]);
256        let a = compute_registry_root(&r1).unwrap();
257        let b = compute_registry_root(&r2).unwrap();
258        assert_ne!(a, b);
259    }
260
261    #[test]
262    fn checkpoint_roundtrips() {
263        let r = make_registry(&["vfr_a", "vfr_b"]);
264        let sk = make_key();
265        let cp = RegistryCheckpoint::build(
266            &r,
267            CheckpointDraft {
268                hub_id: "hub:test".to_string(),
269                sequence: 1,
270                previous_checkpoint: None,
271                created_at: "2026-05-11T00:00:00+00:00".to_string(),
272            },
273            &sk,
274        )
275        .unwrap();
276        assert!(cp.checkpoint_id.starts_with("vrc_"));
277        assert_eq!(cp.entry_count, 2);
278        cp.verify(&r).unwrap();
279    }
280
281    #[test]
282    fn checkpoint_fails_against_tampered_registry() {
283        let r1 = make_registry(&["vfr_a", "vfr_b"]);
284        let r2 = make_registry(&["vfr_a", "vfr_c"]);
285        let sk = make_key();
286        let cp = RegistryCheckpoint::build(
287            &r1,
288            CheckpointDraft {
289                hub_id: "hub:test".to_string(),
290                sequence: 1,
291                previous_checkpoint: None,
292                created_at: "2026-05-11T00:00:00+00:00".to_string(),
293            },
294            &sk,
295        )
296        .unwrap();
297        let err = cp.verify(&r2).unwrap_err();
298        assert!(err.contains("registry_root"), "got: {err}");
299    }
300
301    #[test]
302    fn checkpoint_fails_with_tampered_signature() {
303        let r = make_registry(&["vfr_a"]);
304        let sk = make_key();
305        let mut cp = RegistryCheckpoint::build(
306            &r,
307            CheckpointDraft {
308                hub_id: "hub:test".to_string(),
309                sequence: 1,
310                previous_checkpoint: None,
311                created_at: "2026-05-11T00:00:00+00:00".to_string(),
312            },
313            &sk,
314        )
315        .unwrap();
316        cp.signature = "0".repeat(128);
317        let err = cp.verify(&r).unwrap_err();
318        assert!(
319            err.contains("mismatch") || err.contains("does not verify"),
320            "got: {err}"
321        );
322    }
323}