diff --git a/Cargo.lock b/Cargo.lock index 29a33d2..55553fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2295,7 +2295,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.100.0" +version = "0.100.1" dependencies = [ "async-trait", "axum", diff --git a/Cargo.toml b/Cargo.toml index b75088c..e8cea60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ edition = "2021" # the ROOT manifest (`[workspace.package].version`), so it MUST be set here for a # release to fire (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) # keep their own independent versions — only the released binary tracks the workspace version. -version = "0.100.0" +version = "0.100.1" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over diff --git a/DEVELOPMENT_LOG.md b/DEVELOPMENT_LOG.md index ace67ea..4f58eb3 100644 --- a/DEVELOPMENT_LOG.md +++ b/DEVELOPMENT_LOG.md @@ -4,6 +4,18 @@ High-signal realizations from debugging/development: non-obvious cross-system co sharp edges, and gotchas. Concise durable facts with context — NOT a change diary. See `CLAUDE.md` §4.5 for the maintenance contract (a curator periodically re-verifies + prunes). +## Local-RPC authz — holder-REVEALING reads gate too, not just mutators (#2108) + +Holder-revealing `cache.*` READS must be control-token-gated over the HTTP (loopback) surface, not +just the holder-MUTATING ones (`fetchAndCache`/`pushCapsule`). `cache.listCached` enumerates the +operator's cached-capsule inventory (storeId:rootHash, sizes, LRU order), deanonymizing consumed +content, so a cross-site page POSTing to `dig.local` (DNS-rebinding / local-service attack) could read +it. The gate lives at the transport (`server.rs` `rpc`), not the core dispatch handler. The #2032 +WS-parity lesson applies to reads too: verify the second (`/ws`) transport before declaring a method +gated — here `cache.*` is NOT WS-routable because `ws_dispatch`'s fall-through hits `WalletBackend:: +dispatch`, whose match has no `cache.*` arm (returns "unknown method"), so the HTTP gate is the only +reachable surface. FFI/in-process callers never reach the HTTP handler and stay open. + ## Authority validation is not memory backpressure — bound the reassembly state too (#2149) `cache.pushCapsule` reassembles chunked capsule uploads in a process-wide `HashMap<(cache_dir, diff --git a/README.md b/README.md index ec356e2..cf7ff5e 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,7 @@ in-process node expose: | `dig.getAnchoredRoot` | The store's **chain-anchored tip root**, resolved on-chain by walking the DataStore singleton lineage on coinset.org (the trusted root for the extension's `chia://` root-pinning). | | `dig.getManifest` | A capsule's (`storeId:rootHash`) embedded **normalized public manifest** (data-section id 13): the store's complete public file surface as of that commit — `{ schema_version, entries: [{ path, latest_root, generation_index, sha256_latest, version_count }] }`. Served **local-first** when this node holds the requested capsule. `null` (never an error) when the module carries no manifest section (an older `.dig`, or a private store); `-32004` when the capsule isn't held locally at all. | | `cache.getConfig` / `cache.setCapBytes` / `cache.clear` | On-disk cache config: `{ cap_bytes (floored at 64 MiB), used_bytes, cache_dir, shared }` — `cache_dir` is the effective dir and `shared` whether it is the canonical dir shared with the DIG Browser (#96). | -| `cache.listCached` / `cache.removeCached` / `cache.fetchAndCache` | Cached-capsule manager (`storeId:rootHash`). Over HTTP, `cache.fetchAndCache` is **local-token gated** (like `control.*`): it makes this node a durable DHT holder of the requested capsule, so it is not a public read. The in-process FFI `cache.*` path stays open. | +| `cache.listCached` / `cache.removeCached` / `cache.fetchAndCache` | Cached-capsule manager (`storeId:rootHash`). Over HTTP, `cache.fetchAndCache` is **local-token gated** (like `control.*`): it makes this node a durable DHT holder of the requested capsule, so it is not a public read. `cache.listCached` is likewise **local-token gated** over HTTP (#2108): it enumerates the operator's cached-capsule inventory, deanonymizing consumed content. The in-process FFI `cache.*` path stays open. | | `rpc.discover` | **Method discovery** — returns this node's OpenRPC document (the standard OpenRPC discovery method), so a client can introspect every method + error over the wire with no out-of-band knowledge. | | `control.*` | **CONTROL / admin surface** (loopback-only + **local-token gated** — see below). Manage the node: hosted/pinned stores, cache, §21 sync, config. Read methods above stay open; only `control.*` requires the token. | | `dig.health`, `dig.methods` | **Served locally by the shell** — this node's own liveness + method list, answered on its own authority with no upstream. | diff --git a/SPEC.md b/SPEC.md index d18adec..b7cd48b 100644 --- a/SPEC.md +++ b/SPEC.md @@ -5060,7 +5060,17 @@ address does not prove operator intent (a cross-site page can POST to `dig.local require the local control token (the `X-Dig-Control-Token` header or `params._control_token`) OR a valid paired controller token, exactly like a `control.*` method; an unauthorized call is rejected `UNAUTHORIZED` (-32030) before any landing work. The in-process FFI `cache.*` path is the operator's own -process and MUST stay open — it never traverses this HTTP handler. Reads remain ungated. +process and MUST stay open — it never traverses this HTTP handler. Anonymous public CONTENT reads remain +ungated. + +The same HTTP token-gate MUST also bind `cache.pushCapsule` (§5.5.3, the same holder side effect) and +`cache.listCached` (#2108). `cache.listCached` is a READ, but a HOLDINGS-revealing one: it enumerates the +operator's full cached-capsule inventory (`storeId:rootHash`, sizes, LRU order), which deanonymizes what +content the user has consumed. Over the loopback HTTP surface a cross-site page (DNS-rebinding / +local-service attack) could otherwise POST here and read it, so `cache.listCached` MUST require the same +control/paired token and is rejected `UNAUTHORIZED` (-32030) with no inventory in the body when +unauthorized. The FFI path stays open, and `cache.*` is not routable over the `/ws` transport (the +wallet-backend fall-through has no `cache.*` arm), so the HTTP gate is the only reachable surface. ### 21.10. Reverse-proxy trust caveat (informative) diff --git a/crates/dig-node-core/SPEC.md b/crates/dig-node-core/SPEC.md index 2b9fb4d..e091d4f 100644 --- a/crates/dig-node-core/SPEC.md +++ b/crates/dig-node-core/SPEC.md @@ -181,7 +181,11 @@ loopback admin / in-process FFI dispatch (`handle_rpc`). through it. - **`cache.clear`** → `{}`. - **`cache.listCached`** → the durable module inventory: `{ cached: [ { capsule: "storeId:rootHash", - store_id: 64hex, root: 64hex, size_bytes: u64, last_used_unix_ms: u64 } ] }` (§3, §6). + store_id: 64hex, root: 64hex, size_bytes: u64, last_used_unix_ms: u64 } ] }` (§3, §6). Over the HTTP + (loopback) surface this READ is **control-token gated** like `cache.fetchAndCache` (#2108): the + inventory reveals the operator's held capsules, deanonymizing consumed content, so an unauthorized + HTTP call is rejected `UNAUTHORIZED` (-32030) with no inventory in the body. The in-process FFI + `cache.*` path stays open — it never reaches the HTTP handler. - **`cache.removeCached`** `{ store_id: 64hex, root: 64hex }` → `{ removed: bool }`. Error `-32602`. - **`cache.fetchAndCache`** `{ store_id: 64hex, root: 64hex }` → `{ status: "cached"|"already_cached"|"failed", size_bytes?: u64, served_root?: 64hex, message?: string }`. diff --git a/crates/dig-node-service/src/server.rs b/crates/dig-node-service/src/server.rs index d191ce9..83c2aa7 100644 --- a/crates/dig-node-service/src/server.rs +++ b/crates/dig-node-service/src/server.rs @@ -980,14 +980,23 @@ async fn rpc( ); } - // LANDING gate (#1654/#1476): `cache.fetchAndCache` (fetch + cache + DHT-announce a capsule of the - // CALLER'S choosing) and `cache.pushCapsule` (accept + cache + DHT-announce capsule BYTES the caller - // supplies) both make this node a durable holder — the same holder side effect (SPEC §14.3/§21.3). - // Over the HTTP surface a loopback address does not prove the operator authorized the call (a - // cross-site page can POST to `dig.local`), so each requires the control token exactly like - // `control.*`: the master control token OR a valid paired token. The in-process FFI `cache.*` path - // stays open (SYSTEM.md) — it never reaches this HTTP `rpc` handler. Reads remain ungated. - if method == "cache.fetchAndCache" || method == "cache.pushCapsule" { + // LANDING gate (#1654/#1476/#2108): the holder-revealing `cache.*` methods over the HTTP surface. + // `cache.fetchAndCache` (fetch + cache + DHT-announce a capsule of the CALLER'S choosing) and + // `cache.pushCapsule` (accept + cache + DHT-announce capsule BYTES the caller supplies) both make + // this node a durable holder — the same holder side effect (SPEC §14.3/§21.3). `cache.listCached` + // ENUMERATES the operator's full cached-capsule inventory (storeId:rootHash, sizes, LRU order), + // which deanonymizes what content the user has consumed (#2108) — a read, but a HOLDINGS-revealing + // one, so it is gated identically. Over the HTTP surface a loopback address does not prove the + // operator authorized the call (a cross-site page can POST to `dig.local` — DNS-rebinding / + // local-service attack), so each requires the control token exactly like `control.*`: the master + // control token OR a valid paired token. The in-process FFI `cache.*` path stays open (SYSTEM.md) — + // 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.) + if method == "cache.fetchAndCache" + || method == "cache.pushCapsule" + || method == "cache.listCached" + { let header_tok = headers .get(control::CONTROL_TOKEN_HEADER) .and_then(|v| v.to_str().ok()); @@ -1006,10 +1015,12 @@ async fn rpc( Json(rpc_error( id, ErrorCode::Unauthorized, - "cache.fetchAndCache / cache.pushCapsule require the local control token \ - (X-Dig-Control-Token header or params._control_token) or a paired controller \ - token (see `dig-node pair`): each makes this node a durable DHT holder of the \ - requested capsule, so it is not a public read", + "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", )), ); } diff --git a/crates/dig-node-service/tests/server.rs b/crates/dig-node-service/tests/server.rs index 809885a..9791295 100644 --- a/crates/dig-node-service/tests/server.rs +++ b/crates/dig-node-service/tests/server.rs @@ -1283,6 +1283,94 @@ async fn cache_fetch_and_cache_over_http_requires_the_control_token() { ); } +/// **Proves (dig_ecosystem#2108):** `cache.listCached` over the HTTP `POST /` surface is +/// control-token-gated like the holder-mutating `cache.*` methods — an untokened call is +/// UNAUTHORIZED (-32030) and NEVER leaks the cached-capsule inventory (`result.cached`), while the +/// master control token gets PAST the gate and the inventory is returned. +/// +/// The method enumerates the operator's full cached-capsule inventory (storeId:rootHash, sizes, LRU +/// order), which deanonymizes what content the user has consumed; over the loopback HTTP surface a +/// cross-site page (DNS-rebinding / local-service attack) could otherwise POST here and read it. The +/// in-process FFI `cache.*` path (which never reaches this handler) stays open; anonymous public +/// CONTENT reads are unaffected. +#[tokio::test] +async fn cache_list_cached_over_http_requires_the_control_token() { + let (upstream, _calls) = start_mock_upstream().await; + let (addr, token, _hold) = start_companion_full(&upstream).await; + + let body = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "cache.listCached", + }); + + // Untokened → rejected at the gate before any enumeration; no inventory leaks. + let rejected = post_rpc(&addr, body.clone(), None).await; + assert_eq!(rejected["error"]["code"], json!(-32030)); + assert_eq!(rejected["error"]["data"]["code"], json!("UNAUTHORIZED")); + assert!( + rejected.get("result").is_none(), + "no result on a rejected enumeration" + ); + assert!( + rejected.pointer("/result/cached").is_none(), + "the cached-capsule inventory must NEVER be present on a rejected call, got {rejected:?}" + ); + + // With the master control token → PAST the gate: the inventory is returned. + let authorized = post_rpc(&addr, body, Some(&token)).await; + let is_unauthorized = authorized + .get("error") + .and_then(|e| e.get("data")) + .and_then(|d| d.get("code")) + .is_some_and(|c| c == &json!("UNAUTHORIZED")); + assert!( + !is_unauthorized, + "a control-token call must clear the gate, got {authorized:?}" + ); + assert!( + authorized["result"]["cached"].is_array(), + "an authorized call returns the cached array, got {authorized:?}" + ); +} + +/// **Proves (dig_ecosystem#2108, WS parity — the #2032 lesson for READS):** `cache.listCached` is +/// NOT routable over the `/ws` transport, so gating it at the HTTP transport is sufficient and there +/// is no second, ungated path that leaks the inventory. The WS `ws_dispatch` fall-through routes an +/// unrecognized method to the wallet backend (`WalletBackend::dispatch`), whose match has NO `cache.*` +/// arm — so `cache.listCached` returns the backend's "unknown method" error, never the inventory. +#[tokio::test] +async fn cache_list_cached_is_not_routable_over_ws() { + use tokio_tungstenite::tungstenite::Message; + let (upstream, _calls) = start_mock_upstream().await; + let (addr, token, _backend, _hold) = start_companion_wallet(&upstream).await; + + let (mut ws, _resp) = tokio_tungstenite::connect_async(format!("ws://{addr}/ws")) + .await + .expect("connect to /ws"); + let _ = next_ws_json(&mut ws).await; // drain the initial sync_status snapshot + + // Present the control token; even so, the WS transport has no route to the cache enumerator. + ws.send(Message::Text( + json!({ "id": "lc1", "type": "request", "method": "cache.listCached", "token": token }) + .to_string(), + )) + .await + .unwrap(); + let resp = next_ws_json(&mut ws).await; + assert_eq!(resp["id"], json!("lc1")); + assert_eq!(resp["type"], json!("response")); + assert_eq!( + resp["ok"], + json!(false), + "cache.listCached is not a WS method, got {resp:?}" + ); + assert!( + resp.pointer("/result/cached").is_none(), + "the cached-capsule inventory must NEVER be reachable over WS, got {resp:?}" + ); +} + /// **Proves (dig_ecosystem#1985):** `control.peers.ping` is REACHABLE over the real HTTP control /// surface — token-gated, registered, and routed to its own handler. ///