feat: Implement SigChain for append-only signed log management
This commit is contained in:
@@ -13,6 +13,7 @@ thiserror = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
blake3 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
prost-build = { workspace = true }
|
||||
|
||||
@@ -1,20 +1,430 @@
|
||||
//! Cryptographic SigChain (append-only signed log)
|
||||
//!
|
||||
//! A SigChain manages a single author's append-only log. It validates entries
|
||||
//! before appending (correct seq, prev_hash, valid signature) and persists to disk.
|
||||
|
||||
use crate::log::{append_entry, read_entries, LogError};
|
||||
use crate::node::Node;
|
||||
use crate::proto::{Entry, SignedEntry};
|
||||
use crate::signed_entry::{hash_signed_entry, verify_signed_entry};
|
||||
use prost::Message;
|
||||
use std::path::{Path, PathBuf};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors that can occur during sigchain operations
|
||||
#[derive(Error, Debug)]
|
||||
pub enum SigChainError {
|
||||
#[error("Log error: {0}")]
|
||||
Log(#[from] LogError),
|
||||
|
||||
#[error("Invalid signature")]
|
||||
InvalidSignature,
|
||||
|
||||
#[error("Wrong author: expected {expected}, got {got}")]
|
||||
WrongAuthor { expected: String, got: String },
|
||||
|
||||
#[error("Invalid sequence: expected {expected}, got {got}")]
|
||||
InvalidSequence { expected: u64, got: u64 },
|
||||
|
||||
#[error("Invalid prev_hash: expected {expected}, got {got}")]
|
||||
InvalidPrevHash { expected: String, got: String },
|
||||
|
||||
#[error("Decode error: {0}")]
|
||||
Decode(#[from] prost::DecodeError),
|
||||
}
|
||||
|
||||
/// An append-only log where each entry is cryptographically signed
|
||||
/// and hash-linked to the previous entry.
|
||||
pub struct SigChain {
|
||||
// TODO: entries, hash chain
|
||||
/// Path to the log file
|
||||
log_path: PathBuf,
|
||||
|
||||
/// Author's public key (32 bytes)
|
||||
author_id: [u8; 32],
|
||||
|
||||
/// Next expected sequence number
|
||||
next_seq: u64,
|
||||
|
||||
/// Hash of the last entry (zeroes if empty)
|
||||
last_hash: [u8; 32],
|
||||
}
|
||||
|
||||
impl SigChain {
|
||||
/// Create a new empty sigchain.
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
/// Create a new empty sigchain for an author
|
||||
pub fn new(log_path: impl AsRef<Path>, author_id: [u8; 32]) -> Self {
|
||||
Self {
|
||||
log_path: log_path.as_ref().to_path_buf(),
|
||||
author_id,
|
||||
next_seq: 1,
|
||||
last_hash: [0u8; 32],
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a sigchain from an existing log file
|
||||
pub fn from_log(log_path: impl AsRef<Path>, author_id: [u8; 32]) -> Result<Self, SigChainError> {
|
||||
let log_path = log_path.as_ref().to_path_buf();
|
||||
let entries = read_entries(&log_path)?;
|
||||
|
||||
let mut chain = Self::new(&log_path, author_id);
|
||||
|
||||
for signed_entry in entries {
|
||||
// Verify signature
|
||||
verify_signed_entry(&signed_entry)
|
||||
.map_err(|_| SigChainError::InvalidSignature)?;
|
||||
|
||||
// Validate author (author_id is in SignedEntry)
|
||||
let entry_author: [u8; 32] = signed_entry.author_id.clone().try_into()
|
||||
.unwrap_or([0u8; 32]);
|
||||
if entry_author != author_id {
|
||||
return Err(SigChainError::WrongAuthor {
|
||||
expected: hex::encode(author_id),
|
||||
got: hex::encode(&entry_author),
|
||||
});
|
||||
}
|
||||
|
||||
// Decode Entry
|
||||
let entry = Entry::decode(&signed_entry.entry_bytes[..])?;
|
||||
|
||||
// Validate sequence
|
||||
if entry.seq != chain.next_seq {
|
||||
return Err(SigChainError::InvalidSequence {
|
||||
expected: chain.next_seq,
|
||||
got: entry.seq,
|
||||
});
|
||||
}
|
||||
|
||||
// Validate prev_hash
|
||||
let expected_prev: [u8; 32] = chain.last_hash;
|
||||
let got_prev: [u8; 32] = entry.prev_hash.try_into()
|
||||
.unwrap_or([0u8; 32]);
|
||||
if got_prev != expected_prev {
|
||||
return Err(SigChainError::InvalidPrevHash {
|
||||
expected: hex::encode(expected_prev),
|
||||
got: hex::encode(got_prev),
|
||||
});
|
||||
}
|
||||
|
||||
// Update state
|
||||
chain.last_hash = hash_signed_entry(&signed_entry);
|
||||
chain.next_seq += 1;
|
||||
}
|
||||
|
||||
Ok(chain)
|
||||
}
|
||||
|
||||
/// Get the author's public key
|
||||
pub fn author_id(&self) -> &[u8; 32] {
|
||||
&self.author_id
|
||||
}
|
||||
|
||||
/// Get the next expected sequence number
|
||||
pub fn next_seq(&self) -> u64 {
|
||||
self.next_seq
|
||||
}
|
||||
|
||||
/// Get the hash of the last entry
|
||||
pub fn last_hash(&self) -> &[u8; 32] {
|
||||
&self.last_hash
|
||||
}
|
||||
|
||||
/// Get the current length of the chain
|
||||
pub fn len(&self) -> u64 {
|
||||
self.next_seq - 1
|
||||
}
|
||||
|
||||
/// Check if the chain is empty
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.next_seq == 1
|
||||
}
|
||||
|
||||
/// Validate a signed entry without appending
|
||||
pub fn validate(&self, signed_entry: &SignedEntry) -> Result<(), SigChainError> {
|
||||
// Verify signature
|
||||
verify_signed_entry(signed_entry)
|
||||
.map_err(|_| SigChainError::InvalidSignature)?;
|
||||
|
||||
// Validate author (author_id is in SignedEntry)
|
||||
let author: [u8; 32] = signed_entry.author_id.clone().try_into()
|
||||
.unwrap_or([0u8; 32]);
|
||||
if author != self.author_id {
|
||||
return Err(SigChainError::WrongAuthor {
|
||||
expected: hex::encode(self.author_id),
|
||||
got: hex::encode(author),
|
||||
});
|
||||
}
|
||||
|
||||
// Decode entry
|
||||
let entry = Entry::decode(&signed_entry.entry_bytes[..])?;
|
||||
|
||||
// Validate sequence
|
||||
if entry.seq != self.next_seq {
|
||||
return Err(SigChainError::InvalidSequence {
|
||||
expected: self.next_seq,
|
||||
got: entry.seq,
|
||||
});
|
||||
}
|
||||
|
||||
// Validate prev_hash
|
||||
let prev: [u8; 32] = entry.prev_hash.try_into()
|
||||
.unwrap_or([0u8; 32]);
|
||||
if prev != self.last_hash {
|
||||
return Err(SigChainError::InvalidPrevHash {
|
||||
expected: hex::encode(self.last_hash),
|
||||
got: hex::encode(prev),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Append a signed entry to the chain (validates first)
|
||||
pub fn append(&mut self, signed_entry: &SignedEntry) -> Result<(), SigChainError> {
|
||||
// Validate
|
||||
self.validate(signed_entry)?;
|
||||
|
||||
// Write to log
|
||||
append_entry(&self.log_path, signed_entry)?;
|
||||
|
||||
// Update state
|
||||
self.last_hash = hash_signed_entry(signed_entry);
|
||||
self.next_seq += 1;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create and append a new entry using the node's key
|
||||
pub fn create_entry(&mut self, node: &Node, ops: Vec<crate::proto::Operation>) -> Result<SignedEntry, SigChainError> {
|
||||
use crate::clock::SystemClock;
|
||||
use crate::hlc::HLC;
|
||||
use crate::signed_entry::EntryBuilder;
|
||||
|
||||
let hlc = HLC::now_with_clock(&SystemClock);
|
||||
|
||||
let mut builder = EntryBuilder::new(self.next_seq, hlc)
|
||||
.prev_hash(self.last_hash.to_vec());
|
||||
|
||||
// Add operations
|
||||
for op in ops {
|
||||
builder = builder.operation(op);
|
||||
}
|
||||
|
||||
let signed = builder.sign(node);
|
||||
|
||||
self.append(&signed)?;
|
||||
|
||||
Ok(signed)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SigChain {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::clock::MockClock;
|
||||
use crate::hlc::HLC;
|
||||
use crate::node::Node;
|
||||
use crate::proto::{operation, Operation, PutOp};
|
||||
use crate::signed_entry::EntryBuilder;
|
||||
use std::env::temp_dir;
|
||||
|
||||
fn temp_log_path(name: &str) -> PathBuf {
|
||||
temp_dir().join(format!("lattice_sigchain_test_{}.log", name))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_sigchain() {
|
||||
let path = temp_log_path("new");
|
||||
let author = [1u8; 32];
|
||||
|
||||
let chain = SigChain::new(&path, author);
|
||||
|
||||
assert_eq!(chain.author_id(), &author);
|
||||
assert_eq!(chain.next_seq(), 1);
|
||||
assert_eq!(chain.last_hash(), &[0u8; 32]);
|
||||
assert!(chain.is_empty());
|
||||
assert_eq!(chain.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_append_entry() {
|
||||
let path = temp_log_path("append");
|
||||
std::fs::remove_file(&path).ok();
|
||||
|
||||
let node = Node::generate();
|
||||
let author = node.public_key_bytes();
|
||||
let mut chain = SigChain::new(&path, author);
|
||||
|
||||
let clock = MockClock::new(1000);
|
||||
let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock))
|
||||
.prev_hash([0u8; 32].to_vec())
|
||||
.put("/key", b"value".to_vec())
|
||||
.sign(&node);
|
||||
|
||||
chain.append(&entry).unwrap();
|
||||
|
||||
assert_eq!(chain.next_seq(), 2);
|
||||
assert_eq!(chain.len(), 1);
|
||||
assert!(!chain.is_empty());
|
||||
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_append_multiple() {
|
||||
let path = temp_log_path("multiple");
|
||||
std::fs::remove_file(&path).ok();
|
||||
|
||||
let node = Node::generate();
|
||||
let author = node.public_key_bytes();
|
||||
let mut chain = SigChain::new(&path, author);
|
||||
let clock = MockClock::new(1000);
|
||||
|
||||
for i in 1..=3 {
|
||||
let entry = EntryBuilder::new(i, HLC::now_with_clock(&clock))
|
||||
.prev_hash(chain.last_hash.to_vec())
|
||||
.put(format!("/key/{}", i), format!("value{}", i).into_bytes())
|
||||
.sign(&node);
|
||||
chain.append(&entry).unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(chain.len(), 3);
|
||||
assert_eq!(chain.next_seq(), 4);
|
||||
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_log() {
|
||||
let path = temp_log_path("from_log");
|
||||
std::fs::remove_file(&path).ok();
|
||||
|
||||
let node = Node::generate();
|
||||
let author = node.public_key_bytes();
|
||||
let clock = MockClock::new(1000);
|
||||
|
||||
// Write some entries
|
||||
{
|
||||
let mut chain = SigChain::new(&path, author);
|
||||
for i in 1..=3 {
|
||||
let entry = EntryBuilder::new(i, HLC::now_with_clock(&clock))
|
||||
.prev_hash(chain.last_hash.to_vec())
|
||||
.put("/key", b"val".to_vec())
|
||||
.sign(&node);
|
||||
chain.append(&entry).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// Reload from log
|
||||
let chain = SigChain::from_log(&path, author).unwrap();
|
||||
|
||||
assert_eq!(chain.len(), 3);
|
||||
assert_eq!(chain.next_seq(), 4);
|
||||
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_wrong_sequence() {
|
||||
let path = temp_log_path("wrong_seq");
|
||||
std::fs::remove_file(&path).ok();
|
||||
|
||||
let node = Node::generate();
|
||||
let author = node.public_key_bytes();
|
||||
let mut chain = SigChain::new(&path, author);
|
||||
let clock = MockClock::new(1000);
|
||||
|
||||
// Try to append with wrong seq (2 instead of 1)
|
||||
let entry = EntryBuilder::new(2, HLC::now_with_clock(&clock))
|
||||
.prev_hash([0u8; 32].to_vec())
|
||||
.put("/key", b"val".to_vec())
|
||||
.sign(&node);
|
||||
|
||||
let result = chain.append(&entry);
|
||||
|
||||
assert!(matches!(result, Err(SigChainError::InvalidSequence { .. })));
|
||||
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_wrong_prev_hash() {
|
||||
let path = temp_log_path("wrong_prev");
|
||||
std::fs::remove_file(&path).ok();
|
||||
|
||||
let node = Node::generate();
|
||||
let author = node.public_key_bytes();
|
||||
let mut chain = SigChain::new(&path, author);
|
||||
let clock = MockClock::new(1000);
|
||||
|
||||
// First entry
|
||||
let entry1 = EntryBuilder::new(1, HLC::now_with_clock(&clock))
|
||||
.prev_hash([0u8; 32].to_vec())
|
||||
.put("/key", b"v1".to_vec())
|
||||
.sign(&node);
|
||||
chain.append(&entry1).unwrap();
|
||||
|
||||
// Second entry with wrong prev_hash
|
||||
let entry2 = EntryBuilder::new(2, HLC::now_with_clock(&clock))
|
||||
.prev_hash([99u8; 32].to_vec()) // Wrong!
|
||||
.put("/key", b"v2".to_vec())
|
||||
.sign(&node);
|
||||
|
||||
let result = chain.append(&entry2);
|
||||
|
||||
assert!(matches!(result, Err(SigChainError::InvalidPrevHash { .. })));
|
||||
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_wrong_author() {
|
||||
let path = temp_log_path("wrong_author");
|
||||
std::fs::remove_file(&path).ok();
|
||||
|
||||
let node = Node::generate();
|
||||
let other_author = [99u8; 32]; // Different author
|
||||
let mut chain = SigChain::new(&path, other_author);
|
||||
let clock = MockClock::new(1000);
|
||||
|
||||
// Entry signed by node but chain expects other_author
|
||||
let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock))
|
||||
.prev_hash([0u8; 32].to_vec())
|
||||
.put("/key", b"val".to_vec())
|
||||
.sign(&node);
|
||||
|
||||
let result = chain.append(&entry);
|
||||
|
||||
assert!(matches!(result, Err(SigChainError::WrongAuthor { .. })));
|
||||
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_entry() {
|
||||
let path = temp_log_path("create");
|
||||
std::fs::remove_file(&path).ok();
|
||||
|
||||
let node = Node::generate();
|
||||
let author = node.public_key_bytes();
|
||||
let mut chain = SigChain::new(&path, author);
|
||||
|
||||
let ops = vec![
|
||||
Operation {
|
||||
op_type: Some(operation::OpType::Put(PutOp {
|
||||
key: "/test".to_string(),
|
||||
value: b"hello".to_vec(),
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
let signed = chain.create_entry(&node, ops).unwrap();
|
||||
|
||||
assert_eq!(chain.len(), 1);
|
||||
|
||||
// Verify it was written
|
||||
let entries = read_entries(&path).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].entry_bytes, signed.entry_bytes);
|
||||
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,12 @@ impl EntryBuilder {
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a raw operation
|
||||
pub fn operation(mut self, op: Operation) -> Self {
|
||||
self.ops.push(op);
|
||||
self
|
||||
}
|
||||
|
||||
/// Build the Entry proto message
|
||||
pub fn build(self) -> Entry {
|
||||
|
||||
Reference in New Issue
Block a user