use crate::crypto::{self, KeyRing};
use crate::error::{Error, Result};
use crate::merkle::Hash;
use serde::{Deserialize, Serialize};
use std::io::Write;
use std::path::{Path, PathBuf};
const FILE_NAME: &str = "checkpoints.log";
const WIRE_VERSION: u8 = 2;
const SIGNING_DOMAIN: &[u8] = b"quipu-checkpoint-v2\0";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Checkpoint {
pub created_at: u64,
pub segment_seq: u64,
pub record_count: u64,
pub tree_size: u64,
pub merkle_root: Hash,
pub key_version: u32,
pub signature: Vec<u8>,
}
impl Checkpoint {
pub(crate) fn sign(
keys: &KeyRing,
created_at: u64,
segment_seq: u64,
record_count: u64,
tree_size: u64,
merkle_root: Hash,
) -> Result<Self> {
let (key_version, signature) = keys.sign(&signing_bytes(
created_at,
segment_seq,
record_count,
tree_size,
&merkle_root,
))?;
Ok(Self {
created_at,
segment_seq,
record_count,
tree_size,
merkle_root,
key_version,
signature,
})
}
pub fn verify(&self, keys: &KeyRing) -> Result<()> {
keys.verify_signature(
self.key_version,
&signing_bytes(
self.created_at,
self.segment_seq,
self.record_count,
self.tree_size,
&self.merkle_root,
),
&self.signature,
)
.map_err(|e| Error::Crypto(format!("checkpoint signature invalid: {e}")))
}
pub fn merkle_root_hex(&self) -> String {
crypto::hex(&self.merkle_root)
}
}
fn signing_bytes(
created_at: u64,
segment_seq: u64,
record_count: u64,
tree_size: u64,
merkle_root: &Hash,
) -> Vec<u8> {
let mut out = Vec::with_capacity(SIGNING_DOMAIN.len() + 32 + merkle_root.len());
out.extend_from_slice(SIGNING_DOMAIN);
out.extend_from_slice(&created_at.to_le_bytes());
out.extend_from_slice(&segment_seq.to_le_bytes());
out.extend_from_slice(&record_count.to_le_bytes());
out.extend_from_slice(&tree_size.to_le_bytes());
out.extend_from_slice(merkle_root);
out
}
#[derive(Serialize, Deserialize)]
struct Wire {
v: u8,
created_at: u64,
segment_seq: u64,
record_count: u64,
tree_size: u64,
merkle_root: String,
#[serde(default = "default_key_version")]
key_version: u32,
signature: String,
}
fn default_key_version() -> u32 {
1
}
pub(crate) struct CheckpointLog {
path: PathBuf,
}
impl CheckpointLog {
pub(crate) fn new(root: &Path) -> Self {
Self {
path: root.join(FILE_NAME),
}
}
pub(crate) fn path(&self) -> &Path {
&self.path
}
pub(crate) fn append(&self, cp: &Checkpoint) -> Result<()> {
let wire = Wire {
v: WIRE_VERSION,
created_at: cp.created_at,
segment_seq: cp.segment_seq,
record_count: cp.record_count,
tree_size: cp.tree_size,
merkle_root: crypto::hex(&cp.merkle_root),
key_version: cp.key_version,
signature: crypto::b64::encode(&cp.signature),
};
let mut line = serde_json::to_string(&wire).map_err(|e| Error::Encode(e.to_string()))?;
line.push('\n');
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)?;
file.write_all(line.as_bytes())?;
file.sync_data()?;
Ok(())
}
pub(crate) fn read_all(&self) -> Result<Vec<Checkpoint>> {
let text = match std::fs::read_to_string(&self.path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e.into()),
};
let lines: Vec<&str> = text.lines().collect();
let mut out = Vec::with_capacity(lines.len());
for (i, line) in lines.iter().enumerate() {
match parse_line(line) {
Some(cp) => out.push(cp),
None if i == lines.len() - 1 => break,
None => {
return Err(Error::Corrupt {
segment: self.path.display().to_string(),
offset: i as u64,
reason: "unreadable checkpoint line".into(),
});
}
}
}
Ok(out)
}
}
fn parse_line(line: &str) -> Option<Checkpoint> {
let wire: Wire = serde_json::from_str(line).ok()?;
if wire.v != WIRE_VERSION {
return None;
}
let root: Hash = crypto::hex_decode(&wire.merkle_root)?.try_into().ok()?;
Some(Checkpoint {
created_at: wire.created_at,
segment_seq: wire.segment_seq,
record_count: wire.record_count,
tree_size: wire.tree_size,
merkle_root: root,
key_version: wire.key_version,
signature: crypto::b64::decode(&wire.signature)?,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_and_torn_tail_tolerated() {
let dir = tempfile::tempdir().unwrap();
let log = CheckpointLog::new(dir.path());
assert!(log.read_all().unwrap().is_empty(), "missing file is empty");
let keys = KeyRing::generate_ephemeral(2048).unwrap();
let cp = Checkpoint::sign(&keys, 1, 2, 3, 3, [7; 32]).unwrap();
log.append(&cp).unwrap();
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(log.path())
.unwrap();
f.write_all(b"{\"v\":1,\"created_at\":9").unwrap();
drop(f);
let read = log.read_all().unwrap();
assert_eq!(read, vec![cp.clone()]);
read[0].verify(&keys).unwrap();
let mut bad = cp;
bad.record_count += 1;
assert!(bad.verify(&keys).is_err());
}
}