Skip to content

test(capsule): prove every remote-triggered cache-fill records Relayed provenance - #442

Merged
MichaelTaylor3d merged 7 commits into
mainfrom
loop/436-provenance-relayed
Aug 31, 2026
Merged

test(capsule): prove every remote-triggered cache-fill records Relayed provenance#442
MichaelTaylor3d merged 7 commits into
mainfrom
loop/436-provenance-relayed

Conversation

@MichaelTaylor3d

@MichaelTaylor3d MichaelTaylor3d commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Enumerates every path that writes a cache entry, then closes the class the enumeration exposed. Full enumeration with a verdict per path: #436 (comment)

The defect class

Provenance decides whether this node announces a capsule and stakes the operator's $DIG on it. It is not in the capsule's bytes — they are content-addressed and identical whether this node pulled them for itself or for a stranger — so it lives in a <root>.relay sidecar whose absence means Held, the bondable state.

That sidecar's only writer was persist_holder_claim (module_reshare.rs:345), reached solely from promote_into_cache, the reshare path's staging promotion. Every land route that does not stage inherited the bondable default. A missed write failed OPEN.

Two remote-reachable routes did exactly that:

  • Defect Bcache.pushCapsule via land_capsule_bytes. Fixed here.
  • Defect A — inbound-demand backfill via cache_fetch_and_cache. Not fixed here — see below.

CORRECTION, superseding an earlier claim in this body. It previously said both defects were behind default-OFF flags and that this was "not shipped-exploitable". That was false, and the error was mine. A gating review found an eleventh path -- the read-path land, dispatch.rs:899 -> lib.rs:2432 -> :2402 -> :2469 -> :2530 -- remote-triggerable with no flag at all: a stranger requesting content this node does not hold makes it download the capsule and land it Held, i.e. bondable, in a default install. Retraction and root cause: #436 (comment)

Defects A and B are behind default-OFF flags. The overall posture is not default-safe, because of row 11.

The fix

land_capsule_bytes takes a required HolderClaim and records it before the bytes become visible, so no window exists in which a capsule is discoverable with provenance unwritten, and a marker that cannot be written fails the land rather than landing unmarked. A future land route cannot inherit Held by omission — there is no signature left to call incorrectly. The same reasoning that made CapsuleProvenance an enum with no Default, extended to the filesystem that was quietly supplying one.

cache.pushCapsule derives its claim from request origin; a peer-surface push is Suppress. The authorized-writer signature does not change that: authority answers is this content legitimate, provenance answers should THIS operator stake THEIR money on it. Reasoning recorded at the call site.

A note on the failure direction: the first run of the fix went red — every peer push refused to land, because the marker was written before write_atomic had created the store directory. That is the correct direction, and it is worth recording: the guard refused to land rather than landing unmarked.

Blast radius

impact-equivalent by grep + read (gitnexus not indexed in this worktree, stated rather than implied). land_capsule_bytes has exactly one caller, push_capsule.rs:413. Also touched: persist_holder_claim visibility (fnpub(crate), no behaviour change) and a HolderClaim re-export. No production symbol outside those changed.

One edit outside my assigned directory, both mechanical and in files no sibling lane owns: seams/dig_peer/module_reshare.rs (visibility) and seams/dig_peer/mod.rs (re-export). Flagging rather than assuming it was in scope.

Tests

  • a_local_push_lands_held_and_therefore_bondableGREEN. The control; catches an over-correction marking every land Relayed, which would break legitimate operator bonding.
  • a_peer_originated_push_must_land_relayedwas RED (left: Some(Held)), now GREEN and un-ignored.
  • Full dig-node-core --lib: 1014 passed, 0 failed. Clippy -D warnings clean.

Unproven — stated, not assumed safe

  1. Defect A is not fixed and is established by reading, not execution. Closing it means a required claim on cache_fetch_and_cache, which has 4 call sites outside my directorycontrol.rs:1206, control.rs:1456, dig_rpc/dispatch.rs:764, dig-wallet/src/lib.rs:408 — plus ~6 in lib.rs tests. All appear operator-initiated (Announce), but I did not make that change and no test exercises the inbound-demand path.
  2. I did not audit dig-download's own sink for a route finalizing into <cache>/modules/ outside the ten enumerated. promote_into_cache and write_atomic are the only writers I traced there — by grep and read, not a call-graph tool.
  3. The already-held early return leaves an existing claim untouched, so a re-push cannot promote a relayed capsule to bondable. That is the fail-closed direction, but it means a genuinely-local re-push of a previously-relayed capsule stays Relayed until evicted.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict

Audited head: 6980fdeae4de13713f7dcee9ff6ea1191488a025 (resolved from gh pr view 442 --json headRefOid, matches the dispatch brief).

