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    pub fn encode(&self) -> String {
73        format!(
74            "{}\n{}\n{}\n",
75            self.origin,
76            self.tree_size,
77            STANDARD.encode(self.root_hash)
78        )
79    }
80
81    /// Strict parse of a body (typically one returned by `verify_note`).
82    ///
83    /// Line 1: a valid origin per the note key-name rules. Line 2: ASCII
84    /// decimal `u64`, no leading zeros unless the value is `"0"`, no sign,
85    /// no whitespace. Line 3: exactly 44 base64 characters decoding
86    /// canonically to 32 bytes. Extension lines (non-empty lines after
87    /// line 3) are tolerated and discarded, per the C2SP spec; an empty
88    /// line anywhere is malformed (bodies cannot contain blank lines — see
89    /// [`super::note::sign_note`]).
90    ///
91    /// # Errors
92    ///
93    /// Returns [`TrustError::CheckpointParsing`] on any violation,
94    /// including a body that does not end with `'\n'`.
95    pub fn parse(text: &str) -> TrustResult<Self> {
96        let Some(without_final_newline) = text.strip_suffix('\n') else {
97            return Err(TrustError::CheckpointParsing {
98                reason: "checkpoint body must end with a newline".to_string(),
99            });
100        };
101        let lines: Vec<&str> = without_final_newline.split('\n').collect();
102        if lines.len() < 3 {
103            return Err(TrustError::CheckpointParsing {
104                reason: format!(
105                    "checkpoint body must have at least 3 lines, got {}",
106                    lines.len()
107                ),
108            });
109        }
110        if lines.iter().any(|line| line.is_empty()) {
111            return Err(TrustError::CheckpointParsing {
112                reason: "checkpoint body must not contain empty lines".to_string(),
113            });
114        }
115        let origin = lines[0];
116        validate_note_name(origin).map_err(|e| TrustError::CheckpointParsing {
117            reason: format!("invalid checkpoint origin: {e}"),
118        })?;
119        let tree_size = parse_tree_size(lines[1])?;
120        let root_hash = parse_root_line(lines[2])?;
121        // Lines beyond the third are extension lines: tolerated, discarded.
122        Ok(Self {
123            origin: origin.to_string(),
124            tree_size,
125            root_hash,
126        })
127    }
128
129    /// The log's origin (its unique identity, and the note key name).
130    pub fn origin(&self) -> &str {
131        &self.origin
132    }
133
134    /// The tree size this checkpoint commits to.
135    pub fn tree_size(&self) -> u64 {
136        self.tree_size
137    }
138
139    /// The 32-byte RFC 6962 root hash this checkpoint commits to.
140    pub fn root_hash(&self) -> [u8; 32] {
141        self.root_hash
142    }
143
144    /// Bridges into the existing merkle verification helpers:
145    /// `RootHash::from_parts(self.root_hash, self.tree_size)`.
146    pub fn to_root(&self) -> RootHash {
147        RootHash::from_parts(self.root_hash, self.tree_size)
148    }
149}
150
151/// Parses a strict ASCII-decimal `u64` tree size: digits only, no sign, no
152/// whitespace, and no leading zeros unless the value is exactly `"0"`.
153fn parse_tree_size(line: &str) -> TrustResult<u64> {
154    if line.is_empty() || !line.bytes().all(|b| b.is_ascii_digit()) {
155        return Err(TrustError::CheckpointParsing {
156            reason: "tree size must be ASCII decimal digits only".to_string(),
157        });
158    }
159    if line.len() > 1 && line.starts_with('0') {
160        return Err(TrustError::CheckpointParsing {
161            reason: "tree size must not have leading zeros".to_string(),
162        });
163    }
164    line.parse::<u64>()
165        .map_err(|e| TrustError::CheckpointParsing {
166            reason: format!("tree size does not fit in u64: {e}"),
167        })
168}
169
170/// Parses the root-hash line: exactly 44 standard-base64 characters
171/// decoding canonically to 32 bytes.
172fn parse_root_line(line: &str) -> TrustResult<[u8; 32]> {
173    if line.len() != 44 {
174        return Err(TrustError::CheckpointParsing {
175            reason: format!(
176                "root hash line must be exactly 44 base64 characters, got {}",
177                line.len()
178            ),
179        });
180    }
181    let decoded = STANDARD
182        .decode(line)
183        .map_err(|e| TrustError::CheckpointParsing {
184            reason: format!("root hash line is not canonical standard base64: {e}"),
185        })?;
186    let root: [u8; 32] =
187        decoded
188            .as_slice()
189            .try_into()
190            .map_err(|_err| TrustError::CheckpointParsing {
191                reason: format!("root hash must decode to 32 bytes, got {}", decoded.len()),
192            })?;
193    Ok(root)
194}
195
196#[cfg(test)]
197#[path = "body_tests.rs"]
198mod tests;