diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a0b855..3e920cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ release and covers the full history from the initial commit. ## [Unreleased] +### Changed + +- **CLI (behaviour change):** the wallet password no longer has to be a command-line argument. + It can come from `--password-env NAME`, `--password-stdin`, or a no-echo terminal prompt + (with a confirmation when creating); with no terminal and no password the command fails + instead of hanging. `--password P` still works but prints a warning on stderr (shell history, + process list). Plaintext storage is now an explicit opt-in: `create`/`restore*` without a + password and without `--no-encryption` fail, where before they silently wrote the seed + unencrypted. Scripts that relied on omitting `--password` must add `--no-encryption`. + ### Security - Private keys are no longer precomputed for every address in the UI. Address rows and the diff --git a/README.md b/README.md index 3f0a243..440af25 100644 --- a/README.md +++ b/README.md @@ -372,17 +372,21 @@ validation rule, troubleshooting) see [USERGUIDE.md](USERGUIDE.md). ### CLI in brief ```bash # Wallet -dotnet run --project src/Cli -- create [--words 12|24] [--kind segwit|wrapped|legacy] [--net mainnet|testnet|regtest] [--password P] +dotnet run --project src/Cli -- create [--words 12|24] [--kind segwit|wrapped|legacy] [--net mainnet|testnet|regtest] [--no-encryption] dotnet run --project src/Cli -- restore "" [...] -dotnet run --project src/Cli -- restore-xpub [--net ...] [--password P] -dotnet run --project src/Cli -- restore-address [--net ...] [--password P] -dotnet run --project src/Cli -- info [--net ...] [--password P] +dotnet run --project src/Cli -- restore-xpub [--net ...] [--no-encryption] +dotnet run --project src/Cli -- restore-address [--net ...] [--no-encryption] +dotnet run --project src/Cli -- info [--net ...] # Network dotnet run --project src/Cli -- sync [--server host[:port]] [--ssl] dotnet run --project src/Cli -- send --to ADDRESS (--amount X | --all) [--feerate sat/vB] [--broadcast] dotnet run --project src/Cli -- servers [--discover] ``` + +The wallet password is read from `--password-env NAME`, `--password-stdin` or a no-echo terminal +prompt; a new wallet is stored unencrypted only with `--no-encryption` (`--password P` still works +but is unsafe and warns). See USERGUIDE section 17. The default wallet file is `~/.palladium-wallet//wallets/default.wallet.json` (override with `--file`). Run without arguments for the full command list (also covers `newseed`, `addresses`, `reset-certs`); see [USERGUIDE.md §17](USERGUIDE.md#17-command-line-interface-cli) for complete flag reference. diff --git a/USERGUIDE.md b/USERGUIDE.md index bb9b303..fb5cee1 100644 --- a/USERGUIDE.md +++ b/USERGUIDE.md @@ -138,7 +138,7 @@ a sync cache (balances, history, verified proofs). - If you set a password, the file is encrypted with **AES-256-GCM**; the key is derived with **PBKDF2-HMAC-SHA512, 600,000 iterations, random 16-byte salt** (fresh salt and nonce on every save). Tampering with the file is detected before decryption. -- If you **decline encryption** (the wizard checkbox, or omitting `--password` in the CLI), +- If you **decline encryption** (the wizard checkbox, or `--no-encryption` in the CLI), **the seed is stored in plaintext on disk**. The wizard warns you explicitly. Only do this on a disk you fully trust (e.g. an encrypted volume). - There are **no password strength requirements and no attempt limits**. An empty password is @@ -741,8 +741,19 @@ binary); with no arguments it prints usage. **Shared conventions.** Default wallet file: `~/.palladium-wallet//wallets/default.wallet.json` — override with `--file PATH`. Default network mainnet — override with `--net testnet|regtest` (the CLI is the only way to -use test networks). `--password P` both decrypts an existing file and encrypts on save; -**omitting it on `create`/`restore` writes the seed in plaintext** (a warning is printed). +use test networks). + +**Wallet password.** `create`/`restore*` ask for a password and encrypt the file; encrypted +wallets need it for `info`/`sync`/`send`. Supply it, in order of preference, with: +`--password-env NAME` (read from an environment variable), `--password-stdin` (first line of +stdin), or nothing — in a terminal you are prompted (no echo, with a confirmation when +creating); with no terminal the command fails instead of hanging. `--password P` still works +but is unsafe (it stays in the shell history and the process list) and prints a warning. +Only **one** of the three options may be given. A new wallet is stored **unencrypted only +with the explicit `--no-encryption`** (a warning is printed); forgetting the password no +longer produces a plaintext wallet. Examples: +`printf '%s\n' "$PW" | dotnet run --project src/Cli -- restore "" --password-stdin`, +`PLM_PW=… dotnet run --project src/Cli -- info --password-env PLM_PW`. `--kind legacy|wrapped|segwit` selects the script type (default Native SegWit — note that only the literal values `legacy` and `wrapped` switch away from the default). Errors print to stderr and set exit code 1. @@ -755,7 +766,7 @@ newseed [--words 24] Prints a fresh mnemonic (12 words unless `--words 24`) and writes nothing to disk. ``` -create [--words 24] [--kind K] [--net N] [--passphrase W] [--password P] [--file PATH] [--path m/...] +create [--words 24] [--kind K] [--net N] [--passphrase W] [--no-encryption] [--file PATH] [--path m/...] ``` Generates a mnemonic, prints it once, saves the wallet, prints the file path and the first receive address. `--path` overrides the derivation path (advanced; you must remember it to @@ -767,13 +778,13 @@ restore "" [same options as create] Restores from an existing mnemonic (quoted, words space-separated). ``` -restore-xpub [--net N] [--password P] [--file PATH] +restore-xpub [--net N] [--no-encryption] [--file PATH] ``` Creates a **watch-only** wallet from an xpub/ypub/zpub; the script type is inferred from the key prefix. ``` -info [--net N] [--password P] [--file PATH] [--addresses] +info [--net N] [--file PATH] [--addresses] ``` Prints the wallet's identity and, if it has ever synced, the balance breakdown (spendable / maturing / pending), tip height, transaction count and next receive address. @@ -789,7 +800,7 @@ before restoring. ### Network commands ``` -sync [--server host[:port]] [--ssl] [--net N] [--password P] [--file PATH] +sync [--server host[:port]] [--ssl] [--net N] [--file PATH] ``` Connects (default: the first known server; port defaults to 50001, or 50002 with `--ssl`), synchronizes with full Merkle verification, persists the cache into the wallet file, and @@ -798,7 +809,7 @@ marked as unverified. ``` send --to ADDRESS (--amount X | --all) [--feerate R] [--broadcast] - [--server ...] [--ssl] [--net N] [--password P] [--file PATH] + [--server ...] [--ssl] [--net N] [--file PATH] ``` Builds a transaction from the synced cache (`sync` must have been run first). `--amount` is in **PLM**; `--all` sends everything minus the fee. `--feerate` is in sat/vB, **default 1**. diff --git a/docs/adr/0038-cli-password-sources-and-explicit-plaintext.md b/docs/adr/0038-cli-password-sources-and-explicit-plaintext.md new file mode 100644 index 0000000..57d2d35 --- /dev/null +++ b/docs/adr/0038-cli-password-sources-and-explicit-plaintext.md @@ -0,0 +1,29 @@ +# 0038. CLI password sources and explicit plaintext storage + +- **Status:** Accepted +- **Area:** Distribution +- **Recorded:** 2026-11-03 + +## Context + +The CLI read the wallet password only from `--password`, where it is stored in the shell history and visible to other local users in the process list (ADR 0024 already noted it). Worse, when `--password` was missing in `create`/`restore*` the CLI printed a warning and stored the **seed in plaintext**: a forgotten flag silently produced an unencrypted wallet. + +## Decision + +- Password sources, one at most per command: `--password-env NAME`, `--password-stdin` (first line), or `--password P` (kept for compatibility, prints a warning on stderr). With none, a no-echo prompt on the terminal (`Console.ReadKey(intercept: true)`), with a confirmation when creating a wallet. With no terminal the command fails with a clear message and never hangs. +- A new wallet (`create`, `restore`, `restore-xpub`, `restore-address`) is stored unencrypted **only** with `--no-encryption`, which prints a warning and cannot be combined with a password option. An empty password is refused. +- Opening: a plaintext wallet needs no password; an encrypted one needs a source. The password used to open is reused to save the wallet back (`sync`). +- Implemented in `src/Cli/CliPassword.cs`; the command logic in `Program.cs` only asks it for the password. + +## Consequences + +- A forgotten flag can no longer produce a plaintext wallet; scripts that relied on that must add `--no-encryption` (recorded as a behaviour change in the changelog). +- `--password` is not removed, so existing scripts with a password keep working; removing it is left to a major version. +- The environment variable route still exposes the password to processes that can read the environment of the CLI; it is safer than an argument but not a hardened interface (the CLI remains a developer tool). + +## References + +- `src/Cli/CliPassword.cs` +- `src/Cli/Program.cs` +- `tests/PalladiumWallet.Tests/App/CliTests.cs` +- Related: 0024 diff --git a/docs/adr/README.md b/docs/adr/README.md index da7e577..945668b 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -10,7 +10,6 @@ One ADR per feature or significant design decision. Format: `template.md`. Workf | 0004 | [Centralize all chain constants in a network profile](0004-centralized-network-profile.md) | Chain | Accepted | | 0005 | [Use NBitcoin for protocol primitives, hand-write only the rest](0005-nbitcoin-for-protocol-primitives.md) | Crypto | Accepted | | 0006 | [Anchor headers to hardcoded checkpoints and skip PoW validation](0006-spv-checkpoint-anchoring-without-pow.md) | SPV | Accepted | -| 0037 | [Derive an address private key only when it is revealed](0037-derive-address-private-keys-on-reveal.md) | Security | Accepted | | 0007 | [Verify a Merkle proof for every confirmed transaction](0007-mandatory-merkle-verification.md) | SPV | Accepted | | 0008 | [Progressive verification: show early, spend only after proof](0008-progressive-verification-sync.md) | SPV | Accepted | | 0009 | [Hand-written Electrum JSON-RPC client with pipelining and an in-flight cap](0009-custom-electrum-client.md) | Network | Accepted | @@ -41,6 +40,8 @@ One ADR per feature or significant design decision. Format: `template.md`. Workf | 0034 | [Bound every network request with a timeout](0034-bound-every-network-request.md) | Network | Accepted | | 0035 | [Cap the length of a server line](0035-cap-server-line-length.md) | Network | Accepted | | 0036 | [Verify the chain identity of a server right after connecting](0036-verify-chain-identity-on-connect.md) | SPV | Accepted | +| 0037 | [Derive an address private key only when it is revealed](0037-derive-address-private-keys-on-reveal.md) | Security | Accepted | +| 0038 | [CLI password sources and explicit plaintext storage](0038-cli-password-sources-and-explicit-plaintext.md) | Distribution | Accepted | ADRs `0002` onwards were written retroactively on 2026-11-03 from the code, comments and changelog. If one misstates the original reasoning, fix it in a pull request. diff --git a/src/Cli/CliPassword.cs b/src/Cli/CliPassword.cs new file mode 100644 index 0000000..df90d86 --- /dev/null +++ b/src/Cli/CliPassword.cs @@ -0,0 +1,103 @@ +using PalladiumWallet.Core.Storage; +using PalladiumWallet.Core.Wallet; + +/// +/// Wallet-password handling for the CLI (ADR 0038). A password never has to be given as a +/// command-line argument (visible in the process list and shell history): it can come from an +/// environment variable, from stdin, or from a no-echo terminal prompt. Plaintext storage is an +/// explicit opt-in (--no-encryption), never the result of a forgotten flag. +/// +internal static class CliPassword +{ + /// Password of the wallet opened by this process, reused when the wallet is saved back. + internal static string? Current { get; private set; } + + private static bool Interactive => !Console.IsInputRedirected; + + /// Password to decrypt ; null when the file is not encrypted. + internal static string? ForOpening(string[] o, string path) + { + if (!EncryptedFile.IsEncrypted(File.ReadAllText(path))) + return Current = null; + return Current = Explicit(o) ?? Prompt("Wallet password: ") + ?? throw new WalletSpendException( + "The wallet is encrypted: supply the password with --password-env NAME, " + + "--password-stdin, or run in a terminal to be prompted."); + } + + /// Password to encrypt a new wallet; null only with --no-encryption. + internal static string? ForNewWallet(string[] o) + { + var noEncryption = o.Contains("--no-encryption"); + if (noEncryption) + { + if (Opt(o, "--password") is not null || Opt(o, "--password-env") is not null + || o.Contains("--password-stdin")) + throw new WalletSpendException("--no-encryption cannot be combined with a password option."); + Console.Error.WriteLine("WARNING: --no-encryption: the wallet will be stored unencrypted on disk (§17)."); + return Current = null; + } + if (Explicit(o) is { } given) + return Current = given; + var first = Prompt("New wallet password: ") + ?? throw new WalletSpendException( + "A password is required: use --password-env NAME, --password-stdin, run in a terminal " + + "to be prompted, or pass --no-encryption to store the wallet unencrypted."); + if (first.Length == 0) + throw new WalletSpendException("The password cannot be empty (use --no-encryption for no encryption)."); + if (Prompt("Repeat password: ") != first) + throw new WalletSpendException("The passwords do not match."); + return Current = first; + } + + /// Password from --password, --password-env or --password-stdin; null if none given. + private static string? Explicit(string[] o) + { + var flag = Opt(o, "--password"); + var env = Opt(o, "--password-env"); + var stdin = o.Contains("--password-stdin"); + if ((flag is not null ? 1 : 0) + (env is not null ? 1 : 0) + (stdin ? 1 : 0) > 1) + throw new WalletSpendException("Use only one of --password, --password-env, --password-stdin."); + + if (flag is not null) + { + Console.Error.WriteLine( + "WARNING: --password is visible in the process list and the shell history; " + + "prefer --password-env or --password-stdin."); + return flag; + } + if (env is not null) + return Environment.GetEnvironmentVariable(env) is { Length: > 0 } v + ? v + : throw new WalletSpendException($"Environment variable {env} is not set or empty."); + if (stdin) + return Console.In.ReadLine() is { Length: > 0 } line + ? line + : throw new WalletSpendException("--password-stdin: no password received on stdin."); + return null; + } + + /// No-echo prompt on the terminal; null when there is no terminal (never hangs). + private static string? Prompt(string text) + { + if (!Interactive) + return null; + Console.Error.Write(text); + var sb = new System.Text.StringBuilder(); + while (true) + { + var key = Console.ReadKey(intercept: true); + if (key.Key == ConsoleKey.Enter) break; + if (key.Key == ConsoleKey.Backspace) { if (sb.Length > 0) sb.Length--; } + else if (!char.IsControl(key.KeyChar)) sb.Append(key.KeyChar); + } + Console.Error.WriteLine(); + return sb.ToString(); + } + + private static string? Opt(string[] options, string name) + { + var i = Array.IndexOf(options, name); + return i >= 0 && i + 1 < options.Length ? options[i + 1] : null; + } +} diff --git a/src/Cli/Program.cs b/src/Cli/Program.cs index 400aaf0..a0aed57 100644 --- a/src/Cli/Program.cs +++ b/src/Cli/Program.cs @@ -92,7 +92,7 @@ static int RestoreXpub(string xpubText, string[] o) AccountXpub = account.ToSlip132(), }; var path = WalletPath(o, profile); - WalletStore.Save(doc, path, Opt(o, "--password")); + WalletStore.Save(doc, path, CliPassword.ForNewWallet(o)); Console.WriteLine($"Watch-only wallet saved to {path}"); return 0; } @@ -117,7 +117,7 @@ static int RestoreAddress(string addrsText, string[] o) return 1; } var path = WalletPath(o, profile); - WalletStore.Save(doc, path, Opt(o, "--password")); + WalletStore.Save(doc, path, CliPassword.ForNewWallet(o)); Console.WriteLine($"Watch-only wallet saved to {path} ({addresses.Length} address(es), cannot sign)"); return 0; } @@ -190,7 +190,7 @@ static async Task Sync(string[] o) BlockHeaders = blockHeaders, AnchoredUpTo = anchoredUpTo, }; - WalletStore.Save(doc, path, Opt(o, "--password")); + WalletStore.Save(doc, path, CliPassword.Current); Console.WriteLine($"Balance: {CoinAmount.Format(result.SpendableSats, account.Profile.CoinUnit)} spendable" + (result.ImmatureSats != 0 ? $" + {CoinAmount.Format(result.ImmatureSats)} maturing (not spendable)" : "") @@ -322,11 +322,8 @@ static int SaveWallet(string words, string[] o) var (doc, account) = WalletLoader.NewFromMnemonic( words, Opt(o, "--passphrase"), Kind(o), profile, Opt(o, "--path") is { } p ? KeyPath.Parse(p) : null); - var password = Opt(o, "--password"); - if (string.IsNullOrEmpty(password)) - Console.WriteLine("WARNING: no --password, the seed will be stored in plaintext on disk (§17)."); var path = WalletPath(o, profile); - WalletStore.Save(doc, path, password); + WalletStore.Save(doc, path, CliPassword.ForNewWallet(o)); Console.WriteLine($"Wallet saved to {path}"); Console.WriteLine($"First address: {account.GetReceiveAddress(0)}"); return 0; @@ -337,7 +334,7 @@ static (WalletDocument, IWalletAccount, string) OpenWallet(string[] o) var path = WalletPath(o, Profile(o)); if (!WalletStore.Exists(path)) throw new WalletSpendException($"No wallet at {path}: use 'create' or 'restore'."); - var doc = WalletStore.Load(path, Opt(o, "--password")); + var doc = WalletStore.Load(path, CliPassword.ForOpening(o, path)); return (doc, WalletLoader.ToAccount(doc), path); } @@ -387,14 +384,21 @@ static int Usage() Wallet: create [--words 12|24] [--kind segwit|wrapped|legacy] [--net mainnet|testnet|regtest] - [--passphrase W] [--password P] [--file PATH] + [--passphrase W] [--no-encryption] [--file PATH] restore "" [same options as create] [--path m/...] - restore-xpub [--net ...] [--password P] [--file PATH] (watch-only) - restore-address [--net ...] [--password P] [--file PATH] (watch-only, no keys) - info [--net ...] [--password P] [--file PATH] + restore-xpub [--net ...] [--file PATH] (watch-only) + restore-address [--net ...] [--file PATH] (watch-only, no keys) + info [--net ...] [--file PATH] + + Wallet password (create/restore ask for one, encrypted wallets need it to open): + --password-env NAME read it from an environment variable + --password-stdin read it from the first line of stdin + (neither) prompt on the terminal, no echo; fails if there is no terminal + --no-encryption create/restore only: store the wallet UNENCRYPTED (explicit opt-in) + --password P still accepted but unsafe (shell history, process list): warns Network (indexing server; without --server the first known server is used): - sync [--server host[:port]] [--ssl] [--net ...] [--password P] [--file PATH] + sync [--server host[:port]] [--ssl] [--net ...] [--file PATH] send --to ADDRESS (--amount X | --all) [--feerate sat/vB] [--server host[:port]] [--ssl] [--broadcast] [...] servers [--discover] [--server host[:port]] [--ssl] [--net ...] diff --git a/tests/PalladiumWallet.Tests/App/CliTests.cs b/tests/PalladiumWallet.Tests/App/CliTests.cs index e7cb89b..9dd813d 100644 --- a/tests/PalladiumWallet.Tests/App/CliTests.cs +++ b/tests/PalladiumWallet.Tests/App/CliTests.cs @@ -36,20 +36,22 @@ public sealed class CliTests : IDisposable private string WalletFile(string name = "w.json") => Path.Combine(_dir, name); - private static async Task<(int Exit, string Out, string Err)> Cli(params string[] args) + private static Task<(int Exit, string Out, string Err)> Cli(params string[] args) => CliWithStdin(null, args); + + private static async Task<(int Exit, string Out, string Err)> CliWithStdin(string? stdin, params string[] args) { var entry = Assembly.Load("PalladiumWallet.Cli").EntryPoint ?? throw new InvalidOperationException("CLI has no entry point."); - var (oldOut, oldErr) = (Console.Out, Console.Error); + var (oldOut, oldErr, oldIn) = (Console.Out, Console.Error, Console.In); var (o, e) = (new StringWriter(), new StringWriter()); - Console.SetOut(o); Console.SetError(e); + Console.SetOut(o); Console.SetError(e); Console.SetIn(new StringReader(stdin ?? "")); try { // The top-level-statements entry point is a synchronous Main(string[]) → int wrapper. var exit = await Task.Run(() => (int)entry.Invoke(null, [args])!); return (exit, o.ToString(), e.ToString()); } - finally { Console.SetOut(oldOut); Console.SetError(oldErr); } + finally { Console.SetOut(oldOut); Console.SetError(oldErr); Console.SetIn(oldIn); } } [Fact] @@ -86,7 +88,7 @@ public sealed class CliTests : IDisposable [Fact] public async Task Restore_WithAnInvalidMnemonic_FailsWithoutWritingAFile() { - var (exit, _, err) = await Cli("restore", "abandon abandon abandon", "--file", WalletFile()); + var (exit, _, err) = await Cli("restore", "abandon abandon abandon", "--no-encryption", "--file", WalletFile()); Assert.Equal(1, exit); Assert.Contains("Invalid mnemonic", err); Assert.False(File.Exists(WalletFile())); @@ -112,7 +114,7 @@ public sealed class CliTests : IDisposable [Fact] public async Task Create_ShowsTheMnemonicOnce_AndWritesTheWallet() { - var (exit, output, _) = await Cli("create", "--net", "regtest", "--file", WalletFile()); + var (exit, output, _) = await Cli("create", "--net", "regtest", "--no-encryption", "--file", WalletFile()); Assert.Equal(0, exit); Assert.Contains("New mnemonic", output); Assert.True(File.Exists(WalletFile())); @@ -124,15 +126,15 @@ public sealed class CliTests : IDisposable Assert.True(Bip39.TryParse(Mnemonic, out var m)); var account = HdAccount.FromMnemonic(m!, null, ScriptKind.NativeSegwit, ChainProfiles.Regtest); - var (e1, _, _) = await Cli("restore-xpub", account.ToSlip132(), "--net", "regtest", "--file", WalletFile("x.json")); + var (e1, _, _) = await Cli("restore-xpub", account.ToSlip132(), "--net", "regtest", "--no-encryption", "--file", WalletFile("x.json")); Assert.Equal(0, e1); Assert.Contains("watch-only: True", (await Cli("info", "--net", "regtest", "--file", WalletFile("x.json"))).Out); - var (e2, _, _) = await Cli("restore-address", account.GetReceiveAddress(0).ToString(), "--net", "regtest", "--file", WalletFile("a.json")); + var (e2, _, _) = await Cli("restore-address", account.GetReceiveAddress(0).ToString(), "--net", "regtest", "--no-encryption", "--file", WalletFile("a.json")); Assert.Equal(0, e2); Assert.Contains("watch-only: True", (await Cli("info", "--net", "regtest", "--file", WalletFile("a.json"))).Out); - var (e3, _, err3) = await Cli("restore-xpub", "garbage", "--net", "regtest", "--file", WalletFile("bad.json")); + var (e3, _, err3) = await Cli("restore-xpub", "garbage", "--net", "regtest", "--no-encryption", "--file", WalletFile("bad.json")); Assert.Equal(1, e3); Assert.Contains("not recognised", err3); } @@ -140,7 +142,7 @@ public sealed class CliTests : IDisposable [Fact] public async Task Send_BeforeAnySync_AsksToSyncFirst() { - await Cli("restore", Mnemonic, "--net", "regtest", "--file", WalletFile()); + await Cli("restore", Mnemonic, "--net", "regtest", "--no-encryption", "--file", WalletFile()); var (exit, _, err) = await Cli("send", "--to", "x", "--all", "--net", "regtest", "--file", WalletFile()); Assert.Equal(1, exit); Assert.Contains("run 'sync' first", err); @@ -179,7 +181,7 @@ public sealed class CliTests : IDisposable server.Handle("blockchain.transaction.get_merkle", _ => new { block_height = 100, pos = 0, merkle = Array.Empty() }); server.Handle("blockchain.block.header", _ => Header(txid, 100)); - await Cli("restore", Mnemonic, "--net", "regtest", "--file", WalletFile()); + await Cli("restore", Mnemonic, "--net", "regtest", "--no-encryption", "--file", WalletFile()); var serverArg = $"{server.Host}:{server.Port}"; var (syncExit, syncOut, _) = await Cli("sync", "--server", serverArg, "--net", "regtest", "--file", WalletFile()); @@ -204,7 +206,7 @@ public sealed class CliTests : IDisposable public async Task Sync_refuses_a_server_on_another_chain() { await using var server = new FakeElectrumServer(); // default features: mainnet genesis - await Cli("restore", Mnemonic, "--net", "regtest", "--file", WalletFile()); + await Cli("restore", Mnemonic, "--net", "regtest", "--no-encryption", "--file", WalletFile()); var (exit, _, err) = await Cli("sync", "--server", $"{server.Host}:{server.Port}", "--net", "regtest", "--file", WalletFile()); @@ -213,4 +215,144 @@ public sealed class CliTests : IDisposable Assert.Contains("not on the expected chain", err); Assert.Equal(0, server.CallCount("blockchain.headers.subscribe")); // rejected before syncing } + + // ---- wallet password handling (ADR 0038) ---- + + [Fact] + public async Task Create_WithoutAPasswordOrNoEncryption_FailsInsteadOfStoringPlaintext() + { + var (exit, _, err) = await Cli("restore", Mnemonic, "--net", "regtest", "--file", WalletFile()); + + Assert.Equal(1, exit); + Assert.Contains("A password is required", err); + Assert.False(File.Exists(WalletFile())); + } + + [Fact] + public async Task NoEncryption_StoresPlaintextAndWarns() + { + var (exit, _, err) = await Cli("restore", Mnemonic, "--net", "regtest", "--no-encryption", "--file", WalletFile()); + + Assert.Equal(0, exit); + Assert.Contains("unencrypted", err); + Assert.False(EncryptedFile.IsEncrypted(File.ReadAllText(WalletFile()))); + } + + [Fact] + public async Task NoEncryption_CannotBeCombinedWithAPassword() + { + var (exit, _, err) = await Cli("restore", Mnemonic, "--net", "regtest", "--no-encryption", + "--password-stdin", "--file", WalletFile()); + + Assert.Equal(1, exit); + Assert.Contains("cannot be combined", err); + Assert.False(File.Exists(WalletFile())); + } + + [Fact] + public async Task PasswordStdin_EncryptsAndThenOpensTheWallet() + { + var (e1, _, _) = await CliWithStdin("s3cret\n", "restore", Mnemonic, "--net", "regtest", + "--password-stdin", "--file", WalletFile()); + Assert.Equal(0, e1); + var text = File.ReadAllText(WalletFile()); + Assert.True(EncryptedFile.IsEncrypted(text)); + Assert.DoesNotContain("abandon", text); + + var (e2, info, _) = await CliWithStdin("s3cret\n", "info", "--net", "regtest", + "--password-stdin", "--file", WalletFile()); + Assert.Equal(0, e2); + Assert.Contains("network: regtest", info); + } + + [Fact] + public async Task PasswordEnv_EncryptsAndThenOpensTheWallet() + { + const string name = "PLM_TEST_WALLET_PW"; + Environment.SetEnvironmentVariable(name, "from-env"); + try + { + var (e1, _, err1) = await Cli("restore", Mnemonic, "--net", "regtest", "--password-env", name, + "--file", WalletFile()); + Assert.Equal(0, e1); + Assert.DoesNotContain("--password is visible", err1); // no warning for the safe route + Assert.True(EncryptedFile.IsEncrypted(File.ReadAllText(WalletFile()))); + + var (e2, _, _) = await Cli("info", "--net", "regtest", "--password-env", name, "--file", WalletFile()); + Assert.Equal(0, e2); + } + finally { Environment.SetEnvironmentVariable(name, null); } + } + + [Fact] + public async Task PasswordEnv_UnsetOrEmpty_IsAnError() + { + var (exit, _, err) = await Cli("restore", Mnemonic, "--net", "regtest", + "--password-env", "PLM_TEST_SURELY_UNSET", "--file", WalletFile()); + + Assert.Equal(1, exit); + Assert.Contains("PLM_TEST_SURELY_UNSET", err); + } + + [Fact] + public async Task EncryptedWallet_WithNoPasswordAndNoTerminal_FailsWithAClearError() + { + await CliWithStdin("pw\n", "restore", Mnemonic, "--net", "regtest", "--password-stdin", "--file", WalletFile()); + + var (exit, _, err) = await Cli("info", "--net", "regtest", "--file", WalletFile()); // no TTY: never prompts or hangs + + Assert.Equal(1, exit); + Assert.Contains("encrypted", err); + Assert.Contains("--password-env", err); + } + + [Fact] + public async Task EncryptedWallet_WithTheWrongPasswordFails() + { + await CliWithStdin("pw\n", "restore", Mnemonic, "--net", "regtest", "--password-stdin", "--file", WalletFile()); + + var (exit, _, err) = await CliWithStdin("nope\n", "info", "--net", "regtest", "--password-stdin", "--file", WalletFile()); + + Assert.Equal(1, exit); + Assert.NotEmpty(err); + } + + [Fact] + public async Task PasswordFlag_StillWorksButWarnsAndNeverEchoesThePassword() + { + var (exit, output, err) = await Cli("restore", Mnemonic, "--net", "regtest", "--password", "hunter2", + "--file", WalletFile()); + + Assert.Equal(0, exit); + Assert.Contains("--password is visible", err); + Assert.DoesNotContain("hunter2", output + err); + Assert.True(EncryptedFile.IsEncrypted(File.ReadAllText(WalletFile()))); + } + + [Fact] + public async Task MoreThanOnePasswordSource_IsRejected() + { + var (exit, _, err) = await Cli("restore", Mnemonic, "--net", "regtest", "--password", "a", + "--password-stdin", "--file", WalletFile()); + + Assert.Equal(1, exit); + Assert.Contains("only one", err); + } + + [Fact] + public async Task Sync_OfAnEncryptedWallet_SavesItBackEncrypted() + { + await CliWithStdin("pw\n", "restore", Mnemonic, "--net", "regtest", "--password-stdin", "--file", WalletFile()); + await using var server = new FakeElectrumServer(); + server.Handle("server.features", _ => new { genesis_hash = ChainProfiles.Regtest.GenesisHash }); + server.Handle("blockchain.headers.subscribe", _ => new { height = 5, hex = new string('0', 160) }); + server.Handle("blockchain.scripthash.subscribe", _ => null); + server.Handle("blockchain.scripthash.get_history", _ => Array.Empty()); + + var (exit, _, _) = await CliWithStdin("pw\n", "sync", "--server", $"{server.Host}:{server.Port}", + "--net", "regtest", "--password-stdin", "--file", WalletFile()); + + Assert.Equal(0, exit); + Assert.True(EncryptedFile.IsEncrypted(File.ReadAllText(WalletFile()))); + } }