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

+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 ...]