fix(audit): batch 1 — latent unlock/path/rescan correctness bugs

Four correctness fixes surfaced by the codebase audit:

1. Lite address-book path bug: AddressBook::getDefaultPath() hardcoded
   "ObsidianDragon" while Settings::getDefaultPath() uses DRAGONX_APP_NAME,
   so the Lite build wrote addressbook.json into the full-node app's config
   dir instead of ObsidianDragonLite/. Now uses DRAGONX_APP_NAME on all
   platforms (no macOS path change — the getConfigDir consolidation, which
   would move the macOS dir, is deferred).

2. PIN-unlock lockout bypass: the PIN path duplicated unlockWallet's
   success/lockout logic and its RPC-error branch bumped the attempt counter
   but skipped the escalating-lockout math entirely — a PIN user hitting an
   RPC error escaped the lockout curve. Extracted App::applyUnlockSuccess()
   and App::applyUnlockFailure() and routed all unlock paths (passphrase,
   PIN vault-fail, PIN RPC-fail) through them, so the lockout curve now
   applies uniformly. (noVault stays a mode-switch, not a failed attempt.)

3. Witness/rescan reset drift: the ~11-line rescan+witness completion reset
   was copy-pasted at four sites (app.cpp x2, app_network.cpp x2); adding a
   witness field and missing a copy would leave stale progress. Folded into
   App::resetWitnessRescanProgress(). Left the distinct phase-transition
   reset in App::update() untouched (it sets witness_phase, not 0).

4. Truncation underflow: send_tab/receive_tab's size_t TruncateAddress
   copies computed (maxLen - 3) without guarding maxLen <= 3, which wraps
   and throws std::out_of_range on short inputs. Added the guard.

Full-node + Lite build clean; ctest 1/1; source-hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-02 15:54:36 -05:00
parent 530bf24abf
commit f8150434d4
7 changed files with 89 additions and 94 deletions

View File

