# dig-urn-resolver — normative specification
This document is the authoritative contract for `dig-urn-resolver`. An independent
reimplementation MUST behave as specified here. Where this spec references DIG read
semantics it defers to the canonical sources: the Digstore store format + read-crypto
(`digstore-core`), the client→node ladder (superproject CLAUDE.md §5.3), and the
dig-node read wire (`SYSTEM.md`).
## 1. Purpose
Resolve a DIG URN to the bytes + content type of the resource it names, verifying
integrity fail-closed, following the §5.3 node-first ladder.
This crate is INTENDED as the canonical, project-wide client-side URN→data resolver
(#668). Its consumers today are the Rust crate (`dig-node-service`) and the wasm/npm
package `@dignetwork/dig-urn-resolver` (consumed by `dig-web-resolver`); hub.dig.net,
the Chrome extension and `dig-sdk` parse URNs with their own implementations and
consume neither package. That convergence is tracked work, NOT a current invariant
(`dig_ecosystem#2725` / `#2753`); a new consumer MUST use this crate rather than add a
parser. It sits strictly
UPSTREAM of dig-node — a *client* that talks to a dig-node over the wire (node `/s/` +
`/health`, else the rpc gateway). dig-node performs all heavy lifting (sync, serve,
decrypt, chain anchoring) and MUST NOT depend on this crate. Consumers use the Rust
crate or `@dignetwork/dig-urn-resolver` (JS/wasm).
## 2. URN grammar
```
urn:dig:<chain>:<store_id>[:<root>][/<resource_key>][?salt=<hex>]
```
- `chain` — a non-empty label. `chia` is CANONICAL. `mainnet` and `testnet` MUST also be
accepted: `.dig` content is permanently on-chain-anchored, so a URN already published
under a non-canonical label MUST keep resolving. An EMPTY chain token is invalid.
- `store_id` — 64 hex chars (singleton launcher id). REQUIRED.
- `root` — OPTIONAL 64 hex chars pinning one on-chain generation. The root is the
trust anchor for inclusion verification ONLY; it is NOT a key input.
- `resource_key` — the path within the store, OPTIONAL. Three states are distinguished
and all three are valid: ABSENT (a bare store URN), EMPTY (a trailing slash), and a
concrete path. The first two resolve to the §8.5 default view `index.html`, and
therefore derive the SAME key — a bare-store or trailing-slash URN is never rejected.
- `?salt=<hex>` — OPTIONAL out-of-band private-store secret salt. NOT part of URN
identity, so it never enters `canonical()` (§3).
### 2.1 The two layers, and why the same string parses differently at each
The scheme has TWO parse layers. They are not rivals; comparing them as one is the
source of every reported grammar contradiction.
| **edge** | `ParsedUrn::parse` | split a USER-SUPPLIED string into `{urn, salt}` | PEELED off the tail |
| **canonical** | `dig_urn_protocol::DigUrn::parse` | URN identity + key derivation | NOT special — literal resource bytes |
A conforming implementation MUST peel the salt at the edge and MUST NOT peel it again at
the canonical layer. Consequently the two layers derive DIFFERENT keys for a string
carrying a `?salt=` tail, and identical keys for every string without one. The frozen
conformance corpus is a CANONICAL-layer corpus: its `input` column is an already-peeled
URN, which is why it pins `…/index.html?salt=deadbeef` as a resource path rather than a
salt.
### 2.2 Parse EXTRACTS, derive VALIDATES
The edge parser MUST accept any non-empty hex run as the salt and MUST NOT enforce a
length there. The 32-byte / 64-hex requirement is enforced where the salt becomes key
material, and a violation MUST surface as a coded, catchable error (`ResolveError::Parse`)
— never a panic and never an unhandled host exception. This is what makes an edge parser
that accepts `?salt=aaaa` and a validator that requires 64 hex both correct: they are
speaking about different layers.
Parsing MUST reuse the canonical `dig-urn-protocol` URN parser
(`DigUrn::parse_with_salt`) — the single source of truth for the grammar within this
crate, pinned by the frozen conformance corpus vendored at
`tests/fixtures/urn_conformance.json` (a byte-mirror of
`dig-urn-protocol/tests/fixtures/urn_conformance.json`; its vector rows are pinned by
SHA-256 digest so a local edit fails loudly instead of diverging silently). A
syntactically invalid URN MUST produce a hard parse error.
## 3. Keys (reused, root-independent)
- `retrieval_key = SHA-256(canonical_rootless_urn)`, where the canonical rootless URN
is `urn:dig:chia:<store_id>/<resource_key>` (root dropped). This is the ecosystem's
on-wire lookup key (the value the node indexes as `retrieval_key`), and it is
`dig_urn_protocol::DigUrn::content_key()` — NOT `DigUrn::retrieval_key()`, which is a
DIFFERENT, root-PINNED hash. Implementations MUST map the resolver's `retrieval_key`
to `content_key`.
- `decryption_key = digstore_core::crypto::derive_decryption_key(canonical_rootless_urn,
salt?)` (HKDF-SHA256, paper §11).
Both are root-independent so they are stable across generations. Implementations MUST NOT
reimplement these: the URN scheme + `content_key`/`retrieval_key` derivation come from
`dig-urn-protocol`, and the merkle codec/fold + symmetric read-crypto primitives come from
`digstore-core` (injected via `dig_urn_protocol::verify::ContentCrypto`, reused unchanged).
## 4. The §5.3 ladder — node trust is LOOPBACK-ONLY
Resolution order, first that responds wins:
1. explicit endpoint override (from options) — WINS, skips the ladder.
2. `http://dig.local:9778` (node, iff loopback — see below)
3. `http://localhost:9778` (node)
4. `https://rpc.dig.net` (rpc) — the FINAL fallback.
**Node trust (`EndpointKind::Node`) is granted ONLY to an ASSERTED-LOOPBACK host** —
a `127.0.0.0/8` or `::1` literal, the reserved name `localhost`, or `dig.local` iff
it resolves (OS resolver / hosts) to loopback addresses only. The node `/s/` path
returns server-decrypted bytes with NO client-side crypto, so it is sound ONLY on the
user's own machine. EVERY other host — including an explicit override at a remote
host — MUST use the client-verified `Rpc` path. Implementations that cannot resolve
names (e.g. a browser) MUST treat a non-literal, non-`localhost` name as NON-loopback
(no node trust).
- A node tier is selected only when it is an asserted-loopback host AND a cheap `GET
{base}/health` returns 2xx within a short timeout; otherwise the ladder falls
through.
- An override is classified by HOST: a loopback host → node surface; ANY other host →
the client-verified rpc surface. An override MUST NOT silently fall back to the
public gateway.
- The auto-ladder plan is `[node(first-healthy-loopback), rpc(rpc.dig.net)]` when a
loopback node tier is healthy, else `[rpc(rpc.dig.net)]`.
- The resolved plan SHOULD be cached per resolver instance.
### 4.1 Cross-tier error classification — fall through on ABSENCE, fail closed on INTEGRITY
Walking the plan, a per-tier failure is classified into exactly one of three actions.
Getting this split right is a SECURITY property, not a convenience:
- **TRY NEXT TIER (fall through)** — a tier's `NotFound` (content genuinely absent
here — a node `404`, or a gateway `total_length == 0`) OR its transport failure
(tier unreachable). This is the stranger's common case: the local node does not hold
the content, so the ladder MUST continue to the public gateway that does. A local
`NotFound` MUST NOT abort the ladder.
- When EVERY tier returns `NotFound`, the resolve yields ONE branded final
`ResolveError::NotFound` (the content exists nowhere reachable) — not `Unreachable`,
not a raw per-tier error.
- When the LAST tier is transport-unreachable, the resolve yields
`ResolveOutcome::Unreachable` (§6).
- **ABORT THE WHOLE LADDER IMMEDIATELY (fail closed)** — a tier's `VerifyFailed` /
`DecryptFailed` (integrity: tampered bytes, a non-chaining proof, a wrong root, a
decrypt-tag failure). This is surfaced as `ResolveOutcome::IntegrityFailure` and MUST
NEVER fall through to another tier. Falling through after an integrity failure would
let an attacker turn a tampered response at one tier into a silent retry that serves
attacker-chosen bytes from another — the exact fail-closed hole this rule forbids
(superproject CLAUDE.md §5.4).
- **SURFACE A HARD ERROR** — a reachable protocol error (`Rpc`) or a `RootRequired`
(rootless URN over the untrusted gateway) — returned as a hard `Err` (§6). Not
absence, not unreachability, not integrity.
## 5. Read paths
### 5.1 Node path (`EndpointKind::Node`) — loopback only
`GET {base}/s/<store_id>[:<root>]/<resource_key>`. A loopback node may answer with
verified PLAINTEXT or with CIPHERTEXT; the shape is detected DETERMINISTICALLY by
headers (never assumed):
- **Verified plaintext** — `2xx` AND `X-Dig-Verified: true` → the body is the
node-decrypted, node-verified plaintext (loopback trust). Content type: the
response `Content-Type`, else derived (§7). No client-side crypto.
- **Ciphertext** — `2xx` AND (`X-Dig-Encrypted: true` OR an `X-Dig-Inclusion-Proof`
header) → the body is opaque ciphertext that MUST be client-side verified+decrypted
exactly like the rpc path (§5.2 step 4), reusing `digstore-core` and threading the
URN salt. The trust root is the URN's pinned root, else the (loopback) node's
`X-Dig-Root`; the proof is `X-Dig-Inclusion-Proof`; `X-Dig-Chunk-Lens` (comma-
separated) gives the chunk layout. A node returning ciphertext is NOT trusted blindly.
- `2xx` that is neither attested plaintext nor decryptable ciphertext → hard
`VerifyFailed` (fail-closed; §6 `IntegrityFailure`). Bytes are never returned.
- **Rootless resolution over this tier** — a rootless URN is served as
`GET {base}/s/<store_id>/<resource_key>` (no `:<root>`); the node resolves the
store's current chain-anchored tip itself and reports it back via `X-Dig-Root`
(both response shapes above). The client trusts that header as the resolve's root
ONLY because this is the asserted-loopback node (never over rpc, §5.2 step 1); the
ciphertext shape is still fully client-verified against it (gate-then-decrypt, same
as a pinned root) — the node is trusted to NAME the tip, never to attest unverified
bytes. A rootless URN with no healthy loopback node falls through the ladder to the
rpc tier, which rejects it with `RootRequired` (§4.1, §5.2 step 1) — the wall never
opens just because the node tier was unavailable.
- `404` → `NotFound` (content absent at this tier; the ladder falls through to the
next tier — §4.1).
- other non-2xx / transport failure → a transport failure (ladder falls through).
### 5.2 RPC path (`EndpointKind::Rpc`) — the trust root comes from the URN
1. The trust root MUST be the URN's pinned root. A ROOTLESS URN over this untrusted
tier is REJECTED with a hard `RootRequired` error (its root would otherwise come
from the same untrusted gateway, allowing a compromised gateway to prove attacker
bytes for a public store against a fake root). No `dig.getAnchoredRoot` call is
made on this tier.
2. `retrieval_key` per §3.
3. Stream windowed `dig.getContent {store_id, root, retrieval_key, offset, length} ->
{total_length, offset, next_offset?, complete?, ciphertext (b64), inclusion_proof
(b64), chunk_lens}`, accumulating ciphertext until `complete` or `next_offset ==
null`. `total_length == 0` → `NotFound` (absent at this tier; falls through — §4.1).
4. **Verify then decrypt** (gate-then-decrypt), via `digstore-core`:
- inclusion: `resource_leaf(ciphertext) == proof.leaf`, `proof.verify()`, and
`proof.root == trusted_root`. ANY failure → integrity failure (§6).
- decrypt: split by `chunk_lens` (empty ⇒ single chunk), AES-256-GCM-SIV-open each
under the URN key. A tag failure → integrity failure (§6).
5. Content type derived per §7.
A JSON-RPC `error` object or a malformed/unexpected body is a hard `Rpc` error (the
endpoint IS reachable). A transport failure / non-2xx HTTP is a transport failure.
## 6. Outcomes (fail-closed)
A resolve yields `Result<ResolveOutcome, ResolveError>`:
- `ResolveOutcome::Success(data)` — verified content.
- `ResolveOutcome::IntegrityFailure` — bytes were fetched but failed inclusion or
decrypt verification. The unverified bytes MUST NOT be returned or carried. This is
returned IMMEDIATELY at the producing tier (never cascaded, never masked as
unreachable).
- `ResolveOutcome::Unreachable` — every tier was transport-unreachable; nothing was
fetched.
- `Err(ResolveError)` — `Parse`, `NotFound` (the branded final not-found after EVERY
tier reported absence — §4.1), `RootRequired` (a rootless URN over the untrusted rpc
tier), or `Rpc` (a reachable protocol error).
A per-tier `NotFound` MUST NOT abort the ladder — it falls through (§4.1); only an
integrity failure aborts. `IntegrityFailure` and `Unreachable` MUST be distinct and
never conflated:
integrity-fail = reached the network, bytes don't verify (security); unreachable =
couldn't reach the network (retryable).
For a render/image path, `IntegrityFailure` MAY render a branded "Integrity
Verification Failed" `text/html` document and `Unreachable` a branded "DIG Network
unreachable" + Connect-to-Node document. An image/object-URL helper MUST NOT return
the unverified bytes as content for an integrity failure — it returns the security
document instead.
## 7. Content type
Derived from the resource path extension first, then a magic-byte sniff, falling back
to `application/octet-stream`. The node path prefers the response `Content-Type`.
## 8. Transport & environments
The core logic depends only on an injected async transport (`HttpTransport`: `get`,
`post_json`). Bundled implementations: `reqwest` (native, `native` feature) and the
browser `fetch` (wasm, `wasm` feature, resolved off `globalThis.fetch` so it works in
a browser window, a worker, AND Node.js ≥18). Node-class transports SHOULD use short
connect timeouts so dead ladder tiers fall through quickly.
The wasm package (`@dignetwork/dig-urn-resolver`) MUST work in BOTH a browser and
Node.js, degrading gracefully (never a hard env failure). The branded API + the three
outcomes + MIME are IDENTICAL across environments; only env plumbing branches:
- **Ladder** — a local-node `/health` probe that cannot run (e.g. CORS-blocked in a
browser) MUST NOT throw; the ladder catches it and falls through to the verified
rpc tier. A browser that can't reach a local node reaches the SAME fail-closed rpc
path, never unverified bytes.
- **Cache (§11)** — the disk tier is native-only; in the wasm package a `cachePath`
MUST degrade to the in-memory cache without throwing.
- **Image URL** — `resolveImageUrl` MUST return a usable URL in both: a `blob:` URL
where `URL.createObjectURL` exists, else a `data:` URL. `resolve()` (bytes +
`contentType`) MUST work regardless, so a consumer without blob-URL support can use
the bytes directly.
The package ships a dual-target build: the wasm-bindgen `web` (ESM) build for browser
bundlers and the `nodejs` (CommonJS) build for Node, routed by the package `exports`
map (`browser`/`import` → web, `node`/`require` → node).
## 9. wasm surface (`@dignetwork/dig-urn-resolver`)
The front-door API is the branded `DigNetwork` class:
- `new DigNetwork(options?)` — a single, named-field options object (NOT positional
args). `options` is a `DigNetworkOptions` with all-optional fields
`{ endpoint?, connectUrl?, cachePath? }`; an omitted (or blank) field keeps its
§5.3 default, so `new DigNetwork()` is all-defaults and `new DigNetwork({ cachePath })`
sets only the disk cache. Unknown properties are ignored. `cachePath` is an optional
disk-cache directory (native/Node.js; ignored in the browser, see §11). The
configured fields are readable back via the `endpoint` / `connectUrl` / `cachePath`
getters. The package's generated `.d.ts` MUST export the `DigNetworkOptions`
interface with those named fields (never `any`).
- `dig.resolve(urn) : Promise<{ outcome, bytes, contentType }>`, `outcome ∈
"success" | "integrity_failure" | "unreachable"`. `contentType` is present on
EVERY result.
- `dig.resolveImageUrl(urn) : Promise<string>` — an `<img src>` URL that ALWAYS
resolves (never throws for a normal failure): a `blob:` URL of the real verified
image on success, else a branded DIG error IMAGE as a `data:image/png;base64` URI
matching the failure (integrity / unreachable / not-found / invalid-URN / generic).
An `<img>` cannot render the HTML error docs, so these prerendered PNGs are the
image-path variant. FAIL-CLOSED: the integrity image is a STATIC branded
placeholder — unverified bytes are NEVER returned as the image.
Low-level free functions `resolve(urn, endpoint?, connectUrl?)` and
`resolveObjectUrl(urn, endpoint?, connectUrl?)` MAY also be exported (delegating to
`DigNetwork`); the branded class is the documented surface.
## 10. Conformance
- URN parse MUST match `dig-urn-protocol` byte-for-byte (the frozen conformance corpus);
the retrieval key MUST equal `dig_urn_protocol::DigUrn::content_key` and the decryption
key MUST match `digstore-core` byte-for-byte. A cross-parser equivalence test
(`tests/cross_parser_equivalence.rs`) pins the wire key against both parsers over the
frozen corpus, including a root-pinned URN (guarding the `content_key` vs `retrieval_key`
trap).
- A tampered ciphertext, a non-chaining proof, a wrong root, or a wrong/absent salt
MUST yield `IntegrityFailure`, never data.
- Only an asserted-loopback host is granted node trust; a remote host (incl. an
override) MUST use the verified rpc path. A node response without `X-Dig-Verified:
true` MUST fail closed.
- A rootless URN over the rpc tier MUST be rejected (`RootRequired`); the rpc tier
MUST NOT call `dig.getAnchoredRoot`.
- Node-absent + a serving rpc gateway MUST resolve a valid ROOT-PINNED resource to
`Success`.
- A local-tier `NotFound` (node `404`) MUST fall through to the next tier: a healthy
node that lacks the resource + a serving gateway that has it MUST resolve to
`Success` (§4.1). When EVERY tier reports absence the result MUST be one branded
`NotFound`.
- An integrity failure (`VerifyFailed` / `DecryptFailed`) at ANY tier MUST abort the
whole ladder as `IntegrityFailure` and MUST NOT be retried on a later tier — a
tampered response MUST NOT become a silent retry that serves other bytes (§4.1, §5.4).
- A node CIPHERTEXT response MUST be client-side verified+decrypted (not trusted); a
salted URN MUST decrypt salted content on BOTH tiers, and a wrong/absent salt MUST
yield `IntegrityFailure`.
- A ROOTLESS URN resolved over a healthy loopback node returning CIPHERTEXT MUST
derive its trust root from `X-Dig-Root` and verify+decrypt against it to `Success`;
the same rootless URN with no healthy loopback node MUST fall through to the rpc
tier and be rejected `RootRequired` — the node is the ONLY tier ever trusted to
resolve a rootless URN's tip.
- Caching (§11) MUST NOT weaken fail-closed: only `Success` is cached; a disk hit is
re-verified (a tampered file → `IntegrityFailure`).
## 11. Caching
Results are cached in front of resolve; this MUST NOT weaken fail-closed (§6).
- **Cacheable:** ONLY a verified `Success`. `IntegrityFailure` / `Unreachable` /
`NotFound` / any `Err` MUST NOT be cached.
- **Key:** the content-addressed identity `storeId:root:resourceKey:salt` with the
CONCRETE resolved root (a root-pinned URN's root, or the node's `X-Dig-Root`) —
never the raw request URN. A rootless URN with no concrete root is not cached.
- **Memory tier (bounded LRU, both native + wasm):** process-trusted — holds only
what THIS process verified this run; a hit MAY skip re-verification. Bounded by an
entry count AND a byte budget (no unbounded growth in a wallet).
- **Disk tier (optional, native, UNTRUSTED):** stores the VERIFIABLE artifacts
(ciphertext + inclusion proof + chunk lengths), NOT plaintext. A disk hit MUST be
RE-VERIFIED against the URN's root before use (same merkle/decrypt gate); a tampered
entry MUST fail verification → `IntegrityFailure` and MUST NOT be served. Filenames
MUST be `SHA-256(identity)` (content-addressed, no path-traversal). Ignored where
there is no filesystem (the browser); the memory tier still applies.