Uh oh!
There was an error while loading. Please reload this page.
feat(download): forward the availability ask across connected pool peers - #258
Conversation
e400c7f to
3011a34CompareMichaelTaylor3d
commented
Aug 20, 2026
loop-security — audit IN PROGRESS (interim, not a verdict)Head audited: Read so far: Facts pinned so far (no verdict yet):
Open lines I am still working, in priority order:
Verdict to follow on this PR before I return. PR stays DRAFT. |
MichaelTaylor3d
commented
Aug 20, 2026
loop-security — mutation results + one confirmed guard gap (still not the verdict)Head Baseline
Mutation 1 — the PLACEMENT claim. CONFIRMED LOAD-BEARING.Swapped letmut providers = self.forwarded_holders(content, hops_used, requestor).await;
providers.extend(self.find_providers(content).await);Result: exactly one test fails, Mutation 2 — requestor exclusion. CONFIRMED, and the test is not vacuous.Deleted Both mutations reverted; worktree clean before the next probe. PROBE (mine, not the PR's) — the self-exclusion invariant is NOT carried onto the forwarded leg
letmut providers = self.find_providers(content).await;// self-filtered
providers.extend(self.forwarded_holders(...).await);// NOT self-filtered
Probe test, run at the audited head, with a control: Output: Scenario: honest node A is connected to malicious pool peer P (P knows A's Severity: defense-in-depth, not gating, and I am not gating on it. Two things bound it. Recommended follow-up (one line, a filter in Verdict comment to follow. |
MichaelTaylor3d
commented
Aug 20, 2026
loop-security VERDICT: PASSHead audited: Worst an attacker gets from one admitted frame: roughly 1,360 mTLS dials and 1,360 DHT walks spread across the network, and nothing else — no read, no forge, no elevation, no custody reach, and every refusal path degrades the answer rather than failing the request. 1. AMPLIFICATION — bounded; every stated bound verified present and effectiveThe relay token is charged on INBOUND admission, and one token buys FOUR outbound dials. Measured constants: One
About 1,360 dials plus 1,360 DHT walks from one 64 KiB frame. Sustained, the per-hop 1/s refill holds it near 340 asks/s network-wide per attacker identity. Each dial rides The module doc's figure is low. Ordering is correct: both the limiter and the semaphore run BEFORE any peer is selected or dialled, so they pace work not yet done rather than work already built. The 32-slot ceiling is genuinely node-wide and cannot be circumvented by distinct requestors. Residual, non-gating: because the next hop's bucket is keyed by the FORWARDING node, an attacker's traffic burns an innocent relaying node's allowance at every one of its peers. The effect is degradation only (fewer named holders); no request fails. Verdict: not gating. The bounds are layered, each one runs, each is pinned by a test I mutation-checked, and the failure mode is work, not compromise. 2. INSERT POSITION — claim VERIFIED by mutation, and it is load-bearingReproduced. Swapping
3. HEARSAY vs THE ASSERTION PATH — structurally confirmedThe forwarded ask is not a Forwarded records DO reach the Address strings are unvalidated Rust One real gap, defense-in-depth, not gating — detailed with a probe in my previous comment: 4. REQUESTOR EXCLUSION AND LOOPS — verified, and the test is not vacuousReproduced. Deleting the asker filter from Longer cycles (A to B to C back to A) are not prevented by a visited set and are bounded by the hop cap alone. That is the same property the shipped redirect leg already relies on, and it terminates at 4. 5. |
92e4630 to
37a2307CompareMichaelTaylor3d
commented
Aug 20, 2026
loop-security — scoped re-gate of |
MichaelTaylor3d
commented
Aug 20, 2026
loop-security: PASS (scoped re-gate)Head audited: 1. SPEC 10.4.5 privacy rewrite — does NOT over-correct. Both limits verified TRUE.Limit 1, "identity not carried past the first hop" — verified against the wire, not the prose. Limit 2, "nothing is retained" matches the hearsay-containment property already cleared (10.4.4 forbids storing, re-serving, publishing), and the hedge "a peer is free to log what it was asked, and nothing here prevents that" is the honest reading. Neither limit is written as a guarantee. The ~1,360 figure is stated as an upper bound and is one. Distinct nodes reached is necessarily fewer than asks issued, because overlapping pool membership re-asks the same node; "up to ~1,360" is correct and conservative. One thing the rewrite improved that was not asked for. The first paragraph changed "the requestor wanted that resource" to "someone wanted that resource". That is a real correction, not a softening: on the proxy path the serving holder sees the MIDDLE node, so it learns the resource was wanted without learning by whom. The old wording overstated the proxy leg's disclosure. No over-correction found. Nothing in the rewrite claims a protection the code does not have. 2. SPEC 10.4.4 conditionalization — the defect class was NOT re-introduced.Swept every MUST / MUST NOT in 10.4.4-10.4.6 (19 of them). Each falls into exactly one of three sound categories:
Critically, "a node with the feature disabled MUST forward nothing" is ENFORCED, not asserted. Traced it rather than trusting it:
Non-gating drafting nit: read hyper-literally, "Every requirement in this clause is conditional on that gate; a node with the feature disabled MUST forward nothing" lets the blanket scope the disabled-MUST itself, making it vacuous. The semicolon structure makes the intent unambiguous and the code enforces the behaviour regardless. Not worth a round. 3. Kill switch — fail-closed verified by mutation, both mutants killed.Probes run in my own worktree (
Mutant A is not a strawman, and I verified that rather than taking it on faith. The new resolver is also shape-identical to 4. The corrected figure — arithmetically right, and the ceiling correction is material and correct.Verified against the constants at head, not against the prose:
The MAX_CONCURRENT_FORWARDED_ASKS correction is right, and it does change what the ceiling means. The permit is taken once per forwarding miss and held across all 4 sequential dials, so 32 bounds concurrent forwarding misses at THIS node. At the rate the relay bucket permits one identity (4 concurrent), the 32-slot ceiling is never the binding constraint — so it genuinely does not reduce the 1,360 — and every downstream node has its own independent 32. Reading it as an aggregate cap would have been a real understatement. Non-gating precision nit:"the work still happens, serialized" is loose. The permit is taken with 5. Also verified
Flaky test — does NOT gate, and should not be closed as harmless
So the PR cannot have introduced it by env contention, and the residual load effect of +498 lines of tests on a pre-existing timing-sensitive test is not a defect in this diff. Green in CI, green in the full 864-test re-run, green in isolation. The lane was right not to call it harmless, and I am not overriding that. Recommend a follow-up dig-node ticket with one specific instruction that materially changes its severity: record WHICH assertion failed. If it was the first one (a held lock must block a concurrent try_lock), that is a genuine mutual-exclusion failure in Merge preconditions
Leaving DRAFT as instructed. No security defect in the delta. Two non-gating precision nits named above, neither worth a round. |
On a content miss, `NodeContent` now ALSO asks its connected pool peers the existing `dig.getAvailability` verb and merges their returned `providers` into the enrichment/redirect answer it was already building. Content discovery becomes recursive: a holder reachable through connections this node already holds is named even when no DHT record here can point at it. Zero new verbs, zero new address structs, zero new result types, no dig-peer change. The hop budget rides the shipped `redirect_depth`/`REDIRECT_HOP_CAP`, the answer cap is the shipped `MAX_REDIRECT_PROVIDERS`, and admission is the shipped `allow_miss_lookup`. Ordering is a contract, not an implementation detail: our own DHT findings lead and forwarded records follow, deduplicated keeping the first occurrence. The requestor dials in list order and the list is truncated at the cap, so appending is what makes that cap non-displacing -- a peer answering with a full slate of fabricated holders spends only the tail and can never evict a holder we found ourselves. The outbound fan-out is charged to a NEW separate per-requestor relay bucket (burst 4, refill 1/s), never the cheap-lookup budget, because requestor identity keys the immediate caller and a shared bucket would let one admitted inbound frame spend a victim's allowance across every peer this node holds. A node-wide semaphore of 32 bounds the amplification the per-requestor buckets structurally cannot see. Fan-out is 4 peers per miss, excluding self and the asking peer. The dig-nat mux `AvailabilityRequest` shape carries no hop counter, so that leg declares the budget spent and forwards nothing -- fail-closed, since a request that cannot count hops cannot bound a recursion. Also builds the caller dig-dht SPEC 6.8 requires and had none of: a download that reaches none of its located candidates forgets the cached lookup answer. The binding to `DhtService::forget_discovered` is behind a seam because that method ships in dig-dht 0.12, which `dig-download` 0.17.4 and `dig-peer-selector` 0.9.0 both block at `^0.11`. The leg is OPT-IN: `DIG_NODE_FORWARD_ON_MISS`, default OFF, mirroring `DIG_NODE_INBOUND_DEMAND_CACHE`'s shape rather than inventing a second config idiom. The relay token is charged on INBOUND admission while one token buys four OUTBOUND dials, so one admitted frame at `redirect_depth: 0` fans to roughly 1,360 dials and 1,360 DHT walks across the network (16, 64, 256, 1024) -- about 340 asks/s sustained per requestor identity. The node-wide semaphore bounds concurrency, not that total. The strictly cheaper, node-local proxy leg is already opt-in, and a path that amplifies more than an opt-in path cannot honestly be gated less than it. SPEC 10.4.5's privacy claim is corrected rather than left to be inherited: the forwarded ask discloses the requested triple to parties a direct read would never have involved -- up to ~1,360 nodes the requestor did not choose and cannot enumerate. 10.4.4's MUSTs are now explicitly conditional on the gate. Closes#259 Refs: DIG-Network/dig_ecosystem#3128 Co-Authored-By: Claude <noreply@anthropic.com>
Minor, not patch: this PR adds a new capability -- a node now forwards an availability ask to its connected pool peers when it cannot answer from its own holdings, so a requestor reaches content held one hop beyond its own pool. That is additive and compatible; nothing existing changes shape. The bump is against 0.128.0 rather than 0.127.0 because the stable cut for 0.128.0 (the Sage RPC port move) landed on main while this branch was in its gate round. Co-Authored-By: Claude <noreply@anthropic.com>
4639451 to
47fc41cCompareUh oh!
There was an error while loading. Please reload this page.
… ask `SPEC.md` §19.3 states without qualification that NO source — DHT or pool — can ever offer self. The DHT leg honoured it twice over (a `SelfExcludingLocator` wrapper AND a second hand-written filter inside `NodeContent::find_providers`); the FORWARDED leg added by #258 honoured it not at all, so a peer's answer naming this node reached the merged provider set and cost the requestor a self-dial. The rule now has ONE implementation — `retain_excluding_self` — and it is applied at the MERGE point in `locate_holders`, which is where it covers every source the answer draws from rather than only the ones a reader thought to wrap. The two prior copies now call it. Closes#261 Refs DIG-Network/dig_ecosystem#3128 Co-Authored-By: Claude <noreply@anthropic.com>
… ask `SPEC.md` §19.3 states without qualification that NO source — DHT or pool — can ever offer self. The DHT leg honoured it twice over (a `SelfExcludingLocator` wrapper AND a second hand-written filter inside `NodeContent::find_providers`); the FORWARDED leg added by #258 honoured it not at all, so a peer's answer naming this node reached the merged provider set and cost the requestor a self-dial. The rule now has ONE implementation — `retain_excluding_self` — and it is applied at the MERGE point in `locate_holders`, which is where it covers every source the answer draws from rather than only the ones a reader thought to wrap. The two prior copies now call it. Closes#261 Refs DIG-Network/dig_ecosystem#3128 Co-Authored-By: Claude <noreply@anthropic.com>
DO NOT MERGE — DRAFT, gate round not yet run.
Core of epic https://github.com/DIG-Network/dig_ecosystem/issues/3128 (requirements 2/3/6).
Closes#259.
The whole change, in one sentence
On a miss, before answering,
NodeContentalso asks its connected pool peersdig.getAvailabilityand merges their returnedprovidersinto the existing enrichment/redirect answer.Zero new verbs. Zero new address structs. Zero new result types. No dig-peer change.
Blast radius checked
impactwas attempted per-worktree; the index build did not complete inside the 10-minute bound, so the radius below is from ripgrep + call-graph reads, per CLAUDE.md §2.0 bound (2) — stated rather than skipped.NodeContent::find_providersmiss_outcome(download.rs:1598),availability_answer(lib.rs:3679), the DEBUG locate loglocate_holderswraps it; both miss legs moved to the wrapper so they cannot drift apartNode::availability_batchseams/dig_rpc/dispatch.rs:313(JSON-RPC),peer.rs:1554(dig-nat mux), 12 testshops_used; both production sites updated, mux passesREDIRECT_HOP_CAP(fail-closed)Node::availability_answeravailability_batchonly, + 4 testshops_usedNodeContent::fetch_resourcelib.rs/download.rs?became anif let Errso the SPEC 6.8 escape can fire before returning. SameErrvalue, same messageNodeContent::new/for_dhtset_capsule_warmeralready doesMissRateLimiterwith_relay_defaults(); the existing two untoucheddetect_changes()was unavailable for the same reason;git diff --statis 12 files and every touched symbol is in the table above.Risk: MEDIUM. No custody, no crypto, no key handling. It is peer-reachable and network-amplifying, which is why each of the three bounds is pinned by a test proven load-bearing below.
The five constraints, each confirmed
handle_rpc_aswith the truepeer_id— confirmed, and unchanged. The inbound serve path was already correct:peer.rs:1369passesRequestorId::Peer(conn_key)intohandle_rpc_as, andhandle_availability(peer.rs:1553) does the same. This PR adds no call tohandle_rpc, so nothing can reachRequestorId::from_origin'sPeer("").Download locator only — confirmed by NOT touching either union. The forwarded ask is not a
ProviderLocatorat all; it is a per-miss call insidelocate_holders.PoolProviderLocatorstays download-only, the raw discovery locator stays DHT-only, and neitherUnionLocatorconstruction indownload.rs:855-895is modified.Insert position: forwarded records are APPENDED, after this node's own DHT findings. Three reasons, the third load-bearing:
MAX_REDIRECT_PROVIDERStruncation then falls on the forwarded tail, which is what makes the cap non-displacing. Prepending would let one connected peer bury every genuine holder for free.best_address()is untouched: it is decided inside the download union, which this PR does not modify.Relay bucket bound.
DEFAULT_RELAY_ASK_BURST = 4,DEFAULT_RELAY_ASK_REFILL_PER_SEC = 1.0, per requestor, separate from both the lookup and the proxy buckets. Global ceilingMAX_CONCURRENT_FORWARDED_ASKS = 32concurrent outbound asks node-wide (try_acquire, never awaited). Per-miss fan-outFORWARDED_ASK_FANOUT = 4.Corrected cost figure — the gate was right and my original was ~5x low. The relay token is charged on inbound admission and one token buys four outbound dials: the charge is 1:4, not 1:1. One 64 KiB frame at
redirect_depth: 0against a full relay burst fans out 16 → 64 → 256 → 1024 = ~1,360 dials and ~1,360 DHT walks (every ask that lands also drives afind_providersat its receiver, and 4 asks per requestor per peer sits under that node's miss burst of 16, so none are refused). Sustained at the 1 token/s refill that is ~340 asks/s per attacker identity.4^4 = 256is the leaf count of one question, not the cost of a frame. The node-wide semaphore bounds concurrency, not the aggregate — the work still happens, serialized, and every downstream node has its own independent 32. Both corrections now live in theFORWARDED_ASK_FANOUTandMAX_CONCURRENT_FORWARDED_ASKSdoc comments, with the "this is an exponent, not a knob" note kept and sharpened now the true number is visible.Operator kill switch —
DIG_NODE_FORWARD_ON_MISS, default OFF. Truthyon/1/true/yes(case-insensitive) enables it; unset, empty, falsy and unrecognised values all fail CLOSED, so a typo can never become a network-wide amplifier. It mirrorsDIG_NODE_INBOUND_DEMAND_CACHE— an existing default-OFF amplification gate in the same file, sameresolve_*pure-core shape — rather than inventing a second config idiom, and is resolved once at engine construction likeDIG_NODE_ON_MISS, so a node's amplification posture is fixed for its lifetime. Disabled, the leg is never installed, the refusal costs nothing on the miss path, and the answer is byte-identical to what shipped before this PR (already pinned bywithout_the_leg_the_answer_is_the_shipped_dht_answer).Default OFF is the honest call rather than a cautious one: the proxy leg costs one capsule fetch by this node — expensive in bytes, but local and bounded — while the forwarded ask recruits other nodes' bandwidth and DHT budget at ~1,360 dials per admitted frame. A path that amplifies more than an opt-in path cannot honestly be gated less than it.
Every bound reused.
redirect_depth/REDIRECT_HOP_CAPfor depth,MAX_REDIRECT_PROVIDERSfor the cap,allow_miss_lookupfor admission. No parallel bound invented.forget_discovered— the caller is built; the binding is blocked upstreamdig_dht::DhtService::forget_discoveredships in dig-dht 0.12. This crate cannot resolve it:dig-download0.17.4 anddig-peer-selector0.9.0 both requiredig-dht ^0.11, andtests/dependency_tree.rscorrectly forbids twodig-dhtcopies across the download engine's trust boundary. Bumping this crate alone forks the candidate types on that boundary — a worse defect than the one being fixed.Worth stating plainly too: the resolved
dig-dhthere is 0.11.1, which has no discovery cache at all. The 15-minute sticky-poison exposure does not exist in this tree yet.So what was missing is built and tested: the call site.
fetch_resourcenow callsforget_stale_discoveryon the exact edge where "every located candidate was unreachable" becomes true, behind aDiscoveryCacheseam. That judgement is the part dig-dht cannot supply for itself. Binding it is oneset_discovery_cachecall fromfor_dhtthe day the cascade lands.Two blocked release-first cascades found, both pre-existing:
dig-rpc-protocol0.8.0 (this epic's own lane A) is unreachable for the same reason:dig-download0.17.4 pins^0.6,dig-peer0.10 pins^0.7, andthe_workspace_carries_exactly_one_module_wire_crateasserts one copy. The stale"0.6"pin atCargo.toml:135is therefore correct today, not drift to fix here. This PR does not need the crate type:redirect_depthis read from raw JSON by the shippeddownload::redirect_depthparser, the same one every other redirect leg uses.dig-dht0.12.0, above.Gate round 1 — three fixes applied
redirect_depth, and each receiver authenticates the FORWARDING node), and nothing is retained — but a peer may log what it was asked, and timing correlation is untouchedforwarded_ask.rsDIG_NODE_FORWARD_ON_MISS, default OFF — see constraint 5 aboveA third false claim was found while fixing the first and is also corrected: §10.4.4's own requirements were written as unconditional MUSTs, which contradicted the new opt-in gate. They are now explicitly conditional on it, with "a node with the feature disabled MUST forward nothing" stated outright. Leaving that would have reproduced exactly the defect the gate caught — a normative document asserting something the code does not do.
Version: no further bump.
forward_on_miss_enabled()is additive public API on a 0.x crate, which is the minor slot — and dig-node-core is already0.47.0 → 0.48.0, workspace0.127.0 → 0.128.0. Both minor, so the additive surface fits the bump already taken.New guard, proven load-bearing.
the_forwarded_ask_is_off_unless_explicitly_enabledcovers the truthy vocabulary, every falsy spelling, unset, empty, and an unrecognised value. Mutatingresolve_forward_on_missto the default-ON shape (the!matches!(falsy)form its siblingresolve_backfill_on_misslegitimately uses) fails it — so the default is pinned, not incidental. 926 tests green, clippy-D warningsclean.Outstanding coherence obligation (not in this PR — different repo, single-writer)
docs.dig.netdocuments the sibling flag atdocs/protocol/peer-network.md:806("FETCH-THROUGH (opt-in,DIG_NODE_ON_MISS=fetch)"), plus 13 locale copies underi18n/*/docusaurus-plugin-content-docs/current/protocol/peer-network.md:697. Per §4.3 the forwarded ask andDIG_NODE_FORWARD_ON_MISSbelong beside it. I am single-writer for dig-node only and have not touched that repo — flagging it with the exact anchors so it can be dispatched rather than discovered later.Deliberately out of scope
The hand-rolled
json!redirect literals atdownload.rs:1731-1755are NOT migrated to the typedRedirectInfo. Real drift, worth fixing, and folding it in here would make this diff unreviewable.Evidence
cargo test -p dig-node-core), 18 of them new.cargo clippy --all-targets -- -D warningsclean.cargo fmtclean.a_hostile_slate_of_forwarded_holders_cannot_displace_our_own>=becomes>(off by one)the_hop_budget_is_pinned_from_both_sidesthe_relay_allowance_is_per_requestor_and_separate_from_the_lookup_budgetforget_stale_discoveryon every fetch (wrong edge).take(FORWARDED_ASK_FANOUT)removedthe_fan_out_is_capped_regardless_of_pool_sizeFixture notes, since narrowness is where false greens live:
MAX_REDIRECT_PROVIDERSfabricated holders against one honest DHT holder, so prepending is visible as an eviction. A presence assertion would have passed under that mutation.allow_miss_lookupis still willing — proving the separation, not merely the refusal.a_successful_download_forgets_nothingis the control that makes the SPEC-6.8 test a statement about failure rather than about fetching.Demo — four nodes, and why three cannot show it
A-B-C with C holding is one hop, which is exactly the shipped
-32008redirect plus the shippedgetAvailabilityenrichment: control and treatment are the same code path. Recursion needs A-B-C-D, D holding, and neither A nor B able to locate D.Setup (four hosts, one isolated
--network-id e2e-3128): D holds the capsule and announces into a DHT partition A and B cannot reach. A is connected only to B; B only to A and C; C to B and D.On host A:
What distinguishes a recursive answer from a direct one:
peer_idinproviders[]. A direct answer names only holders A's own DHT walk found — with the partition in place that array is[]today. A recursive answer names D'speer_id, which A has no DHT record for and is not connected to.dign peers list --jsonon A must not contain D: a named holder that is in neither A's pool nor its DHT is proof the answer travelled.RUST_LOG=dig_node_core=debugon B and C shows oneforwarded askline each, atredirect_depth1 on B and 2 on C. Two hops is the observation; one hop is the shipped behaviour.providers[]must go empty rather than answering from a stale cache — otherwise the first run proved caching, not recursion.--redirect-depth 4. B must forward nothing (noforwarded askline) and still answer —the_hop_budget_is_pinned_from_both_sides, observed live.Not run here: no 4-node fleet was provisioned for this lane.