Skip to content

fix(dig-node): gate inbound-demand pull on XOR-proximity admission (anti-amplification) - #165

Merged
MichaelTaylor3d merged 2 commits into
mainfrom
loop/2014-inbound-demand-proximity/fix
Aug 3, 2026
Merged

fix(dig-node): gate inbound-demand pull on XOR-proximity admission (anti-amplification)#165
MichaelTaylor3d merged 2 commits into
mainfrom
loop/2014-inbound-demand-proximity/fix

Conversation

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor

Closes dig_ecosystem#2014. Follow-up from epic #1934 child 5 (#1990); the dependency (the XOR-proximity primitive) is now live post-#1934.

Why

The tier-1 inbound-demand pull (DIG_NODE_INBOUND_DEMAND_CACHE) lets a remote peer's read drive this node to fetch + cache + DHT-announce a whole .dig capsule — a classic amplification primitive (a peer could make the node cache + advertise arbitrary attacker-chosen content). It was opt-in default-OFF precisely because the defence wasn't wired. This wires it: gate the pull on XOR-proximity (keyspace distance) so a peer can only drive caching of content NEAR this node's peer_id — ungameable (a peer can't move our peer_id).

The default stays OFF — flipping it is a separate later security pass (explicitly out of scope, per #2014).

What changed

  • Second admission after the flagnote_inbound_demand (lib.rs): if inbound_demand_cache_enabled() && self.inbound_demand_pull_admitted(store,root). New inbound_demand_pull_admitted computes dig_dht::ContentId::capsule(store,root).to_key() and tests relevance::in_keyspace_neighbourhood(capsule_key, peer_id).
  • Predicate (relevance.rs): new in_keyspace_neighbourhood + INBOUND_DEMAND_MIN_PROXIMITY, built on the existing xor_proximity (the SAME primitive relevance()/the tier-0 selector use — xor_proximity promoted to pub(crate)).
  • Reference identity plumbed via a new OnceLock<[u8;32]>node_peer_id + set_node_peer_id, installed at peer-network bring-up (peer.rs) from identity.peer_id() — the exact value tier-0's NodeContext.peer_id uses (one shared reference). Kept OUT of the shared spawn_capsule_backfill body so only the inbound-demand path is gated (not the origin-gated fetch-side path).

The proximity bar + justification (the security-sensitive number)

The tier-0 selector has no fixed proximity cutoff to reuse verbatim (it's a relevance/size greedy knapsack over the xor_proximity primary), so — per the ticket's fallback — the boolean is derived from the same xor_proximity + same reference peer_id, bar at the keyspace midpoint 0.5:

  • Not too tight: a uniform-random content id has proximity uniform in [0,1] (median 0.5); 0.5 admits the half of keyspace closer to our peer_id than a random point (≈ shares the top keyspace bit). Kademlia routes near-key requests to near-key nodes, so genuine inbound demand sits well inside the neighbourhood — legitimate demand-caching always fires.
  • Not too loose (ungameable): the reference is our own peer_id (immovable). Landing a capsule key near it means grinding SHA-256(0x02‖store‖root) toward a fixed target — and because the pull is chain-anchored (backfill merkle-verifies against the CHIP-0035 anchored root), each grind candidate must be a REAL on-chain store, so grinding costs on-chain mints, not cheap hashing. Combined with default-OFF + single-flight + the byte-cap in the shared pull body.
  • Fail-closed: no known self-identity or non-canonical (store,root) → no pull.
  • Residual (documented):0.5 only guarantees "closer than random" (~2 on-chain mints per admitted key at the extreme); tightening toward a routing-aware k-closest / larger-prefix test needs a live network-size signal not available in this pure gate — deferred to the separate default-ON pass (a one-constant change to INBOUND_DEMAND_MIN_PROXIMITY).

How verified (TDD)

  • keyspace_neighbourhood_admits_the_near_half_and_denies_the_far_half (relevance.rs).
  • inbound_demand_pull_gated_on_keyspace_proximity — one store, two nodes with near/far peer_id: near admits, far denies no pull.
  • inbound_demand_pull_denied_without_a_known_self_identity — fail-closed.
  • All 17 inbound_demand tests green (existing opt-in pull tests updated to set a near node_peer_id). cargo test -p dig-node-core --lib → 634 passed, 9 failed (all the known socket-bind sandbox tests). cargo fmt 0, clippy -D warnings clean, build ok.

SPEC

SPEC.md §7.10d updated — the "not yet wired" language replaced with the enforced XOR-proximity admission (bar, ungameability, fail-closed, default-OFF, the later-pass note).

Version

root [workspace.package].version 0.89.0 → 0.90.0; dig-node-core 0.38.0 → 0.39.0; Cargo.lock regenerated (dig-node-service=0.90.0, dig-node-core=0.39.0). Minor (additive gate, no public-API break).


Generated by Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
…nti-amplification)
Wire the intended anti-amplification defence for the tier-1 inbound-demand pull
(DIG_NODE_INBOUND_DEMAND_CACHE, #2014): admit a peer-driven capsule pull ONLY when
the capsule key lies in this node's keyspace neighbourhood (XOR proximity to our own
peer_id >= the keyspace midpoint), reusing relevance::xor_proximity and the same
reference peer_id the tier-0 selector scores against. Binds even when the flag is on;
fails closed without a known self-identity or a concrete (store, root). Default stays
OFF. Read serving is unchanged — the gate governs only the demand-driven cache.
Minor: root workspace 0.89.0->0.90.0, dig-node-core 0.38.0->0.39.0.
Closes #2014
Co-Authored-By: Claude <noreply@anthropic.com>
@MichaelTaylor3d
MichaelTaylor3d merged commit 50a44ab into mainAug 3, 2026
15 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the loop/2014-inbound-demand-proximity/fix branch August 3, 2026 09:46
MichaelTaylor3d added a commit that referenced this pull request Aug 5, 2026
…back for the redirect (#2007)
Compose the shipped redirect-on-miss (#165) + upstream removal (#1997) machinery
with the three residual units of dig_ecosystem#2007:
- Unit A: a per-requestor token-bucket limiter (`crate::rate_limit`, a byte-identical
mirror of dig-wallet's #1957 primitive) in front of `miss_outcome`'s DHT lookup +
proxy fetch, keyed by mTLS peer_id / connection IP / operator. Over-budget → -32009
CONTENT_MISS_RATE_LIMITED; a different requestor is unaffected. Redirect candidate
set capped at MAX_REDIRECT_PROVIDERS (= dig-dht MAX_ADDRESSES_PER_RECORD).
- Unit B: an explicit `params.proxy` (default OFF) routes a miss through the existing
FetchThrough branch + identical chain-anchored merkle-verified fetch_resource; the
origin!=Local reshare refusal stays intact (the middle node never becomes a holder).
- Unit C: regression proving an unconfigured node (no upstream) redirects a miss and
issues zero upstream HTTP, degrading old clients to a well-formed JSON-RPC error.
Threads the requestor identity explicitly through dispatch + handle_rpc_as +
handle_json_rpc(conn_key) so a peer JSON miss is keyed by the asking peer, not one
shared bucket. SPEC §10.4 + the -32009 catalogue entry document the bounds, the proxy
semantics, and the privacy note. Minor bump (additive `proxy` field + rate limit).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmFawKNiEo1dowZ4mPM19j
MichaelTaylor3d added a commit that referenced this pull request Aug 5, 2026
…back for the redirect (#2007) (#182)
* feat(node): rate-limit the miss→DHT-lookup path + explicit proxy fallback for the redirect (#2007)
Compose the shipped redirect-on-miss (#165) + upstream removal (#1997) machinery
with the three residual units of dig_ecosystem#2007:
- Unit A: a per-requestor token-bucket limiter (`crate::rate_limit`, a byte-identical
mirror of dig-wallet's #1957 primitive) in front of `miss_outcome`'s DHT lookup +
proxy fetch, keyed by mTLS peer_id / connection IP / operator. Over-budget → -32009
CONTENT_MISS_RATE_LIMITED; a different requestor is unaffected. Redirect candidate
set capped at MAX_REDIRECT_PROVIDERS (= dig-dht MAX_ADDRESSES_PER_RECORD).
- Unit B: an explicit `params.proxy` (default OFF) routes a miss through the existing
FetchThrough branch + identical chain-anchored merkle-verified fetch_resource; the
origin!=Local reshare refusal stays intact (the middle node never becomes a holder).
- Unit C: regression proving an unconfigured node (no upstream) redirects a miss and
issues zero upstream HTTP, degrading old clients to a well-formed JSON-RPC error.
Threads the requestor identity explicitly through dispatch + handle_rpc_as +
handle_json_rpc(conn_key) so a peer JSON miss is keyed by the asking peer, not one
shared bucket. SPEC §10.4 + the -32009 catalogue entry document the bounds, the proxy
semantics, and the privacy note. Minor bump (additive `proxy` field + rate limit).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmFawKNiEo1dowZ4mPM19j
* fix(node): bound getAvailability miss→DHT lookups per requestor (#2007)
The dig.getAvailability not-held → find_providers enrichment was the largest
DHT-amplification vector on the miss path and ran with NO per-requestor rate
cap: one peer session could send a 512-item batch of distinct valid store ids,
none held, and drive 512 independent DHT lookups from a ~50 KB request. The
candidate cap bounds the RESPONSE, never the outbound LOOKUP work.
Thread RequestorId into availability_batch/availability_answer from both entry
points (the dispatch.rs JSON leg's in-scope requestor; the peer typed-frame
leg's mTLS-verified conn_key as RequestorId::Peer) and gate each per-item
find_providers behind the SAME per-requestor miss-lookup budget the single-item
legs use — ONE token per not-held item that would trigger a lookup. When the
bucket is exhausted the remaining items answer not-available without a lookup;
the availability answer itself (held vs not-held from local inventory) is
unchanged, only the best-effort providers hint is dropped.
So the number of find_providers lookups one requestor can cause via
getAvailability — across any batch size and any call rate — is bounded by its
per-requestor token budget, identical to the single-item legs.
Adds get_availability_enrichment_is_rate_limited_per_item_per_requestor (two
actor, control-peer isolation, budget pinned from both sides, load-bearing
against both once-per-batch and no-check mutations). SPEC §10.4 + rate_limit.rs
module doc extended to include getAvailability in the bounded-path set.
Version: workspace 0.99.0, dig-node-core 0.44.0 (exceed main's independent
0.98.0/0.43.0 so the version-increment gate passes post-rebase onto #179).
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@MichaelTaylor3d@claude