use crate::encoding::{base64_bytes, Sha256Hash};
use crate::error::{Error, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Checkpoint {
pub origin: String,
pub tree_size: u64,
pub root_hash: Sha256Hash,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub other_content: Vec<String>,
pub signatures: Vec<CheckpointSignature>,
#[serde(skip)]
pub signed_note_text: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CheckpointSignature {
#[serde(default, skip_serializing_if = "String::is_empty")]
pub name: String,
#[serde(with = "base64_bytes")]
pub key_id: Vec<u8>,
#[serde(with = "base64_bytes")]
pub signature: Vec<u8>,
}
impl Checkpoint {
pub fn from_text(text: &str) -> Result<Self> {
use base64::{engine::general_purpose::STANDARD, Engine};
if text.is_empty() {
return Err(Error::InvalidCheckpoint("empty checkpoint".to_string()));
}
let parts: Vec<&str> = text.split("\n\n").collect();
if parts.len() < 2 {
return Err(Error::InvalidCheckpoint(
"missing blank line separator".to_string(),
));
}
let checkpoint_body = parts[0];
let signatures_text = parts[1];
let signed_note_text = format!("{}\n", checkpoint_body);
let mut lines = checkpoint_body.lines();
let origin = lines
.next()
.ok_or_else(|| Error::InvalidCheckpoint("missing origin".to_string()))?
.trim()
.to_string();
if origin.is_empty() {
return Err(Error::InvalidCheckpoint("empty origin".to_string()));
}
let tree_size_str = lines
.next()
.ok_or_else(|| Error::InvalidCheckpoint("missing tree size".to_string()))?
.trim();
let tree_size = tree_size_str
.parse()
.map_err(|_| Error::InvalidCheckpoint("invalid tree size".to_string()))?;
let root_hash_b64 = lines
.next()
.ok_or_else(|| Error::InvalidCheckpoint("missing root hash".to_string()))?
.trim();
let root_hash_bytes = STANDARD
.decode(root_hash_b64)
.map_err(|_| Error::InvalidCheckpoint("invalid root hash base64".to_string()))?;
let root_hash = Sha256Hash::try_from_slice(&root_hash_bytes)
.map_err(|e| Error::InvalidCheckpoint(format!("invalid root hash: {}", e)))?;
let other_content: Vec<String> = lines
.map(|line| line.trim().to_string())
.filter(|line| !line.is_empty())
.collect();
let mut signatures = Vec::new();
for line in signatures_text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if !line.starts_with('—') {
return Err(Error::InvalidCheckpoint(
"signature line must start with em dash (U+2014)".to_string(),
));
}
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 3 {
return Err(Error::InvalidCheckpoint(
"signature line must have format: — <name> <base64_signature>".to_string(),
));
}
let name = parts[1].to_string();
let key_and_sig_b64 = parts[2];
let decoded = STANDARD
.decode(key_and_sig_b64)
.map_err(|_| Error::InvalidCheckpoint("invalid signature base64".to_string()))?;
if decoded.len() < 5 {
return Err(Error::InvalidCheckpoint(
"signature too short (must be at least 5 bytes for key_id + signature)"
.to_string(),
));
}
let key_id = decoded[..4].to_vec();
let signature = decoded[4..].to_vec();
signatures.push(CheckpointSignature {
name,
key_id,
signature,
});
}
if signatures.is_empty() {
return Err(Error::InvalidCheckpoint("no signatures found".to_string()));
}
Ok(Checkpoint {
origin,
tree_size,
root_hash,
other_content,
signatures,
signed_note_text,
})
}
pub fn to_signed_note_body(&self) -> String {
let mut result = format!(
"{}\n{}\n{}\n",
self.origin,
self.tree_size,
self.root_hash.to_base64()
);
for line in &self.other_content {
result.push_str(line);
result.push('\n');
}
result
}
pub fn find_signature_by_key_hint(&self, key_hint: &[u8]) -> Option<&CheckpointSignature> {
self.signatures
.iter()
.find(|sig| sig.key_id.as_slice() == key_hint)
}
pub fn signed_data(&self) -> &[u8] {
self.signed_note_text.as_bytes()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_checkpoint() {
let checkpoint_text = "rekor.sigstore.dev - 1193050959916656506
42591958
npv1T/m9N8zX0jPlbh4rB51zL6GpnV9bQaXSOdzAV+s=
— rekor.sigstore.dev wNI9ajBFAiEA0OP4Pv5ks5MoTTwcM0kS6HMn8gZ5fFPjT9s6vVqXgHkCIDCe5qWSdM4OXpCQ1YNP2KpLo1r/2dRfFHXkPR5h3ywe
";
let checkpoint = Checkpoint::from_text(checkpoint_text).unwrap();
assert_eq!(
checkpoint.origin,
"rekor.sigstore.dev - 1193050959916656506"
);
assert_eq!(checkpoint.tree_size, 42591958);
assert_eq!(checkpoint.root_hash.as_bytes().len(), 32);
assert_eq!(checkpoint.signatures.len(), 1);
assert_eq!(checkpoint.signatures[0].name, "rekor.sigstore.dev");
assert_eq!(checkpoint.signatures[0].key_id.len(), 4);
assert!(!checkpoint.signed_note_text.is_empty());
assert!(checkpoint
.signed_note_text
.starts_with("rekor.sigstore.dev"));
}
#[test]
fn test_parse_checkpoint_with_metadata() {
let checkpoint_text = "rekor.sigstore.dev - 2605736670972794746
23083062
dauhleYK4YyAdxwwDtR0l0KnSOWZdG2bwqHftlanvcI=
Timestamp: 1689177396617352539
— rekor.sigstore.dev xNI9ajBFAiBxaGyEtxkzFLkaCSEJqFuSS3dJjEZCNiyByVs1CNVQ8gIhAOoNnXtmMtTctV2oRnSRUZAo4EWUYPK/vBsqOzAU6TMs
";
let checkpoint = Checkpoint::from_text(checkpoint_text).unwrap();
assert_eq!(checkpoint.tree_size, 23083062);
assert_eq!(checkpoint.other_content.len(), 1);
assert_eq!(
checkpoint.other_content[0],
"Timestamp: 1689177396617352539"
);
}
#[test]
fn test_find_signature_by_key_hint() {
let checkpoint_text = "rekor.sigstore.dev - 1193050959916656506
42591958
npv1T/m9N8zX0jPlbh4rB51zL6GpnV9bQaXSOdzAV+s=
— rekor.sigstore.dev wNI9ajBFAiEA0OP4Pv5ks5MoTTwcM0kS6HMn8gZ5fFPjT9s6vVqXgHkCIDCe5qWSdM4OXpCQ1YNP2KpLo1r/2dRfFHXkPR5h3ywe
";
let checkpoint = Checkpoint::from_text(checkpoint_text).unwrap();
let key_hint = &checkpoint.signatures[0].key_id;
let found = checkpoint.find_signature_by_key_hint(key_hint);
assert!(found.is_some());
assert_eq!(found.unwrap().name, "rekor.sigstore.dev");
let not_found = checkpoint.find_signature_by_key_hint(&[0, 0, 0, 0]);
assert!(not_found.is_none());
}
}