diff --git a/Cargo.lock b/Cargo.lock index d158230a..489a3533 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -813,6 +813,7 @@ version = "0.14.3" dependencies = [ "alloy", "ant-protocol", + "bao", "blake3", "bytes", "chrono", @@ -1321,6 +1322,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "bao" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9125f93587b894c196d58b0a752ce2213552d0d483c98ee41530e38202a511" +dependencies = [ + "arrayvec", + "blake3", +] + [[package]] name = "base16ct" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index 4dc1d29e..053910fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -111,6 +111,7 @@ page_size = "0.6" # Protocol serialization postcard = { version = "1.1.3", features = ["use-std"] } +bao = "0.13.1" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/src/replication/config.rs b/src/replication/config.rs index e8acd44d..8fd51362 100644 --- a/src/replication/config.rs +++ b/src/replication/config.rs @@ -14,7 +14,7 @@ use std::time::Duration; use rand::Rng; -use crate::ant_protocol::{CLOSE_GROUP_SIZE, MAX_CHUNK_SIZE}; +use crate::ant_protocol::CLOSE_GROUP_SIZE; // --------------------------------------------------------------------------- // Static constants (compile-time reference profile) @@ -135,14 +135,14 @@ pub const MAX_CONCURRENT_REPLICATION_SENDS: usize = 3; /// challenge handlers are all spawned off the serial replication message loop so /// their disk reads don't stall replication. This caps how many run at once /// across the engine, restoring backpressure: a peer flooding audit challenges -/// cannot fan out unbounded `get_raw` reads or multi-MiB byte serves. When the -/// cap is hit, the challenge is dropped and the caller's audit-specific timeout -/// policy applies. The cap must therefore stay high enough for honest audit -/// traffic while still throttling flooders. +/// cannot fan out unbounded `get_raw` reads. When the cap is hit, the challenge +/// is dropped and the caller's audit-specific timeout policy applies. The cap +/// must therefore stay high enough for honest audit traffic while still +/// throttling flooders. /// Sized to cover a handful of concurrent honest auditors (the per-peer /// gossip-audit cooldown is 30 min, so genuine concurrent audits are few) while -/// bounding the byte round's worst-case resident bytes -/// (`N × MAX_BYTE_CHALLENGE_KEYS × MAX_CHUNK_SIZE`). +/// bounding the round-2 worst-case full-chunk disk reads +/// (`N × MAX_SLICE_OPENINGS` chunks read to build slice proofs). pub const MAX_CONCURRENT_AUDIT_RESPONSES: usize = 16; /// Maximum concurrent in-flight audit-responder tasks from any SINGLE peer. @@ -191,18 +191,15 @@ pub const AUDIT_TICK_INTERVAL_MAX: Duration = Duration::from_secs(AUDIT_TICK_INT /// for the round-1 proof, whose payload is hashes (KB-scale). const AUDIT_RESPONSE_FLOOR_SECS: u64 = 4; -/// Floor on the round-2 BYTE-challenge deadline. +/// Floor on the round-2 SLICE-challenge deadline. /// -/// Unlike round 1 (KB of hashes), the byte challenge ships up to -/// `MAX_BYTE_CHALLENGE_KEYS` full chunks (2 × 4 MiB = 8 MiB) back over the -/// wire, so the envelope must also cover a cold QUIC handshake, the -/// multi-MiB upload back to the auditor, and a busy honest peer's disk read. -/// The round-1 4 s floor is still sized for a hashes-only reply; round 2 needs -/// a larger base for the §4 byte-serving envelope. 5 s matches the -/// cross-continent-RTT + handshake + 8 MiB transfer budget while keeping a relay -/// that must fetch the bytes over a residential link outside it (the scaled -/// term adds the per-byte estimate on top). Mirrors main's more generous -/// byte-round base. +/// The round-2 reply is only a few KB per opening (a 1 KiB block plus two short +/// hash chains), but an honest responder still reads each opened chunk's full +/// bytes from disk to build its Bao slice and nonced opening, so the floor must +/// cover a cold QUIC handshake plus a busy honest peer's full-chunk disk read. +/// The round-1 4 s floor is sized for a hashes-only reply; round 2 keeps a +/// slightly larger 5 s base for the disk-read envelope, with the per-byte scaled +/// term (one full chunk per opening) added on top. const BYTE_AUDIT_RESPONSE_FLOOR_SECS: u64 = 5; /// Conservative honest-responder read throughput, in bytes per second. @@ -247,51 +244,43 @@ pub const PRUNE_HYSTERESIS_DURATION: Duration = Duration::from_secs(PRUNE_HYSTER /// Protocol identifier for replication operations. /// -/// Bumped to `v2` for the v12 storage-bound audit. That change extends the -/// wire types (`NeighborSyncRequest`/`Response` carry an optional trailing -/// `StorageCommitment`, and the gossip-triggered storage-commitment audit adds -/// the `SubtreeAuditChallenge`/`SubtreeAuditResponse` and `SubtreeByteChallenge`/ -/// `SubtreeByteResponse` messages). The bump is for SEMANTIC interop, not -/// decode failure: postcard tolerates the appended optional field (an old -/// decoder reads the fields it knows and ignores the trailer — pinned by the -/// `old_decoder_tolerates_new_neighbor_sync_*` tests in `protocol.rs`), but -/// tolerating bytes is not interoperating. A v1 node cannot decode the NEW -/// message variants at all (unknown enum discriminant) and never acts on a -/// piggybacked commitment, so mixed-version replication would half-function — -/// audit challenges unanswered, commitments silently dropped — and a v2 node -/// could read that silence as misbehaviour. Rather than reason about each -/// such case, we route v12 replication on a distinct protocol id: a node only -/// delivers messages whose topic matches its own id (see the topic check in -/// `mod.rs`), so v1 and v2 nodes simply do not exchange replication traffic -/// during a mixed-version window. This is the rollout-safe behaviour: no -/// half-interpreted exchange, no spurious eviction. Replication between -/// matched-version peers is unaffected. (DHT routing/lookups are a separate -/// protocol and continue to span both versions.) -pub const REPLICATION_PROTOCOL_ID: &str = "autonomi.ant.replication.v2"; +/// Bumped to `v3` for the V2-685 slice audit: round 1's `SubtreeLeaf` now +/// carries a `content_len` + nonced block-tree `nonced_root` instead of a flat +/// `nonced_hash`, and round 2 replaces `SubtreeByteChallenge`/`Response` (which +/// returned full chunk bytes) with `SubtreeSliceChallenge`/`Response` (which +/// return a few-KB Bao verified slice per opened block). Because `ChunkMessage` +/// and the replication envelope are postcard-encoded (non-self-describing), a +/// v2 node and a v3 node cannot interpret each other's audit messages — the leaf +/// layout and the round-2 semantics differ. As with the v1→v2 cutover, we route +/// the changed protocol on a distinct id: a node only delivers messages whose +/// topic matches its own id (see the topic check in `mod.rs`), so v2 and v3 +/// nodes simply do not exchange replication traffic during a mixed-version +/// window. With ~24 h auto-upgrade propagation that window is short, and the +/// behaviour is rollout-safe: no half-interpreted exchange, no spurious +/// eviction. Replication between matched-version peers is unaffected. (DHT +/// routing/lookups are a separate protocol and continue to span both versions.) +pub const REPLICATION_PROTOCOL_ID: &str = "autonomi.ant.replication.v3"; /// 10 MiB — maximum replication wire message size (accommodates hint batches). const REPLICATION_MESSAGE_SIZE_MIB: usize = 10; /// Maximum replication wire message size. pub const MAX_REPLICATION_MESSAGE_SIZE: usize = REPLICATION_MESSAGE_SIZE_MIB * 1024 * 1024; -/// Headroom reserved for the envelope (enum tags, ids, length prefixes) when -/// sizing a round-2 byte-challenge batch against the wire cap. -const BYTE_CHALLENGE_RESPONSE_HEADROOM: usize = 64 * 1024; - -/// Maximum keys per round-2 [`SubtreeByteChallenge`] (per-batch cap). +/// Maximum block openings per round-2 [`SubtreeSliceChallenge`]. /// -/// Sized so the WORST-CASE response (every requested chunk at -/// `MAX_CHUNK_SIZE`) still encodes under [`MAX_REPLICATION_MESSAGE_SIZE`]. -/// The auditor splits its spot-check sample into batches of this size (one -/// challenge per batch, same nonce/pin); the responder rejects any single -/// challenge requesting more. +/// Each opening is a Bao verified slice (a 1 KiB block plus O(log n) BLAKE3 +/// parent hashes) plus a nonced block-tree sibling chain — a few KB, so even +/// this many openings encode far under [`MAX_REPLICATION_MESSAGE_SIZE`] with no +/// batching. The auditor draws at most `BYTE_SPOTCHECK_MAX` openings (one block +/// per sampled leaf); this cap sits just above that with headroom, and the +/// responder rejects any challenge requesting more (a forged-auditor guard: each +/// opening forces one full chunk read to build its proof). /// -/// [`SubtreeByteChallenge`]: crate::replication::protocol::SubtreeByteChallenge -pub const MAX_BYTE_CHALLENGE_KEYS: usize = - (MAX_REPLICATION_MESSAGE_SIZE - BYTE_CHALLENGE_RESPONSE_HEADROOM) / MAX_CHUNK_SIZE; +/// [`SubtreeSliceChallenge`]: crate::replication::protocol::SubtreeSliceChallenge +pub const MAX_SLICE_OPENINGS: usize = 8; const _: () = assert!( - MAX_BYTE_CHALLENGE_KEYS >= 1, - "wire cap must fit at least one max-size chunk per byte-challenge response" + MAX_SLICE_OPENINGS >= 1, + "at least one block opening must be allowed per slice challenge" ); /// Rollout gate for ADR-0004 quote-arithmetic enforcement. @@ -701,20 +690,26 @@ impl ReplicationConfig { .saturating_add(Duration::from_millis(scaled_ms)) } - /// Deadline for the round-2 BYTE challenge serving `challenged_key_count` - /// full chunks back to the auditor. + /// Deadline for the round-2 SLICE challenge opening `openings` blocks. + /// + /// The reply itself is only a few KB per opening (a 1 KiB block plus two + /// short hash chains), but an honest responder still reads each opened + /// chunk's full bytes from disk to build its Bao slice and nonced opening. So + /// the deadline is sized to that honest full-chunk disk read (the same + /// per-byte scaling as [`Self::audit_response_timeout`], one full chunk per + /// opening), on the `BYTE_AUDIT_RESPONSE_FLOOR_SECS` floor to absorb the + /// handshake and a busy disk. /// - /// Same per-byte scaling as [`Self::audit_response_timeout`] (so a relay - /// that must fetch the bytes over a residential link still blows it), but on - /// a higher floor (`BYTE_AUDIT_RESPONSE_FLOOR_SECS`) because the reply - /// carries up to - /// `MAX_BYTE_CHALLENGE_KEYS × MAX_CHUNK_SIZE` of chunk data — handshake + - /// multi-MiB upload + a busy honest disk read do not fit the hashes-only - /// round-1 floor (the §4 finding). + /// Unlike the old full-byte round 2, security here does NOT rest on this + /// deadline being too tight for a relay to fetch bytes — the possession + /// guarantee is the round-1 `nonced_root` commitment (uncomputable without + /// all the bytes, under a fresh nonce), so the deadline can be generous + /// without weakening the audit. It exists only to bound how long the auditor + /// waits for an honest reply. #[must_use] - pub fn byte_audit_response_timeout(&self, challenged_key_count: usize) -> Duration { + pub fn slice_audit_response_timeout(&self, openings: usize) -> Duration { let scaled = self - .audit_response_timeout(challenged_key_count) + .audit_response_timeout(openings) .saturating_sub(self.audit_response_floor); Duration::from_secs(BYTE_AUDIT_RESPONSE_FLOOR_SECS).saturating_add(scaled) } @@ -808,13 +803,13 @@ mod tests { } #[test] - fn replication_protocol_id_is_v2() { - // The v12 storage-bound audit changes replication SEMANTICS. The - // protocol id MUST advance past v1 so v1 and v2 nodes never exchange - // replication traffic they can only half-interpret (rollout safety — - // see the const's doc). If this regresses to v1, mixed-version nodes + fn replication_protocol_id_is_v3() { + // The V2-685 slice audit changes round-1 leaf layout and round-2 + // semantics. The protocol id MUST advance to v3 so v2 and v3 nodes never + // exchange replication traffic they can only half-interpret (rollout + // safety — see the const's doc). If this regresses, mixed-version nodes // would talk past each other and risk spurious penalties. - assert_eq!(REPLICATION_PROTOCOL_ID, "autonomi.ant.replication.v2"); + assert_eq!(REPLICATION_PROTOCOL_ID, "autonomi.ant.replication.v3"); } #[test] diff --git a/src/replication/mod.rs b/src/replication/mod.rs index ada4ecbb..a7e59f69 100644 --- a/src/replication/mod.rs +++ b/src/replication/mod.rs @@ -30,6 +30,7 @@ pub mod pruning; pub mod quorum; pub mod recent_provers; pub mod scheduling; +pub mod slice; pub mod storage_commitment_audit; pub mod subtree; pub mod types; @@ -2251,14 +2252,15 @@ async fn handle_replication_message( }); Ok(()) } - ReplicationMessageBody::SubtreeByteChallenge(challenge) => { - // Round 2 of the storage audit (ADR-0002): serve the original bytes - // for the auditor's spot-check keys, or signal `Absent` for a - // committed key we can no longer produce. Reads chunk bytes from - // disk, so likewise spawned off the serial loop (§5) under the same - // flood-fair admission (codex#1 + codex-r2 A). + ReplicationMessageBody::SubtreeSliceChallenge(challenge) => { + // Round 2 of the storage audit (ADR-0002 / V2-685): open one 1 KiB + // block of each of the auditor's spot-check keys with a Bao verified + // slice + nonced block-tree opening, or signal `Absent` for a + // committed key we can no longer produce. Reads chunk bytes from disk + // to build the proofs, so likewise spawned off the serial loop (§5) + // under the same flood-fair admission (codex#1 + codex-r2 A). info!( - "Audit challenge received: kind=byte source={source} request_response={}", + "Audit challenge received: kind=slice source={source} request_response={}", rr_message_id.is_some(), ); let Some(guard) = @@ -2280,7 +2282,7 @@ async fn handle_replication_message( let rr_message_id = rr_message_id.map(ToOwned::to_owned); tokio::spawn(async move { let _guard = guard; // global permit + per-peer slot, held until done - let response = storage_commitment_audit::handle_subtree_byte_challenge( + let response = storage_commitment_audit::handle_subtree_slice_challenge( &challenge, &storage, p2p_node.peer_id(), @@ -2288,24 +2290,24 @@ async fn handle_replication_message( Some(&my_commitment_state), ) .await; - let response_kind = subtree_byte_response_kind(&response); + let response_kind = subtree_slice_response_kind(&response); let sent = send_replication_response_checked( &source, &p2p_node, request_id, - ReplicationMessageBody::SubtreeByteResponse(response), + ReplicationMessageBody::SubtreeSliceResponse(response), rr_message_id.as_deref(), ) .await; if sent { info!( - "Audit challenge reply sent: kind=byte response={response_kind} \ + "Audit challenge reply sent: kind=slice response={response_kind} \ source={source} request_response={}", rr_message_id.is_some(), ); } else { warn!( - "Audit challenge reply not sent: kind=byte response={response_kind} \ + "Audit challenge reply not sent: kind=slice response={response_kind} \ source={source} request_response={}", rr_message_id.is_some(), ); @@ -2355,7 +2357,7 @@ async fn handle_replication_message( | ReplicationMessageBody::FetchResponse(_) | ReplicationMessageBody::AuditResponse(_) | ReplicationMessageBody::SubtreeAuditResponse(_) - | ReplicationMessageBody::SubtreeByteResponse(_) + | ReplicationMessageBody::SubtreeSliceResponse(_) | ReplicationMessageBody::GetCommitmentByPinResponse(_) => Ok(()), } } @@ -2953,11 +2955,11 @@ fn subtree_audit_response_kind(response: &protocol::SubtreeAuditResponse) -> &'s } } -fn subtree_byte_response_kind(response: &protocol::SubtreeByteResponse) -> &'static str { +fn subtree_slice_response_kind(response: &protocol::SubtreeSliceResponse) -> &'static str { match response { - protocol::SubtreeByteResponse::Items { .. } => "items", - protocol::SubtreeByteResponse::Bootstrapping { .. } => "bootstrapping", - protocol::SubtreeByteResponse::Rejected { .. } => "rejected", + protocol::SubtreeSliceResponse::Items { .. } => "items", + protocol::SubtreeSliceResponse::Bootstrapping { .. } => "bootstrapping", + protocol::SubtreeSliceResponse::Rejected { .. } => "rejected", } } diff --git a/src/replication/protocol.rs b/src/replication/protocol.rs index 7a26c835..f18eb4db 100644 --- a/src/replication/protocol.rs +++ b/src/replication/protocol.rs @@ -121,10 +121,10 @@ pub enum ReplicationMessageBody { SubtreeAuditChallenge(SubtreeAuditChallenge), /// Response to a contiguous-subtree storage audit challenge (round 1). SubtreeAuditResponse(SubtreeAuditResponse), - /// Surprise byte challenge for the spot-checked leaves (round 2). - SubtreeByteChallenge(SubtreeByteChallenge), - /// Response carrying the requested chunks' original bytes (round 2). - SubtreeByteResponse(SubtreeByteResponse), + /// Surprise slice challenge for the spot-checked leaves (round 2). + SubtreeSliceChallenge(SubtreeSliceChallenge), + /// Response carrying verified slices for the opened blocks (round 2). + SubtreeSliceResponse(SubtreeSliceResponse), // === Commitment fetch by pin (ADR-0004) === // APPENDED at the end so postcard variant discriminants of all the @@ -434,11 +434,12 @@ pub enum SubtreeAuditResponse { /// the root from the proof, and requires it to equal the commitment /// root (structure). /// - /// The leaves carry only hashes (`bytes_hash`, `nonced_hash`), so this round + /// The leaves carry only hashes (`bytes_hash`, `nonced_root`), so this round /// proves the tree SHAPE is committed — not that the bytes are still held. /// Real possession is proven in **round 2**: the auditor picks a few of the - /// just-verified leaves and sends a [`SubtreeByteChallenge`] requesting their - /// original chunk bytes FROM the responder (see that type). + /// just-verified leaves and sends a [`SubtreeSliceChallenge`] opening one + /// random 1 KiB block of each against both the chunk address and the round-1 + /// `nonced_root` (see that type). Proof { /// The challenge this response answers. challenge_id: u64, @@ -497,52 +498,74 @@ pub enum RejectKind { Protocol, } -/// Round 2 of the storage audit (ADR-0002): the **surprise byte challenge**. +/// A single block the round-2 slice challenge opens: which committed key, and +/// which 1 KiB block within it. /// -/// After the auditor has structurally verified a [`SubtreeAuditResponse::Proof`] -/// it picks a small sample of that subtree's just-proven leaves with FRESH -/// randomness (chosen now, after the proof is committed — NOT derived from the -/// round-1 nonce, so the responder could not have predicted it at proof-build -/// time) and asks the responder to return the ORIGINAL chunk bytes for exactly -/// those keys. The auditor then checks each returned chunk against the committed -/// leaf: -/// - `BLAKE3(bytes) == leaf.bytes_hash` (the chunk's content address), AND -/// - `compute_audit_digest(nonce, peer, key, bytes) == leaf.nonced_hash`. +/// The `block_index` is drawn with FRESH auditor randomness after round 1 (never +/// nonce-derived), so the responder cannot have prepared only the opened block. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub struct SubtreeSliceOpening { + /// The committed key whose block is opened. + pub key: XorName, + /// The 1 KiB block index within the chunk, in `0..block_count(content_len)`. + pub block_index: u32, +} + +/// Round 2 of the storage audit (ADR-0002 / V2-685): the **surprise slice +/// challenge**. /// -/// This makes possession non-delegable to the auditor: the auditor needs to -/// hold NONE of the responder's chunks. A responder that committed to a chunk it -/// no longer holds cannot fabricate bytes that hash to the committed address (a -/// preimage break), so it is caught regardless of who audits it. +/// After structurally verifying a [`SubtreeAuditResponse::Proof`] the auditor +/// picks a small sample of the just-proven leaves with FRESH randomness (chosen +/// now, after the proof is committed — NOT derived from the round-1 nonce) and, +/// for each, a fresh-random 1 KiB block index. It asks the responder to open +/// exactly those blocks. For each opened block the responder returns a Bao +/// verified slice plus a nonced block-tree opening; the auditor checks, over the +/// same block bytes: +/// - the Bao slice against `leaf.bytes_hash` (the chunk's content address), AND +/// - the nonced opening against `leaf.nonced_root` (round-1 possession commit). +/// +/// This makes possession non-delegable *and* cheap to prove: the response is a +/// few KB, not up to two 4 MiB chunks. A responder that did not hold the bytes at +/// round-1 commit time cannot have committed a correct `nonced_root`, and cannot +/// fold an after-the-fact-fetched block to a foreign root without a preimage +/// break — so it is caught regardless of who audits it. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SubtreeByteChallenge { +pub struct SubtreeSliceChallenge { /// The same `challenge_id` as the round-1 [`SubtreeAuditChallenge`], so the /// responder/auditor correlate the two rounds. pub challenge_id: u64, - /// The same nonce as round 1 — needed for the freshness (`nonced_hash`) - /// check and to bind these bytes to this audit. + /// The same nonce as round 1 — binds each nonced block opening to this audit. pub nonce: [u8; 32], - /// The challenged peer ID (bound into each leaf's possession hash). + /// The challenged peer ID (bound into every nonced block leaf). pub challenged_peer_id: [u8; 32], /// The pinned commitment hash from round 1, so the responder resolves the - /// SAME tree it just proved and serves bytes only for keys it committed to. + /// SAME tree it just proved and opens blocks only for keys it committed to. pub expected_commitment_hash: [u8; 32], - /// The exact keys whose original bytes the responder must return. These are - /// the auditor's freshly-randomised spot-check sample of the round-1 subtree - /// (chosen after the proof was received; not nonce-derived). - pub keys: Vec, + /// The exact blocks to open: the auditor's freshly-randomised spot-check + /// sample of the round-1 subtree (chosen after the proof was received; not + /// nonce-derived), one block per sampled leaf. + pub openings: Vec, } -/// One requested chunk in a [`SubtreeByteResponse`]. +/// One opened block in a [`SubtreeSliceResponse`]. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub enum SubtreeByteItem { - /// The responder holds this committed key and returns its original bytes. +pub enum SubtreeSliceItem { + /// The responder holds this committed key and opens the requested block. Present { /// The requested key. key: XorName, - /// The original chunk bytes (the auditor re-hashes to verify). - bytes: Vec, + /// The 1 KiB block index that was opened. + block_index: u32, + /// Bao verified slice: the block bytes plus the BLAKE3 parent hashes that + /// authenticate them against the chunk address. The auditor decodes this + /// to recover the verified block bytes. + bao_slice: Vec, + /// Sibling hashes on the path from this block up to the committed + /// `nonced_root`, bottom-up. The auditor folds the block leaf with these + /// to prove the block was committed under round 1's fresh nonce. + nonced_siblings: Vec<[u8; 32]>, }, - /// The responder committed to this key but cannot serve its bytes. This is a + /// The responder committed to this key but cannot serve its block. This is a /// PROVABLE cheat (it published a commitment over a chunk it does not hold), /// so the auditor counts it as a confirmed failure — NOT a graced timeout. /// Distinguishing this explicit signal from silence is what separates a @@ -553,29 +576,29 @@ pub enum SubtreeByteItem { }, } -/// Response to a [`SubtreeByteChallenge`] (round 2). One item per requested key, -/// in the requested order. +/// Response to a [`SubtreeSliceChallenge`] (round 2). One item per requested +/// opening, in the requested order. /// -/// Sizing rule: a challenge carries at most -/// [`MAX_BYTE_CHALLENGE_KEYS`](super::config::MAX_BYTE_CHALLENGE_KEYS) keys — -/// the auditor batches its sample, the responder rejects larger requests — so -/// the WORST-CASE `Items` response (every chunk at `MAX_CHUNK_SIZE`) always -/// encodes under [`MAX_REPLICATION_MESSAGE_SIZE`]. +/// Each item is a few KB (a 1 KiB block plus O(log n) hashes on two short +/// chains), so even the worst-case sample fits far under +/// [`MAX_REPLICATION_MESSAGE_SIZE`] with no batching — the auditor bounds the +/// sample to [`MAX_SLICE_OPENINGS`](super::config::MAX_SLICE_OPENINGS) and the +/// responder rejects larger requests. #[derive(Debug, Clone, Serialize, Deserialize)] -pub enum SubtreeByteResponse { - /// The responder's per-key answers (bytes or an explicit absent signal). +pub enum SubtreeSliceResponse { + /// The responder's per-opening answers (a verified slice or an absent signal). Items { /// The challenge this response answers. challenge_id: u64, - /// One entry per requested key. - items: Vec, + /// One entry per requested opening. + items: Vec, }, /// Peer is still bootstrapping (should not happen mid-audit, but handled). Bootstrapping { /// The challenge this response answers. challenge_id: u64, }, - /// The responder rejects the byte challenge outright. `kind` drives the + /// The responder rejects the slice challenge outright. `kind` drives the /// auditor's accounting (ADR-0004 A1: grace removed): [`RejectKind::Transient`] /// routes to the timeout lane (no trust penalty, holder credit revoked); every /// other kind is a confirmed failure, like round 1. @@ -663,30 +686,36 @@ impl std::error::Error for ReplicationProtocolError {} mod tests { use super::*; - // === Round-2 byte response sizing === + // === Round-2 slice response sizing === #[test] - fn max_batch_worst_case_byte_response_fits_wire_cap() { - // The auditor batches its round-2 sample to MAX_BYTE_CHALLENGE_KEYS per - // challenge precisely so this worst case — every requested chunk at - // MAX_CHUNK_SIZE — still encodes. If this fails, honest responders - // would hit encode errors and fail otherwise valid byte challenges. - let items: Vec = (0..crate::replication::config::MAX_BYTE_CHALLENGE_KEYS) - .map(|i| SubtreeByteItem::Present { + fn max_slice_response_is_tiny_relative_to_wire_cap() { + // A worst-case round-2 slice response is MAX_SLICE_OPENINGS openings, each + // a 1 KiB block plus a Bao proof and a nonced sibling chain. For a 4 MiB + // chunk that is ~4096 BLAKE3 chunks → ~12 parent hashes per chain. We + // overestimate generously (16 KiB slice + 24 sibling hashes per opening) + // and assert it encodes far under the wire cap — the whole point of the + // change is that round 2 is now KB-scale, not up to 8 MiB. + let items: Vec = (0..crate::replication::config::MAX_SLICE_OPENINGS) + .map(|i| SubtreeSliceItem::Present { key: [u8::try_from(i).unwrap_or(u8::MAX); 32], - bytes: vec![0xAB; crate::ant_protocol::MAX_CHUNK_SIZE], + block_index: u32::try_from(i).unwrap_or(u32::MAX), + bao_slice: vec![0xAB; 16 * 1024], + nonced_siblings: vec![[0x5A; 32]; 24], }) .collect(); let msg = ReplicationMessage { request_id: 7, - body: ReplicationMessageBody::SubtreeByteResponse(SubtreeByteResponse::Items { + body: ReplicationMessageBody::SubtreeSliceResponse(SubtreeSliceResponse::Items { challenge_id: 7, items, }), }; let encoded = msg .encode() - .expect("worst-case max-batch byte response must fit the wire cap"); + .expect("worst-case slice response must fit the wire cap"); + // Comfortably under 1 MiB, itself a fraction of the 10 MiB wire cap. + assert!(encoded.len() <= 1024 * 1024); assert!(encoded.len() <= MAX_REPLICATION_MESSAGE_SIZE); } diff --git a/src/replication/slice.rs b/src/replication/slice.rs new file mode 100644 index 00000000..7fccea7c --- /dev/null +++ b/src/replication/slice.rs @@ -0,0 +1,500 @@ +//! BLAKE3 verified-slice possession proofs for the storage-commitment audit +//! (ADR-0002 round 2, V2-685). +//! +//! The audit's round 2 used to make a challenged peer return the **complete +//! original bytes** of the sampled chunks (up to two 4 MiB chunks per response), +//! which turned proof-of-storage into the fleet's second-largest bandwidth cost +//! (~7.5 TB/day). This module replaces that with a two-chain verified slice: the +//! response for one opened 1 KiB block is a few KB instead of a full chunk, a +//! ~1000× reduction, while proving *more* than the old flat check. +//! +//! ## Why two chains +//! +//! A chunk's address is `BLAKE3(content)` (its content hash), and BLAKE3 is +//! internally a Merkle tree over 1 KiB blocks, so a **Bao verified slice** proves +//! that a given block is the real content at a given offset *against the address +//! the auditor already knows* — content authenticity, for free, no full chunk. +//! But authenticity alone is checkable from purely public data (the address is +//! public), so a node that stores nothing could pass it by fetching the block on +//! demand from an honest holder. To bind **possession at commitment time** the +//! responder also commits, per leaf in round 1, a fresh **nonced block tree**: +//! a Merkle root over the same 1 KiB blocks whose leaves are +//! `BLAKE3(nonce ‖ peer ‖ key ‖ block_index ‖ block_len ‖ block_bytes)`. +//! +//! Because the fresh per-audit nonce enters **every** block leaf, there is no +//! nonce-independent state a responder can precompute across audits (this closes +//! the BLAKE3-chaining-value preprocessing gap the old flat `nonced_hash` left +//! open, where only the first BLAKE3 chunk saw the nonce). Building the correct +//! `nonced_root` therefore requires *all* of a chunk's bytes at round-1 commit +//! time, and the auditor picks which block to open with fresh randomness *after* +//! the roots are committed (cut-and-choose): a responder cannot connect a real, +//! after-the-fact-fetched block to a garbage committed root without a preimage +//! break, and cannot commit a correct root without holding the bytes. +//! +//! In round 2 the auditor verifies both chains over the **same** block bytes: +//! the Bao chain against the address (authenticity) and the nonced chain against +//! the round-1 `nonced_root` (possession). Either failing is a confirmed cheat. + +use std::io::{Cursor, Read}; + +use crate::ant_protocol::XorName; + +/// Block size for slice audits: one BLAKE3 chunk (1 KiB). A block is the unit +/// both the Bao authenticity proof and the nonced possession tree open on. +/// +/// Matching BLAKE3's internal 1 KiB chunk means a single opened block maps to a +/// single BLAKE3 leaf, so the Bao proof for one block is minimal. +pub const AUDIT_BLOCK_SIZE: u64 = 1024; + +/// Domain tag for a nonced block-tree leaf. Distinct from every other hash in +/// the protocol so a leaf can never be reinterpreted as a node or a commitment. +const DOMAIN_BLOCK_LEAF: &[u8] = b"autonomi.ant.audit.slice.block-leaf.v1"; + +/// Domain tag for a nonced block-tree internal node. +const DOMAIN_BLOCK_NODE: &[u8] = b"autonomi.ant.audit.slice.block-node.v1"; + +/// Number of 1 KiB blocks covering `content_len` bytes. +/// +/// Always at least 1 (an empty chunk is one empty block) so every committed key +/// opens at least one block, and the block index the auditor draws is always in +/// range. +#[must_use] +pub fn block_count(content_len: u64) -> u32 { + if content_len == 0 { + return 1; + } + u32::try_from(content_len.div_ceil(AUDIT_BLOCK_SIZE)).unwrap_or(u32::MAX) +} + +/// Byte range `[start, end)` of block `index` within `content_len` bytes. +/// +/// The final block may be short; an out-of-range index clamps to an empty range +/// at `content_len` (callers never pass one — the auditor draws indices in +/// `0..block_count`). +#[must_use] +pub fn block_range(content_len: u64, index: u32) -> (u64, u64) { + let start = u64::from(index) + .saturating_mul(AUDIT_BLOCK_SIZE) + .min(content_len); + let end = start.saturating_add(AUDIT_BLOCK_SIZE).min(content_len); + (start, end) +} + +/// Slice a block's bytes out of a full chunk. Returns an empty slice for an +/// out-of-range index (never happens for auditor-drawn indices). +#[must_use] +fn block_bytes(content: &[u8], index: u32) -> &[u8] { + let (start, end) = block_range(content.len() as u64, index); + let start = usize::try_from(start).unwrap_or(usize::MAX); + let end = usize::try_from(end).unwrap_or(usize::MAX); + content.get(start..end).unwrap_or(&[]) +} + +/// Nonced block-leaf hash: binds the fresh nonce, challenged peer, key, block +/// index, block length and block bytes. +/// +/// The nonce enters here, at every block, which is the whole point: no part of +/// the tree can be precomputed before the audit's nonce is known. +#[must_use] +fn nonced_block_leaf( + nonce: &[u8; 32], + peer: &[u8; 32], + key: &XorName, + index: u32, + block: &[u8], +) -> [u8; 32] { + let mut h = blake3::Hasher::new(); + h.update(DOMAIN_BLOCK_LEAF); + h.update(nonce); + h.update(peer); + h.update(key); + h.update(&index.to_le_bytes()); + let block_len = u32::try_from(block.len()).unwrap_or(u32::MAX); + h.update(&block_len.to_le_bytes()); + h.update(block); + *h.finalize().as_bytes() +} + +/// Combine two child hashes into a nonced block-tree internal node. +#[must_use] +fn nonced_block_node(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] { + let mut h = blake3::Hasher::new(); + h.update(DOMAIN_BLOCK_NODE); + h.update(left); + h.update(right); + *h.finalize().as_bytes() +} + +/// Fold one level of a left-packed Merkle tree, self-pairing an unpaired last +/// node (`node(x, x)`) exactly like the commitment tree. +#[must_use] +fn fold_level(level: &[[u8; 32]]) -> Vec<[u8; 32]> { + let mut next = Vec::with_capacity(level.len().div_ceil(2)); + let mut i = 0; + while i < level.len() { + let left = level.get(i).copied().unwrap_or([0u8; 32]); + // Self-pair the last node when the level has an odd length. + let right = level.get(i + 1).copied().unwrap_or(left); + next.push(nonced_block_node(&left, &right)); + i += 2; + } + next +} + +/// The nonced block-tree leaves for a chunk's `content`, in block order. +#[must_use] +fn nonced_leaves( + nonce: &[u8; 32], + peer: &[u8; 32], + key: &XorName, + content: &[u8], +) -> Vec<[u8; 32]> { + let count = block_count(content.len() as u64); + (0..count) + .map(|i| nonced_block_leaf(nonce, peer, key, i, block_bytes(content, i))) + .collect() +} + +/// Compute the nonced block-tree root over a chunk's `content` (responder, round +/// 1). Requires every byte of the chunk, under the fresh nonce. +#[must_use] +pub fn nonced_block_root( + nonce: &[u8; 32], + peer: &[u8; 32], + key: &XorName, + content: &[u8], +) -> [u8; 32] { + let mut level = nonced_leaves(nonce, peer, key, content); + // A single leaf is its own root (matches the commitment tree's convention). + while level.len() > 1 { + level = fold_level(&level); + } + level.first().copied().unwrap_or([0u8; 32]) +} + +/// Sibling hashes on the path from block `index` up to the nonced root, +/// bottom-up (leaf level first). `None` if `index` is out of range for the tree. +/// +/// The verifier folds the recomputed leaf with these siblings using node-index +/// parity, so the sibling ordering is positional, not left/right-tagged. +#[must_use] +pub fn nonced_block_siblings( + nonce: &[u8; 32], + peer: &[u8; 32], + key: &XorName, + content: &[u8], + index: u32, +) -> Option> { + let mut level = nonced_leaves(nonce, peer, key, content); + if usize::try_from(index).ok()? >= level.len() { + return None; + } + let mut node_index = index as usize; + let mut siblings = Vec::new(); + while level.len() > 1 { + // Sibling is the other child of this node's parent; the last node of an + // odd level self-pairs, so its sibling is itself. + let sibling_index = node_index ^ 1; + let sibling = level + .get(sibling_index) + .or_else(|| level.get(node_index)) + .copied() + .unwrap_or([0u8; 32]); + siblings.push(sibling); + node_index /= 2; + level = fold_level(&level); + } + Some(siblings) +} + +/// Verify a nonced block opening (auditor, round 2): recompute the block leaf +/// from the served bytes and fold it with `siblings` to the committed +/// `nonced_root`. +/// +/// `block` must be the Bao-verified block bytes for `index`, so this proves the +/// responder committed a nonced root over the *real* content at round-1 time. +#[must_use] +pub fn verify_nonced_block( + nonce: &[u8; 32], + peer: &[u8; 32], + key: &XorName, + index: u32, + block: &[u8], + siblings: &[[u8; 32]], + nonced_root: &[u8; 32], +) -> bool { + let mut node_index = index as usize; + let mut cur = nonced_block_leaf(nonce, peer, key, index, block); + for sibling in siblings { + cur = if node_index % 2 == 0 { + nonced_block_node(&cur, sibling) + } else { + nonced_block_node(sibling, &cur) + }; + node_index /= 2; + } + &cur == nonced_root +} + +/// Extract a Bao verified slice for block `index` of `content` (responder, round +/// 2). The slice carries the block bytes plus the O(log n) BLAKE3 parent hashes +/// that verify it against the chunk address. +/// +/// # Errors +/// +/// Returns the underlying IO error only if the in-memory Bao extraction fails, +/// which cannot happen for a well-formed in-memory chunk; surfaced as a +/// `Result` rather than a panic so the responder degrades to a rejection. +pub fn extract_block_slice(content: &[u8], index: u32) -> std::io::Result> { + let (start, end) = block_range(content.len() as u64, index); + let len = end - start; + // The outboard carries the BLAKE3 tree hashes separately from the content, so + // the extractor reads the real chunk bytes plus just the parent hashes on the + // block's path — no need to materialise a full Bao encoding of the chunk. + let (outboard, _hash) = bao::encode::outboard(content); + let mut extractor = bao::encode::SliceExtractor::new_outboard( + Cursor::new(content.to_vec()), + Cursor::new(outboard), + start, + len, + ); + let mut slice = Vec::new(); + extractor.read_to_end(&mut slice)?; + Ok(slice) +} + +/// Verify a Bao slice for block `index` against the chunk `address` +/// (`BLAKE3(content)`), returning the verified block bytes (auditor, round 2). +/// +/// `content_len` is the responder's round-1 claim; a lie there makes the block +/// range disagree with the address-committed tree shape, so the decode fails — +/// a lying responder can only fail its own audit, never forge a pass. +#[must_use] +pub fn verify_block_slice( + slice: &[u8], + address: &[u8; 32], + content_len: u64, + index: u32, +) -> Option> { + let (start, end) = block_range(content_len, index); + let len = end - start; + let hash = blake3::Hash::from_bytes(*address); + let mut decoder = bao::decode::SliceDecoder::new(Cursor::new(slice), &hash, start, len); + let mut verified = Vec::new(); + decoder.read_to_end(&mut verified).ok()?; + if verified.len() as u64 != len { + return None; + } + Some(verified) +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::cast_possible_truncation +)] +mod tests { + use super::*; + + const NONCE: [u8; 32] = [0x11; 32]; + const PEER: [u8; 32] = [0x22; 32]; + const KEY: XorName = [0x33; 32]; + + /// Deterministic pseudo-content of a given length (avoids RNG in tests). + fn content_of(len: usize) -> Vec { + (0..len).map(|i| (i * 31 + 7) as u8).collect() + } + + // -- block geometry ----------------------------------------------------- + + #[test] + fn block_count_is_ceil_with_empty_floor() { + assert_eq!(block_count(0), 1); + assert_eq!(block_count(1), 1); + assert_eq!(block_count(1024), 1); + assert_eq!(block_count(1025), 2); + assert_eq!(block_count(4096), 4); + assert_eq!(block_count(4 * 1024 * 1024), 4096); + assert_eq!(block_count(4 * 1024 * 1024 - 1), 4096); + } + + #[test] + fn block_range_covers_content_without_gaps_or_overlap() { + let len = 1024 * 3 + 500; + let count = block_count(len); + let mut expected_start = 0u64; + for i in 0..count { + let (s, e) = block_range(len, i); + assert_eq!(s, expected_start); + assert!(e <= len); + assert!(e > s || len == 0); + expected_start = e; + } + assert_eq!(expected_start, len); + } + + // -- bao slice == blake3 address --------------------------------------- + + #[test] + fn bao_root_equals_blake3_address_across_lengths() { + // The whole design rests on Bao's root being the chunk's BLAKE3 address. + for len in [0usize, 1, 1023, 1024, 1025, 2048, 4096, 10_000, 1 << 20] { + let content = content_of(len); + let (_outboard, bao_hash) = bao::encode::outboard(&content); + let blake = blake3::hash(&content); + assert_eq!( + bao_hash.as_bytes(), + blake.as_bytes(), + "bao root must equal blake3(content) at len {len}" + ); + } + } + + #[test] + fn slice_roundtrip_verifies_every_block() { + for len in [1usize, 1024, 1025, 4096, 5000, 1 << 16] { + let content = content_of(len); + let address = *blake3::hash(&content).as_bytes(); + let count = block_count(len as u64); + for i in 0..count { + let slice = extract_block_slice(&content, i).expect("extract"); + let verified = + verify_block_slice(&slice, &address, len as u64, i).expect("verify slice"); + let (s, e) = block_range(len as u64, i); + assert_eq!(verified.as_slice(), &content[s as usize..e as usize]); + } + } + } + + #[test] + fn slice_against_wrong_address_fails() { + let content = content_of(4096); + let mut wrong = *blake3::hash(&content).as_bytes(); + wrong[0] ^= 0x01; + let slice = extract_block_slice(&content, 2).expect("extract"); + assert!(verify_block_slice(&slice, &wrong, 4096, 2).is_none()); + } + + #[test] + fn tampered_slice_bytes_fail_verification() { + let content = content_of(4096); + let address = *blake3::hash(&content).as_bytes(); + let mut slice = extract_block_slice(&content, 1).expect("extract"); + // Corrupt the payload bytes near the end of the slice (a data byte). + if let Some(b) = slice.last_mut() { + *b ^= 0xFF; + } + assert!(verify_block_slice(&slice, &address, 4096, 1).is_none()); + } + + #[test] + fn slice_for_one_block_cannot_serve_a_different_block() { + let content = content_of(4096); + let address = *blake3::hash(&content).as_bytes(); + let slice = extract_block_slice(&content, 0).expect("extract"); + // A slice built for block 0 must not verify as block 2. + assert!(verify_block_slice(&slice, &address, 4096, 2).is_none()); + } + + // -- nonced block tree -------------------------------------------------- + + #[test] + fn nonced_openings_roundtrip_every_block() { + for len in [1usize, 1024, 1025, 3000, 4096, 9001] { + let content = content_of(len); + let root = nonced_block_root(&NONCE, &PEER, &KEY, &content); + let count = block_count(len as u64); + for i in 0..count { + let siblings = + nonced_block_siblings(&NONCE, &PEER, &KEY, &content, i).expect("siblings"); + let block = block_bytes(&content, i); + assert!( + verify_nonced_block(&NONCE, &PEER, &KEY, i, block, &siblings, &root), + "len {len} block {i} must verify" + ); + } + } + } + + #[test] + fn nonced_opening_rejects_wrong_block_bytes() { + let content = content_of(4096); + let root = nonced_block_root(&NONCE, &PEER, &KEY, &content); + let siblings = nonced_block_siblings(&NONCE, &PEER, &KEY, &content, 1).expect("siblings"); + let mut wrong = block_bytes(&content, 1).to_vec(); + wrong[0] ^= 0x01; + assert!(!verify_nonced_block( + &NONCE, &PEER, &KEY, 1, &wrong, &siblings, &root + )); + } + + #[test] + fn nonced_opening_binds_nonce_peer_and_key() { + let content = content_of(4096); + let root = nonced_block_root(&NONCE, &PEER, &KEY, &content); + let siblings = nonced_block_siblings(&NONCE, &PEER, &KEY, &content, 2).expect("siblings"); + let block = block_bytes(&content, 2); + // Correct binding verifies. + assert!(verify_nonced_block( + &NONCE, &PEER, &KEY, 2, block, &siblings, &root + )); + // A different nonce, peer, or key must not verify against the same root. + let other = [0xAB; 32]; + assert!(!verify_nonced_block( + &other, &PEER, &KEY, 2, block, &siblings, &root + )); + assert!(!verify_nonced_block( + &NONCE, &other, &KEY, 2, block, &siblings, &root + )); + assert!(!verify_nonced_block( + &NONCE, &PEER, &other, 2, block, &siblings, &root + )); + } + + #[test] + fn nonced_root_changes_with_any_block_edit() { + let content = content_of(5000); + let root = nonced_block_root(&NONCE, &PEER, &KEY, &content); + let mut edited = content; + // Flip a byte in the LAST block; the root must change (all blocks covered). + if let Some(b) = edited.last_mut() { + *b ^= 0x01; + } + let root2 = nonced_block_root(&NONCE, &PEER, &KEY, &edited); + assert_ne!(root, root2); + } + + #[test] + fn nonced_siblings_out_of_range_is_none() { + let content = content_of(2048); + let count = block_count(content.len() as u64); + assert!(nonced_block_siblings(&NONCE, &PEER, &KEY, &content, count).is_none()); + } + + // -- the combined possession property ---------------------------------- + + #[test] + fn relay_cannot_open_against_a_foreign_committed_root() { + // A responder that did NOT hold the bytes at round 1 commits a root over + // garbage (or a guess). Even if it later fetches the real block, folding + // the real leaf to that committed root would be a preimage break: model + // this by taking a root from DIFFERENT content and checking that the real + // block + honest siblings never fold to it. + let real = content_of(4096); + let garbage = content_of(4097); // different content => different tree + let foreign_root = nonced_block_root(&NONCE, &PEER, &KEY, &garbage); + let siblings = nonced_block_siblings(&NONCE, &PEER, &KEY, &real, 0).expect("siblings"); + let block = block_bytes(&real, 0); + assert!(!verify_nonced_block( + &NONCE, + &PEER, + &KEY, + 0, + block, + &siblings, + &foreign_root + )); + } +} diff --git a/src/replication/storage_commitment_audit.rs b/src/replication/storage_commitment_audit.rs index febf286e..73b56ef4 100644 --- a/src/replication/storage_commitment_audit.rs +++ b/src/replication/storage_commitment_audit.rs @@ -18,12 +18,11 @@ use rand::Rng; use crate::ant_protocol::XorName; use crate::replication::commitment::{commitment_hash, StorageCommitment}; use crate::replication::commitment_state::ResponderCommitmentState; -use crate::replication::config::{ - ReplicationConfig, MAX_BYTE_CHALLENGE_KEYS, REPLICATION_PROTOCOL_ID, -}; +use crate::replication::config::{ReplicationConfig, MAX_SLICE_OPENINGS, REPLICATION_PROTOCOL_ID}; use crate::replication::protocol::{ RejectKind, ReplicationMessage, ReplicationMessageBody, SubtreeAuditChallenge, - SubtreeAuditResponse, SubtreeByteChallenge, SubtreeByteItem, SubtreeByteResponse, + SubtreeAuditResponse, SubtreeSliceChallenge, SubtreeSliceItem, SubtreeSliceOpening, + SubtreeSliceResponse, }; use crate::replication::recent_provers::RecentProvers; use crate::replication::subtree::{ @@ -231,11 +230,11 @@ pub async fn run_subtree_audit( dispatch_subtree_response(resp_msg.body, &ctx).await } -/// Outcome of the round-2 byte challenge round-trip (auditor side). -enum ByteRound { - /// The responder returned per-key items (verified by the caller). - Served(Vec), - /// The responder rejected the byte challenge (confirmed failure for a +/// Outcome of the round-2 slice challenge round-trip (auditor side). +enum SliceRound { + /// The responder returned per-opening items (verified by the caller). + Served(Vec), + /// The responder rejected the slice challenge (confirmed failure for a /// recently pinned commitment). Rejected, /// The responder rejected with `Transient` (a local read error): routed to @@ -244,45 +243,43 @@ enum ByteRound { /// must not keep stale credit. Distinct from a silent network `Timeout`, /// which keeps credit (a dropped packet is not evidence of loss). TransientReject, - /// No response within the byte deadline, or a transport error (graced + /// No response within the slice deadline, or a transport error (graced /// timeout). Keeps holder credit. Timeout, /// Malformed / unexpected round-2 response body. Malformed, } -/// Round 2: ask the responder for the ORIGINAL chunk content of one BATCH of -/// auditor-selected spot-check `keys` (at most [`MAX_BYTE_CHALLENGE_KEYS`], so -/// the worst-case response of max-size chunks fits the wire cap), sized to a -/// possession-in-time deadline (honest local-disk read of `keys.len()` chunks). -/// The responder cannot have predicted which keys are sampled. -async fn request_byte_proof(ctx: &AuditCtx<'_>, keys: &[XorName]) -> ByteRound { - let challenge = SubtreeByteChallenge { +/// Round 2: ask the responder to open `openings` blocks (one 1 KiB block per +/// sampled leaf, at most [`MAX_SLICE_OPENINGS`]) with a Bao verified slice plus a +/// nonced block-tree opening each. The reply is a few KB total, so there is no +/// batching. The responder cannot have predicted which leaves — or which block +/// within each — are opened (fresh post-proof randomness). +async fn request_slice_proof(ctx: &AuditCtx<'_>, openings: &[SubtreeSliceOpening]) -> SliceRound { + let challenge = SubtreeSliceChallenge { challenge_id: ctx.challenge_id, nonce: ctx.nonce, challenged_peer_id: *ctx.challenged_peer.as_bytes(), expected_commitment_hash: ctx.expected_commitment_hash, - keys: keys.to_vec(), + openings: openings.to_vec(), }; let msg = ReplicationMessage { request_id: ctx.challenge_id, - body: ReplicationMessageBody::SubtreeByteChallenge(challenge), + body: ReplicationMessageBody::SubtreeSliceChallenge(challenge), }; let encoded = match msg.encode() { Ok(data) => data, Err(e) => { - warn!("Audit: failed to encode byte challenge: {e}"); - return ByteRound::Malformed; + warn!("Audit: failed to encode slice challenge: {e}"); + return SliceRound::Malformed; } }; - // Deadline sized to "honest responder reads `keys.len()` local chunks AND - // ships them back": a relay forced to fetch them over the network blows it - // (graced timeout, never a confirmed failure — same possession-in-time - // principle as round 1). Uses the byte-round floor, which is high enough for - // the multi-MiB reply (handshake + upload + busy disk) — the round-1 - // hashes-only floor would be too tight for 2 × 4 MiB (§4). - let timeout = ctx.config.byte_audit_response_timeout(keys.len()); + // Deadline sized to "honest responder reads `openings.len()` full local + // chunks to build their proofs": generous, because possession is now + // guaranteed by the round-1 nonced commitment, not by this deadline being + // too tight for a relay to fetch bytes. + let timeout = ctx.config.slice_audit_response_timeout(openings.len()); let response = match ctx .p2p_node .send_request( @@ -296,27 +293,27 @@ async fn request_byte_proof(ctx: &AuditCtx<'_>, keys: &[XorName]) -> ByteRound { Ok(resp) => resp, Err(e) => { debug!( - "Audit: byte challenge to {} timed out / failed: {e}", + "Audit: slice challenge to {} timed out / failed: {e}", ctx.challenged_peer ); - return ByteRound::Timeout; + return SliceRound::Timeout; } }; let resp_msg = match ReplicationMessage::decode(&response.data) { Ok(m) => m, Err(e) => { - warn!("Audit: failed to decode byte response: {e}"); - return ByteRound::Malformed; + warn!("Audit: failed to decode slice response: {e}"); + return SliceRound::Malformed; } }; match resp_msg.body { - ReplicationMessageBody::SubtreeByteResponse(SubtreeByteResponse::Items { + ReplicationMessageBody::SubtreeSliceResponse(SubtreeSliceResponse::Items { challenge_id, items, - }) if challenge_id == ctx.challenge_id => ByteRound::Served(items), - ReplicationMessageBody::SubtreeByteResponse(SubtreeByteResponse::Rejected { + }) if challenge_id == ctx.challenge_id => SliceRound::Served(items), + ReplicationMessageBody::SubtreeSliceResponse(SubtreeSliceResponse::Rejected { challenge_id, kind, reason, @@ -329,17 +326,17 @@ async fn request_byte_proof(ctx: &AuditCtx<'_>, keys: &[XorName]) -> ByteRound { match grade_reject(kind) { RejectGrade::Confirmed => { warn!( - "Audit: {} rejected byte challenge ({kind:?}; confirmed): {reason}", + "Audit: {} rejected slice challenge ({kind:?}; confirmed): {reason}", ctx.challenged_peer ); - ByteRound::Rejected + SliceRound::Rejected } RejectGrade::TimeoutLane => { debug!( - "Audit: {} returned Transient for byte challenge (timeout lane): {reason}", + "Audit: {} returned Transient for slice challenge (timeout lane): {reason}", ctx.challenged_peer ); - ByteRound::TransientReject + SliceRound::TransientReject } } } @@ -347,10 +344,10 @@ async fn request_byte_proof(ctx: &AuditCtx<'_>, keys: &[XorName]) -> ByteRound { // as a timeout: it didn't prove possession but the round-1 proof shows // it isn't bootstrapping, so the bootstrap-claim-abuse detector (round 1) // owns that lane; here we just don't credit it. - ReplicationMessageBody::SubtreeByteResponse(SubtreeByteResponse::Bootstrapping { + ReplicationMessageBody::SubtreeSliceResponse(SubtreeSliceResponse::Bootstrapping { challenge_id, - }) if challenge_id == ctx.challenge_id => ByteRound::Timeout, - _ => ByteRound::Malformed, + }) if challenge_id == ctx.challenge_id => SliceRound::Timeout, + _ => SliceRound::Malformed, } } @@ -510,17 +507,19 @@ pub(crate) fn evaluate_subtree_structure( /// CRITICAL (ADR-0002 soundness): the sample MUST NOT be derivable from /// anything the responder knew when it built the round-1 proof. The structural /// root check binds only `(key, bytes_hash)` (both public — `bytes_hash` is the -/// chunk's network address), NOT `nonced_hash`. So a relay holding only public -/// addresses can fabricate a structurally-valid proof with bogus `nonced_hash` +/// chunk's network address), NOT `nonced_root`. So a relay holding only public +/// addresses can fabricate a structurally-valid proof with a bogus `nonced_root` /// on every leaf and, if it could predict which leaves round 2 opens, fetch /// only those and pass — earning holder credit for leaves it never held. /// /// Picking the sample with fresh CSPRNG randomness AFTER the proof is received /// turns round 1 into a commitment and round 2 into an unpredictable challenge /// (cut-and-choose): to pass with probability above `(1 - faked_fraction)^count` -/// the responder must have produced a correct `nonced_hash` — which requires the -/// real bytes — for essentially every leaf at round-1 commit time. The auditor -/// still holds none of the peer's chunks. +/// the responder must have produced a correct `nonced_root` — which requires the +/// real bytes under the fresh nonce — for essentially every leaf at round-1 +/// commit time. The auditor still holds none of the peer's chunks. The block +/// index opened within each sampled leaf is likewise drawn fresh +/// ([`random_block_index`]), so no single block can be prepared in advance. fn random_spotcheck_leaves( proof: &SubtreeProof, count: u32, @@ -553,54 +552,87 @@ fn random_spotcheck_leaves( .collect() } -/// Round-2 verdict (ADR-0002): the responder served the original chunk content -/// for the auditor's spot-check sample; verify possession from THAT content. -/// -/// `served(key)` returns what the responder returned for a requested key: -/// `Some(Some(bytes))` for [`SubtreeByteItem::Present`], `Some(None)` for an -/// explicit [`SubtreeByteItem::Absent`], and `None` if the responder omitted the -/// key entirely (treated like `Absent` — a committed key it would not serve). +/// Draw a fresh-random 1 KiB block index in `0..block_count(content_len)`. /// -/// For each sampled leaf the auditor recomputes, from the SERVED content: -/// - `BLAKE3(content) == leaf.bytes_hash` (the chunk's content address), AND -/// - `BLAKE3(nonce ‖ peer ‖ key ‖ content) == leaf.nonced_hash` (freshness), -/// i.e. `compute_audit_digest(nonce, peer, key, content)`. +/// Called AFTER the round-1 proof is in hand, per sampled leaf, so the responder +/// could not have prepared only the opened block (the cut-and-choose property, +/// now at block granularity). +fn random_block_index(content_len: u32) -> u32 { + let count = crate::replication::slice::block_count(u64::from(content_len)); + if count <= 1 { + return 0; + } + rand::thread_rng().gen_range(0..count) +} + +/// Round-2 verdict (ADR-0002 / V2-685): the responder opened one 1 KiB block per +/// sampled leaf with a Bao verified slice plus a nonced block-tree opening; +/// verify possession from those. /// -/// The freshness inputs are byte-identical to what the responder used to BUILD -/// the leaf in round 1 (`subtree_leaf` → `nonced_leaf_hash`): the SAME four -/// inputs, so an honest holder's served content reproduces `nonced_hash` -/// exactly. Round 1 commits over the data (the `nonced_hash` is uncomputable -/// without the bytes); round 2 reveals a random subset to prove the commitment -/// was not fabricated. +/// `openings` pairs each sampled leaf with the block index the auditor drew for +/// it. `items` is what the responder returned. For each opening the auditor: +/// 1. finds the responder's `Present` item for exactly this `(key, block_index)` +/// (a missing / `Absent` / wrong-block item is a provable lie); +/// 2. **Chain 1** — decodes the Bao slice against `leaf.bytes_hash` (the chunk +/// address), recovering the verified block bytes. This proves the block is +/// the real content at that offset, without the auditor holding the chunk. +/// 3. **Chain 2** — folds the nonced block leaf (recomputed from those verified +/// bytes under the audit nonce/peer/key/index) with the returned siblings to +/// `leaf.nonced_root`. This proves the responder committed a nonced tree +/// over the real content at round-1 time, so it held the bytes then. /// -/// Both checks are over the content the responder sent, so the auditor needs to -/// hold none of the peer's chunks. Any `Absent`/omitted committed key, or any -/// served content that fails a hash, is a provable lie → confirmed -/// [`AuditFailureReason::DigestMismatch`]. All sampled leaves verifying → -/// `Pass { checked }`. -pub(crate) fn verify_byte_response( - leaves: &[&crate::replication::subtree::SubtreeLeaf], +/// Both chains are over the SAME block bytes and the auditor holds none of the +/// peer's chunks. Any missing/absent opening, a slice that fails to decode +/// against the address, or a nonced opening that does not fold to the committed +/// root, is a provable lie → confirmed [`AuditFailureReason::DigestMismatch`]. +/// All openings verifying → `Pass { checked }`. +pub(crate) fn verify_slice_response( + openings: &[(crate::replication::subtree::SubtreeLeaf, u32)], nonce: &[u8; 32], challenged_peer_bytes: &[u8; 32], - served: impl Fn(&XorName) -> Option>>, + items: &[SubtreeSliceItem], ) -> AuditVerdict { let mut checked = 0usize; - for leaf in leaves { - // Present{bytes} -> Some(Some(bytes)); Absent -> Some(None); omitted -> None. - // A committed key the responder cannot / will not serve is a provable lie. - let Some(Some(content)) = served(&leaf.key) else { + for (leaf, block_index) in openings { + let block_index = *block_index; + // Match the responder's item for exactly this (key, block_index). A + // missing item, an explicit Absent, or a different block is a provable lie. + let served = items.iter().find_map(|it| match it { + SubtreeSliceItem::Present { + key, + block_index: served_index, + bao_slice, + nonced_siblings, + } if key == &leaf.key && *served_index == block_index => { + Some(Some((bao_slice.as_slice(), nonced_siblings.as_slice()))) + } + SubtreeSliceItem::Absent { key } if key == &leaf.key => Some(None), + _ => None, + }); + let Some(Some((bao_slice, nonced_siblings))) = served else { + return AuditVerdict::Fail(AuditFailureReason::DigestMismatch); + }; + + // Chain 1: authenticate the block against the chunk address. + let Some(block) = crate::replication::slice::verify_block_slice( + bao_slice, + &leaf.bytes_hash, + u64::from(leaf.content_len), + block_index, + ) else { return AuditVerdict::Fail(AuditFailureReason::DigestMismatch); }; - let plain = *blake3::hash(&content).as_bytes(); - let nonced = crate::replication::subtree::nonced_leaf_hash( + + // Chain 2: prove the block was committed under round 1's fresh nonce. + if !crate::replication::slice::verify_nonced_block( nonce, challenged_peer_bytes, &leaf.key, - &content, - ); - if leaf.bytes_hash != plain || leaf.nonced_hash != nonced { - // Served content does not hash to the committed address / freshness - // hash: cannot be the chunk it committed to. + block_index, + &block, + nonced_siblings, + &leaf.nonced_root, + ) { return AuditVerdict::Fail(AuditFailureReason::DigestMismatch); } checked += 1; @@ -613,13 +645,14 @@ pub(crate) fn verify_byte_response( /// **Round 1** (this proof): pin + identity + signature + structure. If the /// proof structurally rebuilds to the pinned root, the tree SHAPE is committed — /// but not yet that the bytes are held. **Round 2**: the auditor picks a small -/// freshly-random (post-proof) sample of the just-proven leaves and sends a -/// [`SubtreeByteChallenge`] demanding their original chunk content FROM the -/// responder, then verifies that content against the committed `bytes_hash` -/// (content address) and `nonced_hash` (freshness). A responder that committed -/// to a chunk it no longer holds cannot serve content that hashes to the -/// committed address, so it fails — regardless of what the auditor holds. On a -/// full pass, credits the peer as a proven holder. +/// freshly-random (post-proof) sample of the just-proven leaves, draws a fresh +/// block index for each, and sends a [`SubtreeSliceChallenge`] opening those +/// blocks. It verifies each opened block against the committed `bytes_hash` +/// (Bao slice → content address) and `nonced_root` (nonced block-tree opening → +/// round-1 possession commit). A responder that committed to a chunk it no +/// longer held cannot have committed a correct `nonced_root`, so it fails — +/// regardless of what the auditor holds. On a full pass, credits the peer as a +/// proven holder. async fn verify_subtree_response( ctx: &AuditCtx<'_>, commitment: &StorageCommitment, @@ -640,13 +673,14 @@ async fn verify_subtree_response( return failed(challenged_peer, challenge_id, reason); } - // -- Round 2: surprise byte challenge for a 3..=5 FRESHLY-RANDOM sample. -- + // -- Round 2: surprise slice challenge for a 3..=5 FRESHLY-RANDOM sample. -- // The sample is chosen now, with CSPRNG randomness, AFTER the round-1 proof // is in hand — NOT derived from the round-1 nonce. The responder committed - // every leaf's `nonced_hash` in round 1 without knowing which leaves we will - // open, so it cannot have fabricated the un-opened ones (cut-and-choose). - // We cap the sample at the ADR's 3..=5 band (clamped to the subtree size) so - // the round-2 message and the responder's disk read stay cheap. + // every leaf's `nonced_root` in round 1 without knowing which leaves — or + // which block within each — we will open, so it could not have prepared only + // the opened blocks (cut-and-choose). The reply is a few KB per opening (a + // 1 KiB block plus two short hash chains), so one round-2 message serves the + // whole sample; no batching. let sample_n = ctx .config .audit_spotcheck_count() @@ -662,85 +696,56 @@ async fn verify_subtree_response( AuditFailureReason::DigestMismatch, ); } - // The sample is challenged in batches of MAX_BYTE_CHALLENGE_KEYS so each - // response — worst case, every requested chunk at MAX_CHUNK_SIZE — still - // encodes under MAX_REPLICATION_MESSAGE_SIZE. Each batch carries its own - // possession-in-time deadline (sized to its own length), so splitting does - // not widen the per-chunk window a relay would need to fetch over the - // network. - // - // CRITICAL: verify each batch's served bytes AS IT ARRIVES, against that - // batch's own sampled leaves, and return a CONFIRMED failure immediately. - // Deferring all verification until every batch is collected would let a - // later batch's timeout-lane Timeout (`round_failure`) mask a deterministic - // failure already proven by an earlier batch (an absent committed key or a - // hash mismatch) — a confirmed cheat would be downgraded to a timeout. A - // Timeout/Rejected/Malformed only becomes the verdict if NO earlier batch - // already produced confirmed bad bytes. - let verdict = 'rounds: { - for batch in sampled.chunks(MAX_BYTE_CHALLENGE_KEYS) { - let batch_keys: Vec = batch.iter().map(|l| l.key).collect(); - match request_byte_proof(ctx, &batch_keys).await { - ByteRound::Served(items) => { - // Verify THIS batch now. A confirmed failure here is final — - // a later batch's timeout must not be able to overwrite it. - let v = verify_byte_response( - batch, - &ctx.nonce, - challenged_peer.as_bytes(), - |key| { - items.iter().find_map(|it| match it { - SubtreeByteItem::Present { key: k, bytes } if k == key => { - Some(Some(bytes.clone())) - } - SubtreeByteItem::Absent { key: k } if k == key => Some(None), - _ => None, - }) - }, - ); - if let AuditVerdict::Fail(reason) = v { - break 'rounds AuditVerdict::Fail(reason); - } - } - // The responder rejected the byte challenge for a recently - // pinned commitment → confirmed failure, same as round 1. - ByteRound::Rejected => { - break 'rounds AuditVerdict::Fail(AuditFailureReason::Rejected) - } - // Transient reject (a local read error): ADR-0004 A1 routes it to - // the timeout lane — no trust penalty, but revoke the holder - // credit for THIS pinned commitment (the peer answered and could - // not prove possession) before taking the Timeout verdict. Scoped - // to the commitment hash, not the whole peer, so it never erases - // credit the peer re-earned for a newer commitment. - ByteRound::TransientReject => { - if let Some(credit) = ctx.credit { - credit - .recent_provers - .write() - .await - .forget_commitment(&ctx.expected_commitment_hash); - } - break 'rounds AuditVerdict::Fail(AuditFailureReason::Timeout); - } - // No response within the byte deadline (or transport error) → - // timeout (graced by the caller's strike policy — could be - // honest slowness). Keeps credit (a dropped packet is not - // evidence of loss). Only reached when no earlier batch already - // confirmed bad bytes. - ByteRound::Timeout => { - break 'rounds AuditVerdict::Fail(AuditFailureReason::Timeout) - } - // Malformed/unexpected round-2 body. - ByteRound::Malformed => { - break 'rounds AuditVerdict::Fail(AuditFailureReason::MalformedResponse) - } + // Pair each sampled leaf with a fresh-random block index. Capped at + // MAX_SLICE_OPENINGS defensively (the sample is already <= BYTE_SPOTCHECK_MAX, + // itself <= MAX_SLICE_OPENINGS). Own the leaves so the borrow on `proof` ends + // before the await. + let openings_with_leaves: Vec<(crate::replication::subtree::SubtreeLeaf, u32)> = sampled + .iter() + .take(MAX_SLICE_OPENINGS) + .map(|leaf| ((*leaf).clone(), random_block_index(leaf.content_len))) + .collect(); + let openings: Vec = openings_with_leaves + .iter() + .map(|(leaf, block_index)| SubtreeSliceOpening { + key: leaf.key, + block_index: *block_index, + }) + .collect(); + + let verdict = match request_slice_proof(ctx, &openings).await { + // The responder served openings: verify both chains for every one. Any + // failing chain is a confirmed cheat. + SliceRound::Served(items) => verify_slice_response( + &openings_with_leaves, + &ctx.nonce, + challenged_peer.as_bytes(), + &items, + ), + // The responder rejected the slice challenge for a recently pinned + // commitment → confirmed failure, same as round 1. + SliceRound::Rejected => AuditVerdict::Fail(AuditFailureReason::Rejected), + // Transient reject (a local read error): ADR-0004 A1 routes it to the + // timeout lane — no trust penalty, but revoke the holder credit for THIS + // pinned commitment (the peer answered and could not prove possession). + // Scoped to the commitment hash, so it never erases credit the peer + // re-earned for a newer commitment. + SliceRound::TransientReject => { + if let Some(credit) = ctx.credit { + credit + .recent_provers + .write() + .await + .forget_commitment(&ctx.expected_commitment_hash); } + AuditVerdict::Fail(AuditFailureReason::Timeout) } - // Every batch served bytes that verified. - AuditVerdict::Pass { - checked: sampled.len(), - } + // No response within the slice deadline (or transport error) → timeout + // (graced by the caller's strike policy — could be honest slowness). + // Keeps credit (a dropped packet is not evidence of loss). + SliceRound::Timeout => AuditVerdict::Fail(AuditFailureReason::Timeout), + // Malformed/unexpected round-2 body. + SliceRound::Malformed => AuditVerdict::Fail(AuditFailureReason::MalformedResponse), }; match verdict { @@ -1020,57 +1025,116 @@ pub async fn handle_subtree_challenge( } } -/// Handle a round-2 byte challenge (responder side), ADR-0002. +/// Build the `Present` slice item for one opened block: the Bao verified slice +/// (authenticity against the chunk address) plus the nonced block-tree opening +/// (possession against round 1's `nonced_root`). +/// +/// Returns `Err(Rejected)` for the terminal cases that abort the whole response: +/// a block index out of range for the chunk (only a forged/buggy auditor sends +/// one → `Protocol`), or a surprise in-memory Bao extraction error (treated as +/// `Transient` rather than branding an honest holder). +fn build_slice_item( + challenge: &SubtreeSliceChallenge, + key: XorName, + block_index: u32, + bytes: &[u8], +) -> Result { + if u64::from(block_index) + >= u64::from(crate::replication::slice::block_count(bytes.len() as u64)) + { + return Err(SubtreeSliceResponse::Rejected { + challenge_id: challenge.challenge_id, + kind: RejectKind::Protocol, + reason: format!( + "block index {block_index} out of range for key {}", + hex::encode(key) + ), + }); + } + let bao_slice = match crate::replication::slice::extract_block_slice(bytes, block_index) { + Ok(slice) => slice, + Err(e) => { + warn!( + "Subtree slice audit: bao extraction failed for key {}: {e}", + hex::encode(key) + ); + return Err(SubtreeSliceResponse::Rejected { + challenge_id: challenge.challenge_id, + kind: RejectKind::Transient, + reason: format!("bao extraction error: {e}"), + }); + } + }; + let nonced_siblings = crate::replication::slice::nonced_block_siblings( + &challenge.nonce, + &challenge.challenged_peer_id, + &key, + bytes, + block_index, + ) + .unwrap_or_default(); + Ok(SubtreeSliceItem::Present { + key, + block_index, + bao_slice, + nonced_siblings, + }) +} + +/// Handle a round-2 slice challenge (responder side), ADR-0002 / V2-685. /// /// The auditor has already structurally verified this node's round-1 subtree -/// proof and now demands the ORIGINAL chunk bytes for a small freshly-random -/// sample of those leaves. For each requested key the responder either returns -/// the bytes ([`SubtreeByteItem::Present`]) or — if it committed to the key but -/// can no longer produce it — an explicit [`SubtreeByteItem::Absent`], which the -/// auditor counts as a provable failure (committing to bytes you don't hold). +/// proof and now opens one 1 KiB block of a small freshly-random sample of those +/// leaves. For each opening the responder reads the committed chunk and builds a +/// two-chain opening via `build_slice_item` (a Bao verified slice for +/// authenticity against the chunk address, and a nonced block-tree opening for +/// possession against round 1's `nonced_root`), returning +/// [`SubtreeSliceItem::Present`]. If it committed to the key but can no longer +/// produce the bytes it returns [`SubtreeSliceItem::Absent`], which the auditor +/// counts as a provable failure. /// /// A key the responder never committed to (not in the pinned tree) is also /// returned `Absent`: the auditor only ever samples keys it saw in round 1, so -/// in practice this guards against a malformed/forged byte challenge rather than -/// an honest mismatch. -pub async fn handle_subtree_byte_challenge( - challenge: &SubtreeByteChallenge, +/// in practice this guards against a malformed/forged challenge rather than an +/// honest mismatch. +pub async fn handle_subtree_slice_challenge( + challenge: &SubtreeSliceChallenge, storage: &LmdbStorage, self_peer_id: &PeerId, is_bootstrapping: bool, commitment_state: Option<&Arc>, -) -> SubtreeByteResponse { +) -> SubtreeSliceResponse { if is_bootstrapping { - return SubtreeByteResponse::Bootstrapping { + return SubtreeSliceResponse::Bootstrapping { challenge_id: challenge.challenge_id, }; } if challenge.challenged_peer_id != *self_peer_id.as_bytes() { - return SubtreeByteResponse::Rejected { + return SubtreeSliceResponse::Rejected { challenge_id: challenge.challenge_id, kind: RejectKind::Protocol, reason: "challenged_peer_id does not match this node".to_string(), }; } - // An honest auditor batches its sample to MAX_BYTE_CHALLENGE_KEYS per - // challenge so the worst-case response fits the wire cap. Reject larger - // requests up front: serving them could only produce an unencodable - // response (and invites disk-read amplification from a forged auditor). - if challenge.keys.len() > MAX_BYTE_CHALLENGE_KEYS { - let requested = challenge.keys.len(); - return SubtreeByteResponse::Rejected { + // An honest auditor opens at most MAX_SLICE_OPENINGS blocks per challenge. + // Reject larger requests up front: each opening forces a full chunk read to + // build its proof, so an oversized request is a disk-read amplification lever + // for a forged auditor. + if challenge.openings.len() > MAX_SLICE_OPENINGS { + let requested = challenge.openings.len(); + return SubtreeSliceResponse::Rejected { challenge_id: challenge.challenge_id, kind: RejectKind::Protocol, reason: format!( - "byte challenge requests {requested} keys; max {MAX_BYTE_CHALLENGE_KEYS} per challenge" + "slice challenge requests {requested} openings; max {MAX_SLICE_OPENINGS} per challenge" ), }; } let Some(state) = commitment_state else { - return SubtreeByteResponse::Rejected { + return SubtreeSliceResponse::Rejected { challenge_id: challenge.challenge_id, kind: RejectKind::Protocol, reason: "no commitment state".to_string(), @@ -1080,38 +1144,44 @@ pub async fn handle_subtree_byte_challenge( // retain it (rotated past it), reject as `UnknownCommitment`. With audit // grace removed (ADR-0004 A1) the auditor treats a responsive miss on an // in-window pin as a confirmed failure — answerability is restart-durable and - // pins are challenged only in-window. We serve bytes only for keys committed + // pins are challenged only in-window. We open blocks only for keys committed // under this pin. let Some(built) = state.lookup_by_hash(&challenge.expected_commitment_hash) else { - return SubtreeByteResponse::Rejected { + return SubtreeSliceResponse::Rejected { challenge_id: challenge.challenge_id, kind: RejectKind::UnknownCommitment, reason: "unknown commitment hash".to_string(), }; }; - let mut items = Vec::with_capacity(challenge.keys.len()); - for key in &challenge.keys { - // Serve ONLY keys committed under this pin. A key the auditor asks for - // that is not in the pinned tree is `Absent` — never served from local - // storage just because we happen to hold it (§15: serving an - // uncommitted-but-held key would let a forged challenge harvest bytes - // and muddy the possession proof, which must be about THIS commitment). - if built.proof_for(key).is_none() { - items.push(SubtreeByteItem::Absent { key: *key }); + let mut items = Vec::with_capacity(challenge.openings.len()); + for opening in &challenge.openings { + let key = opening.key; + // Open ONLY keys committed under this pin. A key not in the pinned tree + // is `Absent` — never served from local storage just because we happen to + // hold it (§15: serving an uncommitted-but-held key would let a forged + // challenge harvest data and muddy the possession proof for THIS commit). + if built.proof_for(&key).is_none() { + items.push(SubtreeSliceItem::Absent { key }); continue; } - match get_raw_retrying(storage, key).await { - // Committed key, bytes present → serve them. - Ok(Some(bytes)) => items.push(SubtreeByteItem::Present { key: *key, bytes }), + match get_raw_retrying(storage, &key).await { + // Committed key, bytes present → build the two-chain opening (or a + // terminal reject for an out-of-range index / surprise extraction error). + Ok(Some(bytes)) => { + match build_slice_item(challenge, key, opening.block_index, &bytes) { + Ok(item) => items.push(item), + Err(reject) => return reject, + } + } // Committed key, definitively absent → provable failure (§7: this is // a real "I don't hold it" answer, distinct from a read error). Ok(None) => { warn!( - "Subtree byte audit: committed key {} requested but bytes absent", + "Subtree slice audit: committed key {} requested but bytes absent", hex::encode(key) ); - items.push(SubtreeByteItem::Absent { key: *key }); + items.push(SubtreeSliceItem::Absent { key }); } // Persistent transient read error after retries → do NOT brand the // peer a deleter. Reject `Transient`; the auditor routes it to the @@ -1119,11 +1189,11 @@ pub async fn handle_subtree_byte_challenge( // possession failure on an honest holder (which also gains no credit). Err(e) => { warn!( - "Subtree byte audit: storage read error for committed key {}: {e} \ + "Subtree slice audit: storage read error for committed key {}: {e} \ (rejecting as transient, not a confirmed failure)", hex::encode(key) ); - return SubtreeByteResponse::Rejected { + return SubtreeSliceResponse::Rejected { challenge_id: challenge.challenge_id, kind: RejectKind::Transient, reason: format!("transient storage read error: {e}"), @@ -1132,7 +1202,7 @@ pub async fn handle_subtree_byte_challenge( } } - SubtreeByteResponse::Items { + SubtreeSliceResponse::Items { challenge_id: challenge.challenge_id, items, } @@ -1143,13 +1213,13 @@ pub async fn handle_subtree_byte_challenge( mod tests { use super::*; use crate::replication::commitment_state::BuiltCommitment; - use crate::replication::subtree::{build_subtree_proof, nonced_leaf_hash, SubtreeLeaf}; + use crate::replication::subtree::{build_subtree_proof, SubtreeLeaf}; use saorsa_pqc::api::sig::ml_dsa_65; /// ADR-0004 A1 grade flip (grace removed): a responsive `UnknownCommitment` /// or `Protocol` rejection is a CONFIRMED failure; only `Transient` routes to /// the timeout lane. This pure decision backs both audit rounds - /// (`Confirmed → AuditFailureReason::Rejected` / `ByteRound::Rejected`; + /// (`Confirmed → AuditFailureReason::Rejected` / `SliceRound::Rejected`; /// `TimeoutLane → AuditFailureReason::Timeout` + pinned-credit revocation). #[test] fn grade_reject_removes_grace_for_unknown_commitment() { @@ -1172,8 +1242,9 @@ mod tests { // structural root rebuild), // - sampling: `random_spotcheck_leaves` (3..=5 FRESHLY-RANDOM leaves chosen // after the proof is in hand — see its doc for the soundness argument), and - // - round 2: `verify_byte_response` (recompute content-address + freshness - // from the bytes the RESPONDER served — the auditor holds nothing). + // - round 2: `verify_slice_response` (decode the Bao slice against the chunk + // address + fold the nonced opening to the round-1 `nonced_root`, both from + // what the RESPONDER served — the auditor holds nothing). fn key(i: u32) -> XorName { let mut k = [0u8; 32]; @@ -1217,8 +1288,8 @@ mod tests { evaluate_subtree_structure(built.commitment(), proof, nonce, &built.hash(), peer) } - /// The 3..=5 spot-check leaves the auditor would demand bytes for in round 2. - /// Now freshly-random (post-proof) rather than nonce-derived; the `_nonce`/ + /// The 3..=5 spot-check leaves the auditor would open in round 2. Now + /// freshly-random (post-proof) rather than nonce-derived; the `_nonce`/ /// `_key_count` params are kept so existing call sites read unchanged. fn sample<'a>( proof: &'a SubtreeProof, @@ -1228,12 +1299,43 @@ mod tests { random_spotcheck_leaves(proof, 8u32.clamp(BYTE_SPOTCHECK_MIN, BYTE_SPOTCHECK_MAX)) } - // A round-2 `served` closure that returns the HONEST content for every key. - // The nested-Option shape is the `verify_byte_response` callback contract: - // Present{bytes} -> Some(Some(bytes)); Absent -> Some(None); omitted -> None. - #[allow(clippy::option_option, clippy::unnecessary_wraps)] - fn served_honest(key: &XorName) -> Option>> { - Some(Some(chunk_bytes(key))) + /// Pair each sampled leaf with a block index for round 2. The fixtures use + /// short (single-block) chunks, so block 0 is the only block; the production + /// auditor draws a fresh random index via `random_block_index`. + fn openings_for(sample: &[&SubtreeLeaf]) -> Vec<(SubtreeLeaf, u32)> { + sample.iter().map(|l| ((*l).clone(), 0u32)).collect() + } + + /// Honest responder: for each opening build a real Bao slice + nonced opening + /// from the true chunk content, exactly as `handle_subtree_slice_challenge` + /// would. + fn served_honest_items( + openings: &[(SubtreeLeaf, u32)], + nonce: &[u8; 32], + peer: &[u8; 32], + ) -> Vec { + openings + .iter() + .map(|(leaf, block_index)| { + let content = chunk_bytes(&leaf.key); + let bao_slice = + crate::replication::slice::extract_block_slice(&content, *block_index).unwrap(); + let nonced_siblings = crate::replication::slice::nonced_block_siblings( + nonce, + peer, + &leaf.key, + &content, + *block_index, + ) + .unwrap(); + SubtreeSliceItem::Present { + key: leaf.key, + block_index: *block_index, + bao_slice, + nonced_siblings, + } + }) + .collect() } // ---- round 1: structure -------------------------------------------------- @@ -1244,10 +1346,12 @@ mod tests { let (built, proof, peer) = honest(400, &nonce); // Round 1. assert!(structure(&built, &proof, &nonce, &peer).is_ok()); - // Round 2: honest responder serves the real content for the sample. + // Round 2: honest responder opens real slices for the sample. let s = sample(&proof, &nonce, built.commitment().key_count); assert!(!s.is_empty()); - match verify_byte_response(&s, &nonce, &peer, served_honest) { + let openings = openings_for(&s); + let items = served_honest_items(&openings, &nonce, &peer); + match verify_slice_response(&openings, &nonce, &peer, &items) { AuditVerdict::Pass { checked } => assert!(checked >= 1, "must verify >=1 leaf"), other @ AuditVerdict::Fail(_) => panic!("expected Pass, got {other:?}"), } @@ -1313,76 +1417,88 @@ mod tests { let (built, proof, peer) = honest(400, &nonce); assert!(structure(&built, &proof, &nonce, &peer).is_ok()); let s = sample(&proof, &nonce, built.commitment().key_count); - // Responder returns Absent for the FIRST sampled key, honest for the rest. - let victim = s.first().map(|l| l.key).unwrap(); - let v = verify_byte_response(&s, &nonce, &peer, |k| { - if *k == victim { - Some(None) // explicit Absent - } else { - Some(Some(chunk_bytes(k))) - } - }); + let openings = openings_for(&s); + // Responder returns Absent for the FIRST opening, honest for the rest. + let victim = openings.first().map(|(l, _)| l.key).unwrap(); + let mut items = served_honest_items(&openings, &nonce, &peer); + if let Some(slot) = items.first_mut() { + *slot = SubtreeSliceItem::Absent { key: victim }; + } + let v = verify_slice_response(&openings, &nonce, &peer, &items); assert_eq!(v, AuditVerdict::Fail(AuditFailureReason::DigestMismatch)); } #[test] fn omitted_committed_key_is_confirmed_failure() { - // A responder that simply omits a sampled committed key from its items + // A responder that simply omits a sampled committed opening from its items // (neither Present nor Absent) is treated identically to Absent: it // committed to the key and won't serve it → confirmed failure. let nonce = [9u8; 32]; let (built, proof, peer) = honest(400, &nonce); let s = sample(&proof, &nonce, built.commitment().key_count); - let victim = s.first().map(|l| l.key).unwrap(); - let v = verify_byte_response(&s, &nonce, &peer, |k| { - if *k == victim { - None // omitted entirely - } else { - Some(Some(chunk_bytes(k))) - } - }); + let openings = openings_for(&s); + let mut items = served_honest_items(&openings, &nonce, &peer); + items.remove(0); // omit the first opening entirely + let v = verify_slice_response(&openings, &nonce, &peer, &items); assert_eq!(v, AuditVerdict::Fail(AuditFailureReason::DigestMismatch)); } #[test] fn fake_storage_garbage_bytes_is_confirmed_failure() { - // A "fake-storage" responder claims possession but serves garbage. The - // garbage does not hash to the committed content address (`bytes_hash`), - // so the round-2 content-address check fails → confirmed failure. No - // auditor holdings involved. + // A "fake-storage" responder claims possession but opens a slice built + // from garbage content. The garbage slice does not decode against the + // committed content address (`bytes_hash`), so chain 1 fails → confirmed + // failure. No auditor holdings involved. let nonce = [9u8; 32]; let (built, proof, peer) = honest(400, &nonce); let s = sample(&proof, &nonce, built.commitment().key_count); - let v = verify_byte_response(&s, &nonce, &peer, |k| { - let mut garbage = blake3::hash(k).as_bytes().to_vec(); - garbage.extend_from_slice(b"adversary-fake-storage"); - Some(Some(garbage)) - }); + let openings = openings_for(&s); + let items: Vec = openings + .iter() + .map(|(leaf, bi)| { + let mut garbage = blake3::hash(&leaf.key).as_bytes().to_vec(); + garbage.extend_from_slice(b"adversary-fake-storage"); + let bao_slice = + crate::replication::slice::extract_block_slice(&garbage, *bi).unwrap(); + let nonced_siblings = crate::replication::slice::nonced_block_siblings( + &nonce, &peer, &leaf.key, &garbage, *bi, + ) + .unwrap(); + SubtreeSliceItem::Present { + key: leaf.key, + block_index: *bi, + bao_slice, + nonced_siblings, + } + }) + .collect(); + let v = verify_slice_response(&openings, &nonce, &peer, &items); assert_eq!(v, AuditVerdict::Fail(AuditFailureReason::DigestMismatch)); } #[test] - fn correct_content_address_but_stale_freshness_fails() { - // Suppose a responder could serve bytes that hash to the content address - // (it holds the chunk) — then BOTH checks pass; that is honest. But if - // it serves bytes whose freshness hash does not match (e.g. replaying a - // different nonce's digest is impossible since we recompute it here), the - // freshness check must catch any content that doesn't reproduce the - // committed `nonced_hash`. We model a leaf whose committed nonced_hash was - // built under a DIFFERENT nonce, so the audit nonce's recompute differs. + fn correct_content_address_but_stale_nonced_root_fails() { + // A responder can serve the real block (chain 1, the Bao slice against the + // address, passes), but if its committed `nonced_root` does not correspond + // to the audit's nonce over that content, the nonced opening (chain 2) + // cannot fold to it. We model a leaf whose committed `nonced_root` was + // built under a DIFFERENT nonce; the honest opening under the audit nonce + // then fails to match it. let nonce = [9u8; 32]; let (built, mut proof, peer) = honest(400, &nonce); - // Rewrite EVERY leaf's nonced_hash to one bound to a different nonce but - // keep its bytes_hash correct (so each leaf's content-address check is - // fine; only freshness is wrong). Tampering all leaves means the - // freshly-random sample is guaranteed to land on a stale-freshness leaf. let other_nonce = [0xEEu8; 32]; for leaf in &mut proof.leaves { - leaf.nonced_hash = - nonced_leaf_hash(&other_nonce, &peer, &leaf.key, &chunk_bytes(&leaf.key)); + leaf.nonced_root = crate::replication::slice::nonced_block_root( + &other_nonce, + &peer, + &leaf.key, + &chunk_bytes(&leaf.key), + ); } let s = sample(&proof, &nonce, built.commitment().key_count); - let v = verify_byte_response(&s, &nonce, &peer, served_honest); + let openings = openings_for(&s); + let items = served_honest_items(&openings, &nonce, &peer); + let v = verify_slice_response(&openings, &nonce, &peer, &items); assert_eq!(v, AuditVerdict::Fail(AuditFailureReason::DigestMismatch)); } @@ -1396,8 +1512,13 @@ mod tests { let (built, proof, peer) = honest(256, &nonce); assert!(structure(&built, &proof, &nonce, &peer).is_ok()); let s = sample(&proof, &nonce, built.commitment().key_count); - // Responder is a total deleter: Absent for everything. - let v = verify_byte_response(&s, &nonce, &peer, |_| Some(None)); + let openings = openings_for(&s); + // Responder is a total deleter: Absent for every opening. + let items: Vec = openings + .iter() + .map(|(l, _)| SubtreeSliceItem::Absent { key: l.key }) + .collect(); + let v = verify_slice_response(&openings, &nonce, &peer, &items); assert_eq!(v, AuditVerdict::Fail(AuditFailureReason::DigestMismatch)); } @@ -1422,8 +1543,10 @@ mod tests { let nonce = [11u8; 32]; let (built, proof, peer) = honest(400, &nonce); let s = sample(&proof, &nonce, built.commitment().key_count); - match verify_byte_response(&s, &nonce, &peer, served_honest) { - AuditVerdict::Pass { checked } => assert_eq!(checked, s.len()), + let openings = openings_for(&s); + let items = served_honest_items(&openings, &nonce, &peer); + match verify_slice_response(&openings, &nonce, &peer, &items) { + AuditVerdict::Pass { checked } => assert_eq!(checked, openings.len()), other @ AuditVerdict::Fail(_) => panic!("expected Pass, got {other:?}"), } } @@ -1432,9 +1555,9 @@ mod tests { #[test] fn structure_fail_short_circuits_before_round_2() { - // A structurally invalid proof is rejected in round 1; the byte challenge + // A structurally invalid proof is rejected in round 1; the slice challenge // is never issued. We assert the round-1 gate returns Err so the auditor - // (verify_subtree_response) never reaches request_byte_proof. + // (verify_subtree_response) never reaches request_slice_proof. let nonce = [5u8; 32]; let (built, mut proof, peer) = honest(300, &nonce); if let Some(first) = proof.leaves.first_mut() { @@ -1473,12 +1596,24 @@ mod tests { let (built_far, proof_far, peer_far) = honest_far(400, &nonce); assert!(structure(&built_far, &proof_far, &nonce, &peer_far).is_ok()); let sf = sample(&proof_far, &nonce, built_far.commitment().key_count); - let v_far = verify_byte_response(&sf, &nonce, &peer_far, served_honest); + let of = openings_for(&sf); + let v_far = verify_slice_response( + &of, + &nonce, + &peer_far, + &served_honest_items(&of, &nonce, &peer_far), + ); let (built_near, proof_near, peer_near) = honest(400, &nonce); assert!(structure(&built_near, &proof_near, &nonce, &peer_near).is_ok()); let sn = sample(&proof_near, &nonce, built_near.commitment().key_count); - let v_near = verify_byte_response(&sn, &nonce, &peer_near, served_honest); + let on = openings_for(&sn); + let v_near = verify_slice_response( + &on, + &nonce, + &peer_near, + &served_honest_items(&on, &nonce, &peer_near), + ); match (&v_far, &v_near) { (AuditVerdict::Pass { checked: cf }, AuditVerdict::Pass { checked: cn }) => { @@ -1498,7 +1633,8 @@ mod tests { let _l = SubtreeLeaf { key: key(1), bytes_hash: [0u8; 32], - nonced_hash: [0u8; 32], + content_len: 0, + nonced_root: [0u8; 32], }; } } diff --git a/src/replication/subtree.rs b/src/replication/subtree.rs index 1aa73faf..28b26858 100644 --- a/src/replication/subtree.rs +++ b/src/replication/subtree.rs @@ -31,7 +31,6 @@ //! intersected with `0..N`. use super::commitment::{leaf_hash, node_hash, StorageCommitment, MAX_COMMITMENT_KEY_COUNT}; -use super::protocol::compute_audit_digest; use crate::ant_protocol::XorName; use serde::{Deserialize, Serialize}; @@ -46,12 +45,20 @@ pub struct SubtreeLeaf { pub key: XorName, /// `BLAKE3(record_bytes)` — the plain content hash. This is also the /// chunk's network address, so it is public; possessing it does NOT prove - /// possession of the bytes (that is what `nonced_hash` is for). + /// possession of the bytes (that is what `nonced_root` is for). It is also + /// the BLAKE3/Bao root the round-2 slice verifies against. pub bytes_hash: [u8; 32], - /// `compute_audit_digest(nonce, peer_id, key, record_bytes)` — the - /// freshness hash. Only a holder of the actual bytes can produce it for a - /// fresh nonce, so a spot-check on it proves real possession. - pub nonced_hash: [u8; 32], + /// Length of the chunk's content, in bytes. Lets the auditor draw a random + /// 1 KiB block index in range and size the Bao slice for the final (short) + /// block. A lie here disagrees with the address-committed tree shape, so the + /// round-2 slice fails to decode — a lying responder only fails its own audit. + pub content_len: u32, + /// Root of the responder's fresh **nonced block tree** for this chunk (see + /// [`crate::replication::slice`]): a Merkle root over the chunk's 1 KiB + /// blocks whose leaves each bind the fresh nonce, peer, key and block bytes. + /// Building it requires every byte of the chunk under the fresh nonce, so it + /// commits real possession; round 2 opens one random block against it. + pub nonced_root: [u8; 32], } /// A responder's single-contiguous-subtree proof (ADR-0002 "The proof"). @@ -412,18 +419,6 @@ fn fold_levels(mut level: Vec<[u8; 32]>, levels: u32) -> [u8; 32] { level.first().copied().unwrap_or([0u8; 32]) } -/// Build the per-leaf nonced freshness hash for a subtree leaf (responder -/// side), reusing the existing audit digest. -#[must_use] -pub fn nonced_leaf_hash( - nonce: &[u8; 32], - challenged_peer_id: &[u8; 32], - key: &XorName, - record_bytes: &[u8], -) -> [u8; 32] { - compute_audit_digest(nonce, challenged_peer_id, key, record_bytes) -} - /// Why a responder could not build a subtree proof for a challenge. #[derive(Debug, Clone, PartialEq, Eq)] pub enum BuildProofError { @@ -542,6 +537,10 @@ pub fn subtree_plan( } /// Build one subtree leaf from its key and the chunk bytes the responder holds. +/// +/// The plain `bytes_hash` is the chunk's content address; the `nonced_root` is +/// the fresh per-audit nonced block-tree root over the same bytes, committing +/// possession at round-1 time (see [`crate::replication::slice`]). #[must_use] pub fn subtree_leaf( nonce: &[u8; 32], @@ -552,7 +551,13 @@ pub fn subtree_leaf( SubtreeLeaf { key: *key, bytes_hash: *blake3::hash(bytes).as_bytes(), - nonced_hash: nonced_leaf_hash(nonce, challenged_peer_id, key, bytes), + content_len: u32::try_from(bytes.len()).unwrap_or(u32::MAX), + nonced_root: crate::replication::slice::nonced_block_root( + nonce, + challenged_peer_id, + key, + bytes, + ), } } @@ -980,25 +985,27 @@ mod tests { } #[test] - fn fabricated_nonced_hash_caught_by_spotcheck_probability() { + fn fabricated_nonced_root_caught_by_spotcheck_probability() { // Simulate the realness check: a responder fabricates a fraction x of - // nonced hashes. The auditor spot-checks k leaves; probability all k - // land on honest leaves is (1-x)^k. Here we just assert the auditor - // *would* catch a fabricated leaf when it samples that position. + // nonced roots. The auditor opens k leaves; probability all k land on + // honest leaves is (1-x)^k. Here we just assert the auditor *would* catch + // a fabricated leaf: its committed root differs from the honest root + // recomputed from the real chunk bytes under the audit nonce. let peer = [1u8; 32]; let entries = entries_for(400); let nonce = nonce_of(9); let (mut proof, _commitment) = build_proof(&entries, &nonce, &peer); - // Fabricate the nonced hash on the first subtree leaf (wrong bytes). - proof.leaves[0].nonced_hash[0] ^= 0xFF; - // The realness check the caller runs: recompute from the real chunk - // bytes (the same fixture the honest tree was built from). - let leaf = &proof.leaves[0]; + // Fabricate the nonced root on the first subtree leaf. + if let Some(first) = proof.leaves.first_mut() { + first.nonced_root[0] ^= 0xFF; + } + let leaf = proof.leaves.first().expect("proof has leaves"); let real_bytes = chunk_bytes(&leaf.key); - let expected = nonced_leaf_hash(&nonce, &peer, &leaf.key, &real_bytes); + let expected = + crate::replication::slice::nonced_block_root(&nonce, &peer, &leaf.key, &real_bytes); assert_ne!( - leaf.nonced_hash, expected, - "fabricated nonced hash must differ from real" + leaf.nonced_root, expected, + "fabricated nonced root must differ from real" ); } diff --git a/tests/poc_audit_handler_live.rs b/tests/poc_audit_handler_live.rs index 11c857d8..5b4f2bbe 100644 --- a/tests/poc_audit_handler_live.rs +++ b/tests/poc_audit_handler_live.rs @@ -25,13 +25,14 @@ use std::sync::Arc; use ant_node::replication::commitment_state::{BuiltCommitment, ResponderCommitmentState}; -use ant_node::replication::config::MAX_BYTE_CHALLENGE_KEYS; +use ant_node::replication::config::MAX_SLICE_OPENINGS; use ant_node::replication::protocol::{ - SubtreeAuditChallenge, SubtreeAuditResponse, SubtreeByteChallenge, SubtreeByteItem, - SubtreeByteResponse, + SubtreeAuditChallenge, SubtreeAuditResponse, SubtreeSliceChallenge, SubtreeSliceItem, + SubtreeSliceOpening, SubtreeSliceResponse, }; +use ant_node::replication::slice::{nonced_block_root, verify_block_slice, verify_nonced_block}; use ant_node::replication::storage_commitment_audit::{ - handle_subtree_byte_challenge, handle_subtree_challenge, + handle_subtree_challenge, handle_subtree_slice_challenge, }; use ant_node::replication::subtree::{verify_subtree_proof, StructureVerdict}; use ant_node::storage::{LmdbStorage, LmdbStorageConfig}; @@ -335,48 +336,94 @@ async fn committed_key_with_missing_bytes_is_rejected() { } // --------------------------------------------------------------------------- -// 6. Round 2 (byte challenge): honest serve + oversize-request rejection +// 6. Round 2 (slice challenge): honest open + oversize-request rejection // --------------------------------------------------------------------------- -/// Round-2 happy path: a byte challenge pinned to the responder's retained -/// commitment, for keys it committed to and still stores, returns `Items` with -/// the ORIGINAL bytes (`Present`) for every requested key. +/// Round-2 happy path: a slice challenge pinned to the responder's retained +/// commitment, for keys it committed to and still stores, returns `Items` with a +/// `Present` verified opening for every requested key. The opening is fully +/// verified here — the Bao slice decodes against the chunk address to the real +/// block, and the nonced opening folds to the honest nonced root — so this FAILS +/// if the responder produces a malformed or non-possessing proof. /// -/// FLIPS IF: the responder stops serving original bytes for committed keys — -/// the auditor would then see byte-verification failures for honest nodes. +/// FLIPS IF: the responder stops opening valid slices for committed keys — the +/// auditor would then see verification failures for honest nodes. #[tokio::test] -async fn byte_challenge_serves_original_bytes_for_committed_keys() { +async fn slice_challenge_opens_valid_blocks_for_committed_keys() { let (storage, _t) = test_storage().await; let r = Responder::new(&storage, &[1, 2, 3, 4]).await; let pin = r.current_hash(); - - let keys = vec![Responder::address(1), Responder::address(2)]; - let challenge = SubtreeByteChallenge { + let nonce = [0x22u8; 32]; + + let openings = vec![ + SubtreeSliceOpening { + key: Responder::address(1), + block_index: 0, + }, + SubtreeSliceOpening { + key: Responder::address(2), + block_index: 0, + }, + ]; + let challenge = SubtreeSliceChallenge { challenge_id: 43, - nonce: [0x22u8; 32], + nonce, challenged_peer_id: r.peer_id_bytes, expected_commitment_hash: pin, - keys: keys.clone(), + openings: openings.clone(), }; let resp = - handle_subtree_byte_challenge(&challenge, &storage, &r.peer_id, false, Some(&r.state)) + handle_subtree_slice_challenge(&challenge, &storage, &r.peer_id, false, Some(&r.state)) .await; match resp { - SubtreeByteResponse::Items { + SubtreeSliceResponse::Items { challenge_id, items, } => { assert_eq!(challenge_id, 43); - assert_eq!(items.len(), keys.len(), "one item per requested key"); - for (item, (i, key)) in items.iter().zip([1u8, 2].into_iter().zip(keys)) { + assert_eq!( + items.len(), + openings.len(), + "one item per requested opening" + ); + for (item, (i, opening)) in items.iter().zip([1u8, 2].into_iter().zip(openings)) { match item { - SubtreeByteItem::Present { key: k, bytes } => { - assert_eq!(*k, key); - assert_eq!(*bytes, chunk_content(i), "must serve the ORIGINAL bytes"); + SubtreeSliceItem::Present { + key: k, + block_index, + bao_slice, + nonced_siblings, + } => { + assert_eq!(*k, opening.key); + assert_eq!(*block_index, 0); + let content = chunk_content(i); + let bytes_hash = *blake3::hash(&content).as_bytes(); + // Chain 1: the Bao slice decodes against the address to + // the real block (a 1024-byte chunk is a single block). + let block = + verify_block_slice(bao_slice, &bytes_hash, content.len() as u64, 0) + .expect("bao slice must verify against the chunk address"); + assert_eq!(block, content, "opened block must be the ORIGINAL bytes"); + // Chain 2: the nonced opening folds to the honest nonced + // root over the real content under this audit's nonce. + let root = + nonced_block_root(&nonce, &r.peer_id_bytes, &opening.key, &content); + assert!( + verify_nonced_block( + &nonce, + &r.peer_id_bytes, + &opening.key, + 0, + &block, + nonced_siblings, + &root, + ), + "nonced opening must fold to the honest nonced root" + ); } - other @ SubtreeByteItem::Absent { .. } => { + other @ SubtreeSliceItem::Absent { .. } => { panic!("expected Present for stored committed key, got {other:?}") } } @@ -386,41 +433,148 @@ async fn byte_challenge_serves_original_bytes_for_committed_keys() { } } -/// A byte challenge requesting more than `MAX_BYTE_CHALLENGE_KEYS` keys is -/// rejected up front: an honest auditor batches its sample to that cap so the -/// worst-case response (all chunks at max size) fits the replication wire cap; -/// anything larger is a forged/over-size request the responder must not try to -/// serve (the response could not encode, and reading the chunks first would be -/// disk-read amplification). +/// Multi-block coverage: open a DEEP block of a genuinely large (multi-block) +/// chunk end-to-end through the live handler. The single-block happy-path test +/// above cannot exercise the Bao parent-hash chain or the multi-level nonced +/// tree; this one does — it stores a ~100 KB chunk (98 blocks), opens block 50, +/// and verifies both chains against that exact block. +/// +/// FLIPS IF: the Bao slice or nonced opening for a non-trivial tree is malformed +/// (e.g. wrong offset, wrong sibling ordering). +#[tokio::test] +async fn slice_challenge_opens_a_deep_block_of_a_large_chunk() { + use ant_node::replication::commitment::commitment_hash; + use ant_node::replication::slice::block_count; + + let (storage, _t) = test_storage().await; + + // One large chunk: ~100 KB => 98 one-KiB blocks. + let content: Vec = (0..100_000u32) + .map(|n| (n.wrapping_mul(2_654_435_761) >> 13) as u8) + .collect(); + let addr = LmdbStorage::compute_address(&content); + storage.put(&addr, &content).await.expect("put chunk"); + let bytes_hash = *blake3::hash(&content).as_bytes(); + + let (pk, sk) = keypair(); + let peer_id_bytes = *blake3::hash(&pk.to_bytes()).as_bytes(); + let peer_id = PeerId::from_bytes(peer_id_bytes); + let built = BuiltCommitment::build( + vec![(addr, bytes_hash)], + &peer_id_bytes, + &sk, + &pk.to_bytes(), + ) + .expect("build"); + let pin = commitment_hash(built.commitment()).unwrap(); + let state = Arc::new(ResponderCommitmentState::new()); + state.rotate(built); + + let count = block_count(content.len() as u64); + assert!( + count > 64, + "test needs a genuinely multi-block chunk, got {count} blocks" + ); + let block_index = 50u32; + let nonce = [0x5Au8; 32]; + let challenge = SubtreeSliceChallenge { + challenge_id: 77, + nonce, + challenged_peer_id: peer_id_bytes, + expected_commitment_hash: pin, + openings: vec![SubtreeSliceOpening { + key: addr, + block_index, + }], + }; + + let resp = + handle_subtree_slice_challenge(&challenge, &storage, &peer_id, false, Some(&state)).await; + + let SubtreeSliceResponse::Items { items, .. } = resp else { + panic!("expected Items, got {resp:?}"); + }; + let Some(SubtreeSliceItem::Present { + block_index: bi, + bao_slice, + nonced_siblings, + .. + }) = items.first() + else { + panic!("expected a single Present opening, got {items:?}"); + }; + assert_eq!(*bi, block_index); + + // Chain 1: the Bao slice decodes against the address to exactly block 50. + let block = verify_block_slice(bao_slice, &bytes_hash, content.len() as u64, block_index) + .expect("deep-block bao slice must verify"); + let start = block_index as usize * 1024; + assert_eq!( + block, + content[start..start + 1024], + "opened block must be block 50's bytes" + ); + + // Chain 2: the nonced opening folds up the multi-level tree to the honest root. + let root = nonced_block_root(&nonce, &peer_id_bytes, &addr, &content); + assert!( + verify_nonced_block( + &nonce, + &peer_id_bytes, + &addr, + block_index, + &block, + nonced_siblings, + &root + ), + "deep-block nonced opening must fold to the honest root" + ); + // And the proof is genuinely small — a slice, not the whole 100 KB chunk. + assert!( + bao_slice.len() < content.len() / 4, + "the slice ({} bytes) must be far smaller than the chunk ({} bytes)", + bao_slice.len(), + content.len() + ); +} + +/// A slice challenge requesting more than `MAX_SLICE_OPENINGS` openings is +/// rejected up front: an honest auditor opens at most that many blocks; anything +/// larger is a forged/over-size request the responder must not try to serve +/// (each opening forces a full chunk read to build its proof — disk-read +/// amplification). /// -/// FLIPS IF: the per-challenge key cap is removed from the responder. +/// FLIPS IF: the per-challenge openings cap is removed from the responder. #[tokio::test] -async fn oversize_byte_challenge_is_rejected() { +async fn oversize_slice_challenge_is_rejected() { let (storage, _t) = test_storage().await; let r = Responder::new(&storage, &[1, 2, 3, 4]).await; let pin = r.current_hash(); - let keys: Vec<[u8; 32]> = (0..=MAX_BYTE_CHALLENGE_KEYS) - .map(|i| [u8::try_from(i % 251).unwrap_or(0); 32]) + let openings: Vec = (0..=MAX_SLICE_OPENINGS) + .map(|i| SubtreeSliceOpening { + key: [u8::try_from(i % 251).unwrap_or(0); 32], + block_index: 0, + }) .collect(); - assert!(keys.len() > MAX_BYTE_CHALLENGE_KEYS); - let challenge = SubtreeByteChallenge { + assert!(openings.len() > MAX_SLICE_OPENINGS); + let challenge = SubtreeSliceChallenge { challenge_id: 44, nonce: [0x33u8; 32], challenged_peer_id: r.peer_id_bytes, expected_commitment_hash: pin, - keys, + openings, }; let resp = - handle_subtree_byte_challenge(&challenge, &storage, &r.peer_id, false, Some(&r.state)) + handle_subtree_slice_challenge(&challenge, &storage, &r.peer_id, false, Some(&r.state)) .await; match resp { - SubtreeByteResponse::Rejected { reason, .. } => { + SubtreeSliceResponse::Rejected { reason, .. } => { assert!( reason.contains("max"), - "expected per-challenge key-cap rejection, got: {reason}" + "expected per-challenge openings-cap rejection, got: {reason}" ); } other => panic!("expected Rejected(oversize), got {other:?}"), diff --git a/tests/poc_commitment_audit_attacks.rs b/tests/poc_commitment_audit_attacks.rs index bc67bc77..5d6440c0 100644 --- a/tests/poc_commitment_audit_attacks.rs +++ b/tests/poc_commitment_audit_attacks.rs @@ -13,17 +13,17 @@ //! The production auditor's `verify_subtree_response` (in //! `src/replication/storage_commitment_audit.rs`) is private, so this file //! reproduces the exact ordered gates it runs — pin, peer-id binding, -//! signature, structural [`verify_subtree_proof`], then the **round-2 byte -//! challenge**: the auditor demands the ORIGINAL chunk bytes for a -//! nonce-selected sample of the just-proven leaves FROM THE RESPONDER and -//! verifies the served content against each leaf's committed `bytes_hash` -//! (content address) and `nonced_hash` (freshness). Possession is -//! non-delegable: the auditor needs to hold NONE of the responder's chunks, -//! and a committed key the responder cannot serve is a deterministic, -//! confirmed failure (`DigestMismatch` in production — never inconclusive, -//! never graced). The helper [`auditor_accepts`] runs these gates in the same -//! order with the same failure semantics, so a reviewer can see each attack -//! is caught at the same gate the network code would catch it. +//! signature, structural [`verify_subtree_proof`], then the **round-2 slice +//! challenge** (V2-685): the auditor opens one 1 KiB block of a fresh-random +//! sample of the just-proven leaves FROM THE RESPONDER and verifies each opened +//! block against the leaf's committed `bytes_hash` (a Bao verified slice against +//! the chunk address) and `nonced_root` (a nonced block-tree opening proving +//! round-1 possession). Possession is non-delegable: the auditor needs to hold +//! NONE of the responder's chunks, and a committed key the responder cannot +//! serve is a deterministic, confirmed failure (`DigestMismatch` in production — +//! never inconclusive, never graced). The helper [`auditor_accepts`] runs these +//! gates in the same order with the same failure semantics, so a reviewer can +//! see each attack is caught at the same gate the network code would catch it. //! //! ## What changed from the old per-key audit (and why) //! @@ -68,9 +68,13 @@ use ant_node::replication::commitment::{ }; use ant_node::replication::commitment_state::{BuiltCommitment, ResponderCommitmentState}; use ant_node::replication::config::AUDIT_SPOTCHECK_COUNT; +use ant_node::replication::slice::{ + extract_block_slice, nonced_block_root, nonced_block_siblings, verify_block_slice, + verify_nonced_block, +}; use ant_node::replication::subtree::{ - build_subtree_proof, nonced_leaf_hash, select_spotcheck_indices, select_subtree_path, - verify_subtree_proof, StructureVerdict, SubtreeProof, + build_subtree_proof, select_spotcheck_indices, select_subtree_path, verify_subtree_proof, + StructureVerdict, SubtreeLeaf, SubtreeProof, }; use rand::Rng; use saorsa_pqc::api::sig::{ml_dsa_65, MlDsaPublicKey, MlDsaSecretKey}; @@ -145,7 +149,7 @@ impl Responder { } /// Bytes source for an HONEST responder: it really holds every chunk it -/// committed to, so it can always produce a correct `nonced_hash`. +/// committed to, so it can always open a real slice + nonced block opening. fn honest_bytes(k: &[u8; 32]) -> Option> { for i in 0..4096u32 { if &key(i) == k { @@ -158,13 +162,15 @@ fn honest_bytes(k: &[u8; 32]) -> Option> { /// The auditor's full ordered verification, mirroring the production /// `verify_subtree_response` gates. Returns `Ok(byte_checked_count)` on accept. /// -/// `responder_serves(k)` models round 2 (`SubtreeByteChallenge`): what the -/// RESPONDER returns when the auditor demands the original bytes of sampled -/// leaf `k`. `Some(bytes)` is a `SubtreeByteItem::Present`; `None` is an -/// explicit `Absent` or an omitted key — a committed key the responder will -/// not serve, which production `verify_byte_response` counts as a confirmed -/// `DigestMismatch`. The auditor verifies the SERVED content, so it needs to -/// hold none of the responder's chunks and no inconclusive lane exists. +/// `responder_serves(k)` models round 2 (`SubtreeSliceChallenge`): the chunk +/// content the RESPONDER can produce for sampled leaf `k`, from which it builds a +/// Bao verified slice + nonced block opening exactly as +/// `handle_subtree_slice_challenge` does. `Some(bytes)` is a +/// `SubtreeSliceItem::Present`; `None` is an explicit `Absent` or an omitted key +/// — a committed key the responder will not serve, which production +/// `verify_slice_response` counts as a confirmed `DigestMismatch`. The auditor +/// verifies the served block against the committed address and nonced root, so it +/// needs to hold none of the responder's chunks and no inconclusive lane exists. fn auditor_accepts( challenged_peer_id: &[u8; 32], expected_commitment_hash: &[u8; 32], @@ -194,13 +200,13 @@ fn auditor_accepts( return Err(AuditError::StructureInvalid(why)); } - // -- Gate: round-2 byte challenge (responder-served possession) ---------- + // -- Gate: round-2 slice challenge (responder-served possession) ---------- // Mirrors `verify_subtree_response` round 2: the sample is chosen with FRESH // randomness over the RECEIVED proof leaves (NOT nonce-derived), AFTER round - // 1, so the responder cannot predict which leaves will be opened (§1 - // cut-and-choose soundness). EVERY sampled leaf must verify from the bytes - // the responder serves. There is no skip and no inconclusive lane: a - // committed key the responder cannot serve is a provable lie. + // 1, so the responder cannot predict which leaves — or which block within — + // will be opened (§1 cut-and-choose soundness). EVERY sampled leaf must + // verify from the slice the responder serves. There is no skip and no + // inconclusive lane: a committed key the responder cannot serve is a lie. let spot = random_sample_indices( proof.leaves.len(), AUDIT_SPOTCHECK_COUNT.clamp(3, 5) as usize, @@ -221,9 +227,39 @@ fn auditor_accepts( // maps this to `DigestMismatch`), NOT a skip. return Err(AuditError::CommittedKeyUnserved); }; - let plain = *blake3::hash(&bytes).as_bytes(); - let nonced = nonced_leaf_hash(nonce, &commitment.sender_peer_id, &leaf.key, &bytes); - if leaf.bytes_hash != plain || leaf.nonced_hash != nonced { + // The fixtures use short (single-block) chunks, so the only block is 0; + // production draws a fresh random index. The responder builds its slice + // and nonced opening from the content it serves. + let block_index = 0u32; + let bao_slice = extract_block_slice(&bytes, block_index).unwrap(); + let nonced_siblings = nonced_block_siblings( + nonce, + &commitment.sender_peer_id, + &leaf.key, + &bytes, + block_index, + ) + .unwrap_or_default(); + + // Auditor chain 1: authenticate the block against the committed address. + let Some(block) = verify_block_slice( + &bao_slice, + &leaf.bytes_hash, + u64::from(leaf.content_len), + block_index, + ) else { + return Err(AuditError::RealBytesMismatch); + }; + // Auditor chain 2: fold the nonced opening to the committed nonced_root. + if !verify_nonced_block( + nonce, + &commitment.sender_peer_id, + &leaf.key, + block_index, + &block, + &nonced_siblings, + &leaf.nonced_root, + ) { return Err(AuditError::RealBytesMismatch); } checked += 1; @@ -255,8 +291,9 @@ enum AuditError { CommitmentHashMismatch, SignatureInvalid, StructureInvalid(&'static str), - /// Round 2: the responder served content that does not hash to the - /// committed address / freshness hash (production: `DigestMismatch`). + /// Round 2: the responder's opened block failed a chain — the Bao slice did + /// not decode against the committed address, or the nonced opening did not + /// fold to the committed `nonced_root` (production: `DigestMismatch`). RealBytesMismatch, /// Round 2: the responder would not serve a committed, sampled key /// (production: `DigestMismatch` — a deterministic, confirmed failure). @@ -310,15 +347,15 @@ fn honest_responder_passes_audit() { /// leaf's `bytes_hash` (that value IS the chunk's network address, which is /// public), but it DROPPED the actual bytes. It fabricates a proof: correct /// `key` and correct `bytes_hash` for every selected leaf (so the structural -/// root rebuild passes), but it cannot compute the `nonced_hash`, which requires -/// the real bytes under a fresh nonce. It fills in a forged `nonced_hash`. +/// root rebuild passes), but it cannot compute the `nonced_root`, which requires +/// the real bytes under a fresh nonce. It fills in a forged `nonced_root`. /// /// The structural gate PASSES (addresses alone rebuild the root), proving that /// structure is NOT sufficient — exactly the storage-binding hole. Round 2 is what -/// catches it: the auditor demands the original bytes FROM THE RELAY, and the -/// relay has nothing to serve. Refusing/omitting a sampled committed key is a -/// confirmed failure, and serving fabricated bytes cannot hash to the -/// committed content address (a preimage break) — both lanes are asserted. +/// catches it: the auditor opens a block FROM THE RELAY, and the relay has nothing +/// to serve. Refusing/omitting a sampled committed key is a confirmed failure, and +/// serving a fabricated block cannot decode against the committed content address +/// (a preimage break) — both lanes are asserted. #[test] fn relay_holding_only_addresses_caught_by_real_bytes_check() { let nonce = [0x77; 32]; @@ -328,18 +365,19 @@ fn relay_holding_only_addresses_caught_by_real_bytes_check() { // The lazy node fabricates the proof from PUBLIC data only: it knows each // leaf key and its bytes_hash (== address), but NOT the bytes, so it forges - // every nonced_hash. + // every nonced_root. let path = select_subtree_path(&nonce, built.commitment().key_count).unwrap(); let mut leaves = Vec::new(); for idx in path.leaf_start..path.leaf_end { let k = built.tree().key_at(idx as usize).unwrap(); // bytes_hash is public (== the chunk address); the responder fakes the - // possession hash because it lacks the bytes. - let forged_nonced = *blake3::hash(b"i-do-not-have-the-bytes").as_bytes(); - leaves.push(ant_node::replication::subtree::SubtreeLeaf { + // possession commitment because it lacks the bytes. + let forged_nonced_root = *blake3::hash(b"i-do-not-have-the-bytes").as_bytes(); + leaves.push(SubtreeLeaf { key: k, bytes_hash: content_hash(idx), - nonced_hash: forged_nonced, + content_len: u32::try_from(content(idx).len()).unwrap(), + nonced_root: forged_nonced_root, }); } // Real sibling cut-hashes from the committed tree (public, derivable). @@ -394,17 +432,17 @@ fn relay_holding_only_addresses_caught_by_real_bytes_check() { /// the relay attack, and the one the §1 review found: a relay holds only public /// addresses, but it knows the round-1 nonce, so under the OLD nonce-derived /// sampling it could compute EXACTLY which 3..=5 leaves round 2 would open, -/// fetch only those few chunks from neighbours, fill in correct `nonced_hash` -/// for them, and fabricate `nonced_hash` for every other leaf — passing the +/// fetch only those few chunks from neighbours, commit a correct `nonced_root` +/// for them, and fabricate `nonced_root` for every other leaf — passing the /// audit while holding almost nothing. /// /// With the fix, the auditor draws the sample with fresh randomness AFTER the /// proof is committed, so the relay's bet on the nonce-derived indices is -/// uncorrelated with what actually gets opened. We model the relay holding the -/// nonce-derived prediction set and nothing else: the random sample lands on a -/// leaf the relay did NOT fetch with overwhelming probability, and the audit -/// fails. Repeated across many nonces to make the probabilistic catch a -/// near-certainty in aggregate. +/// uncorrelated with what actually gets opened. We model the relay committing a +/// CORRECT `nonced_root` (and holding the bytes) only for the nonce-derived +/// prediction set and forging the rest: the random sample lands on a leaf the +/// relay did NOT prepare with overwhelming probability, and the audit fails. +/// Repeated across many nonces to make the probabilistic catch a near-certainty. #[test] fn predict_and_fetch_relay_is_caught_by_fresh_random_sample() { let r = Responder::new(); @@ -418,17 +456,33 @@ fn predict_and_fetch_relay_is_caught_by_fresh_random_sample() { let mut nonce = [0u8; 32]; nonce[..4].copy_from_slice(&t.to_le_bytes()); - // The relay builds a structurally-valid proof from PUBLIC data, forging - // every leaf's nonced_hash (it holds no bytes). let plan = ant_node::replication::subtree::subtree_plan(built.tree(), &nonce).unwrap(); let path = select_subtree_path(&nonce, n).unwrap(); + // The relay PREDICTS the old nonce-derived sample: those are the only + // leaves it fetches and can commit a correct nonced_root for. + let predicted_local: std::collections::HashSet = + select_spotcheck_indices(&nonce, &path, AUDIT_SPOTCHECK_COUNT.clamp(3, 5)) + .into_iter() + .collect(); + + // Build a structurally-valid proof: correct `nonced_root` for predicted + // leaves (it holds those bytes), forged for every other leaf. let mut leaves = Vec::new(); - for idx in path.leaf_start..path.leaf_end { + let mut predicted_keys = std::collections::HashSet::new(); + for (local, idx) in (path.leaf_start..path.leaf_end).enumerate() { let k = built.tree().key_at(idx as usize).unwrap(); - leaves.push(ant_node::replication::subtree::SubtreeLeaf { + let is_predicted = predicted_local.contains(&u32::try_from(local).unwrap()); + let nonced_root = if is_predicted { + predicted_keys.insert(k); + nonced_block_root(&nonce, &r.peer_id_bytes, &k, &content(idx)) + } else { + *blake3::hash(b"forged").as_bytes() + }; + leaves.push(SubtreeLeaf { key: k, bytes_hash: content_hash(idx), - nonced_hash: *blake3::hash(b"forged").as_bytes(), + content_len: u32::try_from(content(idx).len()).unwrap(), + nonced_root, }); } let forged = SubtreeProof { @@ -436,14 +490,6 @@ fn predict_and_fetch_relay_is_caught_by_fresh_random_sample() { sibling_cut_hashes: plan.sibling_cut_hashes, }; - // The relay PREDICTS the old nonce-derived sample and fetches exactly - // those chunks (correct bytes for them only). - let predicted: std::collections::HashSet<[u8; 32]> = - select_spotcheck_indices(&nonce, &path, AUDIT_SPOTCHECK_COUNT.clamp(3, 5)) - .into_iter() - .filter_map(|i| forged.leaves.get(i as usize).map(|l| l.key)) - .collect(); - // Responder serves real bytes ONLY for the predicted set; everything // else it cannot serve (it holds no other bytes). let res = auditor_accepts( @@ -453,9 +499,7 @@ fn predict_and_fetch_relay_is_caught_by_fresh_random_sample() { built.commitment(), &forged, |k| { - // The relay can only serve bytes for the chunks it fetched (the - // predicted set); for those it returns the real content. - if predicted.contains(k) { + if predicted_keys.contains(k) { (0..n).find(|&i| &key(i) == k).map(content) } else { None @@ -492,14 +536,14 @@ fn fabricated_fraction_is_caught_when_a_forged_leaf_is_sampled() { let pin = r.commit_to_range(400); let (mut proof, commitment) = honest_proof_and_commitment(&r, &nonce); - // Forge every leaf's nonced hash. Under fresh-random sampling the auditor + // Forge every leaf's nonced root. Under fresh-random sampling the auditor // is guaranteed to open a forged leaf, so the audit must fail. for leaf in &mut proof.leaves { - leaf.nonced_hash[0] ^= 0xFF; + leaf.nonced_root[0] ^= 0xFF; } - // Even if the responder serves the REAL bytes in round 2, the freshness - // hash recomputed from that served content exposes the forgery. + // Even if the responder serves the REAL bytes in round 2, the nonced opening + // recomputed from that served content cannot fold to the forged root. let res = auditor_accepts( &r.peer_id_bytes, &pin, @@ -990,26 +1034,26 @@ fn signature_round_trips_correctly() { assert!(!verify_commitment_signature(&c2)); } -/// The per-leaf possession hash binds nonce, peer, key, and bytes — the -/// foundation of the real-bytes spot-check. Changing any input changes it, so a -/// responder cannot reuse a possession hash across nonces/peers/keys/chunks. +/// The per-leaf nonced block-tree root binds nonce, peer, key, and bytes — the +/// foundation of the possession opening. Changing any input changes it, so a +/// responder cannot reuse a nonced root across nonces/peers/keys/chunks. #[test] -fn nonced_leaf_hash_binds_all_inputs() { - let base = nonced_leaf_hash(&[1; 32], &[2; 32], &key(3), b"chunk"); +fn nonced_block_root_binds_all_inputs() { + let base = nonced_block_root(&[1; 32], &[2; 32], &key(3), b"chunk"); assert_ne!( base, - nonced_leaf_hash(&[9; 32], &[2; 32], &key(3), b"chunk") + nonced_block_root(&[9; 32], &[2; 32], &key(3), b"chunk") ); assert_ne!( base, - nonced_leaf_hash(&[1; 32], &[9; 32], &key(3), b"chunk") + nonced_block_root(&[1; 32], &[9; 32], &key(3), b"chunk") ); assert_ne!( base, - nonced_leaf_hash(&[1; 32], &[2; 32], &key(9), b"chunk") + nonced_block_root(&[1; 32], &[2; 32], &key(9), b"chunk") ); assert_ne!( base, - nonced_leaf_hash(&[1; 32], &[2; 32], &key(3), b"other") + nonced_block_root(&[1; 32], &[2; 32], &key(3), b"other") ); }