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