Skip to content

feat(mirror): verify a peer's mirror-coin bond against chain on the download path - #467

Draft
MichaelTaylor3d wants to merge 10 commits into
mainfrom
loop/mc-verify
Draft

feat(mirror): verify a peer's mirror-coin bond against chain on the download path#467
MichaelTaylor3d wants to merge 10 commits into
mainfrom
loop/mc-verify

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

loop-implementer — the settled fix is implemented. Still DRAFT: this needs a fresh gate round.

Head e21f617, on loop/mc-verify, merged with origin/main @ 3ce4785. Version read from Cargo.toml on disk after the merge: 0.200.0 (main is at 0.198.0). Cargo.lock taken from main and re-resolved; cargo update -w --offline locks 0 further packages.

What changed

  1. verdict_for and MirrorBondVerifier::verify take the claiming peer id. That missing parameter was the root of both HIGH findings: without it the layer could only ask "does some coin bond this content", which a stranger passes truthfully by republishing an honest holder's coin id.
  2. Credit-only lattice. Bonded promotes; absent / Unverified / Unbonded are ONE baseline tier with source order preserved. BondVerdict lost its Ord derive — the tier is now an explicit credit_rank, so a future variant cannot silently acquire a ranking by its declaration position.
  3. Promotion is gated OFF at peer_declaration() until dig-mirror-coin 0.8.0 exposes a typed dig-peer: accessor, per the gate's section 6. No rival parser over MirrorCoin::urls(); the call site says why.
  4. Amplification bounded, cheapest-first as prescribed: MAX_VERIFIED_PER_LOCATE = 8 (source order), the verdict cache is probed before epoch_and_requirement()'s file read + JSON parse, and cache overflow evicts one entry instead of cache.clear() — a stranger rotating coin ids could otherwise discard every honest verdict this node has earned.
  5. peer.rs's fail-closed claims corrected. Two comments asserted every dial fails closed on peer_id mismatch. Every expected_peer_id in dig-gossip is #[cfg(test)]; production only derives (An outbound dial never checks the presented identity against the claimed peer_id dig-gossip#85). The residual is traffic redirection, not a wasted dial, and the comments now say so rather than telling the next reader the check is already made.
  6. SPEC.md §25.6a rewritten to the two-tier credit-only ranking, the coin-proves-the-bond-not-the-bearer rule, the bounded-work rule, and evict-not-clear.

Blast radius

