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:
+46
-4
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user