use crate::encoding::{base64_decode, base64_encode};
use crate::error::{Error, Result};
use crate::merkle::{HASH_LEN, Hash};
use crate::note::{SignedNote, VerifierKey};
use crate::proof;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Checkpoint {
origin: String,
size: u64,
root_hash: Hash,
extensions: Vec<String>,
}
impl Checkpoint {
pub fn new(origin: &str, size: u64, root_hash: Hash) -> Result<Self> {
Self::with_extensions(origin, size, root_hash, Vec::new())
}
pub fn with_extensions(
origin: &str,
size: u64,
root_hash: Hash,
extensions: Vec<String>,
) -> Result<Self> {
if origin.is_empty() || origin.contains('\n') {
return Err(Error::MalformedCheckpoint(
"origin must be non-empty and contain no newline".into(),
));
}
for ext in &extensions {
if ext.is_empty() || ext.contains('\n') {
return Err(Error::MalformedCheckpoint(
"extension lines must be non-empty and contain no newline".into(),
));
}
}
Ok(Self {
origin: origin.to_string(),
size,
root_hash,
extensions,
})
}
#[must_use]
pub fn origin(&self) -> &str {
&self.origin
}
#[must_use]
pub fn size(&self) -> u64 {
self.size
}
#[must_use]
pub fn root_hash(&self) -> &Hash {
&self.root_hash
}
#[must_use]
pub fn extensions(&self) -> &[String] {
&self.extensions
}
pub fn parse(text: &str) -> Result<Self> {
let mut lines = text.lines();
let origin = lines
.next()
.ok_or_else(|| Error::MalformedCheckpoint("missing origin line".into()))?;
let size_str = lines
.next()
.ok_or_else(|| Error::MalformedCheckpoint("missing tree-size line".into()))?;
let root_b64 = lines
.next()
.ok_or_else(|| Error::MalformedCheckpoint("missing root-hash line".into()))?;
if origin.is_empty() {
return Err(Error::MalformedCheckpoint("empty origin line".into()));
}
if size_str.is_empty() || !size_str.bytes().all(|b| b.is_ascii_digit()) {
return Err(Error::MalformedCheckpoint(format!(
"tree size is not decimal: {size_str:?}"
)));
}
if size_str.len() > 1 && size_str.starts_with('0') {
return Err(Error::MalformedCheckpoint(format!(
"tree size has a leading zero: {size_str:?}"
)));
}
let size: u64 = size_str
.parse()
.map_err(|_| Error::MalformedCheckpoint(format!("tree size overflow: {size_str:?}")))?;
let root_bytes = base64_decode(root_b64).map_err(|_| {
Error::MalformedCheckpoint(format!("root hash is not valid base64: {root_b64:?}"))
})?;
let root_hash: Hash = root_bytes.as_slice().try_into().map_err(|_| {
Error::MalformedCheckpoint(format!(
"root hash is {} bytes, want {HASH_LEN}",
root_bytes.len()
))
})?;
let mut extensions = Vec::new();
for ext in lines {
if ext.is_empty() {
return Err(Error::MalformedCheckpoint("empty extension line".into()));
}
extensions.push(ext.to_string());
}
Ok(Self {
origin: origin.to_string(),
size,
root_hash,
extensions,
})
}
#[must_use]
pub fn marshal(&self) -> String {
let mut out = String::new();
out.push_str(&self.origin);
out.push('\n');
out.push_str(&self.size.to_string());
out.push('\n');
out.push_str(&base64_encode(&self.root_hash));
out.push('\n');
for ext in &self.extensions {
out.push_str(ext);
out.push('\n');
}
out
}
pub fn from_signed_note(msg: &str, trusted: &[VerifierKey]) -> Result<Self> {
let note = SignedNote::parse(msg)?;
note.verify(trusted)?;
Self::parse(note.text())
}
pub fn verify_inclusion(
&self,
leaf_index: u64,
leaf_hash: &[u8],
proof: &[Vec<u8>],
) -> Result<()> {
proof::verify_inclusion(leaf_index, self.size, leaf_hash, proof, &self.root_hash)
}
pub fn verify_consistency(&self, newer: &Checkpoint, proof: &[Vec<u8>]) -> Result<()> {
proof::verify_consistency(
self.size,
newer.size,
proof,
&self.root_hash,
&newer.root_hash,
)
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;
use crate::merkle::MerkleTree;
use crate::note::{sign_ed25519, sign_hybrid};
const SPEC_BODY: &str =
"example.com/behind-the-sofa\n20852163\nCsUYapGGPo4dkMgIAUqom/Xajj7h2fB2MPA3j2jxq2I=\n";
#[test]
fn parses_spec_checkpoint_body() {
let cp = Checkpoint::parse(SPEC_BODY).unwrap();
assert_eq!(cp.origin(), "example.com/behind-the-sofa");
assert_eq!(cp.size(), 20_852_163);
assert_eq!(cp.extensions().len(), 0);
assert_eq!(cp.marshal(), SPEC_BODY);
}
#[test]
fn rejects_malformed_bodies() {
assert!(Checkpoint::parse("origin\n").is_err()); assert!(Checkpoint::parse("origin\n01\nAAAA\n").is_err()); assert!(Checkpoint::parse("origin\nxx\nAAAA\n").is_err()); assert!(Checkpoint::parse("origin\n5\nAAAAAA==\n").is_err());
}
#[test]
fn extension_lines_round_trip() {
let root = [7u8; HASH_LEN];
let cp = Checkpoint::with_extensions(
"example.com/log",
42,
root,
vec!["ext one".into(), "ext two".into()],
)
.unwrap();
let body = cp.marshal();
assert_eq!(Checkpoint::parse(&body).unwrap(), cp);
}
#[test]
fn signed_checkpoint_round_trip_and_verify() {
let mut tree = MerkleTree::new();
for i in 0u32..10 {
tree.push(&i.to_be_bytes());
}
let cp = Checkpoint::new("origin.example/log", tree.size(), tree.root()).unwrap();
let (seed, pk) = metamorphic_crypto::ed25519_generate_keypair();
let sig = sign_ed25519(&cp.marshal(), "origin.example/log", &seed).unwrap();
let note = SignedNote::new(cp.marshal(), vec![sig]).unwrap();
let vkey = VerifierKey::new_ed25519("origin.example/log", &pk).unwrap();
let parsed = Checkpoint::from_signed_note(¬e.marshal(), &[vkey]).unwrap();
assert_eq!(parsed, cp);
}
#[test]
fn checkpoint_wires_inclusion_and_consistency() {
let mut tree = MerkleTree::new();
for i in 0u32..8 {
tree.push(&i.to_be_bytes());
}
let older = Checkpoint::new("o", tree.size(), tree.root()).unwrap();
let proof: Vec<Vec<u8>> = tree
.inclusion_proof(3, 8)
.into_iter()
.map(|h| h.to_vec())
.collect();
let leaf = tree.leaf_hash(3).unwrap();
older.verify_inclusion(3, &leaf, &proof).unwrap();
for i in 8u32..16 {
tree.push(&i.to_be_bytes());
}
let newer = Checkpoint::new("o", tree.size(), tree.root()).unwrap();
let cproof: Vec<Vec<u8>> = tree
.consistency_proof(8, 16)
.into_iter()
.map(|h| h.to_vec())
.collect();
older.verify_consistency(&newer, &cproof).unwrap();
}
#[test]
fn checkpoint_co_signed_classical_and_pq() {
let mut tree = MerkleTree::new();
for i in 0u32..10 {
tree.push(&i.to_be_bytes());
}
let cp = Checkpoint::new("origin.example/log", tree.size(), tree.root()).unwrap();
let body = cp.marshal();
let (seed, ed_pk) = metamorphic_crypto::ed25519_generate_keypair();
let pq_kp = metamorphic_crypto::generate_signing_keypair();
let pq_pk = crate::encoding::base64_decode(&pq_kp.public_key).unwrap();
let ed_sig = sign_ed25519(&body, "origin.example/log", &seed).unwrap();
let pq_sig = sign_hybrid(&body, "origin.example/log-pq", &pq_kp.secret_key).unwrap();
let note = SignedNote::new(body, vec![ed_sig, pq_sig]).unwrap();
let ed_vkey = VerifierKey::new_ed25519("origin.example/log", &ed_pk).unwrap();
let pq_vkey = VerifierKey::new_hybrid("origin.example/log-pq", &pq_pk).unwrap();
let parsed_classical =
Checkpoint::from_signed_note(¬e.marshal(), std::slice::from_ref(&ed_vkey)).unwrap();
assert_eq!(parsed_classical, cp);
let parsed_pq = Checkpoint::from_signed_note(¬e.marshal(), &[ed_vkey, pq_vkey]).unwrap();
assert_eq!(parsed_pq, cp);
}
}