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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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を使用",
|
||||
|
||||
@@ -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 사용",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user