sigstore_types/
checkpoint.rs1use crate::encoding::{KeyHint, Sha256Hash, SignatureBytes};
24use crate::error::{Error, Result};
25use serde::{Deserialize, Serialize};
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub struct Checkpoint {
34 pub origin: String,
36 pub tree_size: u64,
38 pub root_hash: Sha256Hash,
40 #[serde(default, skip_serializing_if = "Vec::is_empty")]
42 pub other_content: Vec<String>,
43 pub signatures: Vec<CheckpointSignature>,
45 #[serde(skip)]
48 pub signed_note_text: String,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "camelCase")]
59pub struct CheckpointSignature {
60 #[serde(default, skip_serializing_if = "String::is_empty")]
62 pub name: String,
63 pub key_id: KeyHint,
65 pub signature: SignatureBytes,
67}
68
69impl Checkpoint {
70 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 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 let signed_note_text = format!("{}\n", checkpoint_body);
102
103 let mut lines = checkpoint_body.lines();
104
105 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 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 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 let other_content: Vec<String> = lines
138 .map(|line| line.trim().to_string())
139 .filter(|line| !line.is_empty())
140 .collect();
141
142 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 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 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 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 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 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 let not_found = checkpoint.find_signature_by_key_hint(&KeyHint::new([0, 0, 0, 0]));
307 assert!(not_found.is_none());
308 }
309}