diff --git a/SPEC.md b/SPEC.md index 96046691..6d7d6ace 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1044,7 +1044,7 @@ MUST NOT re-declare method names. Each entry carries a `served` class and `requi | `served` | Meaning | |---|---| -| `local` | Resolved by the node library (`handle_rpc`). | +| `local` | Resolved by the node library (`handle_rpc`). `requires_auth: false` except the HTTP-token-gated methods named below, which are `requires_auth: true`. | | `passthrough` | Read path returns `-32601`; relayed verbatim to the upstream WHEN one is configured (§5.4), else returned to the caller as `-32601`. | | `shell` | Answered by this service itself (`rpc.discover`). | | `control` | The gated control plane (§7); always `requires_auth: true`. | @@ -1056,8 +1056,11 @@ For the current node library (§2.2) the catalogue is: `dig.getCollection`, `dig.listCollectionItems`, the L7 peer surface (`dig.getNetworkInfo`, `dig.getPeers`, `dig.announce`, `dig.getAvailability`, `dig.listInventory`, `dig.fetchRange`), all `cache.*` (`cache.getConfig`, `cache.setCapBytes`, `cache.clear`, `cache.listCached`, - `cache.removeCached`, `cache.fetchAndCache`, `cache.pushCapsule` — §5.5.3), and the chat subsystem - `chat.send` / `chat.poll` (§5.5.2). + `cache.removeCached`, `cache.fetchAndCache`, `cache.pushCapsule` — §5.5.3), the chat subsystem + `chat.send` / `chat.poll` (§5.5.2), and the reward reads `dig.getRewardProverStatus`, + `dig.listRewardDistributors`, `dig.getPayeeRewardClaimStatus`, `dig.getRewardDistributor`, + `dig.listRewardDistributorCommitments` (dig_ecosystem#3352 / #3351 / #3355 — see the + `requires_auth` clause below). - **passthrough**: `dig.listCapsules` (needs a chain generation walk this node does not perform) and `dig.getProofStatus` (polls an execution-proof JOB this node does not run — inventing a status would be the fabrication the anti-fabrication rule forbids: an absent attestation is @@ -1077,8 +1080,18 @@ Param/result schemas for the `dig.*`/`cache.*` methods are owned by the digstore published on docs.dig.net (Protocol → the L7 read/RPC pages); this repo's OpenRPC document is a method + error **discovery** catalogue with intentionally permissive schemas. -Every non-`control.*` method MUST have `requires_auth: false`; every `control.*` method MUST have -`served: "control"` and `requires_auth: true`. +Every `control.*` method MUST have `served: "control"` and `requires_auth: true`. A non-`control.*` +method MUST have `requires_auth: false` UNLESS the HTTP surface token-gates it — today the +holder-/holdings-revealing `cache.fetchAndCache` / `cache.pushCapsule` / `cache.listCached` (§14.3, +#2108), the node-identity chat pair `chat.send` / `chat.poll` (#1946), and the NODE-LOCAL reward reads +`dig.getRewardProverStatus` / `dig.listRewardDistributors` / `dig.getPayeeRewardClaimStatus` +(dig_ecosystem#3352: each volunteers this node's own prover inventory, funded-distributor set or +payee claim state, failing §7.2's WHO-NAMES-THE-SUBJECT test). Those keep their `served` class and +carry `requires_auth: true`. `requires_auth` is the COMPILED statement of the HTTP token gate: the set +of catalogued methods with `requires_auth: true` MUST equal the set `server.rs` refuses `-32030 +UNAUTHORIZED` without a master or paired token (`requires_http_token`), and a test pins the equality. +The two chain-keyed reward reads `dig.getRewardDistributor` / `dig.listRewardDistributorCommitments` +are OPEN (dig_ecosystem#3351) and rate-bounded per source (§10, `-32034`). #### 5.5.0. `dig.getContent` — the window envelope (#2071) @@ -1469,8 +1482,9 @@ Two layers, both REQUIRED: mismatched credential is answered `UNAUTHORIZED` (`-32030`, §10). Token comparison MUST be constant-time (`ct_eq`) so verification cannot be probed via a timing oracle. -Exactly the `control.` method prefix is gated (`is_control_method`); unknown `control.*` methods -still pass the auth gate first, then yield `METHOD_NOT_FOUND`. The pairing-administration methods +The `control.` method prefix is token-gated as a class (`is_control_method`); the HTTP surface +additionally token-gates the non-`control.*` methods §5.5 enumerates (`requires_http_token`); +unknown `control.*` methods still pass the auth gate first, then yield `METHOD_NOT_FOUND`. The pairing-administration methods (`control.pairing.list`/`approve`/`revoke`, §7.11) require the MASTER token specifically — a paired token is NOT accepted for them. The exceptions are the wallet CHAIN READS — `control.wallet.balance`, `control.wallet.coins`, `control.wallet.coinById`, `control.wallet.coinSpend`, `control.wallet.coinsByParent`, @@ -3315,6 +3329,7 @@ method runs, and it MUST NOT be conflated with the wallet's own `-32043` egress | -32031 | `NOT_SUPPORTED` | shell | A control operation this build/pin cannot perform (e.g. §21 sync without an identity). | | -32032 | `CONTROL_ERROR` | shell | A control operation failed at runtime (distinct from bad input / absent capability). | | -32033 | `CONTROL_INGRESS_LIMITED` | shell | An OPEN, token-less `control.*` read was refused AT INGRESS, before the request reached the dispatcher and before any DB work was done for it: this SOURCE's request bound is exhausted. The open reads present no credential, so without this bound an unauthenticated caller can drive unbounded SQLite work (`.coinById`/`.coinSpend` each run up to two lookups plus an LRU `UPDATE`) simply by asking repeatedly. The bound is PER SOURCE — one flooding source MUST NOT refuse another — and the node's OWN loopback operator is EXEMPT, so this code is only ever seen by a non-loopback caller (i.e. under `DIG_NODE_ALLOW_REMOTE=1`). It MUST stay DISTINCT from `-32043 WALLET_RATE_LIMITED`: that bound is on chain EGRESS and protects the third-party oracle, this one is on REQUESTS and protects this process. They fire for different reasons and have different remedies, so collapsing them would leave a caller unable to tell which bound it hit. Back off and retry. | +| -32034 | `REWARD_INGRESS_LIMITED` | shell | An OPEN reward chain read (`dig.getRewardDistributor` / `dig.listRewardDistributorCommitments`) was refused AT INGRESS: this SOURCE's request bound is exhausted. Each call is one upstream chain read for any caller-supplied launcher_id, so without this bound an anonymous caller drives unbounded upstream work. The bound is PER SOURCE (`RequestorId` — never the launcher id, which the caller controls); the loopback operator is EXEMPT, so only a `DIG_NODE_ALLOW_REMOTE=1` caller ever sees it. Distinct from -32033 (control-read ingress) and -32043 (wallet chain egress). Back off and retry. | | -32040 | `WALLET_NO_CHAIN_SOURCE` | node | a wallet chain read (`control.wallet.balance`/`.coins`/`.coinById`/`.coinSpend`/`.coinsByParent`/`.peak`) or `control.wallet.broadcast` had NO live chain source able to answer an arbitrary (non-wallet) address. Distinct from a truthful `0`. A read the node can answer WITHOUT a chain source MUST NOT be refused with this code: the replica fast path and the node own chain-read cache both answer from bytes already in hand, so on `.coinById`/`.coinSpend` liveness is consulted only on a cache MISS. Refusing a cached answer because a third party is momentarily unreachable gives availability away for nothing on exactly the rows a lineage walk re-reads (a spent coin record is immutable), and the refusal then cascades into the retries that exhaust the `-32043` bound. The refusal MUST stay for a miss, and `.coinSpend` MUST treat a PARTIAL cache hit (spend cached, coin record not) as a miss, because the heights come from the record. | | -32041 | `WALLET_NOT_SYNCED` | node | `control.wallet.balance` of the wallet's OWN address while the local DB is still syncing and no live fallback is attached (nothing can answer yet). | | -32042 | `WALLET_READ_FAILED` | node | `control.wallet.balance`/`.coins`/`.coinById`/`.coinSpend`/`.coinsByParent`/`.peak` failed at the underlying DB / chain-source layer. On `.coinById` this INCLUDES a chain source that answered with a record for a DIFFERENT coin than the id asked for: a coin id is self-certifying (`SHA256(parent ‖ puzzle_hash ‖ amount)`), so a substituted record is a failed READ -- never that coin's record, and never `coin: null`. On `.coinSpend` it likewise INCLUDES a source that answered with another coin's spend, a puzzle reveal that does not tree-hash to the spent coin's own `puzzle_hash` (or will not parse), and a spend the coin record contradicts (no record, or a record calling the coin unspent) -- each fails CLOSED rather than being served unverified. On `.coinsByParent` it INCLUDES a source that returned a child naming a different parent, which fails the WHOLE page rather than being silently filtered (a filtered page is a lineage with an invisible hole). Distinct from `WALLET_NO_CHAIN_SOURCE` and `WALLET_NOT_SYNCED`. | diff --git a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs index 6d6e1ded..256d08f2 100644 --- a/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs +++ b/crates/dig-node-core/src/seams/dig_rpc/dispatch.rs @@ -907,10 +907,11 @@ impl RpcDispatch for Node { "count": set.len()}}); } // dig.getRewardProverStatus (dig_ecosystem#3269, dig-rewards-coin SPEC.md - // §2.3/§2.4) — CONTROL plane: loopback admin / in-process FFI ONLY (the token tier of - // this NODE-LOCAL read is dig_ecosystem#3352's decision, not #3351's), NEVER over the - // mTLS peer surface (absent from `is_peer_reachable_method`; - // `reward_methods_tier_guard.rs` fails closed on that). Reads the node's live + // §2.3/§2.4) — `Tier::Control` = local dispatch only, never the mTLS peer surface + // (`reward_methods_tier_guard.rs`); token-GATED on `POST /` at + // `server.rs::is_node_local_reward_read` (master or paired token, `-32030`; + // dig_ecosystem#3352) because it volunteers node-local state; the in-process FFI path + // stays open like `cache.*`. Reads the node's live // `reward_prover_statuses` registry (empty until dig_ecosystem#3265 spawns a prover // loop) — a REAL read of a real, currently-empty registry, so // `{"statuses":{"outcome":"consulted","observed_at":N,"items":[]}}` means "this node @@ -1013,7 +1014,11 @@ impl RpcDispatch for Node { // dig-node-service `tests/server.rs`. Chain-derived state ONLY — never the local prover // loop's self-reported state (see `GetRewardProverStatus` above for that). Goes entirely // through `rewards::port::RewardsChainPort`: this crate never calls `dig-rewards-coin` - // itself (dig_ecosystem#3269 unit 0). + // itself (dig_ecosystem#3269 unit 0). Token-LESS does not mean UNBOUNDED: staying open + // per dig_ecosystem#3351 above, `POST /` also rate-bounds this read PER SOURCE at + // ingress (`-32034 REWARD_INGRESS_LIMITED`, dig_ecosystem#3355, + // `server.rs::is_open_reward_chain_read`) — a caller-supplied `launcher_id` is never + // the limiter's key, only the source is. Some(Method::GetRewardDistributor) => { let params = req.get("params").cloned().unwrap_or(json!({})); let launcher_id = match parse_launcher_id_arg(¶ms) { @@ -1095,8 +1100,11 @@ impl RpcDispatch for Node { }; return json!({"jsonrpc":"2.0","id":id,"result": result}); } - // dig.listRewardDistributors (dig_ecosystem#3269 unit 2, SPEC §2.6) — CONTROL plane, - // same guard shape as the other reward handlers above. Two independently-consulted + // dig.listRewardDistributors (dig_ecosystem#3269 unit 2, SPEC §2.6) — `Tier::Control` + // = local dispatch only, never the mTLS peer surface (`reward_methods_tier_guard.rs`); + // token-GATED on `POST /` at `server.rs::is_node_local_reward_read` (master or paired + // token, `-32030`; dig_ecosystem#3352) because it volunteers node-local state; the + // in-process FFI path stays open like `cache.*`. Two independently-consulted // halves (`funded` / `claimable`), each a `Half` — SPEC §12.5 // clause 6's "reassuring zero" rule applies to EACH half separately. // @@ -1177,8 +1185,11 @@ impl RpcDispatch for Node { return json!({"jsonrpc":"2.0","id":id,"result": result}); } // dig.getPayeeRewardClaimStatus (dig_ecosystem#3268/#3269 unit 3, SPEC §12.5) — - // CONTROL plane: loopback admin / in-process FFI ONLY, absent from - // `is_peer_reachable_method` (`reward_methods_tier_guard.rs` fails closed on that). + // `Tier::Control` = local dispatch only, never the mTLS peer surface + // (`reward_methods_tier_guard.rs`); token-GATED on `POST /` at + // `server.rs::is_node_local_reward_read` (master or paired token, `-32030`; + // dig_ecosystem#3352) because it volunteers node-local state; the in-process FFI path + // stays open like `cache.*`. // Dispatched through `Method::from_name(..)` like every other reward method — never // the string pre-match above the enum, which bypasses this tier guard entirely // (dig_ecosystem#3261: a reward RPC reachable by a peer is a money hole). diff --git a/crates/dig-node-service/src/meta.rs b/crates/dig-node-service/src/meta.rs index 8e432c48..7dce128a 100644 --- a/crates/dig-node-service/src/meta.rs +++ b/crates/dig-node-service/src/meta.rs @@ -154,7 +154,7 @@ pub fn methods() -> &'static [MethodInfo] { name: "cache.listCached", served: "local", summary: "List cached capsules (storeId:rootHash).", - requires_auth: false, + requires_auth: true, }, MethodInfo { name: "cache.removeCached", @@ -166,7 +166,7 @@ pub fn methods() -> &'static [MethodInfo] { name: "cache.fetchAndCache", served: "local", summary: "Pre-fetch and cache a capsule.", - requires_auth: false, + requires_auth: true, }, MethodInfo { // #1476: the publish→seed push. Local-only by default; the HTTP surface adds a control-token @@ -181,7 +181,7 @@ pub fn methods() -> &'static [MethodInfo] { summary: "Push a freshly-committed capsule's bytes to seed this node as a holder \ (control-token gated over loopback; §21.9 authorized-writer signature when \ DIG_NODE_PUSH_OPEN=true).", - requires_auth: false, + requires_auth: true, }, MethodInfo { name: "cache.stats", @@ -601,7 +601,7 @@ pub fn methods() -> &'static [MethodInfo] { (base64 48-byte BLS G1 sealing key), peer_id (64-hex gossip target), \ envelope (base64 opaque DIGCHAT1) }; result { message_id (64-hex) }. \ recipient_pub + peer_id are app-supplied pending the key directory.", - requires_auth: false, + requires_auth: true, }, MethodInfo { name: "chat.poll", @@ -609,6 +609,53 @@ pub fn methods() -> &'static [MethodInfo] { summary: "Drain the node's inbound chat inbox. No params; result { messages: \ [{ sender_did (64-hex), message_id (64-hex), envelope (base64 opaque \ DIGCHAT1) }] } in arrival order.", + requires_auth: true, + }, + // -- reward reads (dig_ecosystem#3352 / #3355) — the three NODE-LOCAL reads volunteer this + // node's own prover inventory, funded-distributor set or payee claim state and are + // token-gated (`requires_http_token`); the two chain-keyed reads are OPEN (#3351) and + // rate-bounded per source at ingress instead (`-32034`). ------------------------------ + MethodInfo { + name: "dig.getRewardProverStatus", + served: "local", + summary: "This node's own reward-prover-loop status registry: { statuses }. \ + NODE-LOCAL — volunteers this node's own prover inventory — so it requires \ + the local control token or a paired token (dig_ecosystem#3352).", + requires_auth: true, + }, + MethodInfo { + name: "dig.listRewardDistributors", + served: "local", + summary: "This node's own funded/claimable reward-distributor identity sets: \ + { funded, claimable }. NODE-LOCAL — volunteers this node's own funded-\ + distributor set — so it requires the local control token or a paired token \ + (dig_ecosystem#3352).", + requires_auth: true, + }, + MethodInfo { + name: "dig.getPayeeRewardClaimStatus", + served: "local", + summary: "This node's own payee-side reward claim status. NODE-LOCAL — volunteers this \ + node's own claim state — so it requires the local control token or a paired \ + token (dig_ecosystem#3352).", + requires_auth: true, + }, + MethodInfo { + name: "dig.getRewardDistributor", + served: "local", + summary: "One reward distributor's chain-derived report for a caller-supplied \ + launcher_id. OPEN (dig_ecosystem#3351): the subject arrives in the request, \ + so no node-local association is disclosed. Rate-bounded per source at \ + ingress (`-32034 REWARD_INGRESS_LIMITED`, dig_ecosystem#3355).", + requires_auth: false, + }, + MethodInfo { + name: "dig.listRewardDistributorCommitments", + served: "local", + summary: "One reward distributor's clawback commitment slots for a caller-supplied \ + launcher_id. OPEN (dig_ecosystem#3351), same guard shape as \ + dig.getRewardDistributor: rate-bounded per source at ingress \ + (`-32034 REWARD_INGRESS_LIMITED`, dig_ecosystem#3355).", requires_auth: false, }, ] @@ -789,6 +836,16 @@ pub enum ErrorCode { /// leave the next person debugging a refusal unable to tell which bound they hit. Retriable: /// the caller should back off. Shell error (minted before dispatch). (Control range `-3203x`.) ControlIngressLimited, + /// `-32034` -- an OPEN reward chain read (`dig.getRewardDistributor` / + /// `dig.listRewardDistributorCommitments`) was refused AT INGRESS: this source's request + /// bound is exhausted (dig_ecosystem#3355). Each call is one upstream chain read for any + /// caller-supplied `launcher_id`, so without this bound an anonymous caller drives unbounded + /// upstream work. The bound is PER SOURCE (`RequestorId` -- never the launcher id, which the + /// caller controls); the loopback operator is EXEMPT, so only a `DIG_NODE_ALLOW_REMOTE=1` + /// caller ever sees it. Distinct from `-32033` (control-read ingress) and `-32043` + /// (wallet chain egress). Retriable: the caller should back off. Shell error (minted before + /// dispatch). (Control range `-3203x`.) + RewardIngressLimited, } /// The numeric code the shared wire contract assigns, widened to the `i64` the JSON-RPC @@ -838,6 +895,7 @@ impl ErrorCode { ErrorCode::PeerPingRefused => -32060, ErrorCode::PushPendingLimited => -32016, ErrorCode::ControlIngressLimited => -32033, + ErrorCode::RewardIngressLimited => -32034, } } @@ -872,6 +930,7 @@ impl ErrorCode { ErrorCode::PeerPingRefused => "PEER_PING_REFUSED", ErrorCode::PushPendingLimited => "PUSH_PENDING_LIMITED", ErrorCode::ControlIngressLimited => "CONTROL_INGRESS_LIMITED", + ErrorCode::RewardIngressLimited => "REWARD_INGRESS_LIMITED", } } @@ -889,6 +948,7 @@ impl ErrorCode { | ErrorCode::ControlError // Minted by the control SERVER at ingress, before the request reaches the node. | ErrorCode::ControlIngressLimited + | ErrorCode::RewardIngressLimited // The audit record is a node-private FILE read by the shell, not by the node. | ErrorCode::SpendAuditUnreadable // Minted by the shell's dispatch gate itself, before the read path is ever asked. @@ -991,6 +1051,11 @@ impl ErrorCode { "An open, token-less control read was refused at ingress: this source's request \ bound is exhausted. Distinct from WALLET_RATE_LIMITED, which bounds chain egress." } + ErrorCode::RewardIngressLimited => { + "An open reward chain read was refused at ingress: this source's request bound is \ + exhausted. Distinct from CONTROL_INGRESS_LIMITED (control reads) and \ + WALLET_RATE_LIMITED (chain egress)." + } } } @@ -1019,6 +1084,7 @@ impl ErrorCode { ErrorCode::PeerPingRefused, ErrorCode::PushPendingLimited, ErrorCode::ControlIngressLimited, + ErrorCode::RewardIngressLimited, ] } } @@ -1552,9 +1618,16 @@ mod tests { ); assert_eq!(m.served, "control", "{} must be served=control", m.name); } else { - assert!( - !m.requires_auth, - "non-control method {} must NOT require auth", + // Not every non-`control.*` method is a public read: the cache trio, + // the chat pair and the three node-local reward reads are gated on + // `POST /` (dig_ecosystem#3352, SPEC §5.5) though they carry no + // `control.` prefix. `requires_http_token` is the compiled predicate + // that actually enforces the gate, so the catalogue must equal it + // exactly rather than assume every non-control method is open. + assert_eq!( + m.requires_auth, + crate::server::requires_http_token(m.name), + "{} catalogued requires_auth must equal requires_http_token", m.name ); } diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index 8a291270..e8543ba3 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -119,6 +119,13 @@ pub struct AppState { /// re-implemented — it is already a per-[`RequestorId`] token-bucket registry with the /// identity-cycling table bound this needs. control_ingress: Arc, + /// The per-source INGRESS bound on the two OPEN, chain-keyed reward reads + /// (dig_ecosystem#3355): `dig.getRewardDistributor` / `dig.listRewardDistributorCommitments`. + /// + /// A SEPARATE bucket from [`AppState::control_ingress`] (see `is_open_reward_chain_read`'s + /// call site): sharing one bucket would let one client's rewards-pane polling refuse its own + /// unrelated lineage-walk reads (or vice versa) under a code that names the wrong bound. + reward_ingress: Arc, /// §25.8's bond observation, as the last mirror pass published it (dig-node#412 step 7). /// /// Held on the shared state rather than rebuilt per request precisely so the control surface @@ -148,6 +155,18 @@ const CONTROL_INGRESS_REFILL_PER_SEC: f64 = 8.0; /// lowering the burst should fail the BUILD, not wait for someone to run the right test. const _: () = assert!(CONTROL_INGRESS_BURST >= 12.0); +/// Per-source burst for the two OPEN, chain-keyed reward reads (dig_ecosystem#3355): +/// `dig.getRewardDistributor` / `dig.listRewardDistributorCommitments`. Sized identically to +/// [`CONTROL_INGRESS_BURST`] for the same reason — "one rewards pane, a handful of reads" — +/// on its OWN bucket (`AppState::reward_ingress`) so it cannot refuse or be refused by the +/// unrelated control-read bound. +const REWARD_INGRESS_BURST: f64 = 32.0; + +/// Sustained per-source rate for OPEN reward chain reads once the burst is spent. Matches +/// [`CONTROL_INGRESS_REFILL_PER_SEC`]: comfortably above a human-driven refresh, far below what +/// makes the upstream chain work matter. +const REWARD_INGRESS_REFILL_PER_SEC: f64 = 8.0; + /// dig-node's "method not found" error code. `handle_rpc` resolves only /// `dig.getContent` / `dig.getAnchoredRoot` / `cache.*` and returns this for /// anything else; this service treats that as the cue to blind-passthrough the @@ -599,6 +618,10 @@ pub async fn build_state(config: &Config) -> AppState { CONTROL_INGRESS_BURST, CONTROL_INGRESS_REFILL_PER_SEC, )), + reward_ingress: Arc::new(dig_node_core::rate_limit::MissRateLimiter::new( + REWARD_INGRESS_BURST, + REWARD_INGRESS_REFILL_PER_SEC, + )), } } @@ -1259,9 +1282,20 @@ async fn rpc( // it never reaches this HTTP `rpc` handler. Anonymous public CONTENT reads remain ungated; only // these holder-/holdings-revealing methods are gated. (WS parity: `cache.*` is not routable over // `/ws` — the wallet-backend fall-through has no `cache.*` arm — asserted in the server tests.) + // + // FOLDED IN (dig_ecosystem#3352): the three NODE-LOCAL reward reads (`is_node_local_reward_read`) + // — `dig.getRewardProverStatus` (this node's own prover-inventory registry), `dig.listRewardDistributors` + // (this node's own funded-distributor identity set), and `dig.getPayeeRewardClaimStatus` (this + // node's own payee claim state) — join the SAME gate rather than duplicating the token-extraction + // block, because they fail the same WHO-NAMES-THE-SUBJECT test §7.2 already applies here: each + // volunteers a node-to-launcher-id or node-to-inventory ASSOCIATION nobody supplied, exactly the + // shape `cache.listCached` is gated for. `requires_http_token` is the single pure predicate this + // union compiles to; `openrpc_drift_guard`'s `served_classes_are_well_formed` pins the catalogue's + // `requires_auth` to it BY EQUALITY. if method == "cache.fetchAndCache" || method == "cache.pushCapsule" || method == "cache.listCached" + || is_node_local_reward_read(&method) { let header_tok = headers .get(control::CONTROL_TOKEN_HEADER) @@ -1281,17 +1315,46 @@ async fn rpc( Json(rpc_error( id, ErrorCode::Unauthorized, - "cache.fetchAndCache / cache.pushCapsule / cache.listCached require the local \ - control token (X-Dig-Control-Token header or params._control_token) or a paired \ - controller token (see `dig-node pair`): fetchAndCache/pushCapsule make this node \ - a durable DHT holder of the requested capsule, and listCached enumerates the \ - operator's cached-capsule inventory (deanonymizing consumed content) — none is a \ - public read", + "cache.fetchAndCache / cache.pushCapsule / cache.listCached / \ + dig.getRewardProverStatus / dig.listRewardDistributors / \ + dig.getPayeeRewardClaimStatus require the local control token \ + (X-Dig-Control-Token header or params._control_token) or a paired controller \ + token (see `dig-node pair`): fetchAndCache/pushCapsule make this node a durable \ + DHT holder of the requested capsule, listCached enumerates the operator's \ + cached-capsule inventory (deanonymizing consumed content), and the three reward \ + reads volunteer a node-to-launcher-id/inventory association nobody supplied — \ + none is a public read", )), ); } } + // OPEN reward-chain INGRESS bound (dig_ecosystem#3355): `dig.getRewardDistributor` and + // `dig.listRewardDistributorCommitments` stay OPEN (dig_ecosystem#3351) — each is one chain read + // for ANY caller-supplied `launcher_id` — so, exactly like the token-less `control.*` reads above, + // an anonymous caller could otherwise drive unbounded upstream chain work simply by asking, + // repeatedly, for free. Bounded PER SOURCE (`RequestorId`, never `launcher_id` — the caller + // supplies and controls that value, so a limiter keyed on it is a DoS primitive an attacker + // rotates around) on a SEPARATE bucket from `control_ingress`: sized for "one rewards pane, a + // handful of reads", not shared with the lineage-walk bound, so one client's rewards polling can + // never refuse its own unrelated wallet reads with a code that says the wrong bound fired. The + // loopback operator is exempt, identically to `control_ingress` (`control_ingress_admits` is + // already generic over the limiter). + if is_open_reward_chain_read(&method) + && !control_ingress_admits(&state.reward_ingress, &requestor) + { + return ( + StatusCode::OK, + Json(rpc_error( + id, + ErrorCode::RewardIngressLimited, + "open reward chain reads are rate-limited per source; back off and retry. This is \ + the INGRESS bound on requests to this node (per-source), distinct from \ + CONTROL_INGRESS_LIMITED (open control reads) and WALLET_RATE_LIMITED (chain-egress).", + )), + ); + } + // CHAT gate (F1, #1946): `chat.send` seals + BLS-signs a directed message as this node's OWN // 0x0010 identity, and `chat.poll` DRAINS the inbound inbox — both wield node-owned crypto/state, // so they require the control token exactly like `control.*` mutations. A loopback address alone @@ -1433,6 +1496,47 @@ fn is_gated_chat_method(method: &str) -> bool { matches!(method, "chat.send" | "chat.poll") } +/// The three NODE-LOCAL reward reads token-gated at HTTP ingress (dig_ecosystem#3352): each +/// volunteers a node-to-launcher-id or node-to-inventory ASSOCIATION nobody supplied — +/// `dig.getRewardProverStatus` (this node's own `reward_prover_statuses` registry), +/// `dig.listRewardDistributors` (this node's own `FundedDistributorRegistry`), and +/// `dig.getPayeeRewardClaimStatus` (this node's own payee-side claim state) — failing the same +/// WHO-NAMES-THE-SUBJECT test §7.2 already applies (SPEC §5.5). PURE. +fn is_node_local_reward_read(method: &str) -> bool { + matches!( + method, + "dig.getRewardProverStatus" + | "dig.listRewardDistributors" + | "dig.getPayeeRewardClaimStatus" + ) +} + +/// The two OPEN, chain-keyed reward reads rate-bounded per source at HTTP ingress +/// (dig_ecosystem#3355): `dig.getRewardDistributor` and `dig.listRewardDistributorCommitments`, each +/// one `RewardsChainPort::distributor_report` call for any caller-supplied `launcher_id`. Does NOT +/// include `dig.listRewardDistributors` — that read does N chain reads too, but it is token-GATED +/// per dig_ecosystem#3352, so a presented credential is already accountable for it, same as every +/// `control.*` method. PURE. +fn is_open_reward_chain_read(method: &str) -> bool { + matches!( + method, + "dig.getRewardDistributor" | "dig.listRewardDistributorCommitments" + ) +} + +/// Whether `method` requires the HTTP token gate (master control token OR a valid paired token) at +/// this `POST /` ingress — the COMPILED statement the catalogue's `MethodInfo::requires_auth` must +/// equal (`openrpc_drift_guard::served_classes_are_well_formed` pins the equality; see SPEC §5.5). +/// The union: the holder-/holdings-revealing `cache.*` landing trio, the node-owned-identity chat +/// pair, and the three node-local reward reads (dig_ecosystem#3352). PURE. +pub fn requires_http_token(method: &str) -> bool { + method == "cache.fetchAndCache" + || method == "cache.pushCapsule" + || method == "cache.listCached" + || is_gated_chat_method(method) + || is_node_local_reward_read(method) +} + /// Whether `token` authorizes a gated chat call (F1, #1946): the master control token (constant-time) /// OR a valid paired controller token — the same master-or-paired policy that gates `control.*` and /// the wallet surface. Fails CLOSED on an empty master (the in-memory CSPRNG-failure sentinel) so a @@ -3379,8 +3483,9 @@ fn spawn_collateral_census(chain: Arc) mod tests { use super::{ chat_call_authorized, control_ingress_admits, is_app_origin, is_gated_chat_method, - is_local_origin, peer_tier_status, provenance_for, read_origin_for, reflects_origin, - requestor_for, served_response, ws_token, ServeProvenance, StorePath, APP_ORIGINS_ENV, + is_local_origin, is_node_local_reward_read, is_open_reward_chain_read, peer_tier_status, + provenance_for, read_origin_for, reflects_origin, requestor_for, requires_http_token, + served_response, ws_token, ServeProvenance, StorePath, APP_ORIGINS_ENV, EXPOSED_DIG_HEADERS, }; use axum::http::{HeaderMap, Method}; @@ -3520,6 +3625,118 @@ mod tests { ); } + /// **Proves (dig_ecosystem#3355):** the two OPEN chain-keyed reward reads + /// (`dig.getRewardDistributor`, `dig.listRewardDistributorCommitments`) are rate-bounded PER + /// SOURCE, never per `launcher_id` — the caller supplies and controls `launcher_id`, so a + /// limiter keyed on it would be a DoS primitive an attacker rotates around for free. + /// + /// Drives the predicate/limiter pair directly, exactly as + /// `an_anonymous_flood_is_refused_at_ingress_once_its_burst_is_spent` does for the sibling + /// `control_ingress` bound: a real HTTP test can't drive a non-loopback `RequestorId` (the test + /// client IS the loopback operator), so the meaningful assertion lives here, at the pair the + /// HTTP gate calls. + /// + /// **Catches (M3):** a limiter keyed on `launcher_id` instead of `RequestorId` — the same + /// requestor rotating which `launcher_id` it names in `params` must stay refused once its own + /// budget is spent; a different SOURCE must never be touched by another source's burst. + #[test] + fn open_reward_chain_reads_are_rate_bounded_per_source() { + let limiter = MissRateLimiter::new(32.0, 8.0); + let source_a = anon("198.51.100.7"); + let source_b = anon("198.51.100.8"); + + for i in 0..32 { + assert!( + control_ingress_admits(&limiter, &source_a), + "call {i} is within the 32-burst and must be admitted" + ); + } + assert!( + !control_ingress_admits(&limiter, &source_a), + "the 33rd call from the same source must be refused: its burst is spent" + ); + assert!( + control_ingress_admits(&limiter, &source_b), + "a different source draws from its own bucket and is untouched by source_a's burst" + ); + // M3: the SAME requestor rotating which `launcher_id` it names must stay refused — the + // limiter is keyed on the connection's `RequestorId`, never on caller-supplied request + // content, so nothing about the (unmodelled here) launcher_id can revive its budget. + assert!( + !control_ingress_admits(&limiter, &source_a), + "the same source must stay refused regardless of what launcher_id it names in params" + ); + } + + /// Pins the two OPEN reward reads and no others into `is_open_reward_chain_read` — the + /// predicate the ingress bound gates on. `dig.listRewardDistributors` does N chain reads too, + /// but it is token-GATED per dig_ecosystem#3352 (a presented credential is accountable, same as + /// every `control.*` method), so it must NOT be in this OPEN, per-source-bounded set. + #[test] + fn is_open_reward_chain_read_is_exactly_the_two_open_reads() { + assert!(is_open_reward_chain_read("dig.getRewardDistributor")); + assert!(is_open_reward_chain_read( + "dig.listRewardDistributorCommitments" + )); + assert!( + !is_open_reward_chain_read("dig.listRewardDistributors"), + "listRewardDistributors is token-gated per #3352, not ingress-bounded" + ); + assert!(!is_open_reward_chain_read("dig.getRewardProverStatus")); + assert!(!is_open_reward_chain_read("dig.getPayeeRewardClaimStatus")); + assert!(!is_open_reward_chain_read("dig.getContent")); + } + + /// Pins the three NODE-LOCAL reward reads (and no others) into `is_node_local_reward_read` — + /// the predicate the HTTP token gate folds in beside the `cache.*` trio and the chat pair. + #[test] + fn is_node_local_reward_read_is_exactly_the_three_gated_reads() { + assert!(is_node_local_reward_read("dig.getRewardProverStatus")); + assert!(is_node_local_reward_read("dig.listRewardDistributors")); + assert!(is_node_local_reward_read("dig.getPayeeRewardClaimStatus")); + assert!(!is_node_local_reward_read("dig.getRewardDistributor")); + assert!(!is_node_local_reward_read( + "dig.listRewardDistributorCommitments" + )); + assert!(!is_node_local_reward_read("dig.getContent")); + } + + /// **Proves:** `requires_http_token` is exactly the union of the cache-trio landing gate, the + /// gated chat pair, and the three node-local reward reads — the single pure predicate the + /// `openrpc_drift_guard` equality test (`served_classes_are_well_formed`) pins the catalogue + /// against. + /// + /// **Catches (M1):** dropping `is_node_local_reward_read` from the union — the three reward + /// reads would stop requiring a token here while the catalogue still says they do, and the + /// drift-guard equality test (not this one) is what actually trips on that; this test pins the + /// union's OWN membership so a future edit to the fold does not silently narrow it. + #[test] + fn requires_http_token_is_the_gate_plus_reward_reads_union() { + for m in [ + "cache.fetchAndCache", + "cache.pushCapsule", + "cache.listCached", + "chat.send", + "chat.poll", + "dig.getRewardProverStatus", + "dig.listRewardDistributors", + "dig.getPayeeRewardClaimStatus", + ] { + assert!(requires_http_token(m), "{m} must require the HTTP token"); + } + for m in [ + "dig.getRewardDistributor", + "dig.listRewardDistributorCommitments", + "dig.getContent", + "control.status", + ] { + assert!( + !requires_http_token(m), + "{m} must not require the HTTP token via this predicate" + ); + } + } + /// **Regression (#1763):** the `X-Dig-Peer-Tier` wire value for BOTH tiers, asserted on the real /// response builder rather than on the enum alone. /// diff --git a/crates/dig-node-service/tests/openrpc_drift_guard.rs b/crates/dig-node-service/tests/openrpc_drift_guard.rs index 5aa95e60..7412ddf4 100644 --- a/crates/dig-node-service/tests/openrpc_drift_guard.rs +++ b/crates/dig-node-service/tests/openrpc_drift_guard.rs @@ -272,9 +272,16 @@ fn control_peers_connect_disconnect_are_catalogued_and_not_peer_reachable() { fn served_classes_are_well_formed() { for m in meta::methods() { match m.served { - "local" | "passthrough" | "shell" => assert!( - !m.requires_auth, - "{} is a read/discovery method and must not require auth", + // `requires_auth` is the COMPILED statement of the HTTP token gate (SPEC §5.5): the + // set of catalogued methods with `requires_auth: true` MUST equal the set + // `server::requires_http_token` gates at `POST /` — not merely "false for every + // read", since dig_ecosystem#3352 token-gates a handful of non-`control.*` reads + // (the cache trio, the chat pair, the three node-local reward reads) that volunteer + // node-local state. + "local" | "passthrough" | "shell" => assert_eq!( + m.requires_auth, + dig_node_service::server::requires_http_token(m.name), + "{}: catalogued requires_auth must equal requires_http_token", m.name ), "control" => { diff --git a/crates/dig-node-service/tests/server.rs b/crates/dig-node-service/tests/server.rs index 92b63f44..7846a755 100644 --- a/crates/dig-node-service/tests/server.rs +++ b/crates/dig-node-service/tests/server.rs @@ -1535,17 +1535,18 @@ async fn cache_list_cached_is_not_routable_over_ws() { ); } -/// **Proves (dig_ecosystem#3351, WS parity):** `dig.getRewardDistributor` and -/// `dig.listRewardDistributorCommitments` are OPEN reads on the HTTP transport (no token required), -/// but that openness must not accidentally widen into a SECOND, WS-reachable path. The `ws_dispatch` -/// fall-through routes an unrecognized method to `WalletBackend::dispatch`, whose match has no -/// `dig.*` arm, so both methods come back as an unknown-method error over `/ws` -- never as -/// `UNAUTHORIZED` (that would mean WS gates them where HTTP does not, which is its own bug) and -/// never as a real result (that would mean the reward-chain answer leaked over an unaudited -/// transport). +/// **Proves (dig_ecosystem#3351/#3352, WS parity):** ALL FIVE reward reads -- the two OPEN, +/// chain-keyed reads (`dig.getRewardDistributor`, `dig.listRewardDistributorCommitments`) and the +/// three HTTP-token-gated, node-local reads (`dig.getRewardProverStatus`, +/// `dig.listRewardDistributors`, `dig.getPayeeRewardClaimStatus`) -- have no WS-reachable path at +/// all, regardless of which HTTP tier each carries. The `ws_dispatch` fall-through routes an +/// unrecognized method to `WalletBackend::dispatch`, whose match has no `dig.*` arm, so every one +/// comes back as an unknown-method error over `/ws` -- never as `UNAUTHORIZED` (that would mean WS +/// gates a method where HTTP does not, or vice versa, either of which is its own bug) and never as +/// a real result (that would mean a reward answer leaked over an unaudited transport). /// -/// **Catches:** a wallet-backend or `ws_dispatch` arm that starts routing `dig.*` reward reads over -/// `/ws` without the tier decision being revisited. +/// **Catches:** a wallet-backend or `ws_dispatch` arm that starts routing any `dig.*` reward read +/// over `/ws` without the tier decision being revisited. #[tokio::test] async fn reward_distributor_reads_are_not_routable_over_ws() { use tokio_tungstenite::tungstenite::Message; @@ -1560,11 +1561,15 @@ async fn reward_distributor_reads_are_not_routable_over_ws() { for (idx, method) in [ "dig.getRewardDistributor", "dig.listRewardDistributorCommitments", + "dig.getRewardProverStatus", + "dig.listRewardDistributors", + "dig.getPayeeRewardClaimStatus", ] .into_iter() .enumerate() { - // No token: these reads are OPEN on HTTP, but that has no bearing on WS routability. + // No token: these reads (OPEN or HTTP-token-gated) have no bearing on WS routability -- + // the WS transport simply never dispatches ANY reward method (dig_ecosystem#3352/#3355). ws.send(Message::Text( json!({ "id": format!("rd{idx}"), "type": "request", "method": method }).to_string(), )) @@ -3962,3 +3967,164 @@ async fn reward_distributor_reads_answer_on_post_slash_without_a_token() { ); } } + +/// Proves (dig_ecosystem#3352): the three NODE-LOCAL reward reads (`dig.getRewardProverStatus`, +/// `dig.listRewardDistributors`, `dig.getPayeeRewardClaimStatus`) are token-gated at the HTTP +/// `POST /` ingress exactly like the `cache.*` landing trio: an untokened call is `-32030 +/// UNAUTHORIZED` and the response body carries NONE of the fields the handler would otherwise +/// return (`statuses`/`funded`/`subject`) -- a demoted gate that still leaked the payload alongside +/// the error would defeat the whole point. The master control token AND a genuine paired token each +/// clear the gate and reach the real handler (proven by the handler-specific shape each answers +/// with on this ephemeral, chain-portless node). +/// +/// Catches: any of the three reads left OPEN (or newly gated but leaking a body on refusal), and a +/// paired-token caller wrongly excluded from a gate that (per #3352, unlike wallet mutations) is +/// master-OR-paired, not master-only. +#[tokio::test] +async fn node_local_reward_reads_require_the_control_token() { + let (upstream, _calls) = start_mock_upstream().await; + let (addr, master, _hold) = start_node_full(&upstream).await; + + // A genuine paired token, obtained exactly as the extension does (see + // `a_paired_token_cannot_grant_itself_a_trusted_chia_peer`). + let req = post_rpc( + &addr, + json!({ "jsonrpc": "2.0", "id": 1, "method": "pairing.request", + "params": { "client_name": "DIG Chrome Extension" } }), + None, + ) + .await; + let pairing_id = req["result"]["pairing_id"].as_str().unwrap().to_string(); + let approve = post_rpc( + &addr, + json!({ "jsonrpc": "2.0", "id": 2, "method": "control.pairing.approve", + "params": { "pairing_id": pairing_id } }), + Some(&master), + ) + .await; + assert_eq!(approve["result"]["approved"], json!(true)); + let paired = poll_pairing(&addr, &pairing_id).await["result"]["token"] + .as_str() + .unwrap() + .to_string(); + + let cases: &[(&str, &str)] = &[ + ("dig.getRewardProverStatus", "statuses"), + ("dig.listRewardDistributors", "funded"), + ("dig.getPayeeRewardClaimStatus", "subject"), + ]; + + for (method, leaked_field) in cases { + let body = json!({ "jsonrpc": "2.0", "id": 9, "method": method }); + + // No token: UNAUTHORIZED, and the payload never leaks alongside the refusal. + let rejected = post_rpc(&addr, body.clone(), None).await; + assert_eq!( + rejected["error"]["code"], + json!(-32030), + "{method} without a token must be -32030, got {rejected:?}" + ); + assert_eq!( + rejected["error"]["data"]["code"], + json!("UNAUTHORIZED"), + "{method} without a token must be UNAUTHORIZED, got {rejected:?}" + ); + assert!( + rejected + .pointer(&format!("/result/{leaked_field}")) + .is_none(), + "{method} must never leak /result/{leaked_field} on a rejected call, got {rejected:?}" + ); + + // Master control token: clears the gate, reaches the real handler. + let via_master = post_rpc(&addr, body.clone(), Some(&master)).await; + assert_ne!( + via_master["error"]["data"]["code"], + json!("UNAUTHORIZED"), + "{method} with the master control token must clear the gate, got {via_master:?}" + ); + + // Paired token: clears the gate too (master-OR-paired, same as the cache trio). + let via_paired = post_rpc(&addr, body, Some(&paired)).await; + assert_ne!( + via_paired["error"]["data"]["code"], + json!("UNAUTHORIZED"), + "{method} with a paired token must clear the gate, got {via_paired:?}" + ); + } + + // Handler-specific shape, proving dispatch (not a stub) answered each authorized call. + let prover = post_rpc( + &addr, + json!({ "jsonrpc": "2.0", "id": 10, "method": "dig.getRewardProverStatus" }), + Some(&master), + ) + .await; + assert_eq!( + prover["result"]["statuses"]["outcome"], + json!("consulted"), + "got {prover:?}" + ); + + let listed = post_rpc( + &addr, + json!({ "jsonrpc": "2.0", "id": 11, "method": "dig.listRewardDistributors" }), + Some(&master), + ) + .await; + // This ephemeral node never writes a funded-distributor registry record, so the honest + // answer is "nothing looked" (`not_consulted`), NEVER a "consulted, found nothing" that + // would be SPEC §12.5 clause 6's forbidden reassuring zero (`FundedDistributorsRead:: + // NotConfigured`, dig_ecosystem#3269 unit 2). This still proves dispatch reached the real + // handler (a stub or a gate leak would answer neither `funded` nor `claimable` at all). + assert_eq!( + listed["result"]["funded"]["outcome"], + json!("not_consulted"), + "got {listed:?}" + ); + assert_eq!( + listed["result"]["claimable"]["outcome"], + json!("not_consulted"), + "got {listed:?}" + ); + + let payee = post_rpc( + &addr, + json!({ "jsonrpc": "2.0", "id": 12, "method": "dig.getPayeeRewardClaimStatus" }), + Some(&master), + ) + .await; + assert_eq!(payee["result"]["subject"], json!("payee"), "got {payee:?}"); +} + +/// Proves (dig_ecosystem#3355): the loopback operator, driving the two OPEN chain-keyed reward +/// reads (`dig.getRewardDistributor`) well past the 32-call burst that bounds a per-source flood, is +/// NEVER refused with `REWARD_INGRESS_LIMITED` -- the ingress bound exempts `RequestorId::Local` +/// exactly as the existing `control_ingress` bound does (`control_ingress_admits`). The +/// non-exempt/anonymous side of this bound cannot be driven over a real loopback TCP connection (the +/// test client IS the operator), so it is pinned at the predicate/limiter level instead -- +/// `open_reward_chain_reads_are_rate_bounded_per_source` in `server.rs`'s own unit tests, beside the +/// sibling `control_ingress` tests it mirrors. +/// +/// Catches: an ingress bound wired onto the OPEN reward reads that forgets the loopback exemption, +/// reproducing the #3051 failure (a polling operator refused its own reads) one bound over. +#[tokio::test] +async fn open_reward_chain_reads_never_limit_the_loopback_operator() { + let (upstream, _calls) = start_mock_upstream().await; + let (addr, _hold) = start_node(&upstream).await; + let launcher_id = "11".repeat(32); + for i in 0..40 { + let resp = post_rpc( + &addr, + json!({ "jsonrpc": "2.0", "id": i, "method": "dig.getRewardDistributor", + "params": { "launcher_id": launcher_id } }), + None, + ) + .await; + assert_ne!( + resp["error"]["data"]["code"], + json!("REWARD_INGRESS_LIMITED"), + "call {i} from the loopback operator must never be -32034, got {resp:?}" + ); + } +}