impact was unavailable to this lane (gitnexus MCP tools not in the tool set, and the registered indexes are stale by construction), so the radius was measured by grep + direct read and by compiling every consumer. MirrorBondVerifier::verify has exactly three implementations — ChainBondVerifier (production), ByFirstByte (download.rs's engine-path test), and the mirror_bond test doubles — all updated. verdict_for has one caller. BondRankingLocator::new has one production call site (NodeContent::new, download.rs:1262). mirror_bond now re-exports the discovery/download types the seam already speaks in, so a consumer needs no new dependency to implement or test it. Both crates compile and their full lib suites pass.

Evidence — every test proven load-bearing by reverting ONLY its fix

Committed first; each revert applied to the committed tree, one at a time, then restored.

revert run result
three-tier ranking + no per-locate cap -p dig-node-core --lib mirror_bond FAILED. 5 passed; 3 faileda_bogus_pointer_leaves_an_honest_holder_exactly_where_no_pointer_would (["bb","aa","cc"] vs ["aa","bb","cc"]), an_unreachable_chain_is_unverified_not_unbonded, a_locate_reads_at_most_the_budget_off_the_chain (left: 40, right: 8 — the chain-read COUNT, not a timeout)
pass "" instead of record.provider_peer_id -p dig-node-core --lib mirror_bond FAILED. 7 passed; 1 faileda_liar_republishing_an_honest_holders_coin_id_is_not_promoted
peer_declaration reports the coin as declaring the claimant -p dig-node-service --lib bond_verify FAILED. 0 passed; 2 failedan_honest_peer_id_with_attacker_addresses_is_not_promoted returned ["attacker.example","honest.example"]
none (fixed tree) -p dig-node-core --lib mirror_bond ok. 8 passed; 0 failed
none (fixed tree) -p dig-node-core -p dig-node-service --lib ok. 709 passed; 0 failed (dig-node-service; the core suite ran first in the same invocation and cargo would have stopped on failure)

Counts are stated because a filter matching nothing exits 0 printing running 0 tests; each run above names a non-zero count.

The third test, and where it had to live

The gate was right that neither prescribed test catches the addresses attack — and it turned out the lattice cannot catch it at all. Written first against mirror_bond, the test failed with the fix in place: the double correctly reported Bonded (honest peer id, honest coin id) and the lattice promoted, exactly as designed. The defence is the promotion gate, which lives on the chain side. So an_honest_peer_id_with_attacker_addresses_is_not_promoted sits in bond_verify.rs and drives a verifier through the real peer_declaration() — every chain check answering YES, the production gate answering last. It asserts on the returned addresses, since the peer id is identical in the passing and failing versions.

Carried forward, NOT done here

peer_declaration() returning anything but NotReadable makes the section-5 residual live in the same moment. no_visible_term_promotes_a_claim_before_the_typed_accessor_exists is written to FAIL when that lands, so the authoritative-record restriction cannot be forgotten: it must ship with the 0.8.0 adoption, not after it.

Environment note

rustc on this host repeatedly aborted with STATUS_STACK_BUFFER_OVERRUN (no error[E…] anywhere), including on unrelated dependency crates, and left a poisoned tracing-appender artifact that produced 207 bogus "cannot find Send/Vec" errors until cargo clean -p. Every run above was taken after that, with CARGO_INCREMENTAL=0 -j 2.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict

Audited head: 3b978d9618119cf418df4c70b5d6c5d23830ee06 (resolved from gh pr view 467 --json headRefOid; merge-base 95c9582).

Posting as I go so nothing is lost to a stall. This is not the verdict.

Finding 1 (LEAD) — Bonded proves the coin exists, not that the claiming peer owns it. A stranger gets top rank for free.

crates/dig-node-service/src/mirror/bond_verify.rs:105 (verdict_for) and the trait it implements,
crates/dig-node-core/src/mirror_bond.rs:78 (MirrorBondVerifier::verify), take only (content, claimed_coin_id).
The claiming peer is never an input. BondRankingLocator::find_providers
(crates/dig-node-core/src/mirror_bond.rs:120) has record.provider_peer_id in hand and passes only
record.unverified_mirror_coin_id_bytes().

So the four steps establish a valid mirror coin bonding this (store, root, epoch) exists. Nothing
establishes this holder created it. MirrorCoin::advertises derives the owner from the coin's own lineage
proof (dig-mirror-coin-0.7.0/src/coin.rs:93,148) — correct for "does this coin bond this tuple", and it is
the sound test for that question — but the owner is compared only against the coin's own hint, never against
the peer making the claim.

Exploit, concretely. State: honest holder H publishes a provider record for capsule (S,R) carrying its
real mirror-coin id — dig-node already does this on main, crates/dig-node-core/src/seams/dig_peer/dht.rs:494
and :618 call announce_provider_with_collateral(id, coin_id). Attacker action: A calls find_providers for
(S,R) (the same public DHT query every downloading node makes), reads H's unverified_mirror_coin_id in
cleartext off the record, and publishes its own provider record for (S,R) carrying that same coin id.
Impact: every verifying node reads the coin, all four steps pass, A is ranked Bonded — tied with H and
above every honest holder that has no pointer. Zero collateral, zero chain writes, one public read.

A second, independent source of the same id needs no DHT at all: every mirror coin in existence shares one
puzzle hash (dig-mirror-coin-0.7.0/src/asset.rs:29mirror_coin_puzzle_hash() is a global constant), so
get_coin_records_by_puzzle_hash(mirror_coin_puzzle_hash()) enumerates all of them and the declared
(store, root, epoch) is in the memos.

Why this is not merely "the guarantee is weaker than hoped". The sort key is now primary
(mirror_bond.rs:143, sort_by_key), and #836's deliberate connection-verified-pool-first order survives only
within a verdict class. So a strange peer holding a copied 32 bytes is promoted above connection-verified
honest peers. Before this PR no remote party could deterministically buy top rank; after it, one public read
buys it. That is a new free promotion primitive for an unbonded stranger, and it is the exact inverse of what
#466 set out to do.

Severity HIGH. Not CRITICAL: content is still merkle-verified so this is rank/traffic capture, not content
forgery. But it is a live, cheap, remote capability that this PR creates.

The honest part: SPEC.md §25.6a's table says "the named coin passes every §25.6 check", which is literally
true and does not overclaim ownership. The gap is in the mechanism, not in the prose.

Still working: cache eviction/staleness (3), the three failure directions by mutation (2), the pub widening
(4), the acceptance test's real-constructor claim (5), and the declared hint-scan limitation.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS (2/3), not the verdict

Head still 3b978d9.

Finding 2 — the "cheap lookup" budget now buys up to ~170x its calibrated cost. Remote chain-source amplification.

The per-requestor miss limiter runs in the right PLACE but is now sized for the wrong WORK.

crates/dig-node-core/src/download.rs:2603 admits one miss -> find_providers per token, and its own
comment at :2612 calls it "the cheap DHT lookup the (1) budget is sized for". The constant agrees:
crates/dig-node-core/src/rate_limit.rs:165 DEFAULT_MISS_LOOKUP_BURST = 16.0, :169 refill 4.0/sec,
with the doc at :161 justifying the size because "a miss lookup is cheaper ... than an identity ping" —
and the genuinely expensive legs (proxy fetch :179, relay ask :200) each get a QUARTER of it precisely
because they are not cheap.

This PR makes that same admitted lookup perform, per located record, up to two blocking chain RPCs
(crates/dig-node-service/src/mirror/bond_verify.rs:112 coin_record, :141 coin_spend), driven from a
sequential loop at crates/dig-node-core/src/mirror_bond.rs:131-142. Nothing in the PR adds a bound of its
own, and the limiter was not resized.

How big the slate is, and who chooses it. dig-dht-0.15.0/src/lookup.rs:45
MAX_PROVIDERS_PER_RESPONSE = 64, and find_providers runs with stop_on_providers = true
(src/lookup.rs:185) — the walk ENDS on the first peer that answers with any provider. So a single
malicious responder deterministically supplies the whole slate: 64 fabricated records, 64 distinct
fabricated coin ids. Plus up to 20 local (src/provider_store.rs:54), deduped by peer id.

The arithmetic. One admitted miss -> up to ~84 records -> up to ~84 coin_record reads (a
nonexistent coin id costs exactly one: bond_verify.rs:115 returns Unbonded on Ok(None)). Sustained
per requestor at 4 tokens/sec: ~340 chain RPCs/sec, from one remote source, against this node's chain
source. Bytes-in: one redirect request. Work-out: hundreds of upstream reads.

The memoisation does not save it, and the attacker controls that too. bond_verify.rs:76
MAX_CACHED_VERDICTS = 1024, and overflow is cache.clear() (:200) — a full wipe, not an eviction. An
attacker rotating >1024 distinct coin ids thrashes the cache to empty, so every locate re-reads, AND every
honest Bonded verdict is discarded along with it. The key is (coin_id, store, root, epoch) with
coin_id supplied by a stranger (:88), so filling it is free.

Latency, on top of the read cost. The loop is sequential and each read is
tokio::task::block_in_place (bond_verify.rs:263), so one locate can hold a tokio worker for
N x RTT — ~13 s at 84 records and 150 ms RTT — on the redirect path a stranger reaches.

Severity HIGH. This is the amplification shape, not a perf note: a small remote request elicits large
upstream work, the limiter in front of it was calibrated against a cost this PR multiplied, and the
attacker picks both the record count and the ids.

Finding 3 (MEDIUM) — a per-record disk read+parse that the cache cannot absorb

bond_verify.rs:281 calls epoch_and_requirement() BEFORE the cache lookup at :288. That helper
(:227) calls requirement(&EpochRecordStore::in_state_dir(), ...), and EpochRecordStore::get
(crates/dig-node-service/src/collateral.rs:647-667) does a full std::fs::read_to_string plus a
line-by-line serde_json parse of the append-only epoch file — on every record, on every locate, even
on a cache hit
. It is also plain blocking I/O directly on the async worker, with no block_in_place or
spawn_blocking around it. Moving the cache probe ahead of it costs nothing and removes the whole class.

Still to do: mutation spot-check of the three failure directions, the pub(crate) -> pub widening, and
whether the acceptance test really drives the production constructor.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Correction to the line citations in my previous comment

Some file:line refs in the comment above were transcribed from a working copy and are wrong. A verdict
whose citations do not resolve is not checkable, so here is the corrected set at 3b978d9. The findings
themselves are unchanged; only the numbers move.

crates/dig-node-service/src/mirror/bond_verify.rs:

claim I wrote correct
VERDICT_TTL (600 s) 52
MAX_CACHED_VERDICTS = 1024 76 60
coin_id is the attacker-supplied key field 88 69
source.coin_record(...) — chain read 1 112 92
Ok(None) => Unbonded (a nonexistent coin costs exactly one read) 115 95
source.coin_spend(...) — chain read 2 141 116
cache.len() >= MAX -> cache.clear() (full wipe) 200 178-179
fn epoch_and_requirement 227 201
epoch_and_requirement() called BEFORE the cache probe 281 228
the cache probe 288 238
tokio::task::block_in_place 263 254

crates/dig-node-core/src/mirror_bond.rs:

claim I wrote correct
MirrorBondVerifier::verify takes only (content, claimed_coin_id) 78 72
BondRankingLocator 120 94
the sequential per-record verify loop 131-142 122-124
sort_by_key — the verdict is the PRIMARY sort key 143 140

Correct as written, re-checked: crates/dig-node-core/src/download.rs:2603 (limiter) and :2613 ("the
cheap DHT lookup the (1) budget is sized for"); crates/dig-node-core/src/rate_limit.rs:165 / :169 /
:179 / :200; crates/dig-node-core/src/seams/dig_peer/dht.rs:494 and :618;
crates/dig-node-service/src/collateral.rs:647; dig-mirror-coin-0.7.0/src/coin.rs:93,148 and
src/asset.rs:29; dig-dht-0.15.0/src/lookup.rs:45,185 and src/provider_store.rs:54.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS (3/3), not the verdict

Head still 3b978d9. Line numbers below are verified against that tree.

Check 4 — the pub(crate) -> pub widening: CLEAR, no finding

crates/dig-node-core/src/download.rs:2550 (Node::p2p_content) widens correctly and narrowly.

  • dig-node-core is not publishedindex.crates.io/di/g-/dig-node-core returns NoSuchKey, and every
    consumer is a path dep in this same repo (dig-node-service, dig-runtime, dig-wallet). So this is a
    workspace-internal widening, not a crates.io API change.
  • The returned type was already pub (pub struct NodeContent, download.rs:746), so no new type is
    exposed — only the ability to obtain the node's live instance from a &Node.
  • Exactly one out-of-crate caller exists: crates/dig-node-service/src/mirror/bond_verify.rs:282. Every
    other call site is inside dig-node-core (download.rs:2563, :2596; lib.rs:3883, :4541;
    seams/capsule/capsule_store.rs:566; seams/content/bandwidth.rs:199;
    seams/content/content_serve.rs:1025; seams/dig_peer/module_relay.rs:116) plus tests.
  • NodeContent carries 15 pub methods, all of which a consumer could already name; the 0.65.0 -> 0.66.0
    minor bump is the right call under 0.x for an additive surface change.

Check 5 — the acceptance test does drive the real constructor: CONFIRMED

the_engine_ranks_a_disproven_bond_last_on_its_own_discovery_path (download.rs:4413) builds via
NodeContent::new, and that is genuinely the production path:

  • NodeContent::for_dht — the production constructor (download.rs:1501) — assembles
    provider_locator_chain and then calls Self::new at download.rs:1523.
  • NodeContent::new (:1249) wraps whatever locator it is handed in BondRankingLocator at the top of the
    body, so the layer is outermost over the union / self-excluding / capsule-fallback chain in production and
    over the mock in the test alike.

So a wiring change that dropped the layer fails this test with every mirror_bond unit test still green.
The claim in the brief holds.

But one level up is untested, and it is the same failure class (MEDIUM)

The test proves the layer is reachable; nothing proves the verifier is ever installed.
spawn_bond_verifier_install (bond_verify.rs:277) is gated on peer_network_enabled() and
config.enable_chain_sync (crates/dig-node-service/src/server.rs:2164), then polls node.p2p_content()
60 times at 2 s (:298-300) and gives up after 120 s. If the engine is not up inside that window — a
slow bring-up, a large inventory announce — the verifier is never installed, the locator stays a
pass-through forever, and the only trace is a tracing::debug! (:303), not a warning. A node in that
state is indistinguishable from a working one on every surface.

Cache coverage (extends Finding 2): ZERO tests

ChainBondVerifier — the 600 s TTL, the 1024-entry bound, the clear-on-overflow, and
epoch_and_requirement — has no test anywhere in the repo. Every test in
crates/dig-node-service/tests/mirror_bond_verify.rs drives the pure verdict_for directly, and
grep -n "ChainBondVerifier\|MAX_CACHED_VERDICTS\|VERDICT_TTL" outside bond_verify.rs itself returns
nothing. The brief called the cache attacker-adjacent; it is also the one part of this PR that nothing
exercises.

The verdict_for tests themselves look sound rather than vacuous — in particular
a_declaration_that_disagrees_with_its_own_hint_is_unbonded (tests/mirror_bond_verify.rs:342) constructs a
coin whose declared tuple is right and whose hint is solved for a different epoch, which is exactly the
one-sided case that would pass if either half of advertises were dropped. Mutation spot-check still
running; I will report the result or say plainly that I could not reach it.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — Finding 4, and it is the one that decides the gate (still IN PROGRESS)

Head 3b978d9.

Finding 4 (HIGH) — the coin id is unauthenticated hearsay about a THIRD PARTY, so the ranking is steerable in both directions

The PR's central safety argument is that reordering is a safe primitive because it "drops nothing": a lying
publisher is served last, "an honest one that cannot prove itself loses nothing"
(crates/dig-node-core/src/mirror_bond.rs:26-32, and SPEC.md §25.6a "A holder is never refused, dropped,
or blocklisted"). That argument assumes the claim is made by the holder it is about. It is not.

dig-dht-0.15.0/src/service.rs:682 states it plainly about lookup-collected records: "A record collected
during a lookup was attributed by NOBODY: an arbitrary peer"
, and :778 "ProviderRecord carries no
signature". Only records that arrived over mTLS from the announcing peer, or through
ingest_verified_provider with a checked holder signature, are authoritative. Everything a lookup returns is
hearsay — and find_providers hands both kinds to the caller merged
(src/service.rs:243, merge_dedup_by_provider at :891).

BondRankingLocator (crates/dig-node-core/src/mirror_bond.rs:122-124) reads
record.unverified_mirror_coin_id_bytes() off whatever record it is given, with no check that the record was
authoritative, and ranks the peer named in provider_peer_id by the answer.

Exploit. An attacker positioned to answer one find_providers lookup — and it only needs to answer one,
because stop_on_providers = true ends the walk at the first peer returning any provider
(dig-dht-0.15.0/src/lookup.rs:185) — returns a slate of up to 64 records (:45) in which it:

  1. names honest holder H, with H's real addresses, and attaches a bogus coin id. The victim reads the
    chain, gets Unbonded (bond_verify.rs:95), and ranks H last on every read; and
  2. names itself, attaching a real coin id copied per Finding 1. It is ranked first.

Result: the attacker sets the rank of every holder in the slate, in both directions, for the cost of
answering one lookup. No collateral, no forged signature, nothing to break.

Why the existing hearsay mitigation does not cover this. dig-dht-0.15.0/src/service.rs:255 justifies
using hearsay on the fetch path: "a wrong candidate is merely a wasted dial because the merkle bind catches
it
". That is true of addresses. It is not true of the coin id, because the coin id is never fetched
against — it is used to form a judgement about a named third party and then act on it. The same doc says
what happens when hearsay is used that way: "it becomes THIS NODE'S claim about the world". This PR is the
first consumer to cross that line.

So the answer to "is reordering enough for a first implementation?" is not that it is too weak. It is the
wrong primitive while its input is unauthenticated.
A demotion that any stranger can aim at any honest peer
is a censorship primitive, and it is strictly worse than the pre-PR state where the field was inert.

The two fixes are different, and both are needed

  • Against Finding 4 (demotion): only act on the pointer when it came from an AUTHORITATIVE record — the
    mTLS-verified announcement or a signature-checked ingest_verified_provider. A hearsay record's pointer
    must be treated as absent (Unverified), which is already the safe default this PR implements for a
    missing pointer.
  • Against Finding 1 (free promotion): bind the coin's owner_puzzle_hash() to the claiming peer's
    identity. mTLS alone does not do this — it proves the record came from that peer, not that the peer owns
    the coin.

This reframes the limitation the lane declared. The missing peer_id -> owner_puzzle_hash mapping is not
merely why the hint-scan fallback could not be initiated. It is the reason the verification cannot be bound
to the claimant at all — which is the root of Findings 1 and 4 both. The lane was right that the mapping does
not exist and right to say so rather than fake it; the consequence is larger than the fallback.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — VERDICT: CHANGES-REQUIRED

Audited head: 3b978d9618119cf418df4c70b5d6c5d23830ee06 (resolved from gh pr view 467 --json headRefOid;
merge-base 95c9582; main at 6e2475a, so a rebase is owed and the head may move — re-run the two probes
below if it does).

Method: grep + direct read. gitnexus was NOT used — the dig-node index is ~301 commits stale and impact
returns a false-safe zero on a stale index. Two mutation probes ran in a lane-private worktree
(C:\tmp\worktrees\secgate-dn467, since removed); the primary checkout was never touched.


Mutation evidence (checks 1 and 2, both confirmed)

Baseline cargo test -p dig-node-service --test mirror_bond_verify: 9 tests, 9 passed, 0 filtered out.

mutation result reads as
advertises(...) replaced by a declared-triple-only comparison, dropping the recomputed-hint half 9 ran, 1 FAILED, 0 filtereda_declaration_that_disagrees_with_its_own_hint_is_unbonded step 4's hint half is load-bearing and genuinely tested; step 4 cannot be satisfied by the declared triple alone
coin_record Err(_) returns Unbonded instead of Unverified 9 ran, 1 FAILED, 0 filteredan_unreachable_chain_is_unverified_not_unbonded the outage/lie distinction is enforced, not decorative

The other two failure directions are structurally verified rather than mutated: an ABSENT pointer returns
before any chain access (bond_verify.rs:222-224), and a WRONG pointer triggers no blocklist because no
blocklist call exists on the path at all — the only action is sort_by_key (mirror_bond.rs:140).


What is right, and should not be relitigated

  • The four steps are present, in the stated order, and correct. Step 4 calls MirrorCoin::advertises
    rather than recomputing the morph; the crate doc at dig-mirror-coin-0.7.0/src/coin.rs:120-147 explains
    why hint-equality alone is forgeable, and the mutation above proves the PR relies on both halves. The
    deliberate 3/4 reordering (binding before magnitude) is well argued and correct.
  • Reordering rather than refusing is the RIGHT call for a first implementation. The reasoning — that a
    partition, an epoch rollover and a stale republished pointer are indistinguishable from a lie at read
    time, so refusing converts an outage into rejection of honest peers — is sound and I endorse it. A liar
    ranked last is still contactable, and that is acceptable: the merkle bind means a bad holder costs a
    wasted dial, not corrupt content.
  • Check 4 (the pub widening) is CLEAR. dig-node-core is unpublished (index.crates.io returns
    NoSuchKey), the returned type was already pub (download.rs:742), and exactly one out-of-crate caller
    exists (bond_verify.rs:282). The 0.65.0 to 0.66.0 minor bump is right for an additive 0.x surface.
  • Check 5 is CONFIRMED. NodeContent::for_dht — the production constructor at download.rs:1501
    calls Self::new at download.rs:1523, and new installs BondRankingLocator outermost. So the
    acceptance test at download.rs:4414 really would fail on a wiring that dropped the layer.

The problem is not the remedy. It is that the remedy's INPUT is attacker-controlled in both directions.


GATING findings

1. HIGH — the coin id is unauthenticated hearsay about a THIRD PARTY: a demotion primitive against any honest holder

crates/dig-node-core/src/mirror_bond.rs:122-124 reads record.unverified_mirror_coin_id_bytes() off
whatever record the locator was handed, with no provenance check, and ranks the peer named in
provider_peer_id by the answer. But dig-dht-0.15.0/src/service.rs:682 says of lookup-collected records:
"attributed by NOBODY: an arbitrary peer", and :778 notes ProviderRecord carries no signature.

State: honest holder H serves capsule (S,R).
Attacker action: answer one find_providers lookup — one suffices, since stop_on_providers = true ends
the walk at the first peer returning any provider (dig-dht-0.15.0/src/lookup.rs:185) — returning a slate
that names H, with H's real addresses, and attaches a BOGUS coin id.
Gain: the victim reads the chain, gets Unbonded (bond_verify.rs:95), and ranks H last on every
read. Repeat across the slate to demote every honest holder. Cost: nothing.

The existing hearsay mitigation does not cover this. dig-dht-0.15.0/src/service.rs:255 permits hearsay on
the fetch path "because the merkle bind catches it" — true of ADDRESSES, false of the coin id, which is never
fetched against but is used to judge a named third party and act on it. The same doc names the line this
crosses: "it becomes THIS NODE'S claim about the world." This PR is the first consumer to cross it.

Fix: act on the pointer only when the record is AUTHORITATIVE (mTLS-verified announcement, or
signature-checked via ingest_verified_provider). A hearsay record's pointer must be treated as absent —
already the safe default this PR implements for a missing pointer, so the fix is small.

2. HIGH — Bonded proves the coin exists, not that the claimant owns it: a free promotion

MirrorBondVerifier::verify (mirror_bond.rs:72) and verdict_for (bond_verify.rs:85) never receive the
claiming peer. advertises takes the owner from the coin's own lineage proof
(dig-mirror-coin-0.7.0/src/coin.rs:93) — correct for "does this coin bond this tuple", but the owner is
compared only against the coin's own hint, never against the peer making the claim.

Attacker action: call find_providers for (S,R) — the same public query every downloading node makes —
read an honest holder's unverified_mirror_coin_id in cleartext, and republish it as your own. dig-node
already attaches real pointers on main (seams/dig_peer/dht.rs:494, :618), so the ids are there to copy.
An independent second source needs no DHT at all: every mirror coin shares ONE puzzle hash
(dig-mirror-coin-0.7.0/src/asset.rs:29), so get_coin_records_by_puzzle_hash enumerates all of them and
the declared tuple is in the memos.
Gain: ranked Bonded, first, with zero collateral.

This bites because the verdict is now the PRIMARY sort key (mirror_bond.rs:140) and #836's deliberate
connection-verified-pool-first order survives only WITHIN a class. A stranger holding 32 copied bytes
outranks connection-verified honest peers.

Fix: bind owner_puzzle_hash() to the claiming peer's identity. mTLS alone does not do this — it proves
the record came from that peer, not that the peer owns the coin.

3. HIGH — amplification: the "cheap lookup" budget now buys up to ~170x its calibrated cost

download.rs:2603 admits one miss to find_providers per token, and its own comment at :2613 calls it
"the cheap DHT lookup the (1) budget is sized for". rate_limit.rs:165 sets burst 16.0, :169 refill
4.0/sec, and the doc at :161 sizes it precisely BECAUSE a miss lookup is cheap — the genuinely expensive
legs (:179 proxy, :200 relay ask) each get a quarter of it.

This PR makes that same admitted lookup perform up to two blocking chain RPCs per located record
(bond_verify.rs:92, :116) from a sequential loop (mirror_bond.rs:122-124), adding no bound of its own
and not resizing the limiter. A single malicious responder supplies the whole slate: up to 64 records
(dig-dht-0.15.0/src/lookup.rs:45) plus up to 20 local (src/provider_store.rs:54). One admitted miss
becomes ~84 chain reads; sustained at 4 tokens/sec that is ~340 chain RPCs/sec from one remote source
against this node's chain source.

Memoisation does not save it, and the attacker controls that too: MAX_CACHED_VERDICTS = 1024
(bond_verify.rs:60), overflow handled by cache.clear() (:178-179) — a full wipe, not an eviction —
keyed on a stranger-supplied coin_id (:69). Rotating more than 1024 ids thrashes the cache empty, so
every locate re-reads AND every honest Bonded verdict is discarded with it.

Latency compounds it: the loop is sequential and each read is block_in_place (:254), so one locate can
hold a tokio worker for N x RTT — roughly 13 s at 84 records and 150 ms RTT — on a path a stranger reaches.

Fix: bound the verified records per locate, verify concurrently rather than sequentially, and make the
cache evict rather than clear. Finding 1's fix helps here too: restricting the pointer to authoritative
records collapses the attacker-chosen fan-out.


NON-GATING (file as follow-ups; do not hold the PR on these)

  • MEDIUM — a per-record disk read+parse the cache cannot absorb. bond_verify.rs:228 calls
    epoch_and_requirement() BEFORE the cache probe at :238; that helper (:201) reaches
    EpochRecordStore::get (collateral.rs:647-667), a full read_to_string plus line-by-line serde_json
    parse — on every record, on every locate, even on a cache hit — as plain blocking I/O on the async worker
    with no spawn_blocking. Moving the cache probe ahead of it costs nothing.
  • MEDIUM — the install path is untested and fails silently. spawn_bond_verifier_install
    (bond_verify.rs:276) is gated on peer_network_enabled() and config.enable_chain_sync
    (server.rs:2165), polls p2p_content() 60 times at 2 s (:281, :291), and gives up after 120 s with
    only a tracing::debug! (:293). A node whose engine came up slowly never verifies and looks identical
    to one that does — the same failure class the acceptance test was written to end, one level up.
  • MEDIUM — ChainBondVerifier has ZERO tests. The TTL, the 1024 bound, the clear-on-overflow and
    epoch_and_requirement are exercised by nothing in the repo; every test drives the pure verdict_for.
  • LOW — a stale positive can outlive a spend by up to 600 s. VERDICT_TTL (:52) caches a definite
    Bonded, so a coin reclaimed inside the window still ranks first for the remainder. Bounded and
    ranking-only; acceptable, but worth stating in SPEC 25.6a.

On the limitation the lane declared: it does NOT gate on its own, but it is deeper than stated

The lane was right that dig_mirror_coin::discover needs owner_puzzle_hash, that ProviderRecord carries
no owner, that no peer_id -> owner mapping exists in the repo, and it was right to deliver the effect and
say so rather than fake the fallback. As a FALLBACK gap that is acceptable for this PR, and the consequence
the brief names — a pointerless publisher can only ever be Unverified — is real but bounded: Unverified
still ranks above Unbonded, so absence does not read as uncollateralised, which is what the contract
forbids.

But that missing mapping is not merely why the hint scan cannot be initiated. It is the reason the
verification cannot be bound to the claimant at all
, which is the root of findings 1 and 2 both. Track it
as the blocker for those, not as a fallback nicety.

What I could not reach

  • The dig-node-core unit tests in mirror_bond.rs and the acceptance test at download.rs:4414 were READ,
    not executed. The mutation evidence covers verdict_for only, via the service integration suite;
    BondRankingLocator's ranking itself is unmutated.
  • ChainBondVerifier was not exercised end to end — no test exists and no chain source was available.
  • Behaviour AFTER the owed rebase is unverified: the PR is CONFLICTING against main 6e2475a. Re-read
    the version from Cargo.toml on disk afterwards, never from the log — a rebase silently DROPS a bump
    commit whose patch is already upstream, with RC=0 and no conflict.
  • gitnexus impact was not used (index ~301 commits stale); blast radius came from grep plus direct read.

Verdict: CHANGES-REQUIRED. Findings 1, 2 and 3 are live and remotely reachable. Finding 1 in particular
lets a stranger aim a demotion at an honest peer, which is strictly worse than the pre-PR state where the
field was inert. The shape of this PR is good, the four steps are right, and the reordering choice is the
correct one; what is missing is authenticating and binding the input the ranking acts on.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — audit of the credit-only decision (NOT an implementation)

Head read: 3b978d9618119cf418df4c70b5d6c5d23830ee06, still DRAFT. main confirmed at 3ce4785.

I did not write code and will not. This gate's value is that it is independent of the maker
(CLAUDE.md 1.9); if I implement the fix I proposed, the one context that has threat-modelled this
deeply becomes ineligible to gate it. This needs a loop-implementer lane. Everything below is the
audit output that lane needs, including two things that change the plan.


1. Your open question is RESOLVED: the dial does NOT pin peer_id

This is no longer a residual footnote — under credit-only it becomes load-bearing, because promotion is
now the only thing the layer does.

  • dig-gossip rev 1a33916 (the rev this PR locks) has no expected-peer-id parameter anywhere in
    production
    . grep -rn "expected_peer\|expect_peer\|want_peer" src/ returns hits in exactly one
    file, src/connection/rustls_inbound.rs:273,293,332 — and #[cfg(test)] mod tests begins at
    line 192, so all three are inside the test module. The only production statement is derivation:
    outbound.rs:14,32,119, "PeerId = SHA256(remote SPKI DER)". Deriving an identity is not pinning
    one.
  • The download path does not pin either. In dig-download-0.22.0, provider_peer_id is used as a
    health/selection key and passed through (src/source.rs:179, src/module.rs:648,720,1019); its own
    src/error.rs:368 calls it "free-form text off the wire". No comparison against the dialled endpoint.

So peer.rs:4758's comment — "every dial to this node fails closed with peer_id mismatch" — is a
doc-comment claim not backed by production source. It should be corrected or removed in this PR;
a false safety claim in a comment is how the next reviewer skips the check.


2. Part 1 of the decision is technically SOUND — I verified the claim it rests on

dig-mirror-coin-0.7.0/src/create.rs:99-108 writes the memo layout [hint, store, root, epoch, url...],
and src/coin.rs:490-499 collects every trailing entry that is valid UTF-8 into urls, dropping the
rest. The tail is genuinely arbitrary UTF-8, and it is written by the parent spend, which only the owner
key can produce. A dig-peer:<64-hex> term in that tail is a statement by the coin's owner, verified by
executed on-chain code.
Correct, and it needs no dig-dht wire change.

3. Part 2 kills Finding 1 completely — I endorse it

Collapsing absent / Unverified / Unbonded into one baseline tier removes the demotion primitive by
construction
, not by degree. Hearsay can withhold credit and can never subtract it. That was my worst
finding and this closes it cleanly. Ship the lattice.

4. But it does NOT kill Finding 2 — and neither prescribed test would catch what is left

The memo term binds coin -> peer_id. It does not bind peer_id -> record, and a lookup-collected
record has all three of its fields attacker-chosen, not just the coin id.

Attack on the credit-only design. Attacker answers one lookup (stop_on_providers = true,
dig-dht-0.15.0/src/lookup.rs:185, so one answer takes the whole slate) with a record carrying:

field value
provider_peer_id H (honest, bonded)
unverified_mirror_coin_id H's real coin — which declares H
addresses the attacker's addresses

Every check passes: the coin bonds the content, the coin declares peer H, the record claims peer H.
The record is promoted to first — and it points at the attacker. Because the dial does not pin
(section 1), the attacker then serves the traffic while holding H's rank. The method changed from
"copy the coin id onto my own peer id" to "copy the coin id and the peer id onto my own addresses";
the outcome — an unbonded stranger at top rank — did not.

Why your two tests miss it. Test 1 gives the liar its own peer_id, which is exactly the case
the memo binding fixes. Test 2 is about demotion. Neither varies the ADDRESSES while holding
peer_id and coin id honest
, so both pass against a build with this hole wide open. A third test is
required:

a record naming an honest holder's peer_id and its real coin id, but carrying different
addresses
, must not be promoted — assert on the addresses that come back, not on the peer id,
because the peer id is identical in the passing and failing versions.


5. The real remedy costs a dig-dht release — and this is why it does NOT block this PR

The fix is to promote only on an authoritative record (an mTLS-verified announcement, or one
signature-checked through ingest_verified_provider, where dig-gossip's
holdings_announce.rs:568 already enforces SHA-256(provider_spki) == provider_peer_id). The binding
this needs therefore already exists — but dig-node cannot currently see it:

  • ProviderRecord has exactly five fields — content_key, provider_peer_id, addresses,
    expires_at, unverified_mirror_coin_id — and no provenance marker.
  • There is no authoritative-only per-content accessor. find_providers merges local and discovered
    into one Vec (dig-dht-0.15.0/src/service.rs:243, merge_dedup_by_provider at :891);
    cached_providers returns hearsay only.

So the remedy is a release-first dig-dht 0.16 (expose provenance, or an authoritative-only read), the
same cascade shape the decision already accepts for dig-mirror-coin 0.8.0.

Scheduling — this is the part that makes the decision still shippable. The residual becomes live at
exactly the same moment promotion does. While no coin carries a dig-peer: term, nothing is ever
promoted, and an unpromotable layer cannot be captured. So: ship credit-only now; the
authoritative-record restriction must land before or with dig-mirror-coin 0.8.0 adoption, never
after.
Track it as a blocker on the 0.8.0 adoption ticket, not as a follow-up.

6. A fork the implementer will hit in the first hour — decide it now

"Bonded is unreachable until 0.8.0" is true because no coin contains a dig-peer: term, not
because 0.7.0 cannot read one: MirrorCoin::urls() already returns that tail today. So the implementer
has two options and one of them is wrong:

  • Parse dig-peer: out of urls() against 0.7.0 — makes promotion reachable immediately, and
    creates a second parser for a format dig-mirror-coin 0.8.0 is about to own. That is the
    rival-implementation rule (CLAUDE.md 2.0) violated on day one, on a security-critical parse.
  • Gate promotion off until 0.8.0's typed accessor exists. RECOMMENDED. The promotion tier is
    present, tested, and unreachable because its peer-binding check has no sound source yet. That is
    "inert-but-recorded" reached honestly, it avoids the rival parse, and it makes section 5's residual
    provably unreachable in the interim rather than merely unlikely.

Whichever is chosen, say which in the PR body, because the two produce identical-looking green
suites and opposite security postures.


7. Finding 3 (amplification): bound it in THIS PR, and the interim makes the case stronger, not weaker

Credit-only does not touch it — the chain reads happen per record regardless of what the lattice does
with the answer. But it changes the cost/benefit sharply, in the direction of acting now:

While promotion is unreachable, every one of those up-to-84 blocking chain reads per locate is pure
cost with literally zero effect on any outcome
— and a stranger picks the volume. Shipping a
remotely-triggered fan-out that cannot change a decision is the weakest possible position to defend.

Cheapest bounds, in order of value per line changed:

  1. Cap the records verified per locate to a small constant in source order. Kills the
    attacker-chosen fan-out outright (mirror_bond.rs:122-124).
  2. Move the cache probe ahead of epoch_and_requirement() (bond_verify.rs:228 before :238).
    Removes a full read_to_string + line-by-line serde_json parse of the epoch file
    (collateral.rs:647-667) per record per locate, currently unabsorbable by the cache and executed as
    blocking I/O on the async worker.
  3. Evict instead of cache.clear() (bond_verify.rs:178-179), so a stranger rotating >1024 ids
    cannot discard honest verdicts.
  4. Verify concurrently rather than sequentially, removing the N x RTT worker hold (:254).

(1)+(2)+(3) are small and independent of both the lattice and 0.8.0. If only one lands, make it (1).


What I am handing back

  • The decision is right in its core — the memo binding is sound and the credit-only lattice kills
    the demotion primitive by construction. I endorse shipping it.
  • Two corrections: the claim that it "kills both HIGH findings by construction" does not hold for
    Finding 2
    , and the two prescribed tests would not detect the remainder. A third test and the
    section 5 restriction are needed, on the 0.8.0 schedule rather than this one.
  • Your unverified item is now verified: the dial does not pin peer_id, so credit-stealing would
    indeed be traffic redirection rather than a wasted dial once promotion is live. Section 5 is what
    keeps that unreachable.
  • No code was written by me, no failing-then-passing runs to report, and the version/lockfile work
    is untouched — that is an implementer lane's job, and it should re-gate against a fresh context
    rather than this one.

Keeps this branch's 0.200.0 (main is at 0.198.0) and takes main's Cargo.lock as the
base; the lockfile is refreshed in the follow-up commit. `mirror/mod.rs` keeps both
sides: this branch's `bond_verify` module and main's `converge_tests`.
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-implementer — progress (still DRAFT, do not merge)

Branch loop/mc-verify, head e16a6c4 (merge of origin/main @ 3ce4785; version kept at 0.200.0, Cargo.lock taken from main and being refreshed).

Implemented, uncommitted-beyond-the-merge at time of writing:

  • verdict_for and MirrorBondVerifier::verify now take the claiming peer id.
  • mirror_bond.rs is credit-only: Bonded promotes, Unverified/Unbonded/absent are one baseline tier, stable within tier.
  • Amplification bounded: MAX_VERIFIED_PER_LOCATE = 8, cache probed before the epoch file read, and the verdict cache evicts one entry instead of clear().
  • Promotion gated off at peer_declaration() until dig-mirror-coin 0.8.0 exposes a typed dig-peer: accessor — deliberately no rival parser over MirrorCoin::urls().
  • peer.rs fail-closed peer_id mismatch claims corrected (dig-gossip#85: production only derives).

Environment note: rustc on this host is crashing with STATUS_STACK_BUFFER_OVERRUN on the dig-node-core lib-test target (and, at higher -j, on unrelated dependency crates). Not a code error — no error[E…] anywhere in the output. Retrying with CARGO_INCREMENTAL=0, -j 2, larger RUST_MIN_STACK.

Next action: finish the test runs (red-then-green with counts), refresh Cargo.lock, push.

…ming peer (#466)

`verdict_for` and `MirrorBondVerifier::verify` now take the claiming peer id. Without it
the layer could only ask "does some coin bond this content", which a stranger passes
truthfully by republishing an honest holder's coin id under its own record.

The ranking becomes credit-only: `Bonded` promotes, and absent / `Unverified` /
`Unbonded` are one baseline tier that preserves source order. A disproven pointer can no
longer rank a holder below where no pointer would have -- otherwise attaching a bogus
coin id to an honest holder's record is a demotion primitive any stranger gets for free.

Promotion is gated off at `peer_declaration()` until dig-mirror-coin 0.8.0 exposes a
typed `dig-peer:` accessor. `MirrorCoin::urls()` already returns that tail, so it could
be parsed here -- and must not be: a second parser for a security-critical format, in the
consumer, diverges silently rather than failing to compile.

Amplification bounded: at most MAX_VERIFIED_PER_LOCATE (8) chain reads per locate, the
verdict cache is probed before the epoch file is read, and cache overflow evicts one
entry instead of clearing (a stranger rotating coin ids could otherwise discard every
honest verdict).

Also corrects peer.rs's claim that a dial fails closed on `peer_id mismatch`: every
`expected_peer_id` in dig-gossip is test-only and production merely derives
(DIG-Network/dig-gossip#85), so a split identity is not caught by the handshake.
…ce control survives

`chain_bond_verdict` answers "does this coin bond this content"; `verdict_for` adds
"and does it name the peer claiming it". The split keeps `tests/mirror_bond_verify.rs`'s
`Bonded` control meaningful -- routed through `verdict_for` the honest coin and a coin
nobody could look up would both answer `Unverified`, so every negative case would be
equally explained by a fixture too broken to verify at all.

Adds a test on the SAME on-chain fixture asserting both halves: the chain establishes
the bond, and the claim is still not promoted to a peer the coin does not name.
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Update — head e59548a. Two things changed after the PR body above was written; both are corrections, not additions.

1. verdict_for is split into chain_bond_verdict + verdict_for. cargo clippy --all-targets (which --lib runs do not reach) found tests/mirror_bond_verify.rs calling the old 6-argument signature. Those ten conformance tests assert Bonded on real CAT-spend fixtures, and routing them through the gated verdict_for would have made the honest coin and a coin nobody could look up both answer Unverified — destroying the control that gives every negative case in that file its meaning. So the chain half keeps its own entry point and its own signature, and verdict_for is the chain half plus the peer gate.

2. A new test on the same on-chain fixture asserts both halves at once: chain_bond_verdict says Bonded (the control), and verdict_for with a claimant the coin does not name says Unverified. That is a stronger statement of the addresses finding than the unit test, because the coin is genuine rather than modelled.

Runs after the split, all unpiped, CARGO_INCREMENTAL=0 -j 2:

  • -p dig-node-service --test mirror_bond_verifyok. 10 passed; 0 failed (including a_coin_that_passes_every_chain_check_is_still_not_promoted_to_a_claimant)
  • cargo clippy -p dig-node-core -p dig-node-service --all-targetsno errors, no warnings
  • cargo metadata --locked --offline → clean, so CI's --locked has a consistent lockfile

Still DRAFT. The gate that produced the audit should re-audit this in a fresh context; it declined to implement, correctly.

# Conflicts:
#	Cargo.lock
#	Cargo.toml
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-implementer (resumed lane) — progress. Still DRAFT, do not merge.

The prior lane died at the session cap with everything pushed and nothing uncommitted — its worktree was clean at e59548a and matched remote, so there was nothing to salvage and no work was lost.

What this session changed

origin/main advanced to 0.206.0 (a5507db, #475) while this branch sat at 0.200.0, so the version gate would have failed on a stale base.

  • Merged origin/main into loop/mc-verify. Conflicts in Cargo.toml and Cargo.lock only; every source conflict auto-merged.
  • Version re-read from Cargo.toml on disk after the merge and re-bumped to 0.207.0 (above main's 0.206.0, not the branch's stale 0.200.0). crates/dig-node-core stays at 0.66.0 over main's 0.65.0.
  • Cargo.lock taken from main and re-resolved: cargo update -w --offline moved exactly the two workspace members and locked nothing else.

Head is now d3a6ae1.

Verification on the merged tree

check result
cargo fmt --all -- --check clean, exit 0
cargo clippy -p dig-node-core -p dig-node-service --all-targets exit 0, zero errors, zero warnings
cargo test -p dig-node-core -p dig-node-service --lib running

Review of the inherited implementation against the two gates

Read against dig-node#473's DECIDED comment and this PR's loop-security audit; no correction was needed.

  • HIGH 1 — "Bonded proves the coin exists, not that the claimant owns it." Closed at verdict_for (bond_verify.rs:208): the chain half must pass and declared_peer must answer DeclaresThisPeer. Anything else degrades to Unverified. peer_declaration returns NotReadable unconditionally today, so promotion is off by construction rather than by a flag, exactly as the decision specifies for the pre-0.8.0 interim.
  • HIGH 2 — "the demotion path makes the verifier net-negative." Closed by credit_rank (mirror_bond.rs:130): Bonded is tier 0, Unverified and Unbonded are both tier 1, and the sort is sort_by_key on a Vecstable, so source order survives within the tier. BondVerdict carries no Ord derive, so a future variant cannot acquire a ranking from its declaration position.
  • The failure direction this lane was told to guard — a chain read that fails must not demote an honest holder — holds structurally, not by care: Err(_) maps to Unverified at every source call, and Unverified and Unbonded share a tier, so an unreachable chain cannot move any record at all.

Next action

Report the lib-suite counts, update the PR body's version and head, and hand back to the orchestrator for a fresh gate round. This lane does not undraft and does not merge.

Sign up for free to 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.

1 participant