Skip to content

fix(nc12): corroborate the anchored root, and widen the sole-owner sweep to dig-node-core - #369

Merged
MichaelTaylor3d merged 11 commits into
mainfrom
loop/365-366-nc12-anchored-root
Aug 26, 2026
Merged

fix(nc12): corroborate the anchored root, and widen the sole-owner sweep to dig-node-core#369
MichaelTaylor3d merged 11 commits into
mainfrom
loop/365-366-nc12-anchored-root

Conversation

@MichaelTaylor3d

@MichaelTaylor3dMichaelTaylor3d commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

DO NOT MERGE — gate round in progress. Custody-adjacent; takes the full gate.

Closes#365
Closes#366

The two remaining NC-12 items on dig_ecosystem#2790. Measured against freshly-fetched origin/main @ 4efa57fc (re-checked twice during the lane; unmoved).

#365 — the anchored root: what is CORROBORATED, and what is STATED

Corroborated (built)

The anchored root, on all three calls the serve decision makes — anchored_state, verify_pinned_root, verify_lineage_root. New seams/chia_peer/{endpoints,corroborated_resolver}.rs; default_anchored_resolver() now returns the corroborating resolver.

  • Endpoints come from DIG_NODE_CHAIN_ENDPOINTS (comma list). DIG_NODE_COINSET keeps its existing meaning when that is unset, so no current configuration changes meaning; the default remains https://api.coinset.org. An unparseable entry is dropped, never defaulted — a typo must not be able to pose as a second voice.
  • Independence is derived from REACH. Two endpoints are ONE voice when their resolved address sets intersect, transitively. Not their type, not their URL, not their host name. This answers the PR#354 trap at its root: a CNAME costs an attacker nothing, so two names for one machine can never manufacture a quorum. An endpoint that resolves to nothing contributes no voice.
  • Unanimity, never a majority. One dissenter REFUSES. No tie-break — a majority rule hands the answer to whoever can field the most endpoints, and a refusal is survivable where a wrong root is not. Presence vs absence counts as dissent, so one source cannot conjure a store into being. A source that could not be REACHED is dropped rather than counted as dissent (an outage and an attack demand opposite remedies); fewer than two answers is a refusal.

Stated as accepted limitations — SPEC.md §14.4b, each with its blast radius

  1. The DEFAULT INSTALL still resolves from ONE third party. With fewer than two independent voices the node answers from its single source and does not claim corroboration. Refusing instead was considered and rejected: it stops every unconfigured node serving any content at all, and removes the very surface on which an operator would configure a second endpoint. Blast radius is written out in SPEC, including what it does not permit — bytes are still accepted only because they verify against the merkle root.
  2. coin_records_by_puzzle_hashes / _by_hints / _by_parent, mempool submission, the melt confirmation, and the direct RPC-surface singleton walks. Enumerated in SPEC so no reader infers corroboration from the rule above. These live in dig-wallet/src/sage/chain.rs, which belongs to the harden(dig-wallet): chain.rs:111 with_sources is dead code that builds a single-source peak_height path #360 lane (PR fix(node): dead transport ctor, temp-dir leak, version-gate reporting, executed sweep examples #368) — not touched here, by coordination.

Why chia-query PR#39 independence_groups() is not consumed here

