Skip to main content

metamorphic_log/checkpoint/
mod.rs

1//! C2SP [`tlog-checkpoint`] signed tree heads (over the [`crate::note`]
2//! substrate).
3//!
4//! A *checkpoint* is a [signed note](crate::note) whose text is a precisely
5//! formatted Merkle tree head. The note text is at least three newline-separated
6//! lines:
7//!
8//! ```text
9//! <origin>\n          (1) unique log identity, non-empty
10//! <tree size>\n       (2) ASCII decimal leaf count, no leading zeroes
11//! <base64 root hash>\n (3) base64 of the RFC 6962 root at that size
12//! [<extension line>\n] (4) optional, opaque, non-empty (NOT RECOMMENDED)
13//! ```
14//!
15//! This module parses/serializes that body byte-for-byte and wires it to the
16//! Slice-1 proof verifier: a consistency walk between two checkpoints uses
17//! [`crate::proof::verify_consistency`], and an inclusion check against a
18//! checkpoint uses [`crate::proof::verify_inclusion`]. Checkpoints are designed
19//! for external witness co-signing from day one — verification of the signature
20//! lines is handled by [`crate::note::SignedNote::verify`]. A checkpoint can
21//! carry **both** a classical Ed25519 line (so the C2SP witness network can
22//! recompute and co-sign) **and** an additive hybrid post-quantum composite line
23//! (so our own verifiers/monitors get PQ authenticity); a verifier accepts any
24//! mix of trusted [`crate::note::VerifierKey`] types.
25//!
26//! [`tlog-checkpoint`]: https://c2sp.org/tlog-checkpoint
27
28use 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/// A parsed checkpoint (signed-tree-head body).
35#[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    /// Build a checkpoint with no extension lines.
45    ///
46    /// # Errors
47    /// Returns [`Error::MalformedCheckpoint`] if `origin` is empty or contains a
48    /// newline.
49    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    /// Build a checkpoint with explicit extension lines.
54    ///
55    /// # Errors
56    /// Returns [`Error::MalformedCheckpoint`] if `origin` is empty/contains a
57    /// newline, or any extension line is empty or contains a newline.
58    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    /// The log origin (identity) line.
85    #[must_use]
86    pub fn origin(&self) -> &str {
87        &self.origin
88    }
89
90    /// The tree size (number of leaves).
91    #[must_use]
92    pub fn size(&self) -> u64 {
93        self.size
94    }
95
96    /// The RFC 6962 root hash at this tree size.
97    #[must_use]
98    pub fn root_hash(&self) -> &Hash {
99        &self.root_hash
100    }
101
102    /// The opaque extension lines (usually empty).
103    #[must_use]
104    pub fn extensions(&self) -> &[String] {
105        &self.extensions
106    }
107
108    /// Parse a checkpoint body (the note text), byte-for-byte per the spec.
109    ///
110    /// # Errors
111    /// Returns [`Error::MalformedCheckpoint`] for a missing/empty origin, a
112    /// non-decimal or leading-zero size, a root hash that is not exactly 32
113    /// bytes once base64-decoded, fewer than three lines, or an empty extension
114    /// line.
115    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        // Tree size: ASCII decimal, no leading zeroes (unless exactly "0").
132        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    /// Serialize the checkpoint body (the note text), ending in a newline.
173    #[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    /// Parse and verify a full signed checkpoint, returning the checkpoint body.
190    ///
191    /// The `msg` is the complete signed note (body + blank line + signature
192    /// lines). It is verified against `trusted` (at least one trusted signature
193    /// — e.g. the log's Ed25519 key, or a witness co-signature — must verify),
194    /// then its body is parsed as a checkpoint.
195    ///
196    /// # Errors
197    /// Propagates [`crate::note::SignedNote::parse`] / `verify` errors and
198    /// [`Checkpoint::parse`] errors.
199    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    /// Verify that `leaf_hash` is included at `leaf_index` in the tree committed
206    /// by this checkpoint, using the Slice-1 RFC 6962/9162 verifier.
207    ///
208    /// # Errors
209    /// Propagates [`crate::proof::verify_inclusion`] errors (index out of range,
210    /// wrong proof size, hash-length, or root mismatch).
211    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    /// Verify that this (older) checkpoint is consistent with a `newer` one —
221    /// i.e. the newer tree is an append-only extension — using the Slice-1
222    /// RFC 6962/9162 consistency verifier. This is the anti-equivocation walk a
223    /// monitor performs across checkpoints.
224    ///
225    /// # Errors
226    /// Propagates [`crate::proof::verify_consistency`] errors, including a root
227    /// mismatch if the proof does not bind both tree heads.
228    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    /// The canonical checkpoint body from the tlog-checkpoint spec.
246    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        // Round-trips byte-for-byte.
256        assert_eq!(cp.marshal(), SPEC_BODY);
257    }
258
259    #[test]
260    fn rejects_malformed_bodies() {
261        assert!(Checkpoint::parse("origin\n").is_err()); // too few lines
262        assert!(Checkpoint::parse("origin\n01\nAAAA\n").is_err()); // leading zero size
263        assert!(Checkpoint::parse("origin\nxx\nAAAA\n").is_err()); // non-decimal size
264        // Root hash wrong length (4 bytes, not 32).
265        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(&note.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        // Inclusion of leaf 3 against the size-8 checkpoint.
308        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        // Grow the tree and check consistency older -> newer.
317        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        // The log signs the SAME checkpoint body with a classical Ed25519 key
339        // (witness-compatible) and an additive hybrid PQ composite key.
340        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        // A classical-only witness verifies and recomputes from the Ed25519 line.
352        let parsed_classical =
353            Checkpoint::from_signed_note(&note.marshal(), std::slice::from_ref(&ed_vkey)).unwrap();
354        assert_eq!(parsed_classical, cp);
355
356        // A PQ-aware verifier with both trusted keys verifies the full set.
357        let parsed_pq = Checkpoint::from_signed_note(&note.marshal(), &[ed_vkey, pq_vkey]).unwrap();
358        assert_eq!(parsed_pq, cp);
359    }
360}