feat(cli): read the wallet password from env, stdin or a prompt

The password no longer has to be a command-line argument: it can come from
--password-env, --password-stdin or a no-echo prompt (--password still works
but warns). Plaintext storage is now an explicit opt-in (--no-encryption)
instead of the result of a forgotten flag. See ADR 0038.
This commit is contained in:
davide committed 2026-11-03 14:21:58 +01:00
1 parent eca4d4ac0c
commit 92fd783658
8 files changed
+342 -38

No files matched your search

+10
View File
@@ -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
+8 -4
View File
@@ -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 "<mnemonic>" [...]
dotnet run --project src/Cli -- restore-xpub <slip132-key> [--net ...] [--password P]
dotnet run --project src/Cli -- restore-address <addr1,addr2,...> [--net ...] [--password P]
dotnet run --project src/Cli -- info [--net ...] [--password P]
dotnet run --project src/Cli -- restore-xpub <slip132-key> [--net ...] [--no-encryption]
dotnet run --project src/Cli -- restore-address <addr1,addr2,...> [--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/<network>/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.
+19 -8
View File
@@ -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/<network>/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 "<words>" --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 "<mnemonic>" [same options as create]
Restores from an existing mnemonic (quoted, words space-separated).
```
restore-xpub <slip132-key> [--net N] [--password P] [--file PATH]
restore-xpub <slip132-key> [--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**.
@@ -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
+2 -1
View File
@@ -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.
+103
View File
@@ -0,0 +1,103 @@
using PalladiumWallet.Core.Storage;
using PalladiumWallet.Core.Wallet;
/// <summary>
/// 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 (<c>--no-encryption</c>), never the result of a forgotten flag.
/// </summary>
internal static class CliPassword
{
/// <summary>Password of the wallet opened by this process, reused when the wallet is saved back.</summary>
internal static string? Current { get; private set; }
private static bool Interactive => !Console.IsInputRedirected;
/// <summary>Password to decrypt <paramref name="path"/>; null when the file is not encrypted.</summary>
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.");
}
/// <summary>Password to encrypt a new wallet; null only with <c>--no-encryption</c>.</summary>
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;
}
/// <summary>Password from --password, --password-env or --password-stdin; null if none given.</summary>
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;
}
/// <summary>No-echo prompt on the terminal; null when there is no terminal (never hangs).</summary>
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;
}
}
+17 -13
View File
@@ -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<int> 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 "<mnemonic>" [same options as create] [--path m/...]
restore-xpub <slip132 xpub> [--net ...] [--password P] [--file PATH] (watch-only)
restore-address <addr1,addr2,...> [--net ...] [--password P] [--file PATH] (watch-only, no keys)
info [--net ...] [--password P] [--file PATH]
restore-xpub <slip132 xpub> [--net ...] [--file PATH] (watch-only)
restore-address <addr1,addr2,...> [--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 ...]
+154 -12
View File
@@ -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<string>() });
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<object>());
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())));
}
}