diff --git a/src/app_network.cpp b/src/app_network.cpp index 631aac5..c41659d 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -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_) { diff --git a/src/app_security.cpp b/src/app_security.cpp index cbe6b12..959baf3 100644 --- a/src/app_security.cpp +++ b/src/app_security.cpp @@ -33,6 +33,7 @@ #include #include #include +#include #include #include #include @@ -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. diff --git a/src/config/settings.cpp b/src/config/settings.cpp index 009f8a2..ae589ae 100644 --- a/src/config/settings.cpp +++ b/src/config/settings.cpp @@ -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_) diff --git a/src/config/settings.h b/src/config/settings.h index ca11604..a12bb86 100644 --- a/src/config/settings.h +++ b/src/config/settings.h @@ -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 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 empty_wallet_warning_acked_; // wallet files whose empty-wallet warning was dismissed bool encryption_pending_ = false;