Tooling: grep + direct read of git objects at the head SHA, no worktree, no checkout, no mutation of the shared submodule (only git fetch origin loop/436-provenance-relayed). gitnexus was not used — the registered dig-node index is ~300 commits behind and returns false-safe zeros (dig_ecosystem#3188).

Confirmed so far:

1. Exactly one production caller — the signature change is total.
git grep land_capsule_bytes at the head returns one definition (capsule_store.rs:402), one call (push_capsule.rs:426), and four doc/test mentions. There is no second land route through this function to forget. Making claim a required positional argument does make omission inexpressible for this function.

2. write_atomic is genuinely tmp-then-rename, so the error-path cleanup is safe.
crates/dig-node-core/src/lib.rs:690 writes to .tmp-<pid>-<nanos>-<seq> in the same directory and fs::renames. On failure it removes the temp and returns Err, leaving no file at the capsule path. This matters because the new failure branch (capsule_store.rs:441-444) calls persist_holder_claim(&path, HolderClaim::Announce), and Announce REMOVES the marker (module_reshare.rs:353). Removing a marker is only safe if no capsule can exist at that path — and it cannot. Verified rather than assumed, because the reverse would have been a live "unmarked capsule = Held = bondable" hole on the failure path.

3. Ordering: no window where a capsule is discoverable with provenance unwritten.
Sequence at capsule_store.rs:434-445 is create_dir_all(parent)persist_holder_claimwrite_atomic. The extra create_dir_all added to fix the red run creates only a directory; the capsule becomes visible at the rename inside write_atomic, which is strictly after the marker write. Directory-earlier is not marker-later. The red-run fix did not reintroduce a window. This matches the ordering promote_into_cache already uses (module_reshare.rs:320, marker before rename) and its stated reason.

4. Failure direction is fail-closed.
persist_holder_claim returns Err(WarmFailure::CacheWriteFailed) on a failed marker write, and capsule_store.rs:436-437 propagates it with ? before any bytes are written — the land fails rather than landing unmarked. The only swallowed error is the let _ = on the cleanup removal after an already-failed write_atomic; if that cleanup fails, the leftover marker is Suppress, i.e. errs toward not-bondable. Correct direction.

Still open, being worked now: the origin vs landing_origin axis at push_capsule.rs:422, the read-side inventory derivation, CapsuleProvenance Default/deserialization routes, and the peer-surface origin threading.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

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

Head still 6980fdeae4de13713f7dcee9ff6ea1191488a025.

5. CapsuleProvenance cannot be defaulted or deserialized — confirmed.
lib.rs:4115 derives exactly Debug, Clone, Copy, PartialEq, Eq, serde::Serialize. No Default, no Deserialize. So there is no route by which an attacker-supplied byte stream becomes a CapsuleProvenance, and no ..Default::default() construction can inherit Held. The lane's report is accurate.

6. The inventory scan really is the only production producer.
Four other sites construct CapsuleProvenance::Held directly — peer.rs:3319, resolve_capsule.rs:206 and :322, dig-wallet/src/lib.rs:1295 — and all four are inside #[cfg(test)], verified individually. The one production producer is capsule_store.rs:66-70, deriving provenance from relay_marker_beside(&path).exists().

7. The money chain is exactly as the brief describes, traced end to end.
land_capsule_bytes(claim)persist_holder_claim<root>.relaycache_list_cached (capsule_store.rs:66) → mirror::lifecycle::observe_disk (lifecycle.rs:729-740) → split_by_provenance (runner.rs:365-377) → Held => held.push(bond) → mirror-coin creates. The marker genuinely is the last barrier before a mainnet spend. relay_marker_beside (capsule_key.rs:81) is extension-independent, and cached_root_stem rejects .relay, so the sidecar is invisible to the scan as a capsule while visible as a property of one.

8. Peer transport threads ReadOrigin::Peer — not inferable, not spoofable.
peer.rs:1380-1384 passes ReadOrigin::Peer, RequestProvenance::FirstParty literally. Over HTTP, read_origin_for (server.rs:915-927) derives the label from the connection's real remote address only, with the IPv4-mapped-loopback case handled and a ConnectInfo extraction failure being an axum rejection rather than a defaulted Local. A remote caller cannot present itself as Local. The input the whole decision rests on is sound.

9. The control test is real and would fail on the over-correction.
a_local_push_lands_held_and_therefore_bondable (push_capsule.rs:1370) asserts Some(Held) through cache_list_cached — the production scan, not the sidecar. local() = (Local, FirstParty), peer() = (Peer, FirstParty) (push_capsule.rs:625-630); both go through the same push_one_shot. A fix that marked every land Relayed fails this test. The peer test is the load-bearing half and fails pre-fix. Non-vacuous as a pair.

10. The out-of-directory edit widens NOTHING.
seams is pub mod (lib.rs:74), dig_peer is pub mod, module_reshare is pub mod (dig_peer/mod.rs:29), and HolderClaim was already pub enum (module_reshare.rs:247). It was therefore already reachable as dig_node_core::seams::dig_peer::module_reshare::HolderClaim before this PR; the new pub use only adds a shorter alias. persist_holder_claim goes private→pub(crate) and is re-exported pub(crate) usecrate-internal, not public API. Exactly as stated, no further.

test_support is #[cfg(test)] pub(crate) mod (lib.rs:4652-4653) — not compiled into the release build, so no test-only land helper is reachable in release.

One defense-in-depth item and the scope question remain; verdict next.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security: PASS

Audited head: 6980fdeae4de13713f7dcee9ff6ea1191488a025 — resolved from gh pr view 442 --json headRefOid at the start AND re-resolved at the end; unchanged throughout.

Nothing is CRITICAL or HIGH in this diff. Under the default posture a remote peer can NO LONGER cause a Held landing by any route I could reach — but with the operator-set DIG_NODE_INBOUND_DEMAND_CACHE=on, it still can, via defect A (dig-node#446), which this PR deliberately does not fix.


The reachability answer, stated precisely

Three routes a stranger could use to put bytes in this node's cache, and what each records now:

Route Marked Remote-reachable?
cache.pushCapsule over the peer surface (DIG_NODE_PUSH_OPEN=true) Relayed — fixed by this PR yes, when the flag is on
cache.fetchAndCache (dispatch.rs:764) unmarked, so Held no — see below
inbound-demand backfill (lib.rs:4536, note_inbound_demand to spawn_capsule_backfill) unmarked, so Held yes, when DIG_NODE_INBOUND_DEMAND_CACHE is on. Default OFF. This is #446.

I closed the one call site the lane flagged as unverified. dispatch.rs:764 is Method::CacheFetchAndCache, and that variant is absent from Method::is_peer_reachable (dig-rpc-protocol 0.10.2, src/method.rs:218-234 — the allowlist is GetContent, GetNetworkInfo, GetPeers, Announce, GetAvailability, ListInventory, FetchRange, GetModuleInfo, FetchModuleRange, GetAnchoredRoot, GetCollection, ListCollectionItems). peer.rs:1334 refuses anything off that list before dispatch, and over HTTP server.rs:1263-1290 additionally requires the master or a paired control token. So that site is not a remote-peer route. It stays a Held-by-omission route for a token-bearing local caller, which is #446's business, not a stranger's.

Verified, in the brief's order

1. The wrong thing is now inexpressible on this path, not merely checked. land_capsule_bytes has exactly one production caller (push_capsule.rs:426) — the whole grep at the head is one definition, one call, four doc/test mentions. claim is a required positional argument, so a new land route cannot compile without naming one. CapsuleProvenance (lib.rs:4115) derives only Debug, Clone, Copy, PartialEq, Eq, serde::Serializeno Default, no Deserialize — so there is no defaulting route and no attacker-supplied deserialization route. test_support is #[cfg(test)] pub(crate) mod (lib.rs:4652), so no test-only land helper exists in a release build. The four direct CapsuleProvenance::Held constructions outside the scan (peer.rs:3319, resolve_capsule.rs:206 and :322, dig-wallet/src/lib.rs:1295) are each inside #[cfg(test)], checked individually — the scan at capsule_store.rs:66-70 really is the sole production producer.

2. No window, and the red-run fix did not create one. The sequence at capsule_store.rs:434-445 is create_dir_all(parent), then persist_holder_claim, then write_atomic. A capsule becomes visible only at the rename inside write_atomic (lib.rs:704), strictly after the marker write. The added create_dir_all produces a directory, and cache_list_cached enumerates files whose name passes cached_root_stem (.dig / .module) — an empty directory yields no inventory entry. Creating the directory earlier moved nothing past the marker. This matches the ordering promote_into_cache already uses (module_reshare.rs:320) and its stated reason.

3. Failure direction is closed. persist_holder_claim returns Err(CacheWriteFailed) and capsule_store.rs:436 propagates it with ? before any bytes exist — the land fails rather than landing unmarked. The only swallowed error is the cleanup after an already-failed write_atomic; if that removal fails, the residue is a Suppress marker, which errs toward not-bondable.

I checked that cleanup specifically, because HolderClaim::Announce removes the marker (module_reshare.rs:353), and removing a marker is only safe if no capsule can exist at that path. write_atomic is genuinely temp-in-same-directory then rename, and on a failed rename it unlinks the temp (lib.rs:704-711) — so on the error path there is no file at the capsule path, and the removal cannot expose an unmarked bondable capsule.

4. The control test is real and the pair is non-vacuous. a_local_push_lands_held_and_therefore_bondable (push_capsule.rs:1370) asserts Some(Held) read through cache_list_cached — the production scan every announce and bonding decision consumes — rather than by stat-ing the sidecar. local() is (Local, FirstParty) and peer() is (Peer, FirstParty) (push_capsule.rs:625-630), both driven through the same push_one_shot. A fix that marked every land Relayed fails it. The peer half fails pre-fix. Asserting on the inventory rather than the sidecar is the right choice: it pins the answer a spend acts on.

5. The origin input cannot be spoofed. read_origin_for (server.rs:915-927) derives the label solely from the connection's real remote address, handles the IPv4-mapped loopback case, and treats a ConnectInfo extraction failure as an axum rejection rather than a defaulted Local. The peer transport passes ReadOrigin::Peer as a literal (peer.rs:1383). A remote caller has no way to present as Local.

6. The out-of-directory edit widens nothing. seams (lib.rs:74), dig_peer and module_reshare (dig_peer/mod.rs:29) are all pub mod, and HolderClaim was already pub enum (module_reshare.rs:247) — so it was already public as dig_node_core::seams::dig_peer::module_reshare::HolderClaim, and the new pub use only adds a shorter alias. persist_holder_claim goes private to pub(crate) and is re-exported pub(crate) use — crate-internal, never public API. Sharing the writer rather than duplicating the marker-write body was the right call.

7. The money chain, traced end to end. land_capsule_bytes(claim) to persist_holder_claim to <root>.relay to cache_list_cached (capsule_store.rs:66) to mirror::lifecycle::observe_disk (lifecycle.rs:729-740) to split_by_provenance (runner.rs:365-377) to Held => held.push(bond) to mirror-coin creates. relay_marker_beside (capsule_key.rs:81) is extension-independent and cached_root_stem rejects .relay, so the sidecar is invisible to the scan as a capsule and visible as a property of one. The marker really is the last barrier before a mainnet spend, and this PR puts a real one on the peer-push path.

Concurrency note (benign, checked rather than assumed). promote_into_cache writes into the same cache path without taking cache_lock, so it can interleave with a push-land. Every interleaving I worked through is safe because only a Local-origin action ever writes Announce (the marker-removing claim): a stranger cannot induce an Announce, so no interleaving converts a remote-caused land into Held. Worth knowing, not worth a ticket.


One defense-in-depth item — NOT gating

push_capsule.rs:422 decides the claim from raw origin, while push_capsule.rs:296 decides authority from landing_origin(origin, provenance).

landing_origin (download.rs:412) describes itself as the ONE place the two axes meet, so the two surfaces can never drift — and this PR adds a third consumer of the axis that does not use it. A cross-site loopback request (origin == Local, provenance == CrossSite) would therefore be required to carry a §21.9 signature and would still be claimed Announce, i.e. bondable.

It is not exploitable at this head, which is why it is not a gating finding: server.rs:1263-1290 gates cache.pushCapsule over HTTP behind the master or a paired control token, and a cross-site page cannot read that token. A caller holding the token is already fully privileged, so the divergence buys an attacker nothing today. But it is one token-gate change or one new transport away from mattering, and the fix is a one-line landing_origin(origin, provenance) in place of origin at line 422 — cheaper now than as a future audit finding. Follow-up ticket if you want one; do not hold the merge.

Not weighed against this PR

Defect A (#446) and the already-held early return are the deliberate scope decisions stated in the brief, and I agree with both — in particular the early return errs toward forgoing a bond rather than staking one on a stranger's content, which is the correct direction.

dig-download's own sink remains un-audited by me as well. I traced by grep and direct read of git objects at the head SHA and used no call-graph tool (the registered gitnexus dig-node index is ~300 commits behind and returns false-safe zeros — dig_ecosystem#3188). A route finalizing into <cache>/modules/ outside the ones enumerated here is unproven, not proven safe.

No shared checkout was mutated: everything was read via git show <sha>:<path>, with a single git fetch origin loop/436-provenance-relayed. No worktree cut, no file edited, nothing merged, draft state untouched.

FYI for the merge gate rather than a security finding: Test + coverage and Analyze (rust) were still pending when this verdict was written, and the tests are this PR's whole deliverable.

Provenance decides whether this node announces a capsule and stakes the
operator's $DIG on it. It is not carried in the capsule's bytes -- those are
content-addressed and identical whether the node pulled them for itself or for
a stranger -- so it lives in a `<root>.relay` sidecar whose ABSENCE means
`Held`, the bondable state. Until now that sidecar's only writer was
`persist_holder_claim`, reached solely from the reshare path's staging
promotion, so every land route that did not stage inherited the bondable
default. A missed write failed OPEN.

`land_capsule_bytes` now takes a required `HolderClaim` and records it BEFORE
the bytes become visible, so there is no window in which a capsule is
discoverable with its provenance unwritten, and a marker that cannot be written
fails the land rather than landing unmarked. A future land route cannot inherit
`Held` by omission -- there is no signature left to call incorrectly. This is
the reasoning that already made `CapsuleProvenance` an enum with no `Default`,
extended to the filesystem that was quietly supplying one.

`cache.pushCapsule` derives its claim from the request origin: a push over the
opened peer surface is `Suppress`. The authorized-writer signature does not
change that -- authority answers "is this content legitimate", provenance
answers "should THIS operator stake THEIR money on it", and a third party who
owns the store's key is not thereby entitled to spend the node operator's $DIG.

Tests, both over the real push->land path, varying only ReadOrigin:
- a_local_push_lands_held_and_therefore_bondable -- the control, which catches
  an over-correction marking every land Relayed and breaking operator bonding.
- a_peer_originated_push_must_land_relayed -- was RED (left: Some(Held)), now
  green.

Closes #436
@MichaelTaylor3d
MichaelTaylor3d force-pushed the loop/436-provenance-relayed branch from e857d31 to e0371cd Compare August 31, 2026 07:21
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

Rebase Complete ✓

Item Result
New head SHA e0371cd
Version (on-disk) 0.188.0
Rebase dropping No
Conflicts resolved Cargo.toml, Cargo.lock
cargo check -p dig-node-core ✓ Passed (exit 0, 1m22s)

Branch rebased onto origin/main (bcb9645). Version set to 0.188.0 as decided. No dropping detected in rebase output. Cargo.lock regenerated, build verified clean.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

IN PROGRESS — not the verdict. Independent correctness gate, head e0371cd03caa16ae6a1960bbb5975b2079760eb0 (re-resolved from the remote).

Confirmed so far, with evidence:

Q2 — the write_atomic error-cleanup path is SAFE, contrary to my prior. HolderClaim::Announce does mean the bondable state — module_reshare.rs:355-360 REMOVES the <root>.relay sidecar, and capsule_store.rs:68-71 derives Relayed from marker presence, Held (bondable) otherwise. So the cleanup does write the bondable claim. But it is not reachable as a bondable state: list_cached_capsules enumerates capsule files, deriving provenance per file, so a marker with no capsule beside it contributes nothing to the inventory. On a write_atomic failure no capsule exists at the path (lib.rs:704-711 writes a temp and unlinks it on a failed rename), so the post-failure on-disk state is "no capsule, no marker" — absent from the inventory, nothing to bond. Landing state: non-bondable. Not a finding.

Q1 — the other failure directions, each stated:

  • marker unwritable (persist_holder_claim -> Err) -> ? returns before write_atomic -> no capsule on disk -> non-bondable. Correct direction.
  • create_dir_all failure -> returns before both -> no capsule -> non-bondable.
  • marker-removal failure inside the cleanup closure -> let _ = swallows it, marker survives -> if a capsule ever appears there it reads Relayed -> non-bondable. Correct direction.
  • re-push early return (metadata(&path).is_ok()) -> no write at all, existing marker untouched -> a previously-Relayed capsule stays Relayed. Non-bondable, and it cannot be driven the other way through this function: the only write of Announce is the error closure, which is unreachable past the early return.

**Q5 — land_capsule_bytes is pub(crate) at capsule_store.rs:389 and stays so; HolderClaim moves from pub(crate) use to pub use (dig_peer/mod.rs:63), which is an ADDITIVE public re-export of an already-pub type. Minor (0.188.0) is right.

Still open: the third cache-write path (lib.rs:2530, sync_module_from) and the revert-proofs for the two new tests. Continuing.

Comment thread crates/dig-node-core/src/seams/capsule/push_capsule.rs Outdated
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security re-gate — IN PROGRESS, NOT THE VERDICT

Head audited: e0371cd03caa16ae6a1960bbb5975b2079760eb0 (re-resolved from the remote at the start of this round; will re-resolve at the end).

Posting findings as they are established so they survive an interruption. This is not the verdict.

Note on tooling: the gitnexus and socraticode MCP servers both failed to connect this session (CONNECT_TIMEOUT), so blast radius here is grep + direct read of the PR head's git objects. No shared checkout was mutated — everything below is read via git show <sha>:<path> / git diff.


CLEARED — Q3, attacker-controlled path (create_dir_all on a new line in this diff)

capsule_store.rs:434-436 adds create_dir_all(path.parent()) before any byte is validated. It is safe by construction, not by a check:

  • path comes from key.module_path(&self.cache_dir), and key: &CapsuleKey.
  • CapsuleKey has private fields and exactly one constructor, CapsuleKey::parse (capsule_key.rs:186), which admits nothing but two canonical 64-hex ids (is_canonical_hex_id, capsule_key.rs:157).
  • 64 chars of ASCII hex contain no /, \, ., :, NUL or control byte, so module_path's join can only ever produce <cache>/modules/<64hex>/<64hex>.dig — a direct grandchild of the cache dir.

So the new create_dir_all can only create <cache>/modules/<64hex>. No traversal, no absolute-path escape, no UNC. The parent is a directory the process already owns; symlink-swap of a 64-hex-named dir requires prior local write access to the cache, which is outside this threat model.

Verdict on Q3: clear.

CLEARED — Q2, HolderClaim::Announce on the write_atomic failure path

capsule_store.rs:438-446 writes HolderClaim::Announce back when write_atomic fails. Announce removes the sidecar (module_reshare.rs:353-357), and marker-absent reads as Held (capsule_store.rs:68-71), i.e. bondable. So the question is whether a capsule can be present at path at that moment.

It cannot, on this route's own terms:

  • the function early-returns if metadata(&path) is Ok (capsule_store.rs:412-424), so path was absent on entry;
  • write_atomic (lib.rs:690-713) is create_dir_all → write tmp → rename(tmp, path), and every Err arm leaves path untouched (the failed-rename arm also unlinks the tmp).

So after a write_atomic Err, path does not exist, and removing the marker leaves neither artifact. Not bondable. Verdict on Q2 in isolation: clear — but see the concurrency case below, which is where this arm stops being safe.


FINDING UNDER VERIFICATION — the diff's safety argument does not hold: land_capsule_bytes is not the only door

I have confirmed four distinct writers into the inventory-scanned cache path, and only two of them record provenance. Still verifying reachability on one; posting the enumeration now.

The inventory scan is list_cached_capsules (capsule_store.rs:29-82): it globs <cache>/modules/<store>/<root>.dig (and legacy .module) and sets provenance = Relayed iff the <root>.relay sidecar exists, else Held.

# writer holds cache_lock? writes a marker?
A land_capsule_bytes (capsule_store.rs:402) — the push path yes (caller) yes — this PR
B sync_module_from (lib.rs:2531) via cache_fetch_and_cache (capsule_store.rs:272) yes NO
C sync_module_from via sync_module_and_bounddispatch.rs:899 (read path) no (lib.rs:2425 says so explicitly) NO
D promote_into_cache (module_reshare.rs:287-333) — reshare warm no (cache_lock appears nowhere in module_reshare.rs) yes

Two consequences, both being verified before I call them:

  1. The doc's lock claim is narrower than stated. capsule_store.rs:387 says "The caller MUST hold cache_lock so a concurrent pull-land of the same capsule cannot race the write." That serializes A against B only. C and D write the same path without ever taking cache_lock, so the write A is protecting against is not actually excluded.
  2. sync_module_from lands with no marker at all, so every capsule that arrives through B or C reads as Held and is bondable regardless of who asked for it.

Reachability of C from a remote peer is what I am verifying next: dig.getContent is on the peer allowlist (peer.rs, peer_allowlist_is_byte_identical_to_the_pre_adoption_set), and I have not yet found an origin gate on the sync_module_and_bound call at dispatch.rs:899 — the land_origin fold appears only at dispatch.rs:942, after it. If that holds, it is a live money finding of exactly this PR's class, independent of the push path.

To be explicit about attribution: B/C/D are pre-existing, not introduced by this diff. The PR is a strict improvement on the push route. What is at issue is whether its stated invariant — and the PR title, "prove every remote-triggered cache-fill records Relayed provenance" — is true as written.

Owner for anything that comes out of this: the loop. Nothing here is routed to Copilot.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

CHANGES-REQUIRED

SHA audited: e0371cd03caa16ae6a1960bbb5975b2079760eb0 — re-resolved from the remote at the start of this gate and again at the end; the head did not move during the audit. The prior loop-security PASS was posted against 6980fdea, which predates the entire capsule-provenance change, so this PR was treated as ungated.

The fix itself is well built and its two tests are genuinely non-vacuous. The blocker is not the fix; it is that the enumeration this PR closes dig-node#436 on is incomplete in the one place that matters, and two safety claims on the PR are false as a result.

1. GATING — an eleventh cache-write path, remote-triggerable, with NO flag gate

Full evidence in the inline thread. Summary: dispatch.rs:899 inside the Some(Method::GetContent) arm calls sync_module_and_bound -> sync_module -> sync_module_from -> write_atomic (lib.rs:2530-2531) with no HolderClaim and no marker, so the landed capsule reads Held (bondable) per capsule_store.rs:68-71.

  • It is not row 5: that route runs through cache_fetch_and_cache and is gated by DIG_NODE_INBOUND_DEMAND_CACHE. This one reaches the same choke point directly, so that flag does not gate it.
  • It is not row 4: maybe_backfill_capsule's origin gate at capsule_store.rs:352 is a different route.
  • Zero origin references exist anywhere in the GetContent arm (dispatch.rs:788-900), while the same function gates on origin at :356, :381 and :500 — so the omission is specific, not a house style.
  • The crate says so itself: lib.rs:2414 and the test doc at lib.rs:6104 both describe this exact path as a "remotely-triggered read-path backfill". The size-cap work already treated it as remote-triggered; the provenance work did not enumerate it.
  • No env flag gates it, and path 1 of sync_module_from "needs no identity" (lib.rs:2447-2450).

Consequently these two statements are false as written and must not be merged standing:

  • PR body: "Both are behind default-OFF flags ... This is not shipped-exploitable."
  • Enumeration: "Neither is reachable in a default configuration."

Owner: the loop. Acceptable remedies: fix the read path in this PR (thread a required HolderClaim through sync_module_from, passing Suppress when the GetContent request's origin is not ReadOrigin::Local), or amend the enumeration to eleven rows, correct the PR body, file the read-path defect as a named child of the epic, and do not let #436 close until it is closed. The fix must not be a blanket Suppress at sync_module_from — the operator's own reads and cache.fetchAndCache funnel through the same choke point, and suppressing them is the exact over-correction proven catchable below.

2. Failure-path bondability — every path checked, all correct

path landing state bondable?
create_dir_all fails returns before any write; no capsule NO — correct
persist_holder_claim fails ? returns before write_atomic; no capsule NO — correct
write_atomic fails, cleanup writes Announce Announce DOES mean bondable (module_reshare.rs:355-360 removes the sidecar), but write_atomic leaves no capsule at the path (lib.rs:704-711 unlinks the temp on a failed rename), and list_cached_capsules enumerates capsule files — a marker with no capsule beside it is absent from the inventory NO — correct, though it reads alarming
cleanup's own remove_file fails swallowed by let _ =; marker survives; any later capsule there reads Relayed NO — correct
re-push early return no write, existing marker untouched; a previously-Relayed capsule stays Relayed. Cannot be driven Relayed -> Held through this function: the only Announce write is the error closure, unreachable past the early return NO — correct

The one I most doubted going in — the Announce cleanup — is safe, but only because of a non-local invariant (inventory keyed on capsule presence). Worth a sentence at that call site; non-gating.

3. Test vacuity — REVERT-PROVEN, both directions

Run in my own worktree at e0371cd (C:\tmp\worktrees\gate442, since removed; the shared checkout was never mutated). Test counts checked, not just exit status.

Baseline: running 2 tests ... 2 passed; 0 failed; 1012 filtered out.

Mutation A — guard removed (push_capsule.rs:424 Suppress -> Announce, i.e. every land bondable):

a_local_push_lands_held_and_therefore_bondable ... ok
a_peer_originated_push_must_land_relayed ... FAILED
test result: FAILED. 1 passed; 1 failed

Mutation B — over-correction (:423 Announce -> Suppress, i.e. every land relayed):

a_local_push_lands_held_and_therefore_bondable ... FAILED
a_peer_originated_push_must_land_relayed ... ok
test result: FAILED. 1 passed; 1 failed

Each test fails on exactly the mutation it claims to catch and survives the other. Neither is vacuous, and the pair discriminates in both directions — this is the both-inequalities property done right, and the control is what makes it so. Reading the answer through cache_list_cached rather than stat-ing the sidecar is also the right call: it pins the answer a spend acts on rather than the mechanism.

4. Public surface / version — correct

land_capsule_bytes remains pub(crate) (capsule_store.rs:389). The only visibility widening is HolderClaim, moved from pub(crate) use to pub use (dig_peer/mod.rs:63) — an additive re-export of an already-pub type — plus persist_holder_claim fn -> pub(crate) fn. Additive only. 0.188.0 minor is right.

5. dig-constants

Neither question is triggered. Nothing here is a shared/cross-repo constant: RELAY_MARKER_BODY and the .relay extension are node-local filesystem details with no second implementation, and no hardcoded literal in the diff duplicates a dig-constants value.


Non-gating notes (resolve without blocking): the Announce-on-cleanup safety depends on the inventory being keyed on capsule presence — worth stating at capsule_store.rs's error closure, because the code as written reads like a fail-open. The deliberate Relayed -> Relayed stickiness of the re-push early return is already documented at the right level of honesty; no change wanted.

I did not re-run the full 1014-test suite or clippy; the PR reports both green and CI covers them.

@MichaelTaylor3d
MichaelTaylor3d marked this pull request as draft August 31, 2026 08:01
MichaelTaylor3d and others added 3 commits August 31, 2026 01:02
… point

`dig.getContent` for a capsule this node does not hold funnels through
`sync_module_and_bound` -> `sync_module` -> `sync_module_from` -> `write_atomic`,
reaching the cache WITHOUT passing `cache_fetch_and_cache` or `land_capsule_bytes`.
It carried no `HolderClaim`, so the capsule landed `Held` -- the bondable state a
mirror coin is minted against.

It is remote-triggerable and behind no feature flag, so a stranger requesting
content this node does not hold made it fetch a whole capsule and land it
bondable in a default configuration. `lib.rs` already described it as "a
remotely-triggered read-path backfill" in unrelated size-cap work.

`sync_module_from` now takes a required `HolderClaim`, written before
`write_atomic` publishes the bytes, so no window exists where a capsule is
discoverable with its provenance unwritten.

The claim is threaded PER CALL SITE rather than defaulted at the choke point:
`dig.getContent` derives it from the read's `origin` (`Local` -> `Announce`,
otherwise `Suppress`), while `cache.fetchAndCache` -- operator-initiated and not
peer-reachable -- keeps `Announce`. Suppressing every land through this shared
choke point would have disabled the reshare flywheel for the operator's own
content.

Refs #436

Co-Authored-By: Claude <noreply@anthropic.com>
The first regression tests called `sync_module_from` directly with each
`HolderClaim` and asserted the `.relay` sidecar's presence and absence. They
passed, and they were not enough: they prove the PLUMBING carries a claim, and
would have passed just as happily with `dispatch.rs` mapping every origin to
`Announce`. The defect would have survived its own regression test.

The origin -> claim decision is now a named `holder_claim_for_read`, asserted
directly in both directions: a peer read backfills `Suppress`, a local read
backfills `Announce`. An end-to-end assertion on a landed marker passes for many
reasons, only one of which is this mapping being right.

Extracting it also reads better than a match buried mid-arm -- the rule now has
a name, a doc comment stating why the asymmetry is deliberate, and a home where
a reader looking for "what decides bondability" will find it.

Refs #436

Co-Authored-By: Claude <noreply@anthropic.com>
Pure reflow of lines my own edits made over-width. No behaviour change.

Refs #436

Co-Authored-By: Claude <noreply@anthropic.com>
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — IN PROGRESS, not the verdict

Head re-anchored: 8d4a0e7abbb947cbff5cd72516341bf2fc4dcfae (was briefed at f78d0f9; head moved mid-audit).

I checked the fmt-only claim rather than taking it:

  • dispatch.rs — byte-identical modulo whitespace (tr -d '[:space:]' sha256 match).
  • lib.rs — whitespace-strip hashes DIFFER, because rustfmt also normalised trailing commas. Stripping whitespace and , gives an exact match (4b27b7746b577a4b both sides).
  • All 8 hunks lie between lines 5602-8404, i.e. entirely inside mod tests.

Conclusion: the head move is semantically inert. Findings derived at f78d0f9 carry to 8d4a0e7; production-code line numbers below 5602 are unchanged, and all citations below use 8d4a0e7 numbering.

Audit method: sink-first. I am deriving the caller set by starting at every filesystem write into the capsule cache directory and walking up, deliberately without reading the PR body's enumeration first. Findings will be posted as they resolve.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — FINDING 1, HIGH. IN PROGRESS, not the verdict

Audited at 8d4a0e7abbb947cbff5cd72516341bf2fc4dcfae.

The claim is derived from the UNFOLDED origin, so the cross-site axis is ignored

crates/dig-node-core/src/seams/dig_rpc/dispatch.rs:906

let claim = holder_claim_for_read(origin);

origin here is the RAW dispatch parameter. Every other landing decision in this same file folds both axes first:

  • dispatch.rs:381let land_origin = crate::download::landing_origin(origin, provenance); (the dig.fetchRange arm)
  • dispatch.rs:953 — the identical line, 47 lines below the new call, in the same dig.getContent arm
  • content_serve.rs:366 — the /s/ serve path does the same
  • push_capsule.rs:272let require_auth = landing_origin(origin, provenance) == ReadOrigin::Peer;

And the trait's own doc, dispatch.rs:45, states the contract the new line breaks: "...via landing_origin, exactly as the /s/ serve path does."

landing_origin (download.rs:412) exists for precisely this: RequestProvenance::CrossSite => ReadOrigin::Peer, documented at download.rs:371 as "the peer address is loopback is NOT the operator authorized this", closing a CSRF door on the landing side effect.

Concrete exploit — reachable, no feature flag, default install

State: operator browses any store through the node's own local surface. The page is attacker-supplied content.

  1. A page served at http://dig.local/s/<attacker-store>/index.html (or any chrome-extension://<id>) issues:
fetch('http://localhost:<port>/', {method:'POST',
  headers:{'Content-Type':'application/json'},
  body: JSON.stringify({jsonrpc:"2.0",id:1,method:"dig.getContent",
    params:{store_id:"<ATTACKER_STORE>", root:"<ATTACKER_ROOT>", retrieval_key:"<rk>"}})})
  1. CORS approves it. reflects_origin (server.rs:354) returns true whenever is_local_origin does, and is_local_origin (server.rs:433) accepts any chrome-extension:// and any http:// page on localhost / dig.local / 127.0.0.1 / 127.0.0.2. allow_methods(AllowMethods::mirror_request()) mirrors POST, so the preflight passes for POST /.
  2. host_guard (server.rs:455) passes — the Host header is localhost:<port>.
  3. read_origin_for (server.rs:916) sees a loopback TCP peer -> ReadOrigin::Local.
  4. provenance_for (server.rs:987) reads Sec-Fetch-Site: cross-site -> RequestProvenance::CrossSite. (dig.local vs localhost, and chrome-extension:// vs http://, are cross-site by definition.)
  5. holder_claim_for_read(Local) -> HolderClaim::Announce.
  6. sync_module_and_bound -> sync_module -> sync_module_from -> persist_holder_claim(&path, Announce) (lib.rs:2557) -> write_atomic (lib.rs:2559).

Result: the capsule lands with no .relay marker, so cache_list_cached (capsule_store.rs:68) reports CapsuleProvenance::Held — the bondable state a mirror coin is minted against — for a capsule an attacker chose.

Forty-seven lines later the same request computes land_origin == Peer (dispatch.rs:953) and correctly refuses the other landing legs. The code has already decided this request must not create a durable holder side effect, and the new line does it anyway.

Why this is a gate finding and not a nit

This is the same defect class the PR was opened to fix — a land route that reaches the cache and reads as Held — surviving on the browser axis. The eleventh path was found because the search was organised around two functions; this one survives because the fix was organised around one of the two axes.

The fix is one argument

let claim = holder_claim_for_read(crate::download::landing_origin(origin, provenance));

holder_claim_for_read keeps its signature and its two unit tests stay valid. A third test asserting (Local, CrossSite) -> Suppress is what would have caught this.

Continuing: I am still working the de-suppression escalation (an Announce land REMOVES an existing marker) and the remaining derived call chains.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — FINDING 2, MEDIUM (defense-in-depth). IN PROGRESS, not the verdict

At 8d4a0e7. The same raw-origin shape as Finding 1, in the push path — currently NOT exploitable, but the code contradicts its own doc.

crates/dig-node-core/src/seams/capsule/push_capsule.rs:422

let claim = match origin {
    ReadOrigin::Local => crate::seams::dig_peer::HolderClaim::Announce,
    _ => crate::seams::dig_peer::HolderClaim::Suppress,
};

Raw origin again. 150 lines earlier, in the same function, the authority axis folds correctly:

push_capsule.rs:272let require_auth = landing_origin(origin, provenance) == ReadOrigin::Peer;

And the function's own doc-comment, push_capsule.rs:246, states the property the new code does not hold:

"A cross-site loopback request folds to Peer too (landing_origin), so a malicious web page can never drive an unauthenticated seed-push through the loopback shell."

So within one function, (Local, CrossSite) is Peer for authority and Local for bondability.

Why MEDIUM and not HIGH — I checked the reachability rather than assuming it

cache.pushCapsule is genuinely gated on every route that could deliver (Local, CrossSite):

  • HTTPserver.rs:1262-1264 requires the local control token or a paired token for cache.pushCapsule / cache.fetchAndCache / cache.listCached, explicitly because they "make this node a durable holder".
  • Peer wirepeer.rs:1225 admits it only under DIG_NODE_PUSH_OPEN=true, and then origin == Peer so the claim is Suppress correctly.
  • FFI — in-process, genuinely Local.

So a malicious page cannot reach it today. This is defense-in-depth, and I am not gating on it by itself — but it should be fixed in the same commit as Finding 1, because it is literally the same expression and the two will not stay in sync otherwise.

Fix

let claim = match landing_origin(origin, provenance) {

landing_origin is already imported in this file (push_capsule.rs:48).


Cleared while checking this — cache_fetch_and_cache's hardcoded Announce is CORRECT

capsule_store.rs:285 hardcodes HolderClaim::Announce, justified in-comment as "operator-initiated -- a control-plane method, not peer reachable". I tested that claim on both transports and it holds:

  • Peer wire: peer.rs:1199 is_peer_reachable_method runs BEFORE any dispatch and returns -32601 for cache.*; Method::from_name("cache.fetchAndCache").is_peer_reachable() is the deciding predicate.
  • HTTP: token-gated at server.rs:1262, with a direct constant-time compare rather than control::is_authorized (which fails open for reads).

No finding. Worth recording explicitly, because it is the strongest argument FOR Finding 1: the HTTP shell gates cache.fetchAndCache behind a token precisely because it lets a caller choose a capsule this node becomes a durable holder of — while dig.getContent is ungated, and through the 1b sync at dispatch.rs:908 it produces the same durable holder side effect. That asymmetry is exactly what landing_origin exists to absorb, and dispatch.rs:906 does not call it.

Still working: the promote_into_cache caller chain, the ordering/race questions, and the failure direction on every derived path.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security — FINDINGS 3-5 + answers to Q3/Q4/Q5. IN PROGRESS, not the verdict

At 8d4a0e7.


FINDING 3, MEDIUM alone / escalates Finding 1 to HIGH — an Announce re-land over an existing Relayed capsule has no rollback and opens a real window

sync_module_from (lib.rs:2549-2559) claims:

"Written BEFORE the bytes become visible (write_atomic publishes at its rename), so no window exists in which a capsule is discoverable with its provenance unwritten"

That is true for a FIRST land and false for a RE-LAND, and sync_module_from has no already-cached short-circuit, so re-lands happen.

Sequence, with a capsule already on disk correctly marked Relayed:

  1. persist_holder_claim(&path, Announce) (lib.rs:2557) REMOVES the .relay marker (module_reshare.rs:353).
  2. The old capsule bytes are still at pathwrite_atomic stages to a temp and renames.
  3. Between (1) and write_atomic at lib.rs:2559, cache_list_cached reports that capsule as Held. Every DHT reconcile, the bring-up announce and the opcode-222 holdings flood all rebuild from that scan (dht.rs:376).
  4. If write_atomic fails (ENOSPC, permissions), there is no rollback. The capsule stays on disk, permanently unmarked, permanently bondable.

Compare land_capsule_bytes, which gets this rightcapsule_store.rs:449-453 restores the marker in the write_atomic error arm. sync_module_from has no equivalent arm. The two sinks that took the same claim argument in the same PR disagree on failure handling.

Why it escalates Finding 1. serve_local_blocking (lib.rs:1318) returns None when serve_blind cannot resolve the retrieval key. So a request naming a held capsule with a well-formed but absent retrieval_key misses serve_local_cached and falls straight through to the 1b sync at dispatch.rs:908. Combined with Finding 1, a cross-site page can therefore do more than land new content:

it can convert a capsule this node correctly relayed and marked Suppress into an unmarked, bondable Held one, by asking for it with a junk retrieval key.

That turns Finding 1 from "attacker adds a bondable capsule" into "attacker also strips the protection from capsules the fix already protected".

Fix: mirror capsule_store.rs:449-453 — restore the prior marker in sync_module_from's write_atomic error arm. Better: read the existing marker first and only remove it after a successful rename.


Q3 — the de-suppression itself: intended, but now attacker-reachable

Announce removing the marker is documented and intended (module_reshare.rs:337-341: "how a generation this node previously relayed becomes genuinely held when the node later pulls it for itself"). I am not calling the semantics a defect.

The defect is who can trigger it. The design assumes the de-suppressing pull is the operator's. Finding 1 makes it a stranger's.

Other interleavings I checked and cleared:

  • Concurrent Announce + Suppress land of the same root. cache_fetch_and_cache short-circuits on an already-cached capsule (capsule_store.rs:268-270) and holds cache_lock; the read path holds none (documented, lib.rs:2431). The reachable orderings resolve to Relayed, i.e. the safe direction — a bond forgone, never a bond wrongly staked.
  • Eviction sweep. discard_relay_marker_beside (capsule_key.rs:92) is called at every unlink site (lib.rs:2171, lib.rs:2383, capsule_store.rs:250), so a marker cannot outlive its capsule and silently suppress a later genuine acquisition.
  • The .module -> .dig migration (capsule_key.rs:150). Cleared: relay_marker_beside derives the marker via cached_root_stem, which strips both suffixes to the same <root>.relay, so the rename preserves the marker.

Q4 — failure direction: correct on every path except the one in Finding 3

  • sync_module_fromcreate_dir_all ?, persist_holder_claim .map_err(...)? (lib.rs:2553-2558). A marker that cannot be written fails the land. Correct.
  • land_capsule_bytes — same, plus marker restoration on write failure (capsule_store.rs:443-453). Correct.
  • promote_into_cachepersist_holder_claim(cached, claim)? returns WarmFailure::CacheWriteFailed before the rename (module_reshare.rs:320). Correct.

No path degrades to landing unmarked. The one gap is the un-marking direction of Finding 3, which the failure-direction reasoning did not cover because it is a removal rather than a write.


Q5 — provenance consumers, and the one that ignores it

The marker is genuinely load-bearing (I checked rather than assumed):

  • Announce: dht.rs:376-393 inventory_content_ids skips CapsuleProvenance::Relayed, and is the single mapping from inventory to announceable ids.
  • Bonding: mirror/lifecycle.rs:746 observe_disk carries provenance to runner::split_by_provenance for the §25.1 exclusion.
  • Both derive from cache_list_cached (capsule_store.rs:68), the only production producer.

FINDING 4, LOW (pre-existing, not introduced here) — dig.listInventory does not filter Relayed.
peer::list_inventory (peer.rs:629) groups the full cache_list_cached() output with no provenance filter, and dig.listInventory is peer-reachable (peer.rs:1228). So any peer with a self-signed mTLS leaf can enumerate the capsules this node relays for others. The push side (dht.rs:381) suppresses them; the pull side discloses them. "Servable, never advertised" is not the same as "enumerable on request" — this is a deanonymisation surface ("which capsules did this node relay, and for whom"). Recommend a follow-up ticket, not a gate.

FINDING 5, LOW — nothing reads provenance between write_atomic and a later marker write, because on every path the marker precedes the bytes. The only exception is Finding 3's window. No separate action.


Cleared while deriving — paths that fold correctly

  • maybe_backfill_capsule (capsule_store.rs:366) refuses unless origin == Local, and all five production callers pass the folded land_origin (content_serve.rs:909/932/991, dispatch.rs:500/973).
  • spawn_capsule_reshare (download.rs:2189) refuses unless origin == Local; its caller peer_serve_plaintext (content_serve.rs:1042) receives the folded value — serve_tiers_at_root is invoked with land_origin at content_serve.rs:566 and 604.
  • The wallet route to cache_fetch_and_cache (dig-wallet/src/lib.rs:408) is gated by is_self_origin (dig-wallet/src/lib.rs:160), an exact match on the wallet's own origin+port. Fails closed on an absent Origin.
  • profile_sync writes under <cache>/profiles, not <cache>/modules, so it produces no CachedCapsule and cannot bond.

Next and last: my derived caller set vs. the PR body's ten rows, both directions.

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

loop-security VERDICT: CHANGES-REQUIRED

Audited head: 8d4a0e7abbb947cbff5cd72516341bf2fc4dcfae (briefed at f78d0f9; head moved mid-audit, and I verified f78d0f9..8d4a0e7 is whitespace + trailing-comma reflow confined to mod tests -- token-identical otherwise. See my first comment.)

Method: sink-first derivation, per the brief. I enumerated filesystem write primitives across all five crates, identified the sinks that publish bytes into <cache>/modules/<store>/, then walked callers upward transitively. I read the PR body's ten-row enumeration only afterwards. .gitnexus was not used -- the registered index is stale and returns a false-safe impactedCount: 0; this was grep + direct read throughout, stated rather than implied.


Ranked findings

# Severity file:line Gate?
1 HIGH dig_rpc/dispatch.rs:906 GATING
2 MEDIUM (defense-in-depth) capsule/push_capsule.rs:422 fix with #1
3 MEDIUM (escalates #1) lib.rs:2557-2559 GATING
4 MEDIUM (false safety claim) capsule/capsule_store.rs:282-283 GATING (comment only)
5 LOW (pre-existing) peer.rs:629 follow-up ticket
6 LOW (doc vs reality) capsule_key.rs:251 follow-up ticket

Findings 1-3 and 5-6 are detailed in my three prior comments. Finding 4 is new and stated in full below.


FINDING 4 -- MEDIUM. The Announce justification comment is false, and it makes the next audit harder

crates/dig-node-core/src/seams/capsule/capsule_store.rs:282-283 asserts that cache.fetchAndCache is operator-initiated, a control-plane method, not peer reachable, so this land is genuinely this node's own capsule and stays bondable.

The comment is about the RPC method. The Announce it justifies is on the function, and the function is peer-triggerable. Chain, verified line by line at this head:

peer.rs:1438   a REMOTE peer's dig.fetchRange module-window request
  -> Node::note_inbound_demand(store, root)                   lib.rs:4564
  -> spawn_capsule_backfill                                   capsule_store.rs:497
  -> tokio::spawn -> gap_fill_generation                      capsule_store.rs:560
  -> cache_fetch_and_cache                                    capsule_store.rs:341
  -> sync_module_from(.., HolderClaim::Announce)              capsule_store.rs:281-285
  -> persist_holder_claim(Announce) + write_atomic            lib.rs:2557-2559

A remote peer requesting a module window makes this node pull the whole capsule and land it unmarked = Held = bondable. Gated by DIG_NODE_INBOUND_DEMAND_CACHE (default OFF) and by dig_sex::acquisition::decide, so not default-reachable -- which is why this is MEDIUM.

This is the lane's own row 5 / Defect A, which the PR body honestly declares unfixed. I am not gating on the fix. I am gating on the comment, because it is new in this diff and asserts a safety property that is false. Row 11 shipped past a gate precisely because a search was organised around a claim nobody re-checked; a comment reading "not peer reachable" beside a HolderClaim::Announce on a money-adjacent path is the same failure mode, pre-installed for the next reader.

Required: correct the comment to name what is actually true -- the RPC method is not peer-reachable (peer.rs:1199), the function IS peer-triggerable via inbound demand, and the Announce is deliberate pending the row-5 fix. One comment edit; no behaviour change.


My derived set vs. the PR's ten rows -- both directions

Rows you have that I did NOT independently derive

  • Row 3, tier-0 precache (tier0_live.rs:359 -> warm_capped -> Announce). I reached warm_with_config's hardcoded Announce at module_reshare.rs:766 but did not trace it up to tier-0. I subsequently checked your "No -- speculative, node's own selection" verdict and note that tier0_live.rs:95 documents tier-0 yielding to note_inbound_demand, i.e. remote traffic influences tier-0's behaviour. It selects the node's own candidates, so I found no steering primitive -- but I did not fully audit tier-0's selection input for Sybil-steerability, and I flag that as unreached rather than clear.
  • Row 10, operator hand-copy. Correct, and outside a code audit.

Rows I derived that you do NOT have

  • The cross-site axis on row 11 (Finding 1). Row 11 is in your body as a transport-origin fix; the (Local, CrossSite) case is not, and it is reachable through the CORS allowlist.
  • The same shape on row 6 (Finding 2) -- push_capsule.rs:422 uses raw origin while :272 folds. Row 6 is marked fixed; it is fixed on one axis.
  • The un-marking window and missing rollback (Finding 3) -- sync_module_from removes a marker with no restore on failure, unlike land_capsule_bytes:449-453. No row covers a land that removes provenance; row 9 covers eviction only.
  • dig.listInventory discloses Relayed capsules (Finding 5) -- a read path, so outside a write enumeration by construction, but it undoes part of what the marker buys.

Your two "Unproven" items -- both closed

  1. "Defect A established by reading, not execution." I traced it to a concrete remote entry point (peer.rs:1438) -- see Finding 4. Reading confirmed; the flag gate holds.
  2. "I did not audit dig-download's own sink." Closed. dig-download's FileSink finalizes onto staged_module_path = <staging>/<subdir>/<store>-<root>.dig (capsule_key.rs:253), and production wires staging_dir = self.downloads_dir = <cache>/downloads (download.rs:1253, :2327). It is therefore inside the cache dir, contradicting the doc at capsule_key.rs:251 and module_reshare.rs:262 which both say it must not be. It is nonetheless safe today, because cache_list_cached walks only <cache>/modules (capsule_store.rs:30-45), so a staged file is never inventory. Finding 6, LOW: the invariant is stated but not enforced -- widening the inventory scan to the cache root would silently turn every in-flight download into a holder claim. Recommend asserting the path relation rather than documenting it.

Your five questions

Q1 -- Is ReadOrigin::Local un-spoofable by a remote caller? On the transport axis, YES.
read_origin_for (server.rs:916) derives it solely from the accepting TCP connection's real remote address via is_loopback_addr, and into_make_service_with_connect_info::<SocketAddr>() is applied on every listener (server.rs:2288/2296/2304/2457) -- a failed ConnectInfo extraction is an axum rejection, never a defaulted Local. The three hardcoded Local sites are each justified: the control shell (control.rs:885/:2488, token-gated, and is_control_method restricts it to the control. namespace so dig.getContent cannot be smuggled through), and the in-process FFI (dig-runtime/src/lib.rs:115). The peer wire passes Peer (peer.rs:1383, :1613) behind a method allowlist (peer.rs:1199). A remote request cannot arrive carrying Local.

But the fix does not rest on that axis alone, and that is the defect. Local is genuinely un-spoofable and still not sufficient, because a browser is a confused deputy: it holds a loopback connection on the attacker's behalf. That is the entire reason RequestProvenance exists, and it is the axis Finding 1 drops.

Q2 -- Is RequestProvenance load-bearing here and ignored? YES to both, and that is Finding 1.
It is not merely a second signal -- it is the only signal distinguishing the operator's own read from an attacker's page driving the operator's browser. landing_origin (download.rs:412) is the codebase's single collapse point, applied at dispatch.rs:381, dispatch.rs:953, content_serve.rs:366, push_capsule.rs:272. Using origin alone was not right.

On your sub-question -- what a FirstParty request from a non-Local origin means for bondability: landing_origin(Peer, FirstParty) == Peer, so it stays Suppress. Correct: the transport axis denies it and provenance cannot promote. The fold is only ever restrictive, which is why applying it at dispatch.rs:906 cannot break the flywheel for genuine local reads.

Q3 -- Ordering. The create_dir_all -> persist_holder_claim -> write_atomic order is right for a first land. It is not right for a re-land: Announce removes the marker while the previous capsule is still published, and a failed write_atomic leaves it removed permanently (Finding 3). Concurrent Announce/Suppress lands resolve to Relayed (the safe direction); eviction discards markers with capsules; the .module -> .dig migration preserves them via cached_root_stem. The de-suppression itself is intended (module_reshare.rs:337-341) -- the defect is that Finding 1 lets a stranger trigger it.

Q4 -- Failure direction. Correct on all three sinks: a marker that cannot be written fails the land (lib.rs:2557, capsule_store.rs:445, module_reshare.rs:320). No derived path degrades to landing unmarked. The gap is the un-marking direction, which failure-direction reasoning does not cover because it is a removal rather than a write.

Q5 -- Other provenance consumers. The marker is genuinely load-bearing: dht.rs:376-393 (the single inventory-to-announceable-ids mapping) skips Relayed, and mirror/lifecycle.rs:746 carries it into the section 25.1 bonding exclusion. Both read from cache_list_cached, the only production producer. Nothing can observe a capsule between write_atomic and a later marker write, because the marker always precedes the bytes -- except in Finding 3's window.


What I could NOT reach -- stated plainly

  1. I did not compile or run anything. Per your instruction the suite is yours. Every claim here is from reading the tree at 8d4a0e7. Finding 1's exploit is traced, not executed -- I did not stand up a node and fire the cross-site POST. The chain is verified link by link, but a live proof would be stronger and I did not produce one.
  2. .gitnexus unused (stale index, false-safe zeroes). No call-graph tool corroborated my caller derivation; it is grep + read, so a caller reached only through a trait object or dynamic dispatch I did not think to grep for could be missing.
  3. Tier-0 selection input not fully audited for Sybil-steerability (see above).
  4. I did not audit the dig_sex::acquisition::decide crate (capsule_store.rs:521), which gates row 5. I took its AcquisitionDecision::Acquire semantics at face value; it is an external crate, and a permissive answer there widens row 5.
  5. RequestProvenance depends on the browser telling the truth via Sec-Fetch-Site. That is sound for the CSRF threat model (a page cannot forge it) but is not a defence against a non-browser client, which simply omits the header and is classified FirstParty by design (download.rs:395). I did not treat that as a finding -- a local non-browser client is already the operator -- but it is the assumption the whole axis rests on and it should be named.

Required before merge

  1. dispatch.rs:906 -- fold the axes: pass crate::download::landing_origin(origin, provenance) into holder_claim_for_read.
  2. push_capsule.rs:422 -- same fold; landing_origin is already imported at :48.
  3. lib.rs:2557-2559 -- do not leave a marker removed on a failed land; restore it in the write_atomic error arm as capsule_store.rs:449-453 already does.
  4. capsule_store.rs:282-283 -- correct the false "not peer reachable" justification.
  5. A test asserting (ReadOrigin::Local, RequestProvenance::CrossSite) -> Suppress. The two existing holder_claim_tests vary only origin and both still pass with the bug present, so they cannot catch it. This is the test that would have.

Findings 5 and 6 are follow-up tickets, not gates.

The shape of the fix is right and the required-argument design is the correct call. It closes the transport axis and leaves the browser axis open, which is one axis short of the class it names.

…ort alone

`holder_claim_for_read` read the raw `origin`. Every other landing decision in
this file folds `origin` with `provenance` through `landing_origin` first
(`dispatch.rs:381`, `:953`, `content_serve.rs:366`, `push_capsule.rs:272`), and
the trait's own doc states that contract -- so this one call was the outlier.

The transport axis is un-spoofable and was read correctly; it is simply not
sufficient. A browser is a confused deputy holding a loopback socket on someone
else's behalf: a `chrome-extension://` page, or any cross-site page, can POST
`dig.getContent` to the loopback port -- CORS admits both (`is_local_origin`,
server.rs:433) -- so the request arrives with `origin = Local` while being made
for a stranger. That landed an attacker-chosen capsule ANNOUNCED, i.e. bondable,
in a default install with no flag in front of it. Because `Announce` also REMOVES
an existing marker, it could additionally un-suppress a capsule already correctly
relayed.

The fold is only ever restrictive (`CrossSite` -> `Peer`, `FirstParty`
unchanged), so it cannot cost the operator their own flywheel.

Also corrects a comment at the `cache.fetchAndCache` call site that claimed the
method is "not peer reachable". That is false -- the inbound-demand backfill
reaches it from a remote request -- and only "behind a default-OFF flag" is true.
Tracked as #446.

Both pre-existing `holder_claim_tests` pass with the single-axis bug present,
which is why `a_cross_site_read_over_a_local_socket_backfills_suppressed` exists.

Refs #436

Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d and others added 2 commits August 31, 2026 01:50
…e shared rule

`cache.pushCapsule` decided provenance from the raw `origin` -- the identical
defect just fixed on the read path, in a function that ALREADY folds both axes
150 lines earlier for its authority check (`push_capsule.rs:272`). So for a
cross-site push over the loopback port the authority half correctly demanded an
authorized-writer signature, and the provenance half handed that same push
`Announce`.

Creating a store is permissionless, so holding a store key is not a trust
boundary: a third party entitled to push is not thereby entitled to spend this
operator's $DIG.

Both sites now call ONE `holder_claim_for_landing`, re-exported from `dig_rpc`.
Two copies of this rule would be a rival implementation that can drift apart --
which is exactly how the push site kept the bug after the read site was fixed.

Refs #436

Co-Authored-By: Claude <noreply@anthropic.com>
`sync_module_from` wrote the provenance marker and then, on a failed
`write_atomic`, left it behind -- so a `Suppress` marker could outlive its land
and mis-describe a later capsule arriving at the same path by a different route.
`capsule_store.rs` already rolls back this way; this path did not.

`Announce` is the REMOVING claim, and removal is only safe because `write_atomic`
is temp-in-the-same-directory then rename: on failure it unlinks the temp and
leaves NO file at the capsule path, so there is no unmarked capsule for the
rollback to expose. Recorded at the call site, because if that ever stops being
true this rollback becomes a hole rather than a cleanup.

Completes the five items the pre-merge gate required.

Refs #436

Co-Authored-By: Claude <noreply@anthropic.com>
@MichaelTaylor3d

Copy link
Copy Markdown
Contributor Author

All five required items closed — head 82cc673fcf859cac0e7bd14f9e31a0fa5e3c2eae

# Item Commit
1 dispatch.rs — fold both axes via landing_origin a1894e78
2 push_capsule.rs:422 — same fold 065772c1
3 sync_module_from — restore the marker on a failed land 82cc673f
4 capsule_store.rs — correct the false "not peer reachable" justification a1894e78
5 A test asserting (Local, CrossSite) -> Suppress a1894e78

Item 2 became a sweep, not a second patch

Both landing sites now call ONE holder_claim_for_landing, re-exported from dig_rpc. Two copies of this rule would be a rival implementation that can drift — which is exactly how the push site kept the bug after the read site was fixed. Worth stating plainly: push_capsule.rs:272 had folded both axes for its AUTHORITY check all along, 150 lines above the provenance decision that read one axis. So for a cross-site push the same function correctly demanded an authorized-writer signature and then handed that push Announce.

Item 3 is the one that would have shipped

Four of five were done when the code looked finished. sync_module_from wrote the marker and, on a failed write_atomic, left it behind, where capsule_store.rs already rolled back — so a Suppress marker could outlive its land and mis-describe a later capsule arriving at the same path by another route.

The rollback uses Announce, the removing claim, and that is only safe because write_atomic is temp-in-the-same-directory then rename: on failure it unlinks the temp and leaves no file at the capsule path. Recorded at the call site, because if that ever stops being true this rollback stops being a cleanup and becomes a hole.

One finding that is NOT fixed here, and must not be read as fixed

#450 — the same-origin variant survives this fold. /s/*path and POST / are the same router on the same port (server.rs:255, :281), and STORE_CSP grants script-src 'unsafe-inline' 'unsafe-eval' plus connect-src 'self' — where 'self' is the RPC endpoint. A store's own page therefore reaches dig.getContent as same-originFirstPartyAnnounce.

The two-axis model cannot express that fix: the operator's own UI and attacker content served at /s/ are both (Local, FirstParty). Same-origin stopped being a trust signal when the node began serving untrusted content on its control origin. Traced link-by-link, not fired.

What this PR now claims, stated exactly

A cross-site or extension-origin request can no longer cause a Held landing on the read or push path. It does not claim the class is closed — #450 is open, and #446's inbound-demand path remains behind its default-OFF flag.

@MichaelTaylor3d
MichaelTaylor3d marked this pull request as ready for review August 31, 2026 09:38
@MichaelTaylor3d
MichaelTaylor3d merged commit 61ea1b2 into main Aug 31, 2026
14 of 15 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the loop/436-provenance-relayed branch August 31, 2026 09:38
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