metamorphic_log/checkpoint/
mod.rs1use crate::encoding::{base64_decode, base64_encode};
29use crate::error::{Error, Result};
30use crate::merkle::{HASH_LEN, Hash};
31use crate::note::{SignedNote, VerifierKey};
32use crate::proof;
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct Checkpoint {
37 origin: String,
38 size: u64,
39 root_hash: Hash,
40 extensions: Vec<String>,
41}
42
43impl Checkpoint {
44 pub fn new(origin: &str, size: u64, root_hash: Hash) -> Result<Self> {
50 Self::with_extensions(origin, size, root_hash, Vec::new())
51 }
52
53 pub fn with_extensions(
59 origin: &str,
60 size: u64,
61 root_hash: Hash,
62 extensions: Vec<String>,
63 ) -> Result<Self> {
64 if origin.is_empty() || origin.contains('\n') {
65 return Err(Error::MalformedCheckpoint(
66 "origin must be non-empty and contain no newline".into(),
67 ));
68 }
69 for ext in &extensions {
70 if ext.is_empty() || ext.contains('\n') {
71 return Err(Error::MalformedCheckpoint(
72 "extension lines must be non-empty and contain no newline".into(),
73 ));
74 }
75 }
76 Ok(Self {
77 origin: origin.to_string(),
78 size,
79 root_hash,
80 extensions,
81 })
82 }
83
84 #[must_use]
86 pub fn origin(&self) -> &str {
87 &self.origin
88 }
89
90 #[must_use]
92 pub fn size(&self) -> u64 {
93 self.size
94 }
95
96 #[must_use]
98 pub fn root_hash(&self) -> &Hash {
99 &self.root_hash
100 }
101
102 #[must_use]
104 pub fn extensions(&self) -> &[String] {
105 &self.extensions
106 }
107
108 pub fn parse(text: &str) -> Result<Self> {
116 let mut lines = text.lines();
117 let origin = lines
118 .next()
119 .ok_or_else(|| Error::MalformedCheckpoint("missing origin line".into()))?;
120 let size_str = lines
121 .next()
122 .ok_or_else(|| Error::MalformedCheckpoint("missing tree-size line".into()))?;
123 let root_b64 = lines
124 .next()
125 .ok_or_else(|| Error::MalformedCheckpoint("missing root-hash line".into()))?;
126
127 if origin.is_empty() {
128 return Err(Error::MalformedCheckpoint("empty origin line".into()));
129 }
130
131 if size_str.is_empty() || !size_str.bytes().all(|b| b.is_ascii_digit()) {
133 return Err(Error::MalformedCheckpoint(format!(
134 "tree size is not decimal: {size_str:?}"
135 )));
136 }
137 if size_str.len() > 1 && size_str.starts_with('0') {
138 return Err(Error::MalformedCheckpoint(format!(
139 "tree size has a leading zero: {size_str:?}"
140 )));
141 }
142 let size: u64 = size_str
143 .parse()
144 .map_err(|_| Error::MalformedCheckpoint(format!("tree size overflow: {size_str:?}")))?;
145
146 let root_bytes = base64_decode(root_b64).map_err(|_| {
147 Error::MalformedCheckpoint(format!("root hash is not valid base64: {root_b64:?}"))
148 })?;
149 let root_hash: Hash = root_bytes.as_slice().try_into().map_err(|_| {
150 Error::MalformedCheckpoint(format!(
151 "root hash is {} bytes, want {HASH_LEN}",
152 root_bytes.len()
153 ))
154 })?;
155
156 let mut extensions = Vec::new();
157 for ext in lines {
158 if ext.is_empty() {
159 return Err(Error::MalformedCheckpoint("empty extension line".into()));
160 }
161 extensions.push(ext.to_string());
162 }
163
164 Ok(Self {
165 origin: origin.to_string(),
166 size,
167 root_hash,
168 extensions,
169 })
170 }
171
172 #[must_use]
174 pub fn marshal(&self) -> String {
175 let mut out = String::new();
176 out.push_str(&self.origin);
177 out.push('\n');
178 out.push_str(&self.size.to_string());
179 out.push('\n');
180 out.push_str(&base64_encode(&self.root_hash));
181 out.push('\n');
182 for ext in &self.extensions {
183 out.push_str(ext);
184 out.push('\n');
185 }
186 out
187 }
188
189 pub fn from_signed_note(msg: &str, trusted: &[VerifierKey]) -> Result<Self> {
200 let note = SignedNote::parse(msg)?;
201 note.verify(trusted)?;
202 Self::parse(note.text())
203 }
204
205 pub fn verify_inclusion(
212 &self,
213 leaf_index: u64,
214 leaf_hash: &[u8],
215 proof: &[Vec<u8>],
216 ) -> Result<()> {
217 proof::verify_inclusion(leaf_index, self.size, leaf_hash, proof, &self.root_hash)
218 }
219
220 pub fn verify_consistency(&self, newer: &Checkpoint, proof: &[Vec<u8>]) -> Result<()> {
229 proof::verify_consistency(
230 self.size,
231 newer.size,
232 proof,
233 &self.root_hash,
234 &newer.root_hash,
235 )
236 }
237}
238
239#[cfg(all(test, not(target_arch = "wasm32")))]
240mod tests {
241 use super::*;
242 use crate::merkle::MerkleTree;
243 use crate::note::{sign_ed25519, sign_hybrid};
244
245 const SPEC_BODY: &str =
247 "example.com/behind-the-sofa\n20852163\nCsUYapGGPo4dkMgIAUqom/Xajj7h2fB2MPA3j2jxq2I=\n";
248
249 #[test]
250 fn parses_spec_checkpoint_body() {
251 let cp = Checkpoint::parse(SPEC_BODY).unwrap();
252 assert_eq!(cp.origin(), "example.com/behind-the-sofa");
253 assert_eq!(cp.size(), 20_852_163);
254 assert_eq!(cp.extensions().len(), 0);
255 assert_eq!(cp.marshal(), SPEC_BODY);
257 }
258
259 #[test]
260 fn rejects_malformed_bodies() {
261 assert!(Checkpoint::parse("origin\n").is_err()); assert!(Checkpoint::parse("origin\n01\nAAAA\n").is_err()); assert!(Checkpoint::parse("origin\nxx\nAAAA\n").is_err()); assert!(Checkpoint::parse("origin\n5\nAAAAAA==\n").is_err());
266 }
267
268 #[test]
269 fn extension_lines_round_trip() {
270 let root = [7u8; HASH_LEN];
271 let cp = Checkpoint::with_extensions(
272 "example.com/log",
273 42,
274 root,
275 vec!["ext one".into(), "ext two".into()],
276 )
277 .unwrap();
278 let body = cp.marshal();
279 assert_eq!(Checkpoint::parse(&body).unwrap(), cp);
280 }
281
282 #[test]
283 fn signed_checkpoint_round_trip_and_verify() {
284 let mut tree = MerkleTree::new();
285 for i in 0u32..10 {
286 tree.push(&i.to_be_bytes());
287 }
288 let cp = Checkpoint::new("origin.example/log", tree.size(), tree.root()).unwrap();
289
290 let (seed, pk) = metamorphic_crypto::ed25519_generate_keypair();
291 let sig = sign_ed25519(&cp.marshal(), "origin.example/log", &seed).unwrap();
292 let note = SignedNote::new(cp.marshal(), vec![sig]).unwrap();
293
294 let vkey = VerifierKey::new_ed25519("origin.example/log", &pk).unwrap();
295 let parsed = Checkpoint::from_signed_note(¬e.marshal(), &[vkey]).unwrap();
296 assert_eq!(parsed, cp);
297 }
298
299 #[test]
300 fn checkpoint_wires_inclusion_and_consistency() {
301 let mut tree = MerkleTree::new();
302 for i in 0u32..8 {
303 tree.push(&i.to_be_bytes());
304 }
305 let older = Checkpoint::new("o", tree.size(), tree.root()).unwrap();
306
307 let proof: Vec<Vec<u8>> = tree
309 .inclusion_proof(3, 8)
310 .into_iter()
311 .map(|h| h.to_vec())
312 .collect();
313 let leaf = tree.leaf_hash(3).unwrap();
314 older.verify_inclusion(3, &leaf, &proof).unwrap();
315
316 for i in 8u32..16 {
318 tree.push(&i.to_be_bytes());
319 }
320 let newer = Checkpoint::new("o", tree.size(), tree.root()).unwrap();
321 let cproof: Vec<Vec<u8>> = tree
322 .consistency_proof(8, 16)
323 .into_iter()
324 .map(|h| h.to_vec())
325 .collect();
326 older.verify_consistency(&newer, &cproof).unwrap();
327 }
328
329 #[test]
330 fn checkpoint_co_signed_classical_and_pq() {
331 let mut tree = MerkleTree::new();
332 for i in 0u32..10 {
333 tree.push(&i.to_be_bytes());
334 }
335 let cp = Checkpoint::new("origin.example/log", tree.size(), tree.root()).unwrap();
336 let body = cp.marshal();
337
338 let (seed, ed_pk) = metamorphic_crypto::ed25519_generate_keypair();
341 let pq_kp = metamorphic_crypto::generate_signing_keypair();
342 let pq_pk = crate::encoding::base64_decode(&pq_kp.public_key).unwrap();
343
344 let ed_sig = sign_ed25519(&body, "origin.example/log", &seed).unwrap();
345 let pq_sig = sign_hybrid(&body, "origin.example/log-pq", &pq_kp.secret_key).unwrap();
346 let note = SignedNote::new(body, vec![ed_sig, pq_sig]).unwrap();
347
348 let ed_vkey = VerifierKey::new_ed25519("origin.example/log", &ed_pk).unwrap();
349 let pq_vkey = VerifierKey::new_hybrid("origin.example/log-pq", &pq_pk).unwrap();
350
351 let parsed_classical =
353 Checkpoint::from_signed_note(¬e.marshal(), std::slice::from_ref(&ed_vkey)).unwrap();
354 assert_eq!(parsed_classical, cp);
355
356 let parsed_pq = Checkpoint::from_signed_note(¬e.marshal(), &[ed_vkey, pq_vkey]).unwrap();
358 assert_eq!(parsed_pq, cp);
359 }
360}