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:
@@ -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