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:
@@ -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());
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user