fix: security-audit remediation (15 findings), empty-wallet warning, and send/chat/console/shutdown UX
Security audit remediation (15 confirmed findings from the codebase audit): - H-02: scrub+delete the decrypt-flow plaintext key export on ALL exit paths (RAII guard) and purge stale obsidiandecryptexport* files at startup. - M-01/L-03/L-04/L-05/L-07: sodium_memzero the Set-PIN and encrypt-PIN worker passphrase/PIN copies, the RPC Basic-auth string (auth_), the exported/imported key buffers (App::wipeSecrets, called from ~App and before main's _Exit), and the first-run wizard "Skip" buffers. - M-03/M-04/M-05/L-06: return locked COPIES from XmrigManager/EmbeddedDaemon getters (dedicated error_mutex_; DaemonController::lastError now by value), route xmrig last_error_ writes through a locked setter, and wrap shutdown_status_/wizard_stop_status_ in a locking GuardedStatus (wizard_stopping_external_ -> std::atomic). - M-02: persist after a console send/shield/import in the lite backend. - L-01: require the confirm click for z_shieldcoinbase/z_mergetoaddress. - L-02: quote/escape each Windows daemon argv per the MSDN CommandLineToArgvW rules. - L-08: pin json/tomlplusplus/libwebp FetchContent to immutable commit SHAs. - I-01: extract updater archives from the already-verified in-memory buffer (no disk re-read TOCTOU). Feature: warn once (full-node) when the active wallet loads empty while a sibling wallet file in the datadir holds keys. A funded salvage wallet.<ts>.bak routes to the recovery/Restore flow; a funded sibling .dat routes to the wallet manager. Per-wallet-file dismissal; gated on synced + address-list-loaded to avoid false positives on warm reconnect / spent-down wallets. UX fixes: - send: show the TOTAL balance (with a spendable "available" note) in the source dropdown and keep pending-change addresses visible. - chat: insert emoji at the cursor position; restrict new-chat recipients to shielded (z) addresses. - console: optional auto-focus of the command input on tab open (off by default). - shutdown: when "stop external daemon" is on, keep the shutdown screen up until the external node actually exits, showing live status. Adversarially reviewed; verified across full-node, lite, and Windows builds; tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -620,6 +620,13 @@ void App::onConnected()
|
||||
|
||||
detectWalletAutoRecovery(); // also runs every tryConnect tick — catches a salvage even if we never connect
|
||||
|
||||
// Re-arm the empty-wallet-with-funded-sibling check for this (possibly switched) wallet: re-evaluate the
|
||||
// on-disk state once it finishes loading + syncing. Deliberately DON'T touch empty_wallet_scan_in_flight_
|
||||
// here — a scan from a prior connect self-clears it when it posts back, and resetting it while that scan
|
||||
// is still running would let the next submit() block the UI thread on join() (async_tasks_ is only ever
|
||||
// cancelled at shutdown, so the flag can't wedge during normal runtime).
|
||||
empty_wallet_warn_checked_ = false;
|
||||
|
||||
// Stamp the active wallet as opened in the index (last-opened + size + synced-here). Balance +
|
||||
// address count fill in on the first address refresh (addresses aren't loaded yet here).
|
||||
updateWalletIndexForActiveWallet(/*markOpened=*/true);
|
||||
@@ -1761,7 +1768,12 @@ void App::refreshAddressData()
|
||||
auto result = NetworkRefreshService::collectAddressRefreshResult(refreshRpc, addressSnapshot);
|
||||
|
||||
return [this, previousAddressCount, previousWalletIdentity, result = std::move(result)]() mutable {
|
||||
const bool addrListOk = result.addressListOk; // capture before the move
|
||||
NetworkRefreshService::applyAddressRefreshResult(state_, std::move(result));
|
||||
// Mark the address list as loaded ONLY if enumeration actually succeeded — a swallowed
|
||||
// z_listaddresses/getaddressesbyaccount failure returns a falsely-short list, and stamping it
|
||||
// would let the empty-wallet warning trust a spurious 0 count (see maybeWarnEmptyWallet…).
|
||||
if (addrListOk) state_.last_address_update = std::time(nullptr);
|
||||
applyPendingSendBalanceDeltas(false);
|
||||
address_validation_cache_dirty_ = false;
|
||||
address_list_dirty_ = true;
|
||||
@@ -4190,6 +4202,111 @@ void App::maybeRemindSeedBackup()
|
||||
});
|
||||
}
|
||||
|
||||
// Complementary on-disk safety net (full-node) for a wallet salvage we did NOT witness this launch — it
|
||||
// happened on a prior run, or under an external daemon whose startup output we never captured, so
|
||||
// detectWalletAutoRecovery() never fired. If the active wallet loads EMPTY while a sibling wallet file in
|
||||
// the datadir still holds keys, warn once: the user's funds were likely moved into a wallet.<ts>.bak by a
|
||||
// prior BDB salvage and are not lost, just in another file. Fires at most once per wallet-open and once per
|
||||
// unacknowledged wallet filename; the probe runs off the UI thread (scanFundedSiblingsAsync).
|
||||
void App::maybeWarnEmptyWalletWithFundedSiblings()
|
||||
{
|
||||
if (capture_mode_) return; // no live ops during a UI sweep
|
||||
if (lite_wallet_ || !supportsFullNodeLifecycleActions()) return; // full-node only (lite = single-file dir)
|
||||
if (!settings_) return;
|
||||
if (empty_wallet_warn_checked_ || empty_wallet_scan_in_flight_) return; // at most once per wallet-open
|
||||
if (show_empty_wallet_warning_) return; // already surfaced
|
||||
// The console-driven recovery flow owns the salvage-this-launch case — don't double-warn.
|
||||
if (wallet_auto_recovered_ || show_wallet_recovered_dialog_) return;
|
||||
// Only meaningful once the wallet is truly loaded AND fully synced: a mid-sync wallet reads empty.
|
||||
if (!state_.connected || !state_.encryption_state_known) return;
|
||||
if (state_.warming_up || state_.daemon_initializing || !state_.sync.isSynced()) return;
|
||||
// Wait for the first Core refresh to land. The ConnectionInit prefetch sets sync.blocks but NOT headers,
|
||||
// so isSynced() (blocks >= headers-2) is spuriously true in the window before the Core refresh — during
|
||||
// which balance/addresses also read 0. last_balance_update flips non-zero only when the Core refresh
|
||||
// applies (network_refresh_service.cpp), by which point balance & headers are real.
|
||||
if (state_.last_balance_update == 0) return;
|
||||
// And wait for the ADDRESS list to have loaded at least once — otherwise getAddressCount()==0 is
|
||||
// ambiguous ("no keys" vs "not fetched yet"), which would false-fire on a spent-down wallet (0 balance
|
||||
// but has addresses) whose address refresh lands a beat after the balance refresh.
|
||||
if (state_.last_address_update == 0) return;
|
||||
// "Empty" = no addresses and no funds. A salvage-created fresh wallet has no keys; a legitimately
|
||||
// spent-down wallet keeps its addresses, so requiring zero addresses avoids nagging the latter.
|
||||
if (state_.getAddressCount() != 0) return;
|
||||
if (state_.totalBalance > 0.0 || state_.spendableTotalBalance > 0.0) return;
|
||||
if (settings_->isEmptyWalletWarnAcked(settings_->getActiveWalletFile())) return; // dismissed for this file
|
||||
|
||||
empty_wallet_warn_checked_ = true; // evaluate the on-disk state once for this wallet-open
|
||||
scanFundedSiblingsAsync();
|
||||
}
|
||||
|
||||
// Off-UI-thread: enumerate the datadir's OTHER wallet files (incl. salvage wallet.<ts>.bak backups) and
|
||||
// offline-probe each for key material. Read-only; never opens a file in the daemon. The probe reads up to a
|
||||
// bounded prefix per file, so it runs on its own task thread (not the RPC worker) and the result is
|
||||
// marshaled back to the main thread before touching any UI state. Routing, applied on the main thread:
|
||||
// • a funded salvage .bak exists → the coins were moved aside by an unwitnessed salvage; hand off to the
|
||||
// existing recovery dialog, whose Restore action swaps the .bak back (the correct, tested fix).
|
||||
// • otherwise a funded sibling .dat exists → the user simply opened the wrong (empty) wallet; show the
|
||||
// lightweight warning modal that routes to the wallet manager to switch.
|
||||
void App::scanFundedSiblingsAsync()
|
||||
{
|
||||
if (empty_wallet_scan_in_flight_) return;
|
||||
empty_wallet_scan_in_flight_ = true;
|
||||
const std::string datadir = util::Platform::getDragonXDataDir();
|
||||
const std::string activeFile = settings_ ? settings_->getActiveWalletFile() : std::string("wallet.dat");
|
||||
async_tasks_.submit("Empty-wallet sibling scan",
|
||||
[this, datadir, activeFile](const util::AsyncTaskManager::Token& tok) {
|
||||
std::vector<FundedSibling> funded; // funded plain-.dat wallets → the "switch wallet" modal
|
||||
bool hasSalvageBak = false; // a funded wallet.<ts>.bak → route to the recovery/restore dialog
|
||||
for (const auto& path : util::enumerateDatadirWalletFiles(datadir, activeFile, /*includeSalvageBaks=*/true)) {
|
||||
if (tok.cancelled()) return;
|
||||
const auto bt = util::parseWalletBtree(path);
|
||||
if (!(bt.parsed && bt.addresses() > 0)) continue; // ignore junk / empty siblings
|
||||
const std::string name = std::filesystem::path(path).filename().string();
|
||||
const bool isBak = name.size() > 4 && name.compare(name.size() - 4, 4, ".bak") == 0;
|
||||
if (isBak) {
|
||||
// Only a salvage-pattern wallet.<ts>.bak has a defined restore path; other .bak files are
|
||||
// ignored (the wallet manager lists only .dat, so routing them there would be a dead end).
|
||||
if (daemon::parseWalletSalvageBakTs(name) >= 0) hasSalvageBak = true;
|
||||
continue;
|
||||
}
|
||||
FundedSibling s;
|
||||
s.fileName = name;
|
||||
s.transparentKeys = bt.transparentKeys;
|
||||
s.shieldedKeys = bt.shieldedKeys;
|
||||
funded.push_back(std::move(s));
|
||||
}
|
||||
// On teardown/cancel, skip posting (shutdown only; the in-flight flag is irrelevant then).
|
||||
if (tok.cancelled() || !worker_) return;
|
||||
// Apply UI state on the main thread only (the render loop reads these members).
|
||||
worker_->post([this, funded, hasSalvageBak]() -> rpc::RPCWorker::MainCb {
|
||||
return [this, funded, hasSalvageBak]() {
|
||||
empty_wallet_scan_in_flight_ = false;
|
||||
if (wallet_auto_recovered_ || show_wallet_recovered_dialog_) return; // recovery already owns it
|
||||
// Re-validate emptiness on the main thread: balance/address refreshes may have landed while
|
||||
// the scan ran (it takes long enough to read+parse sibling files), so a warm-reconnect or a
|
||||
// spent-down wallet that momentarily read empty is now correctly excluded.
|
||||
if (!state_.connected || state_.getAddressCount() != 0 ||
|
||||
state_.totalBalance > 0.0 || state_.spendableTotalBalance > 0.0) return;
|
||||
// Both cases surface OUR modal (renderEmptyWalletWarningDialog), keyed by has_salvage_bak.
|
||||
// We deliberately DON'T set the wallet_auto_recovered_ latch or auto-open the recovery dialog:
|
||||
// the daemon is healthy, and that latch gates the crash-restart loop (app_network.cpp:556) +
|
||||
// crash-toast suppression, so it would wedge the wallet offline on any later unrelated crash.
|
||||
// For the salvage case the modal's "Restore" button calls restoreOriginalWallet() directly
|
||||
// (self-contained: it drives the recovery dialog into its Working phase itself).
|
||||
if (hasSalvageBak) {
|
||||
empty_wallet_has_salvage_bak_ = true;
|
||||
empty_wallet_funded_siblings_.clear();
|
||||
show_empty_wallet_warning_ = true;
|
||||
} else if (!funded.empty()) {
|
||||
empty_wallet_has_salvage_bak_ = false;
|
||||
empty_wallet_funded_siblings_ = funded;
|
||||
show_empty_wallet_warning_ = true;
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// One-shot (per connect) probe of the current wallet's mnemonic status, so the Settings
|
||||
// Migrate-to-seed button can glow for a legacy wallet without opening the migration dialog. Same
|
||||
// classification as the migration Intro pre-flight, but proactive and cached. Reads no secret past
|
||||
|
||||
Reference in New Issue
Block a user