Replace full-chunk audit responses with BLAKE3 verified slices - #181
Replace full-chunk audit responses with BLAKE3 verified slices#181grumbach wants to merge 23 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR updates ant-node’s storage-commitment audit (ADR-0002) to replace round-2 “serve full chunk bytes” proofs with Bao/BLAKE3 verified 1 KiB slice openings plus a nonced per-block Merkle commitment, drastically reducing audit bandwidth while strengthening possession binding. It also wires in ADR-0005 audit-tally reporting so quote responses can carry a signed, nonce-bound audit report.
Changes:
- Replace round-2 full-chunk byte challenges with slice challenges (
SubtreeSliceChallenge/Response), including block-index sampling and dual-chain verification (Bao slice + nonced block-tree opening). - Introduce
replication::slice(Bao slice helpers + nonced block-tree build/open/verify) and update subtree leaf commitments to carrycontent_len+nonced_root. - Add ADR-0005 audit tally plumbing: record subtree-audit outcomes, persist them, and attach a signed audit report to quote responses; bump replication protocol ID to
v3.
Reviewed changes
Copilot reviewed 15 out of 16 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/poc_commitment_audit_attacks.rs | Updates attack PoC to the slice + nonced-root audit model. |
| tests/poc_audit_handler_live.rs | Live-handler tests for slice openings, including deep-block proof on multi-block chunks and cap enforcement. |
| src/storage/handler.rs | Adds optional signed audit-report sidecar to quote responses and test coverage for report verification. |
| src/replication/subtree.rs | Changes SubtreeLeaf to include content_len and nonced_root; removes old nonced_hash helper. |
| src/replication/storage_commitment_audit.rs | Implements slice round-trip request/response, random block selection, and responder slice-opening handler. |
| src/replication/slice.rs | New module: Bao slice extract/verify + nonced block-tree root/open/verify helpers and tests. |
| src/replication/protocol.rs | Wire types updated for slice challenges/responses; adds SubtreeSliceOpening; adjusts sizing test. |
| src/replication/mod.rs | Exposes new modules; integrates ADR-0005 tally persistence and recording hooks. |
| src/replication/config.rs | Adds MAX_SLICE_OPENINGS, slice response timeout, and bumps replication protocol ID to v3. |
| src/replication/audit.rs | Extends AuditTickResult::Passed to carry optional commitment_key_count for ADR-0005 tally. |
| src/replication/audit_tally.rs | New module: per-peer tally, snapshot tagging, report-row building, and quote-source adapter. |
| src/payment/quote.rs | Adds AuditReportSource and builds/sizes/signs nonce-bound audit reports for quote responses. |
| src/node.rs | Wires replication engine’s tally into the quote generator as an AuditReportSource. |
| docs/adr/ADR-0005-earned-reward-eligibility.md | New ADR describing earned eligibility model and report semantics. |
| Cargo.toml | Adds bao dependency and updates ant-protocol version/pinning. |
| Cargo.lock | Locks bao and updates ant-protocol resolution. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // 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()), |
| let nonced_siblings = crate::replication::slice::nonced_block_siblings( | ||
| &challenge.nonce, | ||
| &challenge.challenged_peer_id, | ||
| &key, | ||
| bytes, | ||
| block_index, | ||
| ) | ||
| .unwrap_or_default(); |
| "Audit: peer {challenged_peer} passed subtree audit ({} leaves, {checked} \ | ||
| byte-checked)", |
| # (the rc-2026.4.2 branch) so Cargo can unify the wire types here | ||
| # with ant-protocol's re-exports. | ||
| ant-protocol = "2.3.0" | ||
| ant-protocol = "3.0.0" |
| # ADR-0005 coordinated cutover: pin ant-protocol to the immutable rev of the | ||
| # audit-report wire-types PR (github.com/WithAutonomi/ant-protocol/pull/19). | ||
| # An immutable rev (not a mutable branch) keeps the build reproducible; the | ||
| # committed Cargo.lock records the exact resolution. STRIP BEFORE MERGE — | ||
| # replace with the published ant-protocol 3.0.0 once it lands on crates.io. | ||
| [patch.crates-io] | ||
| ant-protocol = { git = "https://github.com/grumbach/ant-protocol", rev = "2015113270bbde8204a1740674bed2ada52d2ca6" } |
| ), | ||
| }; | ||
| } | ||
|
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 16 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (4)
src/replication/slice.rs:257
extract_block_slicecopies the entire chunk (Cursor::new(content.to_vec())) before extracting a small Bao slice. For multi‑MiB chunks this adds an avoidable allocation and extra memcpy per opening;Cursor<&[u8]>works here and avoids duplicating the content buffer.
pub fn extract_block_slice(content: &[u8], index: u32) -> std::io::Result<Vec<u8>> {
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),
src/replication/storage_commitment_audit.rs:1207
handle_subtree_slice_challengereads chunk bytes from LMDB once per opening (get_raw_retryinginside the openings loop). Because the auditor can request two openings per sampled leaf (random + final), the same key can appear twice and will be read twice, doubling disk I/O and work for the common case. Consider de-duplicating openings by key (read each chunk once, then build all requested openings for that key while preserving response order).
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 {
src/replication/storage_commitment_audit.rs:821
- This success log still says "byte-checked", but
checkedis now the number of slice openings (block proofs) verified. The message is misleading when diagnosing audit behavior.
"Audit: peer {challenged_peer} passed subtree audit ({} leaves, {checked} \
byte-checked)",
Cargo.toml:207
ant-protocol = "3.0.0"is still being overridden via[patch.crates-io]to a Git rev, with a comment saying "STRIP BEFORE MERGE". This makes the build source ambiguous and can accidentally ship a git override to production. Either remove the patch when 3.0.0 is available on crates.io, or switch the dependency itself to the git source (and drop the patch) until the crates.io release exists.
# An immutable rev (not a mutable branch) keeps the build reproducible; the
# committed Cargo.lock records the exact resolution. STRIP BEFORE MERGE —
# replace with the published ant-protocol 3.0.0 once it lands on crates.io.
[patch.crates-io]
ant-protocol = { git = "https://github.com/grumbach/ant-protocol", rev = "2015113270bbde8204a1740674bed2ada52d2ca6" }
7102840 to
bae97e9
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 10 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
src/replication/slice.rs:257
extract_block_sliceclones the entire chunk withcontent.to_vec()just to create aCursor, doubling memory and adding an extra copy per opening. SinceCursor<&[u8]>works, pass the slice directly to avoid the allocation/copy (especially important when openings require full 4 MiB chunks).
let mut extractor = bao::encode::SliceExtractor::new_outboard(
Cursor::new(content.to_vec()),
Cursor::new(outboard),
src/replication/storage_commitment_audit.rs:1199
handle_subtree_slice_challengereads the same chunk from LMDB once per opening. Since the auditor can legitimately request two openings per sampled leaf (random + final block), this causes redundant reads for the same key and makes disk-read amplification easier for a forged auditor (even withinMAX_SLICE_OPENINGS). Consider grouping openings by key and reading each chunk at most once, while still returning items in the original requested order.
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) {
dirvine
left a comment
There was a problem hiding this comment.
Reviewed exact head bae97e9c867b5b1e3b00159cf316ef68a0708eaf with a six-seat panel (two groups of three), then independently reproduced the blocking cryptographic finding and ran the focused checks locally.
Verdict: changes requested. The bandwidth direction is good, and the Bao length-forgery fix is well tested, but the current construction does not yet establish the possession property claimed by the PR.
Blocking findings
1. High — nonce-independent BLAKE3 CV still permits deterministic under-storage
src/replication/slice.rs:51,99-115
The leaf input is:
domain(38) || nonce(32) || peer(32) || key(32) || index(4) || len(4) || block(1024)
The 142-byte prefix plus the first 882 block bytes fill BLAKE3 chunk 0. The final 142 block bytes form chunk 1 and are nonce-independent. A node can retain that chunk's 32-byte chaining value instead of the 142 original bytes, saving 110 bytes per full block / 10.7421875%, while reconstructing the exact round-one leaf/root for every fresh nonce.
I reproduced this against the production layout using BLAKE3's low-level subtree API: merge_subtrees_root(chunk0_cv, precomputed_chunk1_cv) exactly matched blake3::hash(prefix || block).
Round two still needs the missing suffixes, but at most ten openings require only about 1.4 KiB of missing data. The generous slice deadline therefore becomes security-relevant again, contrary to the comments claiming the preprocessing gap is closed and all bytes are required.
A fresh nonce-derived BLAKE3 key (new_keyed(nonce) or a domain-separated key derived from the nonce) makes every BLAKE3 chunk nonce-dependent and closes this specific attack. Merely prefixing the nonce does not.
2. High — round-two requests amplify full-chunk reads and synchronous hashing
src/replication/storage_commitment_audit.rs:1148-1203; src/replication/slice.rs:144-207,248-263; src/replication/mod.rs:2527-2594
Each opening independently:
- reads the full chunk from LMDB;
- creates a full Bao outboard and clones the full content;
- rebuilds the complete nonced tree.
The normal random-plus-final pair does this twice for the same key. Duplicate openings are accepted, so one ten-opening request can cause up to 40 MiB of reads plus repeated full scans/copies. Sixteen globally admitted responder tasks bound concurrency, but there is no rate/work budget or binding of round two to responder-held round-one state; repeated requests can keep the pool and Tokio workers busy.
Please coalesce by key, read each chunk once, reuse Bao/nonced tree state for all openings, reject/coalesce duplicates, and add byte/work-aware admission or bind the request to live round-one state. The CPU-heavy construction should not run unbounded on Tokio worker threads.
3. Medium — correlated mid-audit Bootstrapping preserves holder credit
src/replication/storage_commitment_audit.rs:351-357,782-785; src/replication/mod.rs:4471-4507
After serving a valid round-one proof, a responder can answer round two with a matching Bootstrapping response. It is mapped to Timeout; subtree timeouts are fully graced and preserve existing recent_provers credit. This responsive contradiction bypasses both confirmed-failure handling and bootstrap-claim-abuse accounting.
Treat it as a protocol failure, or at minimum revoke the pinned commitment's credit and route it through bootstrap-claim accounting.
4. Rollout gate — v3 partitions all replication operations
src/replication/config.rs:247-262; src/replication/mod.rs:1237-1257
The single global protocol ID is used for fresh replication, neighbour sync, verification, fetch, repair/pruning/possession audits and commitment fetches—not just the changed subtree audit. During a staggered v2/v3 rollout, DHT routing still mixes cohorts while cross-version peers exchange no replication traffic at all.
This needs either a dedicated/dual-registered audit protocol or a quantified and tested coordinated-rollout plan. The current test only asserts the string constant; it does not test mixed-version bootstrap, fresh replication or repair.
Design documentation
ADR-0002 is unchanged and still specifies full chunk responses, flat freshness hashes and deadline-based possession. Several code comments also still describe full bytes/one opening or incorrectly imply the Bao header check alone authenticates prefix-slice length. These are material security-invariant mismatches, not wording nits; please update the ADR and comments with the implementation.
Verified locally
cargo fmt --all -- --check— passcargo test -q replication::slice --lib --no-fail-fast— 15/15 passcargo test -q storage_commitment_audit --lib --no-fail-fast— 16/16 passcargo test -q --features test-utils --test poc_commitment_audit_attacks— 19/19 passcargo test -q --features test-utils --test poc_audit_handler_live— 9/9 passcargo clippy --all-targets --all-features -- -D warnings— pass- Max-chunk measurement: Bao slice 1,800 bytes + 12 nonced siblings/384 bytes per opening; about 22 KiB for ten openings before small envelope overhead.
Review-team consensus
All six seats completed. Both cryptographic reviewers independently reproduced the BLAKE3 preprocessing gap. Both runtime reviewers confirmed duplicate full-chunk work amplification. Both rollout/accounting passes confirmed the global v3 split and round-two bootstrap-credit path. No dissent on the blockers; the main qualification is that the CV attack still needs retrieval/delegation for the few opened suffixes.
CI is green except the Windows test job is still pending at review time.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 10 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
src/replication/storage_commitment_audit.rs:1125
nonced_siblingsshould never beNonefor an in-rangeblock_index(you already checkblock_index < count). Usingunwrap_or_default()would silently build an invalidPresentitem if an internal inconsistency ever occurs, causing honest nodes to fail audits and take penalties. Prefer failing the whole response with aTransientrejection so the auditor routes it to the timeout lane.
}
};
let nonced_siblings = opener.nonced_siblings(block_index).unwrap_or_default();
items.push(SubtreeSliceItem::Present {
key,
| /// Response to a [`SubtreeSliceChallenge`] (round 2). One item per requested | ||
| /// opening, in the requested order. |
dirvine
left a comment
There was a problem hiding this comment.
Reviewed exact head 712b4c994ecb04522d357f6330f7e398a5a13e2d (incremental from bae97e9c867b5b1e3b00159cf316ef68a0708eaf).
Verdict: changes still requested. The concrete CV attack, round-two duplicate work, responsive-bootstrap credit path, and global protocol partition are fixed. Two material items from the prior review remain, plus focused regression gaps.
Resolved
- Nonce-independent suffix CV: closed.
nonced_block_keybinds domain/nonce/peer/key and every leaf uses keyed BLAKE3, so every BLAKE3 chunk compression is nonce-dependent (src/replication/slice.rs:60-83,128-142). - Round-two repeated work: substantially fixed. Openings are coalesced per key, the chunk is read once, Bao/nonced state is reused, and CPU work runs through
spawn_blocking(src/replication/storage_commitment_audit.rs:1079-1131,1207-1298). - Responsive round-two
Bootstrapping: now revokes the pinned commitment's credit and becomes a confirmed rejection (src/replication/storage_commitment_audit.rs:360-367,796-808). - Rollout partition: core replication remains on v2; the four subtree-audit bodies use a dedicated protocol ID with symmetric body/ID guards (
src/replication/config.rs:245-282;src/replication/mod.rs:175-208,2342-2358,3360-3377).
Remaining blockers
1. High — expensive audit work is concurrency-bounded, but still not rate- or session-bounded
src/replication/config.rs:132-158; src/replication/mod.rs:2242-2309; src/replication/storage_commitment_audit.rs:995-1067,1171-1298
The 16-global/4-per-peer admission caps prevent unbounded fan-out, but a peer can continuously refill its four slots; a few identities can keep the shared pool saturated. Round two is still accepted from any source presenting a retained commitment hash, without a short-lived/single-use responder-side round-one session.
Round one is the heavier path and still builds each leaf synchronously on Tokio workers after the LMDB read. At the maximum legal subtree hint, a request may process 1,000 × 4 MiB = 3.906 GiB of chunk content; four in-flight requests from one peer are 15.625 GiB, and sixteen globally are 62.5 GiB of worst-case concurrent work. This can starve honest audits and turn dropped honest requests into timeout/failure accounting.
Please add a per-peer rate/work budget, bind round two to a live single-use round-one exchange, and move/bound round-one hashing off Tokio workers. Separate budgets for the much heavier subtree round one versus responsible/slice audits would also avoid one work class consuming the whole shared pool.
2. High — ADR-0002 still specifies a different protocol
docs/adr/ADR-0002-gossip-triggered-contiguous-subtree-audit.md:118-150,152-158,208-218,233-245
The ADR remains unchanged and still specifies flat freshness hashes, original full-chunk responses, deadline-led possession, “last two” retention, and a generic whole-network breaking rollout. The implementation now uses content_len + nonced_root, keyed block leaves, Bao random+final openings, TTL-based retained gossiped commitments, and a dedicated subtree protocol with bounded mixed-version trust effects.
Several source comments also retain removed names/semantics, including nonced_hash, verify_byte_response, full-byte serving, and one opening per leaf (src/replication/storage_commitment_audit.rs:49-53,478-487,1134-1141; src/replication/subtree.rs:235-242; src/replication/protocol.rs:892-895). For a possession protocol, this is normative design drift rather than a wording nit. Please update ADR-0002 and the stale comments in this PR.
3. Medium — the fixed production paths lack direct regressions
There are no focused tests driving:
- responder coalescing/one-read/one-build behavior and duplicate/interleaved requests;
ResponsiveBootstrapthrough scoped credit revocation and confirmed-failure classification;- an actual old-v2 subtree wire fixture being dropped on the core ID;
- dedicated request/response routing and correlation.
The helper tests are useful, but these were explicit prior findings and should be locked at the production-path boundary. Also reconcile the declared response contract (“one item per requested opening, in requested order”, src/replication/protocol.rs:927-942) with grouped/deduplicated emission (src/replication/storage_commitment_audit.rs:1207-1245): either restore original order/cardinality after reusing work, or document and test the coalesced order-independent contract.
Non-blocking hardening
verify_nonced_block accepts any sibling-vector depth that folds to nonced_root (src/replication/slice.rs:254-273). This does not recreate the deterministic under-storage attack—the root precedes fresh sampling—but enforcing the canonical depth from block_count(content_len) (including odd-tail self-pair semantics) would bind the claimed left-packed tree geometry and reject malformed work early.
Verification
Locally on this exact head:
cargo fmt --all -- --check— passcargo test -q replication::slice --lib --no-fail-fast— 16/16 passcargo test -q storage_commitment_audit --lib --no-fail-fast— 16/16 passcargo test -q --features test-utils --test poc_commitment_audit_attacks— 19/19 passcargo test -q --features test-utils --test poc_audit_handler_live— 9/9 passgit diff --check— pass
Current-head CI: builds, format, clippy, docs, security audit, and no-logging tests pass; Ubuntu/macOS/Windows test jobs remain pending.
Review-team result
Five delegated seats plus independent verification reviewed the exact SHA. The cryptographic/adversarial seats agree the concrete CV attack is closed; runtime/release seats agree the rate/session bound and ADR drift remain blockers. The only dissent was severity of canonical sibling-depth validation (low/non-blocking). A required direct Z.AI GLM-5.2 seat was attempted repeatedly but the direct API returned HTTP 429, so this is not represented as full six-model consensus.
|
Follow-up review on head |
712b4c9 to
0acca09
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 13 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
src/replication/slice.rs:362
ChunkOpener::newsays it “hashes the chunk once”, but it computes a Bao outboard plus keyed nonced leaves, which are separate hashes/passes. This is misleading for readers trying to reason about round-2 CPU cost.
/// Hash the chunk once: build its Bao outboard and its nonced block-leaves.
| /// Building the Bao outboard and the nonced block-tree leaves each hash the full | ||
| /// chunk. When one challenge opens several blocks of the same chunk (the normal | ||
| /// random + final pair, plus any duplicates), doing that work once and serving | ||
| /// every opening from it keeps a multi-opening challenge to a single hashing pass | ||
| /// over the bytes instead of one per opening (V2-685 round-2 amplification fix). |
| Subtree = 1, | ||
| /// Subtree audit round-2 byte challenge. | ||
| /// Subtree audit round-2 slice challenge. | ||
| Byte = 2, | ||
| } |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 13 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
src/replication/protocol.rs:1055
- The response is explicitly described as coalesced (items are per distinct (key, block_index), and
Absentis at most one per key), so this field doc is inaccurate: the response is not necessarily one item per requested opening (duplicates and key-levelAbsentcan reduce it).
/// One entry per requested opening.
| &bytes, | ||
| block_index, | ||
| ) | ||
| .unwrap_or_default(); |
| /// ([`SUBTREE_AUDIT_PROTOCOL_ID`]) so a version bump there cannot partition core | ||
| /// replication. A node filters inbound messages by exact topic match (see the | ||
| /// dispatch in `mod.rs`). | ||
| pub const REPLICATION_PROTOCOL_ID: &str = "autonomi.ant.replication.v2"; |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 14 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (3)
tests/poc_commitment_audit_attacks.rs:250
nonced_block_siblings(..)returningNonehere would indicate an invariant break (e.g. block_index out of range). Usingunwrap_or_default()would silently turn that into an empty sibling chain, which can mask fixture mistakes and make failures harder to diagnose. Prefer failing the test explicitly.
.unwrap_or_default();
src/replication/config.rs:294
- PR description says
REPLICATION_PROTOCOL_IDadvances tov3for the slice-audit cutover, but the implementation keeps core replication onv2and introducesSUBTREE_AUDIT_PROTOCOL_IDfor the subtree audit. Please reconcile the PR description/rollout notes with the code so reviewers/operators don't follow the wrong upgrade assumptions.
pub const REPLICATION_PROTOCOL_ID: &str = "autonomi.ant.replication.v2";
src/replication/protocol.rs:263
AuditDropKind::Byteis now documented as the round-2 slice challenge. The variant name no longer matches semantics, which can make audit-drop metrics harder to interpret. Consider renaming it toSlice(updating call sites), or explicitly documenting thatByteis a legacy name kept for metric stability.
/// Subtree audit round-2 slice challenge.
Byte = 2,
}
Testnet evaluation — 990-node DEV run, 9 acceptance criteria, all passRan this branch (
Results
The size reduction, with a same-network controlThe mixed-version cohort turned out to be the cleanest possible measurement. Both cohorts ran in one network, at the same time, under the same client load, differing only in the subtree-audit protocol version:
417× reduction with every confound held constant. The v2 figure independently reproduces what production is currently doing — I measured 14.49 KB rather than the ~6 KB implied by "~1000x" is expected and correct: round 2 now opens two blocks per leaf with two hash chains each. Round 1 costs nothing measurableThis was the regression I most expected, since the responder still reads every byte of each opened chunk off disk (per the Detection parity — note the test-design trapMy first attempt was an invalid test, and it is worth recording so nobody repeats it: deleting The valid construction zeroes chunk bytes in place with no restart, so the node keeps its identity, uptime and already-gossiped commitment while its bytes no longer hash to their addresses. (LMDB is mmap'd, so deleting the file under a live process achieves nothing — it keeps serving from the unlinked inode.) Done that way: 256 MB zeroed at 20:26:29Z → caught by three independent auditors at 20:55:31Z (+29 min, consistent with the 30-min per-peer cooldown), against a counter that had read 0 fleet-wide across ~43,000 round-2 responses over the preceding 5.5 hours. That is the Bao authenticity chain doing exactly what it is designed to do. What this run does not establishNode egress fell to 34.6 GB per GB uploaded (baselines: 82.0 and 85.7) on aligned windows, while moving more data. I am not attributing that to this PR. Round-2 audit egress this run was 1.04 GB out of 3,644 GB of total replication egress — 0.03%. At the old ~6 MB size those same responses would have been ~256 GB, so this PR accounts for roughly 6% of a ~4 TB gap. This branch carries 25 commits of main beyond v0.15.0 (audit scheduler, cooldown and admission fixes) that runs 470/471 predate, which is the more likely explanation. The same caveat applies to criterion 8's timeout improvement. Three notes on the PR itself
Method notes
|
The round-2 response classification lived inside request_slice_proof, mixed with the network send, so the boundary that decides whether a peer keeps or loses its holder credit had no direct coverage. Split the pure decision out as classify_slice_response and test it where it is made. Two behaviours are now pinned. A node that answers round 1 with a valid signed proof and then claims Bootstrapping in round 2 is contradicting itself, since a bootstrapping node has no committed data to prove; grading that as a graced timeout would be the cheapest way to dodge every possession check while keeping the credit round 1 earned, so it is a confirmed failure and the test asserts the credit policy on both sides of that line. And every arm is guarded on challenge_id, so a responder cannot answer the challenge it was sent with a softer body minted for a different one.
…strength Two review findings, both about claims that did not match the code. The digest lanes were not graced. Moving the responsible-chunk audit, the possession probe and prune confirmation onto their own protocol id means a v3 auditor's challenge to a still-v2 peer is never answered and times out. The subtree lane graces timeouts; these three did not — they reported an ApplicationFailure at AUDIT_FAILURE_TRUST_WEIGHT, the confirmed-failure weight. Every honest peer across the version boundary would have been penalised at that severity, repeatedly, for the whole upgrade window, for a skew that is not its fault. The config doc claiming otherwise was simply wrong. GRACE_POSSESSION_AUDIT_TIMEOUTS graces them for the window and MUST be removed in a follow-up: set it false, delete the constant, inline the guarded branches. While it is set, a peer that silently drops challenges is under-penalised — it still takes the upstream unit transport decrement, but not the audit-severity one. The branches stay compiled rather than commented out so they cannot rot while disabled, and rollout_gate_graces_only_timeouts pins that the gate graces a timeout and nothing else: a confirmed storage-integrity failure is still penalised throughout, so this is not an amnesty for cheats. The proof-strength claim was overstated. "Building the correct nonced root requires all of a chunk's bytes" is true but does not carry the weight it was given: a responder does not need the correct root. The auditor holds none of the audited bytes, so it cannot distinguish a correct root from a responder-chosen one. A peer keeping a fraction p of a chunk's blocks commits a root with genuine leaves for what it kept and arbitrary leaves for what it dropped, and passes whenever every draw lands on a kept block — about p^leaves per audit. The mandatory final-block opening anchors the claimed length but adds no detection, since a partial holder keeps the final block. So this is a sampling check: security close to what main gives at roughly a thousandth of the egress, where cheap audits buy frequent audits and frequency is the real lever against a partial deleter. partial_holder_passes_on_kept_blocks_and_fails_on_dropped_ones makes the property executable instead of asserted, and slice.rs, ADR-0007 and the PR description now say it plainly. Also unifies the freshness derivation that ADR-0007 claims is already unified: the slice lane derived its block key with a plain hash over a domain prefix while the possession lane used BLAKE3 derive_key mode. derive_key separates domains at the mode level rather than by convention, so the slice lane now uses it too, under its own context string, and a test pins that the two lanes share the construction while deriving different keys.
The prune lane collapsed a timeout and an undecodable reply into one None, so the rollout gate excused both. A peer that answers is being judged on what it said: an unanswered challenge is a version skew, but garbage bytes from a matched-version peer are a real protocol failure. Split the two at the source, where they are already distinguishable, and grace only the unanswered case. This makes the gate's stated contract true across all three lanes - it covers silence and nothing else, so no confirmed outcome goes unpenalised during the upgrade window.
The qualification was accurate but two headline claims still contradicted it: 'proving more than the old flat check' and 'security close to what main gives', both unqualified. Against a fine-grained partial deleter this is strictly weaker per audit than serving every byte, and a summary line should not imply otherwise. Separate the two axes that move in opposite directions. Freshness gets stronger - the nonce now enters every block leaf, closing a preprocessing gap in the old flat nonced_hash. Coverage gets weaker - round 2 samples blocks rather than checking every byte. Then split the comparison by threat: close to the full-byte audit against bulk under-storage, which is the realistic case and what the fleet run measured, and strictly weaker against a deleter shaving a little off every chunk, where audit frequency is the compensating lever.
Matches how ADR-0006 records its originating PR, so the decision can be traced back to the change that made it without a search.
The audit-proof ADR was numbered 0007, which main has since taken for the Windows LMDB map head-room decision. Renumber it to 0009. Correct its lane count: there are four lanes that ask a peer to prove it holds bytes, not three. The periodic responsible-chunk audit was missing. Record the two decisions that were made but not written down: what an unanswered round 2 costs, and why the responder needs a work bound that is not keyed by peer identity or by concurrency. Give the rollout grace on the digest lanes an owner and an objective removal criterion, rather than an open-ended "in a follow-up". While it is set the timeout penalty is suppressed for every peer, not only for peers on the old protocol, so it is a live weakening and should be tracked as one. REPLICATION_DESIGN still mandated the flat freshness digest this branch replaces, described the timeout as unconditionally penalised, and did not mention that the audit families have left the core protocol id. Bring all three into line with what ships. Restate the measured egress reduction as about 427x. The 417x figure came from dividing the displayed means rather than the raw byte totals.
Round 2 names the blocks to open only after the round-1 roots are committed, so a responder sees the draw before it decides whether to reply. Silence was fully graced and left holder credit in place, which made declining to answer cheaper than answering: an unfavourable draw cost nothing, and credit earned from an earlier favourable one survived every dodged check afterwards. The sampling probability then describes an answered pass rather than detection, which is not what the design claims. An unanswered round 2 following a valid round-1 proof now revokes the holder credit carried by the commitment under audit, the same treatment a transient reject already had. It stays in the graced timeout lane and takes no trust penalty, and the revocation is scoped to the pinned commitment, so an honest peer that drops a reply keeps its standing and re-earns credit on its next audit. A peer on the older protocol cannot reach this state at all, because it never completes the new round 1. The decision is a small predicate rather than a match arm so it can be tested directly, with a companion test on the scoping.
Two responder ceilings were missing. The heavy round-1 path was bounded by a two-slot pool and a thirty-minute per-peer cooldown. Both are keyed by peer identity or by concurrency, and neither bounds sustained work: a slot is refillable the moment it frees, and the cooldown is refillable by presenting a different peer id. At the largest commitment the system allows, one proof reads close to 4 GiB, so what could be sustained was set by the disk rather than by policy. Round 1 is now also charged against a responder-wide budget over the chunk bytes it reads and hashes, refilled at a fixed rate and keyed by nothing. The balance carries debt, so an expensive proof is admitted proportionally less often than a cheap one and the sustained rate settles at the refill rate whatever the caller's identity. It is sized above honest demand even at the worst commitment size, since starving honest audits would cost more than it saves. Charging happens after the proof is built, because the cost is not known until the commitment has been resolved and its subtree selected; only the proof path needs it, as every rejection a caller can provoke returns before a chunk is read. Separately, family, session and admission checks all read fields of a decoded message, so none of them can run before decoding, and the audit bodies carry variable-length collections under a 10 MiB ceiling sized for hint batches that no audit body contains. The encoded length needs no parsing, so the audit families now take their own ceiling checked before decode, sized against the largest legitimate audit body with a test pinning that headroom against the worst round-1 proof.
Neither verifier rejected a block index past the end of the chunk. Nothing the auditor generates is ever out of range, so this is about failing cleanly rather than a live hole: the block range clamps an over-large index to an empty range, which without the check reads back as a successfully verified empty block instead of the malformed request it is. Both verifiers now reject it outright. Restate the module's framing. The daily egress total moved with the audit rate rather than the response size, so quoting a fixed TB/day figure described a moment rather than the change; what this replaces is the cost of an individual audit, which holds whatever the rate does. The measured reduction is about 427x, not 1000x. Also record what an unanswered round 2 now costs, next to the sampling probability it qualifies: that probability describes detection only because declining to answer is no longer cheaper than answering.
Three findings from review, two of them resource-control blockers. The pre-decode ceiling did not bound what it claimed to. It was selected from the protocol id a message arrived on, so an audit body addressed to the CORE id skipped the 512 KiB audit ceiling entirely, was decoded under the 10 MiB core allowance, and was only then dropped by the family guard - after its collections had been allocated. A SubtreeSliceChallenge carrying 200,000 openings encodes to ~6.6 MB and sailed straight through. The ceiling now follows the body rather than the address. A body's postcard discriminant sits behind at most one varint, so it can be read before anything attacker-sized is touched, and family_of_variant maps it to a family. That one table now backs the pre-decode ceiling, the post-decode guard, the outbound response routing and both is_*_audit predicates, so the pre- and post-decode views of "which family is this" cannot drift apart again - the drift was the bug. A prefix too malformed to classify takes the strict ceiling, because a message we cannot classify is not one to decode generously. The 200,000-opening reproduction is now a test, alongside one that round-trips every variant through encode to prove the peek agrees with the decoded discriminant. Partial round-1 work was free. Round 1 reads and hashes leaf by leaf, but the work budget was charged only when the final response was Proof, on the reasoning that the rejecting paths either read nothing or reflected this node's own broken storage. The second half was wrong: a retained commitment holding one unreadable key still costs a full run of reads and keyed-BLAKE3 passes over every leaf before it, then rejects. An attacker who found such a commitment could replay subtrees over it indefinitely at no cost, and the per-peer cooldown does not catch that because it is escapable by rotating identity - the responder-wide budget is the only bound that applies, so it has to see the work. The responder now reports the content it read on every exit path, and the caller charges unconditionally; a zero charge is a no-op, so the paths that touch nothing are unaffected. The prune rollout decision had no direct test. The implementation already distinguished an unanswered challenge from an undecodable reply and graced only the former, but nothing asserted it: the generic gate test lives on the responsible-audit predicate and passed happily while the prune collapse was still present. Both lanes now expose the decision as a pure predicate with direct assertions - silence is graced while the gate is set, a wrong answer never is - so the distinction cannot regress unnoticed. Also aligns the comments that still described timeouts as immediately penalised, or as keeping credit, with the rollout gate and the commitment-scoped round-2 revocation actually shipped.
The pre-decode classifier reads two LEB128 prefixes before anything attacker-sized is touched, and its answer picks the size ceiling the message is then decoded under. So a prefix that classifies as something the real decoder would never produce is the one failure it cannot have. A u64 LEB128 has a single payload bit left in its tenth byte. Rust's shifts discard high bits rather than reporting value overflow, so `checked_shl` alone accepted a terminal payload above 1 and silently wrapped it: a crafted prefix could land on a variant index of the attacker's choosing, and with it the generous core ceiling. Overflow in either prefix field now yields no family, which falls to the strict audit ceiling - failing to classify has to mean "do not decode generously". Both fields are covered by tests. `Option::is_none_or` is stable from 1.82 and this crate declares 1.75, so the two uses that arrived with the peek move to `map_or`.
9fcc404 to
2021de9
Compare
The pre-decode ceiling added last commit bounds what an auditor can make a responder decode. The reverse direction was still open: every audit lane decoded the reply to its own challenge through the plain core path, so a challenged peer could answer a few-hundred-byte challenge with up to MAX_REPLICATION_MESSAGE_SIZE and make the auditor allocate and decode all of it. That is the same asymmetry the responder ceiling exists to close, pointed the other way, and it is worse per byte: the auditor picked the target, so an attacker only has to sit in a neighbourhood and wait to be asked. All five lanes - responsible-chunk, possession probe, prune confirmation, and both subtree rounds - now decode replies through decode_audit_response, which applies the same ~110 KiB-sized family ceiling before postcard sees the bytes. No discriminant peek is needed on this side: the auditor asked the question, so it already knows the family, and a reply that is not the body it expected is rejected by the lane's own matching. An oversized reply reads as MessageTooLarge, which every lane already treats as a malformed answer rather than as silence, so it is penalised throughout the rollout window rather than graced.
dirvine
left a comment
There was a problem hiding this comment.
Re-reviewed current exact head 2021de9f3c1cf4d45c0ab6a59a6fa86baa0bb0ea with six completed delegated seats plus independent code and runtime verification.
Verdict: changes requested. The three findings from the f64f662 review are addressed: wrong-protocol audit requests now take the body-family ceiling before decode, partial successful round-one leaf work is charged on rejected outcomes, and direct prune/possession rollout tests now distinguish silence from malformed replies. One response-side resource-bound gap remains.
Blocking finding
Medium — solicited audit responses still decode under the generic 10 MiB ceiling
src/replication/protocol.rs:65-84; src/replication/storage_commitment_audit.rs:211-232,326-353; src/replication/audit.rs:300-315; src/replication/possession.rs:325-347; src/replication/pruning.rs:1540-1565
The new 512 KiB family-aware guard is only in handle_replication_message. Request-response replies are intercepted by saorsa-core and delivered directly to each send_request caller, where all five audit paths call the generic ReplicationMessage::decode before checking the expected response type or collection count.
I reproduced this on the reviewed tree with a valid oversized AuditResponse::Digests:
encoded response = 6,400,007 bytes
audit cap = 524,288 bytes
generic cap = 10,485,760 bytes
ReplicationMessage::decode = accepted (200,000 digests allocated)
The responder must be the authenticated peer that was challenged, so this is less exposed than the earlier unsolicited/core-ID bypass. It is nevertheless material in this T3 PR because it contradicts the stated audit-family wire bound, and prune confirmation can have up to 32 solicited responses in flight. Apply MAX_AUDIT_MESSAGE_SIZE before every audit-response decode, preferably through one shared bounded audit decoder, and add a response-side regression using an oversized AuditResponse or SubtreeSliceResponse.
Review-team dissent / non-blocking follow-up
A persistent LMDB error on the first selected round-one leaf performs three attempted reads and two 200 ms backoffs but reports content_bytes == 0, so the responder-wide byte budget is not debited. Two seats treated this as blocking; one treated it as bounded unhealthy-storage behaviour because no chunk bytes are returned or hashed, and I agree with the latter classification under a material bar. A fixed attempted-read charge plus a first-leaf-error regression would still make the documented resource guarantee more complete.
Verification
cargo fmt --all -- --check— passgit diff --check— passcargo test -q --lib --no-fail-fast— 822/822 pass on the reviewed resource-control treecargo test -q --features test-utils --test poc_audit_handler_live— 13/13 pass- focused protocol/prune/possession/storage-audit filters — pass
- current-head CI — completed jobs pass; macOS/Ubuntu/Windows tests and Windows build were still pending at final check
The cryptographic construction, selective-silence handling, protocol-family routing, ADR/design alignment, partial-success work accounting, and prune regression coverage now look sound. This remaining issue is the other half of the same pre-decode boundary: replies need the audit ceiling too.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 19 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
src/replication/config.rs:274
- The constant
BYTE_AUDIT_RESPONSE_FLOOR_SECSis now documented as the floor for the round-2 slice challenge, but its name still implies the old byte-challenge semantics. If you want to keep the historical name, it would help to explicitly call that out here to avoid future confusion (especially since there is alsoAUDIT_RESPONSE_FLOOR_SECS).
/// 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;
The rollout gate only softens what a node on this release concludes about a silent peer. It cannot reach the other side, and the other side is the one that matters here. A peer on the previous release sends its responsible-chunk and prune-confirmation challenges on the core protocol id, because the dedicated possession id does not exist in its binary. This branch dropped those at the family guard, deliberately: answering with a digest from a different generation would read as a mismatch, a confirmed failure against an honest peer. That reasoning weighed one side only. The old peer does not treat silence as neutral - it converts a possession timeout straight into an audit-severity failure. So dropping the challenge costs the honest upgraded node exactly what a mismatch would, from every neighbour that has not upgraded yet, for the whole window, and worst at the start when almost every neighbour is one. That is the release penalising the nodes that took it, and no constant here can reach the auditor to stop it. So they are answered, on the id they arrived on and in the construction the asker verifies with. The two possession messages are byte-identical across the two releases - only the digest construction moved - so nothing is being reinterpreted, and the responder picks the dialect from the inbound id rather than from what it would use itself. Challenges only: this node always asks on the dedicated id, so a possession response on the core id is never one it solicited, and that stays refused. The legacy construction is pinned by an exact-equality test, because it is not ours to choose: a drift is invisible locally and shows up in the field as honest upgraded nodes failing old peers' audits. Paired with GRACE_POSSESSION_AUDIT_TIMEOUTS in the code and in ADR-0009, and removed with it under the same criterion.
… proof Three findings from an independent adversarial pass. The pre-decode ceiling only covered inbound requests. A response is decoded by whichever lane asked, and an audit lane asks a question a few hundred bytes long, so a challenged peer could answer with megabytes and be allocated all of it. Last commit fixed the five audit lanes at their call sites; that was the wrong altitude. The ceiling now lives inside decode itself, chosen from the body's own family, so no decode site can be reached that forgot to ask. Core bodies keep the core allowance, since a fetch response legitimately carries a whole chunk, and decode_audit_response still applies the audit ceiling regardless of body - the two together are min(what this body may be, what this lane asked for). The round-1 work budget counted content bytes, which is the right unit for the hashing and the wrong one for everything else a leaf costs: an LMDB lookup with its retries, and a blocking-task dispatch and join, all owed whatever the chunk weighs. Nothing bounds a chunk from below, so a commitment made of a million tiny records ran a full subtree of lookups per audit against almost no budget, and a leaf that failed to read was free outright. With the per-peer cooldown escapable by rotating identity, the budget is the only bound that applies. A leaf now costs max(bytes, 4 KiB), charged at the attempt and topped up after the read, so an honest chunk still pays exactly its bytes and the refill sizing above is unchanged. The live-handler tests carried the old meaning and now carry the new one; their records are 1 KiB, which is precisely the case that used to be nearly free. Finally, a comment claimed the deadline was not load-bearing because the round-1 root is uncomputable without all the bytes. True but overstated: the inputs are all public, so the root binds that SOMEONE holding the bytes computed it, not that the audited peer did. The full-byte round 2 did not prevent that either - it priced it, by forcing the chunk through the relay under a tight deadline - and this design removes the price. That makes a warehouse behind many identities cheaper to run than before, which is a replication-multiplicity gap, not a possession one. Stated plainly in the comment and recorded in ADR-0009 as a known gap owned with the provenance work, rather than left implied as solved.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 19 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
tests/poc_commitment_audit_attacks.rs:250
nonced_block_siblings(..)returningNonefor an in-rangeblock_indexwould currently be silently treated as “no siblings” viaunwrap_or_default(). Because these fixtures are single-block chunks (tree depth 0), that would let this test keep passing even if sibling extraction regressed to always returnNone, weakening the audit gate coverage this PoC is meant to pin.
Prefer failing loudly here (e.g., .expect(...)) so a regression in sibling generation is caught by this test.
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();
…ewed cases Two items from review, both already closed by the last two commits; this adds the reproductions as tests and finishes a comment sweep the first one exposed. The blocking one was the response-side ceiling, found independently on the older head: a solicited reply never passes the receive path's guard, because request-response delivers it straight to the caller, so every audit lane decoded it under the core allowance. The reviewed reproduction is now a test verbatim - a valid AuditResponse::Digests carrying 200,000 digests, 6.4 MB, above the audit ceiling and below the core one - asserted against both the lane's decoder and the general one, so neither is load-bearing alone. The non-blocking one was a round 1 that fails on its FIRST leaf: no bytes returned, nothing hashed, but three attempted reads and two backoffs already spent, and the old accounting reported exactly zero. The per-leaf floor charges the attempt, and a first-leaf regression now pins that it costs the floor and exactly the floor. The sweep: correcting the deadline comment last commit left four other places still saying the proof is non-delegable, or that a relay is priced out by the deadline. That was true of the full-byte shape and is not true of this one, so saying it anywhere makes the ADR's honesty about the gap worthless. All four now say what the deadline actually is, which is a liveness bound. ADR-0002 carries the same superseded claim and is left untouched, as accepted ADRs are; ADR-0009 now records that it supersedes it. Also makes the asking id one shared function rather than three hand-written constants. During the rollout window the id a challenge goes out on selects the construction the answer comes back in, so a lane that asked on the core id would be asking for the superseded, preprocessing-weak digest. A lane cannot get that wrong if it cannot choose, and a test pins that what this release asks on is answered in the current construction. That is what bounds the legacy answering path: the asker picks the dialect and the responder is the one who gains from a weak one, so a malicious holder cannot elect to be asked weakly.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 19 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
tests/poc_commitment_audit_attacks.rs:377
- This comment says the relay fabricates the proof from public data only, but the loop below reads fixture bytes via
honest_bytes(&k)to derivecontent_len. That’s fine for isolating the possession failure, but the comment should reflect it to avoid implying the relay knows the full content.
// 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_root.
dirvine
left a comment
There was a problem hiding this comment.
Re-reviewed exact head 56210cc707b75bce2311004c5042bcac26caacf5 with six completed delegated seats plus independent local verification.
Verdict: changes requested. The response-side ceiling blocker is fixed, the previous zero-charge first-leaf path is now non-zero, and the legacy challenge compatibility path is correctly scoped. One material sustained-work issue remains.
Blocking finding
High — the 4 KiB leaf floor does not make fixed per-leaf work hit the global budget
src/replication/config.rs:162-246; src/replication/mod.rs:3159-3225,3269-3305,3773-3816; src/replication/storage_commitment_audit.rs:67-99,1220-1300
The new charge is correctly placed before the read and totals max(content_len, 4 KiB) per attempted leaf. That fixes the literal zero counter. The calibration, however, leaves the identity-independent budget non-binding for the fixed work it now claims to cover: LMDB lookups/retries, permit occupancy, and one blocking-task dispatch/join per successful leaf.
Using the shipped constants:
work refill 64 MiB/s
attempt floor 4 KiB
persistent first-leaf failure 3 reads + 2 × 200 ms backoff
2 heavy slots / 400 ms ~5 requests/s
charge at that rate 20 KiB/s
refill / charge 3,276.8×
The two heavy slots can therefore remain occupied while the budget stays full. The healthy-storage variant has the same problem: a legal 1,024-leaf tiny-record proof is charged 4 MiB, so the bucket permits 16 such proofs/s — 16,384 LMDB lookups and blocking-task round trips/s — before it binds. In practice the two-slot pipeline saturates first, so rotating identities can continuously capacity-drop honest subtree audits without consuming the identity-independent allowance.
The new first-leaf test covers Ok(None) by deleting the record. It does not exercise persistent Err and its retries/backoffs, and the budget tests prove debt mechanics rather than that the chosen floor constrains this work.
Please keep the byte bucket for content read/hash, and add a separately calibrated responder-wide attempt/IOPS budget (charging each read attempt/retry and blocking dispatch), or otherwise justify and test a floor that becomes limiting before the heavy pool can be held continuously. Add regressions for sustained fresh-identity tiny-record work and persistent first-leaf Err, not only one Ok(None) attempt.
Resolved
- All five solicited audit response paths now use the 512 KiB audit decoder before postcard allocation.
- The exact 200,000-digest / 6.4 MB reproducer is rejected by both the audit decoder and the generic family-aware decoder.
- First-leaf
Ok(None)/Errand later failures can no longer report zero work. - New nodes always ask on the dedicated possession ID; legacy core-ID challenges alone are accepted during the rollout gate and answered using the old digest on the old ID. Core-ID possession responses remain refused.
- ADR-0009 now states the compatibility weakening and proof delegability honestly.
Review-team dissent
The completed panel was split on calibration: three focused/adversarial seats treated the underpriced operation work as blocking; one broad seat approved; the compatibility seat approved; the ceiling seat found only an unknown-discriminant hardening point. I independently classify the unknown-discriminant point as non-blocking because postcard rejects an unknown enum arm before decoding any trailing collection.
Verification
cargo fmt --all -- --check— passgit diff --check origin/main...HEAD— passcargo test -q --lib --no-fail-fast— 829/829 passcargo test -q --features test-utils --test poc_audit_handler_live— 14/14 passcargo test -q --features test-utils --test poc_commitment_audit_attacks— 19/19 pass- exact response-ceiling, first-leaf and compatibility filters — pass
- current-head CI — all completed checks pass; macOS/Ubuntu/Windows test jobs were still pending at final check
Linear issue
Risk tier
This changes the possession-proof representation itself. A weak slice proof would let under-storing nodes pass audits and keep holder credit, so it sits squarely in T3.
Compatibility
replication::slicemodule and public helpers; metric field names deliberately held stable.Wire detail. The four subtree-audit bodies move to
SUBTREE_AUDIT_PROTOCOL_ID(autonomi.ant.replication.subtree-audit.v1) and theAuditChallenge/AuditResponsepair toPOSSESSION_AUDIT_PROTOCOL_ID(autonomi.ant.replication.possession-audit.v2). Round 1'sSubtreeLeafcarriescontent_len + nonced_rootinstead of a flatnonced_hash; round 2'sSubtreeByteChallenge/ResponsebecomeSubtreeSliceChallenge/Response. A receive guard drops any body arriving on the wrong family id, and responses are routed by the same rule.Core replication stays on
autonomi.ant.replication.v2, so fresh replication, neighbour sync, verification, fetch, repair and commitment fetch keep working across versions for the whole upgrade window. Only cross-version audits pause — see Mitigation / rollback for the trust-accounting effect during that window.API detail.
SubtreeLeaf's field changes are within an already-public replication type. The metric field names (subtree_byte_*,audit_dropped_byte) are held stable on purpose: they are what the V2-685 testnet queried to compare a 0.15.0 cohort against this branch with no field mapping, and renaming them would silently zero existing dashboards.Semver impact
Stated precisely, since a review pass raised it: the Rust API is source-breaking for a library consumer.
ReplicationMessageBody::SubtreeByteChallenge/Responseare renamed to...Slice...andSubtreeLeafchanges fields, and both are publicly reachable throughant_node::replication::protocol. On a0.xcrate that is a minor bump, which is what "feature" delivers, so the box is right either way. Nothing outside this repo consumes these types today — the only callers are the crate's own tests — so no deprecated aliases are provided.Test evidence
Testnet (jacderida, 990 nodes / 66 VMs, DEV-01, 07-27): 9/9 acceptance criteria pass. Full data in the testnet registry, row 472.
The mixed-version cohort gave a same-network control — both cohorts in one network, at the same time, under the same client load, differing only in the audit protocol version:
About 427x smaller, with every confound held constant. The v2 figure independently reproduces production, measured at 5.79–6.15 MB/response on
ant-prod-01across four separate days.Other criteria: corrupted node caught by 3 independent auditors in +29 min (consistent with the 30-minute per-peer cooldown); zero false positives (
digest_mismatch/key_absent/malformedall 0 across ~43k audits over 5.5 h); 4,049/4,049 uploads and 797/797 downloads; 736/736 and 135/135 mixed-version transfers with zero failures; all 7 v2 hosts grew bothchunks.mdbandpaid_list.mdb, i.e. the v2 cohort kept accepting paid stores from v3 peers throughout; round-1 CPU 41.3% vs 45.7/45.2 baseline and RSS 908 MB vs 916/901 — no measurable regression despite round 1 now building a nonced Merkle tree over all block bytes; audit-timeout rate 1.57/service-hour vs run 470's ~178.5; 990/990 services up, 0 restarts, 0 failed units (both baselines showed 2/990 and 5/990 panics on 0.15.0).Detection was validated with a construction that zeroes chunk bytes in place with no restart, so the node keeps its identity, uptime and already-gossiped commitment while its bytes no longer hash to their addresses. Deleting
chunks.mdband restarting is an invalid test — the node rebuilds its commitment from the empty store and then honestly declares holding nothing, which is correct behaviour, not cheating.What the fleet run does and does not cover. It ran at
78cc1b0, and the branch has moved since. The measurements that carry — round-2 response size, round-1 CPU and RSS, detection of an in-place corrupted node, mixed-version transfer success — are all properties of the proof shape, which has not changed since. What landed after the run and therefore has no fleet evidence: the three digest lanes moving to their own protocol id (withGRACE_POSSESSION_AUDIT_TIMEOUTScovering the resulting cross-version silence), the pre-decode ceiling now classifying every inbound replication message by its own discriminant, the responder-wide round-1 work budget being charged on every exit path, and round 2 revoking commitment-scoped credit on silence. The first two touch paths every message crosses, so a short re-run on the final head before this rides a train is the honest close — happy to spec it narrowly.Local (on the current head, rebased onto
mainat 0.16.0):cfdclean: clippy-D warnings, fmt,cargo doc --deny=warnings.replication::slice,storage_commitment_audit, the round-2 credit and work-budget tests, the family message ceilings, and the rollout dialect selection.poc_commitment_audit_attacks19/19: relay-holds-addresses, predict-and-fetch under fresh sampling, fabricated-fraction, deleter, substitution / replay / structural.poc_audit_handler_live14/14: the production responder against real LMDB, including a deep-block opening of a multi-block chunk verified on both chains, responder coalescing under duplicate and interleaved openings, and the round-1 work charge on a served proof, a mid-run rejected read, and a first-leaf failure.tests/e2eaudit + prune 10/10: real multi-node QUIC + LMDB. Honest node passes, data-deleting node is caught, repeated audits produce no false positives.Rollout compatibility. A peer on the previous release sends its possession challenges on the core protocol id and converts an unanswered one into an audit-severity failure. Those challenges are answered rather than dropped, on the id they arrived on and in the digest construction that peer verifies with, so the release does not penalise the nodes that take it. That is a temporary weakening for old-auditor pairings only, bounded and never below what is deployed today, and it is removed together with
GRACE_POSSESSION_AUDIT_TIMEOUTS. ADR-0009 records both halves and the criterion.New dependency
bao = "0.13.1"— Bao verified-slice encoding over BLAKE3. Shares theblake3crate already in the tree. This is the mechanism that makes a 1 KiB opening verifiable against a chunk's existing content address, and is what the egress reduction rests on. Needs explicit acknowledgement at review.ADR
https://github.com/WithAutonomi/ant-node/blob/v2-685-blake3-verified-slice-audit/docs/adr/ADR-0009-audit-proof-shape-and-protocol-families.md — ADR-0009: Audit proof shape and protocol families.
Numbered 0009 rather than 0007:
maintook 0007 for the Windows LMDB map head-room decision while this branch was in review.ADR-0002 is left byte-for-byte as it was: it records what was decided then. ADR-0009 records what replaces its round-2 proof shape, the decision to unify freshness derivation across all four audit lanes, and the decision to give each changed message family its own protocol id. Every ADR already on
mainis byte-identical here; this branch adds one file and touches no other.docs/REPLICATION_DESIGN.mdis updated in the same commit, since it is the normative document and still mandated the flat freshness digest this branch replaces.Mitigation / rollback
Revert the branch. No stored data changes, so a rollback needs no migration: nodes rebuild commitments from their existing store either way. Because core replication never left
autonomi.ant.replication.v2, a partially-rolled-back fleet keeps replicating normally — the only effect is that audits pause between mismatched versions, landing in the timeout lane whichGRACE_POSSESSION_AUDIT_TIMEOUTSholds penalty-free for the window. Audit rate and admission caps are config constants, so audit load can also be reduced without a code change.Follow-up owed: remove
GRACE_POSSESSION_AUDIT_TIMEOUTS(setfalse, delete the constant, inline the guarded branches) once the fleet is past the upgrade window, restoring the timeout penalty on the three digest lanes. ADR-0009 now records the owner and an objective criterion for that: at least 99% of nodes seen in the routing table over a seven-day window on the release carrying this change, and no supported release still on the old audit protocol in the field. While the gate is set the suppression applies to every peer, not only old ones, so this should stay open rather than be closed with this PR.What this changes and why
Round 2 of the storage-commitment audit made the audited node return the complete original bytes of the nonce-sampled chunks, up to two full 4 MiB chunks per response. This replaces that with a verified slice: a few KB per opened 1 KiB block, while proving more than the old check.
Framing the benefit correctly, since the original justification has aged: the "~4.2 TB/day" figure predates 0.15.0. Querying V2-623 counters on
ant-prod-01,subtree_byte_responsewas 5.98 TB/day on 07-22 and 32.3 TB/day on 07-24, then fell to 0.46 and 0.22 TB/day on 07-26/27 as the fleet auto-upgraded to 0.15.0. Response size held at 5.8–6.2 MB throughout; only the rate moved. So the incremental saving on today's fleet is nearer 0.2–0.5 TB/day than 4.The stronger argument is that this bounds the cost per audit event regardless of rate, which frequency caps cannot do. On 07-24 the audit rate spiked and cost 32 TB/day at 6 MB per response; after this change that same spike costs ~32 GB.
How
A chunk's address is
BLAKE3(content), and BLAKE3 is internally a Merkle tree over 1 KiB blocks, so a Bao verified slice proves a block is the real content at a given offset against the address the auditor already knows — content authenticity for free. But the address is public, so authenticity alone is delegable: a node storing nothing could pass by fetching the block on demand from an honest holder. Possession must be bound separately.So round 1 additionally commits, per leaf, a fresh nonced block tree: a Merkle root over the same 1 KiB blocks whose leaves are keyed BLAKE3 hashes,
keyed_BLAKE3(key = f(nonce ‖ peer ‖ key), block_index ‖ len ‖ block). The nonce is fed as the BLAKE3 key, not a message prefix, so it mixes into every chunk compression of every leaf — there is no nonce-independent chaining value to precompute. The auditor draws the block to open with fresh randomness after the roots are committed (cut-and-choose), and round 2 verifies both chains over the same block bytes.A responder cannot fold an after-the-fact-fetched block to a garbage committed root without a preimage break.
What a pass proves — stated honestly
Against the realistic threat this keeps security close to what
maingives, at roughly 1/430 of the egress. It is a sampling check, and the claim should be read that way rather than as a proof of complete possession.The auditor holds none of the audited bytes, so it cannot tell a correct
nonced_rootfrom a responder-chosen one. A peer retaining a fractionpof a chunk's blocks passes whenever every fresh draw lands on a kept block — roughlyp^leavesper audit over the 3–5 sampled leaves. The mandatory final-block opening anchors the claimed length but adds no detection, since a partial holder simply keeps the final block. The full-byte round 2 this replaces caught any missing byte of a sampled chunk with certainty.That difference is inherent: verifying every byte means moving every byte. Stating the comparison in both directions:
partial_holder_passes_on_kept_blocks_and_fails_on_dropped_onesinreplication::slicemakes the property executable rather than asserted.For
p^leavesto describe detection rather than merely a pass, it also has to cost something not to answer — see the next section.Round 2 changes: silence is no longer free
Round 2 names the blocks to open only after the round-1 roots are committed, which is what makes the sample a cut-and-choose. The same ordering means the responder sees the draw before deciding whether to reply. Silence was fully graced and left holder credit in place, so an unfavourable draw cost nothing while credit from an earlier favourable one survived. The published probability then describes an answered pass rather than detection.
An unanswered round 2 following a valid round-1 proof now revokes the holder credit carried by the commitment under audit — the same treatment an explicit
Transientreject already had. It stays in the graced timeout lane and takes no trust penalty, and the revocation is scoped to the pinned commitment, so credit re-earned under a newer commitment survives. An honest peer that drops a reply keeps its standing and re-earns credit on its next audit; a peer on the older protocol cannot reach this state, because it never completes the new round 1.The decision is a small predicate (
round2_revokes_pinned_credit) rather than an inline match arm, so it is pinned by a direct test, with a companion test on the scoping.Responder work bounds
Round 1 had a two-slot pool and a per-peer responder cooldown. Both are keyed by peer identity or by concurrency, and neither bounds sustained work: a slot refills the moment it frees, and a per-peer cooldown refills by presenting a different peer id.
Round 1 is now also charged against a responder-wide budget over the chunk bytes it reads and hashes, refilled at a fixed rate and keyed by nothing at all. The balance carries debt, so an expensive proof is admitted proportionally less often than a cheap one and the sustained rate settles at the refill rate whatever the caller's identity. It is sized above honest demand even at the largest commitment the system allows, since starving honest audits would cost more than it saves — at a mid-sized commitment the headroom over measured honest load is more than an order of magnitude.
Charging happens after the proof is built, because the cost is not known until the pinned commitment has been resolved and its subtree selected. Only the proof path needs charging: every rejection a caller can provoke returns before a chunk is read, and the two paths that do read first turn on this node's own storage rather than on anything the caller sends.
Bounding work before admission
Family, session and admission checks all read fields of a decoded message, so none of them can run before decoding. That made decoding the first thing an unknown peer could demand, under a 10 MiB ceiling sized for hint batches that no audit body carries.
The encoded length needs no parsing, so the audit families now take their own wire ceiling, checked before decode and sized against the largest legitimate audit body — the round-1 proof at the commitment key-count cap. A test builds that worst case and pins the headroom, so a later change to leaf size or commitment encoding cannot quietly start dropping honest proofs.
Binding the content length
content_lenandnonced_roottravel in the round-1 leaf but are not covered by the signed commitment root (it hashes onlykey ‖ bytes_hash), so a malicious responder can claim any length. Unchecked, that forges possession two ways: a deflated length collapses the block count so only block 0 is ever opened, and Bao does not bind the slice length header for a prefix slice that never reaches EOF.Both are closed on the auditor side:
verify_block_slicerequires the slice's own length header to equal the claimedcontent_len, and the auditor also opens the claimed final block per sampled leaf. The final-block decode reaches EOF, where Bao authenticates the encoded length against the address, so a forged-short length fails there. Signingcontent_leninto the commitment would not help — the attacker would just commit the false length from the start; only anchoring it against the address does.Both verifiers now also reject a block index past the end of the chunk. Nothing the auditor generates is ever out of range, so this is about failing cleanly rather than a live hole: the block range clamps an over-large index to an empty range, which would otherwise read back as a verified empty block.
Unifying the audit lanes
There are four lanes that ask a peer to prove it holds bytes: the subtree audit, plus three digest-based callers — the periodic responsible-chunk audit, the post-replication possession probe, and prune confirmation. The digest lanes derived their freshness by prefixing the challenge material to the hashed stream, which binds the nonce to the leading bytes rather than the whole content. All lanes now use one construction: derive a BLAKE3 key from the challenge material under a versioned domain-separation context, then hash the content under that key.
That changes what an older peer computes for the same bytes. Left on the shared core id, a cross-version exchange would read as a confirmed digest mismatch and penalise an honest peer for the whole upgrade window — hence the dedicated protocol ids described under Compatibility.
Also: the responsible-chunk audit no longer reports a pass when a challenged key's own local copy is unreadable. That key cannot be checked, so the tick yields no verdict rather than a pass reflecting only the readable keys.
Why not the literal proposal
The ticket's original wording (verify the slice only against the chunk address, with a nonce-derived offset) is unsound: it drops the possession binding entirely, so a storage-less relay farms audit passes. That defeats proof-of-storage and lets one warehouse back many replica identities, breaking replication multiplicity. The offset must also be fresh post-proof randomness, not nonce-derived, or the cut-and-choose collapses. This PR implements the corrected dual-chain design.
Commits
Replace full-chunk audit responses with BLAKE3 verified slices— the dual-chain slice audit.Authenticate slice-audit content_len to defeat possession forgery— the content-length binding (header check + final-block open).Harden slice audit and isolate its protocol id— dedicated subtree-audit protocol family.Address audit review: resource bounds, geometry, contract— admission caps, canonical sibling depth, response contract.Tidy slice-audit comments and lock responder coalescing with a live test— coalesced/order-independent round-2 contract, locked by a live responder test.Close two audit holes: key-content forgery and round-1 work amplification—bytes_hash == keyenforcement and round-1 work bounds.Bind every audit lane to the fresh nonce and isolate the audit protocols— the unified keyed digest and the possession-audit protocol id.Take the responder permit before consuming the round-2 session— admission ordering fix so a transient capacity drop no longer permanently kills a round-2 exchange.Lock the round-2 accounting boundary with direct tests— round-2 classification as a pure function, tested at the production boundary.Grace audit timeouts through the rollout, and state the proof's real strength— the rollout gate, and the corrected strength claim.Grace only silence in the prune audit lane— split "no answer" from "undecodable answer" at the source.State the strength comparison in both directions— both-directions framing in the docs.Cite the PR and issue in the ADR's Related field.Renumber the audit ADR and align the replication design doc— ADR-0009, corrected lane count, grace-removal criterion and owner,REPLICATION_DESIGNbrought into line.Cost an unanswered round 2 the pinned commitment's credit.Bound audit responder work by rate and by message size.Reject out-of-range block openings, and restate the egress figures.Related
Supersedes #186, which pursued the same egress goal by having the auditor verify co-held leaves against its own copy. Its keyed-digest and Idle-on-unreadable changes are carried here (commit 7); its local-verify optimisation is not, because once round 2 is 14.49 KB the auditor-side cost of reading and hashing its own 4 MiB chunk exceeds the wire bytes it would save. Reasoning recorded on that PR.
The ADR-0008 payment-economics document that briefly appeared on this branch has been dropped: it belongs to #188, which has since merged to
main. This branch now carries no ADR content other than its own.