use exo_core::{
crypto,
types::{Hash256, Timestamp},
};
use crate::{
consensus::PublicKeyResolver,
dag::compute_node_hash,
error::{DagError, Result},
store::DagStore,
};
const MAX_CLOCK_SKEW_MS: u64 = 500;
pub async fn validated_append(
store: &mut impl DagStore,
node: crate::dag::DagNode,
validation_time: Timestamp,
public_keys: &impl PublicKeyResolver,
) -> Result<()> {
if node.timestamp.physical_ms > 0 {
let validation_ms = validation_time.physical_ms;
if node.timestamp.physical_ms > validation_ms.saturating_add(MAX_CLOCK_SKEW_MS) {
return Err(DagError::StoreError(format!(
"clock skew: node timestamp {} exceeds validation timestamp {} + {}ms tolerance",
node.timestamp.physical_ms, validation_ms, MAX_CLOCK_SKEW_MS
)));
}
}
for parent_hash in &node.parents {
let parent = store
.get(parent_hash)
.await?
.ok_or(DagError::ParentNotFound(*parent_hash))?;
if node.timestamp <= parent.timestamp {
return Err(DagError::StoreError(format!(
"causality violation: node timestamp {:?} <= parent timestamp {:?}",
node.timestamp, parent.timestamp
)));
}
}
verify_node_creator_signature(&node, public_keys)?;
store.put(node).await
}
pub fn verify_node_creator_signature(
node: &crate::dag::DagNode,
public_keys: &impl PublicKeyResolver,
) -> Result<()> {
let mut sorted_parents = node.parents.clone();
sorted_parents.sort();
sorted_parents.dedup();
if sorted_parents != node.parents {
return Err(DagError::InvalidSignature(node.hash));
}
let expected_hash = compute_node_hash(
&node.parents,
&node.payload_hash,
&node.creator_did,
&node.timestamp,
)?;
if expected_hash != node.hash {
return Err(DagError::InvalidSignature(node.hash));
}
let Some(public_key) = public_keys.resolve(&node.creator_did) else {
return Err(DagError::InvalidSignature(node.hash));
};
if !crypto::verify(node.hash.as_bytes(), &node.signature, &public_key) {
return Err(DagError::InvalidSignature(node.hash));
}
Ok(())
}
pub async fn verify_stored_integrity(store: &impl DagStore, hash: &Hash256) -> Result<bool> {
let node = match store.get(hash).await? {
Some(n) => n,
None => return Err(DagError::NodeNotFound(*hash)),
};
for parent in &node.parents {
if !store.contains(parent).await? {
return Ok(false);
}
}
let recomputed = compute_node_hash(
&node.parents,
&node.payload_hash,
&node.creator_did,
&node.timestamp,
)?;
Ok(recomputed == node.hash)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use exo_core::{
crypto::KeyPair,
types::{Did, PublicKey, Signature, Timestamp},
};
use super::*;
use crate::{
dag::{Dag, DagNode, DeterministicDagClock, append},
store::MemoryStore,
};
fn test_did() -> Did {
Did::new("did:exo:test").expect("valid")
}
type SignFn = Box<dyn Fn(&[u8]) -> Signature>;
fn test_keypair() -> KeyPair {
KeyPair::from_secret_bytes([0xD5; 32]).expect("valid test secret key")
}
fn test_public_key() -> PublicKey {
*test_keypair().public_key()
}
fn test_resolver() -> impl PublicKeyResolver {
let did = test_did();
let public_key = test_public_key();
move |candidate: &Did| {
if candidate == &did {
Some(public_key)
} else {
None
}
}
}
fn make_sign_fn() -> SignFn {
let keypair = test_keypair();
Box::new(move |data: &[u8]| keypair.sign(data))
}
fn make_test_node() -> DagNode {
let mut dag = Dag::new();
let mut clock = DeterministicDagClock::new();
let creator = test_did();
let sign_fn = make_sign_fn();
append(&mut dag, &[], b"genesis", &creator, &*sign_fn, &mut clock).expect("genesis")
}
fn make_child_node(parent: &DagNode) -> DagNode {
let mut dag = Dag::new();
let mut clock = DeterministicDagClock::new();
let creator = test_did();
let sign_fn = make_sign_fn();
let _g =
append(&mut dag, &[], b"genesis", &creator, &*sign_fn, &mut clock).expect("genesis");
let payload_hash = Hash256::digest(b"child-payload");
let timestamp = Timestamp::new(
parent.timestamp.physical_ms + 1,
parent.timestamp.logical + 1,
);
let hash = compute_node_hash(&[parent.hash], &payload_hash, &creator, ×tamp).unwrap();
let signature = (*sign_fn)(hash.as_bytes());
DagNode {
hash,
parents: vec![parent.hash],
payload_hash,
creator_did: creator,
timestamp,
signature,
}
}
fn make_node_at(timestamp: Timestamp) -> DagNode {
let creator = test_did();
let payload_hash = Hash256::digest(b"timestamped-node");
let hash = compute_node_hash(&[], &payload_hash, &creator, ×tamp).unwrap();
let sign_fn = make_sign_fn();
let signature = (*sign_fn)(hash.as_bytes());
DagNode {
hash,
parents: Vec::new(),
payload_hash,
creator_did: creator,
timestamp,
signature,
}
}
fn validation_time_for(node: &DagNode) -> Timestamp {
Timestamp::new(
node.timestamp.physical_ms.saturating_add(MAX_CLOCK_SKEW_MS),
node.timestamp.logical,
)
}
#[test]
fn validated_append_has_no_internal_wall_clock() {
let source = include_str!("append.rs");
let system_time_pattern = format!("{}{}", "SystemTime::", "now()");
let unix_epoch_pattern = format!("{}{}", "UNIX_", "EPOCH");
assert!(
!source.contains(&system_time_pattern),
"validated_append must receive caller-supplied validation time"
);
assert!(
!source.contains(&unix_epoch_pattern),
"validated_append must not derive validation time from the wall clock"
);
}
#[tokio::test]
async fn validated_append_success() {
let mut store = MemoryStore::new();
let genesis = make_test_node();
store.put(genesis.clone()).await.expect("put genesis");
let child = make_child_node(&genesis);
let resolver = test_resolver();
validated_append(
&mut store,
child.clone(),
validation_time_for(&child),
&resolver,
)
.await
.expect("validated append");
assert!(store.contains(&child.hash).await.expect("contains"));
}
#[tokio::test]
async fn validated_append_missing_parent() {
let mut store = MemoryStore::new();
let genesis = make_test_node();
let child = make_child_node(&genesis);
let resolver = test_resolver();
let err = validated_append(
&mut store,
child.clone(),
validation_time_for(&child),
&resolver,
)
.await
.unwrap_err();
assert!(matches!(err, DagError::ParentNotFound(_)));
}
#[tokio::test]
async fn validated_append_rejects_forged_external_signature() {
let mut store = MemoryStore::new();
let genesis = make_test_node();
store.put(genesis.clone()).await.expect("put genesis");
let mut child = make_child_node(&genesis);
child.signature = Signature::from_bytes([0u8; 64]);
let resolver = test_resolver();
let err = validated_append(
&mut store,
child.clone(),
validation_time_for(&child),
&resolver,
)
.await
.unwrap_err();
assert!(
matches!(err, DagError::InvalidSignature(hash) if hash == child.hash),
"external DAG append must reject forged node signatures, got: {err:?}"
);
assert!(
!store.contains(&child.hash).await.expect("contains"),
"forged external node must not be persisted"
);
}
#[tokio::test]
async fn validated_append_rejects_mismatched_canonical_hash() {
let mut store = MemoryStore::new();
let genesis = make_test_node();
store.put(genesis.clone()).await.expect("put genesis");
let mut child = make_child_node(&genesis);
child.payload_hash = Hash256::digest(b"tampered");
let resolver = test_resolver();
let err = validated_append(
&mut store,
child.clone(),
validation_time_for(&child),
&resolver,
)
.await
.unwrap_err();
assert!(
matches!(err, DagError::InvalidSignature(hash) if hash == child.hash),
"external DAG append must reject nodes whose hash does not match canonical fields, got: {err:?}"
);
assert!(!store.contains(&child.hash).await.expect("contains"));
}
#[tokio::test]
async fn validated_append_rejects_unknown_creator_key() {
let mut store = MemoryStore::new();
let genesis = make_test_node();
store.put(genesis.clone()).await.expect("put genesis");
let child = make_child_node(&genesis);
let unknown_key_resolver = |_did: &Did| -> Option<PublicKey> { None };
let err = validated_append(
&mut store,
child.clone(),
validation_time_for(&child),
&unknown_key_resolver,
)
.await
.unwrap_err();
assert!(
matches!(err, DagError::InvalidSignature(hash) if hash == child.hash),
"external DAG append must fail closed without a creator public key, got: {err:?}"
);
assert!(!store.contains(&child.hash).await.expect("contains"));
}
#[tokio::test]
async fn validated_append_causality_violation() {
let mut store = MemoryStore::new();
let genesis = make_test_node();
store.put(genesis.clone()).await.expect("put genesis");
let creator = test_did();
let payload_hash = Hash256::digest(b"bad-child");
let timestamp = Timestamp::new(0, 0); let hash = compute_node_hash(&[genesis.hash], &payload_hash, &creator, ×tamp).unwrap();
let sign_fn = make_sign_fn();
let signature = (*sign_fn)(hash.as_bytes());
let bad_child = DagNode {
hash,
parents: vec![genesis.hash],
payload_hash,
creator_did: creator,
timestamp,
signature,
};
let resolver = test_resolver();
let err = validated_append(
&mut store,
bad_child.clone(),
validation_time_for(&bad_child),
&resolver,
)
.await
.unwrap_err();
assert!(
matches!(err, DagError::StoreError(ref msg) if msg.contains("causality")),
"expected causality violation, got: {err:?}"
);
}
#[tokio::test]
async fn validated_append_rejects_node_after_supplied_validation_time() {
let mut store = MemoryStore::new();
let node = make_node_at(Timestamp::new(2_001, 0));
let resolver = test_resolver();
let err = validated_append(&mut store, node, Timestamp::new(1_500, 0), &resolver)
.await
.unwrap_err();
assert!(
matches!(
err,
DagError::StoreError(ref msg)
if msg.contains("node timestamp 2001 exceeds validation timestamp 1500 + 500ms tolerance")
),
"expected validation-time skew rejection, got: {err:?}"
);
}
#[tokio::test]
async fn validated_append_accepts_node_within_supplied_validation_time_tolerance() {
let mut store = MemoryStore::new();
let node = make_node_at(Timestamp::new(2_000, 0));
let resolver = test_resolver();
validated_append(
&mut store,
node.clone(),
Timestamp::new(1_500, 0),
&resolver,
)
.await
.expect("node inside validation-time tolerance");
assert!(store.contains(&node.hash).await.expect("contains"));
}
#[tokio::test]
async fn verify_stored_integrity_valid() {
let mut store = MemoryStore::new();
let node = make_test_node();
store.put(node.clone()).await.expect("put");
assert!(
verify_stored_integrity(&store, &node.hash)
.await
.expect("verify")
);
}
#[tokio::test]
async fn verify_stored_integrity_tampered_hash() {
let mut store = MemoryStore::new();
let mut node = make_test_node();
let original_hash = node.hash;
node.payload_hash = Hash256::digest(b"tampered");
node.hash = original_hash;
store.put(node).await.expect("put");
assert!(
!verify_stored_integrity(&store, &original_hash)
.await
.expect("verify")
);
}
#[tokio::test]
async fn verify_stored_integrity_not_found() {
let store = MemoryStore::new();
let err = verify_stored_integrity(&store, &Hash256::ZERO)
.await
.unwrap_err();
assert!(matches!(err, DagError::NodeNotFound(_)));
}
#[tokio::test]
async fn genesis_node_validated_append() {
let mut store = MemoryStore::new();
let genesis = make_test_node();
let resolver = test_resolver();
validated_append(
&mut store,
genesis.clone(),
validation_time_for(&genesis),
&resolver,
)
.await
.expect("genesis append");
assert!(store.contains(&genesis.hash).await.expect("contains"));
}
}