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:
2026-08-18 13:18:49 -05:00
parent ea26c0cbbb
commit 6ee81a5abe
40 changed files with 745 additions and 90 deletions

View File

@@ -127,14 +127,24 @@ App::App()
// Seed the auto-balance RNG once per run so weighted-random pool selection isn't
// deterministic across launches.
balance_rng_.seed(std::random_device{}());
// Purge any plaintext key export left behind by a crashed/interrupted decrypt flow. (H-02)
sweepStaleDecryptExports();
}
App::~App()
{
// Scrub any seed/phrase secret still resident (e.g. app quit with a backup/migration modal open).
wipeSecrets();
}
// Scrub every resident secret buffer. Idempotent + safe to call from the forced-exit path (main.cpp
// _Exit bypasses destructors), so key/seed material isn't left in freed heap on the real quit path. (L-05)
void App::wipeSecrets()
{
if (!seed_migration_seed_.empty())
sodium_memzero(&seed_migration_seed_[0], seed_migration_seed_.size());
if (!seed_backup_phrase_.empty())
sodium_memzero(&seed_backup_phrase_[0], seed_backup_phrase_.size());
sodium_memzero(export_result_, sizeof(export_result_)); // exported WIF/z-key (SECRET)
sodium_memzero(import_key_input_, sizeof(import_key_input_)); // pasted private key (SECRET)
}
namespace {
@@ -824,6 +834,10 @@ void App::update()
// One-time reminder to back up the wallet's seed phrase (mnemonic wallets only).
maybeRemindSeedBackup();
// One-time warning if the active wallet loaded empty while a sibling wallet file holds funds
// (a prior/unwitnessed salvage likely moved the coins into a wallet.<ts>.bak).
maybeWarnEmptyWalletWithFundedSiblings();
// Classify the wallet's mnemonic status (once per connect) so the Migrate-to-seed button can
// glow for a legacy, pre-seed-phrase wallet.
probeWalletSeedStatus();
@@ -957,7 +971,7 @@ void App::update()
if (xmrig_poll_tick && xmrig_manager_ && xmrig_manager_->isRunning()) {
xmrig_manager_->pollStats();
auto& ps = state_.pool_mining;
auto& xs = xmrig_manager_->getStats();
const auto xs = xmrig_manager_->getStats(); // getStats() now returns a locked copy (M-03)
ps.xmrig_running = true;
ps.hashrate_10s = xs.hashrate_10s;
ps.hashrate_60s = xs.hashrate_60s;
@@ -1777,6 +1791,12 @@ void App::render()
// Page transition: detect change, ramp alpha
if (current_page_ != prev_page_) {
page_alpha_ = (ui::effects::isLowSpecMode() || (settings_ && settings_->getReduceMotion())) ? 1.0f : 0.0f;
// Switching INTO the console → put the cursor in the command box (toggleable). Done here at the
// transition (not in the console render) because prev_page_ is updated below; the console renders
// later this same frame and consumes the one-shot request.
if ((current_page_ == ui::NavPage::Console || current_page_ == ui::NavPage::LiteConsole)
&& settings_ && settings_->getConsoleAutoFocus())
console_tab_.requestInputFocus();
prev_page_ = current_page_;
}
if (page_alpha_ < 1.0f) {
@@ -2148,6 +2168,7 @@ void App::render()
renderSwitchStopDaemonDialog();
renderBlockDbReindexDialog();
renderWalletRecoveredDialog();
renderEmptyWalletWarningDialog();
// Render notifications (toast messages)
ui::Notifications::instance().render();
@@ -4657,6 +4678,129 @@ void App::renderWalletRecoveredDialog()
ui::material::EndOverlayDialog();
}
// Auto-shown when the active wallet loaded EMPTY but a sibling wallet file in the datadir still holds keys
// (see maybeWarnEmptyWalletWithFundedSiblings). Funds are not lost — they're in another file, most likely a
// wallet.<ts>.bak left by an earlier BDB salvage. This routes the user to the wallet manager to switch, and
// remembers a per-file dismissal so it never nags again for this wallet.
void App::renderEmptyWalletWarningDialog()
{
if (!show_empty_wallet_warning_) return;
const bool salvage = empty_wallet_has_salvage_bak_; // salvage .bak → offer Restore; else → switch wallet
ui::material::OverlayDialogSpec ov;
ov.title = TR(salvage ? "empty_wallet_salvage_title" : "empty_wallet_warning_title");
ov.p_open = &show_empty_wallet_warning_;
ov.style = ui::material::OverlayStyle::BlurFloat;
ov.cardWidth = 560.0f;
ov.idSuffix = "emptywalletwarn";
if (!ui::material::BeginOverlayDialog(ov)) return;
const float dp = ui::Layout::dpiScale();
// Header: wallet icon in a warning tint + a calm "your coins are likely in another file" framing.
{
ImFont* icoF = ui::material::Type().iconLarge();
const float rowTop = ImGui::GetCursorPosY();
ImGui::PushFont(icoF);
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::Warning());
ImGui::TextUnformatted(ICON_MD_ACCOUNT_BALANCE_WALLET);
ImGui::PopStyleColor();
ImGui::PopFont();
ImGui::SameLine();
ImFont* txtF = ui::material::Type().subtitle1();
const float iconH = icoF->LegacySize;
const float textH = txtF ? txtF->LegacySize : ImGui::GetFontSize();
if (iconH > textH) ImGui::SetCursorPosY(rowTop + (iconH - textH) * 0.5f);
ImGui::PushFont(txtF);
ImGui::TextWrapped("%s", TR(salvage ? "empty_wallet_salvage_headline" : "empty_wallet_warning_headline"));
ImGui::PopFont();
}
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
ImGui::PushTextWrapPos(0.0f);
ImGui::TextWrapped("%s", TR(salvage ? "empty_wallet_salvage_body" : "empty_wallet_warning_body"));
ImGui::PopTextWrapPos();
// For the "wrong wallet" case, name the other wallet file(s) that hold keys, with a compact key count —
// concrete evidence the coins are recoverable from them. The count is built with std::to_string so no
// printf format lives in a translatable string (translations are additive and could otherwise drop a %d).
// (The salvage case has no sibling list — restoreOriginalWallet() finds the backup itself.)
if (!empty_wallet_funded_siblings_.empty()) {
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
for (const auto& s : empty_wallet_funded_siblings_) {
ImGui::Bullet();
ImGui::SameLine();
ImGui::TextUnformatted(s.fileName.c_str());
ImGui::PushFont(ui::material::Type().caption());
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
const std::string keys = " " + std::to_string(s.transparentKeys + s.shieldedKeys)
+ " " + TR("empty_wallet_keys_suffix");
ImGui::SameLine();
ImGui::TextUnformatted(keys.c_str());
ImGui::PopStyleColor();
ImGui::PopFont();
}
}
// Primary action: route the user to the wallet manager to switch files (accent-tinted so it dominates).
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
ImFont* rbf = ui::material::Type().button();
auto fitBtnW = [&](const char* label) {
return std::max(120.0f * dp,
rbf->CalcTextSizeA(rbf->LegacySize, FLT_MAX, 0, label).x
+ ImGui::GetStyle().FramePadding.x * 2.0f + 28.0f * dp);
};
const char* primaryLabel = salvage ? TR("empty_wallet_restore") : TR("empty_wallet_open_manager");
ImGui::PushStyleColor(ImGuiCol_Button, ui::material::WithAlpha(ui::material::Primary(), 65));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ui::material::WithAlpha(ui::material::Primary(), 100));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ui::material::WithAlpha(ui::material::Primary(), 125));
if (ui::material::TactileButton(primaryLabel, ImVec2(fitBtnW(primaryLabel), 0))) {
show_empty_wallet_warning_ = false;
if (salvage)
restoreOriginalWallet(); // self-contained: swaps the .bak back + drives the recovery dialog's progress
else
ui::WalletsDialog::show(this);
}
ImGui::PopStyleColor(3);
// Quiet footer: open the data folder, or dismiss permanently for THIS wallet file.
auto linkText = [&](const char* label) -> bool {
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
ImGui::TextUnformatted(label);
ImGui::PopStyleColor();
const bool clicked = ImGui::IsItemClicked();
if (ImGui::IsItemHovered()) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
const ImVec2 mn = ImGui::GetItemRectMin(), mx = ImGui::GetItemRectMax();
ImGui::GetWindowDrawList()->AddLine(ImVec2(mn.x, mx.y), ImVec2(mx.x, mx.y),
ui::material::OnSurface());
}
return clicked;
};
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
if (linkText(TR("wallet_recovered_open_folder")))
util::Platform::openFolder(util::Platform::getDragonXDataDir());
ImGui::SameLine(0, ui::Layout::spacingSm());
ImGui::TextDisabled("\xC2\xB7"); // middle dot separator
ImGui::SameLine(0, ui::Layout::spacingSm());
if (linkText(TR("empty_wallet_warning_dismiss"))) {
if (settings_) {
settings_->ackEmptyWalletWarn(settings_->getActiveWalletFile());
settings_->save();
}
show_empty_wallet_warning_ = false;
}
if (ImGui::IsItemHovered()) {
ImGui::BeginTooltip();
ImGui::PushTextWrapPos(ImGui::GetFontSize() * 22.0f);
ImGui::TextUnformatted(TR("empty_wallet_warning_dismiss_tip"));
ImGui::PopTextWrapPos();
ImGui::EndTooltip();
}
ui::material::EndOverlayDialog();
}
// Auto-shown when the embedded node aborts on an unreadable block database — offers the one-click
// -reindex rebuild instead of leaving the wallet stuck on a silent zero balance.
void App::renderBlockDbReindexDialog()
@@ -5043,6 +5187,12 @@ void App::stopEmbeddedDaemon()
return;
}
// Do we hold a live process handle for this node? Capture it BEFORE stop() reaps the pid. If owned,
// daemon_controller_->stop() below BLOCKS until the process actually exits. If NOT owned (external /
// adopted / direct-connected), stop() returns at once — we can only ask it over RPC and then watch
// for it to disappear (handled after the stop() call).
const bool owned = daemon_controller_->isRunning();
// Send RPC "stop" command — this is the graceful path that lets the
// daemon flush state, save block indexes, close sockets, etc.
bool stop_sent = false;
@@ -5099,6 +5249,27 @@ void App::stopEmbeddedDaemon()
// 20s grace period for the RPC "stop" to complete (LevelDB flush).
// Only after that does stop() escalate to SIGTERM, then SIGKILL.
daemon_controller_->stop(20000);
// EXTERNAL / adopted node during app shutdown: we hold no process handle, so the stop() above returned
// immediately (it can only wait on a node WE spawned). But the user turned on "Stop external daemon",
// so keep the window on the shutdown screen and poll until the node is actually gone — surfacing a live
// status so they can SEE it stop — rather than closing while it's still flushing. Bounded ~120s (a
// graceful full-node shutdown can flush LevelDB for 60-90s). Scoped to real shutdown; the shutdown
// screen's Force Quit stays available and flips shutdown_complete_, which breaks us out at once.
if (stop_sent && !owned && shutting_down_) {
auto stillUp = []() {
return daemon::EmbeddedDaemon::isRpcPortInUse() || daemon::EmbeddedDaemon::isDaemonProcessRunning();
};
// Set the phase text ONCE, then just poll — the shutdown screen already renders a live "N seconds"
// elapsed counter on the UI thread, so the user still sees time passing. (shutdown_status_ is now a
// GuardedStatus, so per-iteration writes would be race-safe; the single write is just a UX choice.)
shutdown_status_ = "Waiting for the external node to stop...";
for (int i = 0; i < 1200 && stillUp() && !shutdown_complete_; ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(100));
shutdown_status_ = stillUp() ? "External node still stopping — closing anyway..."
: "External node stopped";
DEBUG_LOGF("stopEmbeddedDaemon: external node %s\n", stillUp() ? "still up (timed out)" : "confirmed stopped");
}
}
bool App::isEmbeddedDaemonRunning() const
@@ -5497,7 +5668,8 @@ void App::renderShutdownScreen()
// it's never impossible to escape.
static std::string s_lastShutStatus;
static float s_shutStallTimer = 0.0f;
if (shutdown_status_ != s_lastShutStatus) { s_lastShutStatus = shutdown_status_; s_shutStallTimer = 0.0f; }
const std::string curShut = shutdown_status_.get(); // one consistent snapshot per frame (M-05)
if (curShut != s_lastShutStatus) { s_lastShutStatus = curShut; s_shutStallTimer = 0.0f; }
else s_shutStallTimer += ImGui::GetIO().DeltaTime;
const bool shutdownStalled = s_shutStallTimer >= 8.0f;
const bool allowForceQuit = shutdownStalled || shutdown_timer_ >= 20.0f;
@@ -5593,11 +5765,11 @@ void App::renderShutdownScreen()
// -------------------------------------------------------------------
// 3. Phase status (what the shutdown thread is doing)
// -------------------------------------------------------------------
if (!shutdown_status_.empty()) {
ImVec2 ts = ImGui::CalcTextSize(shutdown_status_.c_str());
if (!curShut.empty()) {
ImVec2 ts = ImGui::CalcTextSize(curShut.c_str());
ImGui::SetCursorPosX(cx - ts.x * 0.5f);
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.75f, 0.75f, 0.75f, 1.0f));
ImGui::TextUnformatted(shutdown_status_.c_str());
ImGui::TextUnformatted(curShut.c_str());
ImGui::PopStyleColor();
}
@@ -5631,8 +5803,8 @@ void App::renderShutdownScreen()
ImGui::Spacing();
// State-aware caution: while the status is a daemon flush/exit step, force-quitting risks the
// chainstate; say so instead of a bare button.
if (shutdownStalled && !shutdown_status_.empty()) {
std::string stalledMsg = "Still \"" + shutdown_status_ + "\" — force quitting now may corrupt chain data.";
if (shutdownStalled && !curShut.empty()) {
std::string stalledMsg = "Still \"" + curShut + "\" — force quitting now may corrupt chain data.";
ImVec2 ms = ImGui::CalcTextSize(stalledMsg.c_str());
ImGui::SetCursorPosX(cx - ms.x * 0.5f);
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Warning()));