diff --git a/Cargo.toml b/Cargo.toml index e5a0010..7e98ab5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ tracing = "0.1" bytes = "1" dirs = "5" blake3 = "1" +hex = "0.4" # Testing tokio-test = "0.4" diff --git a/lattice-core/Cargo.toml b/lattice-core/Cargo.toml index 0c0cbc0..7696c22 100644 --- a/lattice-core/Cargo.toml +++ b/lattice-core/Cargo.toml @@ -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 } diff --git a/lattice-core/src/sigchain.rs b/lattice-core/src/sigchain.rs index 65f96fe..42e8c87 100644 --- a/lattice-core/src/sigchain.rs +++ b/lattice-core/src/sigchain.rs @@ -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, 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, author_id: [u8; 32]) -> Result { + 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) -> Result { + 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(); } } diff --git a/lattice-core/src/signed_entry.rs b/lattice-core/src/signed_entry.rs index 042415d..652925b 100644 --- a/lattice-core/src/signed_entry.rs +++ b/lattice-core/src/signed_entry.rs @@ -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 {