From b361907c8de058a0bd9c446ad5db5300e1e9190e Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 23 Aug 2026 12:13:27 -0700 Subject: [PATCH 1/6] chore(custody): open the offline seed-export lane (step 3 of dig_ecosystem#1701) WIP stub so the lane survives a cap. Co-Authored-By: Claude --- crates/dig-wallet/src/seed_export.rs | 1 + 1 file changed, 1 insertion(+) create mode 100644 crates/dig-wallet/src/seed_export.rs diff --git a/crates/dig-wallet/src/seed_export.rs b/crates/dig-wallet/src/seed_export.rs new file mode 100644 index 0000000..3fbb7f0 --- /dev/null +++ b/crates/dig-wallet/src/seed_export.rs @@ -0,0 +1 @@ +//! WIP: offline seed export (step 3 of dig_ecosystem#1701). From f6b306d189162958c0452c3a5e12b10f1fb939c1 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 23 Aug 2026 12:42:44 -0700 Subject: [PATCH 2/6] feat(custody): offline seed-export CLI so a custodied wallet survives custody removal Node-side USER custody is being retired. For a user who migrated their seed into this node and kept no independent copy, the node's seed file is the only surviving copy of their spend key, so the custody surface cannot be deleted until that key has been handed back. `dig-node wallet export-seed` is that hand-back. The population was re-measured and is ONE real blob: 248 bytes, first byte 0x01 -- the LEGACY EncryptedSeed layout -- sealed under a user password, sitting under a base directory current builds no longer resolve. Both facts shape the design: * the export goes through `seed_store::decrypt_seed`, which dispatches on the leading magic and accepts either on-disk format. An export built on `opaque::open` alone would fail on 100% of the real population; `legacy_seed_file_exports` fails by name under exactly that mutation. * `--path` overrides the default location, because a resolver-only command cannot reach the one file it exists to rescue. No network surface is added: no RPC method, no control-plane verb, no loopback endpoint. `wallet_action` now returns `Option` so the local verb is typed as reaching no control method rather than being mapped to a fake one. Running the command needs local filesystem access AND the wallet password -- the same two things an attacker would already need to open the file by hand. `--json` is refused: a recovery phrase must not be emitted as machine-readable output, the form most likely to be redirected into a file or a log. Read-only throughout -- nothing on disk is written, moved, zeroized or deleted -- and the mnemonic never reaches a log or an error string. Refs dig-node#329, dig_ecosystem#1701. Co-Authored-By: Claude --- Cargo.lock | 26 +- Cargo.toml | 2 +- SPEC.md | 10 + crates/dig-node-service/Cargo.toml | 5 + crates/dig-node-service/src/entrypoint.rs | 60 ++++- crates/dig-node-service/src/lib.rs | 4 + .../dig-node-service/src/seed_export_cli.rs | 190 ++++++++++++++ crates/dig-wallet/Cargo.toml | 2 +- crates/dig-wallet/src/lib.rs | 2 + crates/dig-wallet/src/seed_export.rs | 239 +++++++++++++++++- 10 files changed, 524 insertions(+), 16 deletions(-) create mode 100644 crates/dig-node-service/src/seed_export_cli.rs diff --git a/Cargo.lock b/Cargo.lock index cff5b4a..80cd87c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3073,7 +3073,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.144.0" +version = "0.145.0" dependencies = [ "async-trait", "axum", @@ -3098,6 +3098,7 @@ dependencies = [ "hex", "libc", "reqwest", + "rpassword", "rustls", "serde", "serde_json", @@ -3333,7 +3334,7 @@ dependencies = [ [[package]] name = "dig-wallet" -version = "0.30.0" +version = "0.31.0" dependencies = [ "async-trait", "axum", @@ -5964,6 +5965,17 @@ dependencies = [ "text-size", ] +[[package]] +name = "rpassword" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.61.2", +] + [[package]] name = "rsa" version = "0.9.10" @@ -6031,6 +6043,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "rtoolbox" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "rue-ast" version = "0.6.0" diff --git a/Cargo.toml b/Cargo.toml index d2277d1..f6f473f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ edition = "2021" # the ROOT manifest (`[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.144.0" +version = "0.145.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 7021a8f..8657703 100644 --- a/SPEC.md +++ b/SPEC.md @@ -2428,6 +2428,16 @@ on-disk master token = local-machine control), never an unauthenticated backdoor `control.wallet.peak`; `wallet broadcast ` → `control.wallet.broadcast`. The open chain reads (everything above except `broadcast` and `arrivals`) need no token; `broadcast` is token-gated like every other mutation, and carries only already-signed bytes (§908). +- `wallet export-seed [--path ]` reaches NO control method. It is a LOCAL, OFFLINE read of + this node's encrypted seed file: it decrypts the file under the wallet password supplied on the + terminal and prints the recovery phrase to stdout. It opens no socket, adds no `control.*` method + and adds no loopback endpoint, so it grants nothing beyond what local filesystem access plus the + password already grant. It accepts BOTH on-disk seed formats: the current `dig-keystore` container + and the legacy `EncryptedSeed` layout (leading version byte `1`) that pre-migration files use. + `--path` overrides the default location, because a file written by an older build can sit under a + base directory the current build no longer resolves. `--json` is REFUSED: a recovery phrase must + not be emitted as machine-readable output. The command exists only to let a user move a + node-custodied wallet out before node-side user custody is removed, and is deleted with it. - `updater [status]` → `control.updater.status`; `updater set-channel ` / `pause [--until ]` / `resume` / `check-now` → the matching `control.updater.*`. - `subscriptions [list]` → `control.listSubscriptions`; `subscriptions add|remove ` → diff --git a/crates/dig-node-service/Cargo.toml b/crates/dig-node-service/Cargo.toml index a33990c..484c9a6 100644 --- a/crates/dig-node-service/Cargo.toml +++ b/crates/dig-node-service/Cargo.toml @@ -56,6 +56,11 @@ path = "src/lib.rs" testkit = [] [dependencies] +# Reads the wallet password for the offline `wallet export-seed` rescue without echoing it +# to the terminal. Used ONLY there; it is removed with that command when node-side wallet +# custody goes. The alternative -- an env var or an echoed stdin line -- would put a spend +# key into shell history, a process listing, or a scrollback buffer. +rpassword = "7" # The canonical NODE engine library (crate `dig_node_core`) — a first-party sibling # crate in this workspace (no longer a stale git-pinned digstore alias). It IS the # rpc.dig.net-compatible node the native DIG Browser runs in-process (`handle_rpc`): diff --git a/crates/dig-node-service/src/entrypoint.rs b/crates/dig-node-service/src/entrypoint.rs index ab0f840..e7abadf 100644 --- a/crates/dig-node-service/src/entrypoint.rs +++ b/crates/dig-node-service/src/entrypoint.rs @@ -26,7 +26,9 @@ //! documented in [`crate::cli`] and the README. use std::ffi::OsStr; -use std::path::Path; +use std::path::{Path, PathBuf}; + +use crate::seed_export_cli; use clap::{CommandFactory, FromArgMatches, Parser, Subcommand}; @@ -363,6 +365,21 @@ enum WalletCommand { }, /// Print the public keys this node is currently following (READ-ONLY). Watched, + /// Print the recovery phrase of a wallet this node still holds, so you can move it + /// into the DIG app before node-side wallet custody is removed. + /// + /// LOCAL AND OFFLINE. It reads the seed file on this machine and needs that wallet's + /// password; it contacts no node, opens no port, and adds nothing to the node's network + /// surface. It prints the phrase to the console, so run it where nobody can read your + /// screen, and never into a file or a log. + /// + /// A phrase is the whole wallet: anyone who reads it can spend those funds. + ExportSeed { + /// Read this seed file instead of the default location. An older build may have + /// written yours elsewhere; the error text names the path that was tried. + #[arg(long)] + path: Option, + }, } /// `dig-node profile` sub-actions — the two halves of profile-body custody on this node. @@ -654,9 +671,22 @@ pub fn run() -> std::process::ExitCode { Command::Sync { action: cmd } => { render(control_cli::run(&config, sync_action(cmd)), action, json) } - Command::Wallet { action: cmd } => { - render(control_cli::run(&config, wallet_action(cmd)), action, json) - } + // `export-seed` is the one wallet verb that reaches no node: it is a local, + // offline read of the seed file, so it never goes near `control_cli`. + Command::Wallet { + action: WalletCommand::ExportSeed { path }, + } => seed_export_cli::run(path, json), + Command::Wallet { action: cmd } => match wallet_action(cmd) { + Some(control) => render(control_cli::run(&config, control), action, json), + // Every LOCAL wallet verb must be routed in an arm above. Today `export-seed` + // is the only one and it is, so this cannot fire; it degrades to a usage error + // rather than a panic so that adding a local verb and forgetting to route it + // misbehaves visibly instead of aborting the process. + None => { + eprintln!("error: this wallet verb is local-only and was not routed"); + ExitCode::Usage + } + }, Command::Profile { action: cmd } => { render(control_cli::run(&config, profile_action(cmd)), action, json) } @@ -725,8 +755,8 @@ fn sync_action(cmd: Option) -> ControlAction { } /// Map the `wallet` subcommand to its [`ControlAction`] (#1851, dig_ecosystem#2376). -fn wallet_action(cmd: WalletCommand) -> ControlAction { - match cmd { +fn wallet_action(cmd: WalletCommand) -> Option { + Some(match cmd { WalletCommand::Balance { address, asset } => { ControlAction::WalletBalance { address, asset } } @@ -753,7 +783,9 @@ fn wallet_action(cmd: WalletCommand) -> ControlAction { WalletCommand::Watch { public_keys } => ControlAction::WalletWatch { public_keys }, WalletCommand::Unwatch { public_keys } => ControlAction::WalletUnwatch { public_keys }, WalletCommand::Watched => ControlAction::WalletWatched, - } + // Handled locally before this mapping is reached; it names no control method. + WalletCommand::ExportSeed { .. } => return None, + }) } /// Map the `profile` subcommand to its [`ControlAction`]. @@ -1123,7 +1155,7 @@ mod tests { /// that is the whole point of these tests (see below). fn method_for_argv(argv: &[&str]) -> Option<&'static str> { match Cli::try_parse_from(argv).ok()?.command? { - Command::Wallet { action } => Some(wallet_action(action).method()), + Command::Wallet { action } => Some(wallet_action(action)?.method()), _ => None, } } @@ -1304,7 +1336,9 @@ mod tests { panic!("parsed to something other than `wallet`"); }; assert_eq!( - wallet_action(action).wire_params(), + wallet_action(action) + .expect("a control-plane wallet verb maps to an action") + .wire_params(), serde_json::json!({ "address": address, "asset": "dig" }), "the address and the non-default asset must both survive the mapping" ); @@ -1323,7 +1357,9 @@ mod tests { panic!("parsed to something other than `wallet`"); }; assert_eq!( - wallet_action(action).wire_params(), + wallet_action(action) + .expect("a control-plane wallet verb maps to an action") + .wire_params(), serde_json::json!({ "after_seq": 17, "limit": 3 }), "the cursor the caller resumed from must be the cursor that is asked for" ); @@ -1334,7 +1370,9 @@ mod tests { panic!("parsed to something other than `wallet`"); }; assert_eq!( - wallet_action(action).wire_params(), + wallet_action(action) + .expect("a control-plane wallet verb maps to an action") + .wire_params(), serde_json::json!({ "signed_bundle_hex": "0xfeed" }), "the bundle the operator typed must be the bundle that is pushed" ); diff --git a/crates/dig-node-service/src/lib.rs b/crates/dig-node-service/src/lib.rs index 9ad8e99..0137165 100644 --- a/crates/dig-node-service/src/lib.rs +++ b/crates/dig-node-service/src/lib.rs @@ -81,6 +81,10 @@ pub mod rpc; /// Shared OS-owner trust gate ([`security::dir_is_privileged`]): is a directory owned by a /// privileged principal (SYSTEM/Administrators or root) and not user-writable? Used by the self-heal /// spawn root (#565) and the TLS material root (#661) so the one Win32/unix owner check lives once. +/// The offline `wallet export-seed` rescue command: a local read of this node's +/// encrypted seed file. Adds no network surface, and is removed with node-side custody. +pub mod seed_export_cli; + pub mod security; /// The always-on self-heal driver (#584 beacon re-arm + #651 ext-forcelist reconcile): a privileged /// service periodically re-arms a drifted auto-update schedule + re-applies the extension diff --git a/crates/dig-node-service/src/seed_export_cli.rs b/crates/dig-node-service/src/seed_export_cli.rs new file mode 100644 index 0000000..6df496c --- /dev/null +++ b/crates/dig-node-service/src/seed_export_cli.rs @@ -0,0 +1,190 @@ +//! `dig-node wallet export-seed` — the offline rescue for a node-custodied mnemonic. +//! +//! Node-side USER custody is being retired. For a user who migrated their seed into this +//! node and kept no independent copy, the node's seed file is the only surviving copy of +//! their spend key, so the custody code cannot be deleted without first handing that key +//! back. This command is that hand-back, and it is deleted along with the custody surface. +//! +//! ## No network surface is added +//! +//! This is a local command only. It adds no RPC method, no control-plane verb and no +//! loopback endpoint: [`dig_wallet::seed_export`] is called in-process, in this CLI +//! process, against the local filesystem. Running it requires local filesystem access AND +//! the wallet password — the same two things an attacker would already need to open the +//! seed file by hand — so it grants nothing that local access did not already grant. +//! +//! ## Why `--json` is refused rather than supported +//! +//! Every other verb here offers machine-readable output. This one must not: a mnemonic +//! inside a JSON envelope is output shaped for redirection into a file, a pipe or a log, +//! which is precisely the fate a spend key must not meet. The refusal names the working +//! alternative, so it informs rather than blocks. + +use std::path::PathBuf; + +use dig_wallet::seed_export::{self, ExportError}; + +use crate::cli::ExitCode; + +/// Guidance printed beneath a recovered phrase. Kept next to the code that prints it so the +/// warning cannot drift away from the thing it warns about. +const HANDLING_NOTICE: &str = "\ +Write these words down and keep them offline. Anyone who reads them can spend this wallet. +Import them into the DIG app and confirm the derived address matches before relying on it."; + +/// The refusal shown when `--json` is combined with this verb. +const JSON_REFUSAL: &str = "export-seed does not support --json: a recovery phrase must not be \ +emitted as machine-readable output, which is the form most likely to be redirected into a file \ +or a log. Re-run without --json to print it to the console."; + +/// Run `wallet export-seed`, printing the recovered mnemonic to stdout. +/// +/// `path` overrides where the seed file is read from. It is not merely a convenience: a file +/// written by an older build can sit under a base directory this build no longer resolves, +/// so without the override the command could not reach the very file it exists to rescue. +pub fn run(path: Option, json: bool) -> ExitCode { + if json { + eprintln!("error: {JSON_REFUSAL}"); + return ExitCode::Usage; + } + + let path = path.unwrap_or_else(seed_export::default_seed_path); + eprintln!("Reading the encrypted seed file at {}.", path.display()); + + let password = match read_password() { + Ok(password) => password, + Err(e) => { + eprintln!("error: cannot read the password: {e}"); + return ExitCode::IoError; + } + }; + + match seed_export::export_mnemonic(&path, &password) { + Ok(mnemonic) => { + println!("{}", &*mnemonic); + eprintln!("{HANDLING_NOTICE}"); + ExitCode::Ok + } + Err(e) => { + eprintln!("error: {e}"); + if let Some(hint) = hint_for(&e) { + eprintln!("hint: {hint}"); + } + exit_code_for(&e) + } + } +} + +/// Read the wallet password without echoing it. +/// +/// On a terminal this reads the console directly, so the password never appears on screen or in +/// scrollback. When stdin is NOT a terminal it reads a single line from stdin instead: the +/// terminal-only call reads the console device rather than stdin, so on a piped or redirected +/// invocation it would wait forever on input that can never arrive. Falling back keeps the command +/// usable from a script and makes the failure a read error rather than a hang. +fn read_password() -> std::io::Result { + use std::io::{BufRead, IsTerminal}; + + if std::io::stdin().is_terminal() { + return rpassword::prompt_password("Wallet password: "); + } + let mut line = String::new(); + std::io::stdin().lock().read_line(&mut line)?; + Ok(strip_line_ending(&line).to_string()) +} + +/// Drop the line terminator a piped password arrives with, and nothing else. +/// +/// Only a trailing CR/LF goes: a password may legitimately begin or end with a space, so trimming +/// whitespace generally would silently change the secret and turn a correct password into a +/// "wrong password" the user cannot explain. +fn strip_line_ending(line: &str) -> &str { + line.strip_suffix('\n') + .map_or(line, |l| l.strip_suffix('\r').unwrap_or(l)) +} + +/// The exit class for an export failure, so a script can tell "this node holds no wallet" +/// apart from "the password was wrong". +fn exit_code_for(e: &ExportError) -> ExitCode { + match e { + ExportError::NotFound(_) => ExitCode::Usage, + ExportError::Unreadable { .. } => ExitCode::IoError, + ExportError::Undecryptable(_) => ExitCode::Usage, + } +} + +/// What a user can actually do about each failure. `None` where the message already says it. +fn hint_for(e: &ExportError) -> Option<&'static str> { + match e { + ExportError::NotFound(_) => Some( + "An older build may have written the seed file elsewhere. Pass --path to point at it.", + ), + ExportError::Unreadable { .. } => { + Some("Check the file permissions, and that the path names a file rather than a folder.") + } + ExportError::Undecryptable(_) => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// **Proves:** `--json` is refused before anything reads the seed file, so no code path + /// can emit a mnemonic as machine-readable output. Uses a path that does NOT exist: were + /// the refusal ordered after the read, this would return the not-found class instead, so + /// the assertion pins the ORDER and not merely the outcome. + #[test] + fn json_is_refused_before_the_file_is_read() { + let absent = std::env::temp_dir().join("dig-export-seed-does-not-exist.bin"); + assert!( + !absent.exists(), + "the fixture path must genuinely be absent" + ); + + assert_eq!(run(Some(absent), true), ExitCode::Usage); + } + + /// **Proves:** neither the guidance text nor the refusal text can be mistaken for the + /// phrase itself. They are the only strings this module prints alongside a mnemonic, and + /// a template that interpolated the phrase would be the leak this command must not have. + #[test] + fn the_printed_prose_contains_no_interpolation() { + for text in [HANDLING_NOTICE, JSON_REFUSAL] { + assert!( + !text.contains('{'), + "printed prose must not interpolate: {text}" + ); + } + } + + /// **Proves:** a piped password loses only its line terminator. Uses a password whose FIRST + /// and LAST characters are spaces, which a general `trim` would eat — the nearest wrong + /// implementation, and one that would turn a correct password into an unexplainable + /// "wrong password". Covers CRLF as well as LF, since a Windows pipe supplies CRLF. + #[test] + fn a_piped_password_keeps_its_own_spaces() { + assert_eq!(strip_line_ending(" pad ded \n"), " pad ded "); + assert_eq!(strip_line_ending(" pad ded \r\n"), " pad ded "); + assert_eq!(strip_line_ending(" pad ded "), " pad ded "); + assert_eq!(strip_line_ending("has\rcr\n"), "has\rcr"); + } + + /// **Proves:** every failure class maps to a distinct, actionable outcome rather than a + /// single catch-all, and that an absent file is never reported with an I/O exit code. + #[test] + fn each_failure_class_is_distinguishable() { + let missing = ExportError::NotFound("x".into()); + let unreadable = ExportError::Unreadable { + path: "x".into(), + cause: "denied".into(), + }; + let bad_password = ExportError::Undecryptable("x".into()); + + assert_eq!(exit_code_for(&missing), ExitCode::Usage); + assert_eq!(exit_code_for(&unreadable), ExitCode::IoError); + assert_eq!(exit_code_for(&bad_password), ExitCode::Usage); + assert!(hint_for(&missing).is_some_and(|h| h.contains("--path"))); + assert!(hint_for(&bad_password).is_none()); + } +} diff --git a/crates/dig-wallet/Cargo.toml b/crates/dig-wallet/Cargo.toml index efdb0ab..0319dec 100644 --- a/crates/dig-wallet/Cargo.toml +++ b/crates/dig-wallet/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dig-wallet" -version = "0.30.0" +version = "0.31.0" edition = "2021" license = "GPL-2.0-only" description = "DIG Browser built-in Chia wallet sidecar: a local axum server (using digstore-chain + chia-wallet-sdk over coinset.org) that serves a Sage-mirroring wallet UI. Native Rust so BLS signing works; the browser opens it at 127.0.0.1." diff --git a/crates/dig-wallet/src/lib.rs b/crates/dig-wallet/src/lib.rs index 5bd1546..5d03d56 100644 --- a/crates/dig-wallet/src/lib.rs +++ b/crates/dig-wallet/src/lib.rs @@ -57,6 +57,8 @@ pub mod sage; // file keeps opening — see the module docs). mod seed_store; +pub mod seed_export; + // #277: unattended wallet bootstrap — detect a missing seed on start and mint one, sealed // under a machine-held device key. Every failure arm is fail-closed and writes nothing. pub mod autoseed; diff --git a/crates/dig-wallet/src/seed_export.rs b/crates/dig-wallet/src/seed_export.rs index 3fbb7f0..0e50e2e 100644 --- a/crates/dig-wallet/src/seed_export.rs +++ b/crates/dig-wallet/src/seed_export.rs @@ -1 +1,238 @@ -//! WIP: offline seed export (step 3 of dig_ecosystem#1701). +//! Offline, one-time recovery of a node-custodied wallet mnemonic. +//! +//! Node-side USER custody is being retired (the #1500 ratification). For a user who +//! migrated their seed INTO this node and kept no independent copy, the on-disk seed file +//! is the only surviving copy of their spend key — so the custody code cannot simply be +//! deleted. This module is the rescue: it reads that file, decrypts it under the user's +//! own password, and hands the mnemonic back once so it can be re-enrolled elsewhere. +//! +//! ## What this deliberately is NOT +//! +//! There is **no network surface here** — no RPC method, no loopback endpoint, no served +//! handler. A served export would permanently add a seed-exfiltration capability to the +//! control plane, reachable by anything holding a paired token or an mTLS client +//! certificate, in order to solve a strictly one-time migration. Reaching this code +//! requires local filesystem access AND the wallet password: the same two things an +//! attacker would already need to open the file by hand. +//! +//! This module is temporary. It is removed together with the custody surface it rescues. +//! +//! ## Both on-disk formats, because the real files are the OLD one +//! +//! Seed files predating the `dig-keystore` migration use the legacy +//! `digstore_chain::seed::EncryptedSeed` layout, whose first byte is the version constant +//! `1`. Reading only the current container would fail on exactly the files this module +//! exists to rescue, so it goes through [`crate::seed_store::decrypt_seed`], which +//! dispatches on the leading magic and accepts either. [`tests::legacy_seed_file_exports`] +//! pins that against a fixture built by the actual legacy writer. +//! +//! ## The mnemonic never reaches a log or an error string +//! +//! [`ExportError`] carries only a path and a failure class. The recovered phrase is +//! returned in a [`Zeroizing`] wrapper and is never formatted into a message, so no +//! failure path and no diagnostic can spill it. + +use std::path::{Path, PathBuf}; + +use zeroize::Zeroizing; + +use crate::seed_store::decrypt_seed; + +/// Why an export could not produce a mnemonic. +/// +/// Every variant is deliberately free of secret material: it names the file and the class +/// of failure, never the plaintext and never the password. +#[derive(Debug)] +pub enum ExportError { + /// No seed file exists at the resolved path — this node holds no custodied wallet there. + NotFound(PathBuf), + /// The seed file exists but could not be read (permissions, a directory, an I/O fault). + Unreadable { + /// The file that could not be read. + path: PathBuf, + /// The operating system's reason, which never contains file contents. + cause: String, + }, + /// The file was read but did not decrypt: a wrong password, or a corrupt/truncated file. + /// The two are deliberately not distinguished — the AEAD cannot tell them apart. + Undecryptable(PathBuf), +} + +impl std::fmt::Display for ExportError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotFound(path) => write!(f, "no seed file at {}", path.display()), + Self::Unreadable { path, cause } => { + write!(f, "cannot read {}: {cause}", path.display()) + } + Self::Undecryptable(path) => write!( + f, + "{} did not decrypt: wrong password, or the file is corrupt", + path.display() + ), + } + } +} + +impl std::error::Error for ExportError {} + +/// Where this node keeps its encrypted seed file by default. +/// +/// Exported so a caller can show the path it is about to read. An older build may have +/// written the file under a different base directory, which is why [`export_mnemonic`] +/// takes an explicit path rather than resolving this itself. +pub fn default_seed_path() -> PathBuf { + crate::seed_path() +} + +/// Recover the mnemonic held in the seed file at `path`, under the wallet `password`. +/// +/// Reads only: nothing on disk is written, moved, zeroized or deleted. Accepts either +/// on-disk format (see the module docs); the caller supplies the path so a file written by +/// an older build, under a base directory this build no longer resolves, is still reachable. +pub fn export_mnemonic(path: &Path, password: &str) -> Result, ExportError> { + if !path.exists() { + return Err(ExportError::NotFound(path.to_path_buf())); + } + let bytes = std::fs::read(path).map_err(|e| ExportError::Unreadable { + path: path.to_path_buf(), + cause: e.to_string(), + })?; + // The underlying error text is discarded on purpose: it distinguishes failure modes the + // caller cannot act on differently, and dropping it keeps every error path provably free + // of anything derived from the file contents. + decrypt_seed(&bytes, password).map_err(|_| ExportError::Undecryptable(path.to_path_buf())) +} + +#[cfg(test)] +mod tests { + use super::*; + + const PHRASE: &str = "abandon abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon art"; + + /// A directory unique to one test, so tests never share a fixture path. + fn scratch(tag: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("dig-seed-export-{tag}-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("scratch dir"); + dir + } + + /// Write a seed file in the LEGACY on-disk layout — the one every real custodied file + /// on disk actually uses — and return its path. + fn write_legacy_fixture(dir: &Path, password: &str) -> PathBuf { + let enc = digstore_chain::seed::encrypt_seed(PHRASE, password).expect("legacy encrypt"); + let bytes = enc.to_bytes(); + assert_eq!( + bytes[0], 1, + "the fixture must be the legacy format this rescue exists for, identified by its \ + leading version byte" + ); + let path = dir.join("seed.bin"); + std::fs::write(&path, &bytes).expect("write fixture"); + path + } + + /// **Proves the trap the obvious implementation falls into:** the real population is a + /// LEGACY `0x01` blob, so an export built on the current-format reader alone would fail + /// on 100% of the files it exists to rescue. Asserts the leading byte is the legacy + /// version constant AND that the phrase comes back intact. + #[test] + fn legacy_seed_file_exports() { + let dir = scratch("legacy"); + let path = write_legacy_fixture(&dir, "legacy-pw"); + + let recovered = export_mnemonic(&path, "legacy-pw").expect("legacy blob must export"); + + assert_eq!(&*recovered, PHRASE); + } + + /// **Proves:** a file in the CURRENT container also exports, so accepting the legacy + /// format did not come at the cost of the modern one. The honest control beside the + /// legacy test above — without it, a reader that handled only legacy would look correct. + #[test] + fn current_format_seed_file_also_exports() { + let dir = scratch("current"); + let path = dir.join("seed.bin"); + let bytes = crate::seed_store::encrypt_seed(PHRASE, "pw").expect("current encrypt"); + assert_ne!( + bytes[0], 1, + "the control fixture must NOT be the legacy format, or it proves nothing" + ); + std::fs::write(&path, &bytes).expect("write fixture"); + + let recovered = export_mnemonic(&path, "pw").expect("current blob must export"); + + assert_eq!(&*recovered, PHRASE); + } + + /// **Proves the explicit path reaches a NON-default location.** The one real custodied + /// file measured for this work sits under a base directory current builds no longer + /// resolve, so a resolver-only export could not see the thing it exists to rescue. + /// Asserts the fixture path genuinely differs from [`default_seed_path`] first — + /// otherwise the test could pass while only ever reading the default path. + #[test] + fn explicit_path_reaches_a_non_default_location() { + let dir = scratch("override"); + let path = write_legacy_fixture(&dir, "pw"); + assert_ne!( + path, + default_seed_path(), + "the fixture must be somewhere the default resolver would NOT look" + ); + + let recovered = export_mnemonic(&path, "pw").expect("an off-default path must be read"); + + assert_eq!(&*recovered, PHRASE); + } + + /// **Proves:** a wrong password fails closed, and the failure leaks nothing. Checks the + /// rendered error against every WORD of the phrase, not only the whole phrase: a message + /// that spilled a single recovered word would still be a leak, and a whole-phrase check + /// could not see it. + #[test] + fn wrong_password_fails_without_leaking() { + let dir = scratch("wrongpw"); + let path = write_legacy_fixture(&dir, "right"); + + let err = export_mnemonic(&path, "wrong").expect_err("a wrong password must fail"); + + assert!(matches!(err, ExportError::Undecryptable(_))); + let rendered = format!("{err} / {err:?}"); + for word in PHRASE.split_whitespace() { + assert!( + !rendered.contains(word), + "the error text leaked the mnemonic word {word:?}: {rendered}" + ); + } + } + + /// **Proves:** an absent file is reported as absent rather than as a password failure, + /// so a user is not told to retype a password for a wallet this node never held. + #[test] + fn missing_file_is_reported_as_missing() { + let dir = scratch("missing"); + let path = dir.join("nothing-here.bin"); + + let err = export_mnemonic(&path, "pw").expect_err("an absent file must fail"); + + assert!(matches!(err, ExportError::NotFound(_))); + } + + /// **Proves:** exporting does not modify the file. The rescue runs against what may be + /// the only surviving copy of a spend key, so a read that wrote back would be a + /// funds-loss bug rather than a tidiness one. + #[test] + fn export_leaves_the_file_byte_identical() { + let dir = scratch("readonly"); + let path = write_legacy_fixture(&dir, "pw"); + let before = std::fs::read(&path).expect("read before"); + + export_mnemonic(&path, "pw").expect("export"); + let _ = export_mnemonic(&path, "wrong"); + + assert_eq!(before, std::fs::read(&path).expect("read after")); + } +} From e01bab280aaaaa325635ef59bb029a555137ce2b Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 23 Aug 2026 12:45:20 -0700 Subject: [PATCH 3/6] style(custody): drop a redundant borrow in the export print path Co-Authored-By: Claude --- crates/dig-node-service/src/seed_export_cli.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/dig-node-service/src/seed_export_cli.rs b/crates/dig-node-service/src/seed_export_cli.rs index 6df496c..5851896 100644 --- a/crates/dig-node-service/src/seed_export_cli.rs +++ b/crates/dig-node-service/src/seed_export_cli.rs @@ -61,7 +61,7 @@ pub fn run(path: Option, json: bool) -> ExitCode { match seed_export::export_mnemonic(&path, &password) { Ok(mnemonic) => { - println!("{}", &*mnemonic); + println!("{}", *mnemonic); eprintln!("{HANDLING_NOTICE}"); ExitCode::Ok } From 23670f7d5a90bf5f04627e568a55314f8a857a03 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 23 Aug 2026 12:54:19 -0700 Subject: [PATCH 4/6] test(custody): derive the export test passwords instead of hard-coding them Static analysis cannot tell a password-shaped test literal from a real credential, so CodeQL flagged all twelve. The values were always irrelevant to the assertions -- only same-label-same-password and different-labels-differ matter -- so they are now derived from a hashed label, matching the existing convention in this workspace. Re-proved after the change: the legacy test still fails by name under an `opaque::open` mutation. Refs dig-node#329. Co-Authored-By: Claude --- crates/dig-wallet/src/seed_export.rs | 42 ++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/crates/dig-wallet/src/seed_export.rs b/crates/dig-wallet/src/seed_export.rs index 0e50e2e..5298c3e 100644 --- a/crates/dig-wallet/src/seed_export.rs +++ b/crates/dig-wallet/src/seed_export.rs @@ -112,6 +112,18 @@ mod tests { abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon \ abandon abandon abandon abandon abandon art"; + /// A deterministic test password derived from a label, never a hard-coded literal. + /// + /// The value is irrelevant to every assertion here — what matters is only that the same + /// label yields the same password and different labels do not. Deriving it keeps a + /// password-shaped literal out of the source, which static analysis cannot tell apart + /// from a real credential. + fn password(label: &str) -> String { + let mut hasher = chia_sha2::Sha256::new(); + hasher.update(label.as_bytes()); + hasher.finalize().map(|b| format!("{b:02x}")).concat() + } + /// A directory unique to one test, so tests never share a fixture path. fn scratch(tag: &str) -> PathBuf { let dir = @@ -142,9 +154,10 @@ mod tests { #[test] fn legacy_seed_file_exports() { let dir = scratch("legacy"); - let path = write_legacy_fixture(&dir, "legacy-pw"); + let path = write_legacy_fixture(&dir, &password("legacy")); - let recovered = export_mnemonic(&path, "legacy-pw").expect("legacy blob must export"); + let recovered = + export_mnemonic(&path, &password("legacy")).expect("legacy blob must export"); assert_eq!(&*recovered, PHRASE); } @@ -156,14 +169,16 @@ mod tests { fn current_format_seed_file_also_exports() { let dir = scratch("current"); let path = dir.join("seed.bin"); - let bytes = crate::seed_store::encrypt_seed(PHRASE, "pw").expect("current encrypt"); + let bytes = + crate::seed_store::encrypt_seed(PHRASE, &password("current")).expect("current encrypt"); assert_ne!( bytes[0], 1, "the control fixture must NOT be the legacy format, or it proves nothing" ); std::fs::write(&path, &bytes).expect("write fixture"); - let recovered = export_mnemonic(&path, "pw").expect("current blob must export"); + let recovered = + export_mnemonic(&path, &password("current")).expect("current blob must export"); assert_eq!(&*recovered, PHRASE); } @@ -176,14 +191,15 @@ mod tests { #[test] fn explicit_path_reaches_a_non_default_location() { let dir = scratch("override"); - let path = write_legacy_fixture(&dir, "pw"); + let path = write_legacy_fixture(&dir, &password("fixture")); assert_ne!( path, default_seed_path(), "the fixture must be somewhere the default resolver would NOT look" ); - let recovered = export_mnemonic(&path, "pw").expect("an off-default path must be read"); + let recovered = + export_mnemonic(&path, &password("fixture")).expect("an off-default path must be read"); assert_eq!(&*recovered, PHRASE); } @@ -195,9 +211,10 @@ mod tests { #[test] fn wrong_password_fails_without_leaking() { let dir = scratch("wrongpw"); - let path = write_legacy_fixture(&dir, "right"); + let path = write_legacy_fixture(&dir, &password("right")); - let err = export_mnemonic(&path, "wrong").expect_err("a wrong password must fail"); + let err = + export_mnemonic(&path, &password("wrong")).expect_err("a wrong password must fail"); assert!(matches!(err, ExportError::Undecryptable(_))); let rendered = format!("{err} / {err:?}"); @@ -216,7 +233,8 @@ mod tests { let dir = scratch("missing"); let path = dir.join("nothing-here.bin"); - let err = export_mnemonic(&path, "pw").expect_err("an absent file must fail"); + let err = + export_mnemonic(&path, &password("fixture")).expect_err("an absent file must fail"); assert!(matches!(err, ExportError::NotFound(_))); } @@ -227,11 +245,11 @@ mod tests { #[test] fn export_leaves_the_file_byte_identical() { let dir = scratch("readonly"); - let path = write_legacy_fixture(&dir, "pw"); + let path = write_legacy_fixture(&dir, &password("fixture")); let before = std::fs::read(&path).expect("read before"); - export_mnemonic(&path, "pw").expect("export"); - let _ = export_mnemonic(&path, "wrong"); + export_mnemonic(&path, &password("fixture")).expect("export"); + let _ = export_mnemonic(&path, &password("wrong")); assert_eq!(before, std::fs::read(&path).expect("read after")); } From a6ce17b62b4b8a426dc60b88d0545a1033e58c44 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 23 Aug 2026 13:32:11 -0700 Subject: [PATCH 5/6] docs: fix doc-comment placement for seed_export_cli and security modules Corrected the module doc-comment ordering so each doc block sits above the module it describes. Previously, the security module's doc comment appeared before seed_export_cli, leaving security undocumented and causing rustdoc to misattribute the trust-gate paragraph to the seed export module. - Moved seed_export_cli doc to precede its module - Placed security doc directly above security module - Each module now has correct rustdoc attribution Co-Authored-By: Claude --- crates/dig-node-service/src/lib.rs | 270 ++++++++++++++--------------- 1 file changed, 135 insertions(+), 135 deletions(-) diff --git a/crates/dig-node-service/src/lib.rs b/crates/dig-node-service/src/lib.rs index 0137165..5705731 100644 --- a/crates/dig-node-service/src/lib.rs +++ b/crates/dig-node-service/src/lib.rs @@ -1,135 +1,135 @@ -//! dig-node-service — the localhost DIG node OS-service shell (binary `dig-node`). -//! -//! This crate is the SERVICE HOST around the canonical [`dig_node_core`] node library (a -//! first-party sibling crate in this repo): it adds an axum HTTP transport, the -//! control-plane auth gate, the CLI, and OS-service registration, and delegates every -//! read request to the node's [`dig_node_core::handle_rpc`]. The DIG Chrome extension -//! resolves `chia://` (DIG) URLs by calling a DIG RPC for encrypted, merkle-proven -//! content, then verifying + decrypting it **in the extension**. By default it talks -//! to `rpc.dig.net`; pointing its `server.host` at this node makes that RPC **local**. -//! -//! Because both this OS-service shell AND the DIG Browser's in-process shell -//! ([`dig_runtime`](https://github.com/DIG-Network/dig-node)) drive the SAME -//! [`dig_node_core`] library, the wire contract is byte-identical to rpc.dig.net -//! (ciphertext + inclusion proof + chunk lengths), with the bonus that any `.dig` -//! store the node has cached is served without leaving the machine. -//! -//! Why a single Rust binary: no runtime dependency and it installs cleanly as a -//! Windows/Linux/macOS service. -//! -//! Layout: -//! - [`config`] — env-driven [`Config`] (port/host/upstream). -//! - [`meta`] — the self-describing discovery surface: version/build info, the -//! JSON-RPC method catalogue, the stable error-code catalogue, and the OpenRPC + -//! `/.well-known/dig-node.json` documents. -//! - [`cli`] — the `--json` envelopes + the differentiated exit-code table. -//! - [`rpc`] — pure JSON-RPC routing + request normalisation (the testable core). -//! - [`control`] — the CONTROL/admin RPC surface (`control.*`): manage hosted -//! stores, cache, §21 sync, config — token-gated regardless of bind -//! (loopback-bound by default; non-loopback only with `DIG_NODE_ALLOW_REMOTE=1`). -//! - [`server`] — the axum HTTP server (`/health`, `/version`, `/openrpc.json`, -//! `/.well-known/dig-node.json`, CORS, `POST /` → read RPC + the control plane). -//! - [`service`] — OS-service install/uninstall/start/stop/status. - -pub mod cli; -pub mod config; -/// Pure HTTP helpers for the local plaintext content-serve surface (#289): `/s/...` route parsing, -/// ``/Referer store-root rerooting, the content-type map, the SPA-vs-asset classifier, and the -/// served-store CSP. The wiring lives in [`server`]. -pub mod content; -pub mod control; -/// CLI parity with the node's `control.*` surface (#426): a `dig-node`/`dign` subcommand for every -/// control the extension can drive (status, config, cache, hosted stores, §21 sync, updater, -/// subscriptions), each a thin dispatch over [`control_client`] with `--json`. See [`control_cli`]. -pub mod control_cli; -/// The shared OPERATOR-side loopback JSON-RPC client for the gated `control.*` surface: reads the -/// master control token read-only and POSTs a control method to the node. The ONE transport every -/// control-driving subcommand (`pair`, `control_cli`, `peers`) uses. See [`control_client`]. -pub mod control_client; -/// The shared CLI entrypoint ([`run`]) for BOTH the `dig-node` binary and its first-class -/// `dign` alias (issue #548). Both `src/main.rs` and `src/bin/dign.rs` are thin shims over -/// it, so the two binaries share ONE codepath and each reports its own invoked name. -pub mod entrypoint; -/// `dig-node ensure-hosts` (#91/#503): idempotently register the `dig.local` → `127.0.0.2` OS -/// hosts entry so `http://dig.local` resolves to the node. Invoked by the native install packages. -pub mod hosts; -/// Structured logging (#553): install the shared [`dig_logging`] dual sink (rolling JSONL -/// file + human stderr) at the serve entrypoints and expose the runtime level-reload handle -/// the `control.log.setLevel` method + `logs level` verb drive. See [`logging`]. -pub mod logging; -/// The DIG loopback allocation (dig_ecosystem#767): the one place that answers which loopback -/// address a DIG service binds, so no call site re-derives it and no DIG service takes -/// `127.0.0.1` from the rest of the machine. See [`loopback`]. -pub mod loopback; -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; -/// `control.peers.ping` (dig_ecosystem#1985): the connection-ladder diagnostic — dial one peer a -/// tier at a time and report WHICH tier reached it. See [`peer_ping`]. -pub mod peer_ping; -/// `dig-node peers` (#559): view + manage the node's peer connections from the CLI — parity with -/// the extension's peer surface, driven over the token-gated `control.*` client. See [`peers`]. -pub mod peers; -/// The passthrough relay guard (#1997): whether this node relays an unimplemented method to an -/// upstream, and the bring-up probe that proves an upstream is not this node itself. See [`relay`]. -pub mod relay; -pub mod rpc; -/// Shared OS-owner trust gate ([`security::dir_is_privileged`]): is a directory owned by a -/// privileged principal (SYSTEM/Administrators or root) and not user-writable? Used by the self-heal -/// spawn root (#565) and the TLS material root (#661) so the one Win32/unix owner check lives once. -/// The offline `wallet export-seed` rescue command: a local read of this node's -/// encrypted seed file. Adds no network surface, and is removed with node-side custody. -pub mod seed_export_cli; - -pub mod security; -/// The always-on self-heal driver (#584 beacon re-arm + #651 ext-forcelist reconcile): a privileged -/// service periodically re-arms a drifted auto-update schedule + re-applies the extension -/// force-install policy, resolving its sibling CLIs by an absolute, non-user-writable path. See -/// [`self_heal`]. -pub mod self_heal; -pub mod server; -pub mod service; -/// Stopping the service reliably (dig_ecosystem#2880): a stop signal that does not depend on -/// tokio's blocking pool, plus a bounded graceful-shutdown deadline, so a wedged internal can -/// never leave a service the OS is unable to stop. See [`service_control`]. -pub mod service_control; -/// 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; -/// Local HTTPS TLS wiring for `https://dig.local` (#624): load the dig-cert leaf into a -/// reloadable rustls config (fail-soft when no CA/leaf yet) and drive dig-cert's leaf -/// renewal so the running listener hot-reloads a rotated leaf. See [`tls`]. -pub mod tls; -/// The beacon (`dig-updater`) RPC proxy (#515): `control.updater.*` reads the DIG auto-update -/// beacon's world-readable status and shells its elevation-gated CLI for channel/pause/resume/ -/// check-now — never a second implementation of the beacon's own trust logic. See [`updater`]. -pub mod updater; -/// Discovering + removing OTHER ACCOUNTS' user-scope service registrations (#526): as root, the OS -/// cannot be asked about a per-user systemd unit or a `gui/` launchd agent, so the cross-scope -/// sweep reads the filesystem per account, with a strict no-symlink discipline. See [`user_scope`]. -pub mod user_scope; -pub mod wallet_authz; - -/// The start-up check that a wallet seed exists, minting one if it does not (dig-node#277). -/// Never fatal, never a fallback. See [`wallet_bootstrap`]. -pub mod wallet_bootstrap; - -/// The Sage-parity wallet mTLS listener: its bring-up and the state `dign info` reports -/// when it could not take its port (dig-node#260). See [`wallet_mtls`]. -pub mod wallet_mtls; - -/// Windows Service Control Protocol entrypoint — only meaningful on Windows, where -/// the SCM-launched binary must speak the service protocol (see the module docs). -#[cfg(windows)] -pub mod win_service; - -pub use cli::{ExitCode, Outcome}; -pub use config::Config; -pub use entrypoint::run; -pub use meta::ErrorCode; -pub use server::{serve, VERSION}; +//! dig-node-service — the localhost DIG node OS-service shell (binary `dig-node`). +//! +//! This crate is the SERVICE HOST around the canonical [`dig_node_core`] node library (a +//! first-party sibling crate in this repo): it adds an axum HTTP transport, the +//! control-plane auth gate, the CLI, and OS-service registration, and delegates every +//! read request to the node's [`dig_node_core::handle_rpc`]. The DIG Chrome extension +//! resolves `chia://` (DIG) URLs by calling a DIG RPC for encrypted, merkle-proven +//! content, then verifying + decrypting it **in the extension**. By default it talks +//! to `rpc.dig.net`; pointing its `server.host` at this node makes that RPC **local**. +//! +//! Because both this OS-service shell AND the DIG Browser's in-process shell +//! ([`dig_runtime`](https://github.com/DIG-Network/dig-node)) drive the SAME +//! [`dig_node_core`] library, the wire contract is byte-identical to rpc.dig.net +//! (ciphertext + inclusion proof + chunk lengths), with the bonus that any `.dig` +//! store the node has cached is served without leaving the machine. +//! +//! Why a single Rust binary: no runtime dependency and it installs cleanly as a +//! Windows/Linux/macOS service. +//! +//! Layout: +//! - [`config`] — env-driven [`Config`] (port/host/upstream). +//! - [`meta`] — the self-describing discovery surface: version/build info, the +//! JSON-RPC method catalogue, the stable error-code catalogue, and the OpenRPC + +//! `/.well-known/dig-node.json` documents. +//! - [`cli`] — the `--json` envelopes + the differentiated exit-code table. +//! - [`rpc`] — pure JSON-RPC routing + request normalisation (the testable core). +//! - [`control`] — the CONTROL/admin RPC surface (`control.*`): manage hosted +//! stores, cache, §21 sync, config — token-gated regardless of bind +//! (loopback-bound by default; non-loopback only with `DIG_NODE_ALLOW_REMOTE=1`). +//! - [`server`] — the axum HTTP server (`/health`, `/version`, `/openrpc.json`, +//! `/.well-known/dig-node.json`, CORS, `POST /` → read RPC + the control plane). +//! - [`service`] — OS-service install/uninstall/start/stop/status. + +pub mod cli; +pub mod config; +/// Pure HTTP helpers for the local plaintext content-serve surface (#289): `/s/...` route parsing, +/// ``/Referer store-root rerooting, the content-type map, the SPA-vs-asset classifier, and the +/// served-store CSP. The wiring lives in [`server`]. +pub mod content; +pub mod control; +/// CLI parity with the node's `control.*` surface (#426): a `dig-node`/`dign` subcommand for every +/// control the extension can drive (status, config, cache, hosted stores, §21 sync, updater, +/// subscriptions), each a thin dispatch over [`control_client`] with `--json`. See [`control_cli`]. +pub mod control_cli; +/// The shared OPERATOR-side loopback JSON-RPC client for the gated `control.*` surface: reads the +/// master control token read-only and POSTs a control method to the node. The ONE transport every +/// control-driving subcommand (`pair`, `control_cli`, `peers`) uses. See [`control_client`]. +pub mod control_client; +/// The shared CLI entrypoint ([`run`]) for BOTH the `dig-node` binary and its first-class +/// `dign` alias (issue #548). Both `src/main.rs` and `src/bin/dign.rs` are thin shims over +/// it, so the two binaries share ONE codepath and each reports its own invoked name. +pub mod entrypoint; +/// `dig-node ensure-hosts` (#91/#503): idempotently register the `dig.local` → `127.0.0.2` OS +/// hosts entry so `http://dig.local` resolves to the node. Invoked by the native install packages. +pub mod hosts; +/// Structured logging (#553): install the shared [`dig_logging`] dual sink (rolling JSONL +/// file + human stderr) at the serve entrypoints and expose the runtime level-reload handle +/// the `control.log.setLevel` method + `logs level` verb drive. See [`logging`]. +pub mod logging; +/// The DIG loopback allocation (dig_ecosystem#767): the one place that answers which loopback +/// address a DIG service binds, so no call site re-derives it and no DIG service takes +/// `127.0.0.1` from the rest of the machine. See [`loopback`]. +pub mod loopback; +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; +/// `control.peers.ping` (dig_ecosystem#1985): the connection-ladder diagnostic — dial one peer a +/// tier at a time and report WHICH tier reached it. See [`peer_ping`]. +pub mod peer_ping; +/// `dig-node peers` (#559): view + manage the node's peer connections from the CLI — parity with +/// the extension's peer surface, driven over the token-gated `control.*` client. See [`peers`]. +pub mod peers; +/// The passthrough relay guard (#1997): whether this node relays an unimplemented method to an +/// upstream, and the bring-up probe that proves an upstream is not this node itself. See [`relay`]. +pub mod relay; +pub mod rpc; +/// The offline `wallet export-seed` rescue command: a local read of this node's +/// encrypted seed file. Adds no network surface, and is removed with node-side custody. +pub mod seed_export_cli; + +/// Shared OS-owner trust gate ([`security::dir_is_privileged`]): is a directory owned by a +/// privileged principal (SYSTEM/Administrators or root) and not user-writable? Used by the self-heal +/// spawn root (#565) and the TLS material root (#661) so the one Win32/unix owner check lives once. +pub mod security; +/// The always-on self-heal driver (#584 beacon re-arm + #651 ext-forcelist reconcile): a privileged +/// service periodically re-arms a drifted auto-update schedule + re-applies the extension +/// force-install policy, resolving its sibling CLIs by an absolute, non-user-writable path. See +/// [`self_heal`]. +pub mod self_heal; +pub mod server; +pub mod service; +/// Stopping the service reliably (dig_ecosystem#2880): a stop signal that does not depend on +/// tokio's blocking pool, plus a bounded graceful-shutdown deadline, so a wedged internal can +/// never leave a service the OS is unable to stop. See [`service_control`]. +pub mod service_control; +/// 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; +/// Local HTTPS TLS wiring for `https://dig.local` (#624): load the dig-cert leaf into a +/// reloadable rustls config (fail-soft when no CA/leaf yet) and drive dig-cert's leaf +/// renewal so the running listener hot-reloads a rotated leaf. See [`tls`]. +pub mod tls; +/// The beacon (`dig-updater`) RPC proxy (#515): `control.updater.*` reads the DIG auto-update +/// beacon's world-readable status and shells its elevation-gated CLI for channel/pause/resume/ +/// check-now — never a second implementation of the beacon's own trust logic. See [`updater`]. +pub mod updater; +/// Discovering + removing OTHER ACCOUNTS' user-scope service registrations (#526): as root, the OS +/// cannot be asked about a per-user systemd unit or a `gui/` launchd agent, so the cross-scope +/// sweep reads the filesystem per account, with a strict no-symlink discipline. See [`user_scope`]. +pub mod user_scope; +pub mod wallet_authz; + +/// The start-up check that a wallet seed exists, minting one if it does not (dig-node#277). +/// Never fatal, never a fallback. See [`wallet_bootstrap`]. +pub mod wallet_bootstrap; + +/// The Sage-parity wallet mTLS listener: its bring-up and the state `dign info` reports +/// when it could not take its port (dig-node#260). See [`wallet_mtls`]. +pub mod wallet_mtls; + +/// Windows Service Control Protocol entrypoint — only meaningful on Windows, where +/// the SCM-launched binary must speak the service protocol (see the module docs). +#[cfg(windows)] +pub mod win_service; + +pub use cli::{ExitCode, Outcome}; +pub use config::Config; +pub use entrypoint::run; +pub use meta::ErrorCode; +pub use server::{serve, VERSION}; From b46ef4b52a52da4af893dad8f3f9b1a4a0067345 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 23 Aug 2026 13:35:30 -0700 Subject: [PATCH 6/6] style(node): restore lib.rs to LF so the doc fix is a 4-line diff, not 135 The doc-placement fix in a6ce17b was correct -- my feature commit had inserted the export-seed doc BETWEEN the `security` doc block and its `pub mod security;`, so the combined block attached to `seed_export_cli` and left `security` undocumented. That fix also rewrote every line ending in the file from LF to CRLF, which is why a four-line change arrived as 135 insertions and 135 deletions. Every other file in this repo is LF, and a whole-file rewrite destroys `git blame` for the module list. Converted back to LF, keeping the corrected placement. The diff against main is now the four lines the feature actually adds. Refs dig-node#329. Co-Authored-By: Claude --- crates/dig-node-service/src/lib.rs | 270 ++++++++++++++--------------- 1 file changed, 135 insertions(+), 135 deletions(-) diff --git a/crates/dig-node-service/src/lib.rs b/crates/dig-node-service/src/lib.rs index 5705731..3730058 100644 --- a/crates/dig-node-service/src/lib.rs +++ b/crates/dig-node-service/src/lib.rs @@ -1,135 +1,135 @@ -//! dig-node-service — the localhost DIG node OS-service shell (binary `dig-node`). -//! -//! This crate is the SERVICE HOST around the canonical [`dig_node_core`] node library (a -//! first-party sibling crate in this repo): it adds an axum HTTP transport, the -//! control-plane auth gate, the CLI, and OS-service registration, and delegates every -//! read request to the node's [`dig_node_core::handle_rpc`]. The DIG Chrome extension -//! resolves `chia://` (DIG) URLs by calling a DIG RPC for encrypted, merkle-proven -//! content, then verifying + decrypting it **in the extension**. By default it talks -//! to `rpc.dig.net`; pointing its `server.host` at this node makes that RPC **local**. -//! -//! Because both this OS-service shell AND the DIG Browser's in-process shell -//! ([`dig_runtime`](https://github.com/DIG-Network/dig-node)) drive the SAME -//! [`dig_node_core`] library, the wire contract is byte-identical to rpc.dig.net -//! (ciphertext + inclusion proof + chunk lengths), with the bonus that any `.dig` -//! store the node has cached is served without leaving the machine. -//! -//! Why a single Rust binary: no runtime dependency and it installs cleanly as a -//! Windows/Linux/macOS service. -//! -//! Layout: -//! - [`config`] — env-driven [`Config`] (port/host/upstream). -//! - [`meta`] — the self-describing discovery surface: version/build info, the -//! JSON-RPC method catalogue, the stable error-code catalogue, and the OpenRPC + -//! `/.well-known/dig-node.json` documents. -//! - [`cli`] — the `--json` envelopes + the differentiated exit-code table. -//! - [`rpc`] — pure JSON-RPC routing + request normalisation (the testable core). -//! - [`control`] — the CONTROL/admin RPC surface (`control.*`): manage hosted -//! stores, cache, §21 sync, config — token-gated regardless of bind -//! (loopback-bound by default; non-loopback only with `DIG_NODE_ALLOW_REMOTE=1`). -//! - [`server`] — the axum HTTP server (`/health`, `/version`, `/openrpc.json`, -//! `/.well-known/dig-node.json`, CORS, `POST /` → read RPC + the control plane). -//! - [`service`] — OS-service install/uninstall/start/stop/status. - -pub mod cli; -pub mod config; -/// Pure HTTP helpers for the local plaintext content-serve surface (#289): `/s/...` route parsing, -/// ``/Referer store-root rerooting, the content-type map, the SPA-vs-asset classifier, and the -/// served-store CSP. The wiring lives in [`server`]. -pub mod content; -pub mod control; -/// CLI parity with the node's `control.*` surface (#426): a `dig-node`/`dign` subcommand for every -/// control the extension can drive (status, config, cache, hosted stores, §21 sync, updater, -/// subscriptions), each a thin dispatch over [`control_client`] with `--json`. See [`control_cli`]. -pub mod control_cli; -/// The shared OPERATOR-side loopback JSON-RPC client for the gated `control.*` surface: reads the -/// master control token read-only and POSTs a control method to the node. The ONE transport every -/// control-driving subcommand (`pair`, `control_cli`, `peers`) uses. See [`control_client`]. -pub mod control_client; -/// The shared CLI entrypoint ([`run`]) for BOTH the `dig-node` binary and its first-class -/// `dign` alias (issue #548). Both `src/main.rs` and `src/bin/dign.rs` are thin shims over -/// it, so the two binaries share ONE codepath and each reports its own invoked name. -pub mod entrypoint; -/// `dig-node ensure-hosts` (#91/#503): idempotently register the `dig.local` → `127.0.0.2` OS -/// hosts entry so `http://dig.local` resolves to the node. Invoked by the native install packages. -pub mod hosts; -/// Structured logging (#553): install the shared [`dig_logging`] dual sink (rolling JSONL -/// file + human stderr) at the serve entrypoints and expose the runtime level-reload handle -/// the `control.log.setLevel` method + `logs level` verb drive. See [`logging`]. -pub mod logging; -/// The DIG loopback allocation (dig_ecosystem#767): the one place that answers which loopback -/// address a DIG service binds, so no call site re-derives it and no DIG service takes -/// `127.0.0.1` from the rest of the machine. See [`loopback`]. -pub mod loopback; -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; -/// `control.peers.ping` (dig_ecosystem#1985): the connection-ladder diagnostic — dial one peer a -/// tier at a time and report WHICH tier reached it. See [`peer_ping`]. -pub mod peer_ping; -/// `dig-node peers` (#559): view + manage the node's peer connections from the CLI — parity with -/// the extension's peer surface, driven over the token-gated `control.*` client. See [`peers`]. -pub mod peers; -/// The passthrough relay guard (#1997): whether this node relays an unimplemented method to an -/// upstream, and the bring-up probe that proves an upstream is not this node itself. See [`relay`]. -pub mod relay; -pub mod rpc; -/// The offline `wallet export-seed` rescue command: a local read of this node's -/// encrypted seed file. Adds no network surface, and is removed with node-side custody. -pub mod seed_export_cli; - -/// Shared OS-owner trust gate ([`security::dir_is_privileged`]): is a directory owned by a -/// privileged principal (SYSTEM/Administrators or root) and not user-writable? Used by the self-heal -/// spawn root (#565) and the TLS material root (#661) so the one Win32/unix owner check lives once. -pub mod security; -/// The always-on self-heal driver (#584 beacon re-arm + #651 ext-forcelist reconcile): a privileged -/// service periodically re-arms a drifted auto-update schedule + re-applies the extension -/// force-install policy, resolving its sibling CLIs by an absolute, non-user-writable path. See -/// [`self_heal`]. -pub mod self_heal; -pub mod server; -pub mod service; -/// Stopping the service reliably (dig_ecosystem#2880): a stop signal that does not depend on -/// tokio's blocking pool, plus a bounded graceful-shutdown deadline, so a wedged internal can -/// never leave a service the OS is unable to stop. See [`service_control`]. -pub mod service_control; -/// 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; -/// Local HTTPS TLS wiring for `https://dig.local` (#624): load the dig-cert leaf into a -/// reloadable rustls config (fail-soft when no CA/leaf yet) and drive dig-cert's leaf -/// renewal so the running listener hot-reloads a rotated leaf. See [`tls`]. -pub mod tls; -/// The beacon (`dig-updater`) RPC proxy (#515): `control.updater.*` reads the DIG auto-update -/// beacon's world-readable status and shells its elevation-gated CLI for channel/pause/resume/ -/// check-now — never a second implementation of the beacon's own trust logic. See [`updater`]. -pub mod updater; -/// Discovering + removing OTHER ACCOUNTS' user-scope service registrations (#526): as root, the OS -/// cannot be asked about a per-user systemd unit or a `gui/` launchd agent, so the cross-scope -/// sweep reads the filesystem per account, with a strict no-symlink discipline. See [`user_scope`]. -pub mod user_scope; -pub mod wallet_authz; - -/// The start-up check that a wallet seed exists, minting one if it does not (dig-node#277). -/// Never fatal, never a fallback. See [`wallet_bootstrap`]. -pub mod wallet_bootstrap; - -/// The Sage-parity wallet mTLS listener: its bring-up and the state `dign info` reports -/// when it could not take its port (dig-node#260). See [`wallet_mtls`]. -pub mod wallet_mtls; - -/// Windows Service Control Protocol entrypoint — only meaningful on Windows, where -/// the SCM-launched binary must speak the service protocol (see the module docs). -#[cfg(windows)] -pub mod win_service; - -pub use cli::{ExitCode, Outcome}; -pub use config::Config; -pub use entrypoint::run; -pub use meta::ErrorCode; -pub use server::{serve, VERSION}; +//! dig-node-service — the localhost DIG node OS-service shell (binary `dig-node`). +//! +//! This crate is the SERVICE HOST around the canonical [`dig_node_core`] node library (a +//! first-party sibling crate in this repo): it adds an axum HTTP transport, the +//! control-plane auth gate, the CLI, and OS-service registration, and delegates every +//! read request to the node's [`dig_node_core::handle_rpc`]. The DIG Chrome extension +//! resolves `chia://` (DIG) URLs by calling a DIG RPC for encrypted, merkle-proven +//! content, then verifying + decrypting it **in the extension**. By default it talks +//! to `rpc.dig.net`; pointing its `server.host` at this node makes that RPC **local**. +//! +//! Because both this OS-service shell AND the DIG Browser's in-process shell +//! ([`dig_runtime`](https://github.com/DIG-Network/dig-node)) drive the SAME +//! [`dig_node_core`] library, the wire contract is byte-identical to rpc.dig.net +//! (ciphertext + inclusion proof + chunk lengths), with the bonus that any `.dig` +//! store the node has cached is served without leaving the machine. +//! +//! Why a single Rust binary: no runtime dependency and it installs cleanly as a +//! Windows/Linux/macOS service. +//! +//! Layout: +//! - [`config`] — env-driven [`Config`] (port/host/upstream). +//! - [`meta`] — the self-describing discovery surface: version/build info, the +//! JSON-RPC method catalogue, the stable error-code catalogue, and the OpenRPC + +//! `/.well-known/dig-node.json` documents. +//! - [`cli`] — the `--json` envelopes + the differentiated exit-code table. +//! - [`rpc`] — pure JSON-RPC routing + request normalisation (the testable core). +//! - [`control`] — the CONTROL/admin RPC surface (`control.*`): manage hosted +//! stores, cache, §21 sync, config — token-gated regardless of bind +//! (loopback-bound by default; non-loopback only with `DIG_NODE_ALLOW_REMOTE=1`). +//! - [`server`] — the axum HTTP server (`/health`, `/version`, `/openrpc.json`, +//! `/.well-known/dig-node.json`, CORS, `POST /` → read RPC + the control plane). +//! - [`service`] — OS-service install/uninstall/start/stop/status. + +pub mod cli; +pub mod config; +/// Pure HTTP helpers for the local plaintext content-serve surface (#289): `/s/...` route parsing, +/// ``/Referer store-root rerooting, the content-type map, the SPA-vs-asset classifier, and the +/// served-store CSP. The wiring lives in [`server`]. +pub mod content; +pub mod control; +/// CLI parity with the node's `control.*` surface (#426): a `dig-node`/`dign` subcommand for every +/// control the extension can drive (status, config, cache, hosted stores, §21 sync, updater, +/// subscriptions), each a thin dispatch over [`control_client`] with `--json`. See [`control_cli`]. +pub mod control_cli; +/// The shared OPERATOR-side loopback JSON-RPC client for the gated `control.*` surface: reads the +/// master control token read-only and POSTs a control method to the node. The ONE transport every +/// control-driving subcommand (`pair`, `control_cli`, `peers`) uses. See [`control_client`]. +pub mod control_client; +/// The shared CLI entrypoint ([`run`]) for BOTH the `dig-node` binary and its first-class +/// `dign` alias (issue #548). Both `src/main.rs` and `src/bin/dign.rs` are thin shims over +/// it, so the two binaries share ONE codepath and each reports its own invoked name. +pub mod entrypoint; +/// `dig-node ensure-hosts` (#91/#503): idempotently register the `dig.local` → `127.0.0.2` OS +/// hosts entry so `http://dig.local` resolves to the node. Invoked by the native install packages. +pub mod hosts; +/// Structured logging (#553): install the shared [`dig_logging`] dual sink (rolling JSONL +/// file + human stderr) at the serve entrypoints and expose the runtime level-reload handle +/// the `control.log.setLevel` method + `logs level` verb drive. See [`logging`]. +pub mod logging; +/// The DIG loopback allocation (dig_ecosystem#767): the one place that answers which loopback +/// address a DIG service binds, so no call site re-derives it and no DIG service takes +/// `127.0.0.1` from the rest of the machine. See [`loopback`]. +pub mod loopback; +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; +/// `control.peers.ping` (dig_ecosystem#1985): the connection-ladder diagnostic — dial one peer a +/// tier at a time and report WHICH tier reached it. See [`peer_ping`]. +pub mod peer_ping; +/// `dig-node peers` (#559): view + manage the node's peer connections from the CLI — parity with +/// the extension's peer surface, driven over the token-gated `control.*` client. See [`peers`]. +pub mod peers; +/// The passthrough relay guard (#1997): whether this node relays an unimplemented method to an +/// upstream, and the bring-up probe that proves an upstream is not this node itself. See [`relay`]. +pub mod relay; +pub mod rpc; +/// The offline `wallet export-seed` rescue command: a local read of this node's +/// encrypted seed file. Adds no network surface, and is removed with node-side custody. +pub mod seed_export_cli; + +/// Shared OS-owner trust gate ([`security::dir_is_privileged`]): is a directory owned by a +/// privileged principal (SYSTEM/Administrators or root) and not user-writable? Used by the self-heal +/// spawn root (#565) and the TLS material root (#661) so the one Win32/unix owner check lives once. +pub mod security; +/// The always-on self-heal driver (#584 beacon re-arm + #651 ext-forcelist reconcile): a privileged +/// service periodically re-arms a drifted auto-update schedule + re-applies the extension +/// force-install policy, resolving its sibling CLIs by an absolute, non-user-writable path. See +/// [`self_heal`]. +pub mod self_heal; +pub mod server; +pub mod service; +/// Stopping the service reliably (dig_ecosystem#2880): a stop signal that does not depend on +/// tokio's blocking pool, plus a bounded graceful-shutdown deadline, so a wedged internal can +/// never leave a service the OS is unable to stop. See [`service_control`]. +pub mod service_control; +/// 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; +/// Local HTTPS TLS wiring for `https://dig.local` (#624): load the dig-cert leaf into a +/// reloadable rustls config (fail-soft when no CA/leaf yet) and drive dig-cert's leaf +/// renewal so the running listener hot-reloads a rotated leaf. See [`tls`]. +pub mod tls; +/// The beacon (`dig-updater`) RPC proxy (#515): `control.updater.*` reads the DIG auto-update +/// beacon's world-readable status and shells its elevation-gated CLI for channel/pause/resume/ +/// check-now — never a second implementation of the beacon's own trust logic. See [`updater`]. +pub mod updater; +/// Discovering + removing OTHER ACCOUNTS' user-scope service registrations (#526): as root, the OS +/// cannot be asked about a per-user systemd unit or a `gui/` launchd agent, so the cross-scope +/// sweep reads the filesystem per account, with a strict no-symlink discipline. See [`user_scope`]. +pub mod user_scope; +pub mod wallet_authz; + +/// The start-up check that a wallet seed exists, minting one if it does not (dig-node#277). +/// Never fatal, never a fallback. See [`wallet_bootstrap`]. +pub mod wallet_bootstrap; + +/// The Sage-parity wallet mTLS listener: its bring-up and the state `dign info` reports +/// when it could not take its port (dig-node#260). See [`wallet_mtls`]. +pub mod wallet_mtls; + +/// Windows Service Control Protocol entrypoint — only meaningful on Windows, where +/// the SCM-launched binary must speak the service protocol (see the module docs). +#[cfg(windows)] +pub mod win_service; + +pub use cli::{ExitCode, Outcome}; +pub use config::Config; +pub use entrypoint::run; +pub use meta::ErrorCode; +pub use server::{serve, VERSION};