diff --git a/Cargo.lock b/Cargo.lock index 0e340c1..16cf62b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1956,7 +1956,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.26.0" +version = "0.27.0" dependencies = [ "axum", "base64", diff --git a/Cargo.toml b/Cargo.toml index 2858d71..2b4bd0e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ edition = "2021" # (`[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.26.0" +version = "0.27.0" # 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/SPEC.md b/SPEC.md index 80baf28..d9a6bb4 100644 --- a/SPEC.md +++ b/SPEC.md @@ -761,21 +761,121 @@ token is NOT accepted for them. ### 7.3. The control token -- File: `/control-token`, where `` is the parent of the read path's - `config.json`. +- **File: `/control-token`** (§7.3a), where `` is the machine-wide, identity- + INDEPENDENT daemon state dir — NOT the per-user config dir. This is REQUIRED (#501): on a real + install the daemon runs as a service under a different OS account (Windows LocalSystem, a root + daemon) than the operator's interactive CLI. A per-user path resolves DIFFERENTLY for the two + identities, so the CLI would never see the token the service wrote (and would mint a phantom the + daemon never trusts). Resolving a machine-wide dir independently of the running user makes the + daemon and the CLI read the SAME token. - Value: 32 bytes of OS randomness rendered as 64 lowercase hex characters. Generated at first - run; subsequent runs (and other same-host processes) read the same value. On Unix the file MUST - be written with owner-only permissions (`0600`, best-effort). The token MUST never be committed - or logged. + run; subsequent runs (and other same-host processes/users) read the same value. The token MUST + never be committed or logged. - Presentation, either of (header preferred): the `X-Dig-Control-Token` request header, or the `params._control_token` field. Blank presentations are treated as absent. -- If the token cannot be persisted (unwritable config dir), the node MUST fall back to an +- **The daemon MAY create the token (write); an operator CLI MUST NOT mint one.** The CLI + (`dig-node pair` / any control tool) reads the token READ-ONLY; if it is missing or unreadable it + MUST fail with a precise remedy (§7.3a) rather than write a fresh token the daemon does not trust. +- **Trust a pre-existing token file ONLY when it is owned by a TRUSTED principal (owner + verification).** Before the daemon loads and trusts the bytes of an EXISTING `control-token`, it + MUST verify the file's OWNER; a foreign-owned token is DELETED and REGENERATED, never returned. + This closes the residual where an unprivileged local user plants a KNOWN token in the machine-wide + state dir (a `%PROGRAMDATA%` squat, or the narrow window during a service harden) so the daemon + (LocalSystem) reads + trusts it and the attacker learns the control token → full local control + (local privilege escalation). Trusted owners: **Windows** — `S-1-5-18` (SYSTEM) or `S-1-5-32-544` + (Administrators) always; a NON-service (dev/operator) run ALSO trusts the CURRENT process user's + own SID (so a dev token in the legacy per-user dir keeps working), and a SERVICE run requires + SYSTEM/Administrators. **Unix** — owner uid `0` (root) always, else the CURRENT effective uid AND + mode `0600` (owner-only); a group/other-readable or foreign-uid token is untrusted. This is layered + BENEATH the §7.3a state-dir hardening (which already purges a squatter-owned dir on a service run) + as defense-in-depth: it also guards the non-hardened dev path and any harden gap. +- If the token cannot be persisted (unwritable state dir), the daemon MUST fall back to an in-memory token that no controller can read — the control plane fails **closed**; the read plane is unaffected. - Randomness source: the kernel CSPRNG (`/dev/urandom`) on Unix; elsewhere a non-deterministic mixed fallback. The security model is *possession of a same-host-readable file*, layered on the loopback bind — not secrecy from a network attacker. +### 7.3a. The daemon state dir — location, ACL, threat model (#501) + +The state dir holds ONLY the control/auth state — the control token (§7.3) and the paired-token +store (`paired-tokens.json`, §7.11). The bulk per-user `.dig` cache and `config.json` (§3.5–3.6) do +NOT move; they stay per-user (shared with the browser/digstore, #96). + +**Resolution order** (the daemon and every operator CLI MUST resolve this identically, so it MUST +NOT depend on `$HOME`/`%LOCALAPPDATA%`/the running user): + +1. `DIG_NODE_STATE_DIR` (env override) — wins outright (tests + custom deploys). +2. The first machine-wide candidate that already EXISTS — Windows `%PROGRAMDATA%\DigNode` + (`C:\ProgramData\DigNode`); Linux `/var/lib/dig-node` then `/etc/dig-node`; macOS + `/Library/Application Support/DigNode`. +3. Only for a SERVICE run (self-identified via `DIG_NODE_RUN_CONTEXT=service`): the first + machine-wide candidate it can create. A bare CLI MUST NOT create a machine-wide dir. +4. Else the LEGACY per-user dir (the parent of `config.json`) — the back-compat fallback that keeps + a non-service `dig-node run` as a normal user working exactly as before (additive). + +**Creation + ACL — the HARDENING CONTRACT.** The state dir holds the control token that grants FULL +local control, so its ACL MUST NOT be world/all-users-readable. On Windows this is the HARD case: +`%PROGRAMDATA%` grants `BUILTIN\Users` "create subfolder", so ANY low-priv user can pre-create +`C:\ProgramData\DigNode`, become its CREATOR OWNER, and keep `WRITE_DAC` forever — a naive +`icacls /inheritance:r /grant:r` (which never resets OWNER nor purges foreign explicit ACEs) leaves +that squatter able to rewrite the DACL and read the token → local privilege escalation. A +pre-existing machine dir MUST therefore NOT be trusted blindly. + +On a SERVICE run (self-identified via `DIG_NODE_RUN_CONTEXT=service`), BEFORE the daemon writes or +reads the control token, the resolved MACHINE state dir MUST be HARDENED and READBACK-VERIFIED: + +1. Resolve the interactive read-grant principal as a real SID from the CURRENT PROCESS TOKEN + (`whoami /user`), NEVER the spoofable `%USERNAME%`/`%USERDOMAIN%` env. REJECT it if it is a + well-known group/broad SID (`S-1-1-0` Everyone, `S-1-5-11` Authenticated Users, `S-1-5-7` + Anonymous, `S-1-5-32-545` Users) or SYSTEM (`S-1-5-18`). A LocalSystem service thus resolves NO + interactive grant; it instead PRESERVES an installer-set interactive read grant if one is + discoverable on a TRUSTED (SYSTEM/Administrators-owned) pre-existing dir. +2. Create the dir if absent — but NEVER early-return when it already exists (a squatter may have + pre-created it); always run steps 3-6. +3. Take ownership so the squatter loses `WRITE_DAC`: `icacls D /setowner *S-1-5-18 /T` (owner ⇒ + SYSTEM). A pre-existing dir with an UNTRUSTED owner is PURGED (`remove_dir_all`) and recreated. +4. Purge ALL foreign explicit ACEs: `icacls D /reset /T`. +5. Lock the DACL: `icacls D /inheritance:r /grant:r *S-1-5-18:(OI)(CI)F *S-1-5-32-544:(OI)(CI)F` and, + only when a valid interactive read grant survived step 1, append `:(OI)(CI)R` (READ only + for the interactive user, never full). Principals are always addressed by SID literal, never the + localized name. +6. READBACK-VERIFY (`Get-Acl`, SID-based) as the acceptance gate: NO Everyone/Users/Authenticated- + Users/Anonymous ACE; owner is SYSTEM (or Administrators); SYSTEM + Administrators present with + full; the interactive read grant present iff one was applied; NO principal beyond those; and + inheritance disabled. Any violation FAILS. +7. FAIL CLOSED: if any of steps 3-6 fails, hardening returns an error, the dir is best-effort + DELETED, and the daemon MUST NOT write the token there — it falls back to an ephemeral, unshared + dir + a random in-memory token so the control plane is UNAUTHORIZABLE (never served from an + attacker-controlled dir). The read plane is unaffected. + +`dig-node install` (run elevated by the interactive user) applies the SAME hardening as the +installing user, setting owner = SYSTEM and granting THAT user READ, so the LocalSystem service's +startup harden later sees a TRUSTED dir and PRESERVES the grant. Idempotency: running the harden +twice on a legit dir yields the same final ACL. On Unix the dir is `0700` and the token file `0600`, +owned by the daemon identity (root on a real install under root-owned `/var/lib`, which is not +squattable); the installer additionally best-effort `setfacl`s READ for the `SUDO_USER`. + +The CLI (non-service) NEVER hardens (it is not elevated) — it only READS an existing machine dir, +else falls back to the LEGACY per-user dir. A non-service dev run on the legacy per-user dir does NOT +invoke the machine-dir hardening (that dir is already user-scoped). + +**Threat model.** The control token grants full local control of the node (mint controller tokens, +change pins, drive wallet-adjacent control). A machine-wide file readable by every local user, or one +sitting in a squatter-controlled dir, would be a local privilege-escalation vector — any user could +seize control of a node running as another identity. The invariant that MUST hold either way is: NO +Users/Everyone/Authenticated-Users ACE and owner = SYSTEM. The ACL is defense-in-depth layered on the +loopback bind + token-possession model, BUT because a loose ACL is itself a priv-esc, the SERVICE-run +harden is FAIL-CLOSED (unlike the best-effort dev/self-create path, where an `icacls`-unavailable +tighten failure does not hard-fail startup). + +**Degraded case + remedy.** When the daemon bootstraps the dir itself as SYSTEM/root (the installer +did not pre-create it), the interactive user is not a trustee and cannot read the token. The +`UNAUTHORIZED` (`-32030`) error and the operator CLI MUST then print the PRECISE remedy — the exact +token path, and that it needs elevation (Administrator / `sudo`) or the install-user read ACL — +rather than a generic hint. The CLI distinguishes token-present-but-unreadable (the service-vs-user +split → "elevate / grant read") from token-absent ("start the node so it mints one"). + ### 7.4. Control methods All results/errors use the standard envelopes of §5.1. `storeId` and `rootHash` are canonical @@ -827,7 +927,7 @@ same-host control token. cap/used. Node detection uses the §5.3 ladder (explicit `server.host` override > `dig.local` > `localhost:9778` > `rpc.dig.net`); the localhost tier MUST target the §3.2 default port `9778`. - **Token-gated management (a same-host process controller).** The mutating + privacy-sensitive - `control.*` methods require the control token from `/control-token` (§7.3). Only a + `control.*` methods require the control token from `/control-token` (§7.3/§7.3a). Only a process that can read that file — the DIG Browser "My Node" UI (a native process), the CLI, a local tool — can drive them. A sandboxed extension MUST NOT attempt to read the token; it MAY still CALL `control.status` and, on the canonical `-32030 UNAUTHORIZED` (§10), fall back to deep-linking a @@ -854,7 +954,9 @@ a clean foreground node with zero out-of-band setup: it binds `127.0.0.1:$DIG_NO on Ctrl-C/SIGTERM (§9.5) so a test harness can `spawn → poll GET /health → drive control.* / read → signal-stop`. `DIG_NODE_PORT` MUST be honored so a test picks a free port; `DIG_NODE_DIGLOCAL=0` SHOULD be set in tests to skip the privileged `:80` dig.local bind. The control token for a -token-gated test is read from `/control-token` after startup. +token-gated test is read from `/control-token` after startup; a test SHOULD set +`DIG_NODE_STATE_DIR` to an isolated temp dir (§7.3a) so its token/paired-token state is hermetic +regardless of any real machine-wide state dir on the host. ### 7.9. Cache-method families (open `cache.*` vs gated `control.cache.*`) @@ -915,7 +1017,7 @@ eviction. These additive fields/methods complete that surface; all are `served: ### 7.11. Control-token pairing for browser controllers (#280) -An MV3 browser extension cannot read the `/control-token` file, so it cannot drive +An MV3 browser extension cannot read the `/control-token` file, so it cannot drive token-gated `control.*` mutations. PAIRING lets it obtain its OWN scoped, revocable controller token after LOCAL operator approval, WITHOUT ever exposing the master token. Two OPEN bootstrap methods + three MASTER-gated administration methods, all loopback-only. @@ -936,8 +1038,8 @@ three MASTER-gated administration methods, all loopback-only. - **`control.pairing.list`** → `{ pending: [{ pairing_id, pairing_code, client_name, created_ms, expires_ms }], tokens: [{ id, client_name, created_ms }] }`. The token VALUE is never listed. - **`control.pairing.approve { pairing_id }`** → `{ approved: true, client_name, token_id }`. Mints a - fresh 64-hex scoped token, PERSISTS it to `/paired-tokens.json` (owner-only, atomic), - and marks the pending entry approved so the requester's next `pairing.poll` returns it. Approval is + fresh 64-hex scoped token, PERSISTS it to `/paired-tokens.json` (§7.3a, restricted, + atomic), and marks the pending entry approved so the requester's next `pairing.poll` returns it. Approval is the CONSENT step: it requires the master token (a local file read), so only the machine's operator can grant a pairing. - **`control.pairing.revoke { token_id }`** → `{ revoked: bool, token_id }`. Removes the token; the @@ -957,8 +1059,8 @@ origin is CORS-blocked from reading it (and blocked at preflight from sending a header). A paired token is SCOPED (it authorizes `control.*` mutations but not pairing administration) and REVOCABLE. All token comparisons are constant-time. -**Paired-token store.** `/paired-tokens.json` = `{ "tokens": [{ id, token, client_name, -created_ms }] }`, owner-only, atomic writes. The auth gate accepts the master token OR any token in +**Paired-token store.** `/paired-tokens.json` (§7.3a) = `{ "tokens": [{ id, token, +client_name, created_ms }] }`, restricted (dir ACL), atomic writes. The auth gate accepts the master token OR any token in this store (except for the pairing-administration methods). ### 7.12. Paired-token authorization for wallet methods (#370) @@ -1002,7 +1104,30 @@ authorizes a mutation but NOT pairing administration; a revoked token is denied `run` (default when no subcommand; serves in the foreground and is the unix-service entrypoint) · `run-service` (hidden; the Windows SCM entrypoint, §9.4; behaves as `run` off Windows) · -`install` · `uninstall` · `start` · `stop` · `status`. +`install` · `uninstall` · `start` · `stop` · `status` · `pair` (§7.11) · `open` (§8.5). + +### 8.5. `open` — the OS scheme handler (#389) + +`dig-node open ` is the target the installer registers for the OS `chia://` and +`urn:dig:chia:` protocol handlers (`dig-node open "%1"`). It is the OS-level fallback resolver for a +DIG link that no in-browser DIG extension intercepted. + +- **Input.** Accepts ONLY `chia://[:][/]` and + `urn:dig:chia:[:][/]` (scheme match is case-insensitive). The store reference + MUST be canonical 64-hex (`storeId` or `storeId:root`). +- **Untrusted-input validation (MUST).** The argument arrives from an OS handler and may be + attacker-influenced (a hostile page can invoke a registered scheme). The command MUST reject every + other scheme (`file:`, `javascript:`, `data:`, `http(s):`, …), shell metacharacters, control + characters, whitespace, and `..` path traversal, and MUST NOT pass the argument to a shell — the + resolved URL is launched via the OS "open a URL" facility with the URL as a SINGLE, non-shell argv + entry (Windows `rundll32 url.dll,FileProtocolHandler`, Linux `xdg-open`, macOS `open`). A rejected + link exits `USAGE` (2) and launches nothing. +- **Behavior.** Opens the user's DEFAULT browser at the node's LOCAL serve URL + `http://:/s/[:]/` (the §4.6 plaintext content route; host/port + from the node's own config, default `localhost:9778`), so the node — the resolver of record — + serves the content and any installed DIG extension can still verify it. It NEVER opens `chia://` + at the OS level (dig-node is itself the OS `chia://` handler, so that would recurse) and NEVER + opens a dig-node GUI (it has none). Under `--json`: `{ opened: true, url, store_id, root, path }`. ### 8.2. `--json` (global flag) @@ -1168,10 +1293,17 @@ does offset/length arithmetic over untrusted serialized input). - **Read/control split:** read methods open to local consumers; `control.*` requires possession of the same-host capability file, compared in constant time, failing closed when unpersistable (§7.2–7.3). +- **Machine-wide auth state, not world-readable:** the control token + paired-token store live in a + machine-wide state dir resolved identically by the daemon and the operator CLI, restricted by ACL + to SYSTEM + Administrators + the creating user (Unix `0700`/`0600`) — never all-users-readable, so + it is not a local privilege-escalation vector (§7.3a). The operator CLI reads the token read-only + and never mints a rival token. +- **Untrusted scheme-handler input:** `dig-node open` (the OS `chia://`/`urn:dig:chia:` handler, + §8.5) strictly validates its argument and launches the resolved URL without a shell. - **Blind serving:** content reads return ciphertext + proofs; verification/decryption is the client's job (§1.3). The node never returns plaintext for content reads. -- **No secrets in artifacts:** the control token is generated at runtime, owner-restricted on - Unix, and never committed or logged. +- **No secrets in artifacts:** the control token is generated at runtime, ACL-restricted (§7.3a), + and never committed or logged. --- @@ -1189,7 +1321,7 @@ does offset/length arithmetic over untrusted serialized input). | 8 | CLI exit codes + `--json` envelopes | Table §8.4; one JSON object on stdout | §8; `src/cli.rs`, `tests/cli.rs` | | 9 | Service label | `net.dignetwork.dig-node` across install/uninstall/start/stop/SCM dispatcher | §2.4, §9.4 | | 10 | Release assets | Dual-named `dig-node-*` + legacy `dig-companion-*`, identical bytes, per §11.3 matrix | §11; `.github/workflows/release.yml` | -| 11 | Control-token scheme | `/control-token`, 64-hex, `X-Dig-Control-Token` / `params._control_token`, constant-time | §7.2–7.3 | +| 11 | Control-token scheme | `/control-token` (machine-wide, ACL-restricted, §7.3a), 64-hex, `X-Dig-Control-Token` / `params._control_token`, constant-time | §7.2–7.3a | | 12 | Health/version/well-known shapes | §6 fields; additions additive only | §6; `src/meta.rs`, `src/server.rs` | | 13 | Subscription persistence | `/subscriptions.json` schema-versioned, atomic, cross-process-locked | §14.1; `subscription.rs` | | 14 | Autonomous sync fail-closed | chain-watch + gap-fill + read-path pin never serve/pull against an unconfirmable root | §14.2–14.4; `chainwatch.rs`, `lib.rs` | diff --git a/crates/dig-node-service/src/cli.rs b/crates/dig-node-service/src/cli.rs index da2cb0e..e4a454c 100644 --- a/crates/dig-node-service/src/cli.rs +++ b/crates/dig-node-service/src/cli.rs @@ -96,6 +96,9 @@ impl ExitCode { match e.kind() { PermissionDenied => ExitCode::PermissionDenied, AddrInUse | AddrNotAvailable => ExitCode::BindFailed, + // A bad argument surfaced as `InvalidInput` (e.g. `dig-node open` rejecting a + // non-DIG/malformed link) is a USAGE error, not a generic I/O failure. + InvalidInput => ExitCode::Usage, _ => ExitCode::IoError, } } @@ -118,6 +121,7 @@ impl ExitCode { /// stderr in the default mode) and a machine `result` object (folded into the /// `--json` success envelope). Service functions return this instead of printing /// directly, so main.rs renders ONE consistent surface for both audiences. +#[derive(Debug)] pub struct Outcome { /// Human-readable, possibly multi-line, summary lines. pub summary: String, @@ -204,6 +208,8 @@ mod tests { assert_eq!(ExitCode::from_io_error(&bind), ExitCode::BindFailed); let other = std::io::Error::other("x"); assert_eq!(ExitCode::from_io_error(&other), ExitCode::IoError); + let usage = std::io::Error::new(std::io::ErrorKind::InvalidInput, "bad link"); + assert_eq!(ExitCode::from_io_error(&usage), ExitCode::Usage); } #[test] diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index e011c51..344878b 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -17,13 +17,14 @@ //! 1. **Loopback-only.** The whole server binds `127.0.0.1` (see [`crate::config`]), //! so nothing off-machine can reach any method. //! 2. **Local authorization** for the mutating control namespace. A random -//! **control token** is generated at first run into the node's config dir -//! (`/control-token`, next to dig-node's `config.json`) with -//! owner-only permissions where the OS supports it. A same-host controller reads -//! that file (it can, because it runs as the same user on the same machine) and -//! presents the token on every `control.*` call — as the `X-Dig-Control-Token` -//! request header or a `params._control_token` field. The READ methods are NOT -//! gated; only `control.*` requires the token. +//! **control token** is generated at first run into the machine-wide, identity- +//! INDEPENDENT state dir (`/control-token` — [`crate::state`], #501) with +//! a restrictive ACL. A same-host controller reads that file and presents the token +//! on every `control.*` call — as the `X-Dig-Control-Token` request header or a +//! `params._control_token` field. The READ methods are NOT gated; only `control.*` +//! requires the token. The token lives in [`crate::state::state_dir`] (NOT the +//! per-user config dir) so the daemon (which may run as a service under a different +//! OS account) and the operator CLI resolve the SAME file. //! //! This is the standard "local capability file" pattern (cf. Chia's `daemon` / //! Bitcoin's cookie auth): possession of the on-disk token = authorization, so a @@ -86,57 +87,137 @@ pub fn is_pairing_admin_method(method: &str) -> bool { ) } -/// The path to the control-token file, given the node's config dir. The config dir -/// is `config.json`'s parent (dig-node's `config_path()`), so the token lives beside -/// the shared config. PURE (path math only). -pub fn control_token_path(config_path: &Path) -> PathBuf { - config_path - .parent() - .map(|p| p.join(CONTROL_TOKEN_FILE)) - .unwrap_or_else(|| PathBuf::from(CONTROL_TOKEN_FILE)) +/// The path to the control-token file: `/control-token`, where the state +/// dir is the machine-wide, identity-INDEPENDENT daemon state dir (#501, +/// [`crate::state::state_dir`]) — NOT the per-user config dir. Decoupling the token +/// from `config_path()` is the fix for the service-vs-user path split: the running +/// daemon and the operator CLI resolve this ONE path regardless of which OS user each +/// runs as, so the CLI reads the SAME token the service wrote. +pub fn control_token_path() -> PathBuf { + crate::state::state_dir().join(CONTROL_TOKEN_FILE) } /// Load the control token, generating + persisting a fresh one if absent. /// /// The token is 32 random bytes rendered as 64-hex. Generated at RUNTIME into the -/// node's config dir on first call; subsequent calls (and other processes sharing -/// the dir) read the same value. Written with owner-only permissions on Unix -/// (`0600`) so another user on the box cannot read it. Never committed. +/// machine-wide state dir ([`control_token_path`]) on first call; subsequent calls (and +/// other processes / users on the box) read the same value. The dir + file are created +/// with a RESTRICTIVE ACL (owner/SYSTEM + Administrators, the creating user; never +/// world/all-users-readable — see [`crate::state`]). Never committed. pub fn load_or_create_token() -> std::io::Result { - let path = control_token_path(&dig_node_core::config_path()); - load_or_create_token_at(&path) + load_or_create_token_at(&control_token_path()) +} + +/// A precise, service-aware remedy for a control-token authorization failure (#501). +/// +/// The classic failure is the service-vs-user PATH/PERMISSION split: the node runs as a +/// service (Windows LocalSystem / a root daemon) and minted `control-token` in the +/// machine-wide state dir with restrictive perms, but the interactive user running +/// `dig-node pair` / a `control.*` call cannot READ it. This inspects the resolved token +/// path from the CALLER's perspective and returns the exact fix (which dir + that it +/// needs elevation or the install-user's read ACL), instead of the generic hint. +pub fn control_token_remedy() -> String { + let path = control_token_path(); + let dir = path + .parent() + .map(|p| p.display().to_string()) + .unwrap_or_default(); + // Read once: an Ok read = readable (whatever the contents); an Err with the file present + // = the service-vs-user ACL split; absent = the node has not minted one yet. + let read = std::fs::read_to_string(&path); + let readable = read.is_ok(); + let present = readable || path.exists(); + if present && !readable { + format!( + "the node's control token at {} exists but is NOT readable by your account — \ + the node runs as a service under a different account (Windows LocalSystem / a \ + root daemon). Re-run this command elevated (Administrator on Windows, sudo on \ + Unix), or have the installer grant your account read access to {}.", + path.display(), + dir + ) + } else if !present { + format!( + "no control token found at {}. Start the node so it mints one \ + (`dig-node run`, or `dig-node start` for the installed service), then retry.", + path.display() + ) + } else { + format!( + "the presented control token was not accepted. Ensure the node and this command \ + resolve the SAME state dir ({dir}) — if you set DIG_NODE_STATE_DIR it must match \ + on both the node and this command." + ) + } +} + +/// Read the master control token WITHOUT creating one — the OPERATOR-side load (`dig-node +/// pair` / any local control CLI, #501). It must NEVER mint a token: minting a fresh token +/// the running node does not trust is the exact original bug (the CLI wrote its own token to +/// a per-user path the service never read). On a missing/unreadable/blank token it returns a +/// rich [`std::io::Error`] carrying [`control_token_remedy`] — the precise service-vs-user +/// remedy — with the error KIND chosen so the CLI maps it to the right exit code +/// ([`crate::cli::ExitCode::from_io_error`]): `PermissionDenied` (the ACL split → "elevate") +/// when the file is present but unreadable, else `NotFound` ("start the node"). +pub fn load_token_readonly() -> std::io::Result { + let path = control_token_path(); + match std::fs::read_to_string(&path) { + Ok(s) if !s.trim().is_empty() => Ok(s.trim().to_string()), + // Present but blank/unreadable, or absent → never mint; surface the exact remedy. + other => { + let kind = if path.exists() { + std::io::ErrorKind::PermissionDenied + } else { + std::io::ErrorKind::NotFound + }; + // Drop the raw read value; the remedy is the actionable message. + drop(other); + Err(std::io::Error::new(kind, control_token_remedy())) + } + } } /// [`load_or_create_token`] for an explicit path (so tests use a temp dir and never -/// touch the real config). Reads an existing non-blank token; otherwise generates, -/// persists (owner-only on Unix), and returns a fresh one. +/// touch the real config). Reads an existing non-blank token ONLY when its file is owned by a +/// trusted principal ([`crate::state::token_file_is_trusted`], #501 residual); otherwise (or +/// when absent) generates, persists (owner-only on Unix), and returns a fresh one. pub fn load_or_create_token_at(path: &Path) -> std::io::Result { if let Ok(existing) = std::fs::read_to_string(path) { let t = existing.trim().to_string(); if !t.is_empty() { - return Ok(t); + // #501 residual: TRUST a pre-existing token ONLY when its file is owned by a + // trusted principal. An attacker who can plant a KNOWN token in the machine-wide + // state dir — a `%PROGRAMDATA%` squat, or the narrow window during a service + // harden — would otherwise have the daemon (LocalSystem) read + trust it, learning + // the control token → full local node control (a local privilege escalation). A + // foreign-owned token is deleted + regenerated, so the daemon only ever trusts a + // token it (or a trusted principal: SYSTEM/Administrators/root) owns. + if crate::state::token_file_is_trusted(path, crate::state::running_as_service()) { + return Ok(t); + } + let _ = std::fs::remove_file(path); } } let token = generate_token(); if let Some(dir) = path.parent() { - std::fs::create_dir_all(dir)?; + // Create the state dir with a RESTRICTIVE ACL (not the world-readable default of + // a machine-wide `%PROGRAMDATA%`) — see [`crate::state::ensure_dir_restricted`]. + crate::state::ensure_dir_restricted(dir)?; } std::fs::write(path, &token)?; restrict_permissions(path); Ok(token) } -/// Restrict the token file to owner read/write where the OS supports it (Unix -/// `0600`). On Windows the default ACL on a per-user profile dir already scopes it -/// to the user, and loopback-only binding is the primary control; this is -/// best-effort defense-in-depth, so a failure is ignored. -#[cfg(unix)] +/// Restrict a control/auth file so it is not readable by every local user. Delegates to +/// [`crate::state::restrict_file`]: Unix `0600`; on Windows the file inherits the tight, +/// inheritable ACL of the machine-wide state dir ([`crate::state::ensure_dir_restricted`]) — +/// critical now that the file lives under `%PROGRAMDATA%`, whose default would otherwise let +/// every local user read it (a local privilege-escalation vector, #501). Best-effort (a +/// failure is ignored — loopback bind + token possession are the primary gate). pub(crate) fn restrict_permissions(path: &Path) { - use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); + crate::state::restrict_file(path); } -#[cfg(not(unix))] -pub(crate) fn restrict_permissions(_path: &Path) {} /// Generate a fresh 64-hex control token from 32 bytes of OS randomness. fn generate_token() -> String { @@ -453,6 +534,10 @@ pub struct ControlCtx { pub node: Arc, /// The node's config.json path (pins + upstream override live here). pub config_path: PathBuf, + /// The machine-wide daemon STATE dir (#501) — where the control token + + /// `paired-tokens.json` live (NOT the per-user config dir). The pairing-admin + /// methods read/write the paired-token store from here. + pub state_dir: PathBuf, /// The loopback `host:port` the node is bound to (status/config). pub addr: String, /// The upstream DIG RPC the node proxies/syncs to. @@ -490,11 +575,11 @@ pub async fn dispatch_control(ctx: &ControlCtx, id: Value, method: &str, params: "control.sync.trigger" => sync_trigger(ctx, id, params).await, // Pairing administration (#280) — reached only with the MASTER token (the // gate blocks a paired token from these, see `is_pairing_admin_method`). - "control.pairing.list" => crate::pairing::list(&ctx.pairings, &ctx.config_path, id), + "control.pairing.list" => crate::pairing::list(&ctx.pairings, &ctx.state_dir, id), "control.pairing.approve" => { - crate::pairing::approve(&ctx.pairings, &ctx.config_path, id, params) + crate::pairing::approve(&ctx.pairings, &ctx.state_dir, id, params) } - "control.pairing.revoke" => crate::pairing::revoke(&ctx.config_path, id, params), + "control.pairing.revoke" => crate::pairing::revoke(&ctx.state_dir, id, params), // Control methods the shell does not own are delegated to the NODE's own // control surface (`control.peerStatus` / `control.subscribe` / // `control.unsubscribe` / `control.listSubscriptions` — the node's persisted @@ -984,6 +1069,122 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// SECURITY (#501 residual): a pre-existing control-token file that is NOT owned by a + /// trusted principal (here forced group/other-readable, so not owner-only) MUST be DELETED + /// and REGENERATED — never returned — so a planted/squatted token can never become the + /// trusted one (which would hand an attacker full local node control). Unix-gated: it + /// relies on mode bits (CI runs on Linux). Skipped when running as root, where a + /// root-owned file is legitimately trusted regardless of mode. + #[cfg(unix)] + #[test] + fn foreign_owned_token_file_is_regenerated_not_trusted() { + use std::os::unix::fs::MetadataExt; + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join(format!( + "dig-node-token-untrusted-{}-{}", + std::process::id(), + line!() + )); + let path = dir.join(CONTROL_TOKEN_FILE); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let planted = "planted0".repeat(8); // a KNOWN 64-char attacker value (non-empty) + std::fs::write(&path, &planted).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + let running_as_root = std::fs::metadata(&path) + .map(|m| m.uid() == 0) + .unwrap_or(false); + let got = load_or_create_token_at(&path).unwrap(); + if !running_as_root { + assert_ne!( + got, planted, + "an untrusted (group-readable) token must be regenerated, not returned" + ); + assert_eq!( + got.len(), + 64, + "the regenerated token is a fresh 64-hex value" + ); + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!( + mode & 0o077, + 0, + "the regenerated token must be owner-only 0600 (got {mode:o})" + ); + } + let _ = std::fs::remove_dir_all(&dir); + } + + /// A trusted (owner-only `0600`, current-user-owned) pre-existing token is loaded AS-IS — + /// never regenerated — so a legit token stays stable across runs (#501 residual). + #[cfg(unix)] + #[test] + fn trusted_owner_only_token_file_is_kept() { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join(format!( + "dig-node-token-trusted-{}-{}", + std::process::id(), + line!() + )); + let path = dir.join(CONTROL_TOKEN_FILE); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let existing = "a".repeat(64); + std::fs::write(&path, &existing).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + let got = load_or_create_token_at(&path).unwrap(); + assert_eq!( + got, existing, + "a trusted owner-only token must be loaded as-is, not regenerated" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + /// SECURITY (#501): the control token grants full local control, so the created + /// file MUST NOT be readable by other local users. On Unix that is a hard `0600` + /// assertion (no group/other bits) — the CI-gated path (CI runs on Linux). On + /// Windows the restriction is applied via `icacls` (asserted separately in a + /// Windows-gated test / by the orchestrator's adversarial ACL check). + #[cfg(unix)] + #[test] + fn created_token_file_is_not_world_or_group_readable() { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join(format!( + "dig-node-token-perms-{}-{}", + std::process::id(), + line!() + )); + let path = dir.join(CONTROL_TOKEN_FILE); + let _ = std::fs::remove_dir_all(&dir); + load_or_create_token_at(&path).unwrap(); + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!( + mode & 0o077, + 0, + "token must have NO group/other permission bits (got {mode:o})" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + /// The remedy hint names the concrete token path and, when the token is absent from + /// the caller's perspective, tells them to start the node — never the old generic + /// "" wording. + #[test] + fn control_token_remedy_names_a_concrete_path() { + let remedy = control_token_remedy(); + assert!( + remedy.contains("control token") || remedy.contains("control-token"), + "remedy should mention the control token: {remedy}" + ); + assert!( + remedy.contains("dig-node") + || remedy.contains("state dir") + || remedy.contains('/') + || remedy.contains('\\'), + "remedy should name a path or command: {remedy}" + ); + } + #[test] fn parse_store_ref_validates_hex_and_splits_capsule() { let store = "a".repeat(64); diff --git a/crates/dig-node-service/src/lib.rs b/crates/dig-node-service/src/lib.rs index 35c1448..c678f4c 100644 --- a/crates/dig-node-service/src/lib.rs +++ b/crates/dig-node-service/src/lib.rs @@ -38,11 +38,19 @@ pub mod config; pub mod content; pub mod control; pub mod meta; +/// `dig-node open ` (#389): the OS scheme-handler target the +/// installer registers for `chia://` + `urn:dig:chia:`. Strictly validates the untrusted +/// handler argument, then opens the user's default browser at the resolving URL. See [`open`]. +pub mod open; pub mod pair; pub mod pairing; pub mod rpc; pub mod server; pub mod service; +/// The machine-wide, identity-independent daemon STATE dir (#501): where the control token + +/// paired-token store live so the daemon (which may run as a service under a different OS +/// account) and the operator CLI resolve the SAME files. See [`state`]. +pub mod state; pub mod wallet_authz; /// Windows Service Control Protocol entrypoint — only meaningful on Windows, where diff --git a/crates/dig-node-service/src/main.rs b/crates/dig-node-service/src/main.rs index c979312..997277f 100644 --- a/crates/dig-node-service/src/main.rs +++ b/crates/dig-node-service/src/main.rs @@ -25,6 +25,7 @@ use clap::{Parser, Subcommand}; use dig_node_service::cli::{error_envelope, success_envelope, ExitCode, Outcome}; use dig_node_service::config::Config; +use dig_node_service::open; use dig_node_service::pair::{self, PairAction}; use dig_node_service::{serve, service, VERSION}; @@ -70,6 +71,13 @@ enum Command { #[command(subcommand)] action: Option, }, + /// Open a DIG link in the default browser (#389). The OS scheme-handler target the + /// installer registers for `chia://` + `urn:dig:chia:`. Accepts ONLY those two schemes, + /// resolves via the local node's serve URL, and never invokes a shell. + Open { + /// The DIG link (`chia://[:]/` or `urn:dig:chia:<…>`). + link: String, + }, } /// `dig-node pair` sub-actions. With none, lists pending requests + issued tokens. @@ -100,6 +108,7 @@ impl Command { Command::Stop => "stop", Command::Status => "status", Command::Pair { .. } => "pair", + Command::Open { .. } => "open", } } } @@ -129,6 +138,7 @@ fn main() -> std::process::ExitCode { }; render(pair::run(&config, pair_action), action, json) } + Command::Open { link } => render(open::run(&config, &link), action, json), }; std::process::ExitCode::from(exit.code()) } diff --git a/crates/dig-node-service/src/meta.rs b/crates/dig-node-service/src/meta.rs index 9f8175e..eb0fffd 100644 --- a/crates/dig-node-service/src/meta.rs +++ b/crates/dig-node-service/src/meta.rs @@ -452,8 +452,8 @@ pub enum ErrorCode { /// `-32030` — a `control.*` (CONTROL/admin) method was called without a valid /// local control token. The control surface is loopback-only AND locally /// authorized: a same-host controller (the DIG Browser "My Node" UI) must read - /// the node's control token from its config dir and present it. Read methods - /// are NOT gated; only the mutating/management control namespace is. Shell error. + /// the node's control token from the machine-wide state dir and present it. Read + /// methods are NOT gated; only the mutating/management control namespace is. Shell error. /// (Canonical dig-rpc-types §10 code; `-32020` is RESERVED for onion routing.) Unauthorized, /// `-32031` — a control operation the embedded dig-node cannot perform on this @@ -710,7 +710,8 @@ pub fn openrpc_document() -> Value { let auth_note = if m.requires_auth { " CONTROL method: requires the local control token (loopback-only + \ locally authorized — present it as the X-Dig-Control-Token header or \ - params._control_token; read it from /control-token)." + params._control_token; read it from the machine-wide state dir's \ + control-token file, e.g. via `dig-node pair`)." } else { "" }; diff --git a/crates/dig-node-service/src/open.rs b/crates/dig-node-service/src/open.rs new file mode 100644 index 0000000..d458066 --- /dev/null +++ b/crates/dig-node-service/src/open.rs @@ -0,0 +1,383 @@ +//! `dig-node open ` (#389) — the OS scheme-handler target. +//! +//! The dig-installer registers the OS handlers for `chia://` and `urn:dig:chia:` to invoke +//! `dig-node open "%1"`. This subcommand is therefore the OS-level fallback resolver for a DIG +//! link that no in-browser DIG extension intercepted (a link clicked outside a browser, or in a +//! browser without the extension): it opens the user's DEFAULT browser at the node's LOCAL serve +//! URL (`/s/[:]/`, the #289 plaintext content surface), so the node — the +//! resolver of record (#365 thin-client) — serves the decrypted content and any installed DIG +//! extension can still verify it. It NEVER opens dig-node's own GUI (it has none). +//! +//! # Security — the argument is UNTRUSTED +//! +//! `%1` arrives from an OS protocol handler, so a hostile web page can invoke the registered +//! scheme with an attacker-chosen argument. This module therefore: +//! +//! 1. **Validates STRICTLY.** Only `chia://` and `urn:dig:chia:` are accepted (case-insensitive +//! scheme); every other scheme (`file:`, `javascript:`, `data:`, `http:`, …) is rejected. The +//! store reference must be canonical 64-hex (`storeId` or `storeId:root`). Shell +//! metacharacters, control characters, whitespace, and `..` path traversal are rejected +//! outright — even though the launch path never uses a shell (defense in depth). +//! 2. **Never touches a shell.** The resolved URL is handed to the OS "open a URL" facility as a +//! SINGLE, non-shell argv entry (`rundll32 url.dll,FileProtocolHandler` on Windows, +//! `xdg-open` on Linux, `/usr/bin/open` on macOS) — never `cmd /c start` / `sh -c`, so even a +//! crafted argument cannot inject a command. The launcher is behind [`UrlLauncher`] so tests +//! assert the exact URL without spawning a real browser. + +use serde_json::json; + +use crate::cli::Outcome; +use crate::config::Config; + +/// Shell metacharacters (and quoting/grouping characters) rejected anywhere in the link. The +/// launch path never uses a shell, so this is defense-in-depth against the untrusted OS argument +/// — it also keeps a well-formed DIG link from ever carrying surprising bytes into the serve URL. +const DISALLOWED: &[char] = &[ + '&', '|', ';', '`', '$', '<', '>', '(', ')', '{', '}', '[', ']', '!', '*', '"', '\'', '\\', + '^', '\n', '\r', ' ', '\t', +]; + +/// A validated, normalized DIG link: a 64-hex store id, an optional 64-hex root (a capsule +/// pin), and the (traversal-free) resource path within the store. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DigLink { + /// The 64-hex store id. + pub store_id: String, + /// The optional 64-hex root hash (present for a `storeId:root` capsule reference). + pub root: Option, + /// The resource path within the store (may be empty for the store root). + pub path: String, +} + +impl DigLink { + /// The `storeId` or `storeId:root` reference the `/s//…` serve route expects. + fn store_ref(&self) -> String { + match &self.root { + Some(r) => format!("{}:{}", self.store_id, r), + None => self.store_id.clone(), + } + } +} + +/// Abstracts the OS "open this URL in the default browser / protocol handler" action so the +/// launch path is unit-testable (a fake asserts the exact URL) and the real implementation never +/// goes through a shell. `url` is ALREADY strictly validated + built by this module. +pub trait UrlLauncher { + /// Open `url` in the user's default browser. Implementations MUST pass `url` as a single, + /// non-shell argument. + fn open_url(&self, url: &str) -> std::io::Result<()>; +} + +/// The real OS launcher. Opens `url` via a per-OS facility with the URL as a SINGLE argv entry — +/// never a shell — so a crafted URL cannot inject a command. Fire-and-forget: a failure to SPAWN +/// the launcher (e.g. `xdg-open` not installed) is surfaced; the launcher's own exit code is not +/// awaited (it hands off to the browser and exits). +pub struct OsLauncher; + +impl UrlLauncher for OsLauncher { + fn open_url(&self, url: &str) -> std::io::Result<()> { + use std::process::{Command, Stdio}; + let mut cmd; + #[cfg(target_os = "windows")] + { + // `rundll32 url.dll,FileProtocolHandler ` invokes the registered URL/protocol + // handler with the URL as one argv entry and NO shell — unlike `cmd /c start`, which + // would parse `&`/`^`/`|` in the argument. + cmd = Command::new("rundll32.exe"); + cmd.arg("url.dll,FileProtocolHandler").arg(url); + } + #[cfg(target_os = "macos")] + { + cmd = Command::new("/usr/bin/open"); + cmd.arg(url); + } + #[cfg(all(unix, not(target_os = "macos")))] + { + cmd = Command::new("xdg-open"); + cmd.arg(url); + } + #[cfg(not(any(windows, unix)))] + { + return Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "dig-node open: no URL launcher for this platform", + )); + } + #[cfg(any(windows, unix))] + { + cmd.stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + Ok(()) + } + } +} + +/// Run `dig-node open `: validate + normalize the link, build the local serve URL, and open +/// it in the default browser via the real [`OsLauncher`]. See [`run_with`] for the injectable form. +pub fn run(config: &Config, link: &str) -> std::io::Result { + run_with(config, link, &OsLauncher) +} + +/// [`run`] with an injected [`UrlLauncher`] (so tests assert the exact URL without spawning a +/// browser). A rejected link returns an `InvalidInput` error (→ `USAGE` exit) and the launcher is +/// NEVER invoked. +pub fn run_with( + config: &Config, + link: &str, + launcher: &dyn UrlLauncher, +) -> std::io::Result { + let parsed = normalize(link).map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("dig-node open: {e}"), + ) + })?; + let url = serve_url(config, &parsed); + launcher.open_url(&url)?; + Ok(Outcome::new( + format!("dig-node: opening {url} in your default browser"), + json!({ + "opened": true, + "url": url, + "store_id": parsed.store_id, + "root": parsed.root, + "path": parsed.path, + }), + )) +} + +/// Strictly validate + normalize a DIG link into a [`DigLink`]. PURE. Accepts ONLY `chia://` and +/// `urn:dig:chia:` (case-insensitive scheme); rejects any other scheme, shell metacharacters, +/// control characters, whitespace, `..` path traversal, and a non-64-hex store reference. +pub fn normalize(input: &str) -> Result { + let s = input.trim(); + if s.is_empty() { + return Err("empty link".to_string()); + } + if let Some(bad) = s.chars().find(|c| c.is_control()) { + return Err(format!( + "control character not allowed (U+{:04X})", + bad as u32 + )); + } + if let Some(bad) = s.chars().find(|c| DISALLOWED.contains(c)) { + return Err(format!("disallowed character in link: {bad:?}")); + } + // Scheme gate — ONLY chia:// or urn:dig:chia: (case-insensitive), nothing else. + let rest = strip_prefix_ci(s, "chia://") + .or_else(|| strip_prefix_ci(s, "urn:dig:chia:")) + .ok_or_else(|| { + "only chia:// and urn:dig:chia: links are allowed (this link's scheme is rejected)" + .to_string() + })?; + // Drop any query/fragment — the serve path is store-ref + resource path only. + let rest = rest + .split(['?', '#']) + .next() + .unwrap_or("") + .trim_start_matches('/'); + // Split the store reference from the resource path. + let (store_ref, path) = match rest.split_once('/') { + Some((r, p)) => (r, p), + None => (rest, ""), + }; + if path.split('/').any(|seg| seg == "..") { + return Err("path traversal (`..`) not allowed".to_string()); + } + // The store reference MUST be canonical 64-hex (storeId or storeId:root) — this rejects any + // non-hex authority (a hostname, an IP, an attacker string) outright. + let (store_id, root) = crate::control::parse_store_ref(store_ref)?; + Ok(DigLink { + store_id, + root, + path: path.to_string(), + }) +} + +/// Build the node's LOCAL serve URL for a validated link: `http://:/s//` +/// (the #289 plaintext content route). The host/port come from the node's own config (default +/// `localhost:9778`), so the URL always reaches the running node on this machine. +fn serve_url(config: &Config, link: &DigLink) -> String { + let host = browser_host(config); + let port = config.port; + let store_ref = link.store_ref(); + if link.path.is_empty() { + format!("http://{host}:{port}/s/{store_ref}/") + } else { + format!("http://{host}:{port}/s/{store_ref}/{}", link.path) + } +} + +/// The host to put in the browser URL: the operator's explicit `DIG_NODE_HOST` when set (bracketed +/// for IPv6), else `localhost` — friendlier than `127.0.0.1` and equally reachable (the node binds +/// both loopback families on the port). +fn browser_host(config: &Config) -> String { + match config.host { + Some(ip) if ip.is_ipv6() => format!("[{ip}]"), + Some(ip) => ip.to_string(), + None => "localhost".to_string(), + } +} + +/// Case-insensitive prefix strip that is safe on non-ASCII input (never panics on a char +/// boundary): returns the remainder after `prefix` when `s` starts with it ignoring ASCII case. +fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> { + let head = s.get(..prefix.len())?; + if head.eq_ignore_ascii_case(prefix) { + Some(&s[prefix.len()..]) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + fn store() -> String { + "a".repeat(64) + } + fn root() -> String { + "b".repeat(64) + } + + /// A test launcher that records the URL it was asked to open (and proves the real launcher was + /// never reached, so no browser/shell is spawned in CI). + #[derive(Default)] + struct FakeLauncher { + opened: Mutex>, + } + impl UrlLauncher for FakeLauncher { + fn open_url(&self, url: &str) -> std::io::Result<()> { + self.opened.lock().unwrap().push(url.to_string()); + Ok(()) + } + } + + #[test] + fn accepts_chia_scheme_with_store_only() { + let n = normalize(&format!("chia://{}", store())).unwrap(); + assert_eq!(n.store_id, store()); + assert_eq!(n.root, None); + assert_eq!(n.path, ""); + } + + #[test] + fn accepts_chia_scheme_with_capsule_and_path() { + let n = normalize(&format!("chia://{}:{}/index.html", store(), root())).unwrap(); + assert_eq!(n.store_id, store()); + assert_eq!(n.root.as_deref(), Some(root().as_str())); + assert_eq!(n.path, "index.html"); + } + + #[test] + fn accepts_urn_dig_chia_scheme() { + let n = normalize(&format!("urn:dig:chia:{}/a/b.css", store())).unwrap(); + assert_eq!(n.store_id, store()); + assert_eq!(n.path, "a/b.css"); + } + + #[test] + fn scheme_match_is_case_insensitive() { + assert!(normalize(&format!("CHIA://{}", store())).is_ok()); + assert!(normalize(&format!("Urn:Dig:Chia:{}", store())).is_ok()); + } + + #[test] + fn strips_query_and_fragment() { + let n = normalize(&format!("chia://{}/p?x=1#frag", store())).unwrap(); + assert_eq!(n.path, "p"); + } + + #[test] + fn rejects_dangerous_schemes() { + for bad in [ + "file:///etc/passwd", + "javascript:alert(1)", + "data:text/html,