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:
1 parent
eca4d4ac0c
commit
92fd783658
8 files changed
+342
-38
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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 ...]
|
||||
|
||||
@@ -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())));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user