Uh oh!
There was an error while loading. Please reload this page.
fix(control)!: gate chiaPeers on the master-token tier and adopt dnci 0.18 - #248
Conversation
…r the control plane
…n tier A paired token could call control.chiaPeers.add, installing a Chia peer the wallet replica believes WITHOUT corroboration. The entry outlives the token -- pairing.revoke removes a token id and touches no peer row -- so the designated remedy for a compromised paired app could not take the authority back. The cause was a duplicated predicate: is_pairing_admin_method restated the master tier as a match on three pairing strings, so when the contract moved chiaPeers.add/.remove onto that tier this node kept honouring the old list, failing OPEN. The duplicate is now gone rather than corrected -- requires_master_token delegates to ControlMethod::requires_master_token -- and a lockstep test pins the two sets together. Closes part of #254
… ban list Items 2-9 of #254. - remove reports ChiaPeerRemovalOutcome (removed|no_such_peer) with no boolean companion, so a consumer cannot render 'nothing was there' as 'it is gone' - add emits its notice INTO the declared notice field - corroboration_bypassed is the RESULTING trust state, read back from the row: adding a banned peer un-bans it WITHOUT granting the bypass, which previously reported a falsehood about custody-grade authority - list includes BANNED entries; it is the only enumeration of them. The dialling read (unbanned_peers) stays separate so banned peers can never reach the dialler - peak_height is Option<u32>: null means unobserved, never height 0 - ip is canonicalised on the way IN via params::canonical_peer_ip, and joined via params::chia_peer_endpoint so ::1 + 8444 cannot render ::1:8444 - ban list bounded at MAX_BANNED_CHIA_PEERS, oldest evicted - trust wording narrowed to 'a node you run yourself' Refs #254
… NC-12 trust wording
…as a failure to act The add line defaulted an ABSENT corroboration_bypassed to false, which would report a peer as untrusted against a pre-0.18 node that had in fact granted trust; absent now reads as granted and only an explicit false says the bypass was withheld. The fallback notice for a node too old to send one warns again -- it had been weakened to a neutral sentence. Refs #254
MichaelTaylor3d
commented
Aug 19, 2026
loop-security — GATING finding (interim, posted as formed)Head audited: CRITICAL — the escalation is NOT closed. A paired token still installs a trusted Chia peer, via the Sage-parity wallet plane.The master-tier gate added here covers the Exploit (state -> action -> impact): State: the attacker holds a valid paired controller token (a compromised or malicious paired app). This is exactly the principal the master tier exists to exclude. Action: one request, no Path, each hop verified at this head:
That final call is byte-for-byte the writer Impact: precisely the escalation this PR states it closes — a peer believed without corroboration, authoritative for money-bearing chain facts, which survives The same hole inverts the remedy. Both transports, not one. The WS fall-through in Why this gates rather than being filed as pre-existingThe dispatch arms predate this PR (the diff refactors
A false claim of unreachability is worse than the silence it replaced: it is the sentence the next reviewer will trust instead of re-deriving the reachability. The nine revert proofs are all anchored on the What would close itThe tier must be a property of the capability, not of the plane the caller chose. Options, in preference order:
Either way it needs a lockstep test in the shape of Remaining audit items (carve-out width, dialler split, |
MichaelTaylor3d
commented
Aug 19, 2026
loop-security — items 2 to 6, at head |
MichaelTaylor3d
commented
Aug 19, 2026
loop-security — VERDICT: CHANGES-REQUIREDHead audited: Executed probes, in a private worktreeAll builds and mutations ran in Baseline first: Revert proof 1 (item 1) — CONFIRMED. Reverted It names the two missing methods, as the PR body claims. (Note the second failure differs from the body's table — reverting also breaks the fail-closed default, which the body attributes to Revert proof 2 (item 5) — CONFIRMED. Dropped Revert proof 3 (item 4) — CONFIRMED. Made The test is two-sided (a fresh add must return Two probes of my own, both of which changed a reasoned claim into a measured oneProbe A — the carve-out DOES widen silently (finding 2). I added an unpublished Both pass, while the Probe B — the gating bypass is real, not a reading error. I added a temporary test asserting the control-plane and wallet-plane answers side by side for the same capability: assert!(crate::control::requires_master_token("control.chiaPeers.add"));assert!(crate::control::requires_master_token("control.chiaPeers.remove"));assert!(authorize("add_peer",Some(PAIRED),MASTER, is_paired));assert!(authorize("remove_peer",Some(PAIRED),MASTER, is_paired));All four hold at once. The control plane refuses a paired token for Verdict by area
Can a paired token still reach the escalation? YES — by one routePlainly stated, because this is the question the gate exists to answer: a paired token can still install a Chia peer that is believed without corroboration and that survives That is the finding this PR must close before merge, and it is ranked first in my earlier comment with the full call chain. Ranked findings
State left behindPR remains DRAFT, as instructed. Merge preconditions at this head: all five required checks SUCCESS by name, 0 unresolved threads, |
…t plane too The master tier was enforced on the control plane only, so a paired token reached the identical writer through the Sage-parity POST /add_peer and its /ws equivalent: same network::add_peer call, same user_managed row, same corroboration-free authority surviving pairing.revoke. Only the URL differed. The tier is a property of the capability, not of the plane it arrived on, so the wallet gate now resolves the parity aliases to their control names and reads the tier from the same published contract. A cross-plane test asserts both policies for one capability in one place, which is the only shape that can fail on a divergence a per-plane suite cannot see. Co-Authored-By: Claude <noreply@anthropic.com>
…o null at the boundary A strict Sage-compatible client deserializes peak_height into a non-optional integer, so emitting null broke it at parse time regardless of whether it read the value. The unobserved-vs-genesis distinction is the control surface's to draw, and it has no third-party consumers, so the mapping moves there and the parity wire returns to the shape Sage sends. Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d
commented
Aug 19, 2026
Gating finding CLOSED — the tier is now a property of the capability, on both planesHead: 1. CRITICAL / GATING — the wallet plane now derives the same tier ( |
MichaelTaylor3d
commented
Aug 20, 2026
loop-security re-gate — interim note 1 (audit in progress)Head audited: Scope: the escalation fix on the Sage-parity wallet plane, the carve-out fixture, and the door enumeration. Not re-auditing items already cleared in round 1. Progress so far (no verdict yet):
Still open: the 9257 cert-ephemerality containment, the cross-plane revert probe, and the |
MichaelTaylor3d
commented
Aug 20, 2026
loop-security re-gate — interim note 2 (head |
| # | Call site | Gate |
|---|---|---|
| 1 | server.rs:1117 — JSON-RPC handler, wallet branch | wallet_authz::authorize (~1090) |
| 2 | server.rs:1363 — wallet_rpc, POST /{method} | wallet_call_authorized -> authorize (~1348) |
| 3 | server.rs:1481 — ws_dispatch, /ws | wallet_call_authorized -> authorize (~1470) |
| 4 | transport.rs:159 — the 9257 mTLS listener | NONE (see containment below) |
Plus the two control-plane doors (server.rs ~1054 HTTP, ~1430 WS) which reach the writer under the control.chiaPeers.* names via dispatch_control. That is six doors total, not four; the table folded #1 into #2. No ungated door was found.
Two candidate fifth planes were run down and are NOT doors:
dig_wallet::wc_dispatch/wallet_dispatch(the dapp +dig-runtimeFFI plane) is a separate dispatcher with its own catalogue (chip0002_*,chia_*,dig_*).add_peeris not in it and it never callsWalletBackend::dispatch.dig-node-core/src/chat.rs:281isMessageRegistry::dispatch, an unrelated chat message-type registry.
The normalization-split bypass does not exist either. In all three server.rs sites the gate and the dispatcher read the samemethod string variable, and dispatch_inner matches with a plain exact-match match method { "add_peer" => ... } — no lowercasing, no trimming, no alias arm. So Add_Peer / add_peer classify as Other, pass the gate ungated, and then fail to match any dispatch arm. It reaches nothing.
The 9257 containment HOLDS — verified, not accepted
transport.rs:159 genuinely has no authz. Four independent checks say it is nonetheless unreachable:
- Per-run, in-memory only.
service.rs:299callsSharedCert::generate()(a freshrcgenself-signed keypair) once perWalletServiceconstruction. There is nofs::writeof it anywhere in the tree. - No export path.
SharedCert::client_identity_pem()— the method whose entire purpose is handing a client the identity — has zero callers;transport.rs:78is the sole definition.wallet_certappears only as a struct field (server.rs:102), its construction (:492), and two clones into the listener task (:1990,:2037). It is never serialized, never returned by any RPC. - Cannot leak into a log.
SharedCertderivesCloneonly — noDebug, noSerialize— so it cannot be emitted by a?certtracing field. - Loopback + byte-identical client cert.
server.rsbinds("127.0.0.1", 9257), andSharedCertVerifier::verify_client_certaccepts a client cert iff its DER equals the server's own.
So reaching it requires a value that exists only in this process's heap. A paired token cannot supply it; an attacker who can read that heap already holds custody. Containment confirmed — dig-node#257 correctly stays out of scope for this PR, and this is not a gating finding.
One residual for #257, not for this PR:transport.rs::build_cors_router layers allow_origin(Any) + allow_headers(Any) over the same ungated router and is served by serve_http/serve_dual. Grep confirms serve_http, serve_dual and build_cors_router have no caller anywhere outside transport.rs — server.rs:2039 spawns serve_mtls only. It is latent, not live. But if it is ever wired, it is strictly worse than the mTLS door: no client cert at all, and a wildcard CORS origin makes it reachable from any web page the user visits. The module doc's "loopback only, so a wildcard origin is safe" is not true of a browser-reachable surface. Worth recording on #257 so a future wiring does not inherit that sentence as an assurance.
MichaelTaylor3d
commented
Aug 20, 2026
loop-security re-gate: PASSHead audited: Scoped re-gate of the round-1 gating finding plus the two secondary items. Items 1-9, the dialler split, 1. The escalation is CLOSED on both planes — verified by reverting the fix
The revert probe reproduces exactly as claimed. Deleting only the Exactly one test fails, and it is the one that would have caught the original finding. Its discrimination is real, not asserted. Worth recording that this test also incidentally closes an ordering hazard I went looking for: 2. No fifth door — the writer's reachable set is closedEnumerated by grepping every caller of
The table in the PR narrative says four; it folded the JSON-RPC wallet branch into Two candidate fifth planes run down and rejected: The normalization-split bypass does not exist. In each 3. The 9257 containment HOLDS
Reaching it needs a value that exists only in this process heap. A paired token cannot supply it; anyone who can read that heap already holds custody. dig-node#257 correctly stays out of scope, and this is not gating. One residual for #257, not for this PR: 4. The carve-out fixture is genuinely discriminating
The carve-out can no longer widen by itself: 5. |
Uh oh!
There was an error while loading. Please reload this page.
…keep the service stoppable
Three fixes that share `dig-node-service`, plus a measurement that says the fourth ticket needs no
code.
CORS (#702) is now decided per request rather than once for the router. Local web/extension origins
keep the whole surface; desktop-app origins are reflected for content reads only. The discriminator
has to be the method, not the route: `POST /` multiplexes content reads and the open wallet-read
methods onto one JSON-RPC endpoint, and `/{method}` serves the Sage-parity wallet RPC on POST and
content on GET, so a route-keyed decision must answer both traffic classes the same way. That is
what #693 deferred. Every open wallet-read method is reached by POST and every cross-origin content
read is a GET, so the split closes the wallet-read reach with #669 left intact. Preflights are
judged against `Access-Control-Request-Method` so the preflight answer matches the real one, and a
preflight declaring no method fails closed.
The DIG loopback rule (#767) gains a single source of truth in `loopback.rs` and a build-time guard.
The ephemeral content server was still on `127.0.0.1` despite the P0 #745 fix the ticket asks to
confirm; it now takes the DIG address, falling back only where that address cannot be bound at all
and logging when it does. The guard fails the build on a new literal-loopback bind, ignoring test
fixtures and dials, and it immediately found two binds a manual sweep had missed. Three sites keep a
literal address for cross-repo dial-contract or crate-layering reasons and are enumerated in the
guard with their reasons rather than left to memory.
The SCM 1061 wedge (#2880) was neither of its two usual causes: the service does reach RUNNING and
does report a STOP-accepting mask. The stop was bridged into the serve future by
`spawn_blocking(recv)`, putting it on tokio's blocking pool — the same pool the wallet replica's
synchronous database work draws from. With the pool saturated the receiving task never ran, so an
accepted stop was never observed and the service kept serving HTTP while the SCM could not stop it.
That is also why the wedge correlated with the frozen replica: one blocked resource, two symptoms.
The stop is now a watch signal delivered by the runtime itself, and graceful shutdown is bounded, so
a body that will not wind down no longer holds the service RUNNING and a forced stop is reported as
a failed run rather than a clean one.
The trusted-Chia-peer surface (#2870) is already shipped and no code was added: `control.chiaPeers.*`
(PR #248) and `dign chia-peers` (PR #45) already wire the user-facing surface to the existing
`user_managed` writer, and the help text already names the custody grant. No env var or config key
was added on purpose — that would be a second configuration path into write authority.
Closes #767
Closes #702
Closes #2880
Closes #2870
Co-Authored-By: Claude <noreply@anthropic.com>…keep the service stoppable
Three fixes that share `dig-node-service`, plus a measurement that says the fourth ticket needs no
code.
CORS (#702) is now decided per request rather than once for the router. Local web/extension origins
keep the whole surface; desktop-app origins are reflected for content reads only. The discriminator
has to be the method, not the route: `POST /` multiplexes content reads and the open wallet-read
methods onto one JSON-RPC endpoint, and `/{method}` serves the Sage-parity wallet RPC on POST and
content on GET, so a route-keyed decision must answer both traffic classes the same way. That is
what #693 deferred. Every open wallet-read method is reached by POST and every cross-origin content
read is a GET, so the split closes the wallet-read reach with #669 left intact. Preflights are
judged against `Access-Control-Request-Method` so the preflight answer matches the real one, and a
preflight declaring no method fails closed.
The DIG loopback rule (#767) gains a single source of truth in `loopback.rs` and a build-time guard.
The ephemeral content server was still on `127.0.0.1` despite the P0 #745 fix the ticket asks to
confirm; it now takes the DIG address, falling back only where that address cannot be bound at all
and logging when it does. The guard fails the build on a new literal-loopback bind, ignoring test
fixtures and dials, and it immediately found two binds a manual sweep had missed. Three sites keep a
literal address for cross-repo dial-contract or crate-layering reasons and are enumerated in the
guard with their reasons rather than left to memory.
The SCM 1061 wedge (#2880) was neither of its two usual causes: the service does reach RUNNING and
does report a STOP-accepting mask. The stop was bridged into the serve future by
`spawn_blocking(recv)`, putting it on tokio's blocking pool — the same pool the wallet replica's
synchronous database work draws from. With the pool saturated the receiving task never ran, so an
accepted stop was never observed and the service kept serving HTTP while the SCM could not stop it.
That is also why the wedge correlated with the frozen replica: one blocked resource, two symptoms.
The stop is now a watch signal delivered by the runtime itself, and graceful shutdown is bounded, so
a body that will not wind down no longer holds the service RUNNING and a forced stop is reported as
a failed run rather than a clean one.
The trusted-Chia-peer surface (#2870) is already shipped and no code was added: `control.chiaPeers.*`
(PR #248) and `dign chia-peers` (PR #45) already wire the user-facing surface to the existing
`user_managed` writer, and the help text already names the custody grant. No env var or config key
was added on purpose — that would be a second configuration path into write authority.
Closes #767
Closes #702
Closes #2880
Closes #2870
Co-Authored-By: Claude <noreply@anthropic.com>…keep the service stoppable
Three fixes that share `dig-node-service`, plus a measurement that says the fourth ticket needs no
code.
CORS (#702) is now decided per request rather than once for the router. Local web/extension origins
keep the whole surface; desktop-app origins are reflected for content reads only. The discriminator
has to be the method, not the route: `POST /` multiplexes content reads and the open wallet-read
methods onto one JSON-RPC endpoint, and `/{method}` serves the Sage-parity wallet RPC on POST and
content on GET, so a route-keyed decision must answer both traffic classes the same way. That is
what #693 deferred. Every open wallet-read method is reached by POST and every cross-origin content
read is a GET, so the split closes the wallet-read reach with #669 left intact. Preflights are
judged against `Access-Control-Request-Method` so the preflight answer matches the real one, and a
preflight declaring no method fails closed.
The DIG loopback rule (#767) gains a single source of truth in `loopback.rs` and a build-time guard.
The ephemeral content server was still on `127.0.0.1` despite the P0 #745 fix the ticket asks to
confirm; it now takes the DIG address, falling back only where that address cannot be bound at all
and logging when it does. The guard fails the build on a new literal-loopback bind, ignoring test
fixtures and dials, and it immediately found two binds a manual sweep had missed. Three sites keep a
literal address for cross-repo dial-contract or crate-layering reasons and are enumerated in the
guard with their reasons rather than left to memory.
The SCM 1061 wedge (#2880) was neither of its two usual causes: the service does reach RUNNING and
does report a STOP-accepting mask. The stop was bridged into the serve future by
`spawn_blocking(recv)`, putting it on tokio's blocking pool — the same pool the wallet replica's
synchronous database work draws from. With the pool saturated the receiving task never ran, so an
accepted stop was never observed and the service kept serving HTTP while the SCM could not stop it.
That is also why the wedge correlated with the frozen replica: one blocked resource, two symptoms.
The stop is now a watch signal delivered by the runtime itself, and graceful shutdown is bounded, so
a body that will not wind down no longer holds the service RUNNING and a forced stop is reported as
a failed run rather than a clean one.
The trusted-Chia-peer surface (#2870) is already shipped and no code was added: `control.chiaPeers.*`
(PR #248) and `dign chia-peers` (PR #45) already wire the user-facing surface to the existing
`user_managed` writer, and the help text already names the custody grant. No env var or config key
was added on purpose — that would be a second configuration path into write authority.
Closes #767
Closes #702
Closes #2880
Closes #2870
Co-Authored-By: Claude <noreply@anthropic.com>…keep the service stoppable
Three fixes that share `dig-node-service`, plus a measurement that says the fourth ticket needs no
code.
CORS (#702) is now decided per request rather than once for the router. Local web/extension origins
keep the whole surface; desktop-app origins are reflected for content reads only. The discriminator
has to be the method, not the route: `POST /` multiplexes content reads and the open wallet-read
methods onto one JSON-RPC endpoint, and `/{method}` serves the Sage-parity wallet RPC on POST and
content on GET, so a route-keyed decision must answer both traffic classes the same way. That is
what #693 deferred. Every open wallet-read method is reached by POST and every cross-origin content
read is a GET, so the split closes the wallet-read reach with #669 left intact. Preflights are
judged against `Access-Control-Request-Method` so the preflight answer matches the real one, and a
preflight declaring no method fails closed.
The DIG loopback rule (#767) gains a single source of truth in `loopback.rs` and a build-time guard.
The ephemeral content server was still on `127.0.0.1` despite the P0 #745 fix the ticket asks to
confirm; it now takes the DIG address, falling back only where that address cannot be bound at all
and logging when it does. The guard fails the build on a new literal-loopback bind, ignoring test
fixtures and dials, and it immediately found two binds a manual sweep had missed. Three sites keep a
literal address for cross-repo dial-contract or crate-layering reasons and are enumerated in the
guard with their reasons rather than left to memory.
The SCM 1061 wedge (#2880) was neither of its two usual causes: the service does reach RUNNING and
does report a STOP-accepting mask. The stop was bridged into the serve future by
`spawn_blocking(recv)`, putting it on tokio's blocking pool — the same pool the wallet replica's
synchronous database work draws from. With the pool saturated the receiving task never ran, so an
accepted stop was never observed and the service kept serving HTTP while the SCM could not stop it.
That is also why the wedge correlated with the frozen replica: one blocked resource, two symptoms.
The stop is now a watch signal delivered by the runtime itself, and graceful shutdown is bounded, so
a body that will not wind down no longer holds the service RUNNING and a forced stop is reported as
a failed run rather than a clean one.
One defect found by CI and fixed at the call site, not by widening the check that caught it. The new
ephemeral bind first built its URL as `format!("http://{host}:{port}/...")`, which dig-node-core's
`banned_address_patterns` sweep correctly rejects: text concatenation loses the brackets every IPv6
literal needs. The URL is now formatted from the `SocketAddr` itself, whose Display brackets v6 and
leaves v4 alone, and the bind helper returns only the listener so the advertised address can no
longer disagree with the bound one.
The trusted-Chia-peer surface (#2870) is already shipped and no code was added: `control.chiaPeers.*`
(PR #248) and `dign chia-peers` (PR #45) already wire the user-facing surface to the existing
`user_managed` writer, and the help text already names the custody grant. No env var or config key
was added on purpose — that would be a second configuration path into write authority.
Closes #767
Closes #702
Closes #2880
Closes #2870
Co-Authored-By: Claude <noreply@anthropic.com>…keep the service stoppable (#291) * feat(node): scope CORS per route+method, hold the DIG loopback rule, keep the service stoppable Three fixes that share `dig-node-service`, plus a measurement that says the fourth ticket needs no code. CORS (#702) is now decided per request rather than once for the router. Local web/extension origins keep the whole surface; desktop-app origins are reflected for content reads only. The discriminator has to be the method, not the route: `POST /` multiplexes content reads and the open wallet-read methods onto one JSON-RPC endpoint, and `/{method}` serves the Sage-parity wallet RPC on POST and content on GET, so a route-keyed decision must answer both traffic classes the same way. That is what #693 deferred. Every open wallet-read method is reached by POST and every cross-origin content read is a GET, so the split closes the wallet-read reach with #669 left intact. Preflights are judged against `Access-Control-Request-Method` so the preflight answer matches the real one, and a preflight declaring no method fails closed. The DIG loopback rule (#767) gains a single source of truth in `loopback.rs` and a build-time guard. The ephemeral content server was still on `127.0.0.1` despite the P0 #745 fix the ticket asks to confirm; it now takes the DIG address, falling back only where that address cannot be bound at all and logging when it does. The guard fails the build on a new literal-loopback bind, ignoring test fixtures and dials, and it immediately found two binds a manual sweep had missed. Three sites keep a literal address for cross-repo dial-contract or crate-layering reasons and are enumerated in the guard with their reasons rather than left to memory. The SCM 1061 wedge (#2880) was neither of its two usual causes: the service does reach RUNNING and does report a STOP-accepting mask. The stop was bridged into the serve future by `spawn_blocking(recv)`, putting it on tokio's blocking pool — the same pool the wallet replica's synchronous database work draws from. With the pool saturated the receiving task never ran, so an accepted stop was never observed and the service kept serving HTTP while the SCM could not stop it. That is also why the wedge correlated with the frozen replica: one blocked resource, two symptoms. The stop is now a watch signal delivered by the runtime itself, and graceful shutdown is bounded, so a body that will not wind down no longer holds the service RUNNING and a forced stop is reported as a failed run rather than a clean one. One defect found by CI and fixed at the call site, not by widening the check that caught it. The new ephemeral bind first built its URL as `format!("http://{host}:{port}/...")`, which dig-node-core's `banned_address_patterns` sweep correctly rejects: text concatenation loses the brackets every IPv6 literal needs. The URL is now formatted from the `SocketAddr` itself, whose Display brackets v6 and leaves v4 alone, and the bind helper returns only the listener so the advertised address can no longer disagree with the bound one. The trusted-Chia-peer surface (#2870) is already shipped and no code was added: `control.chiaPeers.*` (PR #248) and `dign chia-peers` (PR #45) already wire the user-facing surface to the existing `user_managed` writer, and the help text already names the custody grant. No env var or config key was added on purpose — that would be a second configuration path into write authority. Closes #767 Closes #702 Closes #2880 Closes #2870 Co-Authored-By: Claude <noreply@anthropic.com> * fix(service): exit on a forced stop instead of blocking in the runtime drop SEC-1 (gating): the forced-stop branch reported `Stopped` to the SCM and then returned, dropping the tokio runtime. `Runtime::drop` joins the blocking pool with no timeout, and a forced stop is by definition the case where a blocking task never finished, so the drop blocked forever AFTER the SCM had been told the service was stopped. Measured on the pinned tokio 1.53.0: still blocked after 8.03s, then 50.19ms once the wedged closure was released. That is a privileged action reporting success without taking effect: `sc stop` succeeds, the service is marked stopped, and the process stays alive holding its binary image locked - the exact symptom dig_ecosystem#2880 exists to remove. It also leaves an SCM-stopped-but-alive state from which a `StartService` yields a second process against the same wallet replica. A forced stop now abandons the pool (`shutdown_background`) and exits. The graceful branch, the watch-based stop signal and the 20s deadline are unchanged. SEC-2 (low): `Access-Control-Allow-Methods` now mirrors the requested method. tower-http emits it on every answered preflight independent of the origin verdict, so a static [GET, POST, OPTIONS] answered an approved app-origin GET preflight by also advertising POST, seeding the browser preflight cache with a POST entry a later `POST /` could use to skip its preflight. SEC-3: narrow two overclaiming sentences. The sandboxed-blob comment claimed origin isolation from "anything else"; cookies ignore port, so the blob shares the 127.0.0.2 cookie jar with the bare-IP content surface. The SPEC's app-origin scoping sentence is true of Access-Control-Allow-Origin, not of the whole response. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
What this does
Adopts
dig-node-control-interface0.18.0 (live on crates.io) and closes#254 — nine items, of which item 1 is a live
privilege escalation, not a version bump.
Parent epic: https://github.com/DIG-Network/dig_ecosystem/issues/2870
DO NOT MERGE — security gate pending. Item 1 earns a real security gate; this stays DRAFT
until that verdict returns.
1. SECURITY — a paired token could grant itself unrevocable chain authority
chiaPeers.add/.removeare master-tier in the contract. This node did not honour that, because itrestated the predicate as a string list:
chiaPeers.*is absent, so a paired token callingcontrol.chiaPeers.addsucceeded — installing apeer with
WriteAuthority::Operator, "full authority, no ceiling", over the wallet replica.It delegated and was not revocable. After the call the attacker no longer needed the token, and
pairing::revoke_paired_token(pairing.rs) removes a token id and touches no peer row.The fix: call the contract, do not re-state it
The duplicate is gone, not corrected. A security predicate duplicated across a repo boundary as a
string match drifts silently and fails OPEN — which is exactly what happened.
is_control_methodmatches on thecontrol.prefix, so an unrecognised name does reach this gate;it fails closed. A method this node genuinely serves but the contract has not published yet
(
control.peers.ping, tracked known-drift) keeps the ordinary tier, because promoting it wouldsilently break paired clients — a behaviour change unrelated to this escalation. That exception is
asserted explicitly so it cannot quietly widen.
Rejected: making
revokestrip user-managed peers. A compromised app's cleanup would then silentlyun-trust nodes the operator deliberately configured. The escalation is unrevocable by design, so it is
made unreachable instead. Recorded on
revoke_paired_tokenso it is not "fixed" later.After the change, a paired token
control.chiaPeers.list— read the trust state it is subject tocontrol.chiaPeers.add— grant trustcontrol.*mutationcontrol.chiaPeers.remove— strip operator-configured trustliststays ordinary-tier deliberately: it is a read, grants nothing that outlives the token, andgating it would leave a paired client unable to show the operator the trust state it is subject to.
Blast radius checked (gitnexus
impact, per-worktree index)is_pairing_admin_methodserver.rs::rpc(HTTP gate),server.rs::ws_dispatch(WS gate); 1 transitivews_handle_text. Both are the auth gates — exactly the intended radius.all_peers→unbanned_peerssync_supervisor.rs:1822— the dialleradd_peernetwork::add_peer, 3 db testsremove_peernetwork::remove_peer, 4 db testsget_peersdispatch_inner. Real shape consumers:chia_peers_listand the Sage-parityget_peersarm — see the caveat below.The dialler finding is why item 5 is not a one-line change.
all_peers()feedssync_supervisor.rs:1822, which picks the full node to dial. Item 5 askslistto include bannedentries; satisfying that by relaxing that one query would have fed banned peers to the dialler. A
ban applied to a previously
user_managedpeer leaves that flag set, so the existing.filter(|p| p.user_managed)would not have caught it. The reads are therefore split:unbanned_peers()(dialling) andall_peers_including_banned()(control plane), asserted together inone test.
Renames were done by explicit per-site edit against the enumerated radius, not find-and-replace —
and a missed site is a Rust compile error, which is a stronger check than a rename tool.
The other eight items
removed: bool→outcome: ChiaPeerRemovalOutcome, no boolean companionremoving_a_chia_peer_the_node_never_had_is_not_reported_as_a_removalCORROBORATION_BYPASS_NOTICEemitted into the declarednoticefielda_trusted_chia_peer_can_be_added_listed_and_removed_over_the_control_planecorroboration_bypassedis the resulting trust state, read back from the rowadding_a_banned_peer_unbans_it_without_granting_trustChiaPeerEntry.banned;liststops filtering banned rowsbanned_peers_are_listed_for_the_operator_but_never_dialledpeak_height→Option<u32>;nullunobservable, never0add_get_remove_peer_round_tripthe_trust_wording_authorises_only_a_node_the_operator_runs(+ CLI help twin)ipcanonicalised in viaparams::canonical_peer_ip; joined viaparams::chia_peer_endpointan_ipv6_chia_peer_is_stored_canonically_so_a_second_spelling_still_matchesMAX_BANNED_CHIA_PEERS = 256, oldest evicted;remove {ban:false}unbans, grants no trustthe_ban_list_is_bounded_and_evicts_the_oldest_banItem 4 is the one that was reporting a falsehood about custody-grade authority: the
db.rsDO UPDATEclears
bannedand refreshes the port but leavesuser_managedalone, so ban-then-add returnedsuccess without granting trust while the result claimed
corroboration_bypassed: true. It now readsthe flag back and reports what actually happened, with a distinct notice.
Item 9's bound is pinned from both sides — at exactly 256 nothing is evicted, one over evicts
exactly one — and the direction is asserted: the newest ban survives, because a full list that
refused the newest would deny the ban facility exactly when an operator needs it.
Caveat worth a reviewer's eye
PeerRecordalso serializes on the Sage-parityget_peersJSON surface, so items 5 and 6 changethat body too:
peak_heightbecomesnullandbannedis added. The old value was always0(nothing writes peer telemetry — SPEC §18.16), so no consumer was reading a meaningful number. Keeping
two divergent peer shapes seemed worse than one honest one, but this is a deliberate call, not an
oversight.
Verification
control_contract_conformance— GREEN, no exemptions. The file is byte-identical tomain(
git diff mainon it is empty);KNOWN_UNPUBLISHEDis still exactly["control.peers.ping"]. Itpasses on the 0.18 bump alone, which is what it was red to force.
restored after):
cargo fmt --all --checkexit 0;cargo clippy --workspace --all-targets -- -D warningsexit 0.0.126.2→0.127.0(minor: breaking control-plane result shapes, which is minor in 0.x).SPEC.mdupdated in the same unit — the three method rows and the CLI section now state themaster-token tier, the outcome enum, the resulting-trust semantics, the banned enumeration, the
nullpeak and the bounded ban list.Test design notes
Two fixtures were built to avoid pinning a coincidence:
truthful control (
listsucceeds). Asserting only the two refusals would be satisfied by a gate thatrefused the whole namespace, or by a node with no Chia-peer surface at all — so it would prove
nothing about placement.
master_token_set_matches_the_contractwrites the expected set out by name rather than derivingit from the predicate under test. A derivation would agree with any implementation, including the
one the test exists to catch.
Not fixed here — filed separately
control.config.setUpstream's tier is #255. Samedefect class as item 1 and arguably wider reach: ordinary-tier, persists a caller-chosen RPC upstream,
and survives
pairing.revoke. Deliberately its own ticket and its own PR — not folded in here, so thesecurity gate on this diff stays scoped to the peer-trust boundary.
Closes#254