574 lines
19 KiB
Rust
574 lines
19 KiB
Rust
//! 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_identity::NodeIdentity;
|
|
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("Wrong store_id: expected {expected}, got {got}")]
|
|
WrongStoreId { 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, scoped to a specific store.
|
|
pub struct SigChain {
|
|
/// Path to the log file
|
|
log_path: PathBuf,
|
|
|
|
/// Store UUID (16 bytes)
|
|
store_id: [u8; 16],
|
|
|
|
/// 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 for a (store, author) pair
|
|
pub fn new(log_path: impl AsRef<Path>, store_id: [u8; 16], author_id: [u8; 32]) -> Self {
|
|
Self {
|
|
log_path: log_path.as_ref().to_path_buf(),
|
|
store_id,
|
|
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>, store_id: [u8; 16], 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, store_id, 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 store_id
|
|
// Note: Empty/malformed store_id becomes [0u8;16], which fails validation
|
|
// against any real UUID store. This intentionally rejects legacy entries.
|
|
let entry_store: [u8; 16] = entry.store_id.clone().try_into()
|
|
.unwrap_or([0u8; 16]);
|
|
if entry_store != store_id {
|
|
return Err(SigChainError::WrongStoreId {
|
|
expected: hex::encode(store_id),
|
|
got: hex::encode(entry_store),
|
|
});
|
|
}
|
|
|
|
// 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 log file path
|
|
pub fn log_path(&self) -> &std::path::Path {
|
|
&self.log_path
|
|
}
|
|
|
|
/// 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 store_id
|
|
// Note: Empty/malformed store_id becomes [0u8;16], which fails validation
|
|
// against any real UUID store. This intentionally rejects legacy entries.
|
|
let entry_store: [u8; 16] = entry.store_id.clone().try_into()
|
|
.unwrap_or([0u8; 16]);
|
|
if entry_store != self.store_id {
|
|
return Err(SigChainError::WrongStoreId {
|
|
expected: hex::encode(self.store_id),
|
|
got: hex::encode(entry_store),
|
|
});
|
|
}
|
|
|
|
// 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: &NodeIdentity, 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)
|
|
.store_id(self.store_id.to_vec())
|
|
.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)
|
|
}
|
|
}
|
|
|
|
/// Manages multiple SigChains (one per author) for a store.
|
|
/// Provides unified interface for appending entries from any author.
|
|
pub struct SigChainManager {
|
|
/// Directory containing log files (one per author)
|
|
logs_dir: PathBuf,
|
|
|
|
/// Store UUID (16 bytes)
|
|
store_id: [u8; 16],
|
|
|
|
/// Cache of loaded SigChains by author
|
|
chains: std::collections::HashMap<[u8; 32], SigChain>,
|
|
}
|
|
|
|
impl SigChainManager {
|
|
/// Create a new manager for a store's logs directory
|
|
pub fn new(logs_dir: impl AsRef<Path>, store_id: [u8; 16]) -> Self {
|
|
Self {
|
|
logs_dir: logs_dir.as_ref().to_path_buf(),
|
|
store_id,
|
|
chains: std::collections::HashMap::new(),
|
|
}
|
|
}
|
|
|
|
/// Get or create a SigChain for an author
|
|
pub fn get_or_create(&mut self, author: [u8; 32]) -> &mut SigChain {
|
|
self.chains.entry(author).or_insert_with(|| {
|
|
let author_hex = hex::encode(author);
|
|
let log_path = self.logs_dir.join(format!("{}.log", author_hex));
|
|
|
|
// Try to load existing log, or create new
|
|
SigChain::from_log(&log_path, self.store_id, author)
|
|
.unwrap_or_else(|_| SigChain::new(&log_path, self.store_id, author))
|
|
})
|
|
}
|
|
|
|
/// Get the local node's sigchain (for creating new entries)
|
|
pub fn get(&self, author: &[u8; 32]) -> Option<&SigChain> {
|
|
self.chains.get(author)
|
|
}
|
|
|
|
/// Append an entry to the appropriate author's log
|
|
/// This is the main entry point for all entry writes (from put, sync, etc.)
|
|
pub fn append_entry(&mut self, entry: &SignedEntry) -> Result<(), SigChainError> {
|
|
let author: [u8; 32] = entry.author_id.clone()
|
|
.try_into()
|
|
.map_err(|_| SigChainError::WrongAuthor {
|
|
expected: "32 bytes".to_string(),
|
|
got: format!("{} bytes", entry.author_id.len()),
|
|
})?;
|
|
|
|
// For synced entries, we can't validate seq/prev_hash since they may arrive
|
|
// out of order. Just append to the log file directly.
|
|
let chain = self.get_or_create(author);
|
|
append_entry(chain.log_path(), entry)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Get the logs directory path
|
|
pub fn logs_dir(&self) -> &Path {
|
|
&self.logs_dir
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::clock::MockClock;
|
|
use crate::hlc::HLC;
|
|
use crate::node_identity::NodeIdentity;
|
|
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))
|
|
}
|
|
|
|
const TEST_STORE: [u8; 16] = [1u8; 16];
|
|
|
|
#[test]
|
|
fn test_new_sigchain() {
|
|
let path = temp_log_path("new");
|
|
let author = [1u8; 32];
|
|
|
|
let chain = SigChain::new(&path, TEST_STORE, 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 = NodeIdentity::generate();
|
|
let author = node.public_key_bytes();
|
|
let mut chain = SigChain::new(&path, TEST_STORE, author);
|
|
|
|
let clock = MockClock::new(1000);
|
|
let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock))
|
|
.store_id(TEST_STORE.to_vec())
|
|
.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 = NodeIdentity::generate();
|
|
let author = node.public_key_bytes();
|
|
let mut chain = SigChain::new(&path, TEST_STORE, author);
|
|
let clock = MockClock::new(1000);
|
|
|
|
for i in 1..=3 {
|
|
let entry = EntryBuilder::new(i, HLC::now_with_clock(&clock))
|
|
.store_id(TEST_STORE.to_vec())
|
|
.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 = NodeIdentity::generate();
|
|
let author = node.public_key_bytes();
|
|
let clock = MockClock::new(1000);
|
|
|
|
// Write some entries
|
|
{
|
|
let mut chain = SigChain::new(&path, TEST_STORE, author);
|
|
for i in 1..=3 {
|
|
let entry = EntryBuilder::new(i, HLC::now_with_clock(&clock))
|
|
.store_id(TEST_STORE.to_vec())
|
|
.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, TEST_STORE, 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 = NodeIdentity::generate();
|
|
let author = node.public_key_bytes();
|
|
let mut chain = SigChain::new(&path, TEST_STORE, 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))
|
|
.store_id(TEST_STORE.to_vec())
|
|
.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 = NodeIdentity::generate();
|
|
let author = node.public_key_bytes();
|
|
let mut chain = SigChain::new(&path, TEST_STORE, author);
|
|
let clock = MockClock::new(1000);
|
|
|
|
// First entry
|
|
let entry1 = EntryBuilder::new(1, HLC::now_with_clock(&clock))
|
|
.store_id(TEST_STORE.to_vec())
|
|
.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))
|
|
.store_id(TEST_STORE.to_vec())
|
|
.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 = NodeIdentity::generate();
|
|
let other_author = [99u8; 32]; // Different author
|
|
let mut chain = SigChain::new(&path, TEST_STORE, 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))
|
|
.store_id(TEST_STORE.to_vec())
|
|
.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 = NodeIdentity::generate();
|
|
let author = node.public_key_bytes();
|
|
let mut chain = SigChain::new(&path, TEST_STORE, author);
|
|
|
|
let ops = vec![
|
|
Operation {
|
|
op_type: Some(operation::OpType::Put(PutOp {
|
|
key: b"/test".to_vec(),
|
|
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();
|
|
}
|
|
|
|
#[test]
|
|
fn test_reject_wrong_store_id() {
|
|
let path_a = temp_log_path("storeid_a");
|
|
let path_b = temp_log_path("storeid_b");
|
|
std::fs::remove_file(&path_a).ok();
|
|
std::fs::remove_file(&path_b).ok();
|
|
|
|
let node = NodeIdentity::generate();
|
|
let author = node.public_key_bytes();
|
|
let clock = MockClock::new(1000);
|
|
|
|
let store_a = [0xAAu8; 16];
|
|
let store_b = [0xBBu8; 16];
|
|
|
|
// Create valid entry for store A
|
|
let mut chain_a = SigChain::new(&path_a, store_a, author);
|
|
let entry = EntryBuilder::new(1, HLC::now_with_clock(&clock))
|
|
.store_id(store_a.to_vec())
|
|
.prev_hash([0u8; 32].to_vec())
|
|
.put("/key", b"val".to_vec())
|
|
.sign(&node);
|
|
chain_a.append(&entry).unwrap();
|
|
|
|
// Try to replay that entry into store B's chain
|
|
let mut chain_b = SigChain::new(&path_b, store_b, author);
|
|
let result = chain_b.append(&entry);
|
|
|
|
assert!(matches!(result, Err(SigChainError::WrongStoreId { .. })));
|
|
|
|
std::fs::remove_file(&path_a).ok();
|
|
std::fs::remove_file(&path_b).ok();
|
|
}
|
|
}
|