fix(security): don't silently leave a wallet unencrypted or unlocked (W2-2, W2-4)

P0-B encryption-integrity cluster.

W2-2: the first-run wizard's "encrypt" stored the passphrase only in memory and let the
user into the app immediately, so a quit/crash or a failed daemon connect before the
deferred encryption applied left the wallet unencrypted with NO record encryption was
ever requested — the user believing it was encrypted. A persisted encryption_pending
settings flag is now set the moment encryption is requested (never the passphrase, only
the fact). refreshWalletEncryptionState() reconciles it on every connect: wallet observed
encrypted -> clear the flag; wallet NOT encrypted while the flag is set and no deferred
encryption is pending/in-flight -> a once-per-session "your wallet is NOT encrypted — open
Settings to finish" warning (the flag stays set, so it recurs each launch until resolved).
The passphrase is deliberately never persisted to auto-complete — surfacing it is the
secure choice.

W2-4: lockWallet()'s continuation only handled success — a failed walletlock RPC silently
left the wallet UNLOCKED (an unfulfilled auto-lock). It now logs and warns once (reset on
the next successful lock) so a failing auto-lock is visible instead of leaving the wallet
exposed.

Touches settings.{h,cpp}, app_wizard.cpp, app_security.cpp, app.h. Not unit-testable at
this layer (RPC/connect-driven state). Build-clean; ctest 1/1. See docs/wallet-hardening.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-02 14:48:02 -05:00
co-authored by Claude Opus 4.8
parent c7d163f44a
commit 8c12b27c0a
6 changed files with 50 additions and 1 deletions
+2
View File
@@ -231,6 +231,7 @@ bool Settings::load(const std::string& path)
}
loadScalar(j, "wizard_completed", wizard_completed_);
loadScalar(j, "seed_backup_reminded", seed_backup_reminded_);
loadScalar(j, "encryption_pending", encryption_pending_);
loadScalar(j, "daemon_update_prompted_size", daemon_update_prompted_size_);
loadScalar(j, "active_wallet_file", active_wallet_file_);
loadScalar(j, "seed_migration_pending", seed_migration_pending_);
@@ -497,6 +498,7 @@ bool Settings::save(const std::string& path)
}
j["wizard_completed"] = wizard_completed_;
j["seed_backup_reminded"] = seed_backup_reminded_;
j["encryption_pending"] = encryption_pending_;
j["daemon_update_prompted_size"] = daemon_update_prompted_size_;
j["active_wallet_file"] = active_wallet_file_;
j["seed_migration_pending"] = seed_migration_pending_;
+7
View File
@@ -327,6 +327,12 @@ public:
bool getSeedBackupReminded() const { return seed_backup_reminded_; }
void setSeedBackupReminded(bool v) { seed_backup_reminded_ = v; }
// Persisted the moment deferred (wizard) encryption is requested; cleared only once the wallet is
// observed to be actually encrypted. Lets a quit/crash/failed-connect before it applies be detected
// and surfaced (W2-2). NEVER stores the passphrase — only the fact that encryption was requested.
bool getEncryptionPending() const { return encryption_pending_; }
void setEncryptionPending(bool v) { encryption_pending_ = v; }
// Bundled-daemon size we last prompted to install (see App::renderDaemonUpdatePrompt). Lets the
// "a newer node is bundled — update?" prompt fire once per wallet version, never re-nagging.
long long getDaemonUpdatePromptedSize() const { return daemon_update_prompted_size_; }
@@ -574,6 +580,7 @@ private:
std::map<std::string, AddressMeta> address_meta_;
bool wizard_completed_ = false;
bool seed_backup_reminded_ = false;
bool encryption_pending_ = false;
long long daemon_update_prompted_size_ = 0; // bundled daemon size last offered via the update prompt
std::string active_wallet_file_ = "wallet.dat"; // -wallet=<name> the daemon loads (multi-wallet)
bool seed_migration_pending_ = false;