@@ -396,6 +396,38 @@ void App::processDeferredEncryption() {
}
}
// Shared main-thread success continuation for both the passphrase and PIN unlock paths.
// walletpassphrase has already succeeded on the worker thread, so set the unlocked state
// directly rather than issuing another RPC round-trip.
void App::applyUnlockSuccess(const std::string& passphrase, int timeout) {
lock_unlock_in_progress_ = false;
lock_error_msg_.clear();
lock_attempts_ = 0;
memset(lock_passphrase_buf_, 0, sizeof(lock_passphrase_buf_));
last_interaction_ = std::chrono::steady_clock::now();
state_.encrypted = true;
state_.locked = false;
state_.unlocked_until = std::time(nullptr) + timeout;
unlockTransactionHistoryCacheWithPassphrase(passphrase);
}
// Shared main-thread failure continuation: bump the attempt counter, surface the error,
// and apply the escalating lockout curve. Every failed unlock path routes through here so
// none can silently skip the lockout math (the PIN RPC-error path previously did).
void App::applyUnlockFailure(const std::string& errorMessage) {
lock_unlock_in_progress_ = false;
lock_attempts_++;
lock_error_msg_ = errorMessage;
lock_error_timer_ = 3.0f;
memset(lock_passphrase_buf_, 0, sizeof(lock_passphrase_buf_));
const float baseDelay = ui::schema::UI().drawElement("security", "lockout-base-delay").sizeOr(2.0f);
const int maxAttempts = (int)ui::schema::UI().drawElement("security", "max-attempts-before-lockout").sizeOr(5.0f);
if (lock_attempts_ >= maxAttempts) {
lock_lockout_timer_ = baseDelay * (float)(1 << std::min(lock_attempts_ - maxAttempts, 8));
}
DEBUG_LOGF("[App] Wallet unlock failed (attempt %d)\n", lock_attempts_);
}
void App::unlockWallet(const std::string& passphrase, int timeout) {
if (!rpc_ || !rpc_->isConnected() || !worker_) return;
lock_unlock_in_progress_ = true;
@@ -411,30 +443,11 @@ void App::unlockWallet(const std::string& passphrase, int timeout) {
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
return [this, ok, err_msg, timeout, passphrase = std::move(cachePassphrase)]() mutable {
lock_unlock_in_progress_ = false;
if (ok) {
lock_error_msg_.clear();
lock_attempts_ = 0;
memset(lock_passphrase_buf_, 0, sizeof(lock_passphrase_buf_));
last_interaction_ = std::chrono::steady_clock::now();
// Set unlock state immediately — walletpassphrase
// already succeeded, no need for another RPC round-trip.
state_.encrypted = true;
state_.locked = false;
state_.unlocked_until = std::time(nullptr) + timeout;
unlockTransactionHistoryCacheWithPassphrase(passphrase);
applyUnlockSuccess(passphrase, timeout);
} else {
lock_attempts_++;
lock_error_msg_ = TR("incorrect_passphrase");
lock_error_timer_ = 3.0f;
memset(lock_passphrase_buf_, 0, sizeof(lock_passphrase_buf_));
float baseDelay = ui::schema::UI().drawElement("security", "lockout-base-delay").sizeOr(2.0f);
int maxAttempts = (int)ui::schema::UI().drawElement("security", "max-attempts-before-lockout").sizeOr(5.0f);
if (lock_attempts_ >= maxAttempts) {
lock_lockout_timer_ = baseDelay * (float)(1 << std::min(lock_attempts_ - maxAttempts, 8));
}
DEBUG_LOGF("[App] Wallet unlock failed (attempt %d): %s\n", lock_attempts_, err_msg.c_str());
DEBUG_LOGF("[App] Passphrase unlock RPC error: %s\n", err_msg.c_str());
applyUnlockFailure(TR("incorrect_passphrase"));
}
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
};
@@ -980,21 +993,15 @@ void App::renderLockScreen() {
if (!vaultOk) {
bool noVault = !vault_ || !vault_->hasVault();
return [this, noVault]() {
lock_unlock_in_progress_ = false;
if (noVault) {
// Vault file missing — switch to passphrase mode
// Vault file missing — switch to passphrase mode. Not a failed
// attempt, so no counter bump / lockout escalation.
lock_unlock_in_progress_ = false;
lock_error_msg_ = TR("pin_not_set");
lock_use_pin_ = false;
lock_error_timer_ = 3.0f;
} else {
lock_attempts_++;
lock_error_msg_ = TR("incorrect_pin");
}
lock_error_timer_ = 3.0f;
float baseDelay = ui::schema::UI().drawElement("security", "lockout-base-delay").sizeOr(2.0f);
int maxAttempts = (int)ui::schema::UI().drawElement("security", "max-attempts-before-lockout").sizeOr(5.0f);
if (lock_attempts_ >= maxAttempts) {
lock_lockout_timer_ = baseDelay * (float)(1 << std::min(lock_attempts_ - maxAttempts, 8));
applyUnlockFailure(TR("incorrect_pin"));
}
};
}
@@ -1015,25 +1022,15 @@ void App::renderLockScreen() {
if (rpcOk) {
return [this, timeout, passphrase = std::move(passphrase)]() mutable {
lock_unlock_in_progress_ = false;
lock_error_msg_.clear();
lock_attempts_ = 0;
memset(lock_passphrase_buf_, 0, sizeof(lock_passphrase_buf_));
last_interaction_ = std::chrono::steady_clock::now();
// Set unlock state immediately — walletpassphrase
// already succeeded, no need for another RPC round-trip.
state_.encrypted = true;
state_.locked = false;
state_.unlocked_until = std::time(nullptr) + timeout;
unlockTransactionHistoryCacheWithPassphrase(passphrase);
applyUnlockSuccess(passphrase, timeout);
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
};
} 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).
return [this, rpcErr, passphrase = std::move(passphrase)]() mutable {
lock_unlock_in_progress_ = false;
lock_attempts_++;
lock_error_msg_ = "Unlock failed: " + rpcErr;
lock_error_timer_ = 3.0f;
applyUnlockFailure("Unlock failed: " + rpcErr);
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
};
}