Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions DEVELOPMENT_LOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
12 changes: 11 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
6 changes: 5 additions & 1 deletion crates/dig-node-core/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }`.
Expand Down
35 changes: 23 additions & 12 deletions crates/dig-node-service/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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",
)),
);
}
Expand Down
88 changes: 88 additions & 0 deletions crates/dig-node-service/tests/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down
Loading