|
|
|
@@ -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],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Default for SigChain {
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
Self::new()
|
|
|
|
|
/// 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|