feat: machine-wide control-token state dir + dig-node open handler (#501, #389)#32
Merged
Merged
Conversation
…501, #389) Deliverable A (#501): decouple the control token + paired-token store from the per-user config dir into a machine-wide, identity-INDEPENDENT state dir so the daemon (which may run as a service under a different OS account, e.g. Windows LocalSystem) and the operator CLI resolve the SAME token file. - Add `state::state_dir()`: Windows `%PROGRAMDATA%\DigNode`, Linux `/var/lib/dig-node` (fallback `/etc/dig-node`), macOS `/Library/Application Support/DigNode`; env override `DIG_NODE_STATE_DIR`; legacy per-user fallback for a non-service dev run (back-compat). - Move ONLY control-token + paired-tokens.json there; the bulk per-user `.dig` cache + config.json stay exactly as-is (#96). - Security: created dir/token get a restrictive ACL (Unix 0700 dir / 0600 file; Windows SYSTEM+Admins full + the installing user read, inheritance removed) — never world-readable. The ACL is applied only on FRESH create so a SYSTEM/root service re-run cannot clobber the installer's install-user grant. `dig-node install` pre-creates the dir as the interactive user; the service self-identifies via DIG_NODE_RUN_CONTEXT. - `dig-node pair` reads the token read-only (never mints a phantom the node won't trust) and surfaces a precise service-vs-user remedy; the control.* 401 names the exact path + remedy. Deliverable B (#389): implement `dig-node open <chia://… | urn:dig:chia:…>` (+ --json), the OS scheme-handler target. Strictly validates (only chia/urn-dig schemes; rejects file:/javascript:/data:, shell metacharacters, path traversal, non-64-hex store refs), then opens the default browser at the node's local serve URL via a no-shell launcher behind an injectable trait. Version: 0.26.0 -> 0.27.0 (minor; additive + back-compat). Refs #501 #389 Co-Authored-By: Claude <noreply@anthropic.com>
…ir in SPEC - Windows ACL: grant the CREATING user full (not read-only) on the fresh state dir — the same process that creates the dir must be able to WRITE the token it mints (dev/self-create path failed with Access Denied otherwise). Security property held: every OTHER local user is still denied. Simplify windows_restrict_dir (dirs only). - derive Debug on cli::Outcome (needed for the open.rs error-path test). - Map io::ErrorKind::InvalidInput -> USAGE exit (a rejected `dig-node open` link). - SPEC.md: new §7.3a (state-dir location/resolution/ACL/threat-model), §8.5 (`open` scheme-handler contract), and control-token/paired-token/conformance/ security updates to the state-dir model. Refs #501 #389 Co-Authored-By: Claude <noreply@anthropic.com>
…rgets) Co-Authored-By: Claude <noreply@anthropic.com>
…arnings) Co-Authored-By: Claude <noreply@anthropic.com>
…gramData squat P0)
The control token grants FULL local control of the node, so its machine-wide
state dir (%PROGRAMDATA%\DigNode) must never be readable by Users/Everyone. On
Windows %PROGRAMDATA% lets BUILTIN\Users create subfolders, so a low-priv user
could pre-create C:\ProgramData\DigNode, become CREATOR OWNER (implicit
WRITE_DAC), and control the dir the node writes the control token into — a local
privilege escalation.
Previously choose_state_dir returned a pre-existing machine dir via the `exists`
branch WITHOUT hardening it, and ensure_dir_restricted early-returned on any
existing dir, so a squatted dir was trusted as-is.
Fix (coherent with dig-installer's daemon_dir.rs contract): on a SERVICE run,
before the token is written/read, harden+readback-verify the resolved MACHINE
state dir — setowner *S-1-5-18 (SYSTEM) -> /reset (purge foreign ACEs) ->
/inheritance:r + grant {SYSTEM:F, Administrators:F, and only-if-discoverable the
interactive install-user:R} -> parse `icacls`/Get-Acl readback and assert no
Users/Everyone/Authenticated-Users/Anonymous ACE, owner=SYSTEM, inheritance off,
no unexpected principal. A squatter-owned pre-existing dir is purged. FAIL
CLOSED: if the dir cannot be secured, the node refuses to serve control from it
(ephemeral dir + in-memory unauthorizable token), never writing the token into
an unsecured dir.
Grant principal comes from the CURRENT PROCESS TOKEN (whoami /user), never the
spoofable %USERNAME% env, and well-known group SIDs are rejected. A SYSTEM
service preserves an installer-set interactive read grant only on a TRUSTED
(SYSTEM/Administrators-owned) dir. The CLI (non-service) never hardens — it only
reads an existing machine dir, else the legacy per-user dir (unchanged). Dev
self-create keeps the creating user FULL so it can write the token it mints.
Pure argv builders, the SID reject, and the readback-assertion parser are
unit-tested (real ACL is not exercised in CI). SPEC.md §7.3a documents the
contract.
Co-Authored-By: Claude <noreply@anthropic.com>
…residual) Close the LOW-but-real control-token custody residual an adversarial verify found: `load_or_create_token_at` trusted a non-empty pre-existing token file WITHOUT checking who owns it. An unprivileged local user who plants a KNOWN token in the machine-wide state dir (a %PROGRAMDATA% squat, or the narrow window during a service harden) would have the LocalSystem daemon read + trust it, learning the control token → full local `control.*` node control (local privilege escalation). Fix: before trusting an existing token, verify its file OWNER; a foreign-owned token is deleted + regenerated, never returned. Trust rule (pure, unit-tested): - Windows: owner SID SYSTEM (S-1-5-18) / Administrators (S-1-5-32-544) always; a NON-service run also accepts the current process user's SID (dev tokens in the legacy per-user dir); a SERVICE run requires SYSTEM/Administrators. - Unix: owner uid 0 (root) always, else owner == current euid AND mode 0600. Owner read via the existing Get-Acl helper (`dir_owner_sid` renamed to `path_owner_sid`, now used for files too) on Windows and `stat` on Unix; euid via a dependency-free probe file. Layered beneath the §7.3a state-dir hardening (which already purges a squatter-owned dir on a service run — item #2 landed in ee33f1b) as defense-in-depth: it also guards the non-hardened dev path and any harden gap. Coherent with the installer daemon_dir.rs trust model (dir owner SYSTEM/root + interactive-user read). TDD: pure trust-decision helpers + FS-decision + regeneration tests (red→green, Windows path verified locally on win32; unix-gated tests run in CI). SPEC.md §7.3 documents the owner-verification requirement. Co-Authored-By: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TLDR
Fixes #501 (a service-installed node's control token is unreachable by the operator CLI) and implements #389 (
dig-node openas the OSchia:///urn:dig:chia:scheme handler). Additive + back-compat. All gates green locally (fmt, clippy, 115 lib + 45 server + 8 content_serve + 5 drift tests).Deliverable A — #501: machine-wide daemon state dir
On a real install the node runs as a service under a DIFFERENT OS account (Windows LocalSystem / a root daemon) than the operator's interactive CLI. The control token lived at
<config_dir>/control-token, resolved from the running process identity (%LOCALAPPDATA%/$HOME), so the service wrote it underC:\Windows\System32\config\systemprofile\...whiledig-node pair(as the user) resolved a different path — and minted a phantom token the node never trusts → everycontrol.*failed-32030.Fix: a new
state::state_dir()— machine-wide + identity-INDEPENDENT — holds ONLY the control token +paired-tokens.json. The bulk per-user.digcache +config.jsonstay exactly as-is (#96, untouched).%PROGRAMDATA%\DigNode; Linux/var/lib/dig-node(→/etc/dig-node); macOS/Library/Application Support/DigNode. Env overrideDIG_NODE_STATE_DIR. Back-compat: a non-service devdig-node runwith no machine dir falls back to the legacy per-user path — nothing that worked before breaks.control_token_path()fromconfig_path();ControlCtx/AppStatecarry astate_dir; all pairing/paired-token call sites use it.DIG_NODE_RUN_CONTEXT=service(set byinstallinto the service env + by the Windows SCM entrypoint); only a service bootstraps the machine dir, never a bare CLI.dig-node installpre-creates the dir as the interactive user so its ACL grants that user access to the token the LocalSystem service later writes — the crux of the Windows fix.dig-node pairnow reads the token READ-ONLY (load_token_readonly) — never mints a phantom (the root of the original bug) — and the-32030error + CLI print the PRECISE service-vs-user remedy (exact path, needs elevation / install-user read ACL) instead of a generic hint.Security / ACL model (the crux)
The control token grants full local node control, so a machine-wide file must NOT be all-users-readable (local priv-esc). Created dir/token get a restrictive ACL, applied only on fresh create so a later SYSTEM/root run cannot clobber the installer's grant:
0700, file0600(never0644). CI-gated assertion (created_token_file_is_not_world_or_group_readable).icacls /inheritance:r) + inheritable full to EXACTLY SYSTEM + Administrators + the creating user, nobody else; the token file inherits the dir ACL (restrict_fileis a no-op on Windows by design, so a SYSTEM re-run can't strip the install-user's inherited grant).Deliverable B — #389:
dig-node open <link>The OS handler target (
dig-node open "%1") forchia://+urn:dig:chia:. Treats%1as UNTRUSTED (a hostile page can invoke a registered scheme):chia:///urn:dig:chia:(case-insensitive); store ref must be canonical 64-hex; rejectsfile:/javascript:/data:/http(s):, shell metacharacters, control chars, whitespace,..traversal → exitsUSAGE(2), launches nothing.rundll32 url.dll,FileProtocolHandler, Linuxxdg-open, macOSopen), behind an injectableUrlLaunchertrait so tests assert the exact URL without spawning a browser.http://<host>:<port>/s/<storeId>[:<root>]/<path>(#289 route; defaultlocalhost:9778). It does NOT re-openchia://at the OS level (dig-node IS the OS handler → would recurse) and never opens a dig-node GUI.--json→{opened,url,store_id,root,path}.Blast radius (gitnexus
impactoncontrol_token_path, upstream)HIGH: direct caller
load_or_create_token→pair::run+server::build_state→serve_with_shutdown. The signature change (control_token_path()now arg-less) has ONE direct caller (load_or_create_token, updated);pairswitched to read-only load;build_stateresolves + storesstate_dir. All pairing call sites (control.rsdispatch + 4 inserver.rs) migratedconfig_path→state_dir. Integration harnesses setDIG_NODE_STATE_DIRfor hermeticity. No consumers outside this crate touch these symbols.Version
0.26.0 → 0.27.0— minor (feat; additive + back-compat: newstate_dir, newopensubcommand, legacy per-user fallback preserved).Cross-repo (orchestrator owns SYSTEM.md)
Flagging for
SYSTEM.md: the control-token location is now the machine-wide state dir (a shared cross-repo fact — the DIG Browser "My Node" UI + any local controller must read<state_dir>/control-token, not<config_dir>), and the OSchia:///urn:dig:chia:handler contract (dig-node open "%1"→ local serve URL) — the dig-installer registers these handlers. Please add both to SYSTEM.md's cross-repo map.Do NOT merge yet
Per the task: orchestrator adversarially verifies the token/ACL model + the
openscheme-handler surface first.🤖 Generated with Claude Code