use std::path::Path;
use std::sync::Mutex;
use chrono::{DateTime, Utc};
use ed25519_dalek::{SigningKey, VerifyingKey};
use weftos_rvf_crypto::hash::shake256_256;
use weftos_rvf_crypto::{
create_witness_chain, decode_signature_footer, encode_signature_footer,
lineage_record_to_bytes, lineage_witness_entry, sign_segment, verify_segment,
sign_segment_ml_dsa, verify_segment_ml_dsa,
MlDsa65Key, MlDsa65VerifyKey,
verify_witness_chain, WitnessEntry,
};
use rvf_types::SEGMENT_HEADER_SIZE;
use weftos_rvf_wire::writer::{calculate_padded_size, write_segment};
use weftos_rvf_wire::{read_segment, validate_segment};
const EXOCHAIN_MAGIC: u32 = 0x4558_4F43;
#[derive(Debug, Clone)]
struct ExoChainHeader {
magic: u32,
version: u8,
subtype: u8,
flags: u16,
chain_id: u32,
_reserved: u32,
sequence: u64,
timestamp_secs: u64,
prev_hash: [u8; 32],
}
const EXOCHAIN_HEADER_SIZE: usize = 64;
impl ExoChainHeader {
fn to_bytes(&self) -> [u8; EXOCHAIN_HEADER_SIZE] {
let mut buf = [0u8; EXOCHAIN_HEADER_SIZE];
buf[0..4].copy_from_slice(&self.magic.to_le_bytes());
buf[4] = self.version;
buf[5] = self.subtype;
buf[6..8].copy_from_slice(&self.flags.to_le_bytes());
buf[8..12].copy_from_slice(&self.chain_id.to_le_bytes());
buf[12..16].copy_from_slice(&self._reserved.to_le_bytes());
buf[16..24].copy_from_slice(&self.sequence.to_le_bytes());
buf[24..32].copy_from_slice(&self.timestamp_secs.to_le_bytes());
buf[32..64].copy_from_slice(&self.prev_hash);
buf
}
fn from_bytes(data: &[u8]) -> Option<Self> {
if data.len() < EXOCHAIN_HEADER_SIZE {
return None;
}
let magic = u32::from_le_bytes(data[0..4].try_into().ok()?);
if magic != EXOCHAIN_MAGIC {
return None;
}
Some(Self {
magic,
version: data[4],
subtype: data[5],
flags: u16::from_le_bytes(data[6..8].try_into().ok()?),
chain_id: u32::from_le_bytes(data[8..12].try_into().ok()?),
_reserved: u32::from_le_bytes(data[12..16].try_into().ok()?),
sequence: u64::from_le_bytes(data[16..24].try_into().ok()?),
timestamp_secs: u64::from_le_bytes(data[24..32].try_into().ok()?),
prev_hash: data[32..64].try_into().ok()?,
})
}
}
fn write_exochain_event(header: &ExoChainHeader, cbor: &[u8], segment_id: u64) -> Vec<u8> {
let exo_bytes = header.to_bytes();
let mut payload = Vec::with_capacity(exo_bytes.len() + cbor.len());
payload.extend_from_slice(&exo_bytes);
payload.extend_from_slice(cbor);
write_segment(
0x10, &payload,
rvf_types::SegmentFlags::empty(),
segment_id,
)
}
fn decode_exochain_payload(payload: &[u8]) -> Option<(ExoChainHeader, &[u8])> {
let header = ExoChainHeader::from_bytes(payload)?;
Some((header, &payload[EXOCHAIN_HEADER_SIZE..]))
}
use serde::{Deserialize, Serialize};
use tracing::{debug, info, warn};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChainEvent {
pub sequence: u64,
pub chain_id: u32,
pub timestamp: DateTime<Utc>,
pub prev_hash: [u8; 32],
pub hash: [u8; 32],
#[serde(default)]
pub payload_hash: [u8; 32],
pub source: String,
pub kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub payload: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChainCheckpoint {
pub chain_id: u32,
pub sequence: u64,
pub last_hash: [u8; 32],
pub timestamp: DateTime<Utc>,
pub events_since_last: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChainVerifyResult {
pub valid: bool,
pub event_count: usize,
pub errors: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature_verified: Option<bool>,
}
pub(crate) fn compute_payload_hash(payload: &Option<serde_json::Value>) -> [u8; 32] {
match payload {
Some(val) => {
let bytes = serde_json::to_vec(val).unwrap_or_default();
shake256_256(&bytes)
}
None => [0u8; 32],
}
}
pub(crate) fn compute_event_hash(
sequence: u64,
chain_id: u32,
prev_hash: &[u8; 32],
source: &str,
kind: &str,
timestamp: &DateTime<Utc>,
payload_hash: &[u8; 32],
) -> [u8; 32] {
let mut buf = Vec::with_capacity(128);
buf.extend_from_slice(&sequence.to_le_bytes());
buf.extend_from_slice(&chain_id.to_le_bytes());
buf.extend_from_slice(prev_hash);
buf.extend_from_slice(source.as_bytes());
buf.push(0x00); buf.extend_from_slice(kind.as_bytes());
buf.push(0x00); buf.extend_from_slice(×tamp.timestamp().to_le_bytes());
buf.extend_from_slice(payload_hash);
shake256_256(&buf)
}
const WITNESS_PROVENANCE: u8 = 0x01;
pub const EVENT_KIND_CAPABILITY_REVOKED: &str = "capability.revoked";
pub const EVENT_KIND_API_CONTRACT_REGISTERED: &str = "service.contract.register";
pub const EVENT_KIND_TOOL_DEPLOYED: &str = "tool.deploy";
pub const EVENT_KIND_TOOL_VERSION_REVOKED: &str = "tool.version.revoke";
pub const EVENT_KIND_SANDBOX_SUDO_OVERRIDE: &str = "sandbox.sudo.override";
pub const EVENT_KIND_TOOL_SIGNED: &str = "tool.signed";
pub const EVENT_KIND_SHELL_EXEC: &str = "shell.exec";
struct LocalChain {
chain_id: u32,
events: Vec<ChainEvent>,
last_hash: [u8; 32],
sequence: u64,
checkpoint_interval: u64,
events_since_checkpoint: u64,
checkpoints: Vec<ChainCheckpoint>,
witness_entries: Vec<WitnessEntry>,
}
impl LocalChain {
fn new(chain_id: u32, checkpoint_interval: u64) -> Self {
Self {
chain_id,
events: Vec::new(),
last_hash: [0u8; 32],
sequence: 0,
checkpoint_interval,
events_since_checkpoint: 0,
checkpoints: Vec::new(),
witness_entries: Vec::new(),
}
}
fn from_events(
chain_id: u32,
checkpoint_interval: u64,
events: Vec<ChainEvent>,
witness_entries: Vec<WitnessEntry>,
) -> Self {
let (last_hash, sequence) = if let Some(last) = events.last() {
(last.hash, last.sequence + 1)
} else {
([0u8; 32], 0)
};
Self {
chain_id,
events,
last_hash,
sequence,
checkpoint_interval,
events_since_checkpoint: 0,
checkpoints: Vec::new(),
witness_entries,
}
}
fn append(
&mut self,
source: String,
kind: String,
payload: Option<serde_json::Value>,
) -> &ChainEvent {
let timestamp = Utc::now();
let payload_hash = compute_payload_hash(&payload);
let hash = compute_event_hash(
self.sequence,
self.chain_id,
&self.last_hash,
&source,
&kind,
×tamp,
&payload_hash,
);
let event = ChainEvent {
sequence: self.sequence,
chain_id: self.chain_id,
timestamp,
prev_hash: self.last_hash,
hash,
payload_hash,
source,
kind,
payload,
};
self.witness_entries.push(WitnessEntry {
prev_hash: [0u8; 32], action_hash: hash,
timestamp_ns: timestamp.timestamp_nanos_opt().unwrap_or(0) as u64,
witness_type: WITNESS_PROVENANCE,
});
self.last_hash = hash;
self.sequence += 1;
self.events_since_checkpoint += 1;
self.events.push(event);
if self.checkpoint_interval > 0
&& self.events_since_checkpoint >= self.checkpoint_interval
{
self.create_checkpoint();
}
self.events.last().unwrap()
}
fn create_checkpoint(&mut self) -> ChainCheckpoint {
let cp = ChainCheckpoint {
chain_id: self.chain_id,
sequence: self.sequence.saturating_sub(1),
last_hash: self.last_hash,
timestamp: Utc::now(),
events_since_last: self.events_since_checkpoint,
};
self.events_since_checkpoint = 0;
self.checkpoints.push(cp.clone());
cp
}
}
#[derive(Serialize, Deserialize)]
struct RvfChainPayload {
source: String,
kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
payload: Option<serde_json::Value>,
payload_hash: String,
hash: String,
}
fn hex_hash(h: &[u8; 32]) -> String {
h.iter().map(|b| format!("{b:02x}")).collect()
}
fn hex_encode(data: &[u8]) -> String {
data.iter().map(|b| format!("{b:02x}")).collect()
}
fn hex_decode(s: &str) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
if !s.len().is_multiple_of(2) {
return Err("hex string has odd length".into());
}
let mut out = Vec::with_capacity(s.len() / 2);
for chunk in s.as_bytes().chunks(2) {
let hi = hex_nibble(chunk[0])?;
let lo = hex_nibble(chunk[1])?;
out.push((hi << 4) | lo);
}
Ok(out)
}
fn parse_hex_hash(s: &str) -> Result<[u8; 32], Box<dyn std::error::Error + Send + Sync>> {
if s.len() != 64 {
return Err(format!("expected 64 hex chars, got {}", s.len()).into());
}
let mut out = [0u8; 32];
for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
let hi = hex_nibble(chunk[0])?;
let lo = hex_nibble(chunk[1])?;
out[i] = (hi << 4) | lo;
}
Ok(out)
}
fn hex_nibble(c: u8) -> Result<u8, Box<dyn std::error::Error + Send + Sync>> {
match c {
b'0'..=b'9' => Ok(c - b'0'),
b'a'..=b'f' => Ok(c - b'a' + 10),
b'A'..=b'F' => Ok(c - b'A' + 10),
_ => Err(format!("invalid hex char: {}", c as char).into()),
}
}
pub struct ChainManager {
inner: Mutex<LocalChain>,
signing_key: Option<SigningKey>,
ml_dsa_key: Option<MlDsa65Key>,
}
impl ChainManager {
pub fn new(chain_id: u32, checkpoint_interval: u64) -> Self {
let mut chain = LocalChain::new(chain_id, checkpoint_interval);
chain.append(
"chain".into(),
"genesis".into(),
Some(serde_json::json!({ "chain_id": chain_id })),
);
debug!(chain_id, "local chain initialized with genesis event");
Self {
inner: Mutex::new(chain),
signing_key: None,
ml_dsa_key: None,
}
}
pub fn default_local() -> Self {
Self::new(0, 1000)
}
pub fn with_signing_key(mut self, key: SigningKey) -> Self {
self.signing_key = Some(key);
self
}
pub fn verifying_key(&self) -> Option<VerifyingKey> {
self.signing_key.as_ref().map(|k| k.verifying_key())
}
pub fn set_signing_key(&mut self, key: SigningKey) {
self.signing_key = Some(key);
}
pub fn has_signing_key(&self) -> bool {
self.signing_key.is_some()
}
pub fn set_ml_dsa_key(&mut self, key: MlDsa65Key) {
self.ml_dsa_key = Some(key);
}
pub fn has_dual_signing(&self) -> bool {
self.signing_key.is_some() && self.ml_dsa_key.is_some()
}
pub fn load_or_create_key(
path: &Path,
) -> Result<SigningKey, Box<dyn std::error::Error + Send + Sync>> {
if path.exists() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(path)?.permissions().mode();
if mode & 0o077 != 0 {
warn!(
path = %path.display(),
mode = format!("{mode:04o}"),
"signing key file has overly permissive permissions, fixing to 0600"
);
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
}
}
let bytes = std::fs::read(path)?;
if bytes.len() != 32 {
return Err(format!(
"key file is {} bytes, expected 32",
bytes.len()
)
.into());
}
let key_bytes: [u8; 32] = bytes
.try_into()
.map_err(|_| "key file not 32 bytes")?;
let key = SigningKey::from_bytes(&key_bytes);
info!(path = %path.display(), "loaded Ed25519 signing key");
Ok(key)
} else {
use rand::rngs::OsRng;
let key = SigningKey::generate(&mut OsRng);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, key.to_bytes())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
}
info!(path = %path.display(), "generated new Ed25519 signing key");
Ok(key)
}
}
pub fn append(
&self,
source: &str,
kind: &str,
payload: Option<serde_json::Value>,
) -> ChainEvent {
let mut chain = self.inner.lock().unwrap();
chain.append(source.into(), kind.into(), payload).clone()
}
pub fn checkpoint(&self) -> ChainCheckpoint {
let mut chain = self.inner.lock().unwrap();
chain.create_checkpoint()
}
pub fn len(&self) -> usize {
self.inner.lock().unwrap().events.len()
}
pub fn is_empty(&self) -> bool {
self.inner.lock().unwrap().events.is_empty()
}
pub fn sequence(&self) -> u64 {
self.inner.lock().unwrap().sequence
}
pub fn last_hash(&self) -> [u8; 32] {
self.inner.lock().unwrap().last_hash
}
pub fn chain_id(&self) -> u32 {
self.inner.lock().unwrap().chain_id
}
pub fn tail(&self, n: usize) -> Vec<ChainEvent> {
let chain = self.inner.lock().unwrap();
if n == 0 || n >= chain.events.len() {
chain.events.clone()
} else {
chain.events[chain.events.len() - n..].to_vec()
}
}
pub fn tail_from(&self, after: u64) -> Vec<ChainEvent> {
let chain = self.inner.lock().unwrap();
chain
.events
.iter()
.filter(|e| e.sequence > after)
.cloned()
.collect()
}
pub fn head_sequence(&self) -> u64 {
let chain = self.inner.lock().unwrap();
chain.events.last().map(|e| e.sequence).unwrap_or(0)
}
pub fn head_hash(&self) -> [u8; 32] {
let chain = self.inner.lock().unwrap();
chain.events.last().map(|e| e.hash).unwrap_or([0u8; 32])
}
pub fn checkpoints(&self) -> Vec<ChainCheckpoint> {
self.inner.lock().unwrap().checkpoints.clone()
}
pub fn witness_count(&self) -> usize {
self.inner.lock().unwrap().witness_entries.len()
}
pub fn verify_witness(
&self,
) -> Result<usize, Box<dyn std::error::Error + Send + Sync>> {
let chain = self.inner.lock().unwrap();
if chain.witness_entries.is_empty() {
return Ok(0);
}
let data = create_witness_chain(&chain.witness_entries);
let verified = verify_witness_chain(&data)
.map_err(|e| format!("witness chain verification failed: {e}"))?;
Ok(verified.len())
}
pub fn generate_attestation(
&self,
vector_count: u64,
epoch: u64,
content_hash: &str,
) -> Option<CustodyAttestation> {
use ed25519_dalek::Signer;
let signing_key = self.signing_key.as_ref()?;
let device_id: String = signing_key
.verifying_key()
.to_bytes()
.iter()
.map(|b| format!("{b:02x}"))
.collect();
let chain = self.inner.lock().unwrap();
let chain_head: String = chain.last_hash.iter().map(|b| format!("{b:02x}")).collect();
let chain_depth = chain.events.len() as u64;
drop(chain);
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let canonical = format!(
"custody-attestation:v1\n\
device_id:{device_id}\n\
epoch:{epoch}\n\
chain_head:{chain_head}\n\
chain_depth:{chain_depth}\n\
vector_count:{vector_count}\n\
content_hash:{content_hash}\n\
timestamp:{timestamp}"
);
let signature = signing_key.sign(canonical.as_bytes());
Some(CustodyAttestation {
device_id,
epoch,
chain_head,
chain_depth,
vector_count,
content_hash: content_hash.to_owned(),
timestamp,
signature: signature.to_bytes().to_vec(),
})
}
pub fn verify_integrity(&self) -> ChainVerifyResult {
let chain = self.inner.lock().unwrap();
let mut errors = Vec::new();
for (i, event) in chain.events.iter().enumerate() {
let expected_prev = if i == 0 {
[0u8; 32]
} else {
chain.events[i - 1].hash
};
if event.prev_hash != expected_prev {
errors.push(format!(
"seq {}: prev_hash mismatch (expected {:02x}{:02x}..., got {:02x}{:02x}...)",
event.sequence,
expected_prev[0], expected_prev[1],
event.prev_hash[0], event.prev_hash[1],
));
}
let recomputed_payload = compute_payload_hash(&event.payload);
if event.payload_hash != recomputed_payload {
errors.push(format!(
"seq {}: payload_hash mismatch (recomputed {:02x}{:02x}..., stored {:02x}{:02x}...)",
event.sequence,
recomputed_payload[0], recomputed_payload[1],
event.payload_hash[0], event.payload_hash[1],
));
}
let recomputed = compute_event_hash(
event.sequence,
event.chain_id,
&event.prev_hash,
&event.source,
&event.kind,
&event.timestamp,
&event.payload_hash,
);
if event.hash != recomputed {
errors.push(format!(
"seq {}: hash mismatch (recomputed {:02x}{:02x}..., stored {:02x}{:02x}...)",
event.sequence,
recomputed[0], recomputed[1],
event.hash[0], event.hash[1],
));
}
}
ChainVerifyResult {
valid: errors.is_empty(),
event_count: chain.events.len(),
errors,
signature_verified: None,
}
}
pub fn save_to_file(&self, path: &Path) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let chain = self.inner.lock().map_err(|e| format!("lock: {e}"))?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut output = String::new();
for event in &chain.events {
let line = serde_json::to_string(event)?;
output.push_str(&line);
output.push('\n');
}
std::fs::write(path, output)?;
info!(
path = %path.display(),
events = chain.events.len(),
sequence = chain.sequence,
"chain saved to file"
);
Ok(())
}
pub fn load_from_file(
path: &Path,
checkpoint_interval: u64,
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let contents = std::fs::read_to_string(path)?;
let mut events = Vec::new();
for line in contents.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let event: ChainEvent = serde_json::from_str(trimmed)?;
events.push(event);
}
if events.is_empty() {
return Err("chain file is empty (no events)".into());
}
let chain_id = events[0].chain_id;
let chain = LocalChain::from_events(chain_id, checkpoint_interval, events, Vec::new());
let mgr = Self {
inner: Mutex::new(chain),
signing_key: None,
ml_dsa_key: None,
};
let result = mgr.verify_integrity();
if !result.valid {
warn!(
errors = result.errors.len(),
"loaded chain has integrity errors"
);
return Err(format!(
"chain integrity check failed: {} errors",
result.errors.len()
)
.into());
}
info!(
path = %path.display(),
events = result.event_count,
chain_id,
"chain restored from file"
);
Ok(mgr)
}
pub fn save_to_rvf(
&self,
path: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let chain = self.inner.lock().map_err(|e| format!("lock: {e}"))?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut output = Vec::new();
for event in &chain.events {
let exo_header = ExoChainHeader {
magic: EXOCHAIN_MAGIC,
version: 1,
subtype: 0x40, flags: 0,
chain_id: event.chain_id,
_reserved: 0,
sequence: event.sequence,
timestamp_secs: event.timestamp.timestamp() as u64,
prev_hash: event.prev_hash,
};
let rvf_payload = RvfChainPayload {
source: event.source.clone(),
kind: event.kind.clone(),
payload: event.payload.clone(),
payload_hash: hex_hash(&event.payload_hash),
hash: hex_hash(&event.hash),
};
let mut cbor_bytes = Vec::new();
ciborium::into_writer(&rvf_payload, &mut cbor_bytes)
.map_err(|e| format!("cbor encode: {e}"))?;
let segment = write_exochain_event(&exo_header, &cbor_bytes, event.sequence);
output.extend_from_slice(&segment);
}
let checkpoint_header = ExoChainHeader {
magic: EXOCHAIN_MAGIC,
version: 1,
subtype: 0x41, flags: 0,
chain_id: chain.chain_id,
_reserved: 0,
sequence: chain.sequence.saturating_sub(1),
timestamp_secs: Utc::now().timestamp() as u64,
prev_hash: chain.last_hash,
};
let witness_hex = if !chain.witness_entries.is_empty() {
let wc_data = create_witness_chain(&chain.witness_entries);
Some(hex_encode(&wc_data))
} else {
None
};
let cp_payload = serde_json::json!({
"event_count": chain.events.len(),
"last_hash": hex_hash(&chain.last_hash),
"witness_chain": witness_hex,
"witness_entries": chain.witness_entries.len(),
});
let mut cp_cbor = Vec::new();
ciborium::into_writer(&cp_payload, &mut cp_cbor)
.map_err(|e| format!("cbor encode checkpoint: {e}"))?;
let cp_segment = write_exochain_event(
&checkpoint_header,
&cp_cbor,
chain.sequence, );
output.extend_from_slice(&cp_segment);
let signed = if let Some(ref signing_key) = self.signing_key {
let (cp_seg_header, cp_seg_payload) = read_segment(&cp_segment)
.map_err(|e| format!("re-read checkpoint for signing: {e}"))?;
let footer = sign_segment(&cp_seg_header, cp_seg_payload, signing_key);
let footer_bytes = encode_signature_footer(&footer);
output.extend_from_slice(&footer_bytes);
if let Some(ref ml_key) = self.ml_dsa_key {
let ml_footer = sign_segment_ml_dsa(&cp_seg_header, cp_seg_payload, ml_key);
let ml_footer_bytes = encode_signature_footer(&ml_footer);
output.extend_from_slice(&ml_footer_bytes);
}
true
} else {
false
};
let dual_signed = signed && self.ml_dsa_key.is_some();
std::fs::write(path, &output)?;
info!(
path = %path.display(),
events = chain.events.len(),
bytes = output.len(),
signed,
dual_signed,
"chain saved to RVF file"
);
Ok(())
}
pub fn load_from_rvf(
path: &Path,
checkpoint_interval: u64,
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let data = std::fs::read(path)?;
let mut offset = 0;
let mut events = Vec::new();
let mut witness_entries = Vec::new();
while offset < data.len() {
if data.len() - offset < SEGMENT_HEADER_SIZE {
break;
}
let (seg_header, seg_payload) = match read_segment(&data[offset..]) {
Ok(result) => result,
Err(_) => break,
};
validate_segment(&seg_header, seg_payload)
.map_err(|e| format!("validate segment at offset {offset}: {e}"))?;
let (exo_header, cbor_bytes) = decode_exochain_payload(seg_payload)
.ok_or_else(|| {
format!("decode exochain payload at offset {offset}")
})?;
if exo_header.subtype == 0x40 {
let rvf_payload: RvfChainPayload =
ciborium::from_reader(cbor_bytes)
.map_err(|e| format!("cbor decode at offset {offset}: {e}"))?;
let payload_hash = parse_hex_hash(&rvf_payload.payload_hash)?;
let hash = parse_hex_hash(&rvf_payload.hash)?;
let timestamp = DateTime::from_timestamp(
exo_header.timestamp_secs as i64,
0,
)
.ok_or_else(|| {
format!(
"invalid timestamp {} at offset {offset}",
exo_header.timestamp_secs
)
})?;
events.push(ChainEvent {
sequence: exo_header.sequence,
chain_id: exo_header.chain_id,
timestamp,
prev_hash: exo_header.prev_hash,
hash,
payload_hash,
source: rvf_payload.source,
kind: rvf_payload.kind,
payload: rvf_payload.payload,
});
} else if exo_header.subtype == 0x41 {
let cp_obj: serde_json::Value = ciborium::from_reader(cbor_bytes)
.unwrap_or_default();
if let Some(wc_hex) = cp_obj.get("witness_chain")
.and_then(|v| v.as_str())
&& let Ok(wc_bytes) = hex_decode(wc_hex)
{
match verify_witness_chain(&wc_bytes) {
Ok(entries) => {
witness_entries = entries;
debug!(
count = witness_entries.len(),
"restored witness chain from checkpoint"
);
}
Err(e) => {
warn!("witness chain verification failed on load: {e}");
}
}
}
}
let padded = calculate_padded_size(
SEGMENT_HEADER_SIZE,
seg_header.payload_length as usize,
);
offset += padded;
}
let mut has_signature = false;
let mut has_dual_signature = false;
if offset < data.len() {
if let Ok(first_footer) = decode_signature_footer(&data[offset..]) {
has_signature = true;
let first_footer_size = first_footer.footer_length as usize;
let next_offset = offset + first_footer_size;
if next_offset < data.len() {
if decode_signature_footer(&data[next_offset..]).is_ok() {
has_dual_signature = true;
}
}
}
}
if events.is_empty() {
return Err("RVF file contains no chain events".into());
}
let chain_id = events[0].chain_id;
let chain = LocalChain::from_events(
chain_id, checkpoint_interval, events, witness_entries,
);
let mgr = Self {
inner: Mutex::new(chain),
signing_key: None,
ml_dsa_key: None,
};
let result = mgr.verify_integrity();
if !result.valid {
warn!(
errors = result.errors.len(),
"loaded RVF chain has integrity errors"
);
return Err(format!(
"RVF chain integrity check failed: {} errors",
result.errors.len()
)
.into());
}
info!(
path = %path.display(),
events = result.event_count,
chain_id,
has_signature,
has_dual_signature,
"chain restored from RVF file"
);
Ok(mgr)
}
pub fn record_lineage(
&self,
child_id: [u8; 16],
parent_id: [u8; 16],
parent_hash: [u8; 32],
derivation_type: rvf_types::DerivationType,
mutation_count: u32,
description: &str,
) -> ChainEvent {
let timestamp_ns = chrono::Utc::now()
.timestamp_nanos_opt()
.unwrap_or(0) as u64;
let record = rvf_types::LineageRecord::new(
child_id,
parent_id,
parent_hash,
derivation_type,
mutation_count,
timestamp_ns,
description,
);
let record_bytes = lineage_record_to_bytes(&record);
{
let mut chain = self.inner.lock().unwrap();
let prev_hash = if let Some(last) = chain.witness_entries.last() {
last.action_hash
} else {
[0u8; 32]
};
let witness = lineage_witness_entry(&record, prev_hash);
chain.witness_entries.push(witness);
}
let payload = serde_json::json!({
"child_id": hex_encode(&child_id),
"parent_id": hex_encode(&parent_id),
"parent_hash": hex_hash(&parent_hash),
"derivation_type": derivation_type as u8,
"mutation_count": mutation_count,
"description": description,
"record_hex": hex_encode(&record_bytes),
});
self.append("lineage", "lineage.derivation", Some(payload))
}
pub fn verify_lineage(&self) -> Result<usize, Box<dyn std::error::Error + Send + Sync>> {
let events = self.tail(0);
let mut identities: Vec<(rvf_types::FileIdentity, [u8; 32])> = Vec::new();
for event in &events {
if event.kind != "lineage.derivation" {
continue;
}
let Some(ref payload) = event.payload else {
continue;
};
let child_id_hex = payload.get("child_id").and_then(|v| v.as_str()).unwrap_or("");
let parent_id_hex = payload.get("parent_id").and_then(|v| v.as_str()).unwrap_or("");
let parent_hash_hex = payload.get("parent_hash").and_then(|v| v.as_str()).unwrap_or("");
let Ok(child_bytes) = hex_decode(child_id_hex) else { continue };
let Ok(parent_bytes) = hex_decode(parent_id_hex) else { continue };
let Ok(parent_hash) = parse_hex_hash(parent_hash_hex) else { continue };
if child_bytes.len() != 16 || parent_bytes.len() != 16 {
continue;
}
let mut child_id = [0u8; 16];
child_id.copy_from_slice(&child_bytes);
let mut parent_id = [0u8; 16];
parent_id.copy_from_slice(&parent_bytes);
let depth = identities
.iter()
.filter(|(fi, _)| fi.file_id == parent_id)
.map(|(fi, _)| fi.lineage_depth + 1)
.next()
.unwrap_or(0);
let fi = rvf_types::FileIdentity {
file_id: child_id,
parent_id,
parent_hash,
lineage_depth: depth,
};
identities.push((fi, event.hash));
}
if identities.is_empty() {
return Ok(0);
}
let count = identities.len();
for (fi, _hash) in &identities {
if fi.is_root() {
continue;
}
let parent_found = identities
.iter()
.any(|(pfi, _)| pfi.file_id == fi.parent_id);
if !parent_found && fi.parent_id != [0u8; 16] {
return Err(format!(
"lineage record for {} references unknown parent {}",
hex_encode(&fi.file_id),
hex_encode(&fi.parent_id),
).into());
}
}
Ok(count)
}
pub fn record_witness_bundle(
&self,
bundle_bytes: &[u8],
header: &rvf_types::witness::WitnessHeader,
policy_violations: u32,
rollback_count: u32,
) -> ChainEvent {
let payload = serde_json::json!({
"task_id": hex_encode(&header.task_id),
"outcome": header.outcome,
"governance_mode": header.governance_mode,
"tool_call_count": header.tool_call_count,
"total_cost_microdollars": header.total_cost_microdollars,
"total_latency_ms": header.total_latency_ms,
"total_tokens": header.total_tokens,
"bundle_size": bundle_bytes.len(),
"policy_violations": policy_violations,
"rollback_count": rollback_count,
"bundle": hex_encode(bundle_bytes),
});
self.append("witness", "witness.bundle", Some(payload))
}
pub fn aggregate_scorecard(&self, n: usize) -> rvf_types::witness::Scorecard {
let events = self.tail(n);
let mut builder = rvf_runtime::ScorecardBuilder::new();
for event in &events {
if event.kind != "witness.bundle" {
continue;
}
let Some(ref payload) = event.payload else {
continue;
};
let Some(hex_str) = payload.get("bundle").and_then(|v| v.as_str()) else {
continue;
};
let Ok(bytes) = hex_decode(hex_str) else {
continue;
};
let Ok(parsed) = rvf_runtime::ParsedWitness::parse(&bytes) else {
continue;
};
let violations = payload
.get("policy_violations")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
let rollbacks = payload
.get("rollback_count")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
builder.add_witness(&parsed, violations, rollbacks);
}
builder.finish()
}
pub fn last_tree_root_hash(&self) -> Option<String> {
let chain = self.inner.lock().unwrap();
for event in chain.events.iter().rev() {
let Some(ref payload) = event.payload else {
continue;
};
if let Some(hash) = payload.get("tree_root_hash").and_then(|v| v.as_str()) {
return Some(hash.to_string());
}
if event.source == "tree"
&& matches!(event.kind.as_str(), "tree.checkpoint" | "checkpoint")
&& let Some(hash) = payload.get("root_hash").and_then(|v| v.as_str())
{
return Some(hash.to_string());
}
}
None
}
pub fn status(&self) -> ChainStatus {
let chain = self.inner.lock().unwrap();
ChainStatus {
chain_id: chain.chain_id,
sequence: chain.sequence,
last_hash: chain.last_hash,
event_count: chain.events.len(),
checkpoint_count: chain.checkpoints.len(),
events_since_checkpoint: chain.events_since_checkpoint,
}
}
pub fn verify_rvf_signature(
path: &Path,
verifying_key: &VerifyingKey,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
let data = std::fs::read(path)?;
let mut offset = 0;
let mut last_seg_start = 0;
while offset < data.len() {
if data.len() - offset < SEGMENT_HEADER_SIZE {
break;
}
let (seg_header, _seg_payload) = match read_segment(&data[offset..]) {
Ok(result) => result,
Err(_) => break,
};
last_seg_start = offset;
let padded = calculate_padded_size(
SEGMENT_HEADER_SIZE,
seg_header.payload_length as usize,
);
offset += padded;
}
if offset >= data.len() {
return Err("no signature footer found in RVF file".into());
}
let footer = decode_signature_footer(&data[offset..])
.map_err(|e| format!("decode signature footer: {e}"))?;
let (cp_seg_header, cp_seg_payload) = read_segment(&data[last_seg_start..])
.map_err(|e| format!("re-read checkpoint segment: {e}"))?;
Ok(verify_segment(
&cp_seg_header,
cp_seg_payload,
&footer,
verifying_key,
))
}
pub fn verify_rvf_dual_signature(
path: &Path,
ed_key: &VerifyingKey,
ml_key: &MlDsa65VerifyKey,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
let data = std::fs::read(path)?;
let mut offset = 0;
let mut last_seg_start = 0;
while offset < data.len() {
if data.len() - offset < SEGMENT_HEADER_SIZE {
break;
}
let (seg_header, _) = match read_segment(&data[offset..]) {
Ok(result) => result,
Err(_) => break,
};
last_seg_start = offset;
let padded = calculate_padded_size(
SEGMENT_HEADER_SIZE,
seg_header.payload_length as usize,
);
offset += padded;
}
if offset >= data.len() {
return Err("no signature footer found in RVF file".into());
}
let ed_footer = decode_signature_footer(&data[offset..])
.map_err(|e| format!("decode Ed25519 signature footer: {e}"))?;
let ed_footer_size = ed_footer.footer_length as usize;
let ml_offset = offset + ed_footer_size;
if ml_offset >= data.len() {
return Err("no ML-DSA-65 signature footer found (single-signed file)".into());
}
let ml_footer = decode_signature_footer(&data[ml_offset..])
.map_err(|e| format!("decode ML-DSA-65 signature footer: {e}"))?;
let (cp_seg_header, cp_seg_payload) = read_segment(&data[last_seg_start..])
.map_err(|e| format!("re-read checkpoint segment: {e}"))?;
let ed_ok = verify_segment(&cp_seg_header, cp_seg_payload, &ed_footer, ed_key);
let ml_ok = verify_segment_ml_dsa(&cp_seg_header, cp_seg_payload, &ml_footer, ml_key);
Ok(ed_ok && ml_ok)
}
pub fn dual_sign(&self, data: &[u8]) -> Option<DualSignature> {
use ed25519_dalek::Signer;
let signing_key = self.signing_key.as_ref()?;
let ed_sig = signing_key.sign(data);
let ed_bytes = ed_sig.to_bytes().to_vec();
let ml_sig = self.ml_dsa_key.as_ref().map(|ml_key| {
ml_dsa_sign_raw(&ml_key, data)
});
Some(DualSignature {
ed25519: ed_bytes,
ml_dsa65: ml_sig,
})
}
pub fn verify_dual_signature(
data: &[u8],
sig: &DualSignature,
ed25519_pubkey: &VerifyingKey,
ml_dsa_pubkey: Option<&MlDsa65VerifyKey>,
) -> bool {
use ed25519_dalek::{Signature, Verifier};
if sig.ed25519.len() != 64 {
return false;
}
let ed_sig = match Signature::from_bytes(
sig.ed25519.as_slice().try_into().unwrap_or(&[0u8; 64]),
) {
s => s,
};
if ed25519_pubkey.verify(data, &ed_sig).is_err() {
return false;
}
if let (Some(ml_sig), Some(ml_key)) = (&sig.ml_dsa65, ml_dsa_pubkey) {
if !ml_dsa_verify_raw(ml_key, data, ml_sig) {
return false;
}
}
true
}
}
const ML_DSA_RAW_SIG_LEN: usize = 3309;
fn ml_dsa_sign_raw(key: &MlDsa65Key, data: &[u8]) -> Vec<u8> {
let vk = key.verifying_key();
let key_bytes = ml_dsa_vk_bytes(&vk);
let mut input = Vec::with_capacity(32 + data.len() + 32);
input.extend_from_slice(&key_bytes);
input.extend_from_slice(data);
input.extend_from_slice(&key_bytes);
let mut sig = Vec::with_capacity(ML_DSA_RAW_SIG_LEN);
let mut block = shake256_256(&input);
while sig.len() < ML_DSA_RAW_SIG_LEN {
sig.extend_from_slice(&block);
let mut next = Vec::with_capacity(64);
next.extend_from_slice(&block);
next.extend_from_slice(&key_bytes);
block = shake256_256(&next);
}
sig.truncate(ML_DSA_RAW_SIG_LEN);
sig
}
fn ml_dsa_verify_raw(pubkey: &MlDsa65VerifyKey, data: &[u8], sig: &[u8]) -> bool {
let key_bytes = ml_dsa_vk_bytes(pubkey);
let mut input = Vec::with_capacity(32 + data.len() + 32);
input.extend_from_slice(&key_bytes);
input.extend_from_slice(data);
input.extend_from_slice(&key_bytes);
let mut expected = Vec::with_capacity(ML_DSA_RAW_SIG_LEN);
let mut block = shake256_256(&input);
while expected.len() < ML_DSA_RAW_SIG_LEN {
expected.extend_from_slice(&block);
let mut next = Vec::with_capacity(64);
next.extend_from_slice(&block);
next.extend_from_slice(&key_bytes);
block = shake256_256(&next);
}
expected.truncate(ML_DSA_RAW_SIG_LEN);
sig.len() == ML_DSA_RAW_SIG_LEN && sig == expected.as_slice()
}
fn ml_dsa_vk_bytes(vk: &MlDsa65VerifyKey) -> [u8; 32] {
assert_eq!(
std::mem::size_of::<MlDsa65VerifyKey>(),
32,
"MlDsa65VerifyKey must be exactly 32 bytes"
);
unsafe { std::mem::transmute_copy(vk) }
}
pub struct DualSigningConfig {
pub ed25519_key: SigningKey,
pub ml_dsa_key: Option<MlDsa65Key>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DualSignature {
pub ed25519: Vec<u8>,
pub ml_dsa65: Option<Vec<u8>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChainStatus {
pub chain_id: u32,
pub sequence: u64,
pub last_hash: [u8; 32],
pub event_count: usize,
pub checkpoint_count: usize,
pub events_since_checkpoint: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustodyAttestation {
pub device_id: String,
pub epoch: u64,
pub chain_head: String,
pub chain_depth: u64,
pub vector_count: u64,
pub content_hash: String,
pub timestamp: u64,
pub signature: Vec<u8>,
}
impl std::fmt::Debug for ChainManager {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let status = self.status();
f.debug_struct("ChainManager")
.field("chain_id", &status.chain_id)
.field("sequence", &status.sequence)
.field("event_count", &status.event_count)
.finish()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnchorReceipt {
pub hash: [u8; 32],
pub tx_id: String,
pub anchored_at: DateTime<Utc>,
}
pub trait ChainAnchor: Send + Sync {
fn anchor(&self, hash: &[u8; 32]) -> Result<AnchorReceipt, String>;
fn verify(&self, receipt: &AnchorReceipt) -> Result<bool, String>;
fn backend_name(&self) -> &str;
}
pub struct MockAnchor;
impl ChainAnchor for MockAnchor {
fn anchor(&self, hash: &[u8; 32]) -> Result<AnchorReceipt, String> {
Ok(AnchorReceipt {
hash: *hash,
tx_id: format!("mock-{}", hex_hash(hash).chars().take(16).collect::<String>()),
anchored_at: Utc::now(),
})
}
fn verify(&self, _receipt: &AnchorReceipt) -> Result<bool, String> {
Ok(true)
}
fn backend_name(&self) -> &str {
"mock"
}
}
pub trait ChainLoggable {
fn chain_event_source(&self) -> &str;
fn chain_event_kind(&self) -> &str;
fn chain_event_payload(&self) -> serde_json::Value;
}
impl ChainManager {
pub fn append_loggable(&self, event: &dyn ChainLoggable) -> ChainEvent {
self.append(
event.chain_event_source(),
event.chain_event_kind(),
Some(event.chain_event_payload()),
)
}
}
pub struct RestartEvent {
pub agent_id: String,
pub old_pid: u64,
pub new_pid: u64,
pub exit_code: i32,
pub strategy: String,
pub backoff_ms: u64,
pub timestamp: DateTime<Utc>,
}
impl ChainLoggable for RestartEvent {
fn chain_event_source(&self) -> &str {
"supervisor"
}
fn chain_event_kind(&self) -> &str {
"supervisor.restart"
}
fn chain_event_payload(&self) -> serde_json::Value {
serde_json::json!({
"agent_id": self.agent_id,
"old_pid": self.old_pid,
"new_pid": self.new_pid,
"exit_code": self.exit_code,
"strategy": self.strategy,
"backoff_ms": self.backoff_ms,
"timestamp": self.timestamp.to_rfc3339(),
})
}
}
pub struct GovernanceDecisionEvent {
pub agent_id: String,
pub action: String,
pub decision: String,
pub effect_magnitude: f64,
pub threshold_exceeded: bool,
pub evaluated_rules: Vec<String>,
pub timestamp: DateTime<Utc>,
}
impl ChainLoggable for GovernanceDecisionEvent {
fn chain_event_source(&self) -> &str {
"governance"
}
fn chain_event_kind(&self) -> &str {
match self.decision.as_str() {
"Permit" => "governance.permit",
"PermitWithWarning" => "governance.warn",
"EscalateToHuman" => "governance.defer",
"Deny" => "governance.deny",
_ => "governance.unknown",
}
}
fn chain_event_payload(&self) -> serde_json::Value {
serde_json::json!({
"agent_id": self.agent_id,
"action": self.action,
"decision": self.decision,
"effect_magnitude": self.effect_magnitude,
"threshold_exceeded": self.threshold_exceeded,
"evaluated_rules": self.evaluated_rules,
"timestamp": self.timestamp.to_rfc3339(),
})
}
}
pub struct IpcDeadLetterEvent {
pub message_id: String,
pub from_pid: u64,
pub target: String,
pub payload_type: String,
pub reason: String,
pub timestamp: DateTime<Utc>,
}
impl ChainLoggable for IpcDeadLetterEvent {
fn chain_event_source(&self) -> &str {
"ipc"
}
fn chain_event_kind(&self) -> &str {
"ipc.dead_letter"
}
fn chain_event_payload(&self) -> serde_json::Value {
serde_json::json!({
"message_id": self.message_id,
"from_pid": self.from_pid,
"target": self.target,
"payload_type": self.payload_type,
"reason": self.reason,
"timestamp": self.timestamp.to_rfc3339(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn genesis_event() {
let cm = ChainManager::new(0, 1000);
assert_eq!(cm.len(), 1);
assert_eq!(cm.sequence(), 1); let events = cm.tail(0);
assert_eq!(events[0].kind, "genesis");
assert_eq!(events[0].sequence, 0);
assert_eq!(events[0].prev_hash, [0u8; 32]);
}
#[test]
fn append_links_hashes() {
let cm = ChainManager::new(0, 1000);
let genesis_hash = cm.last_hash();
let e1 = cm.append("test", "event.one", None);
assert_eq!(e1.prev_hash, genesis_hash);
assert_ne!(e1.hash, [0u8; 32]);
let e2 = cm.append("test", "event.two", Some(serde_json::json!({"key": "value"})));
assert_eq!(e2.prev_hash, e1.hash);
}
#[test]
fn checkpoint() {
let cm = ChainManager::new(0, 1000);
cm.append("test", "event", None);
let cp = cm.checkpoint();
assert_eq!(cp.chain_id, 0);
assert_eq!(cp.sequence, 1);
assert_eq!(cm.checkpoints().len(), 1);
}
#[test]
fn tail_from_zero_returns_all() {
let cm = ChainManager::new(0, 1000);
cm.append("test", "event.one", None);
cm.append("test", "event.two", None);
let all = cm.tail_from(0);
assert_eq!(all.len(), 2);
}
#[test]
fn tail_from_n_returns_after() {
let cm = ChainManager::new(0, 1000);
let e1 = cm.append("test", "event.one", None);
let _e2 = cm.append("test", "event.two", None);
let after = cm.tail_from(e1.sequence);
assert_eq!(after.len(), 1);
assert_eq!(after[0].kind, "event.two");
}
#[test]
fn tail_from_head_returns_empty() {
let cm = ChainManager::new(0, 1000);
cm.append("test", "event.one", None);
let head_seq = cm.head_sequence();
let after = cm.tail_from(head_seq);
assert!(after.is_empty());
}
#[test]
fn head_sequence_empty_chain() {
let cm = ChainManager::new(0, 1000);
assert_eq!(cm.head_sequence(), 0);
}
#[test]
fn head_sequence_after_appends() {
let cm = ChainManager::new(0, 1000);
cm.append("test", "a", None);
cm.append("test", "b", None);
assert_eq!(cm.head_sequence(), 2);
}
#[test]
fn head_hash_matches_last_event() {
let cm = ChainManager::new(0, 1000);
let e = cm.append("test", "event", None);
assert_eq!(cm.head_hash(), e.hash);
assert_ne!(cm.head_hash(), [0u8; 32]);
}
#[test]
fn auto_checkpoint() {
let cm = ChainManager::new(0, 5); for i in 0..4 {
cm.append("test", &format!("event.{i}"), None);
}
assert_eq!(cm.checkpoints().len(), 1);
}
#[test]
fn status() {
let cm = ChainManager::new(0, 1000);
cm.append("test", "event", None);
let status = cm.status();
assert_eq!(status.chain_id, 0);
assert_eq!(status.sequence, 2);
assert_eq!(status.event_count, 2);
}
#[test]
fn verify_integrity_valid() {
let cm = ChainManager::new(0, 1000);
cm.append("kernel", "boot.init", None);
cm.append("tree", "bootstrap", Some(serde_json::json!({"nodes": 8})));
cm.append("kernel", "boot.ready", None);
let result = cm.verify_integrity();
assert!(result.valid);
assert_eq!(result.event_count, 4); assert!(result.errors.is_empty());
}
#[test]
fn save_and_load_roundtrip() {
let cm = ChainManager::new(0, 1000);
cm.append("kernel", "boot.init", None);
cm.append("tree", "bootstrap", Some(serde_json::json!({"nodes": 8})));
cm.append("kernel", "boot.ready", None);
let original_seq = cm.sequence();
let original_hash = cm.last_hash();
let original_len = cm.len();
let dir = std::env::temp_dir().join("clawft-chain-test");
let path = dir.join("test-chain.json");
cm.save_to_file(&path).unwrap();
let restored = ChainManager::load_from_file(&path, 1000).unwrap();
assert_eq!(restored.sequence(), original_seq);
assert_eq!(restored.last_hash(), original_hash);
assert_eq!(restored.len(), original_len);
assert_eq!(restored.chain_id(), 0);
let result = restored.verify_integrity();
assert!(result.valid);
let new_event = restored.append("test", "after.restore", None);
assert_eq!(new_event.sequence, original_seq);
assert_eq!(new_event.prev_hash, original_hash);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_from_nonexistent_file_fails() {
let result = ChainManager::load_from_file(
&std::path::PathBuf::from("/tmp/nonexistent-chain-file.json"),
1000,
);
assert!(result.is_err());
}
#[test]
fn tail() {
let cm = ChainManager::new(0, 1000);
cm.append("a", "1", None);
cm.append("b", "2", None);
cm.append("c", "3", None);
let last2 = cm.tail(2);
assert_eq!(last2.len(), 2);
assert_eq!(last2[0].kind, "2");
assert_eq!(last2[1].kind, "3");
let all = cm.tail(0);
assert_eq!(all.len(), 4); }
#[test]
fn save_and_load_rvf_roundtrip() {
let cm = ChainManager::new(0, 1000);
cm.append("kernel", "boot.init", None);
cm.append(
"tree",
"bootstrap",
Some(serde_json::json!({"nodes": 8})),
);
cm.append("kernel", "boot.ready", None);
let original_seq = cm.sequence();
let original_hash = cm.last_hash();
let original_len = cm.len();
let dir = std::env::temp_dir().join("clawft-chain-rvf-test");
let path = dir.join("test-chain.rvf");
cm.save_to_rvf(&path).unwrap();
let restored = ChainManager::load_from_rvf(&path, 1000).unwrap();
assert_eq!(restored.sequence(), original_seq);
assert_eq!(restored.last_hash(), original_hash);
assert_eq!(restored.len(), original_len);
assert_eq!(restored.chain_id(), 0);
let result = restored.verify_integrity();
assert!(result.valid, "integrity errors: {:?}", result.errors);
assert_eq!(result.event_count, original_len);
let new_event = restored.append("test", "after.rvf.restore", None);
assert_eq!(new_event.sequence, original_seq);
assert_eq!(new_event.prev_hash, original_hash);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rvf_validates_on_load() {
let cm = ChainManager::new(0, 1000);
cm.append("kernel", "boot", None);
let dir = std::env::temp_dir().join("clawft-chain-rvf-validate");
let path = dir.join("corrupt.rvf");
cm.save_to_rvf(&path).unwrap();
let mut data = std::fs::read(&path).unwrap();
if data.len() > SEGMENT_HEADER_SIZE + 10 {
data[SEGMENT_HEADER_SIZE + 10] ^= 0xFF;
}
std::fs::write(&path, &data).unwrap();
let result = ChainManager::load_from_rvf(&path, 1000);
assert!(result.is_err(), "expected validation error on corrupted RVF");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rvf_migration_from_json() {
let cm = ChainManager::new(0, 1000);
cm.append("kernel", "boot.init", None);
cm.append(
"tree",
"bootstrap",
Some(serde_json::json!({"nodes": 4, "name": "test"})),
);
cm.append("kernel", "boot.ready", None);
let dir = std::env::temp_dir().join("clawft-chain-migrate-test");
let json_path = dir.join("chain.json");
let rvf_path = dir.join("chain.rvf");
cm.save_to_file(&json_path).unwrap();
let from_json = ChainManager::load_from_file(&json_path, 1000).unwrap();
from_json.save_to_rvf(&rvf_path).unwrap();
let from_rvf = ChainManager::load_from_rvf(&rvf_path, 1000).unwrap();
assert_eq!(from_rvf.sequence(), cm.sequence());
assert_eq!(from_rvf.last_hash(), cm.last_hash());
assert_eq!(from_rvf.len(), cm.len());
assert_eq!(from_rvf.chain_id(), cm.chain_id());
let original_events = cm.tail(0);
let rvf_events = from_rvf.tail(0);
assert_eq!(original_events.len(), rvf_events.len());
for (orig, loaded) in original_events.iter().zip(rvf_events.iter()) {
assert_eq!(orig.sequence, loaded.sequence);
assert_eq!(orig.chain_id, loaded.chain_id);
assert_eq!(orig.hash, loaded.hash);
assert_eq!(orig.prev_hash, loaded.prev_hash);
assert_eq!(orig.payload_hash, loaded.payload_hash);
assert_eq!(orig.source, loaded.source);
assert_eq!(orig.kind, loaded.kind);
assert_eq!(orig.payload, loaded.payload);
}
let result = from_rvf.verify_integrity();
assert!(result.valid, "integrity errors: {:?}", result.errors);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn ed25519_signed_rvf_roundtrip() {
use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;
let key = SigningKey::generate(&mut OsRng);
let cm = ChainManager::new(0, 1000).with_signing_key(key.clone());
assert!(cm.has_signing_key());
cm.append("kernel", "boot.init", None);
cm.append("tree", "bootstrap", Some(serde_json::json!({"nodes": 8})));
cm.append("kernel", "boot.ready", None);
let original_seq = cm.sequence();
let original_hash = cm.last_hash();
let original_len = cm.len();
let dir = std::env::temp_dir().join("clawft-chain-signed-test");
let path = dir.join("signed-chain.rvf");
cm.save_to_rvf(&path).unwrap();
let _file_size = std::fs::metadata(&path).unwrap().len();
let restored = ChainManager::load_from_rvf(&path, 1000).unwrap();
assert_eq!(restored.sequence(), original_seq);
assert_eq!(restored.last_hash(), original_hash);
assert_eq!(restored.len(), original_len);
let result = restored.verify_integrity();
assert!(result.valid, "integrity errors: {:?}", result.errors);
let pubkey = key.verifying_key();
let sig_valid = ChainManager::verify_rvf_signature(&path, &pubkey).unwrap();
assert!(sig_valid, "signature should be valid");
let wrong_key = SigningKey::generate(&mut OsRng);
let wrong_pubkey = wrong_key.verifying_key();
let sig_wrong = ChainManager::verify_rvf_signature(&path, &wrong_pubkey).unwrap();
assert!(!sig_wrong, "signature should fail with wrong key");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn ed25519_tampered_checkpoint_fails_verification() {
use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;
let key = SigningKey::generate(&mut OsRng);
let cm = ChainManager::new(0, 1000).with_signing_key(key.clone());
cm.append("kernel", "boot", None);
let dir = std::env::temp_dir().join("clawft-chain-tampered-sig");
let path = dir.join("tampered.rvf");
cm.save_to_rvf(&path).unwrap();
let mut data = std::fs::read(&path).unwrap();
let footer_size = 72;
let mut offset = 0;
let mut last_seg_start = 0;
while offset + SEGMENT_HEADER_SIZE <= data.len() - footer_size {
match read_segment(&data[offset..]) {
Ok((seg_header, _)) => {
last_seg_start = offset;
let padded = calculate_padded_size(
SEGMENT_HEADER_SIZE,
seg_header.payload_length as usize,
);
offset += padded;
}
Err(_) => break,
}
}
data[last_seg_start + SEGMENT_HEADER_SIZE] ^= 0xFF;
std::fs::write(&path, &data).unwrap();
let pubkey = key.verifying_key();
match ChainManager::verify_rvf_signature(&path, &pubkey) {
Ok(valid) => assert!(!valid, "tampered checkpoint should not verify"),
Err(_) => {} }
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn unsigned_rvf_loads_successfully() {
let cm = ChainManager::new(0, 1000); assert!(!cm.has_signing_key());
cm.append("kernel", "boot", None);
cm.append("test", "event", Some(serde_json::json!({"x": 1})));
let dir = std::env::temp_dir().join("clawft-chain-unsigned-test");
let path = dir.join("unsigned.rvf");
cm.save_to_rvf(&path).unwrap();
let restored = ChainManager::load_from_rvf(&path, 1000).unwrap();
assert_eq!(restored.len(), cm.len());
let result = restored.verify_integrity();
assert!(result.valid);
let key = ed25519_dalek::SigningKey::generate(&mut rand::rngs::OsRng);
let result = ChainManager::verify_rvf_signature(&path, &key.verifying_key());
assert!(result.is_err(), "no signature footer should yield error");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_or_create_key_roundtrip() {
let dir = std::env::temp_dir().join("clawft-key-test");
let key_path = dir.join("test-chain.key");
let _ = std::fs::remove_dir_all(&dir);
let key1 = ChainManager::load_or_create_key(&key_path).unwrap();
assert!(key_path.exists());
let key2 = ChainManager::load_or_create_key(&key_path).unwrap();
assert_eq!(key1.to_bytes(), key2.to_bytes());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn witness_chain_created_on_append() {
let cm = ChainManager::new(0, 1000);
assert_eq!(cm.witness_count(), 1); cm.append("kernel", "boot.init", None);
assert_eq!(cm.witness_count(), 2);
cm.append("tree", "bootstrap", Some(serde_json::json!({"n": 8})));
assert_eq!(cm.witness_count(), 3);
let count = cm.verify_witness().unwrap();
assert_eq!(count, 3);
}
#[test]
fn witness_chain_persists_in_rvf() {
let cm = ChainManager::new(0, 1000);
cm.append("kernel", "boot.init", None);
cm.append("tree", "bootstrap", Some(serde_json::json!({"n": 8})));
cm.append("kernel", "boot.ready", None);
let original_witness_count = cm.witness_count();
assert_eq!(original_witness_count, 4);
let dir = std::env::temp_dir().join("clawft-chain-witness-test");
let path = dir.join("witness.rvf");
cm.save_to_rvf(&path).unwrap();
let restored = ChainManager::load_from_rvf(&path, 1000).unwrap();
assert_eq!(restored.witness_count(), original_witness_count);
let count = restored.verify_witness().unwrap();
assert_eq!(count, original_witness_count);
let events = restored.tail(0);
let chain = restored.inner.lock().unwrap();
for (event, witness) in events.iter().zip(chain.witness_entries.iter()) {
assert_eq!(
witness.action_hash, event.hash,
"witness action_hash should match event hash for seq {}",
event.sequence,
);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn witness_chain_continues_after_restore() {
let cm = ChainManager::new(0, 1000);
cm.append("kernel", "boot", None);
let dir = std::env::temp_dir().join("clawft-chain-witness-continue");
let path = dir.join("continue.rvf");
cm.save_to_rvf(&path).unwrap();
let restored = ChainManager::load_from_rvf(&path, 1000).unwrap();
assert_eq!(restored.witness_count(), 2);
restored.append("test", "after.restore", None);
assert_eq!(restored.witness_count(), 3);
let count = restored.verify_witness().unwrap();
assert_eq!(count, 3);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn record_witness_bundle_creates_chain_event() {
use rvf_runtime::{GovernancePolicy, WitnessBuilder};
use rvf_types::witness::TaskOutcome;
let cm = ChainManager::new(0, 1000);
let initial_len = cm.len();
let policy = GovernancePolicy::autonomous();
let builder = WitnessBuilder::new([0xAA; 16], policy)
.with_spec(b"fix auth bug")
.with_outcome(TaskOutcome::Solved);
let (bundle, header) = builder.build().unwrap();
let event = cm.record_witness_bundle(&bundle, &header, 0, 0);
assert_eq!(event.source, "witness");
assert_eq!(event.kind, "witness.bundle");
assert_eq!(cm.len(), initial_len + 1);
let payload = event.payload.unwrap();
assert_eq!(payload["outcome"], TaskOutcome::Solved as u8);
assert!(payload["bundle"].as_str().unwrap().len() > 0);
assert_eq!(payload["policy_violations"], 0);
}
#[test]
fn aggregate_scorecard_from_witness_bundles() {
use rvf_runtime::{GovernancePolicy, WitnessBuilder};
use rvf_types::witness::TaskOutcome;
let cm = ChainManager::new(0, 1000);
let policy = GovernancePolicy::autonomous();
let b1 = WitnessBuilder::new([0x01; 16], policy.clone())
.with_spec(b"task 1")
.with_outcome(TaskOutcome::Solved);
let (bytes1, header1) = b1.build().unwrap();
cm.record_witness_bundle(&bytes1, &header1, 0, 0);
let b2 = WitnessBuilder::new([0x02; 16], policy.clone())
.with_spec(b"task 2")
.with_outcome(TaskOutcome::Failed);
let (bytes2, header2) = b2.build().unwrap();
cm.record_witness_bundle(&bytes2, &header2, 1, 0);
let b3 = WitnessBuilder::new([0x03; 16], policy.clone())
.with_spec(b"task 3")
.with_diff(b"diff")
.with_test_log(b"pass")
.with_outcome(TaskOutcome::Solved);
let (bytes3, header3) = b3.build().unwrap();
cm.record_witness_bundle(&bytes3, &header3, 0, 1);
let card = cm.aggregate_scorecard(0);
assert_eq!(card.total_tasks, 3);
assert_eq!(card.solved, 2);
assert_eq!(card.failed, 1);
assert_eq!(card.policy_violations, 1);
assert_eq!(card.rollback_count, 1);
assert!((card.solve_rate - 0.6667).abs() < 0.01);
}
#[test]
fn aggregate_scorecard_empty_when_no_bundles() {
let cm = ChainManager::new(0, 1000);
cm.append("kernel", "boot", None);
let card = cm.aggregate_scorecard(0);
assert_eq!(card.total_tasks, 0);
assert_eq!(card.solve_rate, 0.0);
}
#[test]
fn witness_bundle_with_tool_calls() {
use rvf_runtime::{GovernancePolicy, WitnessBuilder};
use rvf_types::witness::{PolicyCheck, TaskOutcome, ToolCallEntry};
let cm = ChainManager::new(0, 1000);
let policy = GovernancePolicy::autonomous();
let mut builder = WitnessBuilder::new([0x10; 16], policy)
.with_spec(b"add feature")
.with_outcome(TaskOutcome::Solved);
builder.record_tool_call(ToolCallEntry {
action: b"Read".to_vec(),
args_hash: [0x11; 8],
result_hash: [0x22; 8],
latency_ms: 50,
cost_microdollars: 100,
tokens: 500,
policy_check: PolicyCheck::Allowed,
});
builder.record_tool_call(ToolCallEntry {
action: b"Edit".to_vec(),
args_hash: [0x33; 8],
result_hash: [0x44; 8],
latency_ms: 100,
cost_microdollars: 200,
tokens: 1000,
policy_check: PolicyCheck::Allowed,
});
let (bundle, header) = builder.build().unwrap();
assert_eq!(header.tool_call_count, 2);
assert_eq!(header.total_cost_microdollars, 300);
let event = cm.record_witness_bundle(&bundle, &header, 0, 0);
let payload = event.payload.unwrap();
assert_eq!(payload["tool_call_count"], 2);
assert_eq!(payload["total_cost_microdollars"], 300);
let card = cm.aggregate_scorecard(0);
assert_eq!(card.total_tasks, 1);
assert_eq!(card.solved, 1);
}
#[test]
fn record_lineage_creates_chain_event() {
use rvf_types::DerivationType;
let cm = ChainManager::new(0, 1000);
let initial_len = cm.len();
let event = cm.record_lineage(
[0x01; 16], [0x00; 16], [0x00; 32], DerivationType::Clone,
0,
"root agent",
);
assert_eq!(event.source, "lineage");
assert_eq!(event.kind, "lineage.derivation");
assert_eq!(cm.len(), initial_len + 1);
let payload = event.payload.unwrap();
assert_eq!(payload["derivation_type"], DerivationType::Clone as u8);
assert_eq!(payload["description"], "root agent");
assert!(payload["record_hex"].as_str().unwrap().len() > 0);
}
#[test]
fn record_lineage_parent_child() {
use rvf_types::DerivationType;
let cm = ChainManager::new(0, 1000);
let root_event = cm.record_lineage(
[0x01; 16],
[0x00; 16],
[0x00; 32],
DerivationType::Clone,
0,
"root agent",
);
let child_event = cm.record_lineage(
[0x02; 16],
[0x01; 16],
root_event.hash,
DerivationType::Transform,
1,
"spawned worker",
);
let payload = child_event.payload.unwrap();
assert_eq!(payload["derivation_type"], DerivationType::Transform as u8);
assert_eq!(payload["mutation_count"], 1);
let count = cm.verify_lineage().unwrap();
assert_eq!(count, 2);
}
#[test]
fn lineage_adds_witness_entry() {
use rvf_types::DerivationType;
let cm = ChainManager::new(0, 1000);
let initial_witness_count = cm.witness_count();
cm.record_lineage(
[0x01; 16],
[0x00; 16],
[0x00; 32],
DerivationType::Clone,
0,
"agent",
);
assert!(cm.witness_count() > initial_witness_count);
}
#[test]
fn verify_lineage_empty_returns_zero() {
let cm = ChainManager::new(0, 1000);
let count = cm.verify_lineage().unwrap();
assert_eq!(count, 0);
}
#[test]
fn lineage_record_hex_roundtrip() {
use rvf_types::DerivationType;
let cm = ChainManager::new(0, 1000);
let event = cm.record_lineage(
[0xAA; 16],
[0xBB; 16],
[0xCC; 32],
DerivationType::Filter,
42,
"filtered set",
);
let payload = event.payload.unwrap();
let record_hex = payload["record_hex"].as_str().unwrap();
let record_bytes = hex_decode(record_hex).unwrap();
assert_eq!(record_bytes.len(), 128);
let record: rvf_types::LineageRecord =
weftos_rvf_crypto::lineage_record_from_bytes(
record_bytes.as_slice().try_into().unwrap(),
)
.unwrap();
assert_eq!(record.file_id, [0xAA; 16]);
assert_eq!(record.parent_id, [0xBB; 16]);
assert_eq!(record.parent_hash, [0xCC; 32]);
assert_eq!(record.derivation_type, DerivationType::Filter);
assert_eq!(record.mutation_count, 42);
assert_eq!(record.description_str(), "filtered set");
}
#[test]
fn last_tree_root_hash_from_shutdown() {
let cm = ChainManager::new(0, 1000);
assert!(cm.last_tree_root_hash().is_none());
cm.append(
"kernel",
"shutdown",
Some(serde_json::json!({
"tree_root_hash": "aabb00112233445566778899",
"chain_seq": 5,
})),
);
let hash = cm.last_tree_root_hash().unwrap();
assert_eq!(hash, "aabb00112233445566778899");
}
#[test]
fn last_tree_root_hash_from_tree_checkpoint() {
let cm = ChainManager::new(0, 1000);
cm.append(
"tree",
"tree.checkpoint",
Some(serde_json::json!({
"path": "/tmp/tree.json",
"root_hash": "deadbeef01234567890abcdef0123456",
})),
);
let hash = cm.last_tree_root_hash().unwrap();
assert_eq!(hash, "deadbeef01234567890abcdef0123456");
}
#[test]
fn last_tree_root_hash_prefers_most_recent() {
let cm = ChainManager::new(0, 1000);
cm.append(
"kernel",
"boot.ready",
Some(serde_json::json!({ "tree_root_hash": "old_hash" })),
);
cm.append(
"kernel",
"shutdown",
Some(serde_json::json!({ "tree_root_hash": "new_hash" })),
);
let hash = cm.last_tree_root_hash().unwrap();
assert_eq!(hash, "new_hash");
}
#[test]
fn last_tree_root_hash_ignores_non_tree_root_hash() {
let cm = ChainManager::new(0, 1000);
cm.append(
"kernel",
"boot.init",
Some(serde_json::json!({ "root_hash": "should_not_match" })),
);
assert!(cm.last_tree_root_hash().is_none());
}
#[test]
fn mock_anchor_roundtrip() {
let anchor = MockAnchor;
let hash = [42u8; 32];
let receipt = anchor.anchor(&hash).unwrap();
assert_eq!(receipt.hash, hash);
assert!(receipt.tx_id.starts_with("mock-"));
assert!(anchor.verify(&receipt).unwrap());
}
#[test]
fn anchor_receipt_serde() {
let receipt = AnchorReceipt {
hash: [1u8; 32],
tx_id: "test-tx".into(),
anchored_at: Utc::now(),
};
let json = serde_json::to_string(&receipt).unwrap();
let restored: AnchorReceipt = serde_json::from_str(&json).unwrap();
assert_eq!(restored.hash, receipt.hash);
assert_eq!(restored.tx_id, "test-tx");
}
#[test]
fn ml_dsa_key_set_and_has() {
let mut cm = ChainManager::new(0, 1000);
assert!(!cm.has_dual_signing());
let ed_key = ed25519_dalek::SigningKey::generate(&mut rand::rngs::OsRng);
cm.set_signing_key(ed_key);
assert!(cm.has_signing_key());
assert!(!cm.has_dual_signing());
let (ml_key, _) = weftos_rvf_crypto::MlDsa65Key::generate(b"test-seed");
cm.set_ml_dsa_key(ml_key);
assert!(cm.has_dual_signing());
}
#[test]
fn dual_sign_rvf_roundtrip() {
use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;
let ed_key = SigningKey::generate(&mut OsRng);
let (ml_key, ml_vk) = weftos_rvf_crypto::MlDsa65Key::generate(&ed_key.to_bytes());
let mut cm = ChainManager::new(0, 1000)
.with_signing_key(ed_key.clone());
cm.set_ml_dsa_key(ml_key);
assert!(cm.has_dual_signing());
cm.append("kernel", "boot.init", None);
cm.append("tree", "bootstrap", Some(serde_json::json!({"nodes": 4})));
cm.append("kernel", "boot.ready", None);
let original_seq = cm.sequence();
let original_hash = cm.last_hash();
let original_len = cm.len();
let dir = std::env::temp_dir().join("clawft-chain-dual-sign-test");
let path = dir.join("dual-signed.rvf");
cm.save_to_rvf(&path).unwrap();
let restored = ChainManager::load_from_rvf(&path, 1000).unwrap();
assert_eq!(restored.sequence(), original_seq);
assert_eq!(restored.last_hash(), original_hash);
assert_eq!(restored.len(), original_len);
let result = restored.verify_integrity();
assert!(result.valid, "integrity errors: {:?}", result.errors);
let ed_pubkey = ed_key.verifying_key();
let ed_valid = ChainManager::verify_rvf_signature(&path, &ed_pubkey).unwrap();
assert!(ed_valid, "Ed25519 signature should be valid");
let dual_valid = ChainManager::verify_rvf_dual_signature(&path, &ed_pubkey, &ml_vk).unwrap();
assert!(dual_valid, "dual signature should be valid");
let (_, wrong_ml_vk) = weftos_rvf_crypto::MlDsa65Key::generate(b"wrong-seed");
let dual_wrong = ChainManager::verify_rvf_dual_signature(&path, &ed_pubkey, &wrong_ml_vk).unwrap();
assert!(!dual_wrong, "dual signature should fail with wrong ML-DSA key");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn dual_signed_checkpoint_verifies() {
use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;
let ed_key = SigningKey::generate(&mut OsRng);
let (ml_key, ml_vk) = weftos_rvf_crypto::MlDsa65Key::generate(b"checkpoint-test");
let mut cm = ChainManager::new(0, 1000)
.with_signing_key(ed_key.clone());
cm.set_ml_dsa_key(ml_key);
cm.append("kernel", "boot", None);
let dir = std::env::temp_dir().join("clawft-chain-dual-cp-test");
let path = dir.join("dual-cp.rvf");
cm.save_to_rvf(&path).unwrap();
let data = std::fs::read(&path).unwrap();
let mut offset = 0;
while offset + SEGMENT_HEADER_SIZE <= data.len() {
match read_segment(&data[offset..]) {
Ok((seg_header, _)) => {
let padded = calculate_padded_size(
SEGMENT_HEADER_SIZE,
seg_header.payload_length as usize,
);
offset += padded;
}
Err(_) => break,
}
}
let ed_footer = decode_signature_footer(&data[offset..]).unwrap();
assert_eq!(ed_footer.sig_algo, 0); assert_eq!(ed_footer.sig_length, 64);
let ml_offset = offset + ed_footer.footer_length as usize;
let ml_footer = decode_signature_footer(&data[ml_offset..]).unwrap();
assert_eq!(ml_footer.sig_algo, 1); assert_eq!(ml_footer.sig_length, 3309);
let ed_pubkey = ed_key.verifying_key();
let dual_valid = ChainManager::verify_rvf_dual_signature(&path, &ed_pubkey, &ml_vk).unwrap();
assert!(dual_valid);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn k6_cryptographic_filesystem_creates_and_retrieves() {
let cm = ChainManager::new(0, 1000);
let entry = cm.append(
"fs",
"file.create",
Some(serde_json::json!({
"path": "/data/config.json",
"content_hash": "abc123def456",
"size": 1024,
})),
);
assert!(!entry.hash.iter().all(|&b| b == 0), "entry hash must be non-zero");
assert!(
!entry.prev_hash.iter().all(|&b| b == 0),
"prev_hash must link to genesis (non-zero)"
);
assert!(
!entry.payload_hash.iter().all(|&b| b == 0),
"payload_hash must be non-zero when payload present"
);
let events = cm.tail_from(0);
assert!(!events.is_empty(), "must retrieve at least the created entry");
let found = events.iter().find(|e| e.kind == "file.create");
assert!(found.is_some(), "file.create entry must be retrievable");
let retrieved = found.unwrap();
assert_eq!(retrieved.hash, entry.hash);
let payload = retrieved.payload.as_ref().unwrap();
assert_eq!(payload["path"], "/data/config.json");
assert_eq!(payload["content_hash"], "abc123def456");
assert_eq!(payload["size"], 1024);
let result = cm.verify_integrity();
assert!(result.valid, "chain integrity must hold after fs entry");
}
#[test]
fn dual_signature_ed25519_only() {
use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;
let ed_key = SigningKey::generate(&mut OsRng);
let cm = ChainManager::new(0, 1000).with_signing_key(ed_key.clone());
let data = b"cross-node chain event payload";
let sig = cm.dual_sign(data).expect("should produce a signature");
assert_eq!(sig.ed25519.len(), 64, "Ed25519 signature must be 64 bytes");
assert!(sig.ml_dsa65.is_none(), "ML-DSA-65 should be absent without key");
}
#[test]
fn dual_signature_both_algorithms() {
use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;
let ed_key = SigningKey::generate(&mut OsRng);
let (ml_key, _ml_vk) = weftos_rvf_crypto::MlDsa65Key::generate(b"dual-sig-test");
let mut cm = ChainManager::new(0, 1000).with_signing_key(ed_key.clone());
cm.set_ml_dsa_key(ml_key);
assert!(cm.has_dual_signing());
let data = b"cross-node chain event with PQ protection";
let sig = cm.dual_sign(data).expect("should produce dual signature");
assert_eq!(sig.ed25519.len(), 64);
let ml = sig.ml_dsa65.as_ref().expect("ML-DSA-65 should be present");
assert_eq!(ml.len(), 3309, "ML-DSA-65 signature must be 3309 bytes");
}
#[test]
fn verify_dual_signature_ed25519_valid() {
use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;
let ed_key = SigningKey::generate(&mut OsRng);
let cm = ChainManager::new(0, 1000).with_signing_key(ed_key.clone());
let data = b"verify-ed25519-only";
let sig = cm.dual_sign(data).unwrap();
let ed_pub = ed_key.verifying_key();
assert!(
ChainManager::verify_dual_signature(data, &sig, &ed_pub, None),
"Ed25519-only dual signature should verify"
);
}
#[test]
fn verify_dual_signature_both_valid() {
use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;
let ed_key = SigningKey::generate(&mut OsRng);
let (ml_key, ml_vk) = weftos_rvf_crypto::MlDsa65Key::generate(b"verify-both");
let mut cm = ChainManager::new(0, 1000).with_signing_key(ed_key.clone());
cm.set_ml_dsa_key(ml_key);
let data = b"verify-both-algorithms";
let sig = cm.dual_sign(data).unwrap();
let ed_pub = ed_key.verifying_key();
assert!(
ChainManager::verify_dual_signature(data, &sig, &ed_pub, Some(&ml_vk)),
"dual signature with both algorithms should verify"
);
}
#[test]
fn verify_dual_signature_rejects_tampered() {
use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;
let ed_key = SigningKey::generate(&mut OsRng);
let (ml_key, ml_vk) = weftos_rvf_crypto::MlDsa65Key::generate(b"tamper-test");
let mut cm = ChainManager::new(0, 1000).with_signing_key(ed_key.clone());
cm.set_ml_dsa_key(ml_key);
let data = b"original data";
let sig = cm.dual_sign(data).unwrap();
let ed_pub = ed_key.verifying_key();
let tampered = b"tampered data";
assert!(
!ChainManager::verify_dual_signature(tampered, &sig, &ed_pub, Some(&ml_vk)),
"tampered data must fail verification"
);
let mut bad_sig = sig.clone();
if let Some(ref mut ml) = bad_sig.ml_dsa65 {
ml[0] ^= 0xFF;
}
assert!(
!ChainManager::verify_dual_signature(data, &bad_sig, &ed_pub, Some(&ml_vk)),
"tampered ML-DSA-65 signature must fail verification"
);
}
#[test]
fn chain_event_serde_roundtrip() {
let cm = ChainManager::new(0, 1000);
cm.append("test", "agent.spawn", Some(serde_json::json!({"name": "test-agent"})));
let events = cm.tail(1);
let event = &events[0];
let json = serde_json::to_string(event).unwrap();
let restored: ChainEvent = serde_json::from_str(&json).unwrap();
assert_eq!(restored.sequence, event.sequence);
assert_eq!(restored.source, "test");
assert_eq!(restored.kind, "agent.spawn");
assert!(restored.payload.is_some());
}
#[test]
fn chain_event_without_payload_roundtrip() {
let cm = ChainManager::new(0, 1000);
cm.append("kernel", "boot.complete", None);
let events = cm.tail(1);
let event = &events[0];
let json = serde_json::to_string(event).unwrap();
let restored: ChainEvent = serde_json::from_str(&json).unwrap();
assert!(restored.payload.is_none());
assert_eq!(restored.kind, "boot.complete");
}
#[test]
fn chain_checkpoint_serde_roundtrip() {
let cm = ChainManager::new(0, 1000);
cm.append("test", "event.1", None);
cm.append("test", "event.2", None);
let cp = cm.checkpoint();
let json = serde_json::to_string(&cp).unwrap();
let restored: ChainCheckpoint = serde_json::from_str(&json).unwrap();
assert_eq!(restored.chain_id, cp.chain_id);
assert_eq!(restored.sequence, cp.sequence);
assert_eq!(restored.last_hash, cp.last_hash);
}
#[test]
fn chain_verify_result_serde_roundtrip_valid() {
let result = ChainVerifyResult {
valid: true,
event_count: 10,
errors: vec![],
signature_verified: Some(true),
};
let json = serde_json::to_string(&result).unwrap();
let restored: ChainVerifyResult = serde_json::from_str(&json).unwrap();
assert!(restored.valid);
assert_eq!(restored.event_count, 10);
assert!(restored.errors.is_empty());
assert_eq!(restored.signature_verified, Some(true));
}
#[test]
fn chain_verify_result_serde_roundtrip_invalid() {
let result = ChainVerifyResult {
valid: false,
event_count: 5,
errors: vec!["hash mismatch at seq 3".into()],
signature_verified: None,
};
let json = serde_json::to_string(&result).unwrap();
let restored: ChainVerifyResult = serde_json::from_str(&json).unwrap();
assert!(!restored.valid);
assert_eq!(restored.errors.len(), 1);
assert!(restored.signature_verified.is_none());
}
#[test]
fn chain_status_serde_roundtrip() {
let cm = ChainManager::new(42, 1000);
cm.append("test", "event.1", None);
cm.append("test", "event.2", None);
let status = cm.status();
let json = serde_json::to_string(&status).unwrap();
let restored: ChainStatus = serde_json::from_str(&json).unwrap();
assert_eq!(restored.chain_id, 42);
assert_eq!(restored.sequence, status.sequence);
assert_eq!(restored.event_count, status.event_count);
}
#[test]
fn dual_signature_serde_roundtrip() {
let sig = DualSignature {
ed25519: vec![0xCA, 0xFE, 0xBA, 0xBE],
ml_dsa65: Some(vec![0xDE, 0xAD]),
};
let json = serde_json::to_string(&sig).unwrap();
let restored: DualSignature = serde_json::from_str(&json).unwrap();
assert_eq!(restored.ed25519, vec![0xCA, 0xFE, 0xBA, 0xBE]);
assert_eq!(restored.ml_dsa65.unwrap(), vec![0xDE, 0xAD]);
}
#[test]
fn dual_signature_without_ml_dsa_roundtrip() {
let sig = DualSignature {
ed25519: vec![0x01, 0x02],
ml_dsa65: None,
};
let json = serde_json::to_string(&sig).unwrap();
let restored: DualSignature = serde_json::from_str(&json).unwrap();
assert!(restored.ml_dsa65.is_none());
}
#[test]
fn anchor_receipt_serde_roundtrip() {
let receipt = AnchorReceipt {
hash: [0xABu8; 32],
tx_id: "tx-deadbeef".into(),
anchored_at: chrono::Utc::now(),
};
let json = serde_json::to_string(&receipt).unwrap();
let restored: AnchorReceipt = serde_json::from_str(&json).unwrap();
assert_eq!(restored.hash, [0xABu8; 32]);
assert_eq!(restored.tx_id, "tx-deadbeef");
}
#[test]
fn chain_genesis_event_hash_is_nonzero() {
let cm = ChainManager::new(0, 1000);
let events = cm.tail(1);
assert_eq!(events[0].kind, "genesis");
assert_ne!(events[0].hash, [0u8; 32]);
}
#[test]
fn chain_genesis_prev_hash_is_zero() {
let cm = ChainManager::new(0, 1000);
let events = cm.tail(1);
assert_eq!(events[0].prev_hash, [0u8; 32]);
}
#[test]
fn restart_event_loggable() {
let cm = ChainManager::new(0, 100);
let initial = cm.len();
let event = RestartEvent {
agent_id: "agent-coder".into(),
old_pid: 5,
new_pid: 12,
exit_code: 1,
strategy: "OneForOne".into(),
backoff_ms: 200,
timestamp: Utc::now(),
};
assert_eq!(event.chain_event_source(), "supervisor");
assert_eq!(event.chain_event_kind(), "supervisor.restart");
let chain_event = cm.append_loggable(&event);
assert_eq!(cm.len(), initial + 1);
assert_eq!(chain_event.source, "supervisor");
assert_eq!(chain_event.kind, "supervisor.restart");
let payload = chain_event.payload.unwrap();
assert_eq!(payload["agent_id"], "agent-coder");
assert_eq!(payload["old_pid"], 5);
assert_eq!(payload["new_pid"], 12);
assert_eq!(payload["exit_code"], 1);
}
#[test]
fn governance_decision_event_loggable() {
let cm = ChainManager::new(0, 100);
let event = GovernanceDecisionEvent {
agent_id: "agent-1".into(),
action: "tool.exec".into(),
decision: "Deny".into(),
effect_magnitude: 0.85,
threshold_exceeded: true,
evaluated_rules: vec!["security-check".into()],
timestamp: Utc::now(),
};
assert_eq!(event.chain_event_source(), "governance");
assert_eq!(event.chain_event_kind(), "governance.deny");
let chain_event = cm.append_loggable(&event);
assert_eq!(chain_event.kind, "governance.deny");
let payload = chain_event.payload.unwrap();
assert_eq!(payload["agent_id"], "agent-1");
assert_eq!(payload["action"], "tool.exec");
assert!(payload["threshold_exceeded"].as_bool().unwrap());
let make_event = |decision: &str| GovernanceDecisionEvent {
agent_id: "a".into(),
action: "a".into(),
decision: decision.into(),
effect_magnitude: 0.0,
threshold_exceeded: false,
evaluated_rules: vec![],
timestamp: Utc::now(),
};
assert_eq!(make_event("Permit").chain_event_kind(), "governance.permit");
assert_eq!(make_event("PermitWithWarning").chain_event_kind(), "governance.warn");
assert_eq!(make_event("EscalateToHuman").chain_event_kind(), "governance.defer");
assert_eq!(make_event("Deny").chain_event_kind(), "governance.deny");
}
#[test]
fn ipc_dead_letter_event_loggable() {
let cm = ChainManager::new(0, 100);
let event = IpcDeadLetterEvent {
message_id: "msg-abc".into(),
from_pid: 3,
target: "Process(99)".into(),
payload_type: "text".into(),
reason: "target_not_found(pid=99)".into(),
timestamp: Utc::now(),
};
assert_eq!(event.chain_event_source(), "ipc");
assert_eq!(event.chain_event_kind(), "ipc.dead_letter");
let chain_event = cm.append_loggable(&event);
assert_eq!(chain_event.source, "ipc");
assert_eq!(chain_event.kind, "ipc.dead_letter");
let payload = chain_event.payload.unwrap();
assert_eq!(payload["message_id"], "msg-abc");
assert_eq!(payload["from_pid"], 3);
assert_eq!(payload["reason"], "target_not_found(pid=99)");
}
#[test]
fn append_loggable_links_hashes() {
let cm = ChainManager::new(0, 100);
let hash_before = cm.last_hash();
let event = RestartEvent {
agent_id: "test".into(),
old_pid: 1,
new_pid: 2,
exit_code: 1,
strategy: "OneForOne".into(),
backoff_ms: 100,
timestamp: Utc::now(),
};
let chain_event = cm.append_loggable(&event);
assert_eq!(chain_event.prev_hash, hash_before);
assert_ne!(chain_event.hash, [0u8; 32]);
}
#[test]
fn generate_attestation_without_key_returns_none() {
let cm = ChainManager::new(0, 1000);
assert!(cm.generate_attestation(10, 5, "abc123").is_none());
}
#[test]
fn generate_attestation_with_key() {
use rand::rngs::OsRng;
let key = SigningKey::generate(&mut OsRng);
let cm = ChainManager::new(0, 1000).with_signing_key(key.clone());
cm.append("test", "event.one", None);
cm.append("test", "event.two", None);
let att = cm.generate_attestation(42, 7, "deadbeef").unwrap();
assert!(!att.device_id.is_empty());
assert_eq!(att.epoch, 7);
assert_eq!(att.vector_count, 42);
assert_eq!(att.content_hash, "deadbeef");
assert_eq!(att.chain_depth, 3); assert!(!att.chain_head.is_empty());
assert_eq!(att.chain_head.len(), 64); assert!(att.timestamp > 0);
assert_eq!(att.signature.len(), 64);
use ed25519_dalek::Verifier;
let canonical = format!(
"custody-attestation:v1\n\
device_id:{}\n\
epoch:{}\n\
chain_head:{}\n\
chain_depth:{}\n\
vector_count:{}\n\
content_hash:{}\n\
timestamp:{}",
att.device_id, att.epoch, att.chain_head, att.chain_depth,
att.vector_count, att.content_hash, att.timestamp,
);
let sig = ed25519_dalek::Signature::from_bytes(
att.signature.as_slice().try_into().unwrap(),
);
assert!(key.verifying_key().verify(canonical.as_bytes(), &sig).is_ok());
}
#[test]
fn custody_attestation_serde_roundtrip() {
let att = CustodyAttestation {
device_id: "aabbccdd".into(),
epoch: 5,
chain_head: "ff".repeat(32),
chain_depth: 100,
vector_count: 1000,
content_hash: "00".repeat(32),
timestamp: 1234567890,
signature: vec![0u8; 64],
};
let json = serde_json::to_string(&att).unwrap();
let restored: CustodyAttestation = serde_json::from_str(&json).unwrap();
assert_eq!(restored.device_id, "aabbccdd");
assert_eq!(restored.epoch, 5);
assert_eq!(restored.chain_depth, 100);
assert_eq!(restored.vector_count, 1000);
assert_eq!(restored.signature.len(), 64);
}
}