feat: Implement SigChain for append-only signed log management

This commit is contained in:
2025-12-20 14:29:08 +01:00
parent b16f9a0b39
commit a699c0b1a6
4 changed files with 425 additions and 7 deletions
+1
View File
@@ -39,6 +39,7 @@ tracing = "0.1"
bytes = "1"
dirs = "5"
blake3 = "1"
hex = "0.4"
# Testing
tokio-test = "0.4"
+1
View File
@@ -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 }
+417 -7
View File
@@ -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();
}
}
+6
View File
@@ -77,6 +77,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 {
Entry {