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:
2026-10-13 19:24:12 -05:00
co-authored by Claude Opus 4.8
parent 100d76b88f
commit 63a422a615
13 changed files with 100 additions and 8 deletions
+1
View File
@@ -1707,6 +1707,7 @@
"sec_total_elapsed_fmt": "Gesamtdauer: %dm %02ds",
"sec_unlock_button": "Entsperren",
"sec_unlock_failed_prefix": "Entsperren fehlgeschlagen: ",
"sec_unlock_node_busy": "The node is busy; try again in a moment.",
"sec_unlocking_fmt": "Entsperren%s",
"sec_use_passphrase_instead": "Stattdessen Passphrase verwenden",
"sec_use_pin_instead": "Stattdessen PIN verwenden",
+1
View File
@@ -1707,6 +1707,7 @@
"sec_total_elapsed_fmt": "Tiempo total: %dm %02ds",
"sec_unlock_button": "Desbloquear",
"sec_unlock_failed_prefix": "Error al desbloquear: ",
"sec_unlock_node_busy": "The node is busy; try again in a moment.",
"sec_unlocking_fmt": "Desbloqueando%s",
"sec_use_passphrase_instead": "Usar frase de contraseña en su lugar",
"sec_use_pin_instead": "Usar PIN en su lugar",
+1
View File
@@ -1707,6 +1707,7 @@
"sec_total_elapsed_fmt": "Temps total écoulé : %dm %02ds",
"sec_unlock_button": "Déverrouiller",
"sec_unlock_failed_prefix": "Échec du déverrouillage : ",
"sec_unlock_node_busy": "The node is busy; try again in a moment.",
"sec_unlocking_fmt": "Déverrouillage%s",
"sec_use_passphrase_instead": "Utiliser une phrase secrète",
"sec_use_pin_instead": "Utiliser un PIN",
+1
View File
@@ -1707,6 +1707,7 @@
"sec_total_elapsed_fmt": "合計経過時間: %d分 %02d秒",
"sec_unlock_button": "ロック解除",
"sec_unlock_failed_prefix": "ロック解除に失敗しました: ",
"sec_unlock_node_busy": "The node is busy; try again in a moment.",
"sec_unlocking_fmt": "ロック解除中%s",
"sec_use_passphrase_instead": "代わりにパスフレーズを使用",
"sec_use_pin_instead": "代わりにPINを使用",
+1
View File
@@ -1707,6 +1707,7 @@
"sec_total_elapsed_fmt": "총 경과 시간: %d분 %02d초",
"sec_unlock_button": "잠금 해제",
"sec_unlock_failed_prefix": "잠금 해제 실패: ",
"sec_unlock_node_busy": "The node is busy; try again in a moment.",
"sec_unlocking_fmt": "잠금 해제 중%s",
"sec_use_passphrase_instead": "대신 암호문 사용",
"sec_use_pin_instead": "대신 PIN 사용",
+1
View File
@@ -1707,6 +1707,7 @@
"sec_total_elapsed_fmt": "Tempo total: %dm %02ds",
"sec_unlock_button": "Desbloquear",
"sec_unlock_failed_prefix": "Falha ao desbloquear: ",
"sec_unlock_node_busy": "The node is busy; try again in a moment.",
"sec_unlocking_fmt": "Desbloqueando%s",
"sec_use_passphrase_instead": "Usar frase-senha",
"sec_use_pin_instead": "Usar PIN",
+1
View File
@@ -1707,6 +1707,7 @@
"sec_total_elapsed_fmt": "Всего прошло: %dм %02dс",
"sec_unlock_button": "Разблокировать",
"sec_unlock_failed_prefix": "Ошибка разблокировки: ",
"sec_unlock_node_busy": "The node is busy; try again in a moment.",
"sec_unlocking_fmt": "Разблокировка%s",
"sec_use_passphrase_instead": "Использовать парольную фразу",
"sec_use_pin_instead": "Использовать PIN",
+1
View File
@@ -1707,6 +1707,7 @@
"sec_total_elapsed_fmt": "总耗时:%d 分 %02d 秒",
"sec_unlock_button": "解锁",
"sec_unlock_failed_prefix": "解锁失败:",
"sec_unlock_node_busy": "The node is busy; try again in a moment.",
"sec_unlocking_fmt": "正在解锁%s",
"sec_use_passphrase_instead": "改用密码短语",
"sec_use_pin_instead": "改用 PIN",
+2
View File
@@ -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
View File
@@ -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());
};
}
+25
View File
@@ -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
+1
View File
@@ -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";
+24
View File
@@ -815,6 +815,29 @@ void testClassifyRpcFailure()
EXPECT_FALSE(dragonx::rpc::isRpcTimeout(K::QueueFull));
}
void testClassifyUnlockFailure()
{
using dragonx::rpc::RpcError;
using dragonx::rpc::RpcTransportError;
using dragonx::rpc::RpcFailureKind;
using dragonx::rpc::UnlockFailure;
using dragonx::rpc::classifyUnlockFailure;
EXPECT_TRUE(classifyUnlockFailure(RpcError(-14, "bad")) == UnlockFailure::WrongPassphrase);
EXPECT_TRUE(classifyUnlockFailure(RpcError(-15, "not enc")) == UnlockFailure::NotEncrypted);
EXPECT_TRUE(classifyUnlockFailure(RpcError(-28, "warmup")) == UnlockFailure::NodeBusy);
EXPECT_TRUE(classifyUnlockFailure(RpcError(-6, "funds")) == UnlockFailure::Other);
const RpcFailureKind busy[] = {RpcFailureKind::ReplyTimeout, RpcFailureKind::QueueFull,
RpcFailureKind::Deferred, RpcFailureKind::ConnectTimeout, RpcFailureKind::NotListening,
RpcFailureKind::Warmup};
for (auto k : busy)
EXPECT_TRUE(classifyUnlockFailure(RpcTransportError(k, "x")) == UnlockFailure::NodeBusy);
const RpcFailureKind other[] = {RpcFailureKind::ShuttingDown, RpcFailureKind::Unhandled,
RpcFailureKind::Aborted, RpcFailureKind::Auth};
for (auto k : other)
EXPECT_TRUE(classifyUnlockFailure(RpcTransportError(k, "x")) == UnlockFailure::Other);
EXPECT_TRUE(classifyUnlockFailure(std::runtime_error("x")) == UnlockFailure::Other);
}
void testFormatHttpError()
{
using dragonx::rpc::formatHttpError;
@@ -18189,6 +18212,7 @@ int main()
{
testConnectionConfig();
testClassifyRpcFailure();
testClassifyUnlockFailure();
testFormatHttpError();
testRpcFailureKindOf();
testPaymentUri();