Same principle, unreachable layer, and measured rather than assumed: dig-node-core declares no chia-query dependency at all — the dormant light client was removed and its chia-query = "=0.5.1" pin was precisely what held this crate off the chia 0.36 line. And chia-query is now 0.17.0, entirely on chia ^0.36, while this crate pins chia ^0.26. Consuming it would re-add the shed dependency and drag the stranded chia line. Reported, not shimmed (dig_ecosystem#3161/#3152).

Its principle — derive independence from the routing mechanism, not a parallel label — is the one implemented here, one layer out: across separately-configured endpoints rather than within one router's tiers. The two compose; they do not compete. Note also this path does not run through the chia-query router at all — it is the digstore_chain singleton walk.

#366 — the sole-owner sweep now covers dig-node-core

The heuristic is unchangedsweep, ends_a_column_0_item, the Swept { sites, ended_inside_a_test_item } fail-closed flag and the explicitly-open enumeration are all preserved. What changed is the haystack, plus the two things a wider haystack breaks:

  1. Sites are crate-qualified (dig-wallet/sources.rs:NNN), and OWNER with them. File names repeat across a workspace — both swept crates already contain a chain.rs — so the unqualified "sources.rs" owner would have accepted a second fabric built in a sources.rs in any swept crate. The guard would have widened its scope and lost its property in the same change.
  2. The haystack test asserts a FILE COUNT PER ROOT.dig-node-core holds zeroChiaQuery::new(, so a global needle total stays positive from dig-wallet alone — a typo in the second path would leave the new scope reading nothing while the guard still reported a real haystack. Per-root counting separates "clean" from "never opened".

A deliberately-introduced second fabric in the new root is demonstrated to fail, through the same production_call_sites_in, on a fixture named sources.rs precisely because that is the name the unqualified check would have waved through.

The two known-silent shapes, re-run against the new haystack — one occurs

  • Column-0 #[cfg(test)] as string content: ABSENT from both crates. Every occurrence of that text inside a literal is indented, and indentation is what the latch requires.
  • Terminator carrying trailing content: PRESENT ONCE, at dig-node-core/src/lib.rs:195 (const DEFAULT_CACHE_CAP: ... ; // 1 GiB). The guard is not silent there today, and the reason is stated rather than trusted: the shape only bites when the latch is already SET, and no column-0 #[cfg(test)] precedes it in that file. Verified by replaying the classifier over both crates — no file reaches such a terminator with the latch set, and no file ends inside a test item. That is a property of where the line sits; move it below a column-0 #[cfg(test)] and it goes silent with nothing reporting it.

Blast radius checked

gitnexus impact (per-worktree index, 11,798 nodes) on default_anchored_resolver, resolution_coinset, CoinsetResolver, anchored_root_resolver_arc, unique_suffix — all LOW — paired with grep, because gitnexus under-counted (default_anchored_resolver reported 0 direct callers; grep found the real two, lib.rs:4460 and :4731, both Node construction). resolution_coinset has 6 call sites, all retained.

No public API removed or renamed:CoinsetResolver keeps its unit-struct shape and its single-endpoint behaviour, so its pub use and its five doc references are untouched. The only pre-existing bodies changed are default_anchored_resolver, the CoinsetResolver impl (now delegating, same semantics), the test-only production_call_sites -> production_call_sites_in, and a test tempdir helper. detect_changes is MCP-only in this CLI build, so the diff-level equivalent was done by enumerating changed signatures; the result matches the prediction exactly.

Two defects found in my own work, both by existing guards, both fixed here

  • banned_address_patterns caught me concatenating host and port. Real, not stylistic: this module strips IPv6 brackets when parsing, so re-joining produces ::1:8555 and would fail to resolve every v6 endpoint — on a §5.2 IPv6-first dial path. Now lookup_host((host, port)). §5.2 working as designed.
  • A revert-proof caught a false green of mine. The transitivity fixture originally placed the bridging endpoint in the MIDDLE, so there was only ever one existing group and merge-into-all and merge-into-first agreed on every input — reverting the merge left the test GREEN. Rewritten with the bridge LAST; it now fires.

Verification

Revert-proofs, each run after committing (file-copy revert, never git checkout):

revertfails
unanimity -> majority vote3 corroboration tests
independence by name not reach2 endpoint tests
non-transitive mergethe bridging test (only after the fixture was widened)
OWNER/sites unqualifiedthe new-root demonstration only — every other test stays green
a root that exists but holds no Rustthe per-root file-count assertion, by name

Gates, all run unpiped with counts asserted from the test result: line: cargo fmt --check 0 · cargo clippy -D warnings 0 · dig-node-core lib 1000/1000 · dig-wallet lib 656/656 · banned_address_patterns 3/3. CI green: all five required contexts asserted by name via check-merge-preconditions.sh (Lint commit messages, Check version increment, Rustfmt, Clippy, Test + coverage), 0 unresolved threads. The >=80% coverage gate passes inside Test + coverage.

A pre-existing flake fixed on the way (it made the suite red, and would have made the midnight cron red): the profile_sync test tempdir() was keyed (pid, counter-from-zero) and never cleaned up, so a recycled pid inherits an earlier run populated directory — measured against 223 leaked dirs. Failed in-suite, passed alone, passed on re-run. Now carries a monotonic component. The leak itself is untouched and reported as a follow-up.

Versions / deps

dig-node-service 0.154.0 -> 0.155.0, dig-node-core 0.58.0 -> 0.59.0, dig-wallet 0.35.0 -> 0.36.0 (all MINOR: new capability, no breaking surface).

§2.4b: dig-rpc-protocol 0.10.1 -> 0.10.2 and dig-ipc-protocol 0.2 -> 0.3 bumped (chia-free / same chia-0.26 line, verified from the index). Every other dig-* dep is stranded behind the chia 0.36 upliftdig-tls 0.4.0, dig-identity 0.7.1 and dig-nat 0.21.0 all declare chia ^0.36.1 against this crate ^0.26. Reported, not shimmed.

Coordination — a real overlap the orchestrator should sequence

PR #368 (#360/#363) edits the same sweep doc-comment region of sources.rs that #366 required me to update — its doctest conversion of the silent-shapes paragraph versus my re-measurement of that same paragraph against the new haystack. My other four hunks in that file do not overlap theirs.

Whichever merges second must rebase and keep BOTH: their worked examples as doctests, and the dig-node-core re-measurement (which is the #366 deliverable). It is a doc-comment conflict, small and textually resolvable. I did not touch their PR.


Gate round 2 — CHANGES-REQUIRED addressed: took option (a)

The finding was correct and I reproduced it before fixing it. With T = () the unanimity check
answers.iter().any(|a| *a != candidate) is vacuously false() always equals () — so
verify_pinned_root and verify_lineage_root were a flat 2-of-N threshold, and a reached voice's
rejection landed in refusals and was discarded as unreachability.

Why (a) rather than (b)

The gate said (a) is the better outcome and (b) the acceptable floor. (a) is what shipped, because the
classification already existed at the source and only needed a channel to travel in — so this was
a contained change, not a larger one:

  • verify_lineage_root already separated the cases structurally: a COMPLETED walk whose history
    lacks the root is a rejection; a FAILED walk is unreachable. No extra call.
  • verify_pinned_root delegates to a digstore function that collapses both into one error, so
    reachability is probed (unspent_coins_by_hint, the read that function itself starts with) only
    on the failure path
    . A success costs nothing extra.

New Verdict::{Confirmed, Rejected, Unreachable} and a narrow ChainVoice trait carry it.
AnchoredRootResolver — the node-wide contract consumed across the crate — is unchanged, so this
adds no breaking surface.

The residual ambiguity fails in the refusing direction, deliberately. The probe races the call it
classifies; if the chain drops in between, a genuine unreachability is recorded as a REJECTION, which
refuses. The opposite misclassification is the defect being removed and fails open.

The rule now, for all three calls

  1. Any reached voice that REJECTS vetoes the resolution — decided the moment it is seen, never
    weighed against the confirmations.
  2. Otherwise at least two must CONFIRM; unreachable voices are dropped, and too few is thin
    evidence rather than disagreement (the two demand opposite remedies, and the messages say which).
  3. One voice is the default install and answers alone, as before.

The bar now rises with N because every added source can VETO — not because more must agree.

Half two — closed BY the (a) fix, and now proven rather than argued

content_serve.rs:430, dig_rpc/dispatch.rs:102 and module_reshare.rs:931 all fall back to
verify_pinned_root on a failed tip resolution. That fallback is exactly where a widened Err
would have been laundered into a serve — and it is safe only becauseverify_pinned_root now
carries the same dissent rule. Since that is a composition property rather than a local one, the new
test asserts both legs refuse on the SAME fixture, so a future change that makes the fallback lenient
again fails here. (store_melted.rs:433 also matches that arm and returns MeltStatus::Unknown
already fail-closed.)

The missing fixture, added

Voice::Rejects — a REACHED voice that says no. Its absence is precisely why this shipped untested:
every earlier verification fixture scripted only Unreachable, which both semantics treat
identically. a_reached_voice_that_rejects_refuses_and_is_never_outvoted now pins:

fixturemust
2 confirm + 1 rejectREFUSE (a threshold returns Ok(()) — this is the gate's own probe, inverted)
9 confirm + 1 rejectREFUSE — proves the bar rises with N
9 confirm + 1 unreachableSERVE — the control; silence is not dissent, or one outage takes the node down
tip refuses on dissent → bounded fallbackALSO refuse — the composition above

Revert-proof: restoring the shipped threshold (Verdict::Rejected pushed onto the unreachable
list) makes this test — and only this test — fail. Run after committing, restored by file copy.

SPEC corrected

§14.4b no longer claims a property the code lacks. It states the veto clause explicitly, says a flat
k-of-N threshold does NOT satisfy it and why, and records that the bar rises with N through the
veto rather than through the count.

Non-gating items, folded in rather than filed

  • DIG_NODE_CHAIN_ENDPOINTS added to both env tables.
  • crates/dig-node-core/SPEC.md §4.1 rewritten — it still described CoinsetResolver as production.
  • §14.4b now recommends three or more, and says why two is the most fragile accepted count: with
    two, any single outage drops below the floor and every read refuses.
  • voices() — the doc promised per-group failover the code did not perform. Resolved by stating the
    design
    : a group's members are the same machine, so a second member is a retry against the host
    that just failed, not a fallback to another source.
  • DNS lookups are now concurrent and bounded (3s); a timeout counts as unreachable, the
    fail-closed direction. Sequential un-timed-out lookups on the content-serve request path meant one
    black-holed resolver stalled every read.
  • Store-id disclosure to N third parties documented in SPEC §14.4b and the module doc — the price
    of not trusting one source.
  • dig-ipc-protocol pinned to =0.3.0.

Rebase + coordination

Rebased onto 7ea14fe; the only delta was the CHANGELOG commit, so nothing audited moved. PR#368
still overlaps one sources.rs doc block — prefer #368's executed-fixture (doctest) shape when
resolving
, and keep the dig-node-core re-measurement, which is #366's deliverable.

Verification after the fix

cargo fmt --check 0 · cargo clippy -D warnings 0 · cargo test -p dig-node-core -p dig-wallet --all-targets exit 0 — 1001 lib + every integration target + dig-wallet 656, zero failures. (Note
the earlier IPv6 defect was invisible to --lib; all-targets is now the bar.)

@MichaelTaylor3d

Copy link
Copy Markdown
ContributorAuthor

loop-security gate — STARTED

Auditing head 2b551b93e448335fd85fd2221c46e08c1f5400bc (resolved from gh pr view 369 --json headRefOid), merge-base 4efa57fceb6b242e9eb7e3faaa8a67d50a2fdf62.

Note: origin/main has advanced to 7ea14fe since this branch forked. Diff is read against the merge-base.

Scope this round (gated UP — this decides which bytes a user is served):

  1. independence-by-reach: intersection on resolved addresses, transitive closure correctness (bridge-last revert re-run by me)
  2. unanimity vs majority — no majority path
  3. unreachable = dropped, not dissent; boundary at exactly 1 and exactly 2 answers
  4. SPEC §14.4b honesty vs what actually ships (default install = one third party)
  5. NC-12: the sole-owner sweep walks dig-wallet ONLY — dig-node-core is unguarded #366 sweep widening: crate-qualified sites, per-root file counts, zero-needle root non-vacuity, trailing-comment terminator replay
  6. IPv6 socket-address formatting; no surviving host+port concat
  7. no chia ^0.26/^0.36 bridge

Findings posted here as formed. Verdict posted before my final summary.

@MichaelTaylor3d

Copy link
Copy Markdown
ContributorAuthor

GATING FINDING 1 — the two serve-decision calls are NOT unanimous; they are a flat 2-of-N threshold

Severity: HIGH. Measured against the compiled resolver at 2b551b93, in my own worktree — not reasoned.

crates/dig-node-core/src/seams/chia_peer/corroborated_resolver.rs:116-119

for voice in&voices {matchask(voice.clone()).await{Ok(answer) => answers.push(answer),Err(e) => refusals.push(e),}}

For anchored_state this is right: dissent is a different Ok value, and :137 catches it.

For verify_pinned_root and verify_lineage_root it is not, because T = (). Those two calls have
no value channel at all — the ONLY way a voice can say "no, that root is not current" is Err, and
Err is exactly what this loop discards as unreachability. coinset_resolver.rs:133-143 and
:152-174 confirm the producer side: a mismatch and a lineage miss both come back as Err.

So the rule that actually ships for the two calls the read-path pin makes is: two Oks and no
dissent that could be heard.
Honest voices are silent by construction.

Measured

Probe appended to the PR's own tests module in my worktree (C:\tmp\worktrees\sec369), using the
PR's own resolver() helper and the identical fixture as
a_single_dissenting_source_refuses_rather_than_repairing_the_root — a=0xAA, b=0xAA, c=0xBB:

PROBE verify_pinned_root = Ok(())
PROBE verify_lineage_root = Ok(())
test result: FAILED. 10 passed; 1 failed; 0 ignored; 990 filtered out; finished in 12.30s

The control in the same test asserts anchored_rootrefuses on that fixture — and it does. The
three calls disagree with each other on byte-identical input.

Exploit

State: an operator follows §14.4b's stated remedy and configures DIG_NODE_CHAIN_ENDPOINTS with three
or more independently-hosted endpoints.

  1. Attacker controls, MITMs, or merely benefits from staleness at any two of them.
  2. A read arrives for a superseded generation — dig://<store>:<old_root>. Entry points:
    seams/content/content_serve.rs:432 (HTTP serve), seams/dig_rpc/dispatch.rs:104 (RPC), and
    seams/dig_peer/module_reshare.rs:932that last one is peer-reachable.
  3. The two attacker endpoints answer Ok(()). Every honest endpoint answers
    Err("pinned root … is not the store's current on-chain root …") — an authoritative NO.
  4. agreed() files all of those under refusals and drops them. answers.len() == 2, unanimous.
    Ok(()). The node serves the rolled-back generation.

The number of honest dissenters is irrelevant. 2-of-3, 2-of-10, same outcome — so adding endpoints
does not raise the attacker's cost on these two calls at all, which inverts the property the whole
feature is for. content_serve.rs:534's verify_lineage_root is the gate that closes the forged §13
manifest redirect (#2088); two endpoints re-open it.

No attacker is even required. Three endpoints, two of them a generation behind: two say Ok, the
fresh one says superseded and is discarded, and the node serves stale. Under anchored_state that same
lag refuses. More endpoints makes this more likely, not less.

Why the PR's own tests do not see it

the_verification_calls_need_two_agreeing_voices_too is the only test covering these two calls, and it
scripts Voice::Unreachable — a transport failure. There is no fixture anywhere in which a
reached voice rejects the root on these two calls. The dissent case for the two most
security-relevant calls is untested, which is why it is also unimplemented.

SPEC §14.4b claims the property the code does not have

  • "One dissenting voice is a REFUSAL, never a repaired value." — measured false for these two calls.
  • "The rule covers all three resolution calls — the tip state, the bounded pinned-root verification,
    and lineage membership."
    — measured false; it covers one.

That is precisely the false-satisfaction class dig_ecosystem#2790 exists to end, and it is on the
sentence a reader would rely on to decide the feature is safe to depend on.

Shape of the fix (not prescriptive)

ask needs to distinguish rejected from unreachable — e.g. Result<T, VoiceVerdict> with
Rejected vs Unreachable, with Rejected counted as dissent. EndpointResolver has the
information at both coinset_resolver.rs:140 (verify_pinned_root's typed error) and :163-168
(the lineage miss is already a distinct branch from the walk failure at :172). Routing through
anchored_state instead is NOT available — the bounded pin exists precisely to avoid the walk (#747).

Whatever the code fix, §14.4b must not claim unanimity on a call that cannot express dissent.

@MichaelTaylor3d

Copy link
Copy Markdown
ContributorAuthor

VERIFIED CLEAR — independence-by-reach, the transitivity revert, and the boundaries

All measured in my own worktree (C:\tmp\worktrees\sec369, detached at 2b551b93), unpiped, parsing
the test result: line.

1. The transitivity revert now goes RED — and the OLD fixture confirms the false green

Applied the exact revert the test names (merge into the FIRST matching group only, via a done latch):

thread '…an_endpoint_bridging_two_disjoint_groups_merges_all_of_them' panicked at endpoints.rs:349:9
assertion `left == right` failed: … a rule that merged only into the FIRST match leaves c standing alone …
left: [[0, 2], [1]]
right: [[0, 1, 2]]
test result: FAILED. 4 passed; 1 failed; 996 filtered out

It fires on the firstassert_eq! of that test (endpoints.rs:349), and the left value is the
defect itself: one machine reachable under three names reporting two independent voices, which is
exactly enough to satisfy the 2-voice quorum.

I also re-added the old bridge-in-the-MIDDLE fixture and ran it under the same revert:

PROBE middle-bridge voices = [[0, 1, 2]]
test …probe_old_middle_bridge_fixture ... ok

Green under the revert. The lane's self-reported false green is confirmed as real, and the
bridge-last rewrite genuinely fixes it. Good catch, correctly repaired.

2. The closure is correct, not merely correct on the fixture

The two fixtures pin two orderings. I brute-forced the general property instead: 4000 deterministic
random assignments of 5 endpoints over 4 addresses (every subset shape, every input order), comparing
independent_voices against an independent union-find reference:

PROBE union-find agreement over 4000 random address assignments: OK

The single-pass retain merge is sound because the existing groups are pairwise disjoint by
induction, so no group can intersect merged without intersecting the arriving addrs directly. No
counterexample exists in that space.

3. Boundaries at exactly 0, 1, 2, 3 — measured, not read

configuredstateresult
0 reachableErr("no configured chain endpoint could be reached") — fail-closed
1source answersOk(0xAA) — single-source, the documented default install
1source errorsErr("chain unreachable") — fail-closed
2one unreachableErr("only ONE independent chain source answered…") — refusal
3one unreachable, two agreeOk(0xAA) — DROPPED, not counted as dissent

Row 5 is the one that matters and the one the PR does not test: at N=2 both treatments refuse, so
an_unreachable_source_leaves_too_little_evidence_to_serve_on cannot distinguish drop-from-dissent.
At N=3 it can, and the behaviour is correct — an outage at one endpoint does not become a forced
refusal. No path returns a majority answer for anchored_state.

4. Honesty note (NOT gating) — two endpoints is the most fragile count, and §14.4b recommends it

Row 4 above: with exactly two configured endpoints, an outage or a DoS at either one stops the node
serving any pinned or rooted content. That is an availability regression against today's
single-source behaviour, and §14.4b's closing remedy — "name two independently-hosted endpoints"
points an operator straight at it. The < 2 rule itself is stated in the SPEC, so this is not a
false claim; it is a recommendation that omits its own cost. Suggest §14.4b say three or more, or
state the two-endpoint fragility where the remedy is given.

@MichaelTaylor3d

Copy link
Copy Markdown
ContributorAuthor

#366 sweep widening — VERIFIED CLEAR, with one non-gating fragility

I re-implemented the classifier (ends_a_column_0_item + the latch) independently in Python and
replayed it over both crate trees at 2b551b93, rather than trusting the doc-comment measurement.

dig-wallet: 38 files, 9 needle occurrences, sites=['dig-wallet/sources.rs:202'], unread=[]
dig-node-core: 72 files, 0 needle occurrences, sites=[], unread=[]

Every one of the 9 occurrences classified, and each verified by hand against its latch anchor:

siteverdictlatched by
fallback.rs:636TEST#[cfg(test)] mod chain_failure_tests@614
service.rs:561TEST#[cfg(test)] mod one_pool_tests@551
sources.rs:202PRODUCTION — the owner
sources.rs:311TEST#[cfg(test)] mod independence_tests@290
sources.rs:425/512/515TEST#[cfg(test)] mod sole_owner_tests @411
sources.rs:997TEST#[cfg(test)] mod custody_fails_closed_tests@960

All five latch anchors confirmed as genuine column-0 #[cfg(test)] mod … declarations, not string
content. Exactly one production site, and it is the owner. The guard is measuring something.

The three claimed measurements, re-measured

  • Column-0 #[cfg(test)] as string content — absent, confirmed. The only in-literal occurrences
    are sources.rs:512 and :515, both inside /// doc comments and therefore INDENTED. Indentation
    is what the latch requires, so neither can latch.
  • Trailing-comment terminator replay — confirmed, and my scan was strictly BROADER than the
    lane's.
    I did not look for the one line; I enumerated every column-0 line in both crates that
    contains a } or ; NOT at end of line — 220 in dig-wallet, 390 in dig-node-core, 610 total, which
    includes every doc comment. ZERO of them is reached while the latch is set, in either crate. The
    lane's narrower claim holds, and so does the general one.
  • No file in either crate ends inside a test itemunread=[] for all 110 files. The fail-closed
    refusal is not currently masking anything.

Crate-qualification and the zero-needle root — both correct

OWNER = "dig-wallet/sources.rs" vs sites formatted {label}/{name}:{line}, so a future
dig-node-core/sources.rs is a STRAY rather than an impostor owner. The per-root
assert!(!files.is_empty(), …) separates "this crate is clean" from "this crate was never opened"
the right distinction, since dig-node-core contributes zero needles and a cross-root total would stay
positive from dig-wallet alone. a_second_fabric_in_the_new_root_is_seen_and_judged_a_stray then
drives the real classifier over a synthetic root that does contain the violation, which is what stops
the new half being vacuous. The zero-needle root cannot make the test vacuous.

NON-GATING (defense-in-depth) — the terminator replay is prose, and prose rots

The lib.rs:195 finding is, exactly as the dispatch says, a property of where the line sits. Today it
is harmless because no column-0 #[cfg(test)] precedes it. That fact is recorded as a doc comment
produced by a manual replay
— so if someone later inserts a column-0 #[cfg(test)] above line 195,
the doc comment still says "harmless" and nothing re-measures it.

Assessment: acceptable to ship, worth a follow-up guard. Bounding the risk honestly:

  • dig-node-core has zero needles, so a silent window there drops nothing today.
  • The window is bounded by the next column-0 item end, not EOF.
  • The genuinely unbounded case stays loud via ended_inside_a_test_item.

So this is not a live defect. But the cheap fix is a ~15-line executable assertion — the replay I just
ran, as a test: no column-0 line carrying trailing content after a }/; is ever reached with the
latch set, in any swept file
. That converts a measurement-in-prose into a measurement that re-runs.
Recommend a follow-up ticket, not a gate.

SPEC coverage — two drifts, neither in the dangerous direction

  1. DIG_NODE_CHAIN_ENDPOINTS is missing from BOTH env-var tables. It appears only in §14.4b prose
    at SPEC.md:3862. It is absent from the root SPEC.md env table (the DIG_NODE_COINSET row at
    SPEC.md:218) and from crates/dig-node-core/SPEC.md:1198. That table is where an operator would
    look for the one control that turns this whole feature on.
  2. crates/dig-node-core/SPEC.md:397-400 is now stale — it still says production uses
    CoinsetResolver over a single coinset endpoint. Production is now CorroboratedResolver. This
    under-claims, so it is not the false-satisfaction direction, but §4.2 asks the two SPECs to agree.

Also verified clear

  • Secrets: no key, token, credential, mnemonic or projectId added, logged or printed. The only
    real outbound URL introduced is https://api.coinset.org, the pre-existing default now named as a
    constant; every other host in the diff is an RFC-2606 documentation domain used with a test double.
    DnsReach is never exercised by a unit test, so no fixture performs live DNS.
  • Endpoint provenance:DIG_NODE_CHAIN_ENDPOINTS / DIG_NODE_COINSET come from the process
    environment only. No dotenv or config-file loader exists in dig-node-core or dig-node-service, so
    the endpoint list is not remotely or unprivileged-locally influenceable. Unparseable entries are
    dropped, not defaulted — a typo cannot masquerade as a second voice, and an all-unparseable list
    fails closed (measured: Err("no configured chain endpoint could be reached")).
  • IPv6 (§5.2):DnsReach::addrs hands (host.as_str(), *port) to lookup_host as a tuple. No
    surviving format!("{host}:{port}") in the diff; the test double's error string keeps host and port
    apart too ("host {} port {}"). tests/banned_address_patterns.rs scans the whole crates/ tree, is
    an integration target (so it needs --all-targets, not --lib), and KNOWN_VIOLATIONS is still &[]
    — nothing was waived to make this pass.
  • chia line:dig-node-core declares nochia-query at all; chia-query = "0.6.2" exists only
    in dig-wallet. Nothing bridges ^0.26 and ^0.36. Correctly reported, not shimmed.
  • Dep bumps:dig-ipc-protocol 0.2 → 0.3 is "reject a wrong-length engine nonce before signing
    the attach challenge"
    (feat(dig-node): canonical node-control contract + uncommon default port 9778 #5) — a strengthening of the IPC signing contract, not a weakening, and the
    lock stays on chia-bls 0.26.0. dig-rpc-protocol 0.10.1 → 0.10.2 is a patch. Both from crates.io
    with checksums.
    • Minor hygiene note: the caret dig-ipc-protocol = "0.3" admits 0.3.1, which moved that crate
      to chia-bls 0.36.1. The committed lock pins 0.3.0 so nothing bridges today, but per CLAUDE.md
      §2.4b ("pin, do not range, across a version boundary") an exact =0.3.0 would remove the latent
      second-line risk.
  • profile_sync.rs: test-only temp-dir naming fix; no production path touched.

@MichaelTaylor3d

Copy link
Copy Markdown
ContributorAuthor

GATING FINDING 1a — the DISAGREE refusal is caught by an Err(_) arm written for a broken walk, and retried on the check that cannot hear dissent

Severity: HIGH. Same causal chain as Finding 1; this is what makes it reachable on the real serve path.

Both rooted-read call sites have the identical fallback:

  • crates/dig-node-core/src/seams/content/content_serve.rs:430Ok(None) | Err(_) => … verify_pinned_root(…)
  • crates/dig-node-core/src/seams/dig_rpc/dispatch.rs:102 — the same arm, verbatim

That Err(_) arm predates this PR and its comment says exactly what it was written for: "a walk
aborted by a single unparseable intermediate generation (#747 'parse next store: missing child') MUST
NOT block a valid pinned root."
A transport/parse failure.

This PR widens what Err means at that boundary without rechecking the classification. After
2b551b93, anchored_state also returns Err for "independent chain sources DISAGREE — refusing
rather than picking one"
— which is not a broken walk, it is the single strongest signal the entire
new feature produces. The caller responds to it by retrying on verify_pinned_root, the one call
that (Finding 1) discards authoritative rejections.

The full chain, end to end

Operator configures 3+ endpoints per §14.4b. Attacker controls 2. A rooted read arrives for a
superseded generation, dig://<store>:<OLD>:

  1. anchored_state — attacker voices Ok(Some(OLD)), honest voices Ok(Some(NEW))
    Err("… DISAGREE …"). The corroboration rule fires correctly. This is the moment the feature works.
  2. content_serve.rs:430 classifies that as a broken walk and calls verify_pinned_root(store, OLD).
  3. Attacker voices Ok(()). Honest voices Err("pinned root OLD is not the store's current on-chain root NEW")dropped as unreachability (Finding 1).
  4. answers.len() == 2, unanimous → Ok(()).
  5. The node serves OLD. The disagreement detected in step 1 never reaches a decision.

Two things this adds beyond Finding 1

  • The refusal is not merely bypassed, it is inverted into a retry. A node whose sources actively
    disagree — the attack case — is routed onto the weaker check because they disagreed. A node whose
    sources agree never takes that path at all.
  • The operator-visible diagnostic is wrong in the direction that matters. The error an operator
    eventually sees is "only ONE independent chain source answered" (an outage) rather than
    "sources DISAGREE" (an attack). corroborated_resolver.rs:88-92 is explicit that this distinction
    is the point: "an outage and an attack demand opposite remedies." The caller destroys it.

What is NOT affected — checked, so the bound is honest

  • The ROOTLESS path is fail-closed and correct.content_serve.rs:451 and dispatch.rs:111 route
    through decide_pin, and lib.rs:4213-4218 rejects on Err with ROOT_NOT_ANCHORED. A
    disagreement on a rootless read correctly refuses.
  • The threshold is unchanged at 2 attacker voices — the fallback does not lower it. With 1 attacker
    among N honest, step 3 yields a single Ok and the < 2 rule refuses.

Additional peer-reachable entry point

crates/dig-node-core/src/seams/dig_peer/module_reshare.rs:931 (resolve_chain_anchor) calls
verify_pinned_rootdirectly, with no anchored_state first — so it goes straight into the
weakened check, and it gates spawn_capsule_warm. An attacker with two endpoints can therefore make
the node pull and cache a whole rolled-back capsule off a peer-triggered read, not merely serve one.

Suggested shape

Finding 1's fix (a Rejected vs Unreachable verdict) largely resolves this too, but the Err(_)
arm should additionally not treat a corroboration refusal as a #747 broken walk. It needs to
distinguish "the walk broke" from "the voices disagreed" and fail closed on the latter — the SPEC
already says a disagreement is a refusal, so the caller retrying it is the deviation.

@MichaelTaylor3d

Copy link
Copy Markdown
ContributorAuthor

loop-security VERDICT: CHANGES-REQUIRED

Audited head: 2b551b93e448335fd85fd2221c46e08c1f5400bc — resolved myself via
gh pr view 369 --json headRefOid, re-confirmed as the tip by check-merge-preconditions.sh at the
end of the audit. Merge-base 4efa57fceb6b242e9eb7e3faaa8a67d50a2fdf62.

All probes were run in my OWN worktree (C:\tmp\worktrees\sec369, detached at 2b551b93).
No shared checkout was touched; no mutating git command was run in any lane's tree.

origin/main has advanced to 7ea14fe, a CHANGELOG-only release commit (chore(release): v0.154.0,
1 file, +14 lines). A rebase onto it touches nothing I audited and does not invalidate this verdict —
but it produces a NEW head, so cite that head when merging.

GATING (one finding, two halves — both posted in full above)

Finding 1verify_pinned_root and verify_lineage_root are a flat 2-of-N threshold, not
unanimity. corroborated_resolver.rs:116-119: with T = () there is no value channel, so the only
way a reached voice can say "that root is not current" is Err — which the loop discards as
unreachability. Measured, on the PR's own fixture and helper:

PROBE verify_pinned_root = Ok(())
PROBE verify_lineage_root = Ok(())

while the identical fixture on anchored_rootrefuses. The three calls disagree with each other on
byte-identical input, and the threshold does not rise with N: 2-of-3 and 2-of-10 are the same.

Finding 1acontent_serve.rs:430 and dispatch.rs:102 catch the DISAGREE refusal in an
Ok(None) | Err(_) arm written for a #747 broken lineage walk, and retry it on
verify_pinned_root — the one call that cannot hear dissent. module_reshare.rs:931 calls that check
directly and is peer-reachable.

SPEC.md §14.4b claims the property the code does not have"One dissenting voice is a REFUSAL"
and "The rule covers all three resolution calls". False for two of three. That is the
false-satisfaction class dig_ecosystem#2790 exists to end.

Either clears the gate; the second is the floor:

  1. (preferred) Make ask distinguish Rejected from Unreachable and count Rejected as dissent.
    The information exists at coinset_resolver.rs:140 and :163-168. Also stop the Err(_) arm
    treating a corroboration refusal as a broken walk.
  2. (floor) Rewrite §14.4b to say the two verification calls are a 2-of-N threshold in which an
    authoritative rejection is discarded as unreachability, and that adding endpoints does not raise
    the attacker's cost on them.

Either way, add the missing fixture: a reached voice that rejects.
the_verification_calls_need_two_agreeing_voices_too only scripts Voice::Unreachable, which is why
this shipped untested.

Do not merge on this comment. Re-gate the corroboration leg only (loop-security), not the full
trio. Detail comment follows.

@MichaelTaylor3d

Copy link
Copy Markdown
ContributorAuthor

Verdict detail — verified clear, non-gating items, coordination

(continues the CHANGES-REQUIRED verdict above; same head 2b551b93e448335fd85fd2221c46e08c1f5400bc)

The three properties I was asked to check structurally

1. Independence derived from REACH — CORRECT, and correct in general. The intersection is computed
on resolved address sets (endpoints.rs:151-176), never on type, URL or host name. Rather than trust
the two fixtures, I brute-forced the closure against an independent union-find reference over 4000
random assignments of 5 endpoints across 4 addresses, in every input order:

PROBE union-find agreement over 4000 random address assignments: OK

The single-pass retain merge is sound because the existing groups are pairwise disjoint by induction,
so no group can intersect merged without intersecting the arriving addrs directly. The PR#354
defect — a 2-of-2 quorum satisfied by one endpoint — is genuinely closed.

2. Unanimity, never majority — HOLDS for anchored_state. No majority path exists; :137 requires
every answer to equal the first, and Ok(None) vs Ok(Some) compares as dissent, so one source cannot
conjure a store into being. It fails for the other two calls — that is the gating finding.

3. Unreachable DROPPED not dissent, and the boundary — CORRECT, measured:

configuredstateresult
0 reachablerefuse (fail-closed)
1answerssingle-source answer (documented default install)
1errorsfail-closed
2one unreachablerefuse: "only ONE independent chain source answered"
3one unreachable, two agreeserves — dropped, not dissent

Row 5 is the only configuration that distinguishes drop from dissent, and the PR does not test it — at
N=2 both treatments refuse, so an_unreachable_source_leaves_too_little_evidence_to_serve_on cannot
tell them apart. The behaviour is nonetheless correct.

The lane's self-reported false green — genuinely fixed

I re-ran the non-transitive revert (merge into the FIRST matching group only). The rewritten
bridge-last fixture now fails:

panicked at endpoints.rs:349:9
assertion `left == right` failed: ... a rule that merged only into the FIRST match leaves c standing alone ...
left: [[0, 2], [1]]
right: [[0, 1, 2]]

It fires on the first assertion, and left is the defect itself — one machine under three names
reporting two independent voices, exactly enough to satisfy the quorum. I also restored the OLD
bridge-in-the-middle fixture and ran it under the same revert: green, confirming the lane's
self-report was accurate. Good catch, correctly repaired.

#366 sweep — clear

Independent re-implementation of the classifier, replayed over both trees:

dig-wallet: 38 files, 9 needles, sites=['dig-wallet/sources.rs:202'], unread=[]
dig-node-core: 72 files, 0 needles, sites=[], unread=[]

All 9 occurrences hand-classified against a verified column-0 #[cfg(test)] mod anchor: exactly one
production site, and it is the owner. Crate-qualification correct, per-root file count correct, and the
synthetic-root demonstration stops the zero-needle root going vacuous. My trailing-terminator replay
was broader than the lane's — all 610 column-0 lines in both crates carrying a } or ; not at
end of line — and zero is reached while the latch is set.

Also clear

  • Secrets: none added, logged or printed. https://api.coinset.org is the only real host and is
    pre-existing; every other host in the diff is an RFC-2606 documentation domain used with a test
    double. DnsReach is never exercised by a unit test, so no fixture performs live DNS.
  • Endpoint provenance: env-only. No dotenv or config-file loader anywhere in dig-node-core or
    dig-node-service, so the list is not remotely or unprivileged-locally influenceable. Unparseable
    entries are dropped, never defaulted; an all-unparseable list fails closed.
  • IPv6 / §5.2: fixed properly — lookup_host((host.as_str(), *port)), tuple form. No surviving
    format! host-port concatenation in the diff; even the test double keeps host and port apart.
    tests/banned_address_patterns.rs scans the whole crates/ tree, is an integration target, and
    KNOWN_VIOLATIONS is still empty — nothing was waived to make it pass.
  • chia lines:dig-node-core declares nochia-query at all; chia-query = "0.6.2" lives only
    in dig-wallet. Nothing bridges the 0.26 and 0.36 lines. Correctly reported, not shimmed.
  • Deps:dig-ipc-protocol 0.2 -> 0.3 is a strengthening of the IPC signing contract (rejects a
    wrong-length engine nonce before signing the attach challenge), on chia-bls 0.26.0.
    dig-rpc-protocol 0.10.1 -> 0.10.2 is a patch. Both from crates.io with checksums.
  • profile_sync.rs: test-only temp-dir naming; no production path touched.

Evidence reproduced in my worktree — unpiped, --all-targets, parsing the test result: line

gateresult
cargo fmt --all --checkexit 0, 0 diffs
cargo clippy -p dig-node-core -p dig-wallet --all-targets -- -D warningsexit 0, 0 errors, 0 warnings
dig-node-core libtest result: ok. 1000 passed; 0 failed
dig-wallet libtest result: ok. 656 passed; 0 failed; 1 ignored
all integration targets incl. banned_address_patternsgreen, exit 0
required contexts, asserted BY NAMELint commit messages / Check version increment / Rustfmt / Clippy / Test + coverage — all SUCCESS
unresolved review threads0

Every claimed number reproduces.

NON-GATING — follow-up tickets, do not hold the merge

  1. The terminator replay is prose, and prose rots.lib.rs:195 is harmless only because of where
    it sits; a later column-0 #[cfg(test)] above it would go silent and the doc comment would still
    say "harmless". Bounded (dig-node-core has zero needles; the window ends at the next item), so ship
    it — but make the replay a ~15-line executable assertion. PR#368's executed-fixture direction is
    exactly this remedy.
  2. DIG_NODE_CHAIN_ENDPOINTS is absent from BOTH env-var tables (SPEC.md:218 and
    crates/dig-node-core/SPEC.md:1198). It is the one control that turns the feature on, and it exists
    only in §14.4b prose.
  3. crates/dig-node-core/SPEC.md:397-400 is stale — still says production uses CoinsetResolver
    over a single endpoint. Under-claims, so not the dangerous direction, but §4.2 asks the SPECs to agree.
  4. §14.4b's remedy recommends exactly two endpoints — the most fragile count. At N=2 an outage or
    DoS at either one stops the node serving pinned content, an availability regression against
    today's single-source behaviour. Recommend three or more, or state the cost where the remedy is given.
  5. corroborated_resolver.rs:78 uses group.first() only.independent_voices' doc promises that
    a caller can try each member of a group in turn and that any of its endpoints may speak for it — no
    caller does. Fails closed, but the doc claims a failover that does not exist.
  6. voices() does N DNS lookups sequentially, uncached and un-timed-out, on the request path. The
    no-cache choice is right (a stale independence verdict is worse than none), but one blackholed
    endpoint stalls every content read for the resolver timeout, N times over, each holding a
    spawn_blocking thread. Resolve concurrently and bound it. Not a regression at the default N=1.
  7. Privacy: every read now discloses the store id to N third parties instead of 1. Operator's
    choice, but §14.4b does not say so.
  8. Pin dig-ipc-protocol exactly at 0.3.0. The caret admits 0.3.1, which moved that crate to
    chia-bls 0.36.1. The lock pins 0.3.0 so nothing bridges today; per CLAUDE.md §2.4b an exact pin
    removes the latent second-line risk.

Coordination with PR#368

#368 rewrites the same doc block in sources.rs (~lines 502-560), replacing prose measurements
with executed JSON fixtures, while #369 adds new prose measurements to it. Merging #369 first means
#368 conflicts there and must resolve.

No security consequence:#368 removes a dead constructor and adds no ChiaQuery::new( production
site, so the sole-owner sweep still finds exactly one owner after both land. The only note is that the
conflict resolution should prefer #368's executed-fixture shape for the shared block, since that is
the remedy for non-gating item 1.

@MichaelTaylor3d
MichaelTaylor3dforce-pushed the loop/365-366-nc12-anchored-root branch 2 times, most recently from 69b3f73 to 5ab2438CompareAugust 26, 2026 03:58
@MichaelTaylor3d

Copy link
Copy Markdown
ContributorAuthor

loop-security re-gate (scoped: the dissent-classification leg) — IN PROGRESS

Auditing head 5ab2438de91687a165a58e10a74bdb41290e32f1, base 240732e6be97ed7836d54edb413a3f8e7989c63b.
Scope per the re-gate brief: the Verdict / ChainVoice remedy only (commits 1b24e61, 6025ca6, 4e37070). Earlier rounds cleared the rest.

Interim, so the work survives a stall. Not a verdict.

Check 2 (real production producer per variant) — SATISFIED so far. All three variants are produced from a live chain read, not only from a fixture:

  • Confirmedcrates/dig-node-core/src/seams/chia_peer/coinset_resolver.rs:133 (pinned) and :145 (lineage).
  • Rejectedcoinset_resolver.rs:134 (pinned; gated on a reachability probe) and coinset_resolver.rs:147 (lineage; structural — the walk completed and the history does not contain the root).
  • Unreachablecoinset_resolver.rs:135 and coinset_resolver.rs:153.

Check 1 (vacuity gone) — the fixed site is genuinely fixed.verify_pinned_root / verify_lineage_root on CorroboratedResolver now route through all_confirmed (corroborated_resolver.rs:240-284), which matches on Verdict and returns early on Rejected at :266. The generic agreed<T> unanimity check at corroborated_resolver.rs:216 survives, but its only remaining caller is anchored_state (:303) where T = Option<AnchoredStoreState> carries a real value channel. Still checking for siblings elsewhere in the diff.

Open lines of inquiry (not yet findings):

  1. Whether the veto can be STARVED — all_confirmed drops an Unreachable voice rather than counting it, so a voice that would have rejected but cannot be reached does not veto. Tracing whether an attacker can force that classification.
  2. The doc comment at coinset_resolver.rs:117-121 claims the reachability probe "is arranged to be wrong only in the direction that refuses". The code order is verify-then-probe, which admits the opposite race. Verifying which direction it actually errs in.
  3. Amplification: voices() is deliberately recomputed per resolution (corroborated_resolver.rs:158-165), so one request fans out to N endpoints. Tracing who can invoke the call chain.

Continuing with checks 3-6.

@MichaelTaylor3d

Copy link
Copy Markdown
ContributorAuthor

loop-security re-gate: PASS (with three non-gating findings)

Audited head: 5ab2438de91687a165a58e10a74bdb41290e32f1 (resolved from gh pr view 369 --json headRefOid, re-checked after the probe and unchanged). Base 240732e6be97ed7836d54edb413a3f8e7989c63b.

Scope: the dissent-classification leg only (1b24e61, 6025ca6, 4e37070), per the re-gate brief. Earlier rounds cleared the rest.

The remedy holds. Verdict is a real tri-state with real producers, the veto is decided before any counting, all three fallbacks carry it, and the public trait surface is byte-identical to base. The measured revert confirms the new test is what pins the fix. The findings below are ranked; none is a live vulnerability introduced by this PR, and blocking the merge would leave strictly weaker code on main.


Check-by-check

1. The vacuity is gone, and there is no sibling - CLEAR

Exactly one generic equality survives in the changed files: corroborated_resolver.rs:216, inside agreed<T>. Its only caller is anchored_state (corroborated_resolver.rs:303), where T = Option<AnchoredStoreState> - two Bytes32-shaped fields, so dissent is expressible in the value channel. verify_pinned_root (:321) and verify_lineage_root (:330) now route through all_confirmed, which matches on Verdict and never compares T.

No call site instantiates T = (). No second information-free type is compared anywhere in the touched seam - the only other comparisons are root_hash == root on real 32-byte roots (coinset_resolver.rs:144, :231) and is_disjoint on address sets (endpoints.rs). agreed is a private method, so no out-of-module caller can reintroduce ().

2. Every Verdict variant has a production producer - CLEAR

All three are reachable from a live chain read, not only from a fixture:

VariantProduction producer
Confirmedcoinset_resolver.rs:133 (pinned), coinset_resolver.rs:145 (lineage)
Rejectedcoinset_resolver.rs:134 (pinned), coinset_resolver.rs:147 (lineage)
Unreachablecoinset_resolver.rs:135 (pinned), coinset_resolver.rs:153 (lineage)

The lineage producer is the strong one: Rejected at :147 is reached structurally, from a walk that COMPLETED and whose history does not contain the root. No probe, no error-string inspection.

3. Can the veto be starved? - PARTLY. See Findings 1 and 2.

4. The three Ok(None) | Err(_) fallbacks - CLEAR, no bypass

All three take the fallback on the SAME anchored_root_resolver: Arc<dyn AnchoredRootResolver> field, which production sets to default_anchored_resolver() (lib.rs:4460, lib.rs:4731) = CorroboratedResolver:

  • lib.rs:3084-3095
  • seams/content/content_serve.rs:429-441
  • seams/dig_rpc/dispatch.rs:101-108

CorroboratedResolver OVERRIDES both verification methods, so none of the three can fall through to the permissive default in shared/chain_view.rs. The widened Err does land on the dissent rule in every case. Verified, not accepted.

The test at corroborated_resolver.rs:708-720 pins this composition directly - it asserts the tip resolution refuses on dissent AND that the bounded fallback refuses on the same fixture.

5. AnchoredRootResolver genuinely unchanged - CLEAR

git diff <base>..<head> -- crates/dig-node-core/src/shared/chain_view.rs is zero lines. So is the diff for crates/dig-node-core/src/lib.rs. The trait, both default impls and AnchoredStoreState are byte-identical to base. The only export change is seams/chia_peer/mod.rs adding two privatemod lines; pub use coinset_resolver::CoinsetResolver is untouched and default_anchored_resolver stays pub(crate). No added default, no widened surface.

6. The test is not vacuous, and the revert is measured - CLEAR

The control genuinely distinguishes. The suite contains real SERVING cases, so it is not "every case refuses": corroborated_resolver.rs:761-768 (9 confirm + 1 unreachable yields Ok(())) and :640-650 (both_up yields Ok(()) on both calls). An always-refuse implementation fails those; the flat-threshold implementation fails the dissent cases. The two axes are separated.

The revert I ran, verbatim - in my own throwaway worktree C:\tmp\worktrees\sec369 (detached at the head SHA), never the lane's tree:

--- a/crates/dig-node-core/src/seams/chia_peer/corroborated_resolver.rs+++ b/crates/dig-node-core/src/seams/chia_peer/corroborated_resolver.rs@@ -263,12 +263,7 @@ impl CorroboratedResolver {
Verdict::Confirmed => confirmations += 1,
// Decided the moment it is seen: a rejection is not weighed against the
// confirmations, it ends the resolution.
- Verdict::Rejected(why) => {- return Err(format!(- "{what}: an independent chain source REJECTED this root ({why}) - \- refusing rather than outvoting it (chain is the authority)"- ))- }+ Verdict::Rejected(why) => unreachable.push(why),
Verdict::Unreachable(why) => unreachable.push(why),
}
}

(The removed string literal carries an em-dash in the source; rendered as - here so the comment body stays byte-safe.)

That restores the exact pre-fix semantics: a rejection and an unreachable chain become the same signal and both are discarded, leaving the flat 2-of-N confirmations bar.

Command: cargo test -p dig-node-core --lib corroborated_resolver::tests --no-fail-fast

  • Baseline at head:ok. 6 passed; 0 failed.
  • With the revert:FAILED. 5 passed; 1 failed.

The single failing test:

seams::chia_peer::corroborated_resolver::tests::a_reached_voice_that_rejects_refuses_and_is_never_outvoted
panicked at corroborated_resolver.rs:680:14:
a reached rejection must refuse the pin: ()

It fails at the FIRST dissent assertion (2 confirm + 1 reject must refuse) - the .expect_err("a reached rejection must refuse the pin") at head line :685, shifted to :680 by the five removed lines. The : () payload is the very unit type that carried the original vacuity. The other five tests, including both serving controls, still pass - so the new test is the only thing pinning the fix, and it is anchored to the fix rather than to something incidental.

Worktree restored (git status --porcelain empty) and removed. No shared checkout was touched at any point.


Findings (ranked, none gating)

FINDING 1 - MEDIUM - the rationale at coinset_resolver.rs:117-121 claims a property the code does not have

The comment states:

The probe races the call it classifies, and that race is deliberately biased. ... The opposite misclassification, a rejection read as unreachability, is the defect this type exists to remove, and it fails OPEN. So the classification is arranged to be wrong only in the direction that refuses.

The code (coinset_resolver.rs:131-137) runs the verification FIRST and the probe SECOND:

Ok(()) => Verdict::Confirmed,Err(why)ifself.is_reachable(store_id).await => Verdict::Rejected(why),Err(why) => Verdict::Unreachable(why),

That ordering admits exactly the misclassification the comment excludes: the chain is reachable at the verification (a GENUINE rejection) and unreachable at the probe, so the voice is classified Unreachable, dropped, and does not veto. Nothing arranges against it. The only arrangement that would is defaulting an inconclusive probe to Rejected, and the code defaults it to Unreachable.

Sharpening it: is_reachable (coinset_resolver.rs:98-103) is unspent_coins_by_hint(...).is_ok(), so an endpoint that is UP but answering 429/5xx classifies as unreachable rather than as reached - and the failure path is precisely where the extra probe request is spent, which is what makes a rate limit likely there.

Non-gating: it is a comment, not a control. Reported because it is the sentence a future auditor would rely on to skip this analysis. Recommend correcting it in this PR - one paragraph, zero risk.

FINDING 2 - MEDIUM - the veto is starvable, and SPEC.md 14.4b over-claims on that point

all_confirmed (corroborated_resolver.rs:259-283) drops an Unreachable voice and passes on confirmations >= 2. The veto is therefore only as available as the dissenting voice is reachable. Concrete scenario, built from the SPEC's own operational case:

  1. The operator follows 14.4b ("prefer three or more") and configures 3 endpoints.
  2. Two are a generation behind. 14.4b names one-behind as an ordinary state, so two-behind is ordinary too. A read pinned to the old root R_old gets Confirmed from both, no attacker needed.
  3. The current endpoint returns Err(mismatch), its probe succeeds, so Rejected yields a veto and the read is refused. This is the fix working.
  4. The attacker drives reads until the current endpoint rate-limits the node - every read already fans out to all endpoints, and the failure path adds the extra is_reachable request. That endpoint's verification AND its probe now both return Err, so it is classified Unreachable and dropped.
  5. confirmations = 2, zero rejections, so Ok(()) - the node serves the superseded generation R_old. NC-9 anti-rollback defeated.

The same outcome arises with no attacker at all if the current endpoint is merely down while two others lag.

Why this does not gate: on main today the dissent is discarded unconditionally, so this PR is strictly stronger, and the drop-unreachable rule is a disclosed, deliberately chosen trade (corroborated_resolver.rs:167-173, SPEC 14.4b) whose alternative is an availability collapse. It is also pinned as intended behaviour by the control at :761-768.

What should change: 14.4b currently asserts

The bar rises with N because every added source can veto, not because more must agree.

That holds only for REACHED sources. An attacker who can silence rather than outvote is unaffected by N. Recommend qualifying that one sentence in this PR, and tracking the starvation itself as a follow-up.

FINDING 3 - LOW/MEDIUM - NEW on the default install: an uncached DNS gate in front of every read

At base, default_anchored_resolver() returned Arc::new(CoinsetResolver) - no name resolution of its own (verified at 240732e:crates/dig-node-core/src/seams/chia_peer/coinset_resolver.rs:120-122). At head it returns CorroboratedResolver, whose voices() (corroborated_resolver.rs:158-165) is deliberately recomputed per resolution and calls DnsReach::addrs, which calls tokio::net::lookup_host. This sits ABOVE the chain client, so no client-side caching absorbs it, and content_serve.rs:401-404 calls it on every enforced read with no cache in between.

Two consequences, both landing on the DEFAULT single-endpoint install:

  • Cost asymmetry on a caller-drivable path. One content read now costs one getaddrinfo per endpoint, uncached (glibc without nscd does not cache), and a fallback read pays it TWICE because anchored_state and the fallback verify_pinned_root each call voices(). The entry points are peer/client-drivable: content_serve.rs:429, dig_rpc/dispatch.rs:101, dig_peer/module_reshare.rs:932.
  • A new fail-closed denial. If addrs fails, voices() returns empty and both rules return Err("no configured chain endpoint could be reached"), so every read refuses. That now happens even where the HTTP client would have connected fine - a live pooled connection, or an install behind an HTTPS_PROXY that resolves the origin itself. Before this PR the read had no such gate.

A related inconsistency worth naming: the module bounds the DNS lookup at 3s with an explicit comment that it "runs on the content-serve request path", but the CHAIN queries - far more expensive - are issued sequentially over all voices (corroborated_resolver.rs:261) with no per-voice bound at this layer. One slow configured endpoint stalls every read for its full client timeout, N times over. The author's own rationale for bounding DNS applies with more force here.

Follow-up, not a gate: cache the per-authority resolution for a short TTL, and bound or parallelise the per-voice chain query the way the lookups already are.


What I could NOT fully verify

Stated explicitly, because an unverified check reported as passed is the failure this gate exists to prevent.

  1. Check 3 was verified from code, not executed. I confirmed the classification ordering and that is_reachable is a second network call, but I ran no live probe against api.coinset.org - so the rate-limit step of Finding 2 is derived, not measured. What IS measured is the ordering, and that a probe failure yields Unreachable.
  2. Finding 3's per-read DNS cost is verified as a code path, not benchmarked. I confirmed no cache exists between content_serve and voices(). I did not measure real-world resolver caching on a target host, and OS-level caching may absorb much of it on Windows.
  3. I did not re-audit endpoints.rs URL parsing or the dig-wallet sole-owner sweep in depth - out of the re-gate scope and cleared by earlier rounds. I read both; the dig-wallet/src/sage/sources.rs change is confined to #[cfg(test)] mod sole_owner_tests and contains no production code.
  4. I ran only the corroborated_resolver::tests filter, per the disk constraint in the brief - not the full suite. No StorageFull was encountered (218G free); the rest of the suite is CI's word, and CI is reported green.

Read-only audit. Nothing edited, pushed, merged or undrafted. The revert probe ran in a private detached worktree at C:\tmp\worktrees\sec369, cut from the head SHA, restored and removed afterwards; no shared checkout and no other lane's worktree was touched.

@MichaelTaylor3d

Copy link
Copy Markdown
ContributorAuthor

Gate findings folded in — 99bcf13 (still DRAFT, do not merge)

Findings 1 + 2 (one defect) — CLOSED IN CODE, not documented away

EndpointResolver::verify_pinned_root probed reachability after the verification it was
classifying, so a source that answered and then rejected a root was refiled as Unreachable
whenever its follow-up probe happened to fail. CorroboratedResolver DISCARDS Unreachable and
passes at two confirmations, so that ordering is a mechanism for converting the dissent of a source
an attacker can degrade into silence — two lagging endpoints then carry the serve. The doc
comment already claimed the classification "is arranged to be wrong only in the direction that
refuses"; it was a claim, not a control.

The probe now runs first. Reachability, once established, is not withdrawn by a later failure,
and the residual misclassification runs the refusing way (a chain that drops between the probe and
the verification is recorded as a REJECTION).

The probe is load-bearing and could not be removed.digstore_chain::singleton::verify_pinned_root
collapses every outcome into ChainError::Chain(String) — transport failures included (coinset.rs
maps them all to that one variant) — so there is no typed error to classify from, and only the
verify_lineage_root walk separates the cases structurally. Ordering was the available fix.
Cost: one extra unspent_coins_by_hint on the confirming path, beside the several reads the
verification already performs.

SPEC.md §14.4b's "the bar rises with N" now reads "with the number of REACHED sources",
with the silencing attack and the no-downgrade rule stated normatively. The veto rule itself is
unchanged.

Finding 3 — the new per-read DNS gate, both halves

  • Resolved address sets are cached 60 s (CachedReach), so an ordinary read costs a map lookup,
    not a getaddrinfo. A lookup that fails while a known-good answer is still held (10 min) reuses
    it — a resolver blip is not evidence an endpoint moved, and it must not silently change the voice
    count.
  • A single configured endpoint is a voice with no lookup at all. Independence is a relation
    between endpoints; with one there is nothing for a lookup to decide, and the default install had
    no name resolution on this path before corroboration existed. With two or more, an unresolvable
    endpoint still contributes no voice — that half is pinned by a control test.

Logged, not taken — the chain queries themselves are unbounded

DNS is bounded at 3 s, but all_confirmed/agreed iterate the voices sequentially with no
per-voice bound
(corroborated_resolver.rs:261). One slow endpoint stalls a read for as long as
the HTTP client's own timeout allows, and N endpoints add their latencies. Concurrency plus a
per-voice deadline is the right shape; it is a larger change than a gate fold and is not taken here.

Revert proofs (each run, verbatim)

fix revertedfailing testfailure
probe orderinga_source_that_answered_then_rejected_still_vetoes_when_its_chain_dropsa source that answered and then rejected must refuse the resolution: () — i.e. the resolution returned Ok(()) and served
single-endpoint carve-outone_endpoint_answers_without_a_name_lookup_but_two_still_need_oneleft: Err("anchored state: no configured chain endpoint could be reached (chain is the authority)") / right: Ok(Some(Bytes32(aaaa…)))
cache read patha_cached_reach_looks_up_once_per_ttl_and_survives_a_resolver_blipleft: 2 / right: 1 — the lookup was paid twice

The distinguishing fixture is DegradingChain, which answers a bounded number of hint reads and
then stops. A chain that fails every read is Unreachable under both orderings and a chain that
answers every read is Rejected under both; only "reachable for the first read, gone by the second"
separates them. The veto is asserted through the agreement rule with two honest voices present,
not on the classifier's verdict, because the consequence — not the shape — is the property.

State

  • corroborated_resolver filter: 7 passed; 0 failed (baseline 6 + 1 new). chia_peer: 15/15.
    banned_address_patterns: 3/3. cargo clippy --all-targets: clean.
  • Version 0.156.1 / dig-node-core0.59.1 (branch was already 0.156.0 vs main's 0.155.0).
  • Blast radius: EndpointResolver, DnsReach, CachedReach, voices, independent_voices have
    no callers outside seams/chia_peer/ (measured by ripgrep across crates/; gitnexus was not
    indexed for this worktree, so this is the §2.0 documented fallback). The module's exported surface
    default_anchored_resolver, resolution_coinset, CoinsetResolver — is signature-unchanged.
  • Closes list untouched. Still DRAFT.

MichaelTaylor3dand others added 11 commits August 26, 2026 00:23
…che the reach
Three gate findings on PR#369, none gating, two of them the same hole.
The classification in `EndpointResolver::verify_pinned_root` probed reachability
AFTER the verification it was classifying, so a source that answered and then
rejected a root was refiled as `Unreachable` whenever its follow-up probe
happened to fail. `CorroboratedResolver` DISCARDS `Unreachable` and passes at two
confirmations, so that is a mechanism for converting the dissent of a source an
attacker can degrade into silence — two lagging endpoints then carry the serve.
The doc comment already claimed the classification "is arranged to be wrong only
in the direction that refuses"; it was a claim, not a control.
The probe now runs FIRST. Reachability is a fact that, once established, cannot
be withdrawn, and the residual misclassification runs the refusing way: a chain
that drops between the probe and the verification is recorded as a REJECTION.
`EndpointResolver` holds `Arc<dyn ChainReads>` so the one fixture that separates
the two orderings — a chain reachable for the first read and gone by the second —
can be built at all.
`SPEC.md` §14.4b said "the bar rises with `N` because every added source can
veto". That holds only for REACHED sources, and the difference is the whole
property; it now says so, and states the no-downgrade rule as normative.
Separately, `voices()` gained a per-read `lookup_host` above any chain-client
cache. That is a new `getaddrinfo` on a caller-drivable path (twice on a read
that falls back from the tip to the bounded pinned-root check) and a new
fail-closed denial when only the NAME lookup failed. Resolved address sets are
now cached for 60s and reused for 10 minutes across a resolver blip, and a
SINGLE configured endpoint — the default install — is a voice without any lookup
at all, since independence is a relation between endpoints and there is nothing
for a lookup to decide. With two or more, an unresolvable endpoint still
contributes no voice.
Refs #365, #366.
Co-Authored-By: Claude <noreply@anthropic.com>
PR#371 took dig-wallet to 0.37.1 on main, so the rebase resolved this branch's
own 0.37.0 bump away as superseded -- leaving the crate at exactly main's
version while still carrying 239 changed lines in sage/sources.rs.
That is the rebase-drops-an-identical-bump trap: invisible to status, diff,
fmt, clippy and tests, because nothing local compares against main. Bumped to
0.37.2 with the lock synced in the same commit, since a Cargo.toml bump that
does not reach Cargo.lock fails CI under --locked.
Co-Authored-By: Claude <noreply@anthropic.com>
@MichaelTaylor3d
MichaelTaylor3dforce-pushed the loop/365-366-nc12-anchored-root branch from 99bcf13 to d279c48CompareAugust 26, 2026 07:25
@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review August 26, 2026 07:52
@MichaelTaylor3d
MichaelTaylor3d merged commit 160940e into mainAug 26, 2026
15 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the loop/365-366-nc12-anchored-root branch August 26, 2026 07:52
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant

@MichaelTaylor3d