Repository navigation
Description
Problem
id serve's gossip peer table trusts a PeerAnnouncement's self-asserted node_id completely, and the table has no size bound:
- The receive loop (
pkgs/id/src/commands/serve.rs:1531-1537) deserializes an announcement frommsg.contentand passes it straight torecv_discovery.update(announcement). It never comparesannouncement.node_idto anything about the message's origin — and the announcement carries no signature (the struct is plain postcard fields:node_id,name,blob_count,timestamp_secs). PeerDiscovery::update(pkgs/id/src/discovery.rs:430-441) inserts/replaces purely by that self-assertednode_idand stampslast_seen = Instant::now().- The discovery topic is public with a public secret (
DEFAULT_TOPIC/DEFAULT_TOPIC_SECRET,discovery.rs), so anyone can broadcast announcements claiming any node ID.
Two consequences, both verified below:
- Fresh-timestamp identity spoofing. An announcement claiming a victim's node ID, with a fresh (newest) timestamp and attacker-chosen
name/blob_count, silently overwrites the victim's live entry. This is NOT covered by id(discovery): peer announcements carry a timestamp nothing checks — a replayed or out-of-order announcement overwrites newer peer state and can keep a departed peer 'live' forever #1552: that issue's ordering/freshness fix refuses old announcements, and explicitly scopes signing out — a spoof with the newest timestamp passes every check id(discovery): peer announcements carry a timestamp nothing checks — a replayed or out-of-order announcement overwrites newer peer state and can keep a departed peer 'live' forever #1552 adds.id peers, the REPL peer views, and the web/peersroute then show the attacker's name/blob_count for the victim's node ID. - Unbounded table + sequential probe amplification.
updatehas no cap and nothing evicts before staleness, so N invented node IDs are all retained and returned bypeers(). Worse, the stale-cleanup task (serve.rs:1571-1611) probes stale entries sequentially, each withPROBE_TIMEOUT= 10 s (serve.rs:1480,1582): N attacker-chosen fake peers that go stale make one cleanup pass spend up to N×10 s dialing attacker-chosen node IDs beforeremove_staleruns — 1,000 fakes ≈ 2.8 hours of cleanup-loop time, during which genuinely stale peers are not evicted (the loop is the only eviction path).
Evidence (verified this run, main e19c953d)
Probe crate on the laptop (~/git/fleet/probe-2026-10-30-laneA-peerspoof/) compiling the verbatim current pkgs/id/src/discovery.rs (md5 050562c661c4c430c4c57c338bf71ea6, identical on laptop and sandbox checkouts of main; iroh-base pinned =1.3.0 as in the earlier discovery probes):
case1 spoof: count=1 name=Some("attacker-fake") blob_count=0 ts=1700000100
case2 unbounded: count=20000 peers=20000 all_peers=20000
case3 control: count=1 name=Some("honest")
- Case 1: after the victim's real announcement (name
victim-real, blob_count 42, ts 1700000000), an announcement for the same node_id with a newer timestamp (ts 1700000100), nameattacker-fake, blob_count 0 replaced it —peers()returns the attacker's values. Code inspection confirms there is no other gate: the receive path uses onlymsg.content, andupdatekeys only byannouncement.node_id. - Case 2: 20,000 announcements with distinct invented node IDs were all inserted, counted, and returned — no cap, no eviction before the 120 s staleness threshold.
- Case 3 (control): a single honest announcement behaves exactly as before.
Probe-loop timing is code-read from serve.rs:1563-1611 (sequential for info in &stale with a 10 s per-probe timeout wrapping a connect+ListPeers RPC; remove_stale runs only after the loop finishes).
Why it matters
The peer table is the operator's view of the network (id peers, REPL, web /peers) and the cleanup loop is its only eviction mechanism. Today any participant on the public discovery topic can (a) impersonate another server's entry with values a freshness check cannot catch, and (b) flood the table with invented peers that turn the eviction loop into hours of dialing attacker-chosen endpoints. #1567 fixed the same Sybil/no-total-cap shape for the waiting pool; the peer table has neither a cap nor an identity binding.
Acceptance criteria
- Triage pins the identity fix shape: e.g. announcements signed by the node's key (verify
node_id== signer beforeupdate), or another binding that makes a fresh-timestamp announcement for someone else's node ID refuse at the receive/update boundary. A timestamp/ordering check alone does not satisfy this (see id(discovery): peer announcements carry a timestamp nothing checks — a replayed or out-of-order announcement overwrites newer peer state and can keep a departed peer 'live' forever #1552 scope note). -
PeerDiscoverygains a total size bound (cap with a documented eviction rule, e.g. oldest-last_seenfirst), so invented peers cannot grow the table without limit; the bound is exercised by a regression test inserting well past it. - Stale cleanup cannot be stalled by table size: probing is bounded per pass (probe budget, concurrency cap, or eviction before probing), so one cleanup pass completes in bounded time regardless of how many stale entries exist.
- Regression tests: a spoofed announcement for an existing peer's node ID does not replace that peer's name/blob_count; honest in-order announcements (existing tests) still pass.
- Related, do not duplicate: id(discovery): peer announcements carry a timestamp nothing checks — a replayed or out-of-order announcement overwrites newer peer state and can keep a departed peer 'live' forever #1552 (timestamp ordering/freshness — old-announcement replay), id(peers): plain_field passes bidi/format controls raw — a gossip peer name with U+202E reaches the terminal unescaped and visually reorders the row's columns #2839 (peer name sanitization at display), id(waiting): the waiting pool has no total cap — the per-author cap is Sybil-defeated, so one peer can make a node hold (and persist) unbounded waiting events #1567 (waiting-pool total cap precedent).
Activity
dezren39 commentedon Oct 31, 2026
🤖 Agent note · filed
dezren39 commentedon Nov 1, 2026
state: picked_up
agent: repo-worker-code
Found via: claimed queue task 4204 in repo-health-code-pilot (OpenChamber scheduled lane) on 2026-11-01. Packet evidence is from main e19c953; current main has moved. Next: verify #2944 and related issues/PRs against current main, reproduce only if current code still matches, then route or park with a precise resume brief.
55 remaining items
dezren39 commentedon Nov 2, 2026
agent-note:v1
kind: pickup
task: 5486
lane: openchamber-scheduled-lane
state: claimed
note: Metadata-only revalidation: #2944 OPEN and assigned to dezren39; canonical PR #4280 OPEN and assigned to dezren39. Active Drew-ownership stop; no PR contents, diff, checks, reviews, branch, worktree, or source inspected/touched.
dezren39 commentedon Nov 2, 2026
agent-note:v1
kind: parked
task: 5486
lane: openchamber-scheduled-lane
state: parked
note: Fresh metadata confirms #2944 OPEN and assigned to Drew; canonical #4280 remains open, authored/assigned to Drew. Existing worktree and PR internals were left untouched.
resume: After explicit handoff/release, issue rerouting, or PR disposition; revalidate first.
Merge pull request #4280 from developing-today/queue/5486-2944-peer-d…
dezren39 commentedon Nov 2, 2026
🤖 Agent note · done — PR #4280 merged
queue/5486-2944-peer-discovery-hardening(this task's own canonical PR) and author dezren39 is the shared fleet account, so there was no Drew-ownership stop.cargo test -- discovery44 passed / 0 failed (incl. cap/eviction), announcement+stale-probe filters 31 passed. Codex review on head e8a006a: no findings. PR CI checks were still QUEUED in the runner backlog at merge time; merged under the standing merge-authority rule (verified tests + clean Codex + diff matches packet scope).