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:
@@ -910,6 +910,8 @@ private:
|
||||
// curve on every failed path (a PIN RPC failure used to skip it).
|
||||
void applyUnlockSuccess(const std::string& passphrase, int timeout);
|
||||
void applyUnlockFailure(const std::string& errorMessage);
|
||||
// Non-credential failure (busy node, unknown error): message only, no attempt counted.
|
||||
void applyUnlockNotCounted(const std::string& errorMessage);
|
||||
// Clear all rescan + witness-rebuild progress/accumulator state. Shared by the four
|
||||
// rescan-completion sites so a new witness field can't be forgotten in one copy.
|
||||
void resetWitnessRescanProgress();
|
||||
|
||||
+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());
|
||||
};
|
||||
}
|
||||
|
||||
@@ -152,5 +152,30 @@ inline RpcFailureKind rpcFailureKindOf(const std::exception& e)
|
||||
return RpcFailureKind::Other;
|
||||
}
|
||||
|
||||
// What a failed walletpassphrase call means. Only WrongPassphrase (-14) may count toward the lockout;
|
||||
// a busy/warming-up node, a wallet that is not encrypted, or anything unknown is never a bad guess.
|
||||
enum class UnlockFailure { WrongPassphrase, NodeBusy, NotEncrypted, Other };
|
||||
|
||||
inline UnlockFailure classifyUnlockFailure(const std::exception& e)
|
||||
{
|
||||
if (const auto* r = dynamic_cast<const RpcError*>(&e)) {
|
||||
if (r->code == -14) return UnlockFailure::WrongPassphrase; // RPC_WALLET_PASSPHRASE_INCORRECT
|
||||
if (r->code == -15) return UnlockFailure::NotEncrypted; // RPC_WALLET_WRONG_ENC_STATE
|
||||
if (r->code == -28) return UnlockFailure::NodeBusy; // RPC_IN_WARMUP
|
||||
return UnlockFailure::Other;
|
||||
}
|
||||
switch (rpcFailureKindOf(e)) {
|
||||
case RpcFailureKind::ReplyTimeout:
|
||||
case RpcFailureKind::QueueFull:
|
||||
case RpcFailureKind::Deferred:
|
||||
case RpcFailureKind::Warmup:
|
||||
case RpcFailureKind::ConnectTimeout:
|
||||
case RpcFailureKind::NotListening:
|
||||
return UnlockFailure::NodeBusy;
|
||||
default:
|
||||
return UnlockFailure::Other;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace rpc
|
||||
} // namespace dragonx
|
||||
|
||||
@@ -2705,6 +2705,7 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["sec_unlock_button"] = "Unlock";
|
||||
strings_["sec_not_connected_to_daemon"] = "Not connected to daemon";
|
||||
strings_["sec_unlock_failed_prefix"] = "Unlock failed: ";
|
||||
strings_["sec_unlock_node_busy"] = "The node is busy; try again in a moment.";
|
||||
strings_["sec_incorrect_passphrase_decrypt"] = "Incorrect passphrase";
|
||||
strings_["sec_importing_keys_rescanning"] = "Importing keys & rescanning blockchain — wallet is usable while this runs";
|
||||
strings_["sec_encrypted_backup_suffix"] = "\nEncrypted backup: wallet.dat.encrypted.bak";
|
||||
|
||||
Reference in New Issue
Block a user