Skip to main content

sigstore_types/
checkpoint.rs

1//! Checkpoint (signed tree head) types
2//!
3//! A checkpoint represents a signed commitment to the state of a transparency log.
4//! Format specified in: <https://github.com/transparency-dev/formats/blob/main/log/README.md>
5//!
6//! # Format
7//!
8//! A checkpoint (also known as a "signed note") consists of a text header and signature lines,
9//! separated by a blank line:
10//!
11//! ```text
12//! <origin>
13//! <tree_size>
14//! <root_hash_base64>
15//! <optional_metadata>
16//!
17//! — <signer_name> <signature_base64>
18//! ```
19//!
20//! The signature lines begin with the Unicode em dash (U+2014, "—"), not an ASCII hyphen.
21//! Each base64-decoded signature consists of a 4-byte key ID followed by the signature bytes.
22
23use crate::encoding::{KeyHint, Sha256Hash, SignatureBytes};
24use crate::error::{Error, Result};
25use serde::{Deserialize, Serialize};
26
27/// A checkpoint (signed tree head) from a transparency log.
28///
29/// Also known as a "signed note" in the Go ecosystem. Contains the log state
30/// (origin, tree size, root hash) plus one or more cryptographic signatures.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub struct Checkpoint {
34    /// The origin string identifying the log (e.g., "rekor.sigstore.dev - 2605736670972794746")
35    pub origin: String,
36    /// Tree size (number of leaves/entries in the log)
37    pub tree_size: u64,
38    /// Root hash of the Merkle tree (32 bytes SHA-256)
39    pub root_hash: Sha256Hash,
40    /// Other data lines (optional extension data, e.g., "Timestamp: 1689177396617352539")
41    #[serde(default, skip_serializing_if = "Vec::is_empty")]
42    pub other_content: Vec<String>,
43    /// Signatures over the checkpoint
44    pub signatures: Vec<CheckpointSignature>,
45    /// Raw text of the checkpoint body (used for signature verification).
46    /// This is the text before the blank line separator, with trailing newline.
47    #[serde(skip)]
48    pub signed_note_text: String,
49}
50
51/// A signature on a checkpoint.
52///
53/// Each signature consists of:
54/// - A name identifying the signer (e.g., "rekor.sigstore.dev")
55/// - A 4-byte key ID (key hint) used to match the signature to a public key
56/// - The signature bytes
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "camelCase")]
59pub struct CheckpointSignature {
60    /// The name of the signer (appears after the em dash in the signature line)
61    #[serde(default, skip_serializing_if = "String::is_empty")]
62    pub name: String,
63    /// Key identifier (first 4 bytes of SHA-256 of the public key)
64    pub key_id: KeyHint,
65    /// Signature bytes
66    pub signature: SignatureBytes,
67}
68
69impl Checkpoint {
70    /// Parse a checkpoint from its text representation
71    ///
72    /// Format:
73    /// ```text
74    /// <origin>
75    /// <tree_size>
76    /// <root_hash_base64>
77    /// [other_content...]
78    ///
79    /// — <key_id_base64> <sig_base64>
80    /// [additional signatures...]
81    /// ```
82    pub fn from_text(text: &str) -> Result<Self> {
83        use base64::{engine::general_purpose::STANDARD, Engine};
84
85        if text.is_empty() {
86            return Err(Error::InvalidCheckpoint("empty checkpoint".to_string()));
87        }
88
89        // Split into checkpoint body and signatures at the blank line
90        let parts: Vec<&str> = text.split("\n\n").collect();
91        if parts.len() < 2 {
92            return Err(Error::InvalidCheckpoint(
93                "missing blank line separator".to_string(),
94            ));
95        }
96
97        let checkpoint_body = parts[0];
98        let signatures_text = parts[1];
99
100        // Store the signed note text (checkpoint body with trailing newline)
101        let signed_note_text = format!("{}\n", checkpoint_body);
102
103        let mut lines = checkpoint_body.lines();
104
105        // Parse origin
106        let origin = lines
107            .next()
108            .ok_or_else(|| Error::InvalidCheckpoint("missing origin".to_string()))?
109            .trim()
110            .to_string();
111
112        if origin.is_empty() {
113            return Err(Error::InvalidCheckpoint("empty origin".to_string()));
114        }
115
116        // Parse tree size
117        let tree_size_str = lines
118            .next()
119            .ok_or_else(|| Error::InvalidCheckpoint("missing tree size".to_string()))?
120            .trim();
121        let tree_size = tree_size_str
122            .parse()
123            .map_err(|_| Error::InvalidCheckpoint("invalid tree size".to_string()))?;
124
125        // Parse root hash
126        let root_hash_b64 = lines
127            .next()
128            .ok_or_else(|| Error::InvalidCheckpoint("missing root hash".to_string()))?
129            .trim();
130        let root_hash_bytes = STANDARD
131            .decode(root_hash_b64)
132            .map_err(|_| Error::InvalidCheckpoint("invalid root hash base64".to_string()))?;
133        let root_hash = Sha256Hash::try_from_slice(&root_hash_bytes)
134            .map_err(|e| Error::InvalidCheckpoint(format!("invalid root hash: {}", e)))?;
135
136        // Remaining lines are other content (metadata)
137        let other_content: Vec<String> = lines
138            .map(|line| line.trim().to_string())
139            .filter(|line| !line.is_empty())
140            .collect();
141
142        // Parse signatures
143        let mut signatures = Vec::new();
144        for line in signatures_text.lines() {
145            let line = line.trim();
146            if line.is_empty() {
147                continue;
148            }
149
150            // Signature line format: — <name> <base64_signature>
151            // The em dash (U+2014) is required at the start
152            if !line.starts_with('—') {
153                return Err(Error::InvalidCheckpoint(
154                    "signature line must start with em dash (U+2014)".to_string(),
155                ));
156            }
157
158            let parts: Vec<&str> = line.split_whitespace().collect();
159            if parts.len() < 3 {
160                return Err(Error::InvalidCheckpoint(
161                    "signature line must have format: — <name> <base64_signature>".to_string(),
162                ));
163            }
164
165            let name = parts[1].to_string();
166            let key_and_sig_b64 = parts[2];
167
168            let decoded = STANDARD
169                .decode(key_and_sig_b64)
170                .map_err(|_| Error::InvalidCheckpoint("invalid signature base64".to_string()))?;
171
172            if decoded.len() < 5 {
173                return Err(Error::InvalidCheckpoint(
174                    "signature too short (must be at least 5 bytes for key_id + signature)"
175                        .to_string(),
176                ));
177            }
178
179            let key_id = KeyHint::try_from_slice(&decoded[..4])?;
180            let signature = SignatureBytes::new(decoded[4..].to_vec());
181
182            signatures.push(CheckpointSignature {
183                name,
184                key_id,
185                signature,
186            });
187        }
188
189        if signatures.is_empty() {
190            return Err(Error::InvalidCheckpoint("no signatures found".to_string()));
191        }
192
193        Ok(Checkpoint {
194            origin,
195            tree_size,
196            root_hash,
197            other_content,
198            signatures,
199            signed_note_text,
200        })
201    }
202
203    /// Encode the checkpoint to its text representation (without signatures).
204    ///
205    /// This returns the signed note body that can be used for signature verification.
206    pub fn to_signed_note_body(&self) -> String {
207        let mut result = format!(
208            "{}\n{}\n{}\n",
209            self.origin,
210            self.tree_size,
211            self.root_hash.to_base64()
212        );
213
214        for line in &self.other_content {
215            result.push_str(line);
216            result.push('\n');
217        }
218
219        result
220    }
221
222    /// Find a signature matching the given key hint (key ID).
223    ///
224    /// The key hint is the first 4 bytes of SHA-256(public_key_der).
225    /// Returns the signature if found, or None if no matching signature exists.
226    pub fn find_signature_by_key_hint(&self, key_hint: &KeyHint) -> Option<&CheckpointSignature> {
227        self.signatures.iter().find(|sig| &sig.key_id == key_hint)
228    }
229
230    /// Get the raw signed note text for signature verification.
231    ///
232    /// This is the checkpoint body (before the blank line) with trailing newline,
233    /// which is what gets signed.
234    pub fn signed_data(&self) -> &[u8] {
235        self.signed_note_text.as_bytes()
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn test_parse_checkpoint() {
245        let checkpoint_text = "rekor.sigstore.dev - 1193050959916656506
24642591958
247npv1T/m9N8zX0jPlbh4rB51zL6GpnV9bQaXSOdzAV+s=
248
249— rekor.sigstore.dev wNI9ajBFAiEA0OP4Pv5ks5MoTTwcM0kS6HMn8gZ5fFPjT9s6vVqXgHkCIDCe5qWSdM4OXpCQ1YNP2KpLo1r/2dRfFHXkPR5h3ywe
250";
251
252        let checkpoint = Checkpoint::from_text(checkpoint_text).unwrap();
253        assert_eq!(
254            checkpoint.origin,
255            "rekor.sigstore.dev - 1193050959916656506"
256        );
257        assert_eq!(checkpoint.tree_size, 42591958);
258        assert_eq!(checkpoint.root_hash.as_bytes().len(), 32);
259        assert_eq!(checkpoint.signatures.len(), 1);
260        assert_eq!(checkpoint.signatures[0].name, "rekor.sigstore.dev");
261        assert_eq!(checkpoint.signatures[0].key_id.as_bytes().len(), 4);
262
263        // Check that signed_note_text is preserved for verification
264        assert!(!checkpoint.signed_note_text.is_empty());
265        assert!(checkpoint
266            .signed_note_text
267            .starts_with("rekor.sigstore.dev"));
268    }
269
270    #[test]
271    fn test_parse_checkpoint_with_metadata() {
272        let checkpoint_text = "rekor.sigstore.dev - 2605736670972794746
27323083062
274dauhleYK4YyAdxwwDtR0l0KnSOWZdG2bwqHftlanvcI=
275Timestamp: 1689177396617352539
276
277— rekor.sigstore.dev xNI9ajBFAiBxaGyEtxkzFLkaCSEJqFuSS3dJjEZCNiyByVs1CNVQ8gIhAOoNnXtmMtTctV2oRnSRUZAo4EWUYPK/vBsqOzAU6TMs
278";
279
280        let checkpoint = Checkpoint::from_text(checkpoint_text).unwrap();
281        assert_eq!(checkpoint.tree_size, 23083062);
282        assert_eq!(checkpoint.other_content.len(), 1);
283        assert_eq!(
284            checkpoint.other_content[0],
285            "Timestamp: 1689177396617352539"
286        );
287    }
288
289    #[test]
290    fn test_find_signature_by_key_hint() {
291        let checkpoint_text = "rekor.sigstore.dev - 1193050959916656506
29242591958
293npv1T/m9N8zX0jPlbh4rB51zL6GpnV9bQaXSOdzAV+s=
294
295— rekor.sigstore.dev wNI9ajBFAiEA0OP4Pv5ks5MoTTwcM0kS6HMn8gZ5fFPjT9s6vVqXgHkCIDCe5qWSdM4OXpCQ1YNP2KpLo1r/2dRfFHXkPR5h3ywe
296";
297
298        let checkpoint = Checkpoint::from_text(checkpoint_text).unwrap();
299        let key_hint = &checkpoint.signatures[0].key_id;
300
301        let found = checkpoint.find_signature_by_key_hint(key_hint);
302        assert!(found.is_some());
303        assert_eq!(found.unwrap().name, "rekor.sigstore.dev");
304
305        // Non-existent key hint
306        let not_found = checkpoint.find_signature_by_key_hint(&KeyHint::new([0, 0, 0, 0]));
307        assert!(not_found.is_none());
308    }
309}