use std::sync::Arc;
use crate::logging::{debug, info, warn};
use rand::Rng;
use crate::ant_protocol::XorName;
use crate::replication::commitment::{commitment_hash, StorageCommitment};
use crate::replication::commitment_state::ResponderCommitmentState;
use crate::replication::config::{
ReplicationConfig, MAX_BYTE_CHALLENGE_KEYS, REPLICATION_PROTOCOL_ID,
};
use crate::replication::protocol::{
RejectKind, ReplicationMessage, ReplicationMessageBody, SubtreeAuditChallenge,
SubtreeAuditResponse, SubtreeByteChallenge, SubtreeByteItem, SubtreeByteResponse,
};
use crate::replication::recent_provers::RecentProvers;
use crate::replication::subtree::{
select_subtree_path, subtree_plan, verify_subtree_proof, StructureVerdict, SubtreeProof,
};
use crate::replication::types::{AuditFailureReason, AuditFailureSummary, FailureEvidence};
use crate::storage::LmdbStorage;
use saorsa_core::identity::PeerId;
use saorsa_core::P2PNode;
use tokio::sync::RwLock;
use crate::replication::audit::AuditTickResult;
const BYTE_SPOTCHECK_MIN: u32 = 3;
const BYTE_SPOTCHECK_MAX: u32 = 5;
pub struct AuditCredit<'a> {
pub recent_provers: &'a Arc<RwLock<RecentProvers>>,
}
struct AuditCtx<'a> {
p2p_node: &'a Arc<P2PNode>,
challenged_peer: &'a PeerId,
challenge_id: u64,
nonce: [u8; 32],
expected_commitment_hash: [u8; 32],
config: &'a ReplicationConfig,
credit: Option<&'a AuditCredit<'a>>,
}
pub async fn run_subtree_audit(
p2p_node: &Arc<P2PNode>,
config: &ReplicationConfig,
challenged_peer: &PeerId,
expected_commitment_hash: [u8; 32],
key_count: u32,
credit: Option<&AuditCredit<'_>>,
) -> AuditTickResult {
let (nonce, challenge_id) = {
let mut rng = rand::thread_rng();
(rng.gen::<[u8; 32]>(), rng.gen::<u64>())
};
let challenge = SubtreeAuditChallenge {
challenge_id,
nonce,
challenged_peer_id: *challenged_peer.as_bytes(),
expected_commitment_hash,
};
let msg = ReplicationMessage {
request_id: challenge_id,
body: ReplicationMessageBody::SubtreeAuditChallenge(challenge),
};
let encoded = match msg.encode() {
Ok(data) => data,
Err(e) => {
warn!("Audit: failed to encode subtree challenge for {challenged_peer}: {e}");
return AuditTickResult::Idle;
}
};
let subtree_leaves = select_subtree_path(&nonce, key_count).map_or_else(
|| config.subtree_audit_timeout_leaf_hint(),
|p| p.real_leaf_count() as usize,
);
let timeout = config.audit_response_timeout(subtree_leaves);
let response = match p2p_node
.send_request(challenged_peer, REPLICATION_PROTOCOL_ID, encoded, timeout)
.await
{
Ok(resp) => resp,
Err(e) => {
debug!("Audit: subtree challenge to {challenged_peer} timed out / failed: {e}");
return failed(challenged_peer, challenge_id, AuditFailureReason::Timeout);
}
};
let resp_msg = match ReplicationMessage::decode(&response.data) {
Ok(m) => m,
Err(e) => {
warn!("Audit: failed to decode subtree response from {challenged_peer}: {e}");
return failed(
challenged_peer,
challenge_id,
AuditFailureReason::MalformedResponse,
);
}
};
let ctx = AuditCtx {
p2p_node,
challenged_peer,
challenge_id,
nonce,
expected_commitment_hash,
config,
credit,
};
dispatch_subtree_response(resp_msg.body, &ctx).await
}
enum ByteRound {
Served(Vec<SubtreeByteItem>),
Rejected,
GracedReject,
Timeout,
Malformed,
}
async fn request_byte_proof(ctx: &AuditCtx<'_>, keys: &[XorName]) -> ByteRound {
let challenge = SubtreeByteChallenge {
challenge_id: ctx.challenge_id,
nonce: ctx.nonce,
challenged_peer_id: *ctx.challenged_peer.as_bytes(),
expected_commitment_hash: ctx.expected_commitment_hash,
keys: keys.to_vec(),
};
let msg = ReplicationMessage {
request_id: ctx.challenge_id,
body: ReplicationMessageBody::SubtreeByteChallenge(challenge),
};
let encoded = match msg.encode() {
Ok(data) => data,
Err(e) => {
warn!("Audit: failed to encode byte challenge: {e}");
return ByteRound::Malformed;
}
};
let timeout = ctx.config.byte_audit_response_timeout(keys.len());
let response = match ctx
.p2p_node
.send_request(
ctx.challenged_peer,
REPLICATION_PROTOCOL_ID,
encoded,
timeout,
)
.await
{
Ok(resp) => resp,
Err(e) => {
debug!(
"Audit: byte challenge to {} timed out / failed: {e}",
ctx.challenged_peer
);
return ByteRound::Timeout;
}
};
let resp_msg = match ReplicationMessage::decode(&response.data) {
Ok(m) => m,
Err(e) => {
warn!("Audit: failed to decode byte response: {e}");
return ByteRound::Malformed;
}
};
match resp_msg.body {
ReplicationMessageBody::SubtreeByteResponse(SubtreeByteResponse::Items {
challenge_id,
items,
}) if challenge_id == ctx.challenge_id => ByteRound::Served(items),
ReplicationMessageBody::SubtreeByteResponse(SubtreeByteResponse::Rejected {
challenge_id,
kind,
reason,
}) if challenge_id == ctx.challenge_id => {
if kind.is_graced() {
debug!(
"Audit: {} rejected byte challenge (graced, {kind:?}): {reason}",
ctx.challenged_peer
);
ByteRound::GracedReject
} else {
warn!(
"Audit: {} rejected byte challenge: {reason}",
ctx.challenged_peer
);
ByteRound::Rejected
}
}
ReplicationMessageBody::SubtreeByteResponse(SubtreeByteResponse::Bootstrapping {
challenge_id,
}) if challenge_id == ctx.challenge_id => ByteRound::Timeout,
_ => ByteRound::Malformed,
}
}
async fn dispatch_subtree_response(
body: ReplicationMessageBody,
ctx: &AuditCtx<'_>,
) -> AuditTickResult {
let challenged_peer = ctx.challenged_peer;
let challenge_id = ctx.challenge_id;
let malformed = || {
failed(
challenged_peer,
challenge_id,
AuditFailureReason::MalformedResponse,
)
};
match body {
ReplicationMessageBody::SubtreeAuditResponse(SubtreeAuditResponse::Bootstrapping {
challenge_id: resp_id,
}) => {
if resp_id != challenge_id {
return malformed();
}
AuditTickResult::BootstrapClaim {
peer: *challenged_peer,
}
}
ReplicationMessageBody::SubtreeAuditResponse(SubtreeAuditResponse::Rejected {
challenge_id: resp_id,
kind,
reason,
}) => {
if resp_id != challenge_id {
return malformed();
}
if kind.is_graced() {
if let Some(credit) = ctx.credit {
credit
.recent_provers
.write()
.await
.forget_commitment(&ctx.expected_commitment_hash);
}
debug!(
"Audit: peer {challenged_peer} rejected subtree challenge \
(graced, {kind:?}; credit for the pinned commitment revoked): {reason}"
);
failed(challenged_peer, challenge_id, AuditFailureReason::Timeout)
} else {
warn!("Audit: peer {challenged_peer} rejected subtree challenge: {reason}");
failed(challenged_peer, challenge_id, AuditFailureReason::Rejected)
}
}
ReplicationMessageBody::SubtreeAuditResponse(SubtreeAuditResponse::Proof {
challenge_id: resp_id,
commitment,
proof,
}) => {
if resp_id != challenge_id {
return malformed();
}
verify_subtree_response(ctx, &commitment, &proof).await
}
_ => {
warn!("Audit: unexpected response type from {challenged_peer}");
malformed()
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum AuditVerdict {
Pass {
checked: usize,
},
Fail(AuditFailureReason),
}
pub(crate) fn evaluate_subtree_structure(
commitment: &StorageCommitment,
proof: &SubtreeProof,
nonce: &[u8; 32],
expected_commitment_hash: &[u8; 32],
challenged_peer_bytes: &[u8; 32],
) -> Result<(), AuditFailureReason> {
if &commitment.sender_peer_id != challenged_peer_bytes {
return Err(AuditFailureReason::Rejected);
}
let derived_peer_id = *blake3::hash(&commitment.sender_public_key).as_bytes();
if derived_peer_id != commitment.sender_peer_id {
return Err(AuditFailureReason::Rejected);
}
match commitment_hash(commitment) {
Some(h) if &h == expected_commitment_hash => {}
_ => return Err(AuditFailureReason::Rejected),
}
if !crate::replication::commitment::verify_commitment_signature(commitment) {
return Err(AuditFailureReason::Rejected);
}
if let StructureVerdict::Invalid(_) = verify_subtree_proof(proof, nonce, commitment) {
return Err(AuditFailureReason::DigestMismatch);
}
Ok(())
}
fn random_spotcheck_leaves(
proof: &SubtreeProof,
count: u32,
) -> Vec<&crate::replication::subtree::SubtreeLeaf> {
let n = proof.leaves.len();
if n == 0 {
return Vec::new();
}
let want = (count as usize).min(n);
let mut rng = rand::thread_rng();
let mut picked = std::collections::BTreeSet::new();
let mut guard = 0u32;
while picked.len() < want && guard < count.saturating_mul(64).max(64) {
picked.insert(rng.gen_range(0..n));
guard = guard.saturating_add(1);
}
for idx in 0..n {
if picked.len() >= want {
break;
}
picked.insert(idx);
}
picked
.into_iter()
.filter_map(|idx| proof.leaves.get(idx))
.collect()
}
pub(crate) fn verify_byte_response(
leaves: &[&crate::replication::subtree::SubtreeLeaf],
nonce: &[u8; 32],
challenged_peer_bytes: &[u8; 32],
served: impl Fn(&XorName) -> Option<Option<Vec<u8>>>,
) -> AuditVerdict {
let mut checked = 0usize;
for leaf in leaves {
let Some(Some(content)) = served(&leaf.key) else {
return AuditVerdict::Fail(AuditFailureReason::DigestMismatch);
};
let plain = *blake3::hash(&content).as_bytes();
let nonced = crate::replication::subtree::nonced_leaf_hash(
nonce,
challenged_peer_bytes,
&leaf.key,
&content,
);
if leaf.bytes_hash != plain || leaf.nonced_hash != nonced {
return AuditVerdict::Fail(AuditFailureReason::DigestMismatch);
}
checked += 1;
}
AuditVerdict::Pass { checked }
}
async fn verify_subtree_response(
ctx: &AuditCtx<'_>,
commitment: &StorageCommitment,
proof: &SubtreeProof,
) -> AuditTickResult {
let challenged_peer = ctx.challenged_peer;
let challenge_id = ctx.challenge_id;
if let Err(reason) = evaluate_subtree_structure(
commitment,
proof,
&ctx.nonce,
&ctx.expected_commitment_hash,
challenged_peer.as_bytes(),
) {
warn!("Audit: {challenged_peer} failed subtree structure ({reason:?})");
return failed(challenged_peer, challenge_id, reason);
}
let sample_n = ctx
.config
.audit_spotcheck_count()
.clamp(BYTE_SPOTCHECK_MIN, BYTE_SPOTCHECK_MAX);
let sampled = random_spotcheck_leaves(proof, sample_n);
if sampled.is_empty() {
warn!("Audit: {challenged_peer} produced an empty spot-check sample; rejecting");
return failed(
challenged_peer,
challenge_id,
AuditFailureReason::DigestMismatch,
);
}
let verdict = 'rounds: {
for batch in sampled.chunks(MAX_BYTE_CHALLENGE_KEYS) {
let batch_keys: Vec<XorName> = batch.iter().map(|l| l.key).collect();
match request_byte_proof(ctx, &batch_keys).await {
ByteRound::Served(items) => {
let v = verify_byte_response(
batch,
&ctx.nonce,
challenged_peer.as_bytes(),
|key| {
items.iter().find_map(|it| match it {
SubtreeByteItem::Present { key: k, bytes } if k == key => {
Some(Some(bytes.clone()))
}
SubtreeByteItem::Absent { key: k } if k == key => Some(None),
_ => None,
})
},
);
if let AuditVerdict::Fail(reason) = v {
break 'rounds AuditVerdict::Fail(reason);
}
}
ByteRound::Rejected => {
break 'rounds AuditVerdict::Fail(AuditFailureReason::Rejected)
}
ByteRound::GracedReject => {
if let Some(credit) = ctx.credit {
credit
.recent_provers
.write()
.await
.forget_commitment(&ctx.expected_commitment_hash);
}
break 'rounds AuditVerdict::Fail(AuditFailureReason::Timeout);
}
ByteRound::Timeout => {
break 'rounds AuditVerdict::Fail(AuditFailureReason::Timeout)
}
ByteRound::Malformed => {
break 'rounds AuditVerdict::Fail(AuditFailureReason::MalformedResponse)
}
}
}
AuditVerdict::Pass {
checked: sampled.len(),
}
};
match verdict {
AuditVerdict::Fail(reason) => {
warn!("Audit: {challenged_peer} failed subtree audit ({reason:?})");
failed(challenged_peer, challenge_id, reason)
}
AuditVerdict::Pass { checked } => {
observe_closeness(ctx.p2p_node, ctx.config, challenged_peer, proof).await;
if let (Some(credit), Some(pin)) = (ctx.credit, commitment_hash(commitment)) {
let now = std::time::Instant::now();
let mut provers = credit.recent_provers.write().await;
for leaf in &proof.leaves {
provers.record_proof(leaf.key, *challenged_peer, pin, now);
}
}
info!(
"Audit: peer {challenged_peer} passed subtree audit ({} leaves, {checked} \
byte-checked)",
proof.leaves.len()
);
AuditTickResult::Passed {
challenged_peer: *challenged_peer,
keys_checked: checked,
}
}
}
}
async fn observe_closeness(
p2p_node: &Arc<P2PNode>,
config: &ReplicationConfig,
challenged_peer: &PeerId,
proof: &SubtreeProof,
) {
const CLOSENESS_SAMPLE_CAP: usize = 8;
if !crate::logging::enabled!(crate::logging::Level::DEBUG) {
return;
}
let self_id = *p2p_node.peer_id();
let inspected = proof.leaves.len().min(CLOSENESS_SAMPLE_CAP);
let mut far = 0usize;
for leaf in proof.leaves.iter().take(inspected) {
if !crate::replication::admission::is_responsible(
&self_id,
&leaf.key,
p2p_node,
config.close_group_size,
)
.await
{
far += 1;
}
}
if inspected > 0 && far * 2 > inspected {
debug!(
"Audit: closeness signal — {far}/{inspected} sampled of {challenged_peer}'s proven \
leaves are keys this auditor is not close to (observe-only; possible padding, not \
penalized)"
);
}
}
fn failed(
challenged_peer: &PeerId,
challenge_id: u64,
reason: AuditFailureReason,
) -> AuditTickResult {
let summary = subtree_failure_summary(&reason);
AuditTickResult::Failed {
evidence: FailureEvidence::AuditFailure {
challenge_id,
challenged_peer: *challenged_peer,
confirmed_failed_keys: Vec::new(),
summary,
reason,
},
}
}
fn subtree_failure_summary(reason: &AuditFailureReason) -> AuditFailureSummary {
let mut summary = AuditFailureSummary {
challenged_keys: 1,
..AuditFailureSummary::default()
};
match reason {
AuditFailureReason::Timeout => {}
AuditFailureReason::DigestMismatch => {
summary.failed_keys = 1;
summary.digest_mismatch_keys = 1;
}
AuditFailureReason::KeyAbsent => {
summary.failed_keys = 1;
summary.absent_keys = 1;
}
AuditFailureReason::MalformedResponse | AuditFailureReason::Rejected => {
summary.failed_keys = 1;
}
}
summary
}
pub async fn handle_subtree_challenge(
challenge: &SubtreeAuditChallenge,
storage: &LmdbStorage,
self_peer_id: &PeerId,
is_bootstrapping: bool,
commitment_state: Option<&Arc<ResponderCommitmentState>>,
) -> SubtreeAuditResponse {
if is_bootstrapping {
return SubtreeAuditResponse::Bootstrapping {
challenge_id: challenge.challenge_id,
};
}
if challenge.challenged_peer_id != *self_peer_id.as_bytes() {
warn!(
"Subtree audit challenge targeted wrong peer: expected {}, got {}",
hex::encode(self_peer_id.as_bytes()),
hex::encode(challenge.challenged_peer_id),
);
return SubtreeAuditResponse::Rejected {
challenge_id: challenge.challenge_id,
kind: RejectKind::Protocol,
reason: "challenged_peer_id does not match this node".to_string(),
};
}
let Some(state) = commitment_state else {
return SubtreeAuditResponse::Rejected {
challenge_id: challenge.challenge_id,
kind: RejectKind::Protocol,
reason: "no commitment state".to_string(),
};
};
let Some(built) = state.lookup_by_hash(&challenge.expected_commitment_hash) else {
return SubtreeAuditResponse::Rejected {
challenge_id: challenge.challenge_id,
kind: RejectKind::UnknownCommitment,
reason: "unknown commitment hash".to_string(),
};
};
let plan = match subtree_plan(built.tree(), &challenge.nonce) {
Ok(p) => p,
Err(e) => {
warn!("Subtree audit: failed to plan proof: {e:?}");
return SubtreeAuditResponse::Rejected {
challenge_id: challenge.challenge_id,
kind: RejectKind::Protocol,
reason: "could not build subtree proof".to_string(),
};
}
};
let mut leaves = Vec::with_capacity(plan.leaf_keys.len());
for key in &plan.leaf_keys {
let bytes = match storage.get_raw(key).await {
Ok(Some(bytes)) => bytes,
Ok(None) => {
warn!(
"Subtree audit: missing bytes for committed key {}",
hex::encode(key)
);
return SubtreeAuditResponse::Rejected {
challenge_id: challenge.challenge_id,
kind: RejectKind::Protocol,
reason: format!("missing bytes for committed key: {}", hex::encode(key)),
};
}
Err(e) => {
warn!(
"Subtree audit: storage read error for committed key {}: {e} \
(rejecting as transient, not a confirmed failure)",
hex::encode(key)
);
return SubtreeAuditResponse::Rejected {
challenge_id: challenge.challenge_id,
kind: RejectKind::Transient,
reason: format!("transient storage read error: {e}"),
};
}
};
leaves.push(crate::replication::subtree::subtree_leaf(
&challenge.nonce,
&challenge.challenged_peer_id,
key,
&bytes,
));
}
SubtreeAuditResponse::Proof {
challenge_id: challenge.challenge_id,
commitment: built.commitment().clone(),
proof: SubtreeProof {
leaves,
sibling_cut_hashes: plan.sibling_cut_hashes,
},
}
}
pub async fn handle_subtree_byte_challenge(
challenge: &SubtreeByteChallenge,
storage: &LmdbStorage,
self_peer_id: &PeerId,
is_bootstrapping: bool,
commitment_state: Option<&Arc<ResponderCommitmentState>>,
) -> SubtreeByteResponse {
if is_bootstrapping {
return SubtreeByteResponse::Bootstrapping {
challenge_id: challenge.challenge_id,
};
}
if challenge.challenged_peer_id != *self_peer_id.as_bytes() {
return SubtreeByteResponse::Rejected {
challenge_id: challenge.challenge_id,
kind: RejectKind::Protocol,
reason: "challenged_peer_id does not match this node".to_string(),
};
}
if challenge.keys.len() > MAX_BYTE_CHALLENGE_KEYS {
let requested = challenge.keys.len();
return SubtreeByteResponse::Rejected {
challenge_id: challenge.challenge_id,
kind: RejectKind::Protocol,
reason: format!(
"byte challenge requests {requested} keys; max {MAX_BYTE_CHALLENGE_KEYS} per challenge"
),
};
}
let Some(state) = commitment_state else {
return SubtreeByteResponse::Rejected {
challenge_id: challenge.challenge_id,
kind: RejectKind::Protocol,
reason: "no commitment state".to_string(),
};
};
let Some(built) = state.lookup_by_hash(&challenge.expected_commitment_hash) else {
return SubtreeByteResponse::Rejected {
challenge_id: challenge.challenge_id,
kind: RejectKind::UnknownCommitment,
reason: "unknown commitment hash".to_string(),
};
};
let mut items = Vec::with_capacity(challenge.keys.len());
for key in &challenge.keys {
if built.proof_for(key).is_none() {
items.push(SubtreeByteItem::Absent { key: *key });
continue;
}
match storage.get_raw(key).await {
Ok(Some(bytes)) => items.push(SubtreeByteItem::Present { key: *key, bytes }),
Ok(None) => {
warn!(
"Subtree byte audit: committed key {} requested but bytes absent",
hex::encode(key)
);
items.push(SubtreeByteItem::Absent { key: *key });
}
Err(e) => {
warn!(
"Subtree byte audit: storage read error for committed key {}: {e} \
(rejecting as transient, not a confirmed failure)",
hex::encode(key)
);
return SubtreeByteResponse::Rejected {
challenge_id: challenge.challenge_id,
kind: RejectKind::Transient,
reason: format!("transient storage read error: {e}"),
};
}
}
}
SubtreeByteResponse::Items {
challenge_id: challenge.challenge_id,
items,
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use crate::replication::commitment_state::BuiltCommitment;
use crate::replication::subtree::{build_subtree_proof, nonced_leaf_hash, SubtreeLeaf};
use saorsa_pqc::api::sig::ml_dsa_65;
fn key(i: u32) -> XorName {
let mut k = [0u8; 32];
k[..4].copy_from_slice(&i.to_be_bytes());
k
}
fn chunk_bytes(k: &XorName) -> Vec<u8> {
let mut v = k.to_vec();
v.extend_from_slice(b"chunk-body");
v
}
fn honest(n: u32, nonce: &[u8; 32]) -> (BuiltCommitment, SubtreeProof, [u8; 32]) {
let (pk, sk) = ml_dsa_65().generate_keypair().unwrap();
let peer_id = *blake3::hash(&pk.to_bytes()).as_bytes();
let pk_b = pk.to_bytes();
let entries: Vec<_> = (0..n)
.map(|i| {
let k = key(i);
(k, *blake3::hash(&chunk_bytes(&k)).as_bytes())
})
.collect();
let built = BuiltCommitment::build(entries, &peer_id, &sk, &pk_b).unwrap();
let proof =
build_subtree_proof(built.tree(), nonce, &peer_id, |k| Some(chunk_bytes(k))).unwrap();
(built, proof, peer_id)
}
fn structure(
built: &BuiltCommitment,
proof: &SubtreeProof,
nonce: &[u8; 32],
peer: &[u8; 32],
) -> Result<(), AuditFailureReason> {
evaluate_subtree_structure(built.commitment(), proof, nonce, &built.hash(), peer)
}
fn sample<'a>(
proof: &'a SubtreeProof,
_nonce: &[u8; 32],
_key_count: u32,
) -> Vec<&'a SubtreeLeaf> {
random_spotcheck_leaves(proof, 8u32.clamp(BYTE_SPOTCHECK_MIN, BYTE_SPOTCHECK_MAX))
}
#[allow(clippy::option_option, clippy::unnecessary_wraps)]
fn served_honest(key: &XorName) -> Option<Option<Vec<u8>>> {
Some(Some(chunk_bytes(key)))
}
#[test]
fn honest_structure_then_bytes_passes() {
let nonce = [9u8; 32];
let (built, proof, peer) = honest(400, &nonce);
assert!(structure(&built, &proof, &nonce, &peer).is_ok());
let s = sample(&proof, &nonce, built.commitment().key_count);
assert!(!s.is_empty());
match verify_byte_response(&s, &nonce, &peer, served_honest) {
AuditVerdict::Pass { checked } => assert!(checked >= 1, "must verify >=1 leaf"),
other @ AuditVerdict::Fail(_) => panic!("expected Pass, got {other:?}"),
}
}
#[test]
fn commitment_bound_to_another_peer_rejected() {
let nonce = [3u8; 32];
let (built, proof, _peer) = honest(200, &nonce);
let other = [0xAAu8; 32];
assert_eq!(
structure(&built, &proof, &nonce, &other),
Err(AuditFailureReason::Rejected)
);
}
#[test]
fn wrong_pinned_commitment_rejected() {
let nonce = [3u8; 32];
let (built, proof, peer) = honest(200, &nonce);
let mut wrong_pin = built.hash();
wrong_pin[0] ^= 0x01;
assert_eq!(
evaluate_subtree_structure(built.commitment(), &proof, &nonce, &wrong_pin, &peer),
Err(AuditFailureReason::Rejected)
);
}
#[test]
fn tampered_leaf_structure_rejected() {
let nonce = [3u8; 32];
let (built, mut proof, peer) = honest(200, &nonce);
if let Some(first) = proof.leaves.first_mut() {
first.bytes_hash[0] ^= 0x01; }
assert_eq!(
structure(&built, &proof, &nonce, &peer),
Err(AuditFailureReason::DigestMismatch)
);
}
#[test]
fn wrong_leaf_count_structure_rejected() {
let nonce = [3u8; 32];
let (built, mut proof, peer) = honest(200, &nonce);
proof.leaves.pop();
assert_eq!(
structure(&built, &proof, &nonce, &peer),
Err(AuditFailureReason::DigestMismatch)
);
}
#[test]
fn deleter_absent_bytes_is_confirmed_failure() {
let nonce = [9u8; 32];
let (built, proof, peer) = honest(400, &nonce);
assert!(structure(&built, &proof, &nonce, &peer).is_ok());
let s = sample(&proof, &nonce, built.commitment().key_count);
let victim = s.first().map(|l| l.key).unwrap();
let v = verify_byte_response(&s, &nonce, &peer, |k| {
if *k == victim {
Some(None) } else {
Some(Some(chunk_bytes(k)))
}
});
assert_eq!(v, AuditVerdict::Fail(AuditFailureReason::DigestMismatch));
}
#[test]
fn omitted_committed_key_is_confirmed_failure() {
let nonce = [9u8; 32];
let (built, proof, peer) = honest(400, &nonce);
let s = sample(&proof, &nonce, built.commitment().key_count);
let victim = s.first().map(|l| l.key).unwrap();
let v = verify_byte_response(&s, &nonce, &peer, |k| {
if *k == victim {
None } else {
Some(Some(chunk_bytes(k)))
}
});
assert_eq!(v, AuditVerdict::Fail(AuditFailureReason::DigestMismatch));
}
#[test]
fn fake_storage_garbage_bytes_is_confirmed_failure() {
let nonce = [9u8; 32];
let (built, proof, peer) = honest(400, &nonce);
let s = sample(&proof, &nonce, built.commitment().key_count);
let v = verify_byte_response(&s, &nonce, &peer, |k| {
let mut garbage = blake3::hash(k).as_bytes().to_vec();
garbage.extend_from_slice(b"adversary-fake-storage");
Some(Some(garbage))
});
assert_eq!(v, AuditVerdict::Fail(AuditFailureReason::DigestMismatch));
}
#[test]
fn correct_content_address_but_stale_freshness_fails() {
let nonce = [9u8; 32];
let (built, mut proof, peer) = honest(400, &nonce);
let other_nonce = [0xEEu8; 32];
for leaf in &mut proof.leaves {
leaf.nonced_hash =
nonced_leaf_hash(&other_nonce, &peer, &leaf.key, &chunk_bytes(&leaf.key));
}
let s = sample(&proof, &nonce, built.commitment().key_count);
let v = verify_byte_response(&s, &nonce, &peer, served_honest);
assert_eq!(v, AuditVerdict::Fail(AuditFailureReason::DigestMismatch));
}
#[test]
fn auditor_holds_nothing_still_catches_deleter() {
let nonce = [0x21u8; 32];
let (built, proof, peer) = honest(256, &nonce);
assert!(structure(&built, &proof, &nonce, &peer).is_ok());
let s = sample(&proof, &nonce, built.commitment().key_count);
let v = verify_byte_response(&s, &nonce, &peer, |_| Some(None));
assert_eq!(v, AuditVerdict::Fail(AuditFailureReason::DigestMismatch));
}
#[test]
fn sample_size_is_in_3_to_5_band() {
let nonce = [7u8; 32];
let (built, proof, _peer) = honest(1024, &nonce);
let s = sample(&proof, &nonce, built.commitment().key_count);
assert!(
(BYTE_SPOTCHECK_MIN as usize..=BYTE_SPOTCHECK_MAX as usize).contains(&s.len()),
"sample {} must be within 3..=5",
s.len()
);
}
#[test]
fn full_pass_requires_every_sampled_leaf() {
let nonce = [11u8; 32];
let (built, proof, peer) = honest(400, &nonce);
let s = sample(&proof, &nonce, built.commitment().key_count);
match verify_byte_response(&s, &nonce, &peer, served_honest) {
AuditVerdict::Pass { checked } => assert_eq!(checked, s.len()),
other @ AuditVerdict::Fail(_) => panic!("expected Pass, got {other:?}"),
}
}
#[test]
fn structure_fail_short_circuits_before_round_2() {
let nonce = [5u8; 32];
let (built, mut proof, peer) = honest(300, &nonce);
if let Some(first) = proof.leaves.first_mut() {
first.bytes_hash[0] ^= 0x01;
}
assert!(structure(&built, &proof, &nonce, &peer).is_err());
}
fn honest_far(n: u32, nonce: &[u8; 32]) -> (BuiltCommitment, SubtreeProof, [u8; 32]) {
let (pk, sk) = ml_dsa_65().generate_keypair().unwrap();
let peer_id = *blake3::hash(&pk.to_bytes()).as_bytes();
let pk_b = pk.to_bytes();
let entries: Vec<_> = (0..n)
.map(|i| {
let mut k = [0xFFu8; 32];
k[28..].copy_from_slice(&i.to_be_bytes());
(k, *blake3::hash(&chunk_bytes(&k)).as_bytes())
})
.collect();
let built = BuiltCommitment::build(entries, &peer_id, &sk, &pk_b).unwrap();
let proof =
build_subtree_proof(built.tree(), nonce, &peer_id, |k| Some(chunk_bytes(k))).unwrap();
(built, proof, peer_id)
}
#[test]
fn closeness_is_observe_only_far_keys_still_pass() {
let nonce = [9u8; 32];
let (built_far, proof_far, peer_far) = honest_far(400, &nonce);
assert!(structure(&built_far, &proof_far, &nonce, &peer_far).is_ok());
let sf = sample(&proof_far, &nonce, built_far.commitment().key_count);
let v_far = verify_byte_response(&sf, &nonce, &peer_far, served_honest);
let (built_near, proof_near, peer_near) = honest(400, &nonce);
assert!(structure(&built_near, &proof_near, &nonce, &peer_near).is_ok());
let sn = sample(&proof_near, &nonce, built_near.commitment().key_count);
let v_near = verify_byte_response(&sn, &nonce, &peer_near, served_honest);
match (&v_far, &v_near) {
(AuditVerdict::Pass { checked: cf }, AuditVerdict::Pass { checked: cn }) => {
assert!(*cf >= 1 && *cn >= 1);
}
other => panic!("both honest proofs must Pass regardless of closeness, got {other:?}"),
}
assert!(
!matches!(v_far, AuditVerdict::Fail(_)),
"far/padding-shaped honest proof must NEVER fail, got {v_far:?}"
);
}
#[test]
fn subtree_leaf_is_constructible() {
let _l = SubtreeLeaf {
key: key(1),
bytes_hash: [0u8; 32],
nonced_hash: [0u8; 32],
};
}
}