feat(security): persist unlock lockout across restarts; keep unlock errors visible

- The failed-unlock error message now stays on screen until the next attempt /
  mode switch / success, instead of fading after ~3s (the reason was often gone
  before it could be read).
- The escalating failed-attempt lockout counter + expiry are now persisted
  (keyed to the active wallet file) and restored once per lock-screen session,
  so killing and relaunching the app can no longer reset the lockout curve — a
  brute-force window. A forward clock change is capped; the PIN/passphrase is
  never stored. The verification (walletpassphrase/decrypt) path is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 23:20:32 -05:00
co-authored by Claude Opus 4.8
parent 89d3975eb3
commit a30a083df0
4 changed files with 66 additions and 4 deletions
+2
View File
@@ -1673,6 +1673,7 @@ void App::switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed
pin_vault_stale_ = false; // the out-of-date mark was the previous wallet's vault
lock_attempts_ = 0;
lock_lockout_timer_ = 0.0f;
lock_state_restored_ = false; // re-restore persisted lockout for the newly-active wallet
lock_error_msg_.clear();
lock_error_timer_ = 0.0f;
lock_unlock_in_progress_ = false;
@@ -7031,6 +7032,7 @@ void App::pumpSeedMigration()
seed_migration_dest_ = std::move(dest);
seed_migration_temp_dir_ = std::move(tmp);
seed_migration_backed_up_ = false;
seed_migration_understood_ = false;
seed_migration_step_ = SeedMigrationStep::ShowSeed;
// Persist the pending migration so a later sweep/adopt step can find the new wallet.
if (settings_) {
+46 -4
View File
@@ -33,6 +33,7 @@
#include <algorithm>
#include <cstring>
#include <ctime>
#include <cmath>
#include <cstdint>
#include <filesystem>
#include <fstream>
@@ -476,6 +477,12 @@ void App::applyUnlockSuccess(const std::string& passphrase, int timeout) {
lock_unlock_in_progress_ = false;
lock_error_msg_.clear();
lock_attempts_ = 0;
// Clear the persisted lockout on a successful unlock (guarded so a normal unlock isn't a disk write).
if (settings_ && settings_->getLockFailedAttempts() != 0) {
settings_->setLockFailedAttempts(0);
settings_->setLockoutUntilEpoch(0);
settings_->save();
}
memset(lock_passphrase_buf_, 0, sizeof(lock_passphrase_buf_));
idle_.noteActivity();
state_.encrypted = true;
@@ -499,9 +506,37 @@ void App::applyUnlockFailure(const std::string& errorMessage) {
if (lock_attempts_ >= maxAttempts) {
lock_lockout_timer_ = baseDelay * (float)(1 << std::min(lock_attempts_ - maxAttempts, 8));
}
// Persist the attempt count + lockout expiry (keyed to this wallet) so killing and relaunching the
// app can't reset the escalating lockout. The PIN/passphrase itself is never stored.
if (settings_) {
settings_->setLockStateWallet(settings_->getActiveWalletFile());
settings_->setLockFailedAttempts(lock_attempts_);
settings_->setLockoutUntilEpoch(lock_lockout_timer_ > 0.0f
? (long long)std::time(nullptr) + (long long)std::ceil(lock_lockout_timer_) : 0);
settings_->save();
}
DEBUG_LOGF("[App] Wallet unlock failed (attempt %d)\n", lock_attempts_);
}
// Restore the persisted lockout at the start of a lock-screen session so a kill-and-relaunch can't reset
// the escalating failure curve. Only applies when the persisted state is for the active wallet.
void App::restoreLockoutState() {
if (!settings_) return;
if (settings_->getLockStateWallet() != settings_->getActiveWalletFile()) return;
lock_attempts_ = std::max(0, std::min(settings_->getLockFailedAttempts(), 64));
long long remaining = settings_->getLockoutUntilEpoch() - (long long)std::time(nullptr);
// Cap the restored countdown so a forward clock change can't create an absurd wait. The attempt count
// still persists, so rolling the clock back only buys a short window before the next failure re-arms
// the full curve.
const float baseDelay = ui::schema::UI().drawElement("security", "lockout-base-delay").sizeOr(2.0f);
const long long maxLock = (long long)(baseDelay * (float)(1 << 8));
if (remaining > maxLock) remaining = maxLock;
if (remaining > 0) {
lock_lockout_timer_ = (float)remaining;
lock_timer_armed_at_ = std::chrono::steady_clock::now();
}
}
// Failure that is NOT a wrong credential (busy node, not-encrypted, unknown error): surface the
// message and clear the buffer, but do not bump lock_attempts_ or arm the lockout.
void App::applyUnlockNotCounted(const std::string& msg) {
@@ -1040,9 +1075,15 @@ void App::tickMiningThreadCommit()
void App::renderLockScreen() {
using namespace ui::material;
// Restore any persisted lockout once per lock-screen session (re-armed on wallet switch).
if (!lock_state_restored_) {
lock_state_restored_ = true;
restoreLockoutState();
}
// Keep frames coming while something on the lock screen is moving without input: the unlock in
// flight (its dots), the lockout countdown, an error message about to clear.
if (lock_unlock_in_progress_ || lock_lockout_timer_ > 0.0f || lock_error_timer_ > 0.0f) {
if (lock_unlock_in_progress_ || lock_lockout_timer_ > 0.0f) {
RequestProgressFrame();
}
@@ -1264,9 +1305,9 @@ void App::renderLockScreen() {
lock_screen_was_visible_ = true;
}
// Error message
if (!lock_error_msg_.empty() && lock_error_timer_ > 0) {
lock_error_timer_ -= lockDt;
// Error message — stays visible until the next unlock attempt / mode switch / success, instead of
// fading after a few seconds (a failed-unlock reason was often gone before it could be read).
if (!lock_error_msg_.empty()) {
ImVec2 es = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, 0, lock_error_msg_.c_str());
dl->AddText(captionFont, captionFont->LegacySize,
ImVec2(cardX + (cardW - es.x) * 0.5f, cy), ui::material::Error(),
@@ -1312,6 +1353,7 @@ void App::renderLockScreen() {
// Handle submit (Enter key or button click)
if ((submitted || btnClicked) && canSubmit) {
lock_error_msg_.clear(); // the prior error clears only when a new attempt actually starts
// Derive daemon unlock timeout from auto-lock setting:
// 2x the idle timeout so the daemon stays unlocked longer than
// the GUI auto-lock, acting as a safety net if the GUI crashes.
+6
View File
@@ -271,6 +271,9 @@ bool Settings::load(const std::string& path)
}
loadScalar(j, "wizard_completed", wizard_completed_);
loadScalar(j, "seed_backup_reminded", seed_backup_reminded_);
loadScalar(j, "lock_failed_attempts", lock_failed_attempts_);
loadScalar(j, "lockout_until_epoch", lockout_until_epoch_);
loadScalar(j, "lock_state_wallet", lock_state_wallet_);
loadScalar(j, "large_wallet_warned", large_wallet_warned_);
if (j.contains("empty_wallet_warning_acked") && j["empty_wallet_warning_acked"].is_array()) {
empty_wallet_warning_acked_.clear();
@@ -596,6 +599,9 @@ bool Settings::save(const std::string& path)
}
j["wizard_completed"] = wizard_completed_;
j["seed_backup_reminded"] = seed_backup_reminded_;
j["lock_failed_attempts"] = lock_failed_attempts_;
j["lockout_until_epoch"] = lockout_until_epoch_;
j["lock_state_wallet"] = lock_state_wallet_;
j["large_wallet_warned"] = large_wallet_warned_;
j["empty_wallet_warning_acked"] = json::array();
for (const auto& w : empty_wallet_warning_acked_)
+12
View File
@@ -393,6 +393,15 @@ public:
bool getSeedBackupReminded() const { return seed_backup_reminded_; }
void setSeedBackupReminded(bool v) { seed_backup_reminded_ = v; }
// Unlock lockout state, persisted across restarts so the failed-attempt curve can't be reset by
// killing the app. Keyed by the wallet file it applies to; the PIN/passphrase is never stored.
int getLockFailedAttempts() const { return lock_failed_attempts_; }
void setLockFailedAttempts(int v) { lock_failed_attempts_ = v; durable_next_save_ = true; }
long long getLockoutUntilEpoch() const { return lockout_until_epoch_; }
void setLockoutUntilEpoch(long long v) { lockout_until_epoch_ = v; durable_next_save_ = true; }
std::string getLockStateWallet() const { return lock_state_wallet_; }
void setLockStateWallet(const std::string& v) { lock_state_wallet_ = v; durable_next_save_ = true; }
// One-time nudge when wallet.dat grows past the bloat threshold (re-armed if it shrinks back).
bool getLargeWalletWarned() const { return large_wallet_warned_; }
void setLargeWalletWarned(bool v) { large_wallet_warned_ = v; }
@@ -736,6 +745,9 @@ private:
std::map<std::string, AddressMeta> address_meta_;
bool wizard_completed_ = false;
bool seed_backup_reminded_ = false;
int lock_failed_attempts_ = 0; // persisted unlock-failure count (lockout curve)
long long lockout_until_epoch_ = 0; // unix time the current lockout expires (0 = none)
std::string lock_state_wallet_; // wallet file the above two apply to
bool large_wallet_warned_ = false;
std::set<std::string> empty_wallet_warning_acked_; // wallet files whose empty-wallet warning was dismissed
bool encryption_pending_ = false;