Skip to content

Repository files navigation

dig-urn-resolver

Resolve a DIG URN to its data through the protocol, node-first. A Rust crate and a @dignetwork/dig-urn-resolver wasm/npm package. First consumer: Sage wallet NFT images.

Positioning — the canonical client-side URN resolver

This is THE canonical, project-wide client-side URN→data resolver (the #668 convergence: hub, the extension, dig-sdk, dig-dns and other consumers converge on it). It sits strictly upstream of dig-node: it is a client that talks to a dig-node over the wire (the node /s/ + /health surface, else the rpc.dig.net gateway) — dig-node does all the heavy lifting (store sync, serve, decrypt, chain anchoring) and never depends on this crate. Consume it from Rust (dig-urn-resolver) or JS/wasm (@dignetwork/dig-urn-resolver); do not reimplement URN resolution elsewhere.

Given a URN of the form

urn:dig:chia:<store_id>[:<root>]/<resource_key>[?salt=<hex>]

it returns the resource's bytes + content type, following the canonical §5.3 ladder — explicit override > dig.local > localhost:9778 > rpc.dig.net — using the first tier that responds:

  • node tierGET /s/<storeId>[:<root>]/<path>: a local dig-node decrypts + verifies server-side under a loopback trust boundary and returns plaintext.
  • rpc tierdig.getContent: a blind fetch of opaque ciphertext + inclusion proofs from the untrusted public gateway, verified against the chain-anchored root and decrypted client-side (fail-closed).

All read-crypto (URN canonicalization + retrieval-key derivation, merkle inclusion verify, AES-256-GCM-SIV open) is reused verbatim from digstore-core — the same functions the browser read-crypto and the on-chain crates share — so this crate can never skew from the canonical crypto.

Security invariants

  • Node trust is loopback-only. The crypto-free node /s/ path is trusted ONLY for an asserted-loopback host (127.0.0.0/8 / ::1 / localhost, or dig.local iff it resolves to loopback) — it is the user's own machine. ANY other host, including an explicit override pointed at a remote host, uses the client-VERIFIED rpc path. On the node path the response MUST carry X-Dig-Verified: true, else the bytes are rejected fail-closed. (Defeats a remote/override host and a LAN mDNS spoof of .local serving unverified bytes as trusted.)
  • The rpc trust root comes from the URN, not the gateway. Over the untrusted gateway only a root-pinned URN is accepted; a rootless URN is rejected (RootRequired) because its root would otherwise come from the same gateway serving the content. (Rootless is fine over a loopback node — the local node is the trust anchor. Root-pinned + private/salted stores are unaffected.)

Three outcomes, never conflated

Outcome Meaning Bytes
Success verified, decrypted content the real content
IntegrityFailure bytes fetched but failed merkle/decrypt verify (tampered / decoy / wrong root) — a hard, fail-closed security failure never the unverified bytes; renders a branded "Integrity Verification Failed" page
Unreachable every tier down; nothing fetched — friendly + retryable a branded "DIG Network unreachable" + Connect-to-Node page

A malformed URN, a not-found resource, and a reachable rpc protocol error are hard errors (ResolveError).

Rust

use dig_urn_resolver::{native, ResolveOutcome};

#[tokio::main]
async fn main() {
    match native::resolve("urn:dig:chia:<store>:<root>/img/logo.png").await.unwrap() {
        ResolveOutcome::Success(data) => { /* data.bytes, data.content_type */ }
        ResolveOutcome::IntegrityFailure => { /* security failure — do not show */ }
        ResolveOutcome::Unreachable => { /* network down — offer "connect a node" */ }
    }
}

Inject a custom transport (or an explicit endpoint) via Resolver::with_options.

Browser / Sage (@dignetwork/dig-urn-resolver)

The front-door API is the branded, well-typed DigNetwork client:

import init, { DigNetwork } from "@dignetwork/dig-urn-resolver";
await init();

const dig = new DigNetwork();                         // all defaults
// or pass an options object — set only what you need, no undefined placeholders:
// const dig = new DigNetwork({ endpoint, connectUrl, cachePath });

// The NFT-image case — a URL for <img src>, working with no dig-node running:
img.src = await dig.resolveImageUrl(nftDataUri);

// Or the typed form (real outcome + MIME, not an image):
const { outcome, bytes, contentType } = await dig.resolve(nftDataUri);
// outcome ∈ "success" | "integrity_failure" | "unreachable"

resolveImageUrl ALWAYS returns a usable <img> URL and never throws for a normal failure: the real verified image on success, else a branded DIG error image (embedded PNG data: URI) matching the failure — integrity failure, network unreachable, not found, invalid URN, or a generic error. On an integrity failure it is the STATIC branded placeholder — never the unverified bytes as the image. (The HTML error pages via resolve().render() stay for the webview/navigation case.) Low-level free functions (resolve, resolveObjectUrl) remain available.

Caching

URNs are content-addressed → immutable → cacheable, so results are cached as an additive layer in front of resolve that never weakens fail-closed:

  • Only VERIFIED Success bytes are cached — never an integrity/unreachable/ not-found failure (a cached failure would block recovery).
  • Keyed by the content-addressed identity storeId:root:resourceKey:salt with the CONCRETE resolved root — a root-pinned URN is immutable; a rootless URN is cached under the root the resolve produced (X-Dig-Root), never a stale URN→bytes mapping.
  • Memory (default, bounded LRU): process-trusted (only holds what this process verified this run) — a hit skips re-verification.
  • Disk (optional cachePath; native Rust AND Node.js): UNTRUSTED — it stores the verifiable artifacts (ciphertext + proof + chunk lengths), and a disk hit is re-verified against the URN's root before use, so a tampered on-disk file FAILS verification → IntegrityFailure (never served). Filenames are SHA-256(identity) (no path-traversal). Ignored clientside in the browser (no filesystem); the memory cache still applies there.

Persistent disk caching is a native (Rust) capability — pass a cache_path in ResolveOptions, and verified results survive across process runs (a long-lived on-disk cache under the given directory). Because URNs are immutable + content- addressed, a later run re-serves a cached asset without a network round-trip, and every disk hit is still re-verified against the URN's root before use:

use dig_urn_resolver::{native, ResolveOptions, ResolveOutcome};

#[tokio::main]
async fn main() {
    let options = ResolveOptions {
        cache_path: Some("/var/cache/dig-urn".into()), // long-lived disk cache
        ..Default::default()
    };
    match native::resolve_with("urn:dig:chia:<store>:<root>/img/logo.png", options)
        .await
        .unwrap()
    {
        ResolveOutcome::Success(data) => { /* served from disk on a later run */ }
        ResolveOutcome::IntegrityFailure => { /* re-verify failed — never served */ }
        ResolveOutcome::Unreachable => { /* network down — offer "connect a node" */ }
    }
}

In the @dignetwork/dig-urn-resolver wasm package the DigNetwork constructor takes an options object with the same cachePath field, and it is fully functional under Node.js — the Node build injects Node's fs, so verified results persist to the given directory and are re-verified on read, exactly like the native crate:

// Node.js service (CommonJS) — a persistent, cross-run disk cache
const { DigNetwork } = require("@dignetwork/dig-urn-resolver");

// Set ONLY the field you need — no undefined placeholders.
const dig = new DigNetwork({ cachePath: "/var/cache/dig-urn" });

// First run fetches + verifies; a later run (same cachePath) re-serves from disk.
const img = await dig.resolveImageUrl("urn:dig:chia:<store>:<root>/img/logo.png");

The options object accepts { endpoint?, connectUrl?, cachePath? } (all optional); the generated TypeScript exports a DigNetworkOptions interface with those named fields.

Clientside (browser) cachePath is a harmless no-op — a browser has no filesystem, so the wasm build never wires fs there and the bounded in-memory cache applies instead (a same-process hit still skips re-verification). The persistent disk cache is available to native Rust consumers AND to the Node.js build; only the browser falls back to memory-only.

Runs in the browser AND Node.js

The @dignetwork/dig-urn-resolver package is dual-target — one package that imports cleanly in both:

// Browser bundler (Vite / webpack) — ESM
import init, { DigNetwork } from "@dignetwork/dig-urn-resolver";
await init();

// Node.js service — CommonJS
const { DigNetwork } = require("@dignetwork/dig-urn-resolver");

The branded DigNetwork API, the three fail-closed outcomes, and the MIME are IDENTICAL in both; only env-specific plumbing differs, and it degrades gracefully — never a hard-fail in either environment:

  • Ladder — the dig.local/localhost /health probe works in Node; in a browser it's often CORS-blocked. The probe is caught/timed-out, so the ladder simply falls through to the verified rpc.dig.net tier (never to unverified bytes).
  • Cache — the disk cachePath works natively AND under Node.js (the Node build injects fs); in a browser there is no filesystem, so cachePath is a harmless no-op and the bounded in-memory cache applies.
  • Image URLresolveImageUrl returns a blob: URL where URL.createObjectURL exists (browser) and a data: URL otherwise (Node.js) — always usable, never throws.

Content type & the default view

contentType is present on every resolve result: the store's stored Content-Type on the node path, else inferred from the URN path extension / magic bytes. A bare-store or trailing-slash URN resolves to the §8.5 default view index.html.

CORS note for consuming apps (Sage/Tauri)

The /health + /s/ probes hit dig.local/localhost from the app origin and may be CORS-blocked in a desktop webview; the rpc.dig.net fallback (Access-Control- Allow-Origin: *) always works, so a resolve succeeds node-absent. Add the endpoints to the app's connect-src CSP. For the browser node path to read the verification attestation, the node must send Access-Control-Expose-Headers: X-Dig-Verified (a missing header fails closed) — see #669.

Example

cargo run --example sage_nft_image

Resolves a root-pinned NFT-image URN to displayable bytes with no dig-node running (rpc fallback), the exact byte path resolveObjectUrl wraps for <img src>.

License

GPL-2.0-only (inherited from the reused digstore-core read-crypto).

About

DX package: resolve a DIG URN to its data through the protocol (node-first: dig-node if running, else rpc.dig.net). Rust crate + wasm npm pkg. First consumer: Sage wallet NFT images. #662.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages