diff --git a/Cargo.lock b/Cargo.lock index 3b7a5b2..4826a5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3073,7 +3073,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.144.1" +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 28a33e3..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.1" +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 dfccc46..6a9f5a2 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..3730058 100644 --- a/crates/dig-node-service/src/lib.rs +++ b/crates/dig-node-service/src/lib.rs @@ -78,6 +78,10 @@ pub mod peers; /// 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. 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..5851896 --- /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 new file mode 100644 index 0000000..5298c3e --- /dev/null +++ b/crates/dig-wallet/src/seed_export.rs @@ -0,0 +1,256 @@ +//! 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 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 = + 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, &password("legacy")); + + let recovered = + export_mnemonic(&path, &password("legacy")).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, &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, &password("current")).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, &password("fixture")); + assert_ne!( + path, + default_seed_path(), + "the fixture must be somewhere the default resolver would NOT look" + ); + + let recovered = + export_mnemonic(&path, &password("fixture")).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, &password("right")); + + let err = + export_mnemonic(&path, &password("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, &password("fixture")).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, &password("fixture")); + let before = std::fs::read(&path).expect("read before"); + + export_mnemonic(&path, &password("fixture")).expect("export"); + let _ = export_mnemonic(&path, &password("wrong")); + + assert_eq!(before, std::fs::read(&path).expect("read after")); + } +}