fix(security): only a real wrong passphrase counts toward unlock lockout
A failed wallet unlock was always treated as a wrong guess: the lockout counter was bumped and the back-off curve armed no matter why the RPC failed. A busy or warming-up node, a transport timeout, or a wallet that is not actually encrypted would all eat an attempt and could push a user into a lockout without a single mistyped passphrase. The failed call is now classified before it is acted on: - Only the daemon's "passphrase incorrect" error counts as a wrong guess and advances the lockout. - A warming-up or unreachable node, a reply timeout, or a full queue is reported as a transient busy-node condition with no attempt charged. - A "wallet is not encrypted" reply refreshes the local encryption state instead of penalising the user. - Any unknown error surfaces its message without touching the counter. The PIN path got the same treatment: when the vault decrypts correctly but the unlock RPC then fails for a non-credential reason, the attempt is no longer counted. Tests cover the classification of each failure kind. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+40
-8
@@ -511,6 +511,16 @@ void App::applyUnlockFailure(const std::string& errorMessage) {
|
||||
DEBUG_LOGF("[App] Wallet unlock failed (attempt %d)\n", lock_attempts_);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
lock_unlock_in_progress_ = false;
|
||||
lock_error_msg_ = msg;
|
||||
lock_error_timer_ = 3.0f;
|
||||
lock_timer_armed_at_ = std::chrono::steady_clock::now();
|
||||
memset(lock_passphrase_buf_, 0, sizeof(lock_passphrase_buf_));
|
||||
}
|
||||
|
||||
void App::unlockWallet(const std::string& passphrase, int timeout) {
|
||||
if (!rpc_ || !rpc_->isConnected() || !worker_) return;
|
||||
lock_unlock_in_progress_ = true;
|
||||
@@ -520,17 +530,39 @@ void App::unlockWallet(const std::string& passphrase, int timeout) {
|
||||
auto* r = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
|
||||
w->post([this, r, passphrase = std::string(passphrase), timeout]() mutable -> rpc::RPCWorker::MainCb {
|
||||
std::string err_msg;
|
||||
WalletSecurityRpcAdapter rpcAdapter(r);
|
||||
bool ok = rpcAdapter.unlockWallet(passphrase, timeout, err_msg);
|
||||
rpc::UnlockFailure cls = rpc::UnlockFailure::Other;
|
||||
bool ok = false;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Security / Unlock");
|
||||
r->call("walletpassphrase", {passphrase, timeout});
|
||||
ok = true;
|
||||
} catch (const std::exception& e) {
|
||||
err_msg = e.what();
|
||||
cls = rpc::classifyUnlockFailure(e);
|
||||
}
|
||||
std::string cachePassphrase = passphrase;
|
||||
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
|
||||
|
||||
return [this, ok, err_msg, timeout, passphrase = std::move(cachePassphrase)]() mutable {
|
||||
return [this, ok, cls, err_msg, timeout, passphrase = std::move(cachePassphrase)]() mutable {
|
||||
if (ok) {
|
||||
applyUnlockSuccess(passphrase, timeout);
|
||||
} else {
|
||||
DEBUG_LOGF("[App] Passphrase unlock RPC error: %s\n", err_msg.c_str());
|
||||
applyUnlockFailure(TR("incorrect_passphrase"));
|
||||
switch (cls) {
|
||||
case rpc::UnlockFailure::WrongPassphrase:
|
||||
applyUnlockFailure(TR("incorrect_passphrase"));
|
||||
break;
|
||||
case rpc::UnlockFailure::NodeBusy:
|
||||
applyUnlockNotCounted(TR("sec_unlock_node_busy"));
|
||||
break;
|
||||
case rpc::UnlockFailure::NotEncrypted:
|
||||
applyUnlockNotCounted(TR("sec_unlock_node_busy"));
|
||||
refreshWalletEncryptionState();
|
||||
break;
|
||||
case rpc::UnlockFailure::Other:
|
||||
applyUnlockNotCounted(std::string(TR("sec_unlock_failed_prefix")) + err_msg);
|
||||
break;
|
||||
}
|
||||
}
|
||||
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
|
||||
};
|
||||
@@ -1375,11 +1407,11 @@ void App::renderLockScreen() {
|
||||
"PIN marked out of date, no penalty\n");
|
||||
};
|
||||
} else {
|
||||
// Vault decrypt succeeded but the RPC failed — still a failed unlock,
|
||||
// so route through applyUnlockFailure so the lockout curve applies
|
||||
// (this path previously bumped the counter but skipped the lockout math).
|
||||
// Vault decrypt succeeded (the PIN was correct) and the RPC failed for a
|
||||
// reason other than -14 (busy node, warmup, transport error). That is never
|
||||
// a wrong credential, so it must not count toward the lockout.
|
||||
return [this, rpcErr, passphrase = std::move(passphrase)]() mutable {
|
||||
applyUnlockFailure(std::string(TR("sec_unlock_failed_prefix")) + rpcErr);
|
||||
applyUnlockNotCounted(std::string(TR("sec_unlock_failed_prefix")) + rpcErr);
|
||||
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user