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

@@ -277,6 +277,7 @@ void DaemonUpdater::installResolved(const std::string& targetDir, const DaemonRe
// checksum and (b) verify a detached ed25519 signature over the archive bytes against the
// pinned key, so a checksum rewritten in a tampered release body is not sufficient to install.
setProgress(State::Verifying, "Verifying download…");
std::string bytes; // kept in scope through extraction so we extract the VERIFIED buffer (I-01)
{
std::ifstream f(zipPath, std::ios::binary);
if (!f) {
@@ -284,7 +285,7 @@ void DaemonUpdater::installResolved(const std::string& targetDir, const DaemonRe
setProgress(State::Failed, "Could not read the downloaded archive.");
return;
}
const std::string bytes((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
bytes.assign(std::istreambuf_iterator<char>(f), std::istreambuf_iterator<char>());
if (f.bad()) {
fs::remove(zipPath, ec);
setProgress(State::Failed, "Could not read the downloaded archive.");
@@ -342,7 +343,9 @@ void DaemonUpdater::installResolved(const std::string& targetDir, const DaemonRe
const std::string daemonName = wanted.front(); // "dragonxd" / "dragonxd.exe"
mz_zip_archive zip{};
if (!mz_zip_reader_init_file(&zip, zipPath.c_str(), 0)) {
// Extract from the ALREADY-VERIFIED in-memory buffer, not by reopening zipPath — otherwise a fast
// local attacker could swap the file on disk between the hash/signature check and extraction. (I-01)
if (!mz_zip_reader_init_mem(&zip, bytes.data(), bytes.size(), 0)) {
fs::remove(zipPath, ec);
setProgress(State::Failed, "Could not open the downloaded archive.");
return;

View File

@@ -223,6 +223,7 @@ void I18n::loadBuiltinEnglish()
strings_["chat_send"] = "Send";
strings_["chat_new_title"] = "New chat";
strings_["chat_new_zaddr"] = "Recipient z-address";
strings_["chat_new_needs_zaddr"] = "Chat needs a shielded (z) address — transparent (t) addresses can't receive encrypted messages.";
strings_["chat_new_message"] = "Message";
strings_["chat_new_send"] = "Send request";
strings_["chat_cancel"] = "Cancel";
@@ -1194,6 +1195,19 @@ void I18n::loadBuiltinEnglish()
strings_["block_db_reindex_started"] = "Rebuilding the block database from your blocks — this can take a while.";
// Wallet auto-recovery warning (the node moved wallet.dat aside and loaded a salvaged copy).
// Empty-active-wallet-with-funded-sibling warning (App::renderEmptyWalletWarningDialog).
strings_["empty_wallet_warning_title"] = "This wallet is empty";
strings_["empty_wallet_warning_headline"] = "You may have opened the wrong wallet.";
strings_["empty_wallet_warning_body"] = "This wallet has no addresses and no funds, but another wallet file in your DragonX folder holds keys. Your coins are most likely in it, not lost. Open the wallet manager to switch to the wallet that holds your funds.";
strings_["empty_wallet_keys_suffix"] = "keys";
strings_["empty_wallet_open_manager"] = "Open wallet manager";
strings_["empty_wallet_warning_dismiss"] = "Don't warn again for this wallet";
strings_["empty_wallet_warning_dismiss_tip"] = "Stops this warning for the current wallet file only. If you switch to a different empty wallet later, it can warn again.";
// Salvage-backup variant of the same modal (a funded wallet.<ts>.bak from an earlier auto-repair).
strings_["empty_wallet_salvage_title"] = "Your wallet may have been repaired";
strings_["empty_wallet_salvage_headline"] = "Your coins are safe in a backup file.";
strings_["empty_wallet_salvage_body"] = "This wallet is empty because an earlier automatic repair set your original wallet aside as a backup. Your coins are almost certainly in that backup, not lost. Restore it to load your funds again — nothing is deleted; the current file is kept aside first.";
strings_["empty_wallet_restore"] = "Restore my wallet";
strings_["wallet_recovered_title"] = "Your wallet file needs a quick repair";
strings_["wallet_recovered_safety"] = "Your coins are safe.";
strings_["wallet_recovered_warn"] = "When the app started, it found that your wallet file didn't pass its consistency check — this usually happens after an app update or an unclean shutdown. The app already protected your data: it set the old file aside and loaded a repaired copy so you're not stuck.";
@@ -1616,6 +1630,8 @@ void I18n::loadBuiltinEnglish()
strings_["console_zoom_out"] = "Zoom out";
strings_["console_toggle_accents"] = "Toggle line color accents";
strings_["console_toggle_text_color"] = "Toggle line text colors";
strings_["console_auto_focus"] = "Focus input on open";
strings_["console_toggle_auto_focus"] = "Place the cursor in the command box when you open the Console tab";
strings_["console_accents"] = "Color accents";
strings_["console_text_colors"] = "Text colors";
strings_["console_cat_control"] = "Control";
@@ -2123,6 +2139,7 @@ void I18n::loadBuiltinEnglish()
strings_["send_recipient"] = "RECIPIENT";
strings_["send_select_source"] = "Select a source address...";
strings_["send_sending_from"] = "SENDING FROM";
strings_["send_available_note"] = "available";
strings_["send_submitting"] = "Submitting transaction...";
strings_["send_switch_to_receive"] = "Switch to Receive to get your address and start receiving funds.";
strings_["send_tooltip_enter_amount"] = "Enter an amount to send";

View File

@@ -18,14 +18,57 @@
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <string>
#include <system_error>
#include <utility>
#include <vector>
namespace dragonx {
namespace util {
// A reserved bare-filename PREFIX for the datadir "in-place link" wallets: an out-of-datadir wallet the
// user opens gets a stable symlink/hardlink under this name in the datadir so the daemon can load it by
// bare -wallet=<name>. These are plumbing, not standalone wallet files, so wallet enumeration hides them.
// Single source of truth shared with the wallets UI (ui/windows/wallets_dialog.h).
constexpr const char* kInPlaceLinkPrefix = "wallet-ip-";
inline bool isInPlaceLinkName(const std::string& name) { return name.rfind(kInPlaceLinkPrefix, 0) == 0; }
// Enumerate the standalone wallet-bearing files in a DragonX datadir (TOP-LEVEL only): bare "wallet*.dat"
// files, excluding in-place links and (optionally) one active filename. The "wallet" prefix already excludes
// node artifacts (peers.dat / blk*.dat / asmap.dat / …). With includeSalvageBaks, also returns "wallet*.bak"
// files — the daemon's salvage backups (wallet.<ts>.bak) that hold the pre-salvage keys. Returns full paths.
// Exception-safe (error_code iteration); never descends into subdirectories. This is the lightweight
// datadir-only counterpart to the wallets dialog's richer scan (which also walks user-added external folders
// and de-dups by canonical path); the default (.dat only, no baks) matches that dialog's semantics.
inline std::vector<std::string> enumerateDatadirWalletFiles(const std::string& datadir,
const std::string& excludeActiveName = "",
bool includeSalvageBaks = false) {
namespace fs = std::filesystem;
std::vector<std::string> out;
std::error_code ec;
fs::directory_iterator it(datadir, ec), end;
if (ec) return out;
for (; it != end; it.increment(ec)) {
if (ec) break;
const fs::path p = it->path();
const std::string name = p.filename().string();
if (name.size() <= 4) continue;
const std::string ext = name.substr(name.size() - 4);
const bool isDat = (ext == ".dat");
const bool isBak = includeSalvageBaks && (ext == ".bak");
if (!isDat && !isBak) continue; // *.dat (+ *.bak) only
if (name.rfind("wallet", 0) != 0) continue; // wallet-prefixed only
if (isInPlaceLinkName(name)) continue; // hide in-place links
if (!excludeActiveName.empty() && name == excludeActiveName) continue; // skip the active wallet
std::error_code fec;
if (!fs::is_regular_file(p, fec)) continue;
out.push_back(p.string());
}
return out;
}
struct WalletFileProbe {
bool isBerkeleyDB = false; ///< file has a valid BDB btree metapage magic (looks like a real wallet.dat)
bool encrypted = false; ///< has an "mkey" master-key record → passphrase-encrypted

View File

@@ -265,6 +265,7 @@ void XmrigUpdater::installResolved(const std::string& targetDir, const XmrigRele
// the archive bytes against that key, so a checksum rewritten in a tampered release body is
// not sufficient to install.
setProgress(State::Verifying, "Verifying download…");
std::string bytes; // kept in scope through extraction so we extract the VERIFIED buffer (I-01)
{
std::ifstream f(zipPath, std::ios::binary);
if (!f) {
@@ -272,7 +273,7 @@ void XmrigUpdater::installResolved(const std::string& targetDir, const XmrigRele
setProgress(State::Failed, "Could not read the downloaded archive.");
return;
}
const std::string bytes((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
bytes.assign(std::istreambuf_iterator<char>(f), std::istreambuf_iterator<char>());
if (f.bad()) {
fs::remove(zipPath, ec);
setProgress(State::Failed, "Could not read the downloaded archive.");
@@ -333,7 +334,9 @@ void XmrigUpdater::installResolved(const std::string& targetDir, const XmrigRele
const std::string minerName = wanted.front(); // "xmrig" / "xmrig.exe"
mz_zip_archive zip{};
if (!mz_zip_reader_init_file(&zip, zipPath.c_str(), 0)) {
// Extract from the ALREADY-VERIFIED in-memory buffer, not by reopening zipPath — otherwise a fast
// local attacker could swap the file on disk between the hash/signature check and extraction. (I-01)
if (!mz_zip_reader_init_mem(&zip, bytes.data(), bytes.size(), 0)) {
fs::remove(zipPath, ec);
setProgress(State::Failed, "Could not open the downloaded archive.");
return;