Skip to content

id(discovery): peer announcements are unauthenticated and the peer table is unbounded — a fresh-timestamp announcement spoofs another peer's entry, and invented peers stall stale cleanup for N×10s #2944

Closed

Description

@dezren39

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 from msg.content and passes it straight to recv_discovery.update(announcement). It never compares announcement.node_id to 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-asserted node_id and stamps last_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:

  1. 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 /peers route then show the attacker's name/blob_count for the victim's node ID.
  2. Unbounded table + sequential probe amplification. update has no cap and nothing evicts before staleness, so N invented node IDs are all retained and returned by peers(). Worse, the stale-cleanup task (serve.rs:1571-1611) probes stale entries sequentially, each with PROBE_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 before remove_stale runs — 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), name attacker-fake, blob_count 0 replaced it — peers() returns the attacker's values. Code inspection confirms there is no other gate: the receive path uses only msg.content, and update keys only by announcement.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

Activity

  1. added
    queue/queuedHas a queued task in the Supabase agent queue
    source/creatorFiled by the new-issue-creator job
    task/triageQueue packet kind: verify, reproduce, and route this item
    priority/normalOrdinary priority (the default)
    app/idPackage/app: id (pkgs/ or apps/ in this repo)
    kind/securitySecurity issue or security hardening
    effort/sSmall: expected under 2 hours of work
    on Oct 31, 2026
  2. dezren39 commented on Oct 31, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · filed

  3. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor

    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.

  4. added
    work/assignedQueue worker lifecycle state
    queue/claimedQueue worker lifecycle state
    and removed
    queue/queuedHas a queued task in the Supabase agent queue
    on Nov 1, 2026
  5. 55 remaining items

  6. removed
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    status/parkedDeliberately paused with a recorded resume condition
    on Nov 2, 2026
  7. dezren39 commented on Nov 2, 2026

    @dezren39
    MemberAuthor

    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.

  8. dezren39 commented on Nov 2, 2026

    @dezren39
    MemberAuthor

    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.

  9. added
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    status/parkedDeliberately paused with a recorded resume condition
    and removed
    queue/claimedQueue worker lifecycle state
    on Nov 2, 2026
  10. added a commit that references this issue on Nov 2, 2026
  11. dezren39 commented on Nov 2, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · done — PR #4280 merged

  12. removed
    task/triageQueue packet kind: verify, reproduce, and route this item
    status/parkedDeliberately paused with a recorded resume condition
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    work/assignedQueue worker lifecycle state
    on Nov 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

app/idPackage/app: id (pkgs/ or apps/ in this repo)effort/sSmall: expected under 2 hours of workkind/securitySecurity issue or security hardeningpriority/normalOrdinary priority (the default)source/creatorFiled by the new-issue-creator job

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions