Skip to main content

lys_core/checkpoint/
body.rs

1//! [`CheckpointBody`] — the C2SP tlog-checkpoint body, byte-exact.
2//!
3//! # Invariants
4//!
5//! - [`CheckpointBody::encode`] emits exactly three newline-terminated
6//!   lines: origin, tree size in strict ASCII decimal (no leading zeros,
7//!   no sign), and the standard-base64-with-padding encoding of the
8//!   32-byte RFC 6962 root hash. No extension lines are ever emitted.
9//! - [`CheckpointBody::parse`] is strict on those three lines and tolerates
10//!   (discards) non-empty extension lines after them, per the C2SP spec.
11//!   Empty lines are malformed — a body can never contain a blank line,
12//!   which is what keeps the signed-note envelope split unambiguous.
13//! - The origin is validated against the note key-name rules at
14//!   construction: origins double as note key names (the checkpoint is
15//!   signed under its origin), so an origin that cannot be a key name is
16//!   rejected before anything is signed under it.
17//! - encode/parse round-trip byte-exactly.
18
19use base64::Engine;
20use base64::engine::general_purpose::STANDARD;
21
22use crate::error::{TrustError, TrustResult};
23use crate::merkle::RootHash;
24
25use super::verifier_key::validate_note_name;
26
27/// C2SP tlog-checkpoint body: origin, tree size, and RFC 6962 root hash.
28///
29/// The origin is validated against note-name rules at construction — the
30/// origin doubles as the signing key name, so `lys`-emitted checkpoints
31/// sign under the origin and verifiers enforce the binding.
32#[derive(Clone, Debug, PartialEq, Eq)]
33pub struct CheckpointBody {
34    origin: String,
35    tree_size: u64,
36    root_hash: [u8; 32],
37}
38
39impl CheckpointBody {
40    /// Builds a checkpoint body, validating the origin against the note
41    /// key-name rules.
42    ///
43    /// # Errors
44    ///
45    /// Returns [`TrustError::CheckpointEncoding`] if the origin is empty,
46    /// contains whitespace, or contains `'+'`.
47    pub fn new(origin: &str, tree_size: u64, root_hash: [u8; 32]) -> TrustResult<Self> {
48        validate_note_name(origin).map_err(|e| TrustError::CheckpointEncoding {
49            reason: format!("invalid checkpoint origin: {e}"),
50        })?;
51        Ok(Self {
52            origin: origin.to_string(),
53            tree_size,
54            root_hash,
55        })
56    }
57
58    /// Convenience constructor from the existing merkle root type via
59    /// [`RootHash::to_parts`].
60    ///
61    /// # Errors
62    ///
63    /// Returns [`TrustError::CheckpointEncoding`] if the origin is invalid
64    /// (see [`Self::new`]).
65    pub fn from_root(origin: &str, root: &RootHash) -> TrustResult<Self> {
66        let (root_hash, tree_size) = root.to_parts();
67        Self::new(origin, tree_size, root_hash)
68    }
69
70    /// Encodes the body as exactly
71    /// `"{origin}\n{tree_size}\n{base64_std_padded(root_hash)}\n"`.
72    #[must_use]
73    pub fn encode(&self) -> String {
74        format!(
75            "{}\n{}\n{}\n",
76            self.origin,
77            self.tree_size,
78            STANDARD.encode(self.root_hash)
79        )
80    }
81
82    /// Strict parse of a body (typically one returned by `verify_note`).
83    ///
84    /// Line 1: a valid origin per the note key-name rules. Line 2: ASCII
85    /// decimal `u64`, no leading zeros unless the value is `"0"`, no sign,
86    /// no whitespace. Line 3: exactly 44 base64 characters decoding
87    /// canonically to 32 bytes. Extension lines (non-empty lines after
88    /// line 3) are tolerated and discarded, per the C2SP spec; an empty
89    /// line anywhere is malformed (bodies cannot contain blank lines — see
90    /// [`super::note::sign_note`]).
91    ///
92    /// # Errors
93    ///
94    /// Returns [`TrustError::CheckpointParsing`] on any violation,
95    /// including a body that does not end with `'\n'`.
96    pub fn parse(text: &str) -> TrustResult<Self> {
97        let Some(without_final_newline) = text.strip_suffix('\n') else {
98            return Err(TrustError::CheckpointParsing {
99                reason: "checkpoint body must end with a newline".to_string(),
100            });
101        };
102        let lines: Vec<&str> = without_final_newline.split('\n').collect();
103        if lines.len() < 3 {
104            return Err(TrustError::CheckpointParsing {
105                reason: format!(
106                    "checkpoint body must have at least 3 lines, got {}",
107                    lines.len()
108                ),
109            });
110        }
111        if lines.iter().any(|line| line.is_empty()) {
112            return Err(TrustError::CheckpointParsing {
113                reason: "checkpoint body must not contain empty lines".to_string(),
114            });
115        }
116        let origin = lines[0];
117        validate_note_name(origin).map_err(|e| TrustError::CheckpointParsing {
118            reason: format!("invalid checkpoint origin: {e}"),
119        })?;
120        let tree_size = parse_tree_size(lines[1])?;
121        let root_hash = parse_root_line(lines[2])?;
122        // Lines beyond the third are extension lines: tolerated, discarded.
123        Ok(Self {
124            origin: origin.to_string(),
125            tree_size,
126            root_hash,
127        })
128    }
129
130    /// The log's origin (its unique identity, and the note key name).
131    #[must_use]
132    pub fn origin(&self) -> &str {
133        &self.origin
134    }
135
136    /// The tree size this checkpoint commits to.
137    #[must_use]
138    pub fn tree_size(&self) -> u64 {
139        self.tree_size
140    }
141
142    /// The 32-byte RFC 6962 root hash this checkpoint commits to.
143    #[must_use]
144    pub fn root_hash(&self) -> [u8; 32] {
145        self.root_hash
146    }
147
148    /// Bridges into the existing merkle verification helpers:
149    /// `RootHash::from_parts(self.root_hash, self.tree_size)`.
150    #[must_use]
151    pub fn to_root(&self) -> RootHash {
152        RootHash::from_parts(self.root_hash, self.tree_size)
153    }
154}
155
156/// Parses a strict ASCII-decimal `u64` tree size: digits only, no sign, no
157/// whitespace, and no leading zeros unless the value is exactly `"0"`.
158fn parse_tree_size(line: &str) -> TrustResult<u64> {
159    if line.is_empty() || !line.bytes().all(|b| b.is_ascii_digit()) {
160        return Err(TrustError::CheckpointParsing {
161            reason: "tree size must be ASCII decimal digits only".to_string(),
162        });
163    }
164    if line.len() > 1 && line.starts_with('0') {
165        return Err(TrustError::CheckpointParsing {
166            reason: "tree size must not have leading zeros".to_string(),
167        });
168    }
169    line.parse::<u64>()
170        .map_err(|e| TrustError::CheckpointParsing {
171            reason: format!("tree size does not fit in u64: {e}"),
172        })
173}
174
175/// Parses the root-hash line: exactly 44 standard-base64 characters
176/// decoding canonically to 32 bytes.
177fn parse_root_line(line: &str) -> TrustResult<[u8; 32]> {
178    if line.len() != 44 {
179        return Err(TrustError::CheckpointParsing {
180            reason: format!(
181                "root hash line must be exactly 44 base64 characters, got {}",
182                line.len()
183            ),
184        });
185    }
186    let decoded = STANDARD
187        .decode(line)
188        .map_err(|e| TrustError::CheckpointParsing {
189            reason: format!("root hash line is not canonical standard base64: {e}"),
190        })?;
191    let root: [u8; 32] =
192        decoded
193            .as_slice()
194            .try_into()
195            .map_err(|_err| TrustError::CheckpointParsing {
196                reason: format!("root hash must decode to 32 bytes, got {}", decoded.len()),
197            })?;
198    Ok(root)
199}
200
201#[cfg(test)]
202#[path = "body_tests.rs"]
203mod tests;