diff --git a/src/node.rs b/src/node.rs index ebee324..fa23cc9 100644 --- a/src/node.rs +++ b/src/node.rs @@ -150,6 +150,7 @@ impl NodeBuilder { Arc::clone(&p2p_arc), storage_arc, payment_verifier_arc, + Arc::clone(&identity), &self.config.root_dir, fresh_rx, shutdown.clone(), diff --git a/src/replication/audit.rs b/src/replication/audit.rs index af4584f..fcf5b92 100644 --- a/src/replication/audit.rs +++ b/src/replication/audit.rs @@ -10,11 +10,17 @@ use rand::seq::SliceRandom; use rand::Rng; use crate::ant_protocol::XorName; +use crate::replication::commitment::{commitment_hash, CommitmentBoundResult, StorageCommitment}; +use crate::replication::commitment_audit::{ + verify_commitment_bound_metadata, verify_commitment_bound_per_key, +}; +use crate::replication::commitment_state::PeerCommitmentRecord; use crate::replication::config::{ReplicationConfig, REPLICATION_PROTOCOL_ID}; use crate::replication::protocol::{ compute_audit_digest, AuditChallenge, AuditResponse, ReplicationMessage, ReplicationMessageBody, ABSENT_KEY_DIGEST, }; +use crate::replication::recent_provers::RecentProvers; use crate::replication::types::{ AuditFailureReason, FailureEvidence, PeerSyncRecord, RepairProofs, }; @@ -57,6 +63,29 @@ pub enum AuditTickResult { // Main audit tick // --------------------------------------------------------------------------- +/// Read-only context the auditor uses to issue commitment-bound audits. +/// +/// Bundled into one struct so [`audit_tick_with_repair_proofs`] stays +/// readable when v12 enforcement is enabled. Passing `None` falls back +/// to today's plain-digest audit; passing `Some` opts in on a per-peer +/// basis (a peer with no entry in `last_commitment_by_peer` still gets +/// the legacy path). +/// +/// `last_commitment_by_peer` and `recent_provers` are owned by +/// [`crate::replication::ReplicationEngine`]; this struct borrows them. +pub struct CommitmentAuditCtx<'a> { + /// Per-peer record: last-known commitment + sticky `commitment_capable` + /// flag (populated from gossip ingest). The auditor pins + /// `commitment_hash(record.last_commitment)` into the challenge for + /// any peer whose record carries a commitment. + pub last_commitment_by_peer: &'a Arc>>, + /// Holder-eligibility cache. On a successful commitment-bound audit + /// the auditor records `(challenged_peer, key, commitment_hash)` so + /// downstream code (quorum, paid lists) can credit the peer as a + /// real holder. + pub recent_provers: &'a Arc>, +} + /// Execute one audit tick (Section 15 steps 2-9). /// /// Returns the audit result. Caller is responsible for emitting trust events. @@ -81,6 +110,7 @@ pub async fn audit_tick( &repair_proofs, 0, is_bootstrapping, + None, ) .await } @@ -100,6 +130,7 @@ pub async fn audit_tick_with_repair_proofs( repair_proofs: &Arc>, current_sync_epoch: u64, is_bootstrapping: bool, + commitment_ctx: Option<&CommitmentAuditCtx<'_>>, ) -> AuditTickResult { // Invariant 19: never audit while still bootstrapping. if is_bootstrapping { @@ -183,12 +214,60 @@ pub async fn audit_tick_with_repair_proofs( // so no explicit truncation needed. // Step 6: Send challenge. + // + // Phase 3: if we have a commitment audit context AND we have a last + // known commitment from this peer (received via gossip), pin its + // hash into the challenge so the responder must answer against the + // exact commitment whose hash we pinned. Defeats fresh-commitment + // substitution by lazy nodes (v12 §5 gate 2b). + // + // We snapshot the pinned commitment alongside the hash so the + // response-handling code can verify against the SAME commitment we + // pinned (avoids a race where the peer's last_commitment_by_peer + // entry rotates between issue and response handling). + // Snapshot the peer record once; we use it both for pinning the + // challenge and (below) for the §3 commitment_capable downgrade + // check. Record carries last_commitment + sticky `commitment_capable`. + let peer_record = match commitment_ctx { + Some(ctx) => ctx + .last_commitment_by_peer + .read() + .await + .get(&challenged_peer) + .cloned(), + None => None, + }; + let (expected_commitment_hash, pinned_commitment) = match peer_record.as_ref() { + Some(r) => match r.last_commitment.as_ref() { + Some(c) => (commitment_hash(c), Some(c.clone())), + None => (None, None), + }, + None => (None, None), + }; + + // §3 + §6 bootstrap-claim shield: if this peer has EVER gossiped a + // commitment (commitment_capable is sticky) but we currently have + // no last_commitment for them (TTL'd, lost via restart, or they + // stopped gossiping), we MUST NOT fall back to legacy plain-digest + // audits. The peer is fully expected to speak v12. Falling back + // would let them downgrade to the weaker path. Return Idle until + // they re-gossip a fresh commitment. + if let Some(r) = peer_record.as_ref() { + if r.commitment_capable && r.last_commitment.is_none() { + info!( + "Audit: peer {challenged_peer} is commitment-capable but we have no \ + cached commitment (TTL/restart/silence); skipping audit until fresh gossip" + ); + return AuditTickResult::Idle; + } + } let challenge = AuditChallenge { challenge_id, nonce, challenged_peer_id: *challenged_peer.as_bytes(), keys: peer_keys.clone(), + expected_commitment_hash, }; let msg = ReplicationMessage { @@ -282,6 +361,28 @@ pub async fn audit_tick_with_repair_proofs( ) .await; } + // Wire-contract enforcement (codex round-9 MAJOR): when we + // pinned a commitment hash into the challenge, the responder + // MUST answer with CommitmentBound or Rejected/Bootstrapping. + // Falling back to plain Digests would let a peer that has + // already gossiped a commitment ignore the storage-bound + // path and pass via on-demand fetch under the weaker legacy + // verifier. Treat as malformed. + if expected_commitment_hash.is_some() { + warn!( + "Audit: peer {challenged_peer} answered Digests to a pinned challenge \ + (commitment-bound contract violation) — treating as malformed" + ); + return handle_audit_failure( + &challenged_peer, + challenge_id, + &peer_keys, + AuditFailureReason::MalformedResponse, + p2p_node, + config, + ) + .await; + } verify_digests( &challenged_peer, challenge_id, @@ -310,6 +411,89 @@ pub async fn audit_tick_with_repair_proofs( ) .await; } + // v12 paragraph 5 conditional invalidation, refined: + // + // When we issued a pinned challenge and the peer responds + // "unknown commitment hash", DO NOT drop the pin and DO NOT + // give a free pass. Two reasons: + // + // 1. If the peer genuinely rotated past our pin (honest + // case), their two-slot retention (current+previous) + // means they could still answer one rotation back — + // so "unknown" here means we are at least two + // rotations behind their gossip. The next gossip round + // (a few minutes) will bring us a fresh commitment to + // pin, and the cache entry will be replaced naturally + // via the gossip ingest path. We don't need to drop + // anything ourselves. + // + // 2. If we drop the pin on "unknown", a malicious peer + // can claim "unknown" to shed every pinned audit they + // receive — the next tick has no pin → legacy plain- + // digest path → on-demand fetch attack reopens + // (codex round-8 MAJOR). + // + // So: when the responder says "unknown" AND we pinned, log + // and return Idle without penalty (one tick wasted) but + // KEEP the pin. The honest case self-resolves via gossip; + // the malicious case keeps re-failing pinned audits until + // their trust drops naturally through other mechanisms or + // we receive a fresh gossiped commitment. Strict gating on + // exact reason + pinned challenge prevents the round-6 + // bypass (a peer cannot trigger this path on a legacy + // unpinned audit because expected_commitment_hash is None). + if expected_commitment_hash.is_some() && reason == "unknown commitment hash" { + // v12 §5 conditional invalidation: + // - Case 1 (lazy rotation): peer dropped bytes, no fresh + // gossip, still pinned to H. Stored hash == H. Clear + // the pin → recent_provers entries lose their match + // basis → credit dropped via is_credited_holder. This + // is now safe because §3 above causes the next audit + // to return Idle (commitment_capable but no + // last_commitment) instead of falling back to legacy. + // - Case 2 (honest rotation): peer gossiped C2 between + // our challenge and processing. Stored hash != H. + // Keep the new C2 entry, drop credits anchored to H. + // - Case 3 (stale auditor): same as case 1; clear pin, + // wait for next gossip. + if let (Some(ctx), Some(pin)) = (commitment_ctx, expected_commitment_hash) { + let mut last = ctx.last_commitment_by_peer.write().await; + if let Some(rec) = last.get_mut(&challenged_peer) { + let stored_h = rec.last_commitment.as_ref().and_then(commitment_hash); + if stored_h == Some(pin) { + // Still the rejected commitment — clear it + // but keep `commitment_capable` sticky. + rec.last_commitment = None; + } + // else: a fresh commitment arrived in the meantime; + // leave it untouched (don't clobber). + } + drop(last); + // Drop credit anchored to the now-stale pin so the + // peer must re-prove every key under the new + // commitment to keep holder status (v12 §6). + ctx.recent_provers.write().await.forget_commitment(&pin); + } + info!( + "Audit: peer {challenged_peer} rotated past pinned commitment; \ + dropped stale pin and credits (no trust penalty)" + ); + return AuditTickResult::Idle; + } + // v12 paragraph 5: "key not in commitment" is also a benign + // staleness signal, NOT a failure. The auditor sampled a key + // it holds and that the peer SHOULD hold (close-group), but + // which the peer hasn't yet committed to (e.g. just-replicated + // after their last rotation). Penalising this would punish + // honest peers who have the bytes but haven't rebuilt their + // Merkle tree yet (codex round-11 MAJOR #2). + if expected_commitment_hash.is_some() && reason.starts_with("key not in commitment") { + info!( + "Audit: peer {challenged_peer} reports key-not-in-commitment; \ + skipping (responder commitment is stale relative to its key set)" + ); + return AuditTickResult::Idle; + } warn!("Audit: challenge rejected by {challenged_peer}: {reason}"); handle_audit_failure( &challenged_peer, @@ -321,6 +505,39 @@ pub async fn audit_tick_with_repair_proofs( ) .await } + ReplicationMessageBody::AuditResponse(AuditResponse::CommitmentBound { + challenge_id: resp_id, + commitment, + per_key, + }) => { + if resp_id != challenge_id { + warn!("Audit: challenge ID mismatch on CommitmentBound from {challenged_peer}"); + return handle_audit_failure( + &challenged_peer, + challenge_id, + &peer_keys, + AuditFailureReason::MalformedResponse, + p2p_node, + config, + ) + .await; + } + verify_commitment_bound( + &challenged_peer, + challenge_id, + &nonce, + &peer_keys, + expected_commitment_hash.as_ref(), + pinned_commitment.as_ref(), + &commitment, + &per_key, + storage, + p2p_node, + config, + commitment_ctx, + ) + .await + } _ => { warn!("Audit: unexpected response type from {challenged_peer}"); handle_audit_failure( @@ -452,6 +669,164 @@ async fn verify_digests( .await } +// --------------------------------------------------------------------------- +// Commitment-bound verification (v12) +// --------------------------------------------------------------------------- + +/// Verify a `CommitmentBound` audit response (Step 8, v12 path). +/// +/// Runs the pure verifier `verify_commitment_bound_response` against the +/// commitment we pinned (NOT the one in the response — the response's +/// commitment must hash-match the pin), then on success records the +/// challenged peer as a recent prover for each verified key. +/// +/// The verifier checks five gates: structural, peer-id binding, pin, +/// signature (using the pubkey embedded in the commitment), and per-key +/// (bytes_hash + Merkle path + audit digest). Any failure path → standard +/// `AUDIT_FAILURE_TRUST_WEIGHT × keys` penalty. +#[allow(clippy::too_many_arguments)] +async fn verify_commitment_bound( + challenged_peer: &PeerId, + challenge_id: u64, + nonce: &[u8; 32], + keys: &[XorName], + expected_commitment_hash: Option<&[u8; 32]>, + pinned_commitment: Option<&StorageCommitment>, + response_commitment: &StorageCommitment, + response_per_key: &[CommitmentBoundResult], + storage: &Arc, + p2p_node: &Arc, + config: &ReplicationConfig, + commitment_ctx: Option<&CommitmentAuditCtx<'_>>, +) -> AuditTickResult { + // Sanity: a CommitmentBound response must have been answered to a + // pinned challenge. If we didn't pin (or have no ctx), this is a + // protocol violation by the peer. + let Some(pin) = expected_commitment_hash else { + warn!( + "Audit: peer {challenged_peer} sent CommitmentBound for an unpinned challenge — \ + treating as malformed" + ); + return handle_audit_failure( + challenged_peer, + challenge_id, + keys, + AuditFailureReason::MalformedResponse, + p2p_node, + config, + ) + .await; + }; + // `pinned_commitment` itself is not used here — the pin (hash) is + // sufficient because `verify_commitment_bound_response` re-hashes + // the response's commitment and compares to the pin. Keeping the + // parameter at the call site documents the contract and lets future + // optimizations (e.g. cache by-pin local-bytes lookup) use it + // without re-plumbing. + let _ = pinned_commitment; + + // Metadata gates (structural / peer-id / pin / sig). One-shot, cheap. + if let Err(e) = verify_commitment_bound_metadata( + keys, + challenged_peer.as_bytes(), + pin, + response_commitment, + response_per_key, + ) { + warn!( + "Audit: peer {challenged_peer} failed commitment-bound metadata: {e} (pin={})", + hex::encode(pin), + ); + return handle_audit_failure( + challenged_peer, + challenge_id, + keys, + AuditFailureReason::DigestMismatch, + p2p_node, + config, + ) + .await; + } + + // Per-key gates streamed one chunk at a time. Avoids the + // sqrt(n)*MAX_CHUNK_SIZE worst case of preloading every challenged + // chunk (~4 GiB at 1M stored chunks) — codex round-5 BLOCKER #2. + for (i, result) in response_per_key.iter().enumerate() { + let local_bytes = match storage.get_raw(&result.key).await { + Ok(Some(b)) => b, + Ok(None) => { + debug!( + "Audit: local key {} missing for commitment-bound check", + hex::encode(result.key) + ); + // Treat missing local copy as bytes-hash mismatch — we + // sampled it from our key set, so disappearance is rare. + return handle_audit_failure( + challenged_peer, + challenge_id, + keys, + AuditFailureReason::DigestMismatch, + p2p_node, + config, + ) + .await; + } + Err(e) => { + warn!( + "Audit: failed to read local key {}: {e}", + hex::encode(result.key) + ); + return AuditTickResult::Idle; + } + }; + + if let Err(e) = verify_commitment_bound_per_key( + i, + nonce, + challenged_peer.as_bytes(), + response_commitment, + result, + &local_bytes, + ) { + warn!( + "Audit: peer {challenged_peer} failed commitment-bound per-key #{i}: {e} \ + (pin={})", + hex::encode(pin), + ); + // local_bytes drops here, bounding peak memory at one chunk. + return handle_audit_failure( + challenged_peer, + challenge_id, + keys, + AuditFailureReason::DigestMismatch, + p2p_node, + config, + ) + .await; + } + } + + info!( + "Audit: peer {challenged_peer} passed commitment-bound audit ({} keys, pin={})", + keys.len(), + hex::encode(pin), + ); + // Credit the peer as a holder for each verified key under + // this exact commitment hash. Downstream (quorum, paid lists) + // can read `recent_provers.is_credited_holder(...)`. + if let Some(ctx) = commitment_ctx { + let now = std::time::Instant::now(); + let mut guard = ctx.recent_provers.write().await; + for key in keys { + guard.record_proof(*key, *challenged_peer, *pin, now); + } + } + AuditTickResult::Passed { + challenged_peer: *challenged_peer, + keys_checked: keys.len(), + } +} + // --------------------------------------------------------------------------- // Failure handling with responsibility confirmation // --------------------------------------------------------------------------- @@ -538,6 +913,37 @@ pub async fn handle_audit_challenge( self_peer_id: &PeerId, is_bootstrapping: bool, stored_chunks: usize, +) -> AuditResponse { + handle_audit_challenge_with_commitment( + challenge, + storage, + self_peer_id, + is_bootstrapping, + stored_chunks, + None, + ) + .await +} + +/// Like [`handle_audit_challenge`] but also accepts a responder's +/// `ResponderCommitmentState`. If the challenge carries +/// `expected_commitment_hash: Some(h)`, dispatches to the v12 +/// commitment-bound response path (gates: structural / pin / signature +/// / per-key path+digest); otherwise falls through to the legacy +/// plain-digest path. +/// +/// Backwards-compatible: existing callers that don't have a +/// `ResponderCommitmentState` keep calling `handle_audit_challenge`, +/// which forwards here with `commitment_state = None`. +pub async fn handle_audit_challenge_with_commitment( + challenge: &AuditChallenge, + storage: &LmdbStorage, + self_peer_id: &PeerId, + is_bootstrapping: bool, + stored_chunks: usize, + commitment_state: Option< + &std::sync::Arc, + >, ) -> AuditResponse { if is_bootstrapping { return AuditResponse::Bootstrapping { @@ -573,6 +979,93 @@ pub async fn handle_audit_challenge( }; } + // v12 commitment-bound path: when the auditor pinned a specific + // commitment, look it up in our state and produce a CommitmentBound + // response. If we don't have that commitment (rotated away, never + // gossiped, etc.) reject with reason="unknown commitment hash" — + // the auditor's v12 paragraph 5 handler keeps the pin (no penalty) + // and waits for fresh gossip to replace it. + if let (Some(expected_hash), Some(state)) = ( + challenge.expected_commitment_hash.as_ref(), + commitment_state, + ) { + // Precheck WITHOUT reading any chunk bytes (codex round-9 MAJOR: + // the prior preload-into-HashMap pattern hit O(sample×4MiB) + // peak memory). Cheap: hash-map lookup + per-key proof_for. + let built = match crate::replication::commitment_state::precheck_commitment_bound_challenge( + state, + expected_hash, + &challenge.keys, + ) { + Ok(b) => b, + Err( + crate::replication::commitment_state::CommitmentBoundOutcome::UnknownCommitmentHash, + ) => { + return AuditResponse::Rejected { + challenge_id: challenge.challenge_id, + reason: "unknown commitment hash".to_string(), + }; + } + Err( + crate::replication::commitment_state::CommitmentBoundOutcome::KeyNotInCommitment { + key, + }, + ) => { + return AuditResponse::Rejected { + challenge_id: challenge.challenge_id, + reason: format!("key not in commitment: {}", hex::encode(key)), + }; + } + Err(_) => unreachable!("precheck only returns those two outcomes"), + }; + + // Stream per-key: read one chunk, build its proof entry, drop + // the bytes, move to the next. Peak memory is bounded at + // MAX_CHUNK_SIZE (4 MiB) regardless of sample size. + let mut per_key = Vec::with_capacity(challenge.keys.len()); + for key in &challenge.keys { + let bytes = match storage.get_raw(key).await { + Ok(Some(b)) => b, + Ok(None) | Err(_) => { + // Key IS in the commitment (precheck above ensured + // it) but we cannot read the bytes anymore. That's + // real storage loss / deliberate non-response, not + // benign staleness. Use a distinct reason string so + // the auditor penalises (codex round-12 MAJOR #1). + return AuditResponse::Rejected { + challenge_id: challenge.challenge_id, + reason: format!("missing bytes for committed key: {}", hex::encode(key)), + }; + } + }; + let Some(entry) = + crate::replication::commitment_state::build_commitment_bound_result_for_key( + &built, + key, + &challenge.nonce, + &challenge.challenged_peer_id, + &bytes, + ) + else { + // Precheck guaranteed proof_for(key) returns Some, so + // this is unreachable. Defensive only. + return AuditResponse::Rejected { + challenge_id: challenge.challenge_id, + reason: format!("key not in commitment: {}", hex::encode(key)), + }; + }; + per_key.push(entry); + // bytes drops here. + } + + return AuditResponse::CommitmentBound { + challenge_id: challenge.challenge_id, + commitment: built.commitment().clone(), + per_key, + }; + } + + // Legacy plain-digest path (unchanged from pre-v12). let mut digests = Vec::with_capacity(challenge.keys.len()); for key in &challenge.keys { @@ -648,6 +1141,7 @@ mod tests { nonce, challenged_peer_id: peer_id, keys, + expected_commitment_hash: None, } } @@ -698,6 +1192,9 @@ mod tests { AuditResponse::Rejected { .. } => { panic!("Unexpected Rejected response"); } + AuditResponse::CommitmentBound { .. } => { + panic!("Unexpected CommitmentBound response in legacy-digest test") + } } } @@ -734,6 +1231,9 @@ mod tests { AuditResponse::Rejected { .. } => { panic!("Unexpected Rejected response"); } + AuditResponse::CommitmentBound { .. } => { + panic!("Unexpected CommitmentBound response in legacy-digest test") + } } } @@ -774,6 +1274,9 @@ mod tests { AuditResponse::Rejected { .. } => { panic!("Unexpected Rejected response"); } + AuditResponse::CommitmentBound { .. } => { + panic!("Unexpected CommitmentBound response in legacy-digest test") + } } } @@ -799,6 +1302,9 @@ mod tests { AuditResponse::Rejected { .. } => { panic!("Unexpected Rejected response"); } + AuditResponse::CommitmentBound { .. } => { + panic!("Unexpected CommitmentBound response in legacy-digest test") + } } } @@ -831,6 +1337,9 @@ mod tests { AuditResponse::Rejected { .. } => { panic!("Unexpected Rejected response"); } + AuditResponse::CommitmentBound { .. } => { + panic!("Unexpected CommitmentBound response in legacy-digest test") + } } } @@ -977,6 +1486,7 @@ mod tests { nonce, challenged_peer_id: peer_id, keys: vec![addr_k1, addr_k2, addr_k3], + expected_commitment_hash: None, }; let self_id = peer_id_from_bytes(peer_id); @@ -1000,6 +1510,9 @@ mod tests { } AuditResponse::Bootstrapping { .. } => panic!("Expected Digests response"), AuditResponse::Rejected { .. } => panic!("Unexpected Rejected response"), + AuditResponse::CommitmentBound { .. } => { + panic!("Unexpected CommitmentBound response in legacy-digest test") + } } } @@ -1028,6 +1541,7 @@ mod tests { nonce, challenged_peer_id: peer_id, keys: vec![a1, a2, a3], + expected_commitment_hash: None, }; let self_id = peer_id_from_bytes(peer_id); @@ -1046,6 +1560,9 @@ mod tests { } AuditResponse::Bootstrapping { .. } => panic!("Expected Digests"), AuditResponse::Rejected { .. } => panic!("Unexpected Rejected response"), + AuditResponse::CommitmentBound { .. } => { + panic!("Unexpected CommitmentBound response in legacy-digest test") + } } } @@ -1306,6 +1823,9 @@ mod tests { } AuditResponse::Bootstrapping { .. } => panic!("Expected Digests"), AuditResponse::Rejected { .. } => panic!("Unexpected Rejected response"), + AuditResponse::CommitmentBound { .. } => { + panic!("Unexpected CommitmentBound response in legacy-digest test") + } } } @@ -1507,6 +2027,9 @@ mod tests { AuditResponse::Rejected { .. } => { panic!("Unexpected Rejected response") } + AuditResponse::CommitmentBound { .. } => { + panic!("Unexpected CommitmentBound response in legacy-digest test") + } }; assert_eq!(challenge_id, 4700); diff --git a/src/replication/commitment.rs b/src/replication/commitment.rs new file mode 100644 index 0000000..9f99473 --- /dev/null +++ b/src/replication/commitment.rs @@ -0,0 +1,903 @@ +//! Storage-bound audit via piggybacked commitments. +//! +//! Implements the v12 design (`notes/security-findings-2026-05-22/ +//! proposal-gossip-audit-v12.md`) for closing audit Findings 1 and 2. +//! +//! ## What this module provides +//! +//! - [`StorageCommitment`] — the wire type sent on neighbour-sync gossip +//! and embedded in commitment-bound audit responses. `ML-DSA-65` signed +//! over `(root, key_count, sender_peer_id)` with explicit domain separation. +//! - [`MerkleTree`] — an in-memory Merkle tree over `(key, BLAKE3(bytes))` +//! leaves. Rebuilt by the responder when its key set changes; produces +//! inclusion paths used in audit responses. +//! - [`commitment_hash`] — the auditor's pin: a `BLAKE3` digest over the +//! full signed commitment blob. Audit challenges carry this; audit +//! responses must include a commitment that hashes to the same value. +//! - [`CommitmentBoundResult`] — per-key entry in the audit response. +//! - [`verify_path`] — auditor's per-key check: rebuilds the leaf from +//! `(key, bytes_hash)` and verifies the inclusion path against the +//! committed root. +//! +//! Nothing else (responder gossip loop, auditor verify path, +//! reward-eligibility cache) lives here yet — that's the next phase. + +use blake3::Hasher; +use saorsa_pqc::api::sig::{ + ml_dsa_65, MlDsaPublicKey, MlDsaSecretKey, MlDsaSignature, MlDsaVariant, +}; +use serde::{Deserialize, Serialize}; + +use crate::ant_protocol::XorName; + +/// Domain-separation tag for the commitment signature. +/// +/// Signed payload is BLAKE3 over (this tag || canonical commitment fields). +pub const DOMAIN_COMMITMENT: &[u8] = b"autonomi.ant.replication.storage_commitment.v1"; + +/// Domain-separation tag for the auditor's pin: BLAKE3 over (this tag || +/// canonical commitment blob). +pub const DOMAIN_COMMITMENT_HASH: &[u8] = b"autonomi.ant.replication.commitment_hash.v1"; + +/// Domain-separation tag for Merkle leaves: `BLAKE3(this || key || H(bytes))`. +pub const DOMAIN_LEAF: &[u8] = b"autonomi.ant.replication.storage_leaf.v1"; + +/// Domain-separation tag for Merkle internal nodes: `BLAKE3(this || left || right)`. +pub const DOMAIN_NODE: &[u8] = b"autonomi.ant.replication.storage_node.v1"; + +/// Maximum number of keys a single commitment may cover. +/// +/// Bounds the Merkle path depth (audit responses carry `O(log2 key_count)` +/// hashes per key) and the responder-side tree memory. A node storing more +/// keys than this would need to split its claim — out of scope for v1. +pub const MAX_COMMITMENT_KEY_COUNT: u32 = 1_000_000; + +/// Signed storage commitment. +/// +/// Piggybacked on neighbour-sync gossip. The signature commits to the +/// Merkle root, key count, sender peer ID, **and the sender's ML-DSA-65 +/// public key** under [`DOMAIN_COMMITMENT`]. +/// +/// Embedding the public key lets any receiver verify the signature +/// without an external `PeerId → MlDsaPublicKey` lookup. Binding the +/// public key in the signed payload prevents a key-swap attack where an +/// adversary keeps the message body but re-signs it under a different key +/// to claim a different identity. The peer-id binding (gate 2a in +/// `verify_commitment_bound_response`) still ensures the embedded key +/// belongs to the gossiping peer. +/// +/// # Wire size +/// +/// One commitment is approximately 5.3 KiB: +/// - root: 32 B +/// - key_count: 4 B +/// - sender_peer_id: 32 B +/// - sender_public_key: 1952 B (ML-DSA-65 public key) +/// - signature: 3293 B (ML-DSA-65 signature) +/// +/// Piggybacked on every `NeighborSyncRequest`/`Response` (~1 h interval +/// per close-group peer with the round-11 rotation cadence). At a +/// realistic close-group size of 8 with bidirectional sync, that's +/// roughly 8 × 2 × 5.3 KiB / hour = ~85 KiB/h of additional gossip +/// per node. Negligible against typical chunk-transfer bandwidth. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StorageCommitment { + /// Merkle root over the responder's claimed keys. + pub root: [u8; 32], + /// Number of leaves committed over. + pub key_count: u32, + /// Sender peer ID, bound to the signature. + pub sender_peer_id: [u8; 32], + /// Sender's ML-DSA-65 public key bytes (1952 bytes). Embedded so + /// receivers can verify the signature without a separate pubkey + /// directory. Bound by the signature. + pub sender_public_key: Vec, + /// ML-DSA-65 signature over canonical commitment fields. 3293 bytes. + pub signature: Vec, +} + +/// Per-key result in a commitment-bound audit response. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CommitmentBoundResult { + /// The challenged key. + pub key: XorName, + /// `BLAKE3(nonce || challenged_peer_id || key || record_bytes)`. Same + /// digest the existing [`compute_audit_digest`] produces; the auditor + /// recomputes and compares. + /// + /// [`compute_audit_digest`]: crate::replication::protocol::compute_audit_digest + pub digest: [u8; 32], + /// `BLAKE3(record_bytes)`. The auditor uses this to rebuild the Merkle + /// leaf and checks it matches its own local bytes hash. + pub bytes_hash: [u8; 32], + /// Position of the leaf for `key` in the responder's sorted leaf set. + /// + /// The auditor uses this to know, at each level of the path, whether + /// the current hash is the left or right child (even index = left, + /// odd = right). Without it the auditor cannot reconstruct the root + /// because the same set of sibling hashes admits two different + /// orderings. + /// + /// `leaf_index < commitment.key_count` is enforced in the verifier. + pub leaf_index: u32, + /// Inclusion path from `leaf = BLAKE3(DOMAIN_LEAF || key || bytes_hash)` + /// up to the root. One sibling hash per tree level. + pub path: Vec<[u8; 32]>, +} + +// --------------------------------------------------------------------------- +// Hashing helpers +// --------------------------------------------------------------------------- + +/// Compute the Merkle leaf hash for `(key, bytes_hash)`. +/// +/// `bytes_hash` is BLAKE3 over the record bytes; the leaf binds the key to +/// the content so an adversary cannot reuse a leaf for a different chunk. +#[must_use] +pub fn leaf_hash(key: &XorName, bytes_hash: &[u8; 32]) -> [u8; 32] { + let mut h = Hasher::new(); + h.update(DOMAIN_LEAF); + h.update(key); + h.update(bytes_hash); + *h.finalize().as_bytes() +} + +/// Combine two child hashes into a Merkle internal-node hash. +#[must_use] +pub fn node_hash(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] { + let mut h = Hasher::new(); + h.update(DOMAIN_NODE); + h.update(left); + h.update(right); + *h.finalize().as_bytes() +} + +/// The auditor's pin: `BLAKE3(DOMAIN_COMMITMENT_HASH || postcard(commitment))`. +/// +/// Equal commitments produce equal hashes; any change to `root`, `key_count`, +/// peer ID, or signature changes the hash because postcard's canonical +/// encoding includes a length prefix for `signature`. The audit challenge +/// carries this value; the audit response must include a commitment that +/// hashes to the same value, defeating fresh-commitment substitution. +/// +/// Postcard encoding is the same canonical wire form the rest of the +/// replication protocol uses (`MessageCodec::encode`), so an encoded +/// commitment from a `NeighborSyncRequest` produces the same hash as the +/// same commitment received in an `AuditResponse`. +/// +/// # Errors +/// +/// Returns `None` only if postcard fails to serialize the commitment, which +/// in practice means the signature is somehow `> isize::MAX` bytes — not +/// reachable for ML-DSA-65 (3293 bytes). Callers may safely treat `None` as +/// a malformed commitment and drop it. +#[must_use] +pub fn commitment_hash(c: &StorageCommitment) -> Option<[u8; 32]> { + let serialized = postcard::to_allocvec(c).ok()?; + let mut h = Hasher::new(); + h.update(DOMAIN_COMMITMENT_HASH); + h.update(&serialized); + Some(*h.finalize().as_bytes()) +} + +/// Canonical bytes the ML-DSA signature covers: the commitment fields +/// minus the signature itself. +/// +/// `sender_public_key` is included so an adversary cannot keep the body +/// and re-sign under a different key (the audit-time verifier would +/// otherwise accept the swap because verification uses the embedded key). +fn commitment_signed_payload( + root: &[u8; 32], + key_count: u32, + sender_peer_id: &[u8; 32], + sender_public_key: &[u8], +) -> Vec { + let mut v = Vec::with_capacity(32 + 4 + 32 + 4 + sender_public_key.len()); + v.extend_from_slice(root); + v.extend_from_slice(&key_count.to_le_bytes()); + v.extend_from_slice(sender_peer_id); + // Length-prefix the pubkey so two different (key, suffix) splits cannot + // produce the same byte stream (canonical encoding). + let pk_len = u32::try_from(sender_public_key.len()).unwrap_or(u32::MAX); + v.extend_from_slice(&pk_len.to_le_bytes()); + v.extend_from_slice(sender_public_key); + v +} + +// --------------------------------------------------------------------------- +// Merkle tree +// --------------------------------------------------------------------------- + +/// In-memory Merkle tree over the responder's claimed keys. +/// +/// Leaves are `BLAKE3(DOMAIN_LEAF || key || BLAKE3(bytes))`, sorted by +/// `key`. Internal nodes are `BLAKE3(DOMAIN_NODE || left || right)`. When +/// a level has an odd number of nodes, the last node is paired with +/// **itself** — i.e. `node_hash(x, x)` — so the level above has +/// `ceil(n/2)` nodes. This is a standard self-pair construction (NOT +/// node promotion) and deterministically maps any non-empty key set to +/// a single root. +/// +/// Rebuilt by the responder whenever its key set changes meaningfully +/// (debounced in the integration layer; not this module's concern). +pub struct MerkleTree { + /// Sorted leaves, indexed by their position in the sorted key set. + /// + /// `leaves[i] = (key_i, leaf_hash(key_i, bytes_hash_i))`. + leaves: Vec<(XorName, [u8; 32])>, + /// Tree levels, level 0 is the leaves and the last level is the root. + /// + /// `levels[0].len() == leaves.len()`; `levels[L].len() == 1` where L + /// is the root level. + levels: Vec>, +} + +impl MerkleTree { + /// Build a Merkle tree over `(key, bytes_hash)` pairs. + /// + /// `entries` does not need to be sorted; this method sorts internally + /// so the produced root is deterministic per key set. Duplicate keys + /// are an error: the responder must deduplicate before calling. + /// + /// # Errors + /// + /// Returns an error if `entries` is empty (no commitment to make), if + /// `entries.len() > MAX_COMMITMENT_KEY_COUNT`, or if it contains + /// duplicate keys. + pub fn build(mut entries: Vec<(XorName, [u8; 32])>) -> Result { + if entries.is_empty() { + return Err(CommitmentError::EmptyKeySet); + } + if entries.len() > MAX_COMMITMENT_KEY_COUNT as usize { + return Err(CommitmentError::TooManyKeys(entries.len())); + } + + entries.sort_by(|a, b| a.0.cmp(&b.0)); + for w in entries.windows(2) { + if w[0].0 == w[1].0 { + return Err(CommitmentError::DuplicateKey(w[0].0)); + } + } + + let leaves: Vec<(XorName, [u8; 32])> = entries + .into_iter() + .map(|(k, bh)| { + let lh = leaf_hash(&k, &bh); + (k, lh) + }) + .collect(); + + let mut level: Vec<[u8; 32]> = leaves.iter().map(|(_, h)| *h).collect(); + let mut levels = vec![level.clone()]; + while level.len() > 1 { + level = build_next_level(&level); + levels.push(level.clone()); + } + + Ok(Self { leaves, levels }) + } + + /// The Merkle root of this tree. + /// + /// `unwrap`-free: `build` guarantees at least one level with at least + /// one entry, so `last().first()` is always `Some`. + #[must_use] + pub fn root(&self) -> [u8; 32] { + // SAFETY: build() enforces non-empty entries → non-empty leaves → + // non-empty levels → last level has exactly one hash. + self.levels + .last() + .and_then(|l| l.first()) + .copied() + .unwrap_or([0u8; 32]) + } + + /// The number of leaves (== claimed keys). + #[must_use] + pub fn key_count(&self) -> u32 { + // Cast is safe because build() rejects > MAX_COMMITMENT_KEY_COUNT. + u32::try_from(self.leaves.len()).unwrap_or(u32::MAX) + } + + /// Inclusion path for `key` from its leaf up to (but not including) + /// the root. + /// + /// Returns `None` if `key` is not in this tree. + #[must_use] + pub fn path_for(&self, key: &XorName) -> Option> { + let idx = self.leaves.binary_search_by(|(k, _)| k.cmp(key)).ok()?; + + let mut path = Vec::with_capacity(self.levels.len()); + let mut i = idx; + for level in &self.levels[..self.levels.len().saturating_sub(1)] { + // Sibling is the *other* half of the pair containing `i`. If + // `i` is the unpaired last node at this level, its sibling is + // itself (matches the self-pair construction in + // `build_next_level`). + let sibling_idx = if i % 2 == 0 { + if i + 1 < level.len() { + i + 1 + } else { + i + } + } else { + i - 1 + }; + path.push(level[sibling_idx]); + i /= 2; + } + Some(path) + } + + /// Iterate over `(key, leaf_hash)` pairs in sorted order. Test-only. + #[cfg(test)] + pub(crate) fn iter_leaves(&self) -> impl Iterator { + self.leaves.iter() + } + + /// The keys this tree commits to, in sorted order. + /// + /// `sorted_keys()[i]` is the key at leaf index `i`. Used by the + /// responder's audit-answer path to recover the `leaf_index` field + /// for a challenged key in `O(log n)` via binary search. + #[must_use] + pub fn sorted_keys(&self) -> Vec { + self.leaves.iter().map(|(k, _)| *k).collect() + } +} + +/// Build the next level up from `cur`. Odd-length levels pair the last +/// node with itself (`node_hash(x, x)`) so the level above has +/// `ceil(n/2)` nodes. Keeps the tree balanced without needing a dummy +/// leaf domain. +fn build_next_level(cur: &[[u8; 32]]) -> Vec<[u8; 32]> { + let mut next = Vec::with_capacity(cur.len().div_ceil(2)); + let mut i = 0; + while i < cur.len() { + let left = &cur[i]; + let right = if i + 1 < cur.len() { &cur[i + 1] } else { left }; + next.push(node_hash(left, right)); + i += 2; + } + next +} + +/// Verify an inclusion path against a commitment of size `key_count`. +/// +/// `leaf_index` is the responder's position of this leaf in the sorted +/// leaf set; the auditor reads it from `CommitmentBoundResult.leaf_index` +/// and the commitment's `key_count` from `StorageCommitment.key_count`. +/// At each level of the path, if the current index is even, the current +/// hash is the left child and we compute `node_hash(self, sibling)`; +/// otherwise it is the right child and we compute `node_hash(sibling, self)`. +/// +/// Returns `true` iff: +/// - `leaf_index < key_count` (rejects out-of-range claims), AND +/// - `path.len() == ceil(log2(key_count))` for `key_count > 1`, or +/// `path.is_empty()` for `key_count == 1` (rejects wrong-shape paths +/// before doing any hashing), AND +/// - the recomputed root equals `expected_root`. +#[must_use] +pub fn verify_path( + leaf: &[u8; 32], + path: &[[u8; 32]], + leaf_index: usize, + key_count: u32, + expected_root: &[u8; 32], +) -> bool { + if key_count == 0 + || key_count > MAX_COMMITMENT_KEY_COUNT + || (leaf_index as u64) >= u64::from(key_count) + { + return false; + } + // Tree depth = ceil(log2(key_count)). For a power-of-two `n`, + // `n.next_power_of_two() == n` so trailing_zeros == log2(n). For non + // powers-of-two, next_power_of_two rounds up so trailing_zeros gives + // ceil(log2). Special case: key_count == 1 → next_power_of_two == 1 + // → trailing_zeros == 0 → empty path, which matches the single-leaf + // tree's root == leaf invariant. + // + // `checked_next_power_of_two` returns None on overflow; combined with + // the MAX_COMMITMENT_KEY_COUNT cap above it cannot fail in practice, + // but the explicit check is profile-independent (release vs debug + // would otherwise differ on overflow per Rust's primitive docs). + let Some(rounded) = key_count.checked_next_power_of_two() else { + return false; + }; + let expected_path_len = rounded.trailing_zeros() as usize; + if path.len() != expected_path_len { + return false; + } + + let mut cur = *leaf; + let mut i = leaf_index; + for sibling in path { + cur = if i % 2 == 0 { + node_hash(&cur, sibling) + } else { + node_hash(sibling, &cur) + }; + i /= 2; + } + cur == *expected_root +} + +// --------------------------------------------------------------------------- +// Sign + verify +// --------------------------------------------------------------------------- + +/// Sign a commitment's `(root, key_count, sender_peer_id, sender_public_key)` +/// with `secret_key`. +/// +/// The signature is over the canonical signed payload (see +/// [`commitment_signed_payload`]) under [`DOMAIN_COMMITMENT`]. +/// +/// # Errors +/// +/// Returns an error if the underlying ML-DSA-65 signer fails. +pub fn sign_commitment( + secret_key: &MlDsaSecretKey, + root: &[u8; 32], + key_count: u32, + sender_peer_id: &[u8; 32], + sender_public_key: &[u8], +) -> Result, CommitmentError> { + let payload = commitment_signed_payload(root, key_count, sender_peer_id, sender_public_key); + let dsa = ml_dsa_65(); + let sig = dsa + .sign_with_context(secret_key, &payload, DOMAIN_COMMITMENT) + .map_err(|e| CommitmentError::SignatureFailed(e.to_string()))?; + Ok(sig.to_bytes()) +} + +/// Verify a commitment's signature using the embedded `sender_public_key`. +/// +/// Returns `true` iff the signature is valid for `(root, key_count, +/// sender_peer_id, sender_public_key)` under `c.sender_public_key` and +/// [`DOMAIN_COMMITMENT`]. Returns `false` on key-format or signature-format +/// errors so the caller can simply drop the gossip. +/// +/// Verifying against the embedded key removes the need for an external +/// `PeerId → MlDsaPublicKey` lookup. The peer-id binding (gate 2a in +/// `commitment_audit::verify_commitment_bound_response`) still ensures the +/// embedded key belongs to the claimed peer. +#[must_use] +pub fn verify_commitment_signature(c: &StorageCommitment) -> bool { + let Ok(public_key) = MlDsaPublicKey::from_bytes(MlDsaVariant::MlDsa65, &c.sender_public_key) + else { + return false; + }; + verify_commitment_signature_with_key(c, &public_key) +} + +/// Verify a commitment's signature against an externally provided key. +/// +/// Test-helper variant. Production code should use [`verify_commitment_signature`] +/// since the key is embedded in the commitment. +#[must_use] +pub fn verify_commitment_signature_with_key( + c: &StorageCommitment, + public_key: &MlDsaPublicKey, +) -> bool { + let payload = commitment_signed_payload( + &c.root, + c.key_count, + &c.sender_peer_id, + &c.sender_public_key, + ); + let Ok(sig) = MlDsaSignature::from_bytes(MlDsaVariant::MlDsa65, &c.signature) else { + return false; + }; + let dsa = ml_dsa_65(); + dsa.verify_with_context(public_key, &payload, &sig, DOMAIN_COMMITMENT) + .unwrap_or(false) +} + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +/// Errors from commitment construction or verification. +#[derive(Debug, Clone, thiserror::Error)] +pub enum CommitmentError { + /// `MerkleTree::build` was called with an empty key set. + #[error("cannot build commitment over empty key set")] + EmptyKeySet, + /// Key set exceeds [`MAX_COMMITMENT_KEY_COUNT`]. + #[error("commitment key count {0} exceeds MAX_COMMITMENT_KEY_COUNT")] + TooManyKeys(usize), + /// `MerkleTree::build` received the same key twice. + #[error("duplicate key in commitment: {}", hex::encode(.0))] + DuplicateKey(XorName), + /// Underlying ML-DSA-65 signer failed. + #[error("commitment signing failed: {0}")] + SignatureFailed(String), +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + + fn xn(byte: u8) -> XorName { + [byte; 32] + } + + fn bh(byte: u8) -> [u8; 32] { + [byte ^ 0x5A; 32] + } + + #[test] + fn empty_key_set_rejected() { + let result = MerkleTree::build(vec![]); + assert!(matches!(result, Err(CommitmentError::EmptyKeySet))); + } + + #[test] + fn duplicate_keys_rejected() { + let result = MerkleTree::build(vec![(xn(1), bh(1)), (xn(1), bh(2))]); + assert!(matches!(result, Err(CommitmentError::DuplicateKey(_)))); + } + + #[test] + fn single_leaf_tree_root_is_leaf_hash() { + let key = xn(1); + let bytes_hash = bh(1); + let tree = MerkleTree::build(vec![(key, bytes_hash)]).unwrap(); + assert_eq!(tree.root(), leaf_hash(&key, &bytes_hash)); + assert_eq!(tree.key_count(), 1); + assert_eq!(tree.path_for(&key), Some(vec![])); + // Empty path verifies trivially (root == leaf). + assert!(verify_path( + &leaf_hash(&key, &bytes_hash), + &[], + 0, + 1, + &tree.root() + )); + } + + #[test] + fn two_leaf_tree_root_combines_both_leaves() { + let entries = vec![(xn(1), bh(1)), (xn(2), bh(2))]; + let tree = MerkleTree::build(entries.clone()).unwrap(); + // Sorted order: xn(1), xn(2). + let l1 = leaf_hash(&xn(1), &bh(1)); + let l2 = leaf_hash(&xn(2), &bh(2)); + assert_eq!(tree.root(), node_hash(&l1, &l2)); + } + + #[test] + fn root_is_deterministic_regardless_of_input_order() { + let mut a = vec![(xn(3), bh(3)), (xn(1), bh(1)), (xn(2), bh(2))]; + let mut b = vec![(xn(2), bh(2)), (xn(3), bh(3)), (xn(1), bh(1))]; + let tree_a = MerkleTree::build(a.clone()).unwrap(); + let tree_b = MerkleTree::build(b.clone()).unwrap(); + a.sort_by(|x, y| x.0.cmp(&y.0)); + b.sort_by(|x, y| x.0.cmp(&y.0)); + assert_eq!(tree_a.root(), tree_b.root()); + } + + fn xn_u32(i: u32) -> XorName { + let mut k = [0u8; 32]; + k[..4].copy_from_slice(&i.to_le_bytes()); + k + } + + fn bh_u32(i: u32) -> [u8; 32] { + let mut h = [0u8; 32]; + h[..4].copy_from_slice(&i.to_le_bytes()); + h[4] = 0x5A; + h + } + + #[test] + fn paths_verify_for_every_key_at_various_sizes() { + for n in [1u32, 2, 3, 4, 5, 7, 8, 16, 17, 100, 333] { + let entries: Vec<_> = (0..n).map(|i| (xn_u32(i), bh_u32(i))).collect(); + let tree = MerkleTree::build(entries.clone()).unwrap(); + let root = tree.root(); + let key_count = tree.key_count(); + for (idx, (k, _)) in tree.iter_leaves().enumerate() { + let path = tree.path_for(k).expect("path for present key"); + let bytes_hash = entries.iter().find(|(kk, _)| kk == k).unwrap().1; + let lh = leaf_hash(k, &bytes_hash); + assert!( + verify_path(&lh, &path, idx, key_count, &root), + "path verify failed at n={n} idx={idx}", + ); + } + } + } + + #[test] + fn path_for_absent_key_is_none() { + let tree = MerkleTree::build(vec![(xn(1), bh(1)), (xn(2), bh(2))]).unwrap(); + assert!(tree.path_for(&xn(99)).is_none()); + } + + #[test] + fn tampered_bytes_hash_breaks_path_verify() { + // Use 8 distinct sorted keys so the index in `entries` matches the + // sorted leaf index in the tree. + let entries: Vec<_> = (1..=8u8).map(|i| (xn(i), bh(i))).collect(); + let tree = MerkleTree::build(entries.clone()).unwrap(); + let root = tree.root(); + let (k, _) = &entries[3]; + let path = tree.path_for(k).unwrap(); + + let wrong_bytes_hash = [0xFFu8; 32]; + let lh = leaf_hash(k, &wrong_bytes_hash); + assert!(!verify_path(&lh, &path, 3, 8, &root)); + } + + #[test] + fn tampered_path_node_breaks_verify() { + let entries: Vec<_> = (1..=8u8).map(|i| (xn(i), bh(i))).collect(); + let tree = MerkleTree::build(entries.clone()).unwrap(); + let root = tree.root(); + let (k, _) = &entries[3]; + let mut path = tree.path_for(k).unwrap(); + path[0][0] ^= 0x01; + let lh = leaf_hash(k, &bh(4)); + assert!(!verify_path(&lh, &path, 3, 8, &root)); + } + + #[test] + fn wrong_leaf_index_breaks_verify() { + let entries: Vec<_> = (1..=8u8).map(|i| (xn(i), bh(i))).collect(); + let tree = MerkleTree::build(entries.clone()).unwrap(); + let root = tree.root(); + let (k, _) = &entries[3]; + let path = tree.path_for(k).unwrap(); + let lh = leaf_hash(k, &bh(4)); + // Correct index is 3; using 2 should fail because the left/right + // child ordering swaps. + assert!(!verify_path(&lh, &path, 2, 8, &root)); + assert!(verify_path(&lh, &path, 3, 8, &root)); + } + + #[test] + fn out_of_range_leaf_index_rejected() { + let entries: Vec<_> = (1..=8u8).map(|i| (xn(i), bh(i))).collect(); + let tree = MerkleTree::build(entries.clone()).unwrap(); + let root = tree.root(); + let (k, _) = &entries[3]; + let path = tree.path_for(k).unwrap(); + let lh = leaf_hash(k, &bh(4)); + // leaf_index >= key_count must be rejected without even hashing. + assert!(!verify_path(&lh, &path, 8, 8, &root)); + assert!(!verify_path(&lh, &path, 99, 8, &root)); + // Valid baseline. + assert!(verify_path(&lh, &path, 3, 8, &root)); + } + + #[test] + fn wrong_path_length_rejected_pre_hashing() { + let entries: Vec<_> = (1..=8u8).map(|i| (xn(i), bh(i))).collect(); + let tree = MerkleTree::build(entries.clone()).unwrap(); + let root = tree.root(); + let (k, _) = &entries[3]; + let path = tree.path_for(k).unwrap(); + let lh = leaf_hash(k, &bh(4)); + // For key_count=8 the expected path length is 3 (ceil(log2(8))=3). + assert_eq!(path.len(), 3); + // Truncating breaks structural check. + let short: Vec<_> = path.iter().take(2).copied().collect(); + assert!(!verify_path(&lh, &short, 3, 8, &root)); + // Padding too long also breaks structural check. + let mut long = path.clone(); + long.push([0; 32]); + assert!(!verify_path(&lh, &long, 3, 8, &root)); + } + + #[test] + fn zero_key_count_rejected() { + // Defensive: even with an empty path and correct-shape root, a + // commitment claiming zero keys is nonsensical. + let lh = [0u8; 32]; + assert!(!verify_path(&lh, &[], 0, 0, &[0u8; 32])); + } + + #[test] + fn out_of_protocol_key_count_rejected() { + // Wire-supplied key_count exceeding MAX_COMMITMENT_KEY_COUNT is + // refused before any hashing. Defends against the round-3 BLOCKER: + // `next_power_of_two()` would otherwise panic in debug and wrap in + // release on key_count > 1 << 31. + let lh = [0u8; 32]; + assert!(!verify_path( + &lh, + &[], + 0, + MAX_COMMITMENT_KEY_COUNT + 1, + &[0u8; 32] + )); + assert!(!verify_path(&lh, &[], 0, u32::MAX, &[0u8; 32])); + } + + fn pk_bytes(pk: &MlDsaPublicKey) -> Vec { + pk.to_bytes() + } + + #[test] + fn sign_and_verify_roundtrip() { + let dsa = ml_dsa_65(); + let (pk, sk) = dsa.generate_keypair().unwrap(); + let entries: Vec<_> = (0..5u8).map(|i| (xn(i), bh(i))).collect(); + let tree = MerkleTree::build(entries).unwrap(); + let root = tree.root(); + let key_count = tree.key_count(); + let peer_id = [0xAB; 32]; + let pk_b = pk_bytes(&pk); + let signature = sign_commitment(&sk, &root, key_count, &peer_id, &pk_b).unwrap(); + let c = StorageCommitment { + root, + key_count, + sender_peer_id: peer_id, + sender_public_key: pk_b, + signature, + }; + // Verifies via embedded key, no external lookup needed. + assert!(verify_commitment_signature(&c)); + } + + #[test] + fn signature_fails_when_root_tampered() { + let dsa = ml_dsa_65(); + let (pk, sk) = dsa.generate_keypair().unwrap(); + let root = [0u8; 32]; + let pk_b = pk_bytes(&pk); + let signature = sign_commitment(&sk, &root, 1, &[0; 32], &pk_b).unwrap(); + let c = StorageCommitment { + root: [1u8; 32], // tampered + key_count: 1, + sender_peer_id: [0; 32], + sender_public_key: pk_b, + signature, + }; + assert!(!verify_commitment_signature(&c)); + } + + #[test] + fn signature_fails_under_swapped_public_key() { + let dsa = ml_dsa_65(); + let (pk1, sk1) = dsa.generate_keypair().unwrap(); + let (pk2, _sk2) = dsa.generate_keypair().unwrap(); + let pk1_b = pk_bytes(&pk1); + let pk2_b = pk_bytes(&pk2); + // Sign under pk1 but embed pk2 — verification (using embedded key) + // should fail because pk2 didn't sign this payload AND because the + // signed payload binds pk1, not pk2. + let signature = sign_commitment(&sk1, &[0u8; 32], 1, &[0; 32], &pk1_b).unwrap(); + let c = StorageCommitment { + root: [0u8; 32], + key_count: 1, + sender_peer_id: [0; 32], + sender_public_key: pk2_b, + signature, + }; + assert!(!verify_commitment_signature(&c)); + } + + #[test] + fn signature_fails_with_garbage_bytes() { + let dsa = ml_dsa_65(); + let (pk, _sk) = dsa.generate_keypair().unwrap(); + let c = StorageCommitment { + root: [0u8; 32], + key_count: 1, + sender_peer_id: [0; 32], + sender_public_key: pk_bytes(&pk), + signature: vec![0u8; 100], // too short and zero-filled + }; + assert!(!verify_commitment_signature(&c)); + } + + #[test] + fn signature_fails_with_garbage_public_key() { + // Embedded pubkey is wrong length / invalid → from_bytes fails → + // verify returns false. Defends against malformed gossip. + let c = StorageCommitment { + root: [0u8; 32], + key_count: 1, + sender_peer_id: [0; 32], + sender_public_key: vec![0u8; 100], // wrong length + signature: vec![0u8; 3293], + }; + assert!(!verify_commitment_signature(&c)); + } + + #[test] + fn commitment_hash_differs_on_any_field_change() { + let dsa = ml_dsa_65(); + let (pk, sk) = dsa.generate_keypair().unwrap(); + let pk_b = pk_bytes(&pk); + let sig = sign_commitment(&sk, &[0; 32], 1, &[0; 32], &pk_b).unwrap(); + let c1 = StorageCommitment { + root: [0; 32], + key_count: 1, + sender_peer_id: [0; 32], + sender_public_key: pk_b.clone(), + signature: sig.clone(), + }; + let h1 = commitment_hash(&c1).unwrap(); + + let mut c2 = c1.clone(); + c2.root = [1; 32]; + assert_ne!(h1, commitment_hash(&c2).unwrap()); + + let mut c3 = c1.clone(); + c3.key_count = 2; + assert_ne!(h1, commitment_hash(&c3).unwrap()); + + let mut c4 = c1.clone(); + c4.sender_peer_id = [1; 32]; + assert_ne!(h1, commitment_hash(&c4).unwrap()); + + let mut c5 = c1.clone(); + c5.signature[0] ^= 1; + assert_ne!(h1, commitment_hash(&c5).unwrap()); + + let (pk_other, _) = dsa.generate_keypair().unwrap(); + let mut c6 = c1.clone(); + c6.sender_public_key = pk_bytes(&pk_other); + assert_ne!(h1, commitment_hash(&c6).unwrap()); + } + + #[test] + fn commitment_hash_stable_for_identical_input() { + let dsa = ml_dsa_65(); + let (pk, sk) = dsa.generate_keypair().unwrap(); + let pk_b = pk_bytes(&pk); + let sig = sign_commitment(&sk, &[7; 32], 42, &[3; 32], &pk_b).unwrap(); + let c = StorageCommitment { + root: [7; 32], + key_count: 42, + sender_peer_id: [3; 32], + sender_public_key: pk_b, + signature: sig, + }; + assert_eq!(commitment_hash(&c), commitment_hash(&c)); + } + + #[test] + fn commitment_hash_signature_length_change_changes_hash() { + // Postcard's varint length prefix means hashing a 1-byte signature + // and a 2-byte signature whose first byte is the same produces + // different commitment hashes — defends against the codex round-1 + // BLOCKER "omits the serialized length prefix." + let c1 = StorageCommitment { + root: [0; 32], + key_count: 1, + sender_peer_id: [0; 32], + sender_public_key: vec![0u8; 1952], + signature: vec![0xAB], + }; + let c2 = StorageCommitment { + root: [0; 32], + key_count: 1, + sender_peer_id: [0; 32], + sender_public_key: vec![0u8; 1952], + signature: vec![0xAB, 0x00], + }; + assert_ne!(commitment_hash(&c1).unwrap(), commitment_hash(&c2).unwrap()); + } + + #[test] + fn too_many_keys_rejected() { + let mut entries = Vec::with_capacity(MAX_COMMITMENT_KEY_COUNT as usize + 1); + for i in 0..=MAX_COMMITMENT_KEY_COUNT { + let mut k = [0u8; 32]; + k[..4].copy_from_slice(&i.to_le_bytes()); + entries.push((k, [0; 32])); + } + let result = MerkleTree::build(entries); + assert!(matches!(result, Err(CommitmentError::TooManyKeys(_)))); + } +} diff --git a/src/replication/commitment_audit.rs b/src/replication/commitment_audit.rs new file mode 100644 index 0000000..1dfb134 --- /dev/null +++ b/src/replication/commitment_audit.rs @@ -0,0 +1,775 @@ +//! Auditor-side verification of commitment-bound audit responses. +//! +//! Phase 2c of the v12 storage-bound audit design (`notes/security- +//! findings-2026-05-22/proposal-gossip-audit-v12.md`). +//! +//! `verify_commitment_bound_response` is a pure function: it takes the +//! commitment the auditor pinned, the response received from the +//! challenged peer, the auditor's own copy of the bytes for each +//! challenged key, the responder's ML-DSA-65 public key, and the +//! challenged peer ID — and returns either `Ok(())` (audit passed) or a +//! typed [`AuditVerifyError`] explaining which gate failed. +//! +//! The function performs the four checks specified in v12 §5: +//! +//! 1. **Structural**: `per_key.len() == challenge_keys.len()`; same +//! order, no duplicates; each `path.len() == ceil(log2(key_count))`. +//! 2. **Commitment hash pin**: `commitment_hash(response.commitment) == +//! expected_commitment_hash`. Defeats fresh-commitment substitution. +//! 3. **Signature**: `verify_commitment_signature(commitment)` — using the +//! public key embedded in the commitment itself; no external lookup. +//! 4. **Per-key**: for each challenged key K, the response's `bytes_hash` +//! equals BLAKE3 of the auditor's local bytes for K (defeats lying +//! about bytes), the rebuilt Merkle leaf verifies up to the +//! commitment root via [`verify_path`] (proves the responder +//! committed to K under this exact commitment), and the audit digest +//! matches `BLAKE3(nonce || challenged_peer_id || K || bytes)` (the +//! legacy audit-freshness check via the per-challenge nonce). +//! +//! The auditor only commitment-audits keys it itself holds — same +//! constraint as today's plain-digest audit (`audit.rs` step 9). The +//! `local_bytes_for` closure encapsulates that lookup. + +use std::collections::HashSet; + +use crate::ant_protocol::XorName; +use crate::replication::commitment::{ + commitment_hash, leaf_hash, verify_commitment_signature, verify_path, CommitmentBoundResult, + StorageCommitment, MAX_COMMITMENT_KEY_COUNT, +}; +use crate::replication::protocol::compute_audit_digest; + +/// Why a commitment-bound audit response failed verification. +/// +/// Each variant maps to one of the v12 §5 gates. Callers convert +/// any `Err` into a full `AUDIT_FAILURE_TRUST_WEIGHT` per-key penalty. +#[derive(Debug, Clone, thiserror::Error)] +pub enum AuditVerifyError { + /// `per_key.len() != challenge.keys.len()` — responder did not + /// answer the exact challenge set. + #[error("response covers {got} keys, expected {expected}")] + PerKeyCountMismatch { + /// Number of per-key entries in the response. + got: usize, + /// Number of keys in the challenge. + expected: usize, + }, + /// `per_key[i].key != challenge.keys[i]` — responder answered + /// keys in the wrong order or substituted a different key. + #[error("response key #{index} mismatch (got {got:?}, expected {expected:?})")] + PerKeyOrderMismatch { + /// Index in the challenge / response. + index: usize, + /// The key the responder answered. + got: XorName, + /// The key the auditor challenged. + expected: XorName, + }, + /// `per_key` contains a duplicate key — defeats responder trying to + /// answer the same key twice in lieu of a key it doesn't have. + #[error("response contains duplicate key {key:?}")] + DuplicateKey { + /// The duplicated key. + key: XorName, + }, + /// `commitment.key_count` exceeds [`MAX_COMMITMENT_KEY_COUNT`] — + /// rejected before any hashing. + #[error("commitment claims {key_count} keys, exceeds protocol max")] + KeyCountOverProtocolMax { + /// The claimed (rejected) key count. + key_count: u32, + }, + /// A `per_key[i].path` has the wrong length for the claimed + /// `key_count` — caught before any hashing per v12 §5a. + #[error("response key #{index} path length {got} != expected {expected}")] + WrongPathLength { + /// Index in the `per_key` vec. + index: usize, + /// The length the responder sent. + got: usize, + /// The expected length (`ceil(log2(key_count))`). + expected: usize, + }, + /// `commitment_hash(response.commitment) != expected_commitment_hash` + /// — responder substituted a different commitment than the one the + /// auditor pinned. + #[error("commitment hash mismatch (expected pin)")] + CommitmentHashMismatch, + /// `response.commitment.sender_peer_id != challenged_peer_id` — the + /// responder embedded another peer's signed commitment. Caught + /// before the signature gate so callers cannot conflate keys. + #[error("response commitment sender_peer_id mismatch (peer impersonation)")] + SenderPeerIdMismatch, + /// `commitment.signature` is not valid under `public_key`. + #[error("commitment signature did not verify")] + SignatureInvalid, + /// A `per_key[i].bytes_hash` does not match BLAKE3 of the auditor's + /// local bytes — responder lied about the bytes underlying the leaf. + #[error("response key #{index} bytes_hash mismatch")] + BytesHashMismatch { + /// Index in the `per_key` vec. + index: usize, + }, + /// A `per_key[i].leaf_index >= commitment.key_count` — out-of-range + /// leaf claim. + #[error("response key #{index} leaf_index {leaf_index} >= key_count {key_count}")] + LeafIndexOutOfRange { + /// Index in the `per_key` vec. + index: usize, + /// The claimed leaf index. + leaf_index: u32, + /// The commitment's claimed key count. + key_count: u32, + }, + /// A `per_key[i].path` does not verify against the commitment root + /// — the responder did not commit to this `(key, bytes_hash)` pair + /// under this exact commitment. + #[error("response key #{index} merkle path did not verify")] + PathInvalid { + /// Index in the `per_key` vec. + index: usize, + }, + /// A `per_key[i].digest` does not match + /// `BLAKE3(nonce || challenged_peer_id || key || bytes)` — same + /// per-key gate the existing plain-digest audit uses. The nonce + /// defeats replay; the peer-id binding stops a third party forging + /// a digest on the responder's behalf. + #[error("response key #{index} audit digest mismatch")] + DigestMismatch { + /// Index in the `per_key` vec. + index: usize, + }, +} + +/// Verify a `CommitmentBound` audit response against the pin and the +/// auditor's local bytes. +/// +/// `local_bytes_for` returns `Some(bytes)` for keys the auditor itself +/// holds. Per v12, the auditor only commitment-audits keys in its own +/// store; a key for which the closure returns `None` triggers +/// [`AuditVerifyError::BytesHashMismatch`] (the responder cannot prove +/// possession of bytes we don't have to compare against). +/// +/// All four v12 §5 gates run before returning `Ok`. The order is chosen +/// to fail cheapest first: structural checks before any hashing, +/// commitment hash pin before signature verify, signature verify before +/// the per-key loop. +/// +/// # Errors +/// +/// See [`AuditVerifyError`]. Any error means the audit failed and the +/// caller should apply the standard `AUDIT_FAILURE_TRUST_WEIGHT × keys` +/// penalty. +#[allow(clippy::too_many_arguments)] +pub fn verify_commitment_bound_response( + challenge_keys: &[XorName], + challenge_nonce: &[u8; 32], + challenged_peer_id: &[u8; 32], + expected_commitment_hash: &[u8; 32], + response_commitment: &StorageCommitment, + response_per_key: &[CommitmentBoundResult], + local_bytes_for: impl Fn(&XorName) -> Option>, +) -> Result<(), AuditVerifyError> { + verify_commitment_bound_metadata( + challenge_keys, + challenged_peer_id, + expected_commitment_hash, + response_commitment, + response_per_key, + )?; + for (i, result) in response_per_key.iter().enumerate() { + let local_bytes = + local_bytes_for(&result.key).ok_or(AuditVerifyError::BytesHashMismatch { index: i })?; + verify_commitment_bound_per_key( + i, + challenge_nonce, + challenged_peer_id, + response_commitment, + result, + &local_bytes, + )?; + } + Ok(()) +} + +/// Verify the metadata gates (1, 2a, 2b, 3) of a commitment-bound audit +/// response. Pure-sync, fast: structural / peer-identity / pin / signature. +/// +/// Run this once per response before iterating per-key with +/// [`verify_commitment_bound_per_key`]. Split out so the auditor can stream +/// chunk bytes per-key from async storage instead of preloading them all +/// into memory (which at sqrt-scaled sample sizes and 4 MiB chunks would +/// be a remote memory-DoS vector — see codex round-5 BLOCKER #2). +/// +/// # Errors +/// +/// See [`AuditVerifyError`]. Returns the first gate failure encountered. +pub fn verify_commitment_bound_metadata( + challenge_keys: &[XorName], + challenged_peer_id: &[u8; 32], + expected_commitment_hash: &[u8; 32], + response_commitment: &StorageCommitment, + response_per_key: &[CommitmentBoundResult], +) -> Result<(), AuditVerifyError> { + // -- Gate 1: structural --------------------------------------------------- + + if response_per_key.len() != challenge_keys.len() { + return Err(AuditVerifyError::PerKeyCountMismatch { + got: response_per_key.len(), + expected: challenge_keys.len(), + }); + } + + // Key-order match: responder answers in challenge order. (Same + // contract as today's plain-digest audit, where `digests[i]` + // corresponds to `challenge.keys[i]`.) + for (i, (expected, result)) in challenge_keys.iter().zip(response_per_key).enumerate() { + if &result.key != expected { + return Err(AuditVerifyError::PerKeyOrderMismatch { + index: i, + got: result.key, + expected: *expected, + }); + } + } + + // Duplicate-key check (responder can't double-up answers). + let mut seen = HashSet::with_capacity(response_per_key.len()); + for result in response_per_key { + if !seen.insert(result.key) { + return Err(AuditVerifyError::DuplicateKey { key: result.key }); + } + } + + // Wire-input bounds on key_count + expected path length. + let key_count = response_commitment.key_count; + if key_count == 0 || key_count > MAX_COMMITMENT_KEY_COUNT { + return Err(AuditVerifyError::KeyCountOverProtocolMax { key_count }); + } + // verify_path will recompute this same value, but we precompute once + // for an early structural reject before any hashing. + let expected_path_len = key_count + .checked_next_power_of_two() + .map_or(usize::MAX, |n| n.trailing_zeros() as usize); + for (i, result) in response_per_key.iter().enumerate() { + if result.path.len() != expected_path_len { + return Err(AuditVerifyError::WrongPathLength { + index: i, + got: result.path.len(), + expected: expected_path_len, + }); + } + } + + // -- Gate 2a: peer-identity binding -------------------------------------- + // + // A signed commitment from a DIFFERENT peer would have a valid + // signature (it's a real commitment, just not from THIS peer) and + // could pass the hash pin if the auditor's pin was accidentally + // for the wrong peer. Catching this explicitly stops cross-peer + // substitution as a class — the responder cannot embed someone + // else's commitment in a response to a challenge targeting them. + + if &response_commitment.sender_peer_id != challenged_peer_id { + return Err(AuditVerifyError::SenderPeerIdMismatch); + } + + // -- Gate 2b: commitment hash pin ---------------------------------------- + + let response_hash = + commitment_hash(response_commitment).ok_or(AuditVerifyError::CommitmentHashMismatch)?; + if &response_hash != expected_commitment_hash { + return Err(AuditVerifyError::CommitmentHashMismatch); + } + + // -- Gate 2c: peer-identity to embedded-pubkey binding ------------------ + // + // The peer-id field on the commitment must match BLAKE3 of the embedded + // public key — otherwise a responder could sign with a throwaway key + // they own and lie about which identity it belongs to. saorsa-core + // derives PeerId as `BLAKE3(pubkey_bytes)`. + + let derived_peer_id = *blake3::hash(&response_commitment.sender_public_key).as_bytes(); + if derived_peer_id != response_commitment.sender_peer_id { + return Err(AuditVerifyError::SenderPeerIdMismatch); + } + + // -- Gate 3: signature --------------------------------------------------- + + // Verifies against the public key embedded in the commitment itself. + // The peer-id binding above (gate 2a) ensures that key actually belongs + // to the challenged peer — a substituted commitment from another peer + // would have failed there. + if !verify_commitment_signature(response_commitment) { + return Err(AuditVerifyError::SignatureInvalid); + } + + Ok(()) +} + +/// Verify gate 4 (bytes_hash + path + digest) for a single per-key entry. +/// +/// Call this once per challenged key in a streaming loop after running +/// [`verify_commitment_bound_metadata`] once on the response. Lets the +/// caller load one chunk at a time and drop it, bounding peak memory at +/// `MAX_CHUNK_SIZE` per challenge regardless of sample size. +/// +/// # Errors +/// +/// See [`AuditVerifyError`]. Returns `BytesHashMismatch`, `PathInvalid`, +/// `LeafIndexOutOfRange`, or `DigestMismatch` on failure. +pub fn verify_commitment_bound_per_key( + index: usize, + challenge_nonce: &[u8; 32], + challenged_peer_id: &[u8; 32], + response_commitment: &StorageCommitment, + result: &CommitmentBoundResult, + local_bytes: &[u8], +) -> Result<(), AuditVerifyError> { + let expected_bytes_hash = *blake3::hash(local_bytes).as_bytes(); + if result.bytes_hash != expected_bytes_hash { + return Err(AuditVerifyError::BytesHashMismatch { index }); + } + + let leaf = leaf_hash(&result.key, &result.bytes_hash); + let key_count = response_commitment.key_count; + if u64::from(result.leaf_index) >= u64::from(key_count) { + return Err(AuditVerifyError::LeafIndexOutOfRange { + index, + leaf_index: result.leaf_index, + key_count, + }); + } + if !verify_path( + &leaf, + &result.path, + result.leaf_index as usize, + key_count, + &response_commitment.root, + ) { + return Err(AuditVerifyError::PathInvalid { index }); + } + + // Legacy audit digest. Defeats replay (nonce changes per + // challenge) and third-party forging (peer ID is bound). + let expected_digest = compute_audit_digest( + challenge_nonce, + challenged_peer_id, + &result.key, + local_bytes, + ); + if result.digest != expected_digest { + return Err(AuditVerifyError::DigestMismatch { index }); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + use crate::replication::commitment_state::BuiltCommitment; + use saorsa_pqc::api::sig::{ml_dsa_65, MlDsaPublicKey}; + use std::collections::HashMap; + + fn key(byte: u8) -> XorName { + let mut k = [0u8; 32]; + k[0] = byte; + k + } + + fn content(byte: u8) -> Vec { + // 256 bytes of deterministic content per index. + (0..256u32).map(|i| (i as u8) ^ byte).collect() + } + + fn bytes_hash(bytes: &[u8]) -> [u8; 32] { + *blake3::hash(bytes).as_bytes() + } + + struct AuditFixture { + pub built: BuiltCommitment, + pub bytes_by_key: HashMap>, + pub peer_id: [u8; 32], + pub nonce: [u8; 32], + } + + fn fixture(n: u8) -> (AuditFixture, MlDsaPublicKey) { + let (pk, sk) = ml_dsa_65().generate_keypair().unwrap(); + let peer_id = *blake3::hash(&pk.to_bytes()).as_bytes(); + let nonce = [0xCD; 32]; + let entries: Vec<_> = (1..=n) + .map(|i| { + let k = key(i); + let c = content(i); + (k, bytes_hash(&c)) + }) + .collect(); + let bytes_by_key: HashMap<_, _> = (1..=n).map(|i| (key(i), content(i))).collect(); + let built = BuiltCommitment::build(entries, &peer_id, &sk, &pk.to_bytes()).unwrap(); + let fx = AuditFixture { + built, + bytes_by_key, + peer_id, + nonce, + }; + (fx, pk) + } + + /// Build a valid CommitmentBoundResponse for the given challenge + /// keys against `fx`. Used as the baseline; tampering tests mutate + /// the result. + fn build_valid_response(fx: &AuditFixture, keys: &[XorName]) -> Vec { + keys.iter() + .map(|k| { + let bytes = fx.bytes_by_key.get(k).expect("auditor holds key").clone(); + let (path, leaf_index) = fx.built.proof_for(k).expect("present"); + let bh = bytes_hash(&bytes); + let digest = compute_audit_digest(&fx.nonce, &fx.peer_id, k, &bytes); + CommitmentBoundResult { + key: *k, + digest, + bytes_hash: bh, + leaf_index, + path, + } + }) + .collect() + } + + fn local_lookup(fx: &AuditFixture) -> impl Fn(&XorName) -> Option> + '_ { + |k: &XorName| fx.bytes_by_key.get(k).cloned() + } + + #[test] + fn valid_response_verifies() { + let (fx, _pk) = fixture(8); + let keys = vec![key(1), key(2), key(3)]; + let per_key = build_valid_response(&fx, &keys); + let result = verify_commitment_bound_response( + &keys, + &fx.nonce, + &fx.peer_id, + &fx.built.hash(), + fx.built.commitment(), + &per_key, + local_lookup(&fx), + ); + assert!(result.is_ok(), "{result:?}"); + } + + #[test] + fn wrong_key_count_rejected() { + let (fx, _pk) = fixture(8); + let keys = vec![key(1), key(2), key(3)]; + let mut per_key = build_valid_response(&fx, &keys); + per_key.pop(); + let result = verify_commitment_bound_response( + &keys, + &fx.nonce, + &fx.peer_id, + &fx.built.hash(), + fx.built.commitment(), + &per_key, + local_lookup(&fx), + ); + assert!(matches!( + result, + Err(AuditVerifyError::PerKeyCountMismatch { .. }) + )); + } + + #[test] + fn wrong_key_order_rejected() { + let (fx, _pk) = fixture(8); + let keys = vec![key(1), key(2), key(3)]; + let mut per_key = build_valid_response(&fx, &keys); + per_key.swap(0, 2); + let result = verify_commitment_bound_response( + &keys, + &fx.nonce, + &fx.peer_id, + &fx.built.hash(), + fx.built.commitment(), + &per_key, + local_lookup(&fx), + ); + assert!(matches!( + result, + Err(AuditVerifyError::PerKeyOrderMismatch { .. }) + )); + } + + #[test] + fn duplicate_key_rejected() { + let (fx, _pk) = fixture(8); + // Build keys=[k1, k1, k3] — a duplicate. Build the response + // from this so structural+order pass but the duplicate-set + // check fires. + let keys = vec![key(1), key(1), key(3)]; + let per_key = build_valid_response(&fx, &keys); + let result = verify_commitment_bound_response( + &keys, + &fx.nonce, + &fx.peer_id, + &fx.built.hash(), + fx.built.commitment(), + &per_key, + local_lookup(&fx), + ); + assert!(matches!(result, Err(AuditVerifyError::DuplicateKey { .. }))); + } + + #[test] + fn wrong_commitment_hash_pin_rejected() { + let (fx, _pk) = fixture(8); + let keys = vec![key(1)]; + let per_key = build_valid_response(&fx, &keys); + let mut wrong_pin = fx.built.hash(); + wrong_pin[0] ^= 0x01; + let result = verify_commitment_bound_response( + &keys, + &fx.nonce, + &fx.peer_id, + &wrong_pin, + fx.built.commitment(), + &per_key, + local_lookup(&fx), + ); + assert!(matches!( + result, + Err(AuditVerifyError::CommitmentHashMismatch) + )); + } + + #[test] + fn tampered_signature_rejected() { + let (fx, _pk) = fixture(8); + let keys = vec![key(1)]; + let per_key = build_valid_response(&fx, &keys); + // Clone the commitment + flip a byte in the signature. This + // also changes the commitment_hash, so we have to pin against + // the new hash (this isolates the signature gate from gate 2). + let mut bad_commit = fx.built.commitment().clone(); + bad_commit.signature[0] ^= 0xFF; + let pin = commitment_hash(&bad_commit).unwrap(); + let result = verify_commitment_bound_response( + &keys, + &fx.nonce, + &fx.peer_id, + &pin, + &bad_commit, + &per_key, + local_lookup(&fx), + ); + assert!(matches!(result, Err(AuditVerifyError::SignatureInvalid))); + } + + #[test] + fn wrong_bytes_hash_rejected() { + let (fx, _pk) = fixture(8); + let keys = vec![key(1)]; + let mut per_key = build_valid_response(&fx, &keys); + per_key[0].bytes_hash[0] ^= 0x01; + let result = verify_commitment_bound_response( + &keys, + &fx.nonce, + &fx.peer_id, + &fx.built.hash(), + fx.built.commitment(), + &per_key, + local_lookup(&fx), + ); + assert!(matches!( + result, + Err(AuditVerifyError::BytesHashMismatch { .. }) + )); + } + + #[test] + fn missing_local_bytes_rejected_as_bytes_hash_mismatch() { + let (fx, _pk) = fixture(8); + let keys = vec![key(1)]; + let per_key = build_valid_response(&fx, &keys); + // Auditor's local lookup says "I don't have this key" — the + // verifier can't compare bytes and must reject. + let result = verify_commitment_bound_response( + &keys, + &fx.nonce, + &fx.peer_id, + &fx.built.hash(), + fx.built.commitment(), + &per_key, + |_| None, + ); + assert!(matches!( + result, + Err(AuditVerifyError::BytesHashMismatch { .. }) + )); + } + + #[test] + fn out_of_range_leaf_index_rejected() { + let (fx, _pk) = fixture(8); + let keys = vec![key(1)]; + let mut per_key = build_valid_response(&fx, &keys); + per_key[0].leaf_index = 999; + let result = verify_commitment_bound_response( + &keys, + &fx.nonce, + &fx.peer_id, + &fx.built.hash(), + fx.built.commitment(), + &per_key, + local_lookup(&fx), + ); + assert!(matches!( + result, + Err(AuditVerifyError::LeafIndexOutOfRange { .. }) + )); + } + + #[test] + fn tampered_path_rejected() { + let (fx, _pk) = fixture(8); + let keys = vec![key(1)]; + let mut per_key = build_valid_response(&fx, &keys); + if let Some(p) = per_key[0].path.first_mut() { + p[0] ^= 0x01; + } + let result = verify_commitment_bound_response( + &keys, + &fx.nonce, + &fx.peer_id, + &fx.built.hash(), + fx.built.commitment(), + &per_key, + local_lookup(&fx), + ); + assert!(matches!(result, Err(AuditVerifyError::PathInvalid { .. }))); + } + + #[test] + fn wrong_path_length_rejected_before_hashing() { + let (fx, _pk) = fixture(8); + let keys = vec![key(1)]; + let mut per_key = build_valid_response(&fx, &keys); + per_key[0].path.push([0u8; 32]); + let result = verify_commitment_bound_response( + &keys, + &fx.nonce, + &fx.peer_id, + &fx.built.hash(), + fx.built.commitment(), + &per_key, + local_lookup(&fx), + ); + assert!(matches!( + result, + Err(AuditVerifyError::WrongPathLength { .. }) + )); + } + + #[test] + fn wrong_digest_rejected() { + let (fx, _pk) = fixture(8); + let keys = vec![key(1)]; + let mut per_key = build_valid_response(&fx, &keys); + per_key[0].digest[0] ^= 0x01; + let result = verify_commitment_bound_response( + &keys, + &fx.nonce, + &fx.peer_id, + &fx.built.hash(), + fx.built.commitment(), + &per_key, + local_lookup(&fx), + ); + assert!(matches!( + result, + Err(AuditVerifyError::DigestMismatch { .. }) + )); + } + + #[test] + fn lazy_node_on_demand_fetch_attack_fails() { + // The headline attack v12 closes: a "lazy" responder who + // dropped the bytes but fetches them on demand at audit time. + // To pass §5 they would need either (a) a valid path that + // matches the local bytes_hash AND the commitment root they + // already gossiped, OR (b) a fresh commitment they substitute + // into the response. (a) requires them to have built the tree + // with the real bytes at gossip time (i.e. they had them then), + // and (b) is closed by the commitment hash pin. + // + // Concretely model attack (b): the lazy node received the + // challenge, fetched bytes from a neighbour, builds a *fresh* + // commitment over just the challenged keys, and replies with + // that fresh commitment + valid proofs. The pin check rejects. + let (_pk1, sk1) = ml_dsa_65().generate_keypair().unwrap(); + let (pk_lazy, sk_lazy) = ml_dsa_65().generate_keypair().unwrap(); + let peer_id = *blake3::hash(&pk_lazy.to_bytes()).as_bytes(); + let nonce = [0xCD; 32]; + let _ = sk1; + + // Pretend the auditor previously received a commitment from the + // lazy node over keys 1..=8. + let original_entries: Vec<_> = (1..=8u8) + .map(|i| { + let k = key(i); + let c = content(i); + (k, bytes_hash(&c)) + }) + .collect(); + let pk_lazy_bytes = pk_lazy.to_bytes(); + let original_built = + BuiltCommitment::build(original_entries, &peer_id, &sk_lazy, &pk_lazy_bytes).unwrap(); + let pinned_hash = original_built.hash(); + + // Auditor challenges on key 3. Lazy node fetches the bytes + // and builds a fresh commitment that includes key 3. + let challenged_keys = vec![key(3)]; + + // The lazy node fabricates a NEW commitment (different from the + // one originally gossiped). It even includes the correct bytes + // hash for key 3, so per-key path verification would pass + // against the new commitment's root. + let fresh_entries: Vec<_> = vec![(key(3), bytes_hash(&content(3)))]; + let fresh_built = + BuiltCommitment::build(fresh_entries, &peer_id, &sk_lazy, &pk_lazy_bytes).unwrap(); + + // Build a response that contains the fresh commitment + valid + // proofs against it. Per-key entry uses the fresh tree. + let (path, leaf_index) = fresh_built.proof_for(&key(3)).unwrap(); + let per_key = vec![CommitmentBoundResult { + key: key(3), + digest: compute_audit_digest(&nonce, &peer_id, &key(3), &content(3)), + bytes_hash: bytes_hash(&content(3)), + leaf_index, + path, + }]; + + // Auditor's local store has key 3's bytes. + let local = |k: &XorName| if k == &key(3) { Some(content(3)) } else { None }; + + // Verify against the *original* pinned hash, response carries + // the fresh commitment. Must fail at gate 2 (pin mismatch). + let result = verify_commitment_bound_response( + &challenged_keys, + &nonce, + &peer_id, + &pinned_hash, + fresh_built.commitment(), + &per_key, + local, + ); + assert!( + matches!(result, Err(AuditVerifyError::CommitmentHashMismatch)), + "lazy-node fresh-commitment substitution must fail at pin check, got {result:?}", + ); + } +} diff --git a/src/replication/commitment_state.rs b/src/replication/commitment_state.rs new file mode 100644 index 0000000..cf1de9d --- /dev/null +++ b/src/replication/commitment_state.rs @@ -0,0 +1,822 @@ +//! Responder-side commitment builder + rotation state. +//! +//! Phase 2b of the v12 storage-bound audit design. Builds, signs, and +//! caches a [`StorageCommitment`] over the responder's currently-stored +//! key set; serves audit lookups by `expected_commitment_hash`; retains +//! the previous commitment across one rotation so an audit pinned to it +//! does not false-fail at the rotation boundary (v5/v12 §4 retention). +//! +//! Rotation strategy: +//! +//! - `rotate(new_built)` atomically replaces `current` with `new_built` +//! and demotes the prior `current` to `previous`. The prior +//! `previous` is dropped. +//! - `lookup(hash)` reads the in-memory map and returns an [`Arc`] to +//! the matching `BuiltCommitment`, keeping it alive for the audit +//! response regardless of subsequent rotation (mirrors the `ArcSwap` +//! semantics specified in v6 §2: an in-flight reader holding its +//! `Arc` is unaffected by a concurrent rotate). +//! +//! No persistent disk state. Trees are rebuilt from `LmdbStorage` at +//! the next rotation tick. Memory cost is bounded by +//! `2 × (key_count × ~64 bytes + signature_size)` — for 10k keys, ~1.3 MB. + +use std::sync::Arc; +use std::time::Instant; + +use parking_lot::RwLock; +use saorsa_pqc::api::sig::MlDsaSecretKey; + +use crate::ant_protocol::XorName; +use crate::replication::commitment::{ + commitment_hash, sign_commitment, CommitmentError, MerkleTree, StorageCommitment, +}; + +/// Auditor-side per-peer commitment state. +/// +/// Holds two things that together implement v10/v12 §2 step 5 and §6: +/// - `last_commitment`: the most recently received, verified, signed +/// commitment from this peer. `None` if we've evicted it (TTL, +/// sybil cap, peer-removed) or never received one. +/// - `commitment_capable`: a **sticky** boolean that flips to `true` +/// on the first successful gossip ingest and NEVER reverts. Used +/// by holder-eligibility (§6) and bootstrap-claim shield: a peer +/// that has at least once proven it speaks v12 is forever held to +/// that standard. Without stickiness, a peer could flip the flag +/// off by silencing its gossip and downgrade to the weaker legacy +/// audit path. +#[derive(Debug, Clone)] +pub struct PeerCommitmentRecord { + /// Last verified commitment, or `None` if evicted/expired. + pub last_commitment: Option, + /// Sticky: true once this peer has gossiped a valid commitment. + /// Set on ingest. Never set back to false except by full + /// PeerRemoved cleanup. + pub commitment_capable: bool, + /// When `last_commitment` was received. Used for TTL on the + /// commitment itself (independent of the commitment_capable + /// stickiness — losing the commitment via TTL doesn't make us + /// forget the peer ever spoke v12). + pub received_at: Instant, + /// Last time we performed an ML-DSA signature verify for this + /// peer's commitment. Used to enforce the §2 step 3 rate limit + /// (at most one sig verify per peer per 60s). + pub last_sig_verify_at: Instant, +} + +impl PeerCommitmentRecord { + /// Construct from a freshly-verified commitment. `commitment_capable` + /// is set to `true` here and must remain so for the lifetime of the + /// record. + #[must_use] + pub fn from_verified(commitment: StorageCommitment, now: Instant) -> Self { + Self { + last_commitment: Some(commitment), + commitment_capable: true, + received_at: now, + last_sig_verify_at: now, + } + } + + /// Mark commitment-capable without storing a commitment (used when + /// we've TTL-expired the commitment itself but want to remember the + /// peer has spoken v12 before). + #[must_use] + pub fn capable_but_no_commitment(now: Instant) -> Self { + Self { + last_commitment: None, + commitment_capable: true, + received_at: now, + last_sig_verify_at: now, + } + } +} + +/// A fully-built commitment: signed wire blob, cached hash, Merkle tree +/// for inclusion proofs, and a sorted leaf-index lookup for the auditor's +/// `leaf_index` field. +/// +/// Held inside an [`Arc`] so audit responders can grab a reference and +/// build a reply without holding the [`ResponderCommitmentState`] read +/// lock for the duration of the response. +pub struct BuiltCommitment { + /// The signed wire blob. + commitment: StorageCommitment, + /// `commitment_hash(commitment)` — cached so audit lookups don't + /// re-serialize on every match. + cached_hash: [u8; 32], + /// The Merkle tree behind the commitment. `path_for(key)` produces + /// the inclusion proof; the responder's leaf-index lookup is below. + tree: MerkleTree, + /// `sorted_keys[i]` is the key at leaf index `i`. Sorted ascending + /// so binary search reconstructs `leaf_index` for any key in + /// `O(log n)`. + sorted_keys: Vec, +} + +impl BuiltCommitment { + /// Build a commitment over `entries = [(key, bytes_hash), ...]` and + /// sign it with `secret_key`. + /// + /// `entries` does not need to be sorted (the inner [`MerkleTree`] + /// sorts internally); `sender_peer_id` is bound into the signature + /// and the commitment. + /// + /// # Errors + /// + /// Returns the wrapped [`CommitmentError`] on empty key sets, + /// over-cap key counts, duplicates, or signing failures. + pub fn build( + entries: Vec<(XorName, [u8; 32])>, + sender_peer_id: &[u8; 32], + secret_key: &MlDsaSecretKey, + sender_public_key: &[u8], + ) -> Result { + let tree = MerkleTree::build(entries)?; + let root = tree.root(); + let key_count = tree.key_count(); + let signature = sign_commitment( + secret_key, + &root, + key_count, + sender_peer_id, + sender_public_key, + )?; + let commitment = StorageCommitment { + root, + key_count, + sender_peer_id: *sender_peer_id, + sender_public_key: sender_public_key.to_vec(), + signature, + }; + // `commitment_hash` only returns None on a postcard serialization + // failure, which for our fixed-size commitment cannot occur in + // practice (ML-DSA-65 signature is 3293 bytes). If it ever + // somehow does, surface as a SignatureFailed so callers don't + // need a new error variant for an unreachable case. + let cached_hash = commitment_hash(&commitment).ok_or_else(|| { + CommitmentError::SignatureFailed("commitment serialization failed".to_string()) + })?; + // Recover the sorted key list from the tree (path_for uses + // binary search internally, but we need an explicit list for + // leaf_index lookup at audit time). + let sorted_keys: Vec = tree.sorted_keys(); + Ok(Self { + commitment, + cached_hash, + tree, + sorted_keys, + }) + } + + /// The signed wire blob. + #[must_use] + pub fn commitment(&self) -> &StorageCommitment { + &self.commitment + } + + /// The cached commitment hash. Equal to + /// [`commitment_hash`](crate::replication::commitment::commitment_hash) + /// `(self.commitment())`. + #[must_use] + pub fn hash(&self) -> [u8; 32] { + self.cached_hash + } + + /// Inclusion path + leaf index for `key`, if it is in this + /// commitment. Returns `None` if `key` is not committed. + #[must_use] + pub fn proof_for(&self, key: &XorName) -> Option<(Vec<[u8; 32]>, u32)> { + let idx = self.sorted_keys.binary_search(key).ok()?; + let path = self.tree.path_for(key)?; + // u32 cast safe because MerkleTree::build rejects > MAX_COMMITMENT_KEY_COUNT. + let leaf_index = u32::try_from(idx).unwrap_or(u32::MAX); + Some((path, leaf_index)) + } +} + +/// Number of historical commitments retained by [`ResponderCommitmentState`]. +/// +/// Per v12 paragraph 4: a responder MUST retain demoted commitments long +/// enough that audits pinned to them can be answered. +/// +/// Sizing: with 1h rotation interval (see `COMMITMENT_ROTATION_INTERVAL_SECS` +/// in mod.rs) and worst-case neighbor-sync cooldown of ~3h (1h cooldown + +/// batch staggering), keeping 4 slots gives ~4h of pin validity. That +/// comfortably exceeds the worst-case auditor pin lag (codex round-11 +/// MAJOR #1). Memory cost: 4 × (sig + pubkey + ~64 B/key) → at 10k keys +/// per commitment, ~2.6 MB. +const RETAINED_COMMITMENT_SLOTS: usize = 4; + +/// Multi-slot retention state: the current commitment plus +/// [`RETAINED_COMMITMENT_SLOTS`] - 1 historical ones. +/// +/// Per v12 paragraph 4: a responder MUST retain demoted commitments +/// until they would no longer plausibly be pinned by any remote auditor. +/// This struct enforces that as a structural invariant — rotation is the +/// only path that drops the oldest slot. +pub struct ResponderCommitmentState { + inner: RwLock, +} + +struct Inner { + /// Newest-first: slots[0] is `current`, slots[1] is `previous`, + /// slots[2..] are older retained commitments. Length is at most + /// [`RETAINED_COMMITMENT_SLOTS`]. + slots: Vec>, +} + +impl Default for ResponderCommitmentState { + fn default() -> Self { + Self::new() + } +} + +impl ResponderCommitmentState { + /// Empty state: no commitments yet. Audits before the first rotation + /// see `None` lookups and the auditor falls back to the legacy plain + /// digest path. + #[must_use] + pub fn new() -> Self { + Self { + inner: RwLock::new(Inner { + slots: Vec::with_capacity(RETAINED_COMMITMENT_SLOTS), + }), + } + } + + /// Rotate: the new build becomes `current`; existing commitments + /// shift down; the oldest beyond [`RETAINED_COMMITMENT_SLOTS`] is + /// dropped. + /// + /// Invariant INV-R2 (v7 paragraph 2): demoted trees remain reachable + /// until they age out past the retention window. Callers MUST NOT + /// clear the retention buffer by any other mechanism. + pub fn rotate(&self, new_current: BuiltCommitment) { + let new_current = Arc::new(new_current); + let mut guard = self.inner.write(); + guard.slots.insert(0, new_current); + if guard.slots.len() > RETAINED_COMMITMENT_SLOTS { + guard.slots.truncate(RETAINED_COMMITMENT_SLOTS); + } + } + + /// Look up a commitment by its hash. Returns `Some(arc)` if `hash` + /// matches any retained slot. The returned `Arc` keeps the + /// [`BuiltCommitment`] alive for as long as the caller holds it, + /// even if a concurrent `rotate` ages it out of the retention buffer. + #[must_use] + pub fn lookup_by_hash(&self, hash: &[u8; 32]) -> Option> { + let guard = self.inner.read(); + for c in &guard.slots { + if &c.cached_hash == hash { + return Some(Arc::clone(c)); + } + } + None + } + + /// Snapshot the current commitment, if any. Used by the gossip + /// piggyback path: emit `state.current()` on the next outbound + /// `NeighborSyncRequest`/`Response`. + #[must_use] + pub fn current(&self) -> Option> { + self.inner.read().slots.first().map(Arc::clone) + } + + /// Test-only: snapshot of the second-newest slot (legacy "previous"). + #[cfg(test)] + pub(crate) fn previous(&self) -> Option> { + self.inner.read().slots.get(1).map(Arc::clone) + } +} + +// --------------------------------------------------------------------------- +// Responder: commitment-bound audit handler +// --------------------------------------------------------------------------- + +/// Outcome of [`build_commitment_bound_audit_response`]: either a +/// fully-built `CommitmentBound` response, or a typed rejection reason +/// the caller turns into an `AuditResponse::Rejected`. +#[derive(Debug)] +pub enum CommitmentBoundOutcome { + /// Per-key proofs + commitment. Caller wraps in + /// `AuditResponse::CommitmentBound`. + Built { + /// The commitment whose root the proofs are against. + commitment: crate::replication::commitment::StorageCommitment, + /// Per-key Merkle inclusion proofs, in challenge order. + per_key: Vec, + }, + /// The auditor pinned a commitment we don't recognize. Caller emits + /// `AuditResponse::Rejected { reason: "unknown commitment hash" }`. + /// Auditors classify this per the v12 §5 conditional-invalidation + /// rule: only invalidate `last_commitment` if it still matches the + /// rejected hash. + UnknownCommitmentHash, + /// One or more challenged keys are not in the matched commitment. + /// The auditor only commitment-audits keys it itself holds, so this + /// can happen if the responder rotated between the gossip the + /// auditor saw and the audit response. Caller emits + /// `AuditResponse::Rejected { reason: "key not in commitment" }`. + /// (Treated as a normal Rejected by today's auditor.) + KeyNotInCommitment { + /// The first challenged key the matched commitment didn't cover. + key: crate::ant_protocol::XorName, + }, +} + +/// Build a `CommitmentBound` audit response for the challenged peer +/// using the given `state`. +/// +/// Called by the responder when an `AuditChallenge` has +/// `expected_commitment_hash: Some(h)`. The responder looks up `h` in +/// its `ResponderCommitmentState` (current + previous), and produces a +/// per-key proof against the matched tree. Per v12 §4: the responder +/// MUST answer against the *exact* commitment whose hash matches the +/// pin — that's what `lookup_by_hash` enforces. +/// +/// The caller is responsible for: +/// - Looking up record bytes for each challenged key (the per-key +/// `digest` is bound to the bytes via +/// [`compute_audit_digest`]). This module exposes `bytes_for` +/// as a closure so the caller can use whatever storage handle it +/// has without this module depending on `LmdbStorage`. +/// +/// [`compute_audit_digest`]: crate::replication::protocol::compute_audit_digest +/// +/// # Errors / outcome +/// +/// See [`CommitmentBoundOutcome`]. +pub fn build_commitment_bound_audit_response( + state: &ResponderCommitmentState, + expected_commitment_hash: &[u8; 32], + challenge_keys: &[crate::ant_protocol::XorName], + challenge_nonce: &[u8; 32], + challenged_peer_id: &[u8; 32], + bytes_for: impl Fn(&crate::ant_protocol::XorName) -> Option>, +) -> CommitmentBoundOutcome { + use crate::replication::commitment::CommitmentBoundResult; + use crate::replication::protocol::compute_audit_digest; + + let Some(built) = state.lookup_by_hash(expected_commitment_hash) else { + return CommitmentBoundOutcome::UnknownCommitmentHash; + }; + + let mut per_key = Vec::with_capacity(challenge_keys.len()); + for key in challenge_keys { + let Some((path, leaf_index)) = built.proof_for(key) else { + return CommitmentBoundOutcome::KeyNotInCommitment { key: *key }; + }; + // If we don't actually have the bytes, we can't produce a + // valid digest; treat as "key not in commitment" since the + // commitment claims we have it but we don't. + let Some(bytes) = bytes_for(key) else { + return CommitmentBoundOutcome::KeyNotInCommitment { key: *key }; + }; + let bytes_hash = *blake3::hash(&bytes).as_bytes(); + let digest = compute_audit_digest(challenge_nonce, challenged_peer_id, key, &bytes); + per_key.push(CommitmentBoundResult { + key: *key, + digest, + bytes_hash, + leaf_index, + path, + }); + } + + CommitmentBoundOutcome::Built { + commitment: built.commitment().clone(), + per_key, + } +} + +/// Pre-check a commitment-bound audit challenge: look up the pinned +/// commitment in `state` and verify every challenged key is covered by +/// it. Does NOT read any chunk bytes. +/// +/// Used by the responder side to validate the challenge structurally +/// before streaming chunk bytes one at a time (which can be GiB for a +/// sqrt-scaled sample on a large store). The caller then iterates +/// challenge_keys, reads each chunk async, and calls +/// [`build_commitment_bound_result_for_key`] per key — bounding peak +/// memory at one chunk regardless of sample size (codex round-9 MAJOR). +/// +/// Returns the matched commitment Arc on success so the caller doesn't +/// have to look it up again. +pub fn precheck_commitment_bound_challenge( + state: &ResponderCommitmentState, + expected_commitment_hash: &[u8; 32], + challenge_keys: &[crate::ant_protocol::XorName], +) -> Result, CommitmentBoundOutcome> { + let Some(built) = state.lookup_by_hash(expected_commitment_hash) else { + return Err(CommitmentBoundOutcome::UnknownCommitmentHash); + }; + for key in challenge_keys { + if built.proof_for(key).is_none() { + return Err(CommitmentBoundOutcome::KeyNotInCommitment { key: *key }); + } + } + Ok(built) +} + +/// Build one per-key entry of a commitment-bound audit response, given +/// the pre-checked commitment and the chunk bytes for `key`. +/// +/// Pairs with [`precheck_commitment_bound_challenge`] for streaming +/// (one chunk at a time) response construction. Returns `None` if +/// `key` is not in the commitment — precheck should have caught this, +/// so a None here is a programmer error. +#[must_use] +pub fn build_commitment_bound_result_for_key( + built: &BuiltCommitment, + key: &crate::ant_protocol::XorName, + challenge_nonce: &[u8; 32], + challenged_peer_id: &[u8; 32], + bytes: &[u8], +) -> Option { + use crate::replication::commitment::CommitmentBoundResult; + use crate::replication::protocol::compute_audit_digest; + + let (path, leaf_index) = built.proof_for(key)?; + let bytes_hash = *blake3::hash(bytes).as_bytes(); + let digest = compute_audit_digest(challenge_nonce, challenged_peer_id, key, bytes); + Some(CommitmentBoundResult { + key: *key, + digest, + bytes_hash, + leaf_index, + path, + }) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + use crate::replication::commitment::{commitment_hash, leaf_hash, verify_path}; + use saorsa_pqc::api::sig::ml_dsa_65; + + fn key(byte: u8) -> XorName { + let mut k = [0u8; 32]; + k[0] = byte; + k + } + + fn bh(byte: u8) -> [u8; 32] { + [byte ^ 0x5A; 32] + } + + fn keypair() -> (saorsa_pqc::api::sig::MlDsaPublicKey, MlDsaSecretKey) { + ml_dsa_65().generate_keypair().unwrap() + } + + #[test] + fn built_commitment_hash_matches_global_hash() { + let (_pk, sk) = keypair(); + let pk_bytes = _pk.to_bytes(); + let entries: Vec<_> = (1..=5u8).map(|i| (key(i), bh(i))).collect(); + let built = BuiltCommitment::build(entries, &[0xAB; 32], &sk, &pk_bytes).unwrap(); + let expected = commitment_hash(built.commitment()).unwrap(); + assert_eq!(built.hash(), expected); + } + + #[test] + fn built_commitment_proof_verifies_under_its_own_root() { + let (_pk, sk) = keypair(); + let pk_bytes = _pk.to_bytes(); + let entries: Vec<_> = (1..=8u8).map(|i| (key(i), bh(i))).collect(); + let built = BuiltCommitment::build(entries.clone(), &[1; 32], &sk, &pk_bytes).unwrap(); + let root = built.commitment().root; + let key_count = built.commitment().key_count; + + for (k, _) in &entries { + let (path, leaf_index) = built.proof_for(k).expect("present"); + // Find the bytes_hash for this key. + let bh_k = entries.iter().find(|(kk, _)| kk == k).unwrap().1; + let lh = leaf_hash(k, &bh_k); + assert!( + verify_path(&lh, &path, leaf_index as usize, key_count, &root), + "path verify failed for key {k:?}" + ); + } + } + + #[test] + fn proof_for_absent_key_is_none() { + let (_pk, sk) = keypair(); + let pk_bytes = _pk.to_bytes(); + let built = BuiltCommitment::build( + vec![(key(1), bh(1)), (key(2), bh(2))], + &[0; 32], + &sk, + &pk_bytes, + ) + .unwrap(); + assert!(built.proof_for(&key(99)).is_none()); + } + + #[test] + fn empty_state_returns_none() { + let state = ResponderCommitmentState::new(); + assert!(state.current().is_none()); + assert!(state.lookup_by_hash(&[0; 32]).is_none()); + } + + #[test] + fn rotate_promotes_and_demotes() { + let (_pk, sk) = keypair(); + let pk_bytes = _pk.to_bytes(); + let state = ResponderCommitmentState::new(); + + // First rotation: just current, no previous. + let c1 = BuiltCommitment::build(vec![(key(1), bh(1))], &[0; 32], &sk, &pk_bytes).unwrap(); + let h1 = c1.hash(); + state.rotate(c1); + assert_eq!(state.current().unwrap().hash(), h1); + assert!(state.previous().is_none()); + + // Second rotation: c1 demoted to previous. + let c2 = BuiltCommitment::build(vec![(key(2), bh(2))], &[0; 32], &sk, &pk_bytes).unwrap(); + let h2 = c2.hash(); + state.rotate(c2); + assert_eq!(state.current().unwrap().hash(), h2); + assert_eq!(state.previous().unwrap().hash(), h1); + } + + #[test] + fn rotate_drops_oldest_past_retention_window() { + let (_pk, sk) = keypair(); + let pk_bytes = _pk.to_bytes(); + let state = ResponderCommitmentState::new(); + + // RETAINED_COMMITMENT_SLOTS = 4. Insert 5 commitments; the + // oldest should be evicted, the most recent 4 retained. + let cs: Vec<_> = (1..=5u8) + .map(|i| { + BuiltCommitment::build(vec![(key(i), bh(i))], &[0; 32], &sk, &pk_bytes).unwrap() + }) + .collect(); + let hashes: Vec<_> = cs.iter().map(BuiltCommitment::hash).collect(); + + for c in cs { + state.rotate(c); + } + + // Newest is current. + assert_eq!(state.current().unwrap().hash(), hashes[4]); + // Slots 1-4 of the input (indices 1..=4) remain reachable. + for h in hashes.iter().skip(1) { + assert!(state.lookup_by_hash(h).is_some()); + } + // The very first commitment (oldest) has been aged out. + assert!(state.lookup_by_hash(&hashes[0]).is_none()); + } + + #[test] + fn lookup_finds_current_and_previous() { + let (_pk, sk) = keypair(); + let pk_bytes = _pk.to_bytes(); + let state = ResponderCommitmentState::new(); + let c1 = BuiltCommitment::build(vec![(key(1), bh(1))], &[0; 32], &sk, &pk_bytes).unwrap(); + let h1 = c1.hash(); + let c2 = BuiltCommitment::build(vec![(key(2), bh(2))], &[0; 32], &sk, &pk_bytes).unwrap(); + let h2 = c2.hash(); + state.rotate(c1); + state.rotate(c2); + + assert!(state.lookup_by_hash(&h1).is_some()); + assert!(state.lookup_by_hash(&h2).is_some()); + assert!(state.lookup_by_hash(&[0xFF; 32]).is_none()); + } + + // --------------------------------------------------------------------- + // build_commitment_bound_audit_response + // --------------------------------------------------------------------- + + fn content(byte: u8) -> Vec { + (0..256u32).map(|i| (i as u8) ^ byte).collect() + } + + fn bytes_hash(b: &[u8]) -> [u8; 32] { + *blake3::hash(b).as_bytes() + } + + #[test] + fn build_response_succeeds_for_keys_in_current_commitment() { + let (_pk, sk) = keypair(); + let pk_bytes = _pk.to_bytes(); + let state = ResponderCommitmentState::new(); + let peer_id = *blake3::hash(&_pk.to_bytes()).as_bytes(); + + let entries: Vec<_> = (1..=5u8) + .map(|i| (key(i), bytes_hash(&content(i)))) + .collect(); + let built = BuiltCommitment::build(entries, &peer_id, &sk, &pk_bytes).unwrap(); + let h = built.hash(); + state.rotate(built); + + let bytes_lookup = + |k: &XorName| -> Option> { (1..=5u8).find(|i| key(*i) == *k).map(content) }; + let outcome = build_commitment_bound_audit_response( + &state, + &h, + &[key(1), key(3)], + &[0xCD; 32], + &peer_id, + bytes_lookup, + ); + match outcome { + CommitmentBoundOutcome::Built { + commitment, + per_key, + } => { + assert_eq!(commitment_hash(&commitment).unwrap(), h); + assert_eq!(per_key.len(), 2); + assert_eq!(per_key[0].key, key(1)); + assert_eq!(per_key[1].key, key(3)); + } + other => panic!("expected Built, got {other:?}"), + } + } + + #[test] + fn build_response_unknown_commitment_hash() { + let (_pk, sk) = keypair(); + let _ = sk; + let state = ResponderCommitmentState::new(); + // No rotate; state has no commitment. + let outcome = build_commitment_bound_audit_response( + &state, + &[0xAA; 32], // arbitrary hash, nothing matches + &[key(1)], + &[0; 32], + &[0; 32], + |_| Some(content(1)), + ); + assert!(matches!( + outcome, + CommitmentBoundOutcome::UnknownCommitmentHash + )); + } + + #[test] + fn build_response_falls_back_to_previous_after_rotation() { + // INV-R2: an audit pinned to the just-demoted commitment is + // still answerable. v5/v12 §4. + let (_pk, sk) = keypair(); + let pk_bytes = _pk.to_bytes(); + let state = ResponderCommitmentState::new(); + let peer_id = *blake3::hash(&_pk.to_bytes()).as_bytes(); + + let entries_c1: Vec<_> = (1..=3u8) + .map(|i| (key(i), bytes_hash(&content(i)))) + .collect(); + let c1 = BuiltCommitment::build(entries_c1, &peer_id, &sk, &pk_bytes).unwrap(); + let h1 = c1.hash(); + state.rotate(c1); + + // Rotate to a new commitment (key set unchanged for simplicity). + let entries_c2: Vec<_> = (1..=4u8) + .map(|i| (key(i), bytes_hash(&content(i)))) + .collect(); + let c2 = BuiltCommitment::build(entries_c2, &peer_id, &sk, &pk_bytes).unwrap(); + state.rotate(c2); + + // Auditor still pinned to h1. + let outcome = build_commitment_bound_audit_response( + &state, + &h1, + &[key(1)], + &[0; 32], + &peer_id, + |_| Some(content(1)), + ); + assert!(matches!( + outcome, + CommitmentBoundOutcome::Built { commitment, .. } + if commitment_hash(&commitment).unwrap() == h1 + )); + } + + #[test] + fn build_response_key_not_in_commitment() { + let (_pk, sk) = keypair(); + let pk_bytes = _pk.to_bytes(); + let state = ResponderCommitmentState::new(); + let peer_id = *blake3::hash(&_pk.to_bytes()).as_bytes(); + + let entries: Vec<_> = (1..=3u8) + .map(|i| (key(i), bytes_hash(&content(i)))) + .collect(); + let built = BuiltCommitment::build(entries, &peer_id, &sk, &pk_bytes).unwrap(); + let h = built.hash(); + state.rotate(built); + + let outcome = build_commitment_bound_audit_response( + &state, + &h, + &[key(99)], // not committed + &[0; 32], + &peer_id, + |_| Some(content(99)), + ); + assert!(matches!( + outcome, + CommitmentBoundOutcome::KeyNotInCommitment { .. } + )); + } + + // --------------------------------------------------------------------- + // End-to-end: responder builds → auditor verifies + // --------------------------------------------------------------------- + + use crate::replication::commitment_audit::verify_commitment_bound_response; + + #[test] + fn end_to_end_responder_to_auditor_happy_path() { + // Honest responder + honest auditor. Auditor should verify OK. + let (_pk, sk) = keypair(); + let pk_bytes = _pk.to_bytes(); + let state = ResponderCommitmentState::new(); + let peer_id = *blake3::hash(&_pk.to_bytes()).as_bytes(); + let nonce = [0xCD; 32]; + + let entries: Vec<_> = (1..=8u8) + .map(|i| (key(i), bytes_hash(&content(i)))) + .collect(); + let built = BuiltCommitment::build(entries, &peer_id, &sk, &pk_bytes).unwrap(); + let h = built.hash(); + state.rotate(built); + + let bytes_lookup = + |k: &XorName| -> Option> { (1..=8u8).find(|i| key(*i) == *k).map(content) }; + let challenge_keys = vec![key(1), key(4), key(7)]; + + let CommitmentBoundOutcome::Built { + commitment, + per_key, + } = build_commitment_bound_audit_response( + &state, + &h, + &challenge_keys, + &nonce, + &peer_id, + &bytes_lookup, + ) + else { + panic!("expected Built"); + }; + + let result = verify_commitment_bound_response( + &challenge_keys, + &nonce, + &peer_id, + &h, + &commitment, + &per_key, + bytes_lookup, + ); + // `_pk` is not directly used in verify (the embedded key is) but + // we asserted it was the signing key during build. + assert!(result.is_ok(), "{result:?}"); + } + + // (The lazy-node fresh-commitment substitution attack is more + // directly covered in + // commitment_audit::tests::lazy_node_on_demand_fetch_attack_fails. + // Removed here to keep the cross-module test surface focused on the + // happy-path data flow.) + + #[test] + fn lookup_arc_outlives_subsequent_rotation() { + // INV-R2: an in-flight audit responder that grabbed an Arc must + // be able to finish building the response even after the state + // rotates that commitment out past the retention window. + let (_pk, sk) = keypair(); + let pk_bytes = _pk.to_bytes(); + let state = ResponderCommitmentState::new(); + + let c1 = BuiltCommitment::build(vec![(key(1), bh(1))], &[0; 32], &sk, &pk_bytes).unwrap(); + let h1 = c1.hash(); + state.rotate(c1); + + let in_flight = state.lookup_by_hash(&h1).unwrap(); + + // Rotate RETAINED_COMMITMENT_SLOTS times → h1 ages out. + for i in 2..=(super::RETAINED_COMMITMENT_SLOTS as u8 + 1) { + let c = + BuiltCommitment::build(vec![(key(i), bh(i))], &[0; 32], &sk, &pk_bytes).unwrap(); + state.rotate(c); + } + assert!(state.lookup_by_hash(&h1).is_none()); + + // But the in-flight Arc still works. + assert_eq!(in_flight.hash(), h1); + assert!(in_flight.proof_for(&key(1)).is_some()); + } +} diff --git a/src/replication/mod.rs b/src/replication/mod.rs index 996de48..0e59ab8 100644 --- a/src/replication/mod.rs +++ b/src/replication/mod.rs @@ -17,6 +17,9 @@ pub mod admission; pub mod audit; pub mod bootstrap; +pub mod commitment; +pub mod commitment_audit; +pub mod commitment_state; pub mod config; pub mod fresh; pub mod neighbor_sync; @@ -24,6 +27,7 @@ pub mod paid_list; pub mod protocol; pub mod pruning; pub mod quorum; +pub mod recent_provers; pub mod scheduling; pub mod types; @@ -46,6 +50,8 @@ use crate::ant_protocol::XorName; use crate::error::{Error, Result}; use crate::payment::PaymentVerifier; use crate::replication::audit::AuditTickResult; +use crate::replication::commitment::StorageCommitment; +use crate::replication::commitment_state::{PeerCommitmentRecord, ResponderCommitmentState}; use crate::replication::config::{ max_parallel_fetch, ReplicationConfig, MAX_CONCURRENT_REPLICATION_SENDS, REPLICATION_PROTOCOL_ID, @@ -56,13 +62,14 @@ use crate::replication::protocol::{ VerificationResponse, }; use crate::replication::quorum::KeyVerificationOutcome; +use crate::replication::recent_provers::RecentProvers; use crate::replication::scheduling::ReplicationQueues; use crate::replication::types::{ AuditFailureReason, BootstrapClaimObservation, BootstrapState, FailureEvidence, HintPipeline, NeighborSyncState, PeerSyncRecord, RepairProofs, VerificationEntry, VerificationState, }; use crate::storage::LmdbStorage; -use saorsa_core::identity::PeerId; +use saorsa_core::identity::{NodeIdentity, PeerId}; use saorsa_core::{DhtNetworkEvent, P2PEvent, P2PNode, TrustEvent}; // --------------------------------------------------------------------------- @@ -85,6 +92,12 @@ struct VerificationCycleContext<'a> { bootstrap_state: &'a Arc>, is_bootstrapping: &'a Arc>, bootstrap_complete_notify: &'a Arc, + /// v12 §6 holder-eligibility inputs. The verifier downgrades a + /// peer's Present claim to Unresolved unless they're a credited + /// holder of the key (i.e. they recently passed a commitment-bound + /// audit on it under their currently-credited commitment hash). + last_commitment_by_peer: &'a Arc>>, + recent_provers: &'a Arc>, } /// Fetch worker polling interval in milliseconds. @@ -103,6 +116,56 @@ const BOOTSTRAP_DRAIN_CHECK_SECS: u64 = 5; /// is reserved for confirmed audit failures. const REPLICATION_TRUST_WEIGHT: f64 = 1.0; +/// How often the responder rebuilds + rotates its storage commitment. +/// +/// Each rebuild scans LMDB to compute leaf hashes; for ~10k keys this is +/// sub-100ms (BLAKE3 + tree build). The two-slot retention (current + +/// previous) means a rotation is also when a pinned audit may need the +/// previous commitment, so don't rotate so often that we drop a +/// commitment a peer might still pin to. +/// +/// Default: 1 hour, aligned with the worst-case neighbor-sync cooldown +/// (`NEIGHBOR_SYNC_COOLDOWN_SECS = 3600`) so that with the two-slot +/// retention (current + previous), any commitment we gossiped is still +/// answerable for up to ~2 hours after rotation. That covers the gap +/// between our rotation and the next gossip arrival at a remote peer, +/// preventing the "unknown commitment hash" -> Idle audit-skip pattern +/// from being the common case (codex round-10 MAJOR #1). +/// +/// Why not faster: the v12 pin is bound to a specific point-in-time +/// commitment, so rotation isn't security-critical for pin freshness — +/// only for keeping the committed key set current as the responder +/// writes new keys. 1 hour is plenty for that, and slow enough that +/// honest auditors mostly hit `current` or `previous` rather than the +/// "rotated past" case. +const COMMITMENT_ROTATION_INTERVAL_SECS: u64 = 3600; + +/// Minimum interval between commitment signature verifications for a +/// single peer (v10/v12 §2 step 3 + §11 DoS). +/// +/// A sybil that bypasses the routing-table gate (e.g. by transient +/// bucket pollution) could otherwise force one ML-DSA-65 verify (~1 ms) +/// per gossip message. This rate limit caps the verify-per-peer rate +/// at 1/min, which is comfortably above the legitimate gossip cadence +/// (the 10-20 min neighbor-sync round on each peer). +const COMMITMENT_SIG_VERIFY_MIN_INTERVAL: Duration = Duration::from_secs(60); + +/// Hard cap on the size of `last_commitment_by_peer`. +/// +/// Bounds the per-process memory cost of the auditor's per-peer +/// commitment cache. Each entry holds a `StorageCommitment` +/// (~5 KiB: 1952-byte pubkey + 3293-byte signature + small fields). +/// At 4096 entries the cache is ~20 MiB, which comfortably covers a +/// realistic close-group neighborhood. When the cap is hit, one +/// arbitrary existing entry is evicted on insert (HashMap iteration +/// order is unspecified; we do not track insertion order). The +/// `PeerRemoved` handler proactively drops entries as the DHT +/// detects departures, and `ingest_peer_commitment` only admits +/// commitments from peers currently in the routing table — together +/// the cap is the third line of defence against sybil/churn flooding +/// (codex round-6 MAJOR, refined in round-7). +const MAX_LAST_COMMITMENT_BY_PEER: usize = 4096; + // --------------------------------------------------------------------------- // ReplicationEngine // --------------------------------------------------------------------------- @@ -141,6 +204,41 @@ pub struct ReplicationEngine { sync_trigger: Arc, /// Notified when `is_bootstrapping` transitions from `true` to `false`. bootstrap_complete_notify: Arc, + /// Node identity (for signing storage commitments). + /// + /// Phase 3 of the v12 storage-bound audit design. The responder + /// uses this to sign its periodically-built `StorageCommitment`. + identity: Arc, + /// Responder-side commitment state (two-slot atomic rotation). + /// + /// Periodically rebuilt from the live LMDB key set; gossiped on + /// outbound `NeighborSyncRequest`/`Response`; consulted by the + /// commitment-bound audit handler. + commitment_state: Arc, + /// Auditor-side per-peer commitment record (last known commitment + + /// sticky `commitment_capable` flag). + /// + /// Populated whenever an inbound gossip carries a verified + /// commitment from the sender. Used by `audit_tick` to snapshot + /// `expected_commitment_hash` into outbound challenges, and by + /// holder-eligibility (§6) to decide whether a peer's recent_provers + /// proof should be honoured. The sticky `commitment_capable` flag + /// flips true on first successful ingest and never reverts (§2 + /// step 5). + last_commitment_by_peer: Arc>>, + /// Auditor-side holder-eligibility cache (v12 §6). + /// + /// Recorded on successful commitment-bound audit; read by future + /// quorum / paid-list eligibility checks (phase-3 stretch). + recent_provers: Arc>, + /// Per-peer last sig-verify attempt timestamp for the §2 step 3 / + /// §11 DoS rate limit. Bumped on EVERY verify attempt (success or + /// failure) so a peer we've never successfully verified can't burn + /// CPU on a flood of structurally-plausible-but-invalid gossips. + /// Lives separately from `last_commitment_by_peer` because that + /// map's records only exist after a successful verify (codex + /// round-13 finding). + sig_verify_attempts: Arc>>, /// Limits concurrent outbound replication sends to prevent bandwidth /// saturation on home broadband connections. send_semaphore: Arc, @@ -167,6 +265,7 @@ impl ReplicationEngine { p2p_node: Arc, storage: Arc, payment_verifier: Arc, + identity: Arc, root_dir: &Path, fresh_write_rx: mpsc::UnboundedReceiver, shutdown: CancellationToken, @@ -197,6 +296,11 @@ impl ReplicationEngine { is_bootstrapping: Arc::new(RwLock::new(true)), sync_trigger: Arc::new(Notify::new()), bootstrap_complete_notify: Arc::new(Notify::new()), + identity, + commitment_state: Arc::new(ResponderCommitmentState::new()), + last_commitment_by_peer: Arc::new(RwLock::new(HashMap::new())), + recent_provers: Arc::new(RwLock::new(RecentProvers::new())), + sig_verify_attempts: Arc::new(RwLock::new(HashMap::new())), send_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_REPLICATION_SENDS)), fresh_write_rx: Some(fresh_write_rx), shutdown, @@ -210,6 +314,27 @@ impl ReplicationEngine { &self.paid_list } + /// Get a reference to the responder's commitment state. Used by audit + /// handlers to look up commitments by hash; used by the rotation tick + /// to install fresh ones. + #[must_use] + pub fn commitment_state(&self) -> &Arc { + &self.commitment_state + } + + /// Get a reference to the auditor's last-commitment-by-peer table. + #[must_use] + pub fn last_commitment_by_peer(&self) -> &Arc>> { + &self.last_commitment_by_peer + } + + /// Get a reference to the holder-eligibility cache. Phase-3 stretch: + /// will be read by quorum / paid-list eligibility checks. + #[must_use] + pub fn recent_provers(&self) -> &Arc> { + &self.recent_provers + } + /// Start all background tasks. /// /// `dht_events` must be subscribed **before** `P2PNode::start()` so that @@ -226,6 +351,7 @@ impl ReplicationEngine { self.start_neighbor_sync_loop(); self.start_self_lookup_loop(); self.start_audit_loop(); + self.start_commitment_rotation_loop(); self.start_fetch_worker(); self.start_verification_worker(); self.start_bootstrap_sync(dht_events); @@ -367,6 +493,10 @@ impl ReplicationEngine { let sync_cycle_epoch = Arc::clone(&self.sync_cycle_epoch); let repair_proofs = Arc::clone(&self.repair_proofs); let sync_trigger = Arc::clone(&self.sync_trigger); + let my_commitment_state = Arc::clone(&self.commitment_state); + let last_commitment_by_peer = Arc::clone(&self.last_commitment_by_peer); + let recent_provers = Arc::clone(&self.recent_provers); + let sig_verify_attempts = Arc::clone(&self.sig_verify_attempts); let handle = tokio::spawn(async move { loop { @@ -409,6 +539,9 @@ impl ReplicationEngine { &sync_history, &sync_cycle_epoch, &repair_proofs, + &last_commitment_by_peer, + &sig_verify_attempts, + &my_commitment_state, rr_message_id.as_deref(), ).await { Ok(()) => {} @@ -439,6 +572,16 @@ impl ReplicationEngine { } DhtNetworkEvent::PeerRemoved { peer_id } => { repair_proofs.write().await.remove_peer(&peer_id); + // v12: also drop any commitment + recent-prover + // state for the removed peer so a churn / + // sybil attacker cannot leave behind one + // StorageCommitment per identity in + // last_commitment_by_peer (codex round-6 + // MAJOR) — and also drop the sig-verify + // rate-limit timestamp (codex round-13). + last_commitment_by_peer.write().await.remove(&peer_id); + recent_provers.write().await.forget_peer(&peer_id); + sig_verify_attempts.write().await.remove(&peer_id); } _ => {} } @@ -464,6 +607,9 @@ impl ReplicationEngine { let is_bootstrapping = Arc::clone(&self.is_bootstrapping); let bootstrap_state = Arc::clone(&self.bootstrap_state); let sync_trigger = Arc::clone(&self.sync_trigger); + let commitment_state = Arc::clone(&self.commitment_state); + let last_commitment_by_peer = Arc::clone(&self.last_commitment_by_peer); + let sig_verify_attempts = Arc::clone(&self.sig_verify_attempts); let handle = tokio::spawn(async move { loop { @@ -492,6 +638,9 @@ impl ReplicationEngine { &repair_proofs, &is_bootstrapping, &bootstrap_state, + &commitment_state, + &last_commitment_by_peer, + &sig_verify_attempts, ) => {} } } @@ -533,6 +682,8 @@ impl ReplicationEngine { let bootstrap_state = Arc::clone(&self.bootstrap_state); let is_bootstrapping = Arc::clone(&self.is_bootstrapping); let sync_state = Arc::clone(&self.sync_state); + let last_commitment_by_peer = Arc::clone(&self.last_commitment_by_peer); + let recent_provers = Arc::clone(&self.recent_provers); let handle = tokio::spawn(async move { // Invariant 19: wait for bootstrap to drain before starting audits. @@ -552,6 +703,10 @@ impl ReplicationEngine { // Run one audit tick immediately after bootstrap drain. { let bootstrapping = *is_bootstrapping.read().await; + let ctx = audit::CommitmentAuditCtx { + last_commitment_by_peer: &last_commitment_by_peer, + recent_provers: &recent_provers, + }; let result = { let history = sync_history.read().await; let current_sync_epoch = *sync_cycle_epoch.read().await; @@ -563,6 +718,7 @@ impl ReplicationEngine { &repair_proofs, current_sync_epoch, bootstrapping, + Some(&ctx), ) .await }; @@ -576,6 +732,10 @@ impl ReplicationEngine { () = shutdown.cancelled() => break, () = tokio::time::sleep(interval) => { let bootstrapping = *is_bootstrapping.read().await; + let ctx = audit::CommitmentAuditCtx { + last_commitment_by_peer: &last_commitment_by_peer, + recent_provers: &recent_provers, + }; let result = { let history = sync_history.read().await; let current_sync_epoch = *sync_cycle_epoch.read().await; @@ -587,6 +747,7 @@ impl ReplicationEngine { &repair_proofs, current_sync_epoch, bootstrapping, + Some(&ctx), ) .await }; @@ -599,6 +760,88 @@ impl ReplicationEngine { self.task_handles.push(handle); } + /// Periodically rebuild + sign + rotate the responder's storage + /// commitment. + /// + /// Phase 3 of the v12 storage-bound audit. Once per + /// [`COMMITMENT_ROTATION_INTERVAL_SECS`], the responder reads the + /// current LMDB key set, builds a Merkle tree (for content-addressed + /// chunks `bytes_hash == key`, so no chunk re-read is needed), signs + /// the root with the node's `MlDsaSecretKey`, and rotates the result + /// into `commitment_state`. Old `previous` slot is dropped by the + /// rotate (per `ResponderCommitmentState::rotate`). + /// + /// Skips if the key set is empty (no commitment to make) — the + /// auditor side falls back to the legacy plain-digest path for + /// peers that have never gossiped a commitment. + fn start_commitment_rotation_loop(&mut self) { + let storage = Arc::clone(&self.storage); + let identity = Arc::clone(&self.identity); + let commitment_state = Arc::clone(&self.commitment_state); + let shutdown = self.shutdown.clone(); + let p2p = Arc::clone(&self.p2p_node); + let sync_trigger = Arc::clone(&self.sync_trigger); + let recent_provers = Arc::clone(&self.recent_provers); + + let handle = tokio::spawn(async move { + // Build the first commitment immediately on startup so a + // restarted node can answer commitment-bound audits right + // away — otherwise current() stays None for a full rotation + // interval and audits silently fall back to legacy + // (codex round-11 MAJOR #2a). + // + // After the first build, trigger an immediate neighbor-sync + // round so the new commitment gossips out within seconds. + // Without this, after a restart remote auditors keep pinning + // the pre-restart (rotated-away) hash until their normal + // sync cadence elapses — up to 1 h in the worst case, + // during which time commitment-bound audits hit "unknown + // commitment hash" -> Idle no-ops (codex round-12 MAJOR #2). + // ML-DSA signatures are randomized so we cannot reproduce + // the pre-restart hash; the only honest path to recovery + // is fast re-gossip. + if let Err(e) = + rebuild_and_rotate_commitment(&storage, &identity, &commitment_state, &p2p).await + { + warn!("Initial commitment build failed: {e}"); + } else { + sync_trigger.notify_one(); + } + loop { + tokio::select! { + () = shutdown.cancelled() => break, + () = tokio::time::sleep( + std::time::Duration::from_secs(COMMITMENT_ROTATION_INTERVAL_SECS) + ) => { + if let Err(e) = rebuild_and_rotate_commitment( + &storage, + &identity, + &commitment_state, + &p2p, + ).await { + warn!("Commitment rotation failed: {e}"); + } + // Piggyback a sweep of expired recent_provers + // entries on the rotation tick (same cadence, + // 1 h). David's PR review (round-12) flagged + // the lack of TTL eviction — is_credited_holder + // already honours the TTL on read, but the + // sweep reclaims memory for entries we'll + // never re-read. + let dropped = recent_provers.write().await.sweep_expired( + std::time::Instant::now() + ); + if dropped > 0 { + debug!("recent_provers: swept {dropped} expired entries"); + } + } + } + } + debug!("Commitment rotation loop shut down"); + }); + self.task_handles.push(handle); + } + #[allow(clippy::too_many_lines, clippy::option_if_let_else)] fn start_fetch_worker(&mut self) { let p2p = Arc::clone(&self.p2p_node); @@ -774,6 +1017,8 @@ impl ReplicationEngine { let bootstrap_state = Arc::clone(&self.bootstrap_state); let is_bootstrapping = Arc::clone(&self.is_bootstrapping); let bootstrap_complete_notify = Arc::clone(&self.bootstrap_complete_notify); + let last_commitment_by_peer = Arc::clone(&self.last_commitment_by_peer); + let recent_provers = Arc::clone(&self.recent_provers); let handle = tokio::spawn(async move { loop { @@ -791,6 +1036,8 @@ impl ReplicationEngine { bootstrap_state: &bootstrap_state, is_bootstrapping: &is_bootstrapping, bootstrap_complete_notify: &bootstrap_complete_notify, + last_commitment_by_peer: &last_commitment_by_peer, + recent_provers: &recent_provers, }; run_verification_cycle(ctx).await; } @@ -828,6 +1075,9 @@ impl ReplicationEngine { let bootstrap_complete_notify = Arc::clone(&self.bootstrap_complete_notify); let sync_cycle_epoch = Arc::clone(&self.sync_cycle_epoch); let repair_proofs = Arc::clone(&self.repair_proofs); + let my_commitment_state = Arc::clone(&self.commitment_state); + let last_commitment_by_peer = Arc::clone(&self.last_commitment_by_peer); + let sig_verify_attempts = Arc::clone(&self.sig_verify_attempts); let handle = tokio::spawn(async move { // Wait for DHT bootstrap to complete before snapshotting @@ -882,12 +1132,30 @@ impl ReplicationEngine { &paid_list, &config, bootstrapping, + my_commitment_state + .current() + .map(|b| b.commitment().clone()), ) .await; bootstrap::decrement_pending_requests(&bootstrap_state, 1).await; if let Some(outcome) = outcome { + // v12: ingest the peer's piggybacked commitment from + // the response (same verification as request path). + // Bootstrap path is the FIRST gossip we receive from + // most peers, so populating last_commitment_by_peer + // here lets the first audit after drain be + // commitment-bound. + ingest_peer_commitment( + peer, + outcome.response.commitment.as_ref(), + &p2p, + &last_commitment_by_peer, + &sig_verify_attempts, + ) + .await; // sig_verify_attempts in scope from line ~1080 + if !outcome.response.bootstrapping { record_sent_replica_hints( peer, @@ -971,6 +1239,9 @@ async fn handle_replication_message( sync_history: &Arc>>, sync_cycle_epoch: &Arc>, repair_proofs: &Arc>, + last_commitment_by_peer: &Arc>>, + sig_verify_attempts: &Arc>>, + my_commitment_state: &Arc, rr_message_id: Option<&str>, ) -> Result<()> { let msg = ReplicationMessage::decode(data) @@ -1004,6 +1275,18 @@ async fn handle_replication_message( } ReplicationMessageBody::NeighborSyncRequest(ref request) => { let bootstrapping = *is_bootstrapping.read().await; + // Phase-3 storage-bound audit: store the sender's + // commitment for use as `expected_commitment_hash` in + // future audits. Verify signature before storing so a peer + // cannot inject a forged commitment for someone else. + ingest_peer_commitment( + source, + request.commitment.as_ref(), + p2p_node, + last_commitment_by_peer, + sig_verify_attempts, + ) + .await; handle_neighbor_sync_request( source, request, @@ -1017,6 +1300,9 @@ async fn handle_replication_message( sync_history, sync_cycle_epoch, repair_proofs, + my_commitment_state + .current() + .map(|b| b.commitment().clone()), msg.request_id, rr_message_id, ) @@ -1053,6 +1339,7 @@ async fn handle_replication_message( storage, p2p_node, bootstrapping, + my_commitment_state, msg.request_id, rr_message_id, ) @@ -1314,6 +1601,7 @@ async fn handle_neighbor_sync_request( sync_history: &Arc>>, sync_cycle_epoch: &Arc>, repair_proofs: &Arc>, + my_commitment: Option, request_id: u64, rr_message_id: Option<&str>, ) -> Result<()> { @@ -1335,6 +1623,7 @@ async fn handle_neighbor_sync_request( paid_list, config, is_bootstrapping, + my_commitment.clone(), ) .await; @@ -1494,23 +1783,26 @@ async fn handle_fetch_request( Ok(()) } +#[allow(clippy::too_many_arguments)] async fn handle_audit_challenge_msg( source: &PeerId, challenge: &protocol::AuditChallenge, storage: &Arc, p2p_node: &Arc, is_bootstrapping: bool, + commitment_state: &Arc, request_id: u64, rr_message_id: Option<&str>, ) -> Result<()> { #[allow(clippy::cast_possible_truncation)] let stored_chunks = storage.current_chunks().map_or(0, |c| c as usize); - let response = audit::handle_audit_challenge( + let response = audit::handle_audit_challenge_with_commitment( challenge, storage, p2p_node.peer_id(), is_bootstrapping, stored_chunks, + Some(commitment_state), ) .await; @@ -1614,6 +1906,7 @@ async fn record_sent_replica_hints( /// Run one neighbor sync round. #[allow(clippy::too_many_arguments, clippy::too_many_lines)] +#[allow(clippy::too_many_arguments)] async fn run_neighbor_sync_round( p2p_node: &Arc, storage: &Arc, @@ -1626,6 +1919,9 @@ async fn run_neighbor_sync_round( repair_proofs: &Arc>, is_bootstrapping: &Arc>, bootstrap_state: &Arc>, + commitment_state: &Arc, + last_commitment_by_peer: &Arc>>, + sig_verify_attempts: &Arc>>, ) { let self_id = *p2p_node.peer_id(); let bootstrapping = *is_bootstrapping.read().await; @@ -1705,6 +2001,12 @@ async fn run_neighbor_sync_round( debug!("Neighbor sync: syncing with {} peers", batch.len()); + // Snapshot our current commitment once per round so all peers in + // this batch see the same thing (v12 §1: gossip is the responder's + // attestation; same value across the batch is fine and reduces + // RwLock churn). + let my_commitment = commitment_state.current().map(|b| b.commitment().clone()); + // Sync with each peer in the batch. for peer in &batch { let outcome = neighbor_sync::sync_with_peer_with_outcome( @@ -1714,6 +2016,7 @@ async fn run_neighbor_sync_round( paid_list, config, bootstrapping, + my_commitment.clone(), ) .await; @@ -1734,6 +2037,8 @@ async fn run_neighbor_sync_round( sync_history, sync_cycle_epoch, repair_proofs, + last_commitment_by_peer, + sig_verify_attempts, ) .await; } else { @@ -1752,6 +2057,7 @@ async fn run_neighbor_sync_round( paid_list, config, bootstrapping, + my_commitment.clone(), ) .await; @@ -1772,6 +2078,8 @@ async fn run_neighbor_sync_round( sync_history, sync_cycle_epoch, repair_proofs, + last_commitment_by_peer, + sig_verify_attempts, ) .await; } @@ -1799,7 +2107,23 @@ async fn handle_sync_response( sync_history: &Arc>>, sync_cycle_epoch: &Arc>, repair_proofs: &Arc>, + last_commitment_by_peer: &Arc>>, + sig_verify_attempts: &Arc>>, ) { + // v12: ingest the peer's commitment if they piggybacked one on the + // response. Same verification as the request path + // (peer-id binding + signature). Drops forged commitments at the + // edge; honest commitments populate `last_commitment_by_peer` so + // the auditor can pin them on the next audit tick. + ingest_peer_commitment( + peer, + resp.commitment.as_ref(), + p2p_node, + last_commitment_by_peer, + sig_verify_attempts, + ) + .await; + // Record successful sync. { let mut state = sync_state.write().await; @@ -2018,6 +2342,8 @@ async fn run_verification_cycle(ctx: VerificationCycleContext<'_>) { bootstrap_state, is_bootstrapping, bootstrap_complete_notify, + last_commitment_by_peer, + recent_provers, } = ctx; // Evict stale entries that have been pending too long (e.g. unreachable @@ -2156,6 +2482,37 @@ async fn run_verification_cycle(ctx: VerificationCycleContext<'_>) { // Step 3: Evaluate results — collect outcomes without holding the write // lock across paid-list I/O. + // + // v12 §6 holder-eligibility: snapshot the per-peer last-commitment + // table and recent_provers cache up front so the synchronous + // evaluate_key_evidence_with_holder_check predicate can consult + // them without awaiting. The predicate downgrades a Present + // claim to Unresolved unless the peer is credited for that key. + let commitment_by_peer_snapshot: HashMap = { + let map = last_commitment_by_peer.read().await; + map.iter() + .filter_map(|(p, rec)| { + rec.last_commitment.as_ref().and_then(|c| { + crate::replication::commitment::commitment_hash(c).map(|h| (*p, h)) + }) + }) + .collect() + }; + // Take a full snapshot of recent_provers under the read lock, + // then release. The cache is bounded (16/key × keys), so the + // clone is cheap. + let provers_snapshot = recent_provers.read().await.clone(); + let holder_credit = |peer: &PeerId, key: &XorName| -> bool { + let Some(hash) = commitment_by_peer_snapshot.get(peer) else { + // Peer has no current commitment → not credited. + // (Mirrors §3 commitment_capable shield; a peer with + // no commitment can claim Present but we don't trust + // it for quorum until they re-prove storage.) + return false; + }; + provers_snapshot.is_credited_holder(key, peer, hash) + }; + let mut evaluated: Vec<(XorName, KeyVerificationOutcome, HintPipeline)> = Vec::new(); { let q = queues.read().await; @@ -2166,7 +2523,13 @@ async fn run_verification_cycle(ctx: VerificationCycleContext<'_>) { let Some(entry) = q.get_pending(key) else { continue; }; - let outcome = quorum::evaluate_key_evidence(key, ev, &targets, config); + let outcome = quorum::evaluate_key_evidence_with_holder_check( + key, + ev, + &targets, + config, + &holder_credit, + ); evaluated.push((*key, outcome, entry.pipeline)); } } // read lock released @@ -2629,6 +2992,258 @@ fn audit_failure_clears_bootstrap_claim(reason: &AuditFailureReason) -> bool { // `admit_bootstrap_hints` was consolidated into `admit_and_queue_hints`. +// --------------------------------------------------------------------------- +// Storage-bound audit (v12) — auditor-side commitment ingestion +// --------------------------------------------------------------------------- + +/// Verify + store an inbound commitment from a gossip peer. +/// +/// Called from the inbound `NeighborSyncRequest`/`Response` handlers and +/// the bootstrap-sync loop. Drops the commitment unless all five gates +/// pass: +/// 1. `source` is in our DHT routing table (sybil/churn cap). +/// 2. `commitment.sender_peer_id == source.as_bytes()` (peer-id +/// binding to the authenticated transport peer). +/// 3. `BLAKE3(commitment.sender_public_key) == commitment.sender_peer_id` +/// (the embedded pubkey actually belongs to the claimed identity — +/// saorsa-core derives `PeerId = BLAKE3(pubkey)`). +/// 4. `verify_commitment_signature(commitment)` succeeds against the +/// embedded public key. The signed payload binds the pubkey, so an +/// adversary cannot swap the key while keeping the body. +/// 5. The cache has room or this is an update for an existing entry +/// (sybil cap, `MAX_LAST_COMMITMENT_BY_PEER`). +/// +/// On all-pass, the commitment is stored as the auditor's per-peer +/// "last known commitment" for use as `expected_commitment_hash` in +/// future audits. +/// +/// Failures (no commitment / mismatched peer id / bad signature) are +/// silent drops — gossip is best-effort and a malformed commitment from +/// one peer should not affect anything else. +/// +/// Returns `true` iff the commitment was stored. +async fn ingest_peer_commitment( + source: &PeerId, + commitment: Option<&StorageCommitment>, + p2p_node: &Arc, + last_commitment_by_peer: &Arc>>, + sig_verify_attempts: &Arc>>, +) -> bool { + let Some(c) = commitment else { + // Commitment-downgrade signal: a peer that previously gossiped + // a commitment but now gossips None looks like a downgrade + // attempt to drop back onto the weaker legacy audit path. + // §2 step 5 mitigation: `commitment_capable` is sticky, so even + // if we evict the cached commitment (TTL, sybil cap), we + // remember the peer has spoken v12 — holder-eligibility (§6) + // then refuses credit, preventing the downgrade. + if last_commitment_by_peer + .read() + .await + .get(source) + .is_some_and(|r| r.commitment_capable) + { + warn!( + "ingest_peer_commitment: commitment-capable peer {source} sent None commitment \ + (downgrade attempt; sticky capable flag will prevent credit until next valid \ + commitment arrives)" + ); + } + return false; + }; + // RT-membership gate: only accept commitments from peers in our + // routing table. Off-RT senders (sybils, drive-by relays) cannot + // populate the cache, which closes the round-7 MAJOR where a + // flood of off-RT identities could fill the cap and evict honest + // peers. The neighbor-sync request handler applies the same gate + // before admitting inbound replication hints (see neighbor_sync.rs + // `sender_in_rt`); we mirror that policy here for the commitment + // piggyback. + if !p2p_node.dht_manager().is_in_routing_table(source).await { + debug!("ingest_peer_commitment: source {source} not in routing table (dropped)"); + return false; + } + // Peer-id binding: the commitment's claimed sender must match the + // authenticated transport peer (`source`). Defeats relay/replay + // and also pins which embedded public key we are about to verify + // against — the verify itself trusts the embedded key, so the + // peer-id binding is the link to a real identity. + if &c.sender_peer_id != source.as_bytes() { + warn!( + "ingest_peer_commitment: sender_peer_id mismatch from {source} \ + (dropped, possible relay attempt)" + ); + return false; + } + // Peer-id to embedded-pubkey binding: saorsa-core derives PeerId as + // BLAKE3(pubkey_bytes). Without this check, a responder could sign + // with a throwaway key they own and lie about which identity it + // belongs to (the embedded-key signature would verify trivially). + let derived_peer_id = *blake3::hash(&c.sender_public_key).as_bytes(); + if derived_peer_id != c.sender_peer_id { + warn!( + "ingest_peer_commitment: embedded pubkey does not hash to claimed peer_id for \ + {source} (dropped, throwaway-key attack)" + ); + return false; + } + // §2 step 3 + §11 DoS: rate-limit per-peer to at most one ML-DSA + // signature verify per `COMMITMENT_SIG_VERIFY_MIN_INTERVAL`. A + // sybil/RT-membership-bypassing peer that flooded valid-looking + // gossip would otherwise burn CPU on every message. The rate + // limit is checked AFTER cheap structural gates (RT, peer-id + // binding, pubkey-binding) and BEFORE the expensive sig verify. + // + // Tracked in `sig_verify_attempts` (separate from + // last_commitment_by_peer) so EVERY attempt — successful or not — + // bumps the rate-limit clock. Reading only from PeerCommitmentRecord + // would skip the cap for peers we've never successfully verified, + // letting a flood of invalid-but-structurally-plausible gossips + // burn CPU (codex round-13 finding). + let now = Instant::now(); + // Atomic check-and-stamp under a single write lock. Codex round-14 + // found that read-then-write under separate locks let two + // concurrent ingests from the same peer both miss the check and + // both reach ML-DSA verify within the 60s window. Holding the + // write lock across the rate-limit decision closes that race. + // The lock is held only for a hash-map lookup + insert (microseconds), + // not across the expensive verify itself. + { + let mut attempts = sig_verify_attempts.write().await; + if let Some(&last) = attempts.get(source) { + if now.saturating_duration_since(last) < COMMITMENT_SIG_VERIFY_MIN_INTERVAL { + debug!( + "ingest_peer_commitment: rate-limited sig verify from {source} \ + (< {COMMITMENT_SIG_VERIFY_MIN_INTERVAL:?} since last attempt); dropped" + ); + return false; + } + } + // Hard-cap the map size so a wide flood of distinct peer ids + // cannot grow it unbounded. Sized at the same cap as + // last_commitment_by_peer. + if attempts.len() >= MAX_LAST_COMMITMENT_BY_PEER && !attempts.contains_key(source) { + // Drop the entry with the oldest timestamp to make room + // for a fresh attempt (preserves DoS-cap semantics). + if let Some(victim) = attempts.iter().min_by_key(|(_, &ts)| ts).map(|(p, _)| *p) { + attempts.remove(&victim); + } + } + // Stamp BEFORE the verify so even if verify panics or is very + // slow, a concurrent message from the same peer is rejected + // by the 60s cap when it reaches this critical section. + attempts.insert(*source, now); + } + // Signature verify, using the public key embedded in the commitment + // itself. The pubkey is bound by the signature payload (see + // commitment_signed_payload) so an adversary cannot keep the body + // and swap the key to one they hold the secret for. + if !crate::replication::commitment::verify_commitment_signature(c) { + warn!( + "ingest_peer_commitment: signature did not verify under embedded key for {source} \ + (dropped, forged commitment)" + ); + return false; + } + let mut map = last_commitment_by_peer.write().await; + // Sybil/churn cap: if we're at the hard cap AND this is a new peer, + // evict an arbitrary existing entry to make room. Updates for peers + // already in the map are always accepted (they replace, not grow). + if map.len() >= MAX_LAST_COMMITMENT_BY_PEER && !map.contains_key(source) { + // Drop one arbitrary entry. HashMap iter order is random which + // is fine — over time PeerRemoved cleanup keeps the working set + // anchored on the real RT membership; this cap only fires under + // active flooding attempts. + if let Some(victim) = map.keys().next().copied() { + map.remove(&victim); + warn!( + "ingest_peer_commitment: cache full ({MAX_LAST_COMMITMENT_BY_PEER}); \ + evicted {victim} to admit {source}" + ); + } + } + // Preserve sticky commitment_capable across updates — once true, + // always true. New entries start with capable = true (we just + // verified a valid commitment from this peer). + map.entry(*source) + .and_modify(|r| { + r.last_commitment = Some(c.clone()); + r.received_at = now; + r.last_sig_verify_at = now; + r.commitment_capable = true; // sticky-redundant but explicit + }) + .or_insert_with(|| PeerCommitmentRecord::from_verified(c.clone(), now)); + true +} + +// --------------------------------------------------------------------------- +// Storage-bound audit (v12) — responder commitment rotation +// --------------------------------------------------------------------------- + +/// Read the current LMDB key set, build + sign a fresh +/// `StorageCommitment`, and rotate it into `state` as the new `current`. +/// The prior `current` is demoted to `previous`; the prior `previous` is +/// dropped (per `ResponderCommitmentState::rotate`). +/// +/// For content-addressed chunks (Autonomi's chunk store), `address == +/// BLAKE3(content)`, so `bytes_hash := key` and we don't have to +/// re-read each chunk's bytes to compute the leaf hash. +/// +/// Skips (returns `Ok(())`) if the key set is empty — no commitment to +/// rotate. The auditor side handles "no commitment for this peer" by +/// falling back to the legacy plain-digest audit path. +async fn rebuild_and_rotate_commitment( + storage: &Arc, + identity: &Arc, + state: &Arc, + p2p: &Arc, +) -> Result<()> { + use saorsa_pqc::api::sig::{MlDsaSecretKey, MlDsaVariant}; + + let keys = storage + .all_keys() + .await + .map_err(|e| Error::Storage(format!("commitment build: read keys: {e}")))?; + if keys.is_empty() { + debug!("Commitment rotation: storage empty, skipping"); + return Ok(()); + } + + // Cap to MAX_COMMITMENT_KEY_COUNT for v12 (responder must not commit + // to more than the protocol limit; auditor would reject the + // commitment otherwise). + let cap = commitment::MAX_COMMITMENT_KEY_COUNT as usize; + if keys.len() > cap { + warn!( + "Commitment rotation: key set ({}) exceeds MAX_COMMITMENT_KEY_COUNT ({}); \ + truncating — investigate as this likely means a misconfiguration", + keys.len(), + cap + ); + } + + // For content-addressed chunks, bytes_hash == key. Saves a full + // chunk-store rescan per rotation. The audit-verify path still + // checks `bytes_hash == BLAKE3(local_bytes)` (which for + // content-addressed equals key) and the digest (which is bound to + // the actual bytes), so a lying responder is still caught. + let entries: Vec<_> = keys.into_iter().take(cap).map(|k| (k, k)).collect(); + + let sk_bytes = identity.secret_key_bytes().to_vec(); + let sk = MlDsaSecretKey::from_bytes(MlDsaVariant::MlDsa65, &sk_bytes) + .map_err(|e| Error::Crypto(format!("commitment build: load sk: {e}")))?; + let pk_bytes = identity.public_key().as_bytes().to_vec(); + let peer_id_bytes = *p2p.peer_id().as_bytes(); + let built = commitment_state::BuiltCommitment::build(entries, &peer_id_bytes, &sk, &pk_bytes) + .map_err(|e| Error::Crypto(format!("commitment build: {e}")))?; + + let hash = hex::encode(built.hash()); + let key_count = built.commitment().key_count; + state.rotate(built); + info!("Storage commitment rotated: hash={hash} key_count={key_count}"); + Ok(()) +} + #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod tests { diff --git a/src/replication/neighbor_sync.rs b/src/replication/neighbor_sync.rs index 897d41a..b84dab6 100644 --- a/src/replication/neighbor_sync.rs +++ b/src/replication/neighbor_sync.rs @@ -182,11 +182,23 @@ pub async fn sync_with_peer( config: &ReplicationConfig, is_bootstrapping: bool, ) -> Option { - sync_with_peer_with_outcome(peer, p2p_node, storage, paid_list, config, is_bootstrapping) - .await - .map(|outcome| outcome.response) + sync_with_peer_with_outcome( + peer, + p2p_node, + storage, + paid_list, + config, + is_bootstrapping, + None, + ) + .await + .map(|outcome| outcome.response) } +/// `commitment`: sender's current commitment to piggyback on the request. +/// `None` if the responder hasn't rotated one yet (e.g. fresh boot, +/// empty storage) — receiver falls back to legacy path. +#[allow(clippy::too_many_arguments)] pub(crate) async fn sync_with_peer_with_outcome( peer: &PeerId, p2p_node: &Arc, @@ -194,6 +206,7 @@ pub(crate) async fn sync_with_peer_with_outcome( paid_list: &Arc, config: &ReplicationConfig, is_bootstrapping: bool, + commitment: Option, ) -> Option { // Build peer-targeted hint sets (Rule 7). let sent_replica_hints = build_replica_hints_for_peer_with_close_groups( @@ -215,6 +228,7 @@ pub(crate) async fn sync_with_peer_with_outcome( replica_hints, paid_hints, bootstrapping: is_bootstrapping, + commitment, }; let request_id = rand::thread_rng().gen::(); let msg = ReplicationMessage { @@ -335,11 +349,13 @@ pub async fn handle_sync_request( paid_list, config, is_bootstrapping, + None, ) .await; (response, sender_in_rt) } +#[allow(clippy::too_many_arguments)] pub(crate) async fn handle_sync_request_with_proofs( sender: &PeerId, _request: &NeighborSyncRequest, @@ -348,6 +364,7 @@ pub(crate) async fn handle_sync_request_with_proofs( paid_list: &Arc, config: &ReplicationConfig, is_bootstrapping: bool, + my_commitment: Option, ) -> (NeighborSyncResponse, Vec, bool) { let sender_in_rt = p2p_node.dht_manager().is_in_routing_table(sender).await; @@ -376,6 +393,7 @@ pub(crate) async fn handle_sync_request_with_proofs( paid_hints, bootstrapping: is_bootstrapping, rejected_keys: Vec::new(), + commitment: my_commitment, }; // Rule 4-6: accept inbound hints only if sender is in LocalRT. @@ -977,6 +995,7 @@ mod tests { paid_hints: outbound_paid_hints.clone(), bootstrapping: false, rejected_keys: Vec::new(), + commitment: None, }; // Inbound hints from the sender (would be in the request). diff --git a/src/replication/protocol.rs b/src/replication/protocol.rs index a5151a3..08fda54 100644 --- a/src/replication/protocol.rs +++ b/src/replication/protocol.rs @@ -177,6 +177,14 @@ pub struct NeighborSyncRequest { pub paid_hints: Vec, /// Whether sender is currently bootstrapping. pub bootstrapping: bool, + /// Sender's signed storage commitment (optional, see + /// [`crate::replication::commitment`]). `None` from old peers; from + /// new peers this carries the Merkle-root commitment over the + /// sender's claimed keys. Receivers that recognize it store it as + /// the per-peer "last known commitment" used to pin commitment-bound + /// audits. + #[serde(default)] + pub commitment: Option, } /// Neighbor sync response carrying own hint sets. @@ -190,6 +198,10 @@ pub struct NeighborSyncResponse { pub bootstrapping: bool, /// Keys that receiver rejected (optional feedback to sender). pub rejected_keys: Vec, + /// Receiver's signed storage commitment (optional, see + /// [`NeighborSyncRequest::commitment`]). + #[serde(default)] + pub commitment: Option, } // --------------------------------------------------------------------------- @@ -286,6 +298,20 @@ pub struct AuditChallenge { pub challenged_peer_id: [u8; 32], /// Ordered list of keys to prove storage of. pub keys: Vec, + /// Auditor's pin to the commitment it expects the responder to use. + /// + /// `Some(h)`: a commitment-bound audit (v12 design). The responder + /// must reply with `AuditResponse::CommitmentBound` whose + /// commitment hashes via + /// [`crate::replication::commitment::commitment_hash`] to exactly + /// `h`. Any other commitment, or a plain `Digests` reply, is an + /// audit failure. + /// + /// `None`: legacy plain-digest audit (today's behaviour). Allows + /// challenging peers from whom we haven't yet received a commitment + /// without breaking the existing audit flow during rollout. + #[serde(default)] + pub expected_commitment_hash: Option<[u8; 32]>, } /// Response to audit challenge. @@ -316,6 +342,25 @@ pub enum AuditResponse { /// Human-readable rejection reason. reason: String, }, + /// Commitment-bound proof of storage (v12 storage-bound audit). + /// + /// Returned when the challenge carried an + /// [`AuditChallenge::expected_commitment_hash`]. Carries the + /// responder's signed commitment plus per-key Merkle inclusion + /// proofs. The auditor verifies that: + /// 1. `commitment_hash(commitment) == challenge.expected_commitment_hash` + /// 2. The commitment's signature is valid. + /// 3. For each per-key entry: the Merkle path verifies the leaf + /// against the commitment root AND the digest matches the + /// auditor's local copy of the bytes. + CommitmentBound { + /// The challenge this response answers. + challenge_id: u64, + /// The signed commitment whose root the proofs are against. + commitment: crate::replication::commitment::StorageCommitment, + /// Per-key Merkle inclusion proofs, in challenge order. + per_key: Vec, + }, } // --------------------------------------------------------------------------- @@ -490,6 +535,141 @@ mod tests { // === Neighbor Sync roundtrips === + // -- backwards compat across the wire-type extension -------------------- + + /// Backwards-compat: an old peer that has the v0 layout of + /// `NeighborSyncRequest` (no `commitment` field) can still decode a + /// message encoded by a new peer that emits `commitment: None`. This + /// is the realistic mixed-version case during rollout: new peers + /// gossip with the field; old peers must not crash. + /// + /// The check works because postcard's [`from_bytes`] is lenient on + /// trailing bytes — the old decoder reads what it knows about and + /// stops, the new fields are silently ignored. This test pins that + /// invariant so any future codec/library swap that breaks it is + /// caught immediately. + #[test] + fn old_decoder_tolerates_new_neighbor_sync_request() { + use serde::Deserialize; + #[derive(Deserialize)] + struct OldNeighborSyncRequest { + #[allow(dead_code)] + pub replica_hints: Vec, + #[allow(dead_code)] + pub paid_hints: Vec, + #[allow(dead_code)] + pub bootstrapping: bool, + } + + let new_req = NeighborSyncRequest { + replica_hints: vec![[0x01; 32], [0x02; 32]], + paid_hints: vec![[0x03; 32]], + bootstrapping: true, + commitment: None, + }; + let encoded = postcard::to_stdvec(&new_req).expect("encode"); + let old_decoded: OldNeighborSyncRequest = + postcard::from_bytes(&encoded).expect("old decoder accepts"); + // Field-by-field check would fail if old peer misaligned on the + // length prefix — passing decode is the structural check. + assert_eq!(old_decoded.replica_hints.len(), 2); + assert_eq!(old_decoded.paid_hints.len(), 1); + assert!(old_decoded.bootstrapping); + } + + /// Same property for `NeighborSyncResponse`. + #[test] + fn old_decoder_tolerates_new_neighbor_sync_response() { + use serde::Deserialize; + #[derive(Deserialize)] + struct OldNeighborSyncResponse { + #[allow(dead_code)] + pub replica_hints: Vec, + #[allow(dead_code)] + pub paid_hints: Vec, + #[allow(dead_code)] + pub bootstrapping: bool, + #[allow(dead_code)] + pub rejected_keys: Vec, + } + + let new_resp = NeighborSyncResponse { + replica_hints: vec![[0x04; 32]], + paid_hints: vec![], + bootstrapping: false, + rejected_keys: vec![[0x05; 32]], + commitment: None, + }; + let encoded = postcard::to_stdvec(&new_resp).expect("encode"); + let old_decoded: OldNeighborSyncResponse = + postcard::from_bytes(&encoded).expect("old decoder accepts"); + assert_eq!(old_decoded.replica_hints.len(), 1); + assert_eq!(old_decoded.rejected_keys.len(), 1); + } + + /// `AuditChallenge` extension: old peer (no `expected_commitment_hash` + /// field) decodes a new-peer message OK. + #[test] + fn old_decoder_tolerates_new_audit_challenge() { + use serde::Deserialize; + #[derive(Deserialize)] + struct OldAuditChallenge { + #[allow(dead_code)] + pub challenge_id: u64, + #[allow(dead_code)] + pub nonce: [u8; 32], + #[allow(dead_code)] + pub challenged_peer_id: [u8; 32], + #[allow(dead_code)] + pub keys: Vec, + } + + let new_ch = AuditChallenge { + challenge_id: 7, + nonce: [0xAA; 32], + challenged_peer_id: [0xBB; 32], + keys: vec![[0x01; 32], [0x02; 32]], + expected_commitment_hash: None, + }; + let encoded = postcard::to_stdvec(&new_ch).expect("encode"); + let old_decoded: OldAuditChallenge = + postcard::from_bytes(&encoded).expect("old decoder accepts"); + assert_eq!(old_decoded.challenge_id, 7); + assert_eq!(old_decoded.keys.len(), 2); + } + + /// Roundtrip: a new peer can decode its own message including the + /// commitment field. Catches accidental serde annotation breakage + /// (e.g. forgetting `#[serde(default)]` on the new field). + #[test] + fn new_peer_roundtrips_with_commitment_some() { + use crate::replication::commitment::{sign_commitment, StorageCommitment}; + use saorsa_pqc::api::sig::ml_dsa_65; + + let (pk, sk) = ml_dsa_65().generate_keypair().expect("keygen"); + let root = [0x7Fu8; 32]; + let sender = [0xCCu8; 32]; + let pk_bytes = pk.to_bytes(); + let sig = sign_commitment(&sk, &root, 3, &sender, &pk_bytes).expect("sign"); + let commitment = StorageCommitment { + root, + key_count: 3, + sender_peer_id: sender, + sender_public_key: pk_bytes, + signature: sig, + }; + + let req = NeighborSyncRequest { + replica_hints: vec![[0x01; 32]], + paid_hints: vec![], + bootstrapping: false, + commitment: Some(commitment.clone()), + }; + let encoded = postcard::to_stdvec(&req).expect("encode"); + let decoded: NeighborSyncRequest = postcard::from_bytes(&encoded).expect("new decoder"); + assert_eq!(decoded.commitment, Some(commitment)); + } + #[test] fn neighbor_sync_request_roundtrip() { let msg = ReplicationMessage { @@ -498,6 +678,7 @@ mod tests { replica_hints: vec![[0x01; 32], [0x02; 32]], paid_hints: vec![[0x03; 32]], bootstrapping: true, + commitment: None, }), }; let encoded = msg.encode().expect("encode should succeed"); @@ -522,6 +703,7 @@ mod tests { paid_hints: vec![], bootstrapping: false, rejected_keys: vec![[0x05; 32], [0x06; 32]], + commitment: None, }), }; let encoded = msg.encode().expect("encode should succeed"); @@ -697,6 +879,7 @@ mod tests { nonce: [0xAB; 32], challenged_peer_id: [0xCD; 32], keys: vec![[0x01; 32], [0x02; 32]], + expected_commitment_hash: None, }), }; let encoded = msg.encode().expect("encode should succeed"); diff --git a/src/replication/pruning.rs b/src/replication/pruning.rs index 4618ab0..41403e9 100644 --- a/src/replication/pruning.rs +++ b/src/replication/pruning.rs @@ -710,6 +710,11 @@ fn encode_prune_audit_challenge( nonce, challenged_peer_id: *peer.as_bytes(), keys: vec![key], + // Prune-audit challenges keep legacy plain-digest semantics + // (caller does its own per-key digest comparison). Commitment- + // bound prune audits are out of scope for phase 2; revisit in + // phase 3 if we choose to extend coverage there. + expected_commitment_hash: None, }; let msg = ReplicationMessage { request_id: challenge_id, diff --git a/src/replication/quorum.rs b/src/replication/quorum.rs index 5f4d99a..1918663 100644 --- a/src/replication/quorum.rs +++ b/src/replication/quorum.rs @@ -202,19 +202,52 @@ pub fn evaluate_key_evidence( evidence: &KeyVerificationEvidence, targets: &VerificationTargets, config: &ReplicationConfig, +) -> KeyVerificationOutcome { + evaluate_key_evidence_with_holder_check(key, evidence, targets, config, |_, _| true) +} + +/// Variant of [`evaluate_key_evidence`] that consults a holder-credit +/// predicate before counting a peer's Present evidence (v12 §6). +/// +/// `holder_credit` is invoked as `(peer, key) -> bool`. Returning `false` +/// downgrades a Present claim to Unresolved (we don't trust this peer's +/// "I have it" without a recent commitment-bound audit proving it). +/// Returning `true` keeps today's behaviour. Paid-list evidence is +/// independent of holder credit (the paid-list lookup is a property of +/// the receiving peer's own data, not a claim about K being present). +/// +/// The non-`_with_holder_check` form preserves prior behaviour by +/// passing a predicate that always returns true. New call sites that +/// have a `RecentProvers` cache + commitment-by-peer table should pass +/// a real predicate. +#[must_use] +pub fn evaluate_key_evidence_with_holder_check( + key: &XorName, + evidence: &KeyVerificationEvidence, + targets: &VerificationTargets, + config: &ReplicationConfig, + holder_credit: impl Fn(&PeerId, &XorName) -> bool, ) -> KeyVerificationOutcome { let quorum_peers = targets .quorum_targets .get(key) .map_or(&[][..], Vec::as_slice); - // Count presence evidence from QuorumTargets. + // Count presence evidence from QuorumTargets. v12 §6: a peer that + // claims Present but is not commitment-credited for K is downgraded + // to Unresolved (we may have to retry once they re-prove storage). let mut presence_positive = 0usize; let mut presence_unresolved = 0usize; for peer in quorum_peers { match evidence.presence.get(peer) { - Some(PresenceEvidence::Present) => presence_positive += 1, + Some(PresenceEvidence::Present) => { + if holder_credit(peer, key) { + presence_positive += 1; + } else { + presence_unresolved += 1; + } + } Some(PresenceEvidence::Absent) => {} Some(PresenceEvidence::Unresolved) | None => { presence_unresolved += 1; @@ -662,6 +695,108 @@ mod tests { ); } + // ----------------------------------------------------------------------- + // v12 §6 holder-credit predicate downgrades uncredited peers + // ----------------------------------------------------------------------- + + #[test] + fn quorum_downgrades_uncredited_present_peers() { + // 7 quorum peers, threshold 4. 4 say Present, 3 say Absent — + // would normally pass. But with a holder-credit predicate that + // only credits 2 of them, presence_positive drops to 2 and the + // 2 uncredited Presents become Unresolved. Total = 2 positive + // + 2 unresolved + 3 absent = 5 valid → still possible → + // QuorumInconclusive (not yet failed, but not verified either). + let key = xor_name_from_byte(0x33); + let config = ReplicationConfig::default(); + let quorum_peers: Vec = (1..=7).map(peer_id_from_byte).collect(); + let targets = single_key_targets(&key, quorum_peers.clone(), vec![]); + + let evidence = build_evidence( + vec![ + (quorum_peers[0], PresenceEvidence::Present), + (quorum_peers[1], PresenceEvidence::Present), + (quorum_peers[2], PresenceEvidence::Present), + (quorum_peers[3], PresenceEvidence::Present), + (quorum_peers[4], PresenceEvidence::Absent), + (quorum_peers[5], PresenceEvidence::Absent), + (quorum_peers[6], PresenceEvidence::Absent), + ], + vec![], + ); + + // Credit only the first two peers (the other two Presents are + // uncredited and will be downgraded to Unresolved). + let credit = |peer: &PeerId, _: &XorName| -> bool { + *peer == quorum_peers[0] || *peer == quorum_peers[1] + }; + let outcome = + evaluate_key_evidence_with_holder_check(&key, &evidence, &targets, &config, credit); + assert!( + matches!(outcome, KeyVerificationOutcome::QuorumInconclusive), + "credit downgrade should drop presence_positive below threshold, got {outcome:?}" + ); + } + + #[test] + fn quorum_passes_when_all_present_peers_are_credited() { + let key = xor_name_from_byte(0x34); + let config = ReplicationConfig::default(); + let quorum_peers: Vec = (1..=7).map(peer_id_from_byte).collect(); + let targets = single_key_targets(&key, quorum_peers.clone(), vec![]); + + let evidence = build_evidence( + (0..4) + .map(|i| (quorum_peers[i], PresenceEvidence::Present)) + .chain((4..7).map(|i| (quorum_peers[i], PresenceEvidence::Absent))) + .collect(), + vec![], + ); + + let credit = |_: &PeerId, _: &XorName| -> bool { true }; + let outcome = + evaluate_key_evidence_with_holder_check(&key, &evidence, &targets, &config, credit); + assert!( + matches!(outcome, KeyVerificationOutcome::QuorumVerified { .. }), + "all-credited Present should pass quorum, got {outcome:?}" + ); + } + + #[test] + fn paid_list_path_unaffected_by_holder_credit() { + // v12 §6: holder-credit gates Present claims, NOT paid-list + // evidence (the paid-list lookup is the receiving peer's own + // data, not a claim about K). A peer with no credit at all + // can still contribute to paid-list majority. + let key = xor_name_from_byte(0x35); + let config = ReplicationConfig::default(); + let quorum_peers: Vec = (1..=3).map(peer_id_from_byte).collect(); + let paid_peers: Vec = (10..=14).map(peer_id_from_byte).collect(); + let targets = single_key_targets(&key, quorum_peers.clone(), paid_peers.clone()); + + let evidence = build_evidence( + quorum_peers + .iter() + .map(|p| (*p, PresenceEvidence::Absent)) + .collect(), + vec![ + (paid_peers[0], PaidListEvidence::Confirmed), + (paid_peers[1], PaidListEvidence::Confirmed), + (paid_peers[2], PaidListEvidence::Confirmed), + (paid_peers[3], PaidListEvidence::NotFound), + (paid_peers[4], PaidListEvidence::NotFound), + ], + ); + + let credit = |_: &PeerId, _: &XorName| -> bool { false }; + let outcome = + evaluate_key_evidence_with_holder_check(&key, &evidence, &targets, &config, credit); + assert!( + matches!(outcome, KeyVerificationOutcome::PaidListVerified { .. }), + "paid-list path must not be gated by holder-credit, got {outcome:?}" + ); + } + // ----------------------------------------------------------------------- // evaluate_key_evidence: PaidListVerified // ----------------------------------------------------------------------- diff --git a/src/replication/recent_provers.rs b/src/replication/recent_provers.rs new file mode 100644 index 0000000..1d684bc --- /dev/null +++ b/src/replication/recent_provers.rs @@ -0,0 +1,351 @@ +//! Holder-eligibility cache: which peers recently proved storage of +//! which key, against which commitment. +//! +//! Phase 2d of the v12 storage-bound audit design (`notes/security- +//! findings-2026-05-22/proposal-gossip-audit-v12.md`). +//! +//! When the auditor successfully verifies a commitment-bound audit for +//! peer P on key K (against P's currently-credited commitment hash H), +//! it inserts `(P, H, now)` into `recent_provers[K]`. Reward / quorum +//! eligibility for P-as-holder-of-K then checks that this cache entry +//! still matches P's *currently credited* commitment hash; if P rotates +//! the hash via fresh gossip, the cache entry becomes stale and credit +//! is denied until the next successful audit against the new hash. +//! +//! Invariants enforced here: +//! +//! - **Per-key cap**: at most [`MAX_PROVERS_PER_KEY`] entries per key, +//! LRU-evicted by `proved_at`. Bounds the per-key working set so a +//! well-replicated key cannot fill memory. +//! - **RT-only**: only peers in the caller's routing table populate +//! entries — the caller is responsible for filtering before +//! [`RecentProvers::record_proof`]; this module just stores what it's +//! told. +//! - **Hash-bound credit**: [`RecentProvers::is_credited_holder`] +//! requires the cache entry's `commitment_hash` to match the peer's +//! *current* `commitment_hash`. A peer who proves K under C1 then +//! rotates to C2 loses credit until re-proving K under C2. +//! +//! - **TTL**: entries older than [`PROVER_ENTRY_TTL`] are ignored by +//! [`RecentProvers::is_credited_holder`] on read, and +//! [`RecentProvers::sweep_expired`] reclaims their memory when a +//! caller invokes it (e.g. periodically from the engine). +//! - **PeerRemoved cleanup**: the caller should call +//! [`RecentProvers::forget_peer`] when a peer leaves the routing +//! table to drop their entries immediately (faster than waiting for +//! TTL). + +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use saorsa_core::identity::PeerId; + +use crate::ant_protocol::XorName; + +/// Maximum number of cached provers per key. +/// +/// Sized at 2× `CLOSE_GROUP_SIZE = 8`, giving 8 slack slots for churn +/// without unbounded growth. LRU-evicted within the cap. +pub const MAX_PROVERS_PER_KEY: usize = 16; + +/// Maximum age of a cached prover entry before it is considered stale. +/// +/// A proof older than this is treated as "no credit" by +/// [`RecentProvers::is_credited_holder`] even if the commitment hash +/// still matches. +/// +/// v10/v12 §6 spec: `RECENT_PROOF_TTL = 2 × max audit interval` (≈40 min +/// at the default 20 min max). Setting too low → peers fall out of +/// credit between audits. Setting too high → lazy node has more leeway +/// before re-audit is required. 40 min comfortably covers one audit +/// cycle on the average peer while still requiring re-proof inside the +/// rotation window. +pub const PROVER_ENTRY_TTL: Duration = Duration::from_secs(40 * 60); + +/// One cached prover entry: who proved the key, when, and against which +/// commitment. +#[derive(Debug, Clone, Copy)] +pub struct ProverEntry { + /// The peer that produced the audit proof. + pub peer_id: PeerId, + /// When the proof was recorded. Used for LRU eviction. + pub proved_at: Instant, + /// The peer's commitment hash at proof time. Holder-eligibility + /// requires this to match the peer's *currently credited* hash. + pub commitment_hash: [u8; 32], +} + +/// Per-key cache of recent provers, capped at [`MAX_PROVERS_PER_KEY`]. +#[derive(Debug, Default, Clone)] +pub struct RecentProvers { + /// `entries[K]` is the per-key bounded list. Entries are kept sorted + /// by `proved_at` ascending so eviction is `O(1)` (drop head). + entries: HashMap>, +} + +impl RecentProvers { + /// Empty cache. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Record that `peer_id` proved storage of `key` under commitment + /// `commitment_hash` at `proved_at`. + /// + /// If the same `(peer_id, commitment_hash)` is already cached for + /// this key, the entry is updated in place (refreshes `proved_at`). + /// Otherwise a new entry is appended, evicting the oldest entry if + /// the per-key cap would be exceeded. + pub fn record_proof( + &mut self, + key: XorName, + peer_id: PeerId, + commitment_hash: [u8; 32], + proved_at: Instant, + ) { + let bucket = self.entries.entry(key).or_default(); + + // Refresh-in-place if the (peer, hash) already exists. + for e in bucket.iter_mut() { + if e.peer_id == peer_id && e.commitment_hash == commitment_hash { + e.proved_at = proved_at; + bucket.sort_by_key(|e| e.proved_at); + return; + } + } + + // Evict the oldest entry if we're at the cap. + if bucket.len() >= MAX_PROVERS_PER_KEY { + // bucket is sorted ascending; oldest is index 0. + bucket.remove(0); + } + + bucket.push(ProverEntry { + peer_id, + proved_at, + commitment_hash, + }); + bucket.sort_by_key(|e| e.proved_at); + } + + /// Is `peer_id` currently credited as a holder of `key`? + /// + /// Returns `true` iff there is a non-stale cached entry with `peer_id` + /// and `commitment_hash == current_commitment_hash`. + /// + /// "Non-stale" means `now - proved_at < PROVER_ENTRY_TTL`. The hash + /// binding is the v12 §6 lever: a peer that rotates their commitment + /// must re-prove every key they want credit for. The TTL is a + /// secondary safety net that revokes credit even if the hash + /// happens to match (e.g. a peer who proved long ago but has been + /// silent or offline since). + #[must_use] + pub fn is_credited_holder( + &self, + key: &XorName, + peer_id: &PeerId, + current_commitment_hash: &[u8; 32], + ) -> bool { + let now = Instant::now(); + self.entries.get(key).is_some_and(|bucket| { + bucket.iter().any(|e| { + &e.peer_id == peer_id + && &e.commitment_hash == current_commitment_hash + && now.saturating_duration_since(e.proved_at) < PROVER_ENTRY_TTL + }) + }) + } + + /// Sweep entries older than [`PROVER_ENTRY_TTL`] across all keys. + /// + /// Returns the number of entries dropped. Intended for periodic + /// invocation by a background task; `is_credited_holder` already + /// honours the TTL on read, so the sweep only reclaims memory. + pub fn sweep_expired(&mut self, now: Instant) -> usize { + let mut dropped = 0; + for bucket in self.entries.values_mut() { + let before = bucket.len(); + bucket.retain(|e| now.saturating_duration_since(e.proved_at) < PROVER_ENTRY_TTL); + dropped += before - bucket.len(); + } + self.entries.retain(|_, b| !b.is_empty()); + dropped + } + + /// Drop every cached entry for `peer_id` across all keys. + /// + /// Called when a peer leaves the routing table (RT-only invariant) + /// or on explicit eviction. + pub fn forget_peer(&mut self, peer_id: &PeerId) { + for bucket in self.entries.values_mut() { + bucket.retain(|e| &e.peer_id != peer_id); + } + self.entries.retain(|_, b| !b.is_empty()); + } + + /// Drop every entry whose `commitment_hash` matches `stale_hash` + /// (used when the auditor invalidates a peer's `last_commitment` — + /// e.g. on `UnknownCommitmentHash` rejection — to remove the cached + /// proofs against that no-longer-valid commitment). + pub fn forget_commitment(&mut self, stale_hash: &[u8; 32]) { + for bucket in self.entries.values_mut() { + bucket.retain(|e| &e.commitment_hash != stale_hash); + } + self.entries.retain(|_, b| !b.is_empty()); + } + + /// Number of cached entries for `key`. Test/observability helper. + #[must_use] + pub fn provers_for(&self, key: &XorName) -> usize { + self.entries.get(key).map_or(0, Vec::len) + } + + /// Total number of cached entries across all keys. + #[must_use] + pub fn total_entries(&self) -> usize { + self.entries.values().map(Vec::len).sum() + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + use std::time::Duration; + + fn peer(byte: u8) -> PeerId { + let mut bytes = [0u8; 32]; + bytes[0] = byte; + PeerId::from_bytes(bytes) + } + + fn key(byte: u8) -> XorName { + let mut k = [0u8; 32]; + k[0] = byte; + k + } + + fn hash(byte: u8) -> [u8; 32] { + [byte; 32] + } + + #[test] + fn empty_cache_credits_no_one() { + let cache = RecentProvers::new(); + assert!(!cache.is_credited_holder(&key(1), &peer(1), &hash(1))); + assert_eq!(cache.total_entries(), 0); + } + + #[test] + fn recorded_proof_credits_under_same_hash() { + let mut cache = RecentProvers::new(); + cache.record_proof(key(1), peer(7), hash(0xAB), Instant::now()); + assert!(cache.is_credited_holder(&key(1), &peer(7), &hash(0xAB))); + } + + #[test] + fn rotated_hash_loses_credit() { + // Core v12 §6 attack-bound property: a peer who proves K under + // C1 must re-prove under C2 to keep credit. The cache entry's + // hash binding enforces this. + let mut cache = RecentProvers::new(); + cache.record_proof(key(1), peer(7), hash(0xAB), Instant::now()); + // Same peer, same key, but the auditor's "current" hash for + // this peer is now different (peer gossiped a new commitment). + assert!(!cache.is_credited_holder(&key(1), &peer(7), &hash(0xCD))); + } + + #[test] + fn other_peer_under_same_hash_not_credited() { + let mut cache = RecentProvers::new(); + cache.record_proof(key(1), peer(7), hash(0xAB), Instant::now()); + assert!(!cache.is_credited_holder(&key(1), &peer(8), &hash(0xAB))); + } + + #[test] + fn per_key_cap_evicts_oldest() { + let mut cache = RecentProvers::new(); + let now = Instant::now(); + // Fill the bucket with MAX_PROVERS_PER_KEY + 1 distinct peers. + for i in 0..=MAX_PROVERS_PER_KEY { + let t = now + Duration::from_millis(i as u64); + cache.record_proof(key(1), peer(i as u8), hash(0xAB), t); + } + assert_eq!(cache.provers_for(&key(1)), MAX_PROVERS_PER_KEY); + // The oldest (peer 0) should be evicted; peer MAX should be present. + assert!(!cache.is_credited_holder(&key(1), &peer(0), &hash(0xAB))); + assert!(cache.is_credited_holder(&key(1), &peer(MAX_PROVERS_PER_KEY as u8), &hash(0xAB))); + } + + #[test] + fn refresh_in_place_does_not_grow_bucket() { + let mut cache = RecentProvers::new(); + let now = Instant::now(); + // Same (peer, hash) repeated three times. Bucket should stay at 1. + cache.record_proof(key(1), peer(1), hash(0xAB), now); + cache.record_proof(key(1), peer(1), hash(0xAB), now + Duration::from_secs(1)); + cache.record_proof(key(1), peer(1), hash(0xAB), now + Duration::from_secs(2)); + assert_eq!(cache.provers_for(&key(1)), 1); + } + + #[test] + fn forget_peer_drops_all_entries() { + let mut cache = RecentProvers::new(); + let now = Instant::now(); + cache.record_proof(key(1), peer(1), hash(0xAB), now); + cache.record_proof(key(2), peer(1), hash(0xAB), now); + cache.record_proof(key(1), peer(2), hash(0xAB), now); + assert_eq!(cache.total_entries(), 3); + + cache.forget_peer(&peer(1)); + assert_eq!(cache.total_entries(), 1); + assert!(!cache.is_credited_holder(&key(1), &peer(1), &hash(0xAB))); + assert!(cache.is_credited_holder(&key(1), &peer(2), &hash(0xAB))); + } + + #[test] + fn forget_commitment_drops_only_matching_entries() { + let mut cache = RecentProvers::new(); + let now = Instant::now(); + cache.record_proof(key(1), peer(1), hash(0xAB), now); + cache.record_proof(key(1), peer(1), hash(0xCD), now); + cache.record_proof(key(2), peer(2), hash(0xAB), now); + assert_eq!(cache.total_entries(), 3); + + cache.forget_commitment(&hash(0xAB)); + assert_eq!(cache.total_entries(), 1); + // Only the (peer(1), hash 0xCD) entry remains. + assert!(cache.is_credited_holder(&key(1), &peer(1), &hash(0xCD))); + assert!(!cache.is_credited_holder(&key(1), &peer(1), &hash(0xAB))); + assert!(!cache.is_credited_holder(&key(2), &peer(2), &hash(0xAB))); + } + + #[test] + fn lazy_rotation_via_unknown_commitment_hash_drops_credit() { + // Scenario from v12 §5 (revised UnknownCommitmentHash handler): + // 1. Peer P proves K under C1 → cached. + // 2. Auditor pinned to C1 sends a new challenge. + // 3. P replies UnknownCommitmentHash (they rotated and + // dropped the bytes). + // 4. Auditor invalidates last_commitment[P] AND calls + // forget_commitment(C1) so credit doesn't linger. + // + // Property checked: after forget_commitment(C1), P is no longer + // credited as holder of K under C1. + let mut cache = RecentProvers::new(); + cache.record_proof(key(1), peer(7), hash(0xAB), Instant::now()); + assert!(cache.is_credited_holder(&key(1), &peer(7), &hash(0xAB))); + + // Auditor detects rotation/dodge, invalidates the C1 hash. + cache.forget_commitment(&hash(0xAB)); + + assert!(!cache.is_credited_holder(&key(1), &peer(7), &hash(0xAB))); + // And under any new hash too — the peer has to re-prove. + assert!(!cache.is_credited_holder(&key(1), &peer(7), &hash(0xCD))); + } +} diff --git a/tests/e2e/replication.rs b/tests/e2e/replication.rs index 83fc792..778b533 100644 --- a/tests/e2e/replication.rs +++ b/tests/e2e/replication.rs @@ -394,6 +394,7 @@ async fn test_audit_challenge_returns_correct_digest() { nonce, challenged_peer_id: *peer_a.as_bytes(), keys: vec![address], + expected_commitment_hash: None, }; let msg = ReplicationMessage { request_id: 1234, @@ -444,6 +445,7 @@ async fn test_audit_absent_key_returns_sentinel() { nonce, challenged_peer_id: *peer_a.as_bytes(), keys: vec![missing_key], + expected_commitment_hash: None, }; let msg = ReplicationMessage { request_id: 5678, @@ -805,6 +807,7 @@ async fn test_neighbor_sync_request_returns_hints() { replica_hints: vec![], paid_hints: vec![], bootstrapping: false, + commitment: None, }; let msg = ReplicationMessage { request_id: 2000, @@ -866,6 +869,7 @@ async fn test_audit_challenge_multi_key() { nonce, challenged_peer_id: *peer_a.as_bytes(), keys: vec![a1, absent_key, a2], + expected_commitment_hash: None, }; let msg = ReplicationMessage { request_id: 3000, @@ -1254,6 +1258,7 @@ async fn scenario_14_sync_hints_cover_all_local_keys() { replica_hints: vec![], paid_hints: vec![], bootstrapping: false, + commitment: None, }; let msg = ReplicationMessage { request_id: 1400, @@ -1401,6 +1406,7 @@ async fn scenario_17_bidirectional_sync_when_sender_in_rt() { replica_hints: vec![inbound_hint], paid_hints: vec![], bootstrapping: false, + commitment: None, }; let msg = ReplicationMessage { request_id: 1700, diff --git a/tests/e2e/testnet.rs b/tests/e2e/testnet.rs index 14216be..7de1671 100644 --- a/tests/e2e/testnet.rs +++ b/tests/e2e/testnet.rs @@ -1244,11 +1244,22 @@ impl TestNetwork { let shutdown = CancellationToken::new(); let repl_config = ReplicationConfig::default(); let (_fresh_tx, fresh_rx) = tokio::sync::mpsc::unbounded_channel(); + let node_identity = match node.node_identity { + Some(ref id) => Arc::clone(id), + None => { + warn!( + "Node {} has no identity; skipping replication engine", + node.index + ); + return Ok(()); + } + }; match ReplicationEngine::new( repl_config, Arc::clone(p2p), protocol.storage(), protocol.payment_verifier_arc(), + node_identity, &node.data_dir, fresh_rx, shutdown.clone(), diff --git a/tests/poc_bootstrap_stall.rs b/tests/poc_bootstrap_stall.rs new file mode 100644 index 0000000..6364f71 --- /dev/null +++ b/tests/poc_bootstrap_stall.rs @@ -0,0 +1,265 @@ +//! Proof-of-concept regression test for the **bootstrap stall** attack +//! against the neighbour-sync admission / drain detector. +//! +//! ## The attack (no fix yet) +//! +//! While a node is bootstrapping, every inbound `NeighborSyncRequest` +//! whose admission overflows `MAX_PENDING_VERIFY_PER_PEER` (the per-peer +//! cap is the first to bite for any single peer) calls +//! `bootstrap::note_capacity_rejected(source)`. The drain check in +//! `bootstrap::check_bootstrap_drained` then refuses to complete +//! bootstrap while the set is non-empty: +//! +//! ```ignore +//! if !state.capacity_rejected_sources.is_empty() { +//! return false; // "not yet drained" +//! } +//! ``` +//! +//! The set entry for `source` is cleared only when **the same source** +//! later completes an admission cycle with zero rejections. A single +//! peer that keeps sending over-cap hints faster than the verification +//! queue drains never has a "clean cycle" — so it is **permanently** +//! in `capacity_rejected_sources`, and bootstrap **never completes**. +//! +//! ## Why this matters +//! +//! While `is_bootstrapping == true`: +//! - **Audits are paused** (`replication::audit::audit_tick` returns +//! `Idle` if `is_bootstrapping`, see `audit.rs` Invariant 19). A +//! victim stuck in bootstrap mode is effectively a node that does no +//! auditing — bad nodes around it accrue no trust penalties. +//! - Other replication invariants gated on `bootstrap_drained` (paid +//! list repair flow, prune confirmation paths) also stay off. +//! +//! A single Byzantine peer in the victim's routing table can therefore +//! disable the entire reputation system on that victim, for free, +//! using nothing but well-formed `NeighborSyncRequest` messages that +//! the victim's admission path accepts as legitimate. +//! +//! ## What this test proves +//! +//! Drives the in-process pieces (`ReplicationQueues`, `BootstrapState`, +//! `bootstrap::note_capacity_rejected` / +//! `bootstrap::check_bootstrap_drained`) end-to-end through the same +//! call sequence that the live replication loop runs when handling an +//! over-cap `NeighborSyncRequest`. With no fix this test passes — i.e. +//! it documents the buggy behaviour by asserting the victim never +//! drains. The fix (whatever shape it takes — per-source rate limits, +//! capacity-reject decay, trust-event escalation, ...) will need a +//! follow-up test asserting drain happens within a bounded number of +//! over-cap cycles. + +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::missing_panics_doc, + clippy::significant_drop_tightening +)] + +use std::collections::HashSet; +use std::sync::Arc; +use std::time::Instant; + +use tokio::sync::RwLock; + +use ant_node::replication::bootstrap::{ + check_bootstrap_drained, clear_capacity_rejected, note_capacity_rejected, +}; +use ant_node::replication::scheduling::{ + AdmissionResult, ReplicationQueues, MAX_PENDING_VERIFY_PER_PEER, +}; +use ant_node::replication::types::{ + BootstrapState, HintPipeline, VerificationEntry, VerificationState, +}; +use saorsa_core::identity::PeerId; + +fn peer(b: u8) -> PeerId { + let mut bytes = [0u8; 32]; + bytes[0] = b; + PeerId::from_bytes(bytes) +} + +fn entry(sender: PeerId) -> VerificationEntry { + VerificationEntry { + state: VerificationState::PendingVerify, + pipeline: HintPipeline::Replica, + verified_sources: Vec::new(), + tried_sources: HashSet::new(), + created_at: Instant::now(), + hint_sender: sender, + } +} + +fn unique_key(i: u32) -> [u8; 32] { + let mut k = [0u8; 32]; + k[..4].copy_from_slice(&i.to_le_bytes()); + k +} + +/// Simulates one inbound `NeighborSyncRequest` from `source` carrying +/// `hint_count` hints — returns the number of admissions that capacity- +/// rejected (i.e. what `AdmissionOutcome::capacity_rejected_count` would +/// be in the live loop), and as a side effect mutates `queues` and the +/// bootstrap-state in exactly the same way the live `admit_and_queue_hints` +/// followed by the bootstrap-drain accounting do. +async fn simulate_inbound_sync( + queues: &Arc>, + bootstrap_state: &Arc>, + source: PeerId, + key_offset: u32, + hint_count: u32, +) -> usize { + let mut capacity_rejected_count: usize = 0; + + { + let mut q = queues.write().await; + for i in 0..hint_count { + let result = q.add_pending_verify(unique_key(key_offset + i), entry(source)); + match result { + AdmissionResult::Admitted | AdmissionResult::AlreadyPresent => {} + AdmissionResult::CapacityRejected => { + capacity_rejected_count += 1; + } + } + } + } + + // Mirror replication/mod.rs:1391-1400: while bootstrapping, note or + // clear capacity rejection for this source based on the outcome. + if capacity_rejected_count > 0 { + note_capacity_rejected(bootstrap_state, source).await; + } else { + clear_capacity_rejected(bootstrap_state, &source).await; + } + + capacity_rejected_count +} + +/// **The attack.** A single peer keeps the victim's bootstrap permanently +/// undrained by always sending one more hint than the per-peer pending +/// quota can accept. The victim's `capacity_rejected_sources` set stays +/// non-empty forever, so `check_bootstrap_drained` never returns `true`. +/// +/// Pre-fix behaviour: this test passes (the attack succeeds — drain never +/// completes). The presence of this test is the regression marker. +/// +/// Post-fix behaviour: the fix MUST cause `check_bootstrap_drained` to +/// return `true` within a bounded number of cycles regardless of attacker +/// flood pattern. A follow-up test should assert that bound. +#[tokio::test] +async fn poc_bootstrap_stall_via_persistent_per_peer_overflow() { + let queues = Arc::new(RwLock::new(ReplicationQueues::new())); + let bootstrap_state = Arc::new(RwLock::new(BootstrapState::new())); + + let attacker = peer(0xAA); + + // Round 1: attacker sends per-peer-cap + 1 hints. The first + // MAX_PENDING_VERIFY_PER_PEER admit; the last over-cap one rejects. + // After this round, `capacity_rejected_sources` contains the attacker. + let mut next_key: u32 = 0; + #[allow(clippy::cast_possible_truncation)] + let flood = MAX_PENDING_VERIFY_PER_PEER as u32 + 1; + let rejected = + simulate_inbound_sync(&queues, &bootstrap_state, attacker, next_key, flood).await; + next_key += flood; + assert!( + rejected >= 1, + "round 1 must over-cap (got {rejected} rejections); test is mis-sized" + ); + + // Victim has nothing else outstanding: no other pending peer requests, + // no other pending keys discovered. The ONLY thing preventing drain + // is `capacity_rejected_sources` containing the attacker. + let drained_before_attack_continues = { + let q = queues.read().await; + check_bootstrap_drained(&bootstrap_state, &q).await + }; + assert!( + !drained_before_attack_continues, + "bootstrap must NOT drain while attacker has outstanding capacity-rejected hints" + ); + + // Round 2..N: attacker keeps sending one more over-cap hint each + // round. In the live loop, the victim's verification cycle would + // drain a few entries between rounds, but the attacker just sends + // more hints than fit. Here we simulate that pattern by NEVER + // draining queues between attacker rounds: this is the worst-case + // for the victim and matches an attacker who paces hints to keep + // pending_per_sender[attacker] always at the cap. + for round in 0..32 { + let r = simulate_inbound_sync(&queues, &bootstrap_state, attacker, next_key, 1).await; + next_key += 1; + // Each round must keep capacity-rejecting (per-peer cap still hit + // because we never freed slots for this sender). + assert!( + r >= 1, + "round {round}: attacker hint must continue to capacity-reject \ + (per-peer cap still full); got {r}" + ); + + let drained = { + let q = queues.read().await; + check_bootstrap_drained(&bootstrap_state, &q).await + }; + assert!( + !drained, + "round {round}: bootstrap drained despite attacker still capacity-rejecting" + ); + } + + // After 32 rounds (could be 32 million) the attacker is STILL in + // `capacity_rejected_sources`. The victim is permanently in + // bootstrap mode. This is the bug. + let state = bootstrap_state.read().await; + assert!( + state.capacity_rejected_sources.contains(&attacker), + "attacker peer is still in capacity_rejected_sources after the flood — \ + this is the documented stall: the victim has no mechanism to retire \ + the attacker without the attacker's cooperation (a 'clean' admission \ + cycle), so a hostile peer can stall bootstrap indefinitely" + ); + assert_eq!( + state.capacity_rejected_sources.len(), + 1, + "only the attacker is outstanding; honest peers are unaffected — \ + which is exactly what makes this a single-peer DoS" + ); +} + +/// Honest peers are unaffected: the per-source quota means a flood from +/// the attacker cannot starve an honest peer's hints. The honest peer's +/// "clean" cycle correctly clears its bootstrap entry. This test +/// confirms the per-source isolation that D1 already established — +/// included so a future fix doesn't accidentally break it. +#[tokio::test] +async fn honest_peer_drains_normally_alongside_attacker() { + let queues = Arc::new(RwLock::new(ReplicationQueues::new())); + let bootstrap_state = Arc::new(RwLock::new(BootstrapState::new())); + + let attacker = peer(0xAA); + let honest = peer(0x01); + + // Attacker over-caps. + #[allow(clippy::cast_possible_truncation)] + let flood = MAX_PENDING_VERIFY_PER_PEER as u32 + 1; + let r_atk = simulate_inbound_sync(&queues, &bootstrap_state, attacker, 0, flood).await; + assert!(r_atk >= 1); + + // Honest peer sends a small clean batch. + let r_honest = simulate_inbound_sync(&queues, &bootstrap_state, honest, flood + 100, 16).await; + assert_eq!( + r_honest, 0, + "honest peer's small batch must NOT capacity-reject — per-source quota isolates them" + ); + + let state = bootstrap_state.read().await; + assert!( + state.capacity_rejected_sources.contains(&attacker), + "attacker is outstanding" + ); + assert!( + !state.capacity_rejected_sources.contains(&honest), + "honest peer is NOT outstanding; its clean cycle cleared (or never created) its entry" + ); +} diff --git a/tests/poc_commitment_audit_attacks.rs b/tests/poc_commitment_audit_attacks.rs new file mode 100644 index 0000000..6085270 --- /dev/null +++ b/tests/poc_commitment_audit_attacks.rs @@ -0,0 +1,1063 @@ +//! Threat-model proof-of-concept tests for the v12 storage-bound audit +//! design (`notes/security-findings-2026-05-22/proposal-gossip-audit-v12.md`). +//! +//! Each test models a specific attack from the original Finding-1 and +//! Finding-2 reports (`notes/security-findings-2026-05-22/{01,02}-*.md`) +//! and asserts that the v12 mechanisms reject it. +//! +//! This file is the single canonical place to look for "does the +//! storage-bound audit actually close Findings 1 and 2?" — each `#[test]` +//! has a docstring linking the attack back to the original finding. +//! +//! Unit-level coverage of each gate in the verifier lives in +//! `src/replication/commitment_audit.rs` and `src/replication/ +//! commitment_state.rs`. This file composes those gates end-to-end. + +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::missing_panics_doc, + clippy::redundant_clone, + clippy::cast_possible_truncation, + clippy::doc_markdown, + clippy::needless_borrows_for_generic_args +)] + +use ant_node::replication::commitment::{ + commitment_hash, leaf_hash, sign_commitment, verify_commitment_signature, + CommitmentBoundResult, MerkleTree, StorageCommitment, +}; +use ant_node::replication::commitment_audit::{verify_commitment_bound_response, AuditVerifyError}; +use ant_node::replication::commitment_state::{ + build_commitment_bound_audit_response, BuiltCommitment, CommitmentBoundOutcome, + ResponderCommitmentState, +}; +use ant_node::replication::protocol::compute_audit_digest; +use ant_node::replication::recent_provers::RecentProvers; +use saorsa_core::identity::PeerId; +use saorsa_pqc::api::sig::{ml_dsa_65, MlDsaPublicKey, MlDsaSecretKey}; +use std::time::Instant; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +fn keypair() -> (MlDsaPublicKey, MlDsaSecretKey) { + ml_dsa_65().generate_keypair().unwrap() +} + +fn content(byte: u8) -> Vec { + (0..256u32).map(|i| (i as u8) ^ byte).collect() +} + +fn content_hash(byte: u8) -> [u8; 32] { + *blake3::hash(&content(byte)).as_bytes() +} + +fn key(byte: u8) -> [u8; 32] { + let mut k = [0u8; 32]; + k[0] = byte; + k +} + +fn peer_id(byte: u8) -> PeerId { + let mut bytes = [0u8; 32]; + bytes[0] = byte; + PeerId::from_bytes(bytes) +} + +struct Responder { + state: ResponderCommitmentState, + public_key: MlDsaPublicKey, + secret_key: MlDsaSecretKey, + peer_id_bytes: [u8; 32], +} + +impl Responder { + fn new(_peer_byte: u8) -> Self { + let (public_key, secret_key) = keypair(); + // Gate 2c requires peer_id == BLAKE3(public_key_bytes). The + // _peer_byte parameter is kept for source-compat with existing + // tests but is no longer respected — peer identity is derived + // from the actual pubkey, as in production (saorsa-core + // `peer_id_from_public_key`). + let peer_id_bytes = *blake3::hash(&public_key.to_bytes()).as_bytes(); + Self { + state: ResponderCommitmentState::new(), + public_key, + secret_key, + peer_id_bytes, + } + } + + /// Commit to the given set of (key, bytes_hash) entries and rotate + /// into `state.current`. + fn commit_to(&self, key_indices: &[u8]) { + let entries: Vec<_> = key_indices + .iter() + .map(|&i| (key(i), content_hash(i))) + .collect(); + let built = BuiltCommitment::build( + entries, + &self.peer_id_bytes, + &self.secret_key, + &self.public_key.to_bytes(), + ) + .unwrap(); + self.state.rotate(built); + } + + fn current_hash(&self) -> [u8; 32] { + self.state.current().unwrap().hash() + } + + fn build_response( + &self, + pinned_hash: &[u8; 32], + challenge_keys: &[[u8; 32]], + nonce: &[u8; 32], + ) -> CommitmentBoundOutcome { + build_commitment_bound_audit_response( + &self.state, + pinned_hash, + challenge_keys, + nonce, + &self.peer_id_bytes, + |k| { + // Responder serves whatever bytes it actually has, + // matched by key. + for byte in 0..=255u8 { + if &key(byte) == k { + return Some(content(byte)); + } + } + None + }, + ) + } +} + +/// Auditor verification — takes everything from the responder via the +/// `CommitmentBoundOutcome::Built` arm and runs the real auditor's +/// `verify_commitment_bound_response`. The responder's public key is now +/// embedded in the commitment itself, so no external `responder_public_key` +/// argument is needed. +fn auditor_verifies( + responder_peer_id_bytes: &[u8; 32], + pinned_hash: &[u8; 32], + challenge_keys: &[[u8; 32]], + nonce: &[u8; 32], + response_commitment: &StorageCommitment, + response_per_key: &[CommitmentBoundResult], + auditor_local_bytes: impl Fn(&[u8; 32]) -> Option>, +) -> Result<(), AuditVerifyError> { + verify_commitment_bound_response( + challenge_keys, + nonce, + responder_peer_id_bytes, + pinned_hash, + response_commitment, + response_per_key, + auditor_local_bytes, + ) +} + +// --------------------------------------------------------------------------- +// Finding 1: Audit not storage-bound (lazy-node attacks) +// --------------------------------------------------------------------------- + +/// Attack 1a (Finding 1, Path A): lazy node gossips a real commitment, +/// drops the bytes, fetches them on demand at audit time, and computes +/// the digest with its own peer ID + the fetched bytes. The PoC test +/// in commitment_audit.rs proves the auditor's pin closes the variant +/// where the lazy node tries to substitute a fresh commitment; this +/// test composes the full flow. +/// +/// Property: honest responder produces a response that the auditor +/// accepts. Then a lazy responder with a *different* commitment tries +/// to answer the same pin — auditor rejects. +#[test] +fn honest_responder_passes_audit_lazy_responder_fails() { + let nonce = [0xCD; 32]; + + // Honest: the responder gossiped this commitment, the auditor pinned + // its hash, and the responder still has all the bytes. + let honest = Responder::new(0xAB); + honest.commit_to(&[1, 2, 3, 4, 5, 6, 7, 8]); + let pinned_hash = honest.current_hash(); + let challenge_keys = vec![key(1), key(4), key(7)]; + + let CommitmentBoundOutcome::Built { + commitment, + per_key, + } = honest.build_response(&pinned_hash, &challenge_keys, &nonce) + else { + panic!("honest responder should produce Built"); + }; + + let auditor_local = |k: &[u8; 32]| -> Option> { + for byte in 1..=8u8 { + if &key(byte) == k { + return Some(content(byte)); + } + } + None + }; + + let result = auditor_verifies( + &honest.peer_id_bytes, + &pinned_hash, + &challenge_keys, + &nonce, + &commitment, + &per_key, + auditor_local, + ); + assert!(result.is_ok(), "honest path must pass: {result:?}"); + + // Lazy: a different responder (different key set) tries to answer + // the same pin. The pin won't match their commitment — the responder + // helper returns UnknownCommitmentHash before it even tries to + // build proofs. (Models the "lazy node has no commitment for this + // pinned hash" case.) + let lazy = Responder::new(0xAB); // same peer_id_bytes, different key (different commitment). + lazy.commit_to(&[9, 10, 11]); // covers different keys. + + let outcome = lazy.build_response(&pinned_hash, &challenge_keys, &nonce); + assert!( + matches!(outcome, CommitmentBoundOutcome::UnknownCommitmentHash), + "lazy responder with no matching commitment must return UnknownCommitmentHash, got {outcome:?}", + ); +} + +/// Attack 1b (Finding 1, Path B): lazy node fabricates a fresh +/// commitment and tries to substitute it into the response while the +/// auditor's pin is for an older commitment. The auditor's gate-2 +/// commitment-hash pin closes this directly. +/// +/// This is the core property: forging a commitment AFTER the auditor +/// pinned a different one cannot satisfy gate 2. +#[test] +fn fresh_commitment_substitution_rejected_by_pin() { + let nonce = [0xCD; 32]; + + let original = Responder::new(0xAB); + original.commit_to(&[1, 2, 3, 4, 5, 6, 7, 8]); + let pinned_hash = original.current_hash(); + + // Lazy node forges a NEW commitment over only the challenged keys + // (using all real bytes — they fetched on demand). The lazy node + // even uses the same peer_id_bytes as the original; the only + // difference is the key set, hence the new root, hence a different + // commitment_hash that won't match `pinned_hash`. + let lazy = Responder::new(0xAB); + lazy.commit_to(&[1]); + let lazy_hash = lazy.current_hash(); + assert_ne!(pinned_hash, lazy_hash); + + // Responder builds a response that *would* be valid against + // `lazy_hash`, then we feed it to the auditor pinned to + // `pinned_hash`. + let CommitmentBoundOutcome::Built { + commitment, + per_key, + } = lazy.build_response(&lazy_hash, &[key(1)], &nonce) + else { + panic!("lazy responder builds OK against its own hash"); + }; + + let auditor_local = |k: &[u8; 32]| -> Option> { + if k == &key(1) { + Some(content(1)) + } else { + None + } + }; + + let result = auditor_verifies( + &lazy.peer_id_bytes, + &pinned_hash, // <-- ORIGINAL pin, not the fresh hash + &[key(1)], + &nonce, + &commitment, + &per_key, + auditor_local, + ); + assert!( + matches!(result, Err(AuditVerifyError::CommitmentHashMismatch)), + "auditor pin must reject fresh-commitment substitution, got {result:?}", + ); +} + +/// Attack 1c (Finding 1, Path C): lazy node gossips a real commitment +/// over a *small* subset of keys, then claims it holds more via other +/// channels (e.g. replica hints) and earns rewards for keys it never +/// committed to. +/// +/// The §6 holder cache binds credit to (peer, current_commitment_hash, +/// key). A peer that didn't include K in its committed set cannot +/// successfully prove K — gate "key not in commitment" rejects. With +/// no proof, the cache never credits the peer for K. +#[test] +fn overclaim_via_partial_commitment_yields_no_holder_credit() { + let nonce = [0xCD; 32]; + + let lazy = Responder::new(0xAB); + // Lazy node only commits to key 1, but it really wanted credit for + // keys 1..=8. + lazy.commit_to(&[1]); + let pinned_hash = lazy.current_hash(); + + // The auditor challenges on a key the lazy node DIDN'T commit to. + let challenge_keys = [key(5)]; + let outcome = lazy.build_response(&pinned_hash, &challenge_keys, &nonce); + assert!( + matches!(outcome, CommitmentBoundOutcome::KeyNotInCommitment { .. }), + "lazy responder cannot prove a key it didn't commit to, got {outcome:?}", + ); + + // The auditor maps `KeyNotInCommitment` to a Rejected response — + // no successful proof, no `recent_provers` insertion, so the + // holder-cache predicate denies credit. + let cache = RecentProvers::new(); + // The auditor never calls record_proof for key 5 because the + // verification never succeeded. + assert!(!cache.is_credited_holder(&key(5), &peer_id(0xAB), &pinned_hash)); +} + +/// Attack 1d (Finding 1, Path D): lazy node tries to ROTATE its +/// commitment between the auditor's challenge issue and the response. +/// v6/v12 §4 retention guarantees the responder can answer audits +/// pinned to either current or previous, so a single rotation is +/// answerable. But after two rotations the original commitment is +/// gone — and the responder correctly returns UnknownCommitmentHash, +/// which under v12 §5 is conditionally interpreted by the auditor. +/// +/// This test pins the retention invariant: pin to commitment-N, then +/// rotate twice. The responder must NOT be able to answer (the old +/// commitment is contractually allowed to be dropped) AND the auditor +/// can detect this via the structural response. +#[test] +fn responder_drops_old_commitment_past_retention_window() { + let nonce = [0xCD; 32]; + + let responder = Responder::new(0xAB); + + // Commitment 1. + responder.commit_to(&[1, 2, 3]); + let h1 = responder.current_hash(); + + // Round-11 widened retention to 4 slots (covers ~4h with the 1h + // rotation cadence). Rotate 4 more times → h1 ages out. + for batch_size in 4..=8u8 { + let keys: Vec = (1..=batch_size).collect(); + responder.commit_to(&keys); + } + + let outcome = responder.build_response(&h1, &[key(1)], &nonce); + assert!( + matches!(outcome, CommitmentBoundOutcome::UnknownCommitmentHash), + "h1 must be unreachable after RETAINED_COMMITMENT_SLOTS rotations, got {outcome:?}", + ); +} + +/// Attack 1e (Finding 1): replay an old audit response. Since the +/// digest binds the per-challenge nonce, a fresh challenge with a new +/// nonce makes a stale response invalid. +#[test] +fn audit_response_replay_blocked_by_fresh_nonce() { + let original_nonce = [0xCD; 32]; + let fresh_nonce = [0xEF; 32]; + + let responder = Responder::new(0xAB); + responder.commit_to(&[1, 2, 3]); + let pinned_hash = responder.current_hash(); + + // Responder produces a valid response under the ORIGINAL nonce. + let CommitmentBoundOutcome::Built { + commitment, + per_key, + } = responder.build_response(&pinned_hash, &[key(1)], &original_nonce) + else { + panic!("build OK"); + }; + + let auditor_local = |k: &[u8; 32]| -> Option> { + if k == &key(1) { + Some(content(1)) + } else { + None + } + }; + + // Auditor's FRESH challenge has `fresh_nonce`. Replaying the OLD + // response (with `original_nonce`-derived digest) must fail. + let result = auditor_verifies( + &responder.peer_id_bytes, + &pinned_hash, + &[key(1)], + &fresh_nonce, // <-- different nonce + &commitment, + &per_key, + auditor_local, + ); + assert!( + matches!(result, Err(AuditVerifyError::DigestMismatch { .. })), + "replay must fail digest check under fresh nonce, got {result:?}", + ); +} + +// --------------------------------------------------------------------------- +// Finding 2 ingredients: bootstrap-claim shield foundation +// --------------------------------------------------------------------------- +// +// Finding 2 (bootstrap-claim audit shield) is closed in v12 §3+§6 by: +// - A peer that never gossipped a commitment has commitment_capable +// = false; auditor refuses to credit it as a holder. +// - The cache binds credit to (peer, current_commitment_hash, key), +// so a peer with no commitment has no current hash and credit is +// impossible. +// +// Full integration (the gossip emit + audit cadence trigger) lands in +// phase 3. Here we prove the *cache-side* property: no commitment hash +// ⇒ no credit. + +/// A peer with no recent commitment (never gossipped) cannot be +/// credited as a holder via the recent_provers cache. +#[test] +fn silent_peer_earns_no_credit() { + let cache = RecentProvers::new(); + // Even with a non-trivial key, peer, and hash, an empty cache + // means no credit. + assert!(!cache.is_credited_holder(&key(1), &peer_id(0xAB), &[0; 32])); +} + +/// A peer that rotated their commitment between proof and credit-check +/// loses credit (the v12 §6 hash-binding lever). The lazy-node "drop +/// bytes, gossip new commitment, hope auditor doesn't notice" attack +/// is closed here. +#[test] +fn rotated_commitment_drops_holder_credit() { + let mut cache = RecentProvers::new(); + let now = Instant::now(); + cache.record_proof(key(1), peer_id(7), [0xAB; 32], now); + assert!(cache.is_credited_holder(&key(1), &peer_id(7), &[0xAB; 32])); + // The auditor's view of "P's current commitment" has now changed + // (e.g. P gossipped a new commitment that the auditor stored). + // The old cache entry no longer matches; credit is denied. + assert!(!cache.is_credited_holder(&key(1), &peer_id(7), &[0xCD; 32])); +} + +// --------------------------------------------------------------------------- +// Wire-substitution / signature-forgery sanity +// --------------------------------------------------------------------------- + +/// A response carrying a commitment signed by the WRONG key (somebody +/// else's keypair) is rejected at the signature gate. +/// +/// Since the public key is now embedded in the commitment AND must hash +/// to sender_peer_id (gate 2c), isolating the signature gate is fiddly. +/// The construction here: swap the embedded pubkey to one whose +/// signature would NOT verify under the actual signed payload, AND +/// update peer_id to BLAKE3(swapped pubkey) so gate 2c passes, AND +/// re-pin the auditor + the challenged peer to the new identity. Then +/// gate 3 (signature) is the only remaining gate that can fail. +#[test] +fn wrong_signer_rejected_at_signature_gate() { + let nonce = [0xCD; 32]; + let (wrong_public_key, _) = keypair(); + let wrong_pk_bytes = wrong_public_key.to_bytes(); + let wrong_peer_id = *blake3::hash(&wrong_pk_bytes).as_bytes(); + + let responder = Responder::new(0xAB); + responder.commit_to(&[1, 2, 3]); + let pinned_hash = responder.current_hash(); + + let CommitmentBoundOutcome::Built { + commitment, + per_key, + } = responder.build_response(&pinned_hash, &[key(1)], &nonce) + else { + panic!("build OK"); + }; + + let auditor_local = |k: &[u8; 32]| -> Option> { + if k == &key(1) { + Some(content(1)) + } else { + None + } + }; + + // Swap both the embedded pubkey AND sender_peer_id so gate 2c + // passes; pin to the new commitment hash so gate 2b passes; then + // gate 3 is the only failure path because the signature was signed + // under responder.secret_key, not the wrong key. + let mut bad_commit = commitment.clone(); + bad_commit.sender_public_key = wrong_pk_bytes; + bad_commit.sender_peer_id = wrong_peer_id; + let new_pin = commitment_hash(&bad_commit).unwrap(); + + // Per-key digest also bound the original challenged_peer_id; rebuild + // it under the new wrong_peer_id so gate 4 (digest) wouldn't trip + // first. + let mut bad_per_key = per_key.clone(); + bad_per_key[0].digest = compute_audit_digest(&nonce, &wrong_peer_id, &key(1), &content(1)); + + let result = auditor_verifies( + &wrong_peer_id, // challenged peer == new (wrong) peer_id + &new_pin, + &[key(1)], + &nonce, + &bad_commit, + &bad_per_key, + auditor_local, + ); + assert!( + matches!(result, Err(AuditVerifyError::SignatureInvalid)), + "swapped embedded key must trip signature gate, got {result:?}", + ); +} + +/// Attack 1a' (Finding 1, Path A — the ACTUAL on-demand fetch under +/// the original pin): the lazy node retains its gossiped commitment +/// but dropped the bytes. At audit time the lazy node fetches the +/// bytes from honest neighbours and answers with a VALID proof against +/// its OWN original commitment (same pin, same root). The auditor +/// accepts. +/// +/// This is the "lazy node strictly dominated by economic cost" +/// property v12 admits: the pin defeats cross-commitment substitution +/// (covered by `fresh_commitment_substitution_rejected_by_pin` above) +/// but does NOT prevent a node that gossiped a real commitment from +/// answering audits via on-demand fetch. Closing this is bandwidth +/// economics (cost-per-audit > cost-of-storing), not cryptography. +/// +/// **Setup to make the attack structurally distinct from the honest +/// path**: the lazy responder's commitment is built from a fixed key +/// set at gossip time (it HAD bytes then, per the v12 protocol +/// invariant — you cannot compute leaf hashes without bytes). After +/// that, we build the audit response **bypassing the responder's own +/// `ResponderCommitmentState`** and instead **manually constructing +/// the per-key proof entries from an alternate bytes source** that +/// represents fetched-on-demand bytes from a neighbour. This is +/// observationally indistinguishable from honest storage from the +/// auditor's perspective — which is exactly the point. +/// +/// Pinning this test means: any future "we somehow close Path A +/// without bandwidth economics" claim must update this test to assert +/// the new defence (i.e. this test must FAIL after such a fix). +#[test] +fn on_demand_fetch_under_original_pin_succeeds_documenting_v12_limit() { + use ant_node::replication::commitment::leaf_hash; + let nonce = [0xCD; 32]; + + // Lazy node gossipped a commitment over its full claimed set at + // gossip time. The protocol invariant guarantees it had the bytes + // then (leaf_hash requires bytes_hash). + let lazy = Responder::new(0xAB); + lazy.commit_to(&[1, 2, 3, 4, 5, 6, 7, 8]); + let pinned_hash = lazy.current_hash(); + let challenge_keys = vec![key(3)]; + + // ATTACK MODEL: lazy node has DROPPED its local bytes for key 3. + // To audit, it must fetch from a "neighbour" — modeled as an + // alternate bytes source that the lazy node didn't have at + // challenge-receive time but obtains during the audit window. + // + // We construct the audit response by hand using the alternate + // bytes source. This bypasses Responder::build_response (which + // would use the lazy node's own bytes via the closure that always + // returns content(byte)) — making the fetched-vs-stored + // distinction observable in the test setup even though it's + // unobservable to the auditor on the wire. + let neighbour_fetched_bytes_for_key_3 = content(3); + + // Pull the lazy node's original commitment + proof structure for + // key 3 from its retained state. + let built = lazy.state.lookup_by_hash(&pinned_hash).expect("retained"); + let (path, leaf_index) = built.proof_for(&key(3)).expect("key in commitment"); + let bytes_hash = *blake3::hash(&neighbour_fetched_bytes_for_key_3).as_bytes(); + + // Confirm the bytes_hash from "fetched" bytes equals what the + // commitment leaf expects (since the commitment was honest at + // gossip time, the bytes_hash field is the SAME regardless of + // whether the bytes are local or fetched — that's the auditor's + // blind spot). + let expected_leaf = leaf_hash(&key(3), &bytes_hash); + let from_commitment = leaf_hash(&key(3), &content_hash(3)); + assert_eq!( + expected_leaf, from_commitment, + "fetched bytes produce the same leaf hash as locally-stored bytes (the v12 blind spot)" + ); + + let digest = ant_node::replication::protocol::compute_audit_digest( + &nonce, + &lazy.peer_id_bytes, + &key(3), + &neighbour_fetched_bytes_for_key_3, + ); + let per_key = vec![CommitmentBoundResult { + key: key(3), + digest, + bytes_hash, + leaf_index, + path, + }]; + + // Auditor verifies. It has its own copy of the bytes (only + // commitment-audits keys it holds, per v12). + let auditor_local = |k: &[u8; 32]| -> Option> { + if k == &key(3) { + Some(content(3)) + } else { + None + } + }; + let result = auditor_verifies( + &lazy.peer_id_bytes, + &pinned_hash, + &challenge_keys, + &nonce, + built.commitment(), + &per_key, + auditor_local, + ); + + // VERDICT: the audit PASSES. The lazy node sourced bytes from a + // neighbour (modeled by `neighbour_fetched_bytes_for_key_3` being + // a separate local that is then THROWN AWAY — the actual lazy node + // doesn't have those bytes after the audit ends). The verifier + // has no way to distinguish this from honest storage. Mick's + // design note in #02_network on 2026-05-21 explicitly anchors + // this: "harder to fight against when there are few chunks per + // node... the more chunks in an audit, the harder it will become + // to fetch them all on-demand within the time frame." Bandwidth + // economics is the lever, not the audit cryptography. + assert!( + result.is_ok(), + "on-demand-fetch attack with valid original commitment + alternate bytes source \ + passes the v12 verifier — this is by design. v12 is an economic, not \ + cryptographic, defence against Path A. result: {result:?}", + ); +} + +/// Attack 1f (Finding 1 — peer impersonation via cross-peer +/// commitment substitution): the lazy node lifts a signed commitment +/// from another peer P' (e.g. observed in gossip) and embeds it in +/// its own audit response, hoping the auditor verifies the signature +/// against P''s public key by mistake. Gate 2a (sender_peer_id == +/// challenged_peer_id) rejects this before any signature work. +#[test] +fn cross_peer_commitment_substitution_rejected_by_sender_id() { + let nonce = [0xCD; 32]; + + // Peer P with a real signed commitment. + let real_p = Responder::new(0xAA); + real_p.commit_to(&[1, 2, 3]); + let p_hash = real_p.current_hash(); + + // Auditor is challenging peer Q (different peer_id_bytes) but + // somehow has p_hash in its pin (modelling a mis-binding bug). + // Q's public key, P's signed commitment. + let q_peer_id_bytes = [0xCC; 32]; + + // Q builds a response that contains P's commitment (lifted from + // gossip). The path/digests/bytes happen to be valid for P's + // commitment over P's key 1. + let CommitmentBoundOutcome::Built { + commitment: stolen_commitment, + per_key, + } = real_p.build_response(&p_hash, &[key(1)], &nonce) + else { + panic!("real_p builds OK against its own pin"); + }; + + let auditor_local = |k: &[u8; 32]| -> Option> { + if k == &key(1) { + Some(content(1)) + } else { + None + } + }; + + // Auditor challenged Q but the response carries P's commitment. + // sender_peer_id in the commitment is P's (0xAA), not Q's (0xCC). + // Gate 2a rejects. + let result = auditor_verifies( + &q_peer_id_bytes, // challenged peer + &p_hash, + &[key(1)], + &nonce, + &stolen_commitment, // sender_peer_id = 0xAA, not 0xCC + &per_key, + auditor_local, + ); + assert!( + matches!(result, Err(AuditVerifyError::SenderPeerIdMismatch)), + "cross-peer substitution must trip gate 2a, got {result:?}", + ); +} + +/// Attack 1f': throwaway-key substitution. An adversary controls the +/// peer at peer_id P. They build a commitment, fill in P's peer_id, but +/// embed a *different* (throwaway) public key whose secret they hold. +/// The signature verifies under the throwaway key (gate 3). Without +/// gate 2c, the audit would accept this as a valid claim from P even +/// though the throwaway key has no relationship to P's identity. +/// +/// Gate 2c (peer_id == BLAKE3(embedded_pubkey)) rejects this. saorsa- +/// core derives PeerId from the public key bytes; any commitment whose +/// embedded pubkey doesn't match the claimed peer_id is malformed. +#[test] +fn throwaway_key_substitution_rejected_by_pubkey_binding() { + let nonce = [0xCD; 32]; + + // Adversary wants to impersonate peer P. Compute P's peer_id from a + // legitimate pubkey (which the adversary does NOT control). + let (p_pubkey, _) = keypair(); + let p_peer_id = *blake3::hash(&p_pubkey.to_bytes()).as_bytes(); + + // They build a fresh throwaway keypair and sign with it. + let (throwaway_pk, throwaway_sk) = keypair(); + let throwaway_pk_bytes = throwaway_pk.to_bytes(); + + // Build a commitment claiming P's peer_id but embedding the throwaway + // pubkey. Sign under the throwaway secret. The signature verifies + // under the embedded throwaway key. + let entries = vec![(key(1), content_hash(1))]; + let tree = MerkleTree::build(entries).unwrap(); + let root = tree.root(); + let path = tree.path_for(&key(1)).unwrap(); + let key_count = tree.key_count(); + let sig = sign_commitment( + &throwaway_sk, + &root, + key_count, + &p_peer_id, // P's peer_id (LIE) + &throwaway_pk_bytes, + ) + .unwrap(); + let bad_commit = StorageCommitment { + root, + key_count, + sender_peer_id: p_peer_id, + sender_public_key: throwaway_pk_bytes.clone(), + signature: sig, + }; + + let pin = commitment_hash(&bad_commit).unwrap(); + let per_key = vec![CommitmentBoundResult { + key: key(1), + digest: compute_audit_digest(&nonce, &p_peer_id, &key(1), &content(1)), + bytes_hash: content_hash(1), + leaf_index: 0, + path, + }]; + + let auditor_local = |k: &[u8; 32]| -> Option> { (k == &key(1)).then(|| content(1)) }; + + let result = auditor_verifies( + &p_peer_id, // challenged peer is P + &pin, + &[key(1)], + &nonce, + &bad_commit, + &per_key, + auditor_local, + ); + assert!( + matches!(result, Err(AuditVerifyError::SenderPeerIdMismatch)), + "throwaway-key attack must trip gate 2c, got {result:?}", + ); +} + +/// Attack 1g (overclaim, end-to-end via real audit flow): the lazy +/// node gossips a commitment over a small key set (just key 1), but +/// in a real network might claim more via replication hints. The +/// auditor's challenge on key 5 — which is NOT in the lazy node's +/// commitment — is correctly handled: the responder returns +/// `KeyNotInCommitment` (caller maps to `Rejected`), and the +/// auditor's holder cache predicate correctly denies credit because +/// no `record_proof` is ever issued for (peer, key 5, hash). +/// +/// This is stronger than the earlier vacuous version because it +/// composes the full responder helper + cache predicate. +#[test] +fn overclaim_via_partial_commitment_end_to_end_no_credit() { + let nonce = [0xCD; 32]; + + let lazy = Responder::new(0xAB); + lazy.commit_to(&[1]); // claims only key 1 + let pinned_hash = lazy.current_hash(); + + // Auditor challenges key 5 — not committed. + let outcome = lazy.build_response(&pinned_hash, &[key(5)], &nonce); + assert!( + matches!(outcome, CommitmentBoundOutcome::KeyNotInCommitment { .. }), + "responder must reject key not in commitment, got {outcome:?}", + ); + + // Simulate the auditor's flow: it receives Rejected + // (KeyNotInCommitment); does NOT record_proof; cache stays empty + // for (peer, key 5). The credit predicate correctly denies. + let mut cache = RecentProvers::new(); + // No record_proof call — that's the auditor's flow when it sees + // any non-successful outcome. + + // For contrast, prove the cache DOES credit when a successful + // proof IS recorded — so the predicate is meaningful, not + // trivially false. + cache.record_proof(key(1), peer_id(0xAB), pinned_hash, Instant::now()); + assert!( + cache.is_credited_holder(&key(1), &peer_id(0xAB), &pinned_hash), + "cache predicate is meaningful: successful proof yields credit" + ); + + // And the lazy node STILL has no credit for key 5 (because no + // proof was ever recorded for it). + assert!( + !cache.is_credited_holder(&key(5), &peer_id(0xAB), &pinned_hash), + "key 5 was never proved → no credit, despite a successful proof for key 1" + ); +} + +/// `forget_commitment` semantics primitive: the v12 §5 conditional +/// invalidation handler will live at a higher layer (phase 3: +/// auditor coordinator that owns `last_commitment` per peer). The +/// underlying primitive — drop cache entries pinned to a specific +/// hash without touching entries for other hashes — is the building +/// block. This test pins that primitive's contract. +#[test] +fn forget_commitment_only_drops_matching_hash() { + let mut cache = RecentProvers::new(); + let now = Instant::now(); + + // P proves K1 under C1, then K1 under C2 (modelling rotation), + // then K2 under C1. (Last is unusual but exercises the + // "different key same hash" case.) + cache.record_proof(key(1), peer_id(0xAB), [0xAA; 32], now); + cache.record_proof(key(1), peer_id(0xAB), [0xBB; 32], now); + cache.record_proof(key(2), peer_id(0xAB), [0xAA; 32], now); + + // Auditor invalidates C1 (e.g. received UnknownCommitmentHash + // for C1 from this peer). + cache.forget_commitment(&[0xAA; 32]); + + // C1 entries for both keys are gone. + assert!(!cache.is_credited_holder(&key(1), &peer_id(0xAB), &[0xAA; 32])); + assert!(!cache.is_credited_holder(&key(2), &peer_id(0xAB), &[0xAA; 32])); + // C2 entry survives. + assert!(cache.is_credited_holder(&key(1), &peer_id(0xAB), &[0xBB; 32])); +} + +/// Sanity: the four foundational hashes (leaf, node, commitment_hash, +/// signature) are independent — none of them alone is sufficient. +#[test] +fn each_gate_fires_independently() { + let nonce = [0xCD; 32]; + let responder = Responder::new(0xAB); + responder.commit_to(&[1, 2, 3, 4, 5, 6, 7, 8]); + let pinned_hash = responder.current_hash(); + + let CommitmentBoundOutcome::Built { + commitment, + per_key, + } = responder.build_response(&pinned_hash, &[key(1)], &nonce) + else { + panic!("build OK"); + }; + + let auditor_local = |k: &[u8; 32]| -> Option> { + for byte in 1..=8u8 { + if &key(byte) == k { + return Some(content(byte)); + } + } + None + }; + + // Baseline: valid. + let ok = auditor_verifies( + &responder.peer_id_bytes, + &pinned_hash, + &[key(1)], + &nonce, + &commitment, + &per_key, + &auditor_local, + ); + assert!(ok.is_ok()); + + // Tamper bytes_hash → BytesHashMismatch. + let mut bad = per_key.clone(); + bad[0].bytes_hash[0] ^= 1; + let r = auditor_verifies( + &responder.peer_id_bytes, + &pinned_hash, + &[key(1)], + &nonce, + &commitment, + &bad, + &auditor_local, + ); + assert!(matches!(r, Err(AuditVerifyError::BytesHashMismatch { .. }))); + + // Tamper path → PathInvalid. + let mut bad = per_key.clone(); + bad[0].path[0][0] ^= 1; + let r = auditor_verifies( + &responder.peer_id_bytes, + &pinned_hash, + &[key(1)], + &nonce, + &commitment, + &bad, + &auditor_local, + ); + assert!(matches!(r, Err(AuditVerifyError::PathInvalid { .. }))); + + // Tamper digest → DigestMismatch. + let mut bad = per_key.clone(); + bad[0].digest[0] ^= 1; + let r = auditor_verifies( + &responder.peer_id_bytes, + &pinned_hash, + &[key(1)], + &nonce, + &commitment, + &bad, + &auditor_local, + ); + assert!(matches!(r, Err(AuditVerifyError::DigestMismatch { .. }))); +} + +// --------------------------------------------------------------------------- +// Cross-check: documented v12 invariants +// --------------------------------------------------------------------------- + +/// The commitment-hash function is sensitive to every field. This +/// lemma underwrites every "pin doesn't match" test above. +#[test] +fn commitment_hash_is_field_sensitive() { + let (pk, sk) = keypair(); + let pk_bytes = pk.to_bytes(); + let sig = sign_commitment(&sk, &[0; 32], 1, &[0; 32], &pk_bytes).unwrap(); + let c1 = StorageCommitment { + root: [0; 32], + key_count: 1, + sender_peer_id: [0; 32], + sender_public_key: pk_bytes, + signature: sig, + }; + let h1 = commitment_hash(&c1).unwrap(); + + for mutate in 0..5u8 { + let mut c = c1.clone(); + match mutate { + 0 => c.root[0] ^= 1, + 1 => c.key_count += 1, + 2 => c.sender_peer_id[0] ^= 1, + 3 => c.signature[0] ^= 1, + 4 => c.sender_public_key[0] ^= 1, + _ => unreachable!(), + } + let h = commitment_hash(&c).unwrap(); + assert_ne!(h, h1, "mutation {mutate} should change commitment_hash"); + } +} + +/// The leaf hash binds (key, bytes_hash). Same key + different bytes → +/// different leaf → different root. +#[test] +fn leaf_hash_binds_key_and_bytes() { + let h1 = leaf_hash(&key(1), &content_hash(1)); + let h2 = leaf_hash(&key(1), &content_hash(2)); + let h3 = leaf_hash(&key(2), &content_hash(1)); + assert_ne!(h1, h2); + assert_ne!(h1, h3); + assert_ne!(h2, h3); +} + +/// The Merkle tree is deterministic per key set. +#[test] +fn merkle_tree_root_is_deterministic_per_key_set() { + let entries = vec![ + (key(1), content_hash(1)), + (key(2), content_hash(2)), + (key(3), content_hash(3)), + ]; + let r1 = MerkleTree::build(entries.clone()).unwrap().root(); + let r2 = MerkleTree::build(entries).unwrap().root(); + assert_eq!(r1, r2); +} + +/// The signature verifies under the right public key and only under +/// that key. +#[test] +fn signature_round_trips_correctly() { + let (pk1, sk1) = keypair(); + let (pk2, _sk2) = keypair(); + let pk1_bytes = pk1.to_bytes(); + let pk2_bytes = pk2.to_bytes(); + let sig = sign_commitment(&sk1, &[7; 32], 42, &[3; 32], &pk1_bytes).unwrap(); + let c = StorageCommitment { + root: [7; 32], + key_count: 42, + sender_peer_id: [3; 32], + sender_public_key: pk1_bytes, + signature: sig, + }; + // Verifies via the embedded pk1 key. + assert!(verify_commitment_signature(&c)); + // If we swap the embedded key to pk2 (keeping the signature signed by + // sk1), verification must fail because pk2 didn't sign this payload. + let mut c2 = c.clone(); + c2.sender_public_key = pk2_bytes; + assert!(!verify_commitment_signature(&c2)); +} + +// --------------------------------------------------------------------------- +// PeerCommitmentRecord: §2 step 5 sticky commitment_capable +// --------------------------------------------------------------------------- + +use ant_node::replication::commitment_state::PeerCommitmentRecord; + +/// §2 step 5: `commitment_capable` is set on the first verified gossip +/// ingest and never flips back to false. A peer that later evicts the +/// cached commitment (TTL / sybil cap / restart) retains capability +/// status so §6 + §3 still refuse credit and refuse legacy-fallback. +#[test] +fn commitment_capable_flag_is_sticky_across_eviction() { + let (pk, sk) = keypair(); + let pk_bytes = pk.to_bytes(); + let sig = sign_commitment(&sk, &[0; 32], 1, &[0; 32], &pk_bytes).unwrap(); + let commitment = StorageCommitment { + root: [0; 32], + key_count: 1, + sender_peer_id: [0; 32], + sender_public_key: pk_bytes, + signature: sig, + }; + + let mut rec = PeerCommitmentRecord::from_verified(commitment, Instant::now()); + assert!(rec.commitment_capable); + assert!(rec.last_commitment.is_some()); + + // Simulate TTL eviction / restart: drop the commitment but keep + // the record (this is what the engine should do — we don't have + // a public API yet, so we mutate directly). + rec.last_commitment = None; + // Sticky: capable flag stays true. + assert!(rec.commitment_capable); +} + +/// `capable_but_no_commitment` constructor: used when we evict the +/// cached commitment but want to remember the peer has spoken v12. +#[test] +fn capable_but_no_commitment_starts_capable() { + let rec = PeerCommitmentRecord::capable_but_no_commitment(Instant::now()); + assert!(rec.commitment_capable); + assert!(rec.last_commitment.is_none()); +}