Skip to content

Commit ad24450

Browse files
feat(server): local plaintext content-serve surface + local-first cache (#289, #290) (#19)
* feat(server): local plaintext content-serve surface + local-first cache (#289, #290) Add a NEW loopback-only HTTP surface, `GET /s/<storeId>[:<root>]/<path>`, that decrypts store content SERVER-SIDE and returns the real website to a same-machine browser — distinct from the blind-ciphertext JSON-RPC `POST /` (which stays blind for node<->node + mTLS node-class clients). A browser cannot present a client cert to get plaintext from the public gateway, so the trusted local node is the only place a plaintext read can legitimately happen; plaintext only ever crosses loopback. #289 — serve surface: - dig-node-core `content_serve`: `Node::serve_content_plaintext` resolves the chain-anchored root (pinned #127, fail-closed), derives `retrieval_key = SHA-256(canonical rootless URN)` (empty -> index.html), fetches ciphertext local-first -> peer -> public RPC, then verifies inclusion vs the anchored root and AES-256-GCM-SIV-decrypts, reusing the ONE digstore-core read-crypto every DIG client shares. A local module's DECOY for a missing key (proof does not fold to the anchored root) is treated as a miss and falls through, never a served/garbage result. `Node::manifest_paths` exposes the PublicManifest paths for the SPA decision. Plus `derive_retrieval_key` + `ServeSource`/`PlaintextOutcome`. - dig-node-service `content` (pure helpers): `/s/...` route parsing, store-root `<base>` + `<meta referrer=same-origin>` injection, Referer rerooting of root-absolute subresources, the ecosystem ext->MIME map + `is_static_asset_path` classifier (ported from the on.dig.net loader), and the hardened store CSP. - server: `GET /s/*path` + a fallback that reroots via `Referer`; SPA history-fallback (route miss -> the store's index.html, known-file/asset miss -> honest 404, #144 MIME rule); content-type + nosniff; store CSP on HTML; and per-serve `X-Dig-Verified` / `X-Dig-Root` / `X-Dig-Source` provenance headers (#292). #290 — local-first cache: a synced+verified .dig serves local with no network (the default once cached); an unsynced read serves the immediate resource from the peer/RPC tier AND triggers the single-flight background whole-.dig sync-down (`maybe_backfill_capsule`, chain-anchored-root pinned) so the next read is local. Freshness is inherent to the anchored-root pin (a stale generation is never served as current). `X-Dig-Source` reflects the tier that served the main resource. Verified: `cargo fmt --all --check`, `cargo clippy --all-targets --locked -D warnings` (all crates), and the full workspace test suite (0 failed) — including new unit tests (verify/decrypt round-trip + tamper/wrong-root/wrong-key fail-closed, retrieval-key derivation, route parse, Referer reroot, content-type, base injection) and end-to-end HTTP integration tests (decrypted HTML with injected base + headers, JS asset, SPA route fallback, asset 404, Referer reroot). Blast radius: purely ADDITIVE. New `content_serve` module + two `Node` methods in dig-node-core (no existing symbol changed); new `content` module + additive routes (`/s/*path` + fallback) in the service `router()` (the only modified existing symbol; `POST /` / `/health` / discovery / control unchanged). No `.dig` format or wire change (store-format read via the existing digstore libs; §5.1 preserved). Deps: added digstore-stage/core/crypto + tempfile as dig-node-service DEV-deps (the integration fixture) at the same rev as dig-node-core. Bump: minor (0.14.0 -> 0.15.0 workspace; dig-node-core 0.3.0 -> 0.4.0) — a new backwards-compatible capability (feat), no breaking change. Closes #289 Closes #290 Co-Authored-By: Claude <noreply@anthropic.com> * fix(server): use `?` for the Referer path parse (clippy question_mark) The newer stable clippy CI runs flags the `match Some/None -> return None` pattern in `reroot_via_referer` under `-D clippy::question_mark`; rewrite it to the `?` operator (identical behavior, covered by the existing reroot tests). Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent dd2d746 commit ad24450

11 files changed

Lines changed: 1681 additions & 7 deletions

File tree

Cargo.lock

Lines changed: 6 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ edition = "2021"
3131
# (`[workspace.package].version`), so it MUST be set here for a release to fire
3232
# (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) keep their own
3333
# independent versions — only the released binary tracks the workspace version.
34-
version = "0.14.0"
34+
version = "0.15.0"
3535

3636
# Release hardening, matching digstore: keep integer-overflow checks ON in release.
3737
# The node parses untrusted serialized input and does offset/length arithmetic over

SPEC.md

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,10 @@ NOT run this in-process node — it is a pure RPC consumer of an EXTERNAL node o
6767
Chrome extension's `fetchContentViaRPC` pipeline) MUST work against this node unchanged. Verification and
6868
decryption happen in the **client** — for the DIG Browser via the native read-crypto FFI (§15.1), for
6969
webpages via the equivalent `dig-client-wasm`; the node serves blind ciphertext + proofs and MUST NOT
70-
return plaintext for content reads.
70+
return plaintext for content reads. The ONE exception is the loopback-only local plaintext
71+
content-serve surface (§4.6) — a DISTINCT HTTP surface from this JSON-RPC read plane — which decrypts
72+
SERVER-SIDE for a same-machine browser over loopback; the JSON-RPC `POST /` read plane, `rpc.dig.net`,
73+
and every peer surface stay blind ciphertext + proof.
7174

7275
1.4. **Canonical RPC interface — `dig-rpc-types` + `dig-rpc`.** The RPC surface this node exposes
7376
(method names + request/response types, the error-code taxonomy, and the tier classification) is
@@ -294,6 +297,8 @@ method/header-allow behavior above.
294297
| `/openrpc.json` | GET | The OpenRPC document (§6.3). |
295298
| `/.well-known/dig-node.json` | GET | The discovery document (§6.4). |
296299
| `/ws/status` | GET (WS upgrade) | WebSocket status/liveness channel (§4.5). |
300+
| `/s/<storeId>[:<root>]/<path>` | GET | Local plaintext content-serve — server-side decrypt (§4.6). |
301+
| *(fallback)* | GET | Root-absolute subresource rerooted via `Referer` into its store (§4.6). |
297302

298303
### 4.5. `GET /ws/status` — WebSocket status/liveness channel (#239)
299304

@@ -346,6 +351,71 @@ reconnect) even if the socket's `readyState` still reports open. A client SHOULD
346351
exponential backoff + jitter on any close/error and reset that backoff the moment a connection
347352
succeeds again.
348353

354+
### 4.6. Local plaintext content-serve — `GET /s/<storeId>[:<root>]/<path>` (#289/#290)
355+
356+
A same-machine browser cannot present a client cert to obtain plaintext from the public gateway
357+
(§5.3), so the LOCAL node — the trusted, key-holding, loopback-only endpoint — exposes a DISTINCT
358+
HTTP surface that decrypts SERVER-SIDE and returns the real website. This is separate from the blind
359+
JSON-RPC `POST /` read plane (§1.3, §5): plaintext crosses ONLY loopback; `rpc.dig.net` and peers
360+
stay ciphertext-only.
361+
362+
**Route.** `GET /s/<storeId>[:<root>]/<path>` on every loopback listener (§4.1: `localhost:<port>`,
363+
`[::1]:<port>`, and bare `http://dig.local`). `<storeId>` and the optional `<root>` are 64-hex; a
364+
bare `/s/<storeId>[:<root>]/` (empty `<path>`) serves the store's default view `index.html`
365+
(`DEFAULT_RESOURCE_KEY`). The Host allowlist (§4.2) + CORS (§4.3) answer only loopback names, so this
366+
surface is never reachable off-machine.
367+
368+
**Resolution + verify + decrypt (fail-closed).** For `(storeId, path)` the node:
369+
1. resolves `path``retrieval_key = SHA-256(canonical rootless URN)` (`urn:dig:chia:<storeId>[/<path>]`,
370+
empty → `index.html`) — byte-identical to `dig-client-wasm`/`dig-runtime`;
371+
2. resolves the store's chain-anchored tip root and PINS the serve to it (§14.4, #127) — a requested
372+
root that is not the tip, an unconfirmable store, or an unreachable chain fails closed;
373+
3. fetches the resource's ciphertext + inclusion proof + chunk lengths LOCAL-FIRST, then peer, then the
374+
public RPC (§4.6 cache order below);
375+
4. verifies `resource_leaf(ciphertext) == proof.leaf`, `proof.verify()`, and `proof.root ==
376+
chain_anchored_root`, THEN AES-256-GCM-SIV-decrypts each chunk under the per-URN key — the SAME
377+
`digstore-core` read-crypto every DIG client uses. A tampered chunk, decoy, or non-anchored root
378+
never decrypts.
379+
380+
**Store-root scoping (shared-origin best-effort).** Served HTML is rewritten with an injected
381+
`<base href="/s/<storeId>[:<root>]/">` (RELATIVE links resolve within the store) and
382+
`<meta name="referrer" content="same-origin">`. A ROOT-ABSOLUTE `/foo` request (the browser drops the
383+
`/s/...` prefix) lands in the router fallback and is REROOTED via the same-origin `Referer` back into
384+
its store; an unattributable root-absolute request is a `404` (asset) or the SPA fallback (route).
385+
Absolute `https://…` URLs bypass the node entirely.
386+
387+
**SPA history-fallback + MIME rule (#144).** A route-like miss (`path` whose final segment has NO known
388+
static-asset extension) serves the store's `index.html` (`200 text/html`) so a client-side deep link
389+
boots. The node uses the store's `PublicManifest` (§5.5.1) to distinguish a KNOWN file genuinely missing
390+
at this root (an honest `404`) from a route (the SPA fallback); a null manifest (old/private store)
391+
degrades to the extension-less-path heuristic. An ASSET miss (a known non-HTML extension —
392+
`js`/`mjs`/`css`/`json`/`wasm`/`svg`/images/fonts/media/…) is ALWAYS an honest `404`, never `text/html`
393+
(a `text/html` body for a service-worker/module fetch is rejected by the browser for a wrong MIME type).
394+
395+
**Content-type + CSP.** The `Content-Type` is the ecosystem extension→MIME map (byte-identical to the
396+
DIG loader's `contentType()`), with `X-Content-Type-Options: nosniff`. Served HTML additionally carries
397+
a synthesized hardened store CSP (`object-src 'none'`, same-origin `base-uri`, un-framed, with the
398+
sanctioned content network legs) attached as a response header, never trusted from the store body.
399+
400+
**Provenance headers (every serve, #292).** `X-Dig-Verified: true|false` (inclusion + chain-anchored-root
401+
verified server-side — `false` only when the node-side pin is disabled via `DIG_NODE_PIN=off`),
402+
`X-Dig-Root: <root>` (the resolved root served against), and `X-Dig-Source: local|peer|rpc` (the tier
403+
that served the MAIN resource). A consumer's DIG Shields / toolbar reads these.
404+
405+
**Local-first store cache (#290).** Resolution order per `(store, root)`:
406+
1. a synced+verified `.dig` module on disk → serve LOCAL, no network (the DEFAULT once cached);
407+
2. not held → serve the immediate resource from a peer / the public RPC AND trigger a single-flight
408+
background whole-`.dig` sync-down (the deduped `maybe_backfill_capsule` → chain-anchored-root-pinned
409+
whole-store pull) into the reserved LRU cache dir, so the NEXT read is local. LRU eviction (§7.10)
410+
applies; an evicted-then-re-requested capsule re-syncs. Freshness is inherent to the anchored-root
411+
pin (§14.4): a stale locally-cached generation whose root is not the on-chain tip is NEVER served as
412+
current — the read resolves the tip and fetches/backfills that generation, so local-default is never
413+
local-FROZEN. A synced `.dig` is trusted only after it verifies against the on-chain root at serve.
414+
415+
**Salt.** A private store's secret salt is not yet provisioned to this surface; a private store therefore
416+
fails closed at decrypt. Public stores (salt = none) serve fully. (Private-store salt provisioning is a
417+
tracked follow-up.)
418+
349419
---
350420

351421
## 5. JSON-RPC surface (read plane)

crates/dig-node-core/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "dig-node-core"
3-
version = "0.3.0"
3+
version = "0.4.0"
44
edition = "2021"
55
license = "GPL-2.0-only"
66
description = "The canonical DIG node ENGINE library (crate `dig_node_core`): the JSON-RPC dispatch (`handle_rpc`, the same contract as rpc.dig.net), local-first content serve/fetch/redirect from LOCAL .dig store modules (via digstore_host::serve_blind), chain-anchored-root resolution, chain-watch + subscriptions + generation gap-fill, the LRU cache, and the full P2P stack. Shared UNCHANGED by both host shells: the `dig-node` OS-service binary (dig-node-service) and the DIG Browser's in-process cdylib (dig-runtime). Native Rust so the compiled-module serve path works."

0 commit comments

Comments
 (0)