feat(kad_dht): enforce IP subnet diversity in k-buckets (#1383) - #1399
feat(kad_dht): enforce IP subnet diversity in k-buckets (#1383)#1399yashksaini-coder wants to merge 8 commits into
Conversation
|
Guards against eclipse attacks that grind cheap peer IDs from a single subnet to occupy the closest-K slots for a target key. Before admitting a new peer, KBucket.add_peer now rejects it if its globally-routable /24 (IPv4) or /48 (IPv6) subnet already holds MAX_PEERS_PER_SUBNET (default 2) peers in that bucket. - Only globally-routable addresses are grouped (ip.is_global), so loopback, private (RFC1918/ULA), CGNAT, link-local, and documentation ranges are exempt — CI and local testnets are unaffected. - DNS-named and relayed (p2p-circuit) peers are exempt: a circuit address exposes the relay's IP, not the peer's, and is skipped to avoid grouping distinct peers behind a shared relay. - Multi-homed peers are grouped by their first globally-routable address. - Opt-out: set MAX_PEERS_PER_SUBNET <= 0 to disable the check entirely. - _should_split_bucket now only splits genuinely full buckets, so a subnet rejection (add_peer returning False on a non-full bucket) cannot trigger a spurious split. Reject-only (no eviction) matches go-libp2p's TryAddPeer and avoids a churn/DoS vector. No protocol or wire change. Adds unit tests for saturation, exemptions, opt-out, IPv6 grouping, and multi-homing. Closes libp2p#1383.
1d77070 to
913fcc3
Compare
|
@seetadev How go-libp2p does it (reference)Diversity lives in
Defaults in go dual DHT: // go-libp2p-kad-dht/dual/dual.go
maxPrefixCountPerCpl = 2 // per CPL (common prefix length with local node)
maxPrefixCount = 3 // table-wide per IP group1. IPv4
|
- remove unused `import multihash` (ruff F401) - flatten unreachable `return False` after the if/else in KBucket.add_peer - add regression test: a subnet rejection on a non-full bucket must not make _should_split_bucket return True (fails without the guard, passes with it) - document the /24-vs-/16 and first-global-vs-all-addresses divergences from go-libp2p, and note the table-wide cap as a follow-up Refs libp2p#1383.
|
Thanks for going through the go side properly, the comparison table made this easy to act on. Pushed the fixes:
Branch is already on latest main. And filed the two you flagged:
Agree the table-wide cap is the bigger gap of the two — I'll take that one next once this lands. |
|
@acul71 this is ready for another look whenever you have time — all the before-merge items from your review are pushed (unused import, split-guard test, the go-divergence docs) and CI is green. No rush, just flagging it's unblocked on my end. |
Co-authored-by: Cursor <cursoragent@cursor.com>
Summary
Closes #1383.
py-libp2p's k-buckets accept peers with no constraint on network origin. An attacker can grind many valid peer IDs (peer ID = multihash(pubkey), free to mint) that land closest to a target key in XOR space, then run them all from a small number of IPs/subnets and occupy the closest-K slots for that key in every honest node's routing table. Because the messages are authentically signed, record-level defenses don't catch this — the attack lives in the routing layer.
This PR enforces IP/subnet diversity in
KBucket.add_peer: before admitting a new peer, it is rejected if its globally-routable subnet already holdsMAX_PEERS_PER_SUBNETpeers in that bucket.Design
MAX_PEERS_PER_SUBNET2<= 0disables the checkSUBNET_PREFIX_LEN_V424SUBNET_PREFIX_LEN_V648ipaddress.is_global). Loopback, private (RFC1918/ULA), CGNAT (100.64.0.0/10), link-local, and documentation ranges are all exempt — CI and local testnets are unaffected. Usingis_global(rather than enumerating private ranges) keeps behaviour stable across CPython versions.p2p-circuit) peers are exempt. A circuit address exposes the relay's IP, not the peer's, so it is skipped to avoid grouping distinct peers behind a shared relay. DNS names are not resolved in the routing hot path.TryAddPeerand avoids a churn/DoS vector where an attacker forces out established peers._should_split_bucketnow only splits genuinely full buckets, so a subnet rejection (add_peerreturningFalseon a non-full bucket) can no longer trigger a spurious bucket split.No protocol or wire-format change. Self-contained routing-layer change.
Test plan
uv run python -m pytest tests/core/kad_dht/test_unit_routing_table.py→ 23 passed (16 existing + 7 new)/48grouping, multi-homed grouping-by-first-global, and the_subnet_keyhelper (incl. DNS + relay exemption)ruff check/ruff format --checkclean;mypyclean on changed modulesOpen questions for maintainers
/24(stricter than go-libp2p's/16)./24matches the realistic granularity an attacker rents;/16over-blocks large shared ISP blocks less aggressively. Prefer/24, or widen to/16?MaxPeersPerIPGroup=3(needs RoutingTable-level counter bookkeeping). Deferred here as a follow-up — acceptable, or wanted now?MAX_PEERS_PER_SUBNET <= 0) rather than aRoutingTable/KadDHTconstructor kwarg. Want a threaded kwarg instead/in addition?Non-goals
No crypto-puzzle peer IDs or reputation scoring (spec/ecosystem-level). Path-steering hardening is tracked separately in #1384.
Notes
/48(site-allocation boundary); go-libp2p's ASN-based IPv6 grouping is intentionally not ported (it needs a bundled ASN dataset py-libp2p doesn't ship).