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
+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();