Skip to content

feat: machine-wide control-token state dir + dig-node open handler (#501, #389)#32

Merged
MichaelTaylor3d merged 6 commits into
mainfrom
feat/state-dir-and-open
Jul 13, 2026
Merged

feat: machine-wide control-token state dir + dig-node open handler (#501, #389)#32
MichaelTaylor3d merged 6 commits into
mainfrom
feat/state-dir-and-open

Conversation

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor

TLDR

Fixes #501 (a service-installed node's control token is unreachable by the operator CLI) and implements #389 (dig-node open as the OS chia:// / 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 under C:\Windows\System32\config\systemprofile\... while dig-node pair (as the user) resolved a different path — and minted a phantom token the node never trusts → every control.* failed -32030.

Fix: a new state::state_dir() — machine-wide + identity-INDEPENDENT — holds ONLY the control token + paired-tokens.json. The bulk per-user .dig cache + config.json stay exactly as-is (#96, untouched).

  • Locations: Windows %PROGRAMDATA%\DigNode; Linux /var/lib/dig-node (→ /etc/dig-node); macOS /Library/Application Support/DigNode. Env override DIG_NODE_STATE_DIR. Back-compat: a non-service dev dig-node run with no machine dir falls back to the legacy per-user path — nothing that worked before breaks.
  • Decoupled control_token_path() from config_path(); ControlCtx/AppState carry a state_dir; all pairing/paired-token call sites use it.
  • Service self-identifies via DIG_NODE_RUN_CONTEXT=service (set by install into the service env + by the Windows SCM entrypoint); only a service bootstraps the machine dir, never a bare CLI.
  • dig-node install pre-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 pair now reads the token READ-ONLY (load_token_readonly) — never mints a phantom (the root of the original bug) — and the -32030 error + 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:

  • Unix: dir 0700, file 0600 (never 0644). CI-gated assertion (created_token_file_is_not_world_or_group_readable).
  • Windows: inheritance removed (icacls /inheritance:r) + inheritable full to EXACTLY SYSTEM + Administrators + the creating user, nobody else; the token file inherits the dir ACL (restrict_file is a no-op on Windows by design, so a SYSTEM re-run can't strip the install-user's inherited grant).
  • Creator gets full (not read-only): the minting process must write the token; an elevated installer retains read. The security-critical property — every OTHER local user denied — holds either way. (I corrected the prior draft's read-only grant, which made fresh-create fail with Access Denied; verified via the icacls path on Windows + the Unix 0600 test.)
  • Threat model + ACL documented in SPEC §7.3a.

Deliverable B — #389: dig-node open <link>

The OS handler target (dig-node open "%1") for chia:// + urn:dig:chia:. Treats %1 as UNTRUSTED (a hostile page can invoke a registered scheme):

  • Strict validation: only chia:// / urn:dig:chia: (case-insensitive); store ref must be canonical 64-hex; rejects file:/javascript:/data:/http(s):, shell metacharacters, control chars, whitespace, .. traversal → exits USAGE (2), launches nothing.
  • No shell, ever: the resolved URL is launched with the URL as a SINGLE non-shell argv entry (Windows rundll32 url.dll,FileProtocolHandler, Linux xdg-open, macOS open), behind an injectable UrlLauncher trait so tests assert the exact URL without spawning a browser.
  • Behavior: opens the DEFAULT browser at the node's local serve URL http://<host>:<port>/s/<storeId>[:<root>]/<path> (#289 route; default localhost:9778). It does NOT re-open chia:// 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 impact on control_token_path, upstream)

HIGH: direct caller load_or_create_tokenpair::run + server::build_stateserve_with_shutdown. The signature change (control_token_path() now arg-less) has ONE direct caller (load_or_create_token, updated); pair switched to read-only load; build_state resolves + stores state_dir. All pairing call sites (control.rs dispatch + 4 in server.rs) migrated config_pathstate_dir. Integration harnesses set DIG_NODE_STATE_DIR for hermeticity. No consumers outside this crate touch these symbols.

Version

0.26.0 → 0.27.0minor (feat; additive + back-compat: new state_dir, new open subcommand, 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 OS chia:///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 open scheme-handler surface first.

🤖 Generated with Claude Code

MichaelTaylor3d and others added 4 commits July 12, 2026 19:23
…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>
MichaelTaylor3d and others added 2 commits July 12, 2026 21:47
…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>
@MichaelTaylor3d MichaelTaylor3d merged commit 7fb6809 into main Jul 13, 2026
9 checks passed
@MichaelTaylor3d MichaelTaylor3d deleted the feat/state-dir-and-open branch July 13, 2026 05:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant