1use 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
52pub 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 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 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 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 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}