354 lines
11 KiB
Rust
354 lines
11 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
use std::collections::hash_map::DefaultHasher;
|
|
use std::hash::{Hash, Hasher};
|
|
|
|
/// Coherence decision emitted after each epoch.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum CoherenceDecision {
|
|
Pass,
|
|
Fail { severity: u8 },
|
|
Inconclusive,
|
|
}
|
|
|
|
/// A single witness receipt linking an epoch to its predecessor via hashes.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ContainerWitnessReceipt {
|
|
/// Epoch number this receipt covers.
|
|
pub epoch: u64,
|
|
/// Hash of the previous receipt (zero for the genesis receipt).
|
|
pub prev_hash: [u8; 32],
|
|
/// Hash of the input deltas for this epoch.
|
|
pub input_hash: [u8; 32],
|
|
/// Hash of the min-cut result.
|
|
pub mincut_hash: [u8; 32],
|
|
/// Spectral coherence score in fixed-point 32.32 representation.
|
|
pub spectral_scs: u64,
|
|
/// Hash of the evidence accumulation state.
|
|
pub evidence_hash: [u8; 32],
|
|
/// Decision for this epoch.
|
|
pub decision: CoherenceDecision,
|
|
/// Hash of this receipt (covers all fields above).
|
|
pub receipt_hash: [u8; 32],
|
|
}
|
|
|
|
impl ContainerWitnessReceipt {
|
|
/// Serialize all fields except `receipt_hash` into a byte vector for hashing.
|
|
pub fn signable_bytes(&self) -> Vec<u8> {
|
|
let mut buf = Vec::with_capacity(256);
|
|
buf.extend_from_slice(&self.epoch.to_le_bytes());
|
|
buf.extend_from_slice(&self.prev_hash);
|
|
buf.extend_from_slice(&self.input_hash);
|
|
buf.extend_from_slice(&self.mincut_hash);
|
|
buf.extend_from_slice(&self.spectral_scs.to_le_bytes());
|
|
buf.extend_from_slice(&self.evidence_hash);
|
|
match self.decision {
|
|
CoherenceDecision::Pass => buf.push(0),
|
|
CoherenceDecision::Fail { severity } => {
|
|
buf.push(1);
|
|
buf.push(severity);
|
|
}
|
|
CoherenceDecision::Inconclusive => buf.push(2),
|
|
}
|
|
buf
|
|
}
|
|
|
|
/// Compute and set `receipt_hash` from the signable portion of this receipt.
|
|
pub fn compute_hash(&mut self) {
|
|
self.receipt_hash = deterministic_hash(&self.signable_bytes());
|
|
}
|
|
}
|
|
|
|
/// Result of verifying a witness chain.
|
|
#[derive(Debug, Clone)]
|
|
pub enum VerificationResult {
|
|
/// Chain is valid.
|
|
Valid {
|
|
chain_length: usize,
|
|
first_epoch: u64,
|
|
last_epoch: u64,
|
|
},
|
|
/// Chain is empty (no receipts).
|
|
Empty,
|
|
/// A receipt's `prev_hash` does not match the preceding receipt's `receipt_hash`.
|
|
BrokenChain { epoch: u64 },
|
|
/// Epoch numbers are not strictly monotonic.
|
|
EpochGap { expected: u64, got: u64 },
|
|
}
|
|
|
|
/// Append-only chain of witness receipts with hash linking.
|
|
pub struct WitnessChain {
|
|
current_epoch: u64,
|
|
prev_hash: [u8; 32],
|
|
receipts: Vec<ContainerWitnessReceipt>,
|
|
max_receipts: usize,
|
|
}
|
|
|
|
impl WitnessChain {
|
|
/// Create a new empty chain that retains at most `max_receipts` entries.
|
|
pub fn new(max_receipts: usize) -> Self {
|
|
Self {
|
|
current_epoch: 0,
|
|
prev_hash: [0u8; 32],
|
|
receipts: Vec::with_capacity(max_receipts.min(1024)),
|
|
max_receipts,
|
|
}
|
|
}
|
|
|
|
/// Generate a new receipt, append it to the chain, and return a clone.
|
|
pub fn generate_receipt(
|
|
&mut self,
|
|
input_deltas: &[u8],
|
|
mincut_data: &[u8],
|
|
spectral_scs: f64,
|
|
evidence_data: &[u8],
|
|
decision: CoherenceDecision,
|
|
) -> ContainerWitnessReceipt {
|
|
let scs_fixed = f64_to_fixed_32_32(spectral_scs);
|
|
|
|
let mut receipt = ContainerWitnessReceipt {
|
|
epoch: self.current_epoch,
|
|
prev_hash: self.prev_hash,
|
|
input_hash: deterministic_hash(input_deltas),
|
|
mincut_hash: deterministic_hash(mincut_data),
|
|
spectral_scs: scs_fixed,
|
|
evidence_hash: deterministic_hash(evidence_data),
|
|
decision,
|
|
receipt_hash: [0u8; 32],
|
|
};
|
|
receipt.compute_hash();
|
|
|
|
self.prev_hash = receipt.receipt_hash;
|
|
self.current_epoch += 1;
|
|
|
|
// Ring-buffer behavior: drop oldest when full.
|
|
if self.receipts.len() >= self.max_receipts {
|
|
self.receipts.remove(0);
|
|
}
|
|
self.receipts.push(receipt.clone());
|
|
|
|
receipt
|
|
}
|
|
|
|
/// Current epoch counter (next epoch to be generated).
|
|
pub fn current_epoch(&self) -> u64 {
|
|
self.current_epoch
|
|
}
|
|
|
|
/// Most recent receipt, if any.
|
|
pub fn latest_receipt(&self) -> Option<&ContainerWitnessReceipt> {
|
|
self.receipts.last()
|
|
}
|
|
|
|
/// Slice of all retained receipts.
|
|
pub fn receipt_chain(&self) -> &[ContainerWitnessReceipt] {
|
|
&self.receipts
|
|
}
|
|
|
|
/// Verify hash-chain integrity and epoch monotonicity for a slice of receipts.
|
|
pub fn verify_chain(receipts: &[ContainerWitnessReceipt]) -> VerificationResult {
|
|
if receipts.is_empty() {
|
|
return VerificationResult::Empty;
|
|
}
|
|
|
|
// Verify each receipt's self-hash.
|
|
for r in receipts {
|
|
let expected = deterministic_hash(&r.signable_bytes());
|
|
if expected != r.receipt_hash {
|
|
return VerificationResult::BrokenChain { epoch: r.epoch };
|
|
}
|
|
}
|
|
|
|
// Verify prev_hash linkage and epoch ordering.
|
|
for i in 1..receipts.len() {
|
|
let prev = &receipts[i - 1];
|
|
let curr = &receipts[i];
|
|
|
|
if curr.prev_hash != prev.receipt_hash {
|
|
return VerificationResult::BrokenChain { epoch: curr.epoch };
|
|
}
|
|
|
|
let expected_epoch = prev.epoch + 1;
|
|
if curr.epoch != expected_epoch {
|
|
return VerificationResult::EpochGap {
|
|
expected: expected_epoch,
|
|
got: curr.epoch,
|
|
};
|
|
}
|
|
}
|
|
|
|
VerificationResult::Valid {
|
|
chain_length: receipts.len(),
|
|
first_epoch: receipts[0].epoch,
|
|
last_epoch: receipts[receipts.len() - 1].epoch,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Convert an f64 to a 32.32 fixed-point representation.
|
|
fn f64_to_fixed_32_32(value: f64) -> u64 {
|
|
let clamped = value.clamp(0.0, (u32::MAX as f64) + 0.999_999_999);
|
|
(clamped * (1u64 << 32) as f64) as u64
|
|
}
|
|
|
|
/// Public wrapper for deterministic hashing, used by other modules.
|
|
pub fn deterministic_hash_public(data: &[u8]) -> [u8; 32] {
|
|
deterministic_hash(data)
|
|
}
|
|
|
|
/// Deterministic hash producing 32 bytes.
|
|
///
|
|
/// Uses `std::hash::DefaultHasher` (SipHash-2-4) run with four different seeds
|
|
/// to fill 32 bytes. This is NOT cryptographic but fully deterministic across
|
|
/// runs on the same platform.
|
|
fn deterministic_hash(data: &[u8]) -> [u8; 32] {
|
|
let mut result = [0u8; 32];
|
|
for i in 0u64..4 {
|
|
let mut hasher = DefaultHasher::new();
|
|
i.hash(&mut hasher);
|
|
data.hash(&mut hasher);
|
|
let h = hasher.finish();
|
|
let offset = (i as usize) * 8;
|
|
result[offset..offset + 8].copy_from_slice(&h.to_le_bytes());
|
|
}
|
|
result
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_deterministic_hash_consistency() {
|
|
let a = deterministic_hash(b"hello world");
|
|
let b = deterministic_hash(b"hello world");
|
|
assert_eq!(a, b);
|
|
}
|
|
|
|
#[test]
|
|
fn test_deterministic_hash_differs_for_different_inputs() {
|
|
let a = deterministic_hash(b"alpha");
|
|
let b = deterministic_hash(b"beta");
|
|
assert_ne!(a, b);
|
|
}
|
|
|
|
#[test]
|
|
fn test_witness_chain_integrity() {
|
|
let mut chain = WitnessChain::new(100);
|
|
|
|
for i in 0..5 {
|
|
let data = format!("epoch-{i}");
|
|
chain.generate_receipt(
|
|
data.as_bytes(),
|
|
b"mincut",
|
|
0.95,
|
|
b"evidence",
|
|
CoherenceDecision::Pass,
|
|
);
|
|
}
|
|
|
|
assert_eq!(chain.current_epoch(), 5);
|
|
|
|
match WitnessChain::verify_chain(chain.receipt_chain()) {
|
|
VerificationResult::Valid {
|
|
chain_length,
|
|
first_epoch,
|
|
last_epoch,
|
|
} => {
|
|
assert_eq!(chain_length, 5);
|
|
assert_eq!(first_epoch, 0);
|
|
assert_eq!(last_epoch, 4);
|
|
}
|
|
other => panic!("Expected Valid, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_witness_chain_epoch_monotonicity() {
|
|
let mut chain = WitnessChain::new(100);
|
|
for _ in 0..3 {
|
|
chain.generate_receipt(
|
|
b"input",
|
|
b"mincut",
|
|
1.0,
|
|
b"evidence",
|
|
CoherenceDecision::Pass,
|
|
);
|
|
}
|
|
|
|
let receipts = chain.receipt_chain();
|
|
for i in 1..receipts.len() {
|
|
assert_eq!(receipts[i].epoch, receipts[i - 1].epoch + 1);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_verification_detects_tampering() {
|
|
let mut chain = WitnessChain::new(100);
|
|
for _ in 0..3 {
|
|
chain.generate_receipt(
|
|
b"input",
|
|
b"mincut",
|
|
0.5,
|
|
b"evidence",
|
|
CoherenceDecision::Inconclusive,
|
|
);
|
|
}
|
|
|
|
// Tamper with the second receipt's input_hash.
|
|
let mut tampered: Vec<ContainerWitnessReceipt> = chain.receipt_chain().to_vec();
|
|
tampered[1].input_hash[0] ^= 0xFF;
|
|
|
|
match WitnessChain::verify_chain(&tampered) {
|
|
VerificationResult::BrokenChain { epoch } => {
|
|
assert_eq!(epoch, 1);
|
|
}
|
|
other => panic!("Expected BrokenChain, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_empty_chain_verification() {
|
|
let receipts: Vec<ContainerWitnessReceipt> = vec![];
|
|
match WitnessChain::verify_chain(&receipts) {
|
|
VerificationResult::Empty => {}
|
|
other => panic!("Expected Empty, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_ring_buffer_eviction() {
|
|
let mut chain = WitnessChain::new(3);
|
|
for _ in 0..5 {
|
|
chain.generate_receipt(b"data", b"mc", 0.1, b"ev", CoherenceDecision::Pass);
|
|
}
|
|
assert_eq!(chain.receipt_chain().len(), 3);
|
|
assert_eq!(chain.receipt_chain()[0].epoch, 2);
|
|
assert_eq!(chain.receipt_chain()[2].epoch, 4);
|
|
}
|
|
|
|
#[test]
|
|
fn test_f64_to_fixed() {
|
|
assert_eq!(f64_to_fixed_32_32(1.0), 1u64 << 32);
|
|
assert_eq!(f64_to_fixed_32_32(0.0), 0);
|
|
let half = f64_to_fixed_32_32(0.5);
|
|
assert_eq!(half, 1u64 << 31);
|
|
}
|
|
|
|
#[test]
|
|
fn test_signable_bytes_determinism() {
|
|
let receipt = ContainerWitnessReceipt {
|
|
epoch: 42,
|
|
prev_hash: [1u8; 32],
|
|
input_hash: [2u8; 32],
|
|
mincut_hash: [3u8; 32],
|
|
spectral_scs: 100,
|
|
evidence_hash: [4u8; 32],
|
|
decision: CoherenceDecision::Fail { severity: 7 },
|
|
receipt_hash: [0u8; 32],
|
|
};
|
|
let a = receipt.signable_bytes();
|
|
let b = receipt.signable_bytes();
|
|
assert_eq!(a, b);
|
|
}
|
|
}
|