Files
ObsidianDragon/src/rpc/rpc_failure.h
T
DanSandClaude Opus 4.8 63a422a615 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>
2026-10-13 19:24:12 -05:00

182 lines
8.3 KiB
C++

// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#pragma once
// Classifying a failed RPC call from structured data (curl code, request-sent bit, HTTP status,
// JSON-RPC error code, non-JSON body) instead of substrings of what(). Header-only and pure, so the
// test target can use it without compiling rpc_client.cpp.
#include <curl/curl.h>
#include <stdexcept>
#include <string>
namespace dragonx {
namespace rpc {
enum class RpcFailureKind {
None, // the call succeeded
NotListening, // connection refused: nothing bound to the RPC port (not started, or already unbound)
ConnectTimeout, // TCP connect didn't complete in time (Windows after the daemon unbinds; filtered port)
ReplyTimeout, // request sent, no reply in time: the daemon holds it (busy, e.g. blocked on cs_main)
QueueFull, // HTTP 500 "Work queue depth exceeded": rejected before any handler ran; holds no slot
ShuttingDown, // HTTP 503: the daemon's HTTP server was interrupted for shutdown
Unhandled, // HTTP 500 "Unhandled request": queued, then dropped by a shutting-down daemon
Auth, // HTTP 401: wrong rpcuser/rpcpassword (or a stale .cookie)
Warmup, // JSON-RPC -28 (RPC_IN_WARMUP): the node is still loading
AppError, // the daemon ran the method and answered with a JSON-RPC error
Aborted, // our own requestAbort() cut the transfer
Deferred, // not sent: the node is busy (RpcBusyGate), so we held back rather than queue more
Other,
};
inline const char* rpcFailureKindName(RpcFailureKind kind)
{
switch (kind) {
case RpcFailureKind::None: return "None";
case RpcFailureKind::NotListening: return "NotListening";
case RpcFailureKind::ConnectTimeout: return "ConnectTimeout";
case RpcFailureKind::ReplyTimeout: return "ReplyTimeout";
case RpcFailureKind::QueueFull: return "QueueFull";
case RpcFailureKind::ShuttingDown: return "ShuttingDown";
case RpcFailureKind::Unhandled: return "Unhandled";
case RpcFailureKind::Auth: return "Auth";
case RpcFailureKind::Warmup: return "Warmup";
case RpcFailureKind::AppError: return "AppError";
case RpcFailureKind::Aborted: return "Aborted";
case RpcFailureKind::Deferred: return "Deferred";
case RpcFailureKind::Other: return "Other";
}
return "Other";
}
// True for the two curl timeouts: what the old `what().find("Timeout")` checks meant.
inline bool isRpcTimeout(RpcFailureKind kind)
{
return kind == RpcFailureKind::ConnectTimeout || kind == RpcFailureKind::ReplyTimeout;
}
/**
* @param curlCode CURLcode of the transfer (CURLE_OK when a response arrived)
* @param requestSent the request reached the daemon (CURLINFO_REQUEST_SIZE > 0). libcurl reports
* connect- and reply-phase timeouts with the same code; this tells them apart.
* @param httpStatus HTTP status, 0 when there was no response
* @param rpcCode JSON-RPC error.code from a JSON error body, 0 when there was none
* @param plainBody the response body when it was NOT JSON (dragonxd's HTTP-layer replies), else ""
*/
inline RpcFailureKind classifyRpcFailure(int curlCode, bool requestSent, long httpStatus, int rpcCode,
const std::string& plainBody)
{
if (curlCode != CURLE_OK) {
switch (curlCode) {
case CURLE_ABORTED_BY_CALLBACK: return RpcFailureKind::Aborted;
case CURLE_COULDNT_CONNECT: return RpcFailureKind::NotListening;
case CURLE_OPERATION_TIMEDOUT:
return requestSent ? RpcFailureKind::ReplyTimeout : RpcFailureKind::ConnectTimeout;
default: return RpcFailureKind::Other;
}
}
if (rpcCode == -28) return RpcFailureKind::Warmup; // takes precedence over the HTTP status
if (rpcCode != 0) return RpcFailureKind::AppError; // 500, or 400/404 (invalid request / no method)
if (httpStatus == 200) return RpcFailureKind::None;
if (httpStatus == 401) return RpcFailureKind::Auth;
if (httpStatus == 503) return RpcFailureKind::ShuttingDown;
if (httpStatus == 500) {
if (plainBody.find("Work queue depth exceeded") != std::string::npos) return RpcFailureKind::QueueFull;
if (plainBody.find("Unhandled request") != std::string::npos) return RpcFailureKind::Unhandled;
}
return RpcFailureKind::Other;
}
/**
* "RPC error: HTTP <code>", plus the body when the daemon sent a plain-text one (e.g. ": Work queue
* depth exceeded"), trimmed and limited to 120 printable ASCII characters so an HTML page or binary
* junk can't flood a log line. Exactly "RPC error: HTTP <code>" when nothing printable remains.
*/
inline std::string formatHttpError(long httpStatus, const std::string& plainBody)
{
std::string text;
bool pendingSpace = false;
for (unsigned char c : plainBody) {
if (text.size() >= 120) break;
if (c == ' ' || c == '\t' || c == '\r' || c == '\n') {
pendingSpace = !text.empty();
} else if (c >= 0x21 && c <= 0x7e) {
if (pendingSpace && text.size() < 119) text.push_back(' ');
pendingSpace = false;
text.push_back(static_cast<char>(c));
}
}
std::string out = "RPC error: HTTP " + std::to_string(httpStatus);
if (!text.empty()) out += ": " + text;
return out;
}
/**
* The daemon ran the method and answered with a JSON-RPC error. what() is the daemon's message;
* `code` is its JSON-RPC error code, notably -28 (RPC_IN_WARMUP). Callers catch this type to mean
* "the daemon said no" (e.g. no mnemonic -> fall back), as opposed to a transport failure.
*/
class RpcError : public std::runtime_error {
public:
RpcError(int errorCode, const std::string& message, long status = 0)
: std::runtime_error(message), code(errorCode), httpStatus(status) {}
RpcFailureKind kind() const { return code == -28 ? RpcFailureKind::Warmup : RpcFailureKind::AppError; }
int code = 0;
long httpStatus = 0;
};
/**
* The call produced no JSON-RPC answer: curl failed (refused, timed out, aborted), or the daemon's
* HTTP layer turned the request away (401, 503, queue full, unhandled). what() keeps the historic
* text ("RPC request failed: <curl error>" / "RPC error: HTTP <code>[: body]").
*/
class RpcTransportError : public std::runtime_error {
public:
RpcTransportError(RpcFailureKind failureKind, const std::string& message, int curl = CURLE_OK,
long status = 0, bool sent = false)
: std::runtime_error(message), kind(failureKind), curlCode(curl), httpStatus(status), requestSent(sent) {}
RpcFailureKind kind = RpcFailureKind::Other;
int curlCode = CURLE_OK;
long httpStatus = 0;
bool requestSent = false;
};
// The kind of any exception an RPC call threw (Other for anything that isn't an RPC error).
inline RpcFailureKind rpcFailureKindOf(const std::exception& e)
{
if (const auto* t = dynamic_cast<const RpcTransportError*>(&e)) return t->kind;
if (const auto* r = dynamic_cast<const RpcError*>(&e)) return r->kind();
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