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
co-authored by Claude Opus 4.8
parent ea26c0cbbb
commit 6ee81a5abe
40 changed files with 745 additions and 90 deletions
+4 -2
View File
@@ -71,9 +71,11 @@ DaemonController::State DaemonController::state() const
return daemon_->getState();
}
const std::string& DaemonController::lastError() const
std::string DaemonController::lastError() const
{
return daemon_->getLastError();
// By value — getLastError() now returns a mutex-locked COPY, so forwarding it by reference would
// dangle (bind a reference to that temporary). (M-04 follow-through)
return daemon_ ? daemon_->getLastError() : std::string();
}
int DaemonController::crashCount() const
+1 -1
View File
@@ -95,7 +95,7 @@ public:
bool externalDaemonDetected() const;
void clearExternalDaemonDetected();
State state() const;
const std::string& lastError() const;
std::string lastError() const; // by value: EmbeddedDaemon::getLastError() returns a locked copy (M-04)
int crashCount() const;
int lastBlockHeight() const;
double memoryUsageMB() const;
+22 -7
View File
@@ -224,12 +224,11 @@ std::vector<std::string> EmbeddedDaemon::getChainParams()
void EmbeddedDaemon::setState(State s, const std::string& message)
{
state_ = s;
if (!message.empty()) {
if (s == State::Error) {
last_error_ = message;
}
if (!message.empty() && s == State::Error) {
std::lock_guard<std::mutex> lk(error_mutex_); // dedicated mutex — never taken with output_mutex_ held
last_error_ = message;
}
if (state_callback_) {
state_callback_(s, message);
}
@@ -621,12 +620,28 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
// Forward declaration — defined after startProcess
static DWORD findProcessByName(const char* name);
// Quote a single argument per the CommandLineToArgvW rules (MSDN) so a value containing a space or a
// quote is delivered as ONE argv token to the daemon instead of splitting/corrupting argv (L-02).
static std::string quoteWinArg(const std::string& arg) {
if (!arg.empty() && arg.find_first_of(" \t\n\v\"") == std::string::npos) return arg;
std::string out = "\"";
for (size_t i = 0; ; ++i) {
size_t nbs = 0;
while (i < arg.size() && arg[i] == '\\') { ++nbs; ++i; }
if (i == arg.size()) { out.append(nbs * 2, '\\'); break; }
if (arg[i] == '"') { out.append(nbs * 2 + 1, '\\'); out.push_back('"'); }
else { out.append(nbs, '\\'); out.push_back(arg[i]); }
}
out.push_back('"');
return out;
}
bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vector<std::string>& args)
{
// Build command line
// Build command line (binary path always quoted; each arg quoted/escaped per Windows rules — L-02)
std::string cmd = "\"" + binary_path + "\"";
for (const auto& arg : args) {
cmd += " " + arg;
cmd += " " + quoteWinArg(arg);
}
DEBUG_LOGF("[INFO] Starting daemon: %s\n", cmd.c_str());
+4 -1
View File
@@ -79,7 +79,9 @@ public:
/**
* @brief Get last error message
*/
const std::string& getLastError() const { return last_error_; }
// Copy under lock: last_error_ is written from the monitor thread (setState on an unexpected exit)
// while the UI thread reads it — a reference would be a torn-read / use-after-free race (M-04).
std::string getLastError() const { std::lock_guard<std::mutex> lk(error_mutex_); return last_error_; }
/**
* @brief Get dragonxd process output (thread-safe copy)
@@ -286,6 +288,7 @@ private:
std::atomic<State> state_{State::Stopped};
std::atomic<bool> external_daemon_detected_{false};
std::string last_error_;
mutable std::mutex error_mutex_; // protects last_error_ (written by main + monitor threads)
mutable std::mutex output_mutex_; // protects process_output_
std::string process_output_;
StateCallback state_callback_;
+15 -12
View File
@@ -210,7 +210,7 @@ bool XmrigManager::generateConfig(const Config& cfg, const std::string& outPath)
fs::create_directories(fs::path(outPath).parent_path());
std::ofstream ofs(outPath, std::ios::trunc);
if (!ofs.is_open()) {
last_error_ = "Cannot write xmrig config: " + outPath;
setLastError("Cannot write xmrig config: " + outPath);
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
return false;
}
@@ -224,7 +224,7 @@ bool XmrigManager::generateConfig(const Config& cfg, const std::string& outPath)
ofs.close();
return true;
} catch (const std::exception& e) {
last_error_ = std::string("Config write error: ") + e.what();
setLastError(std::string("Config write error: ") + e.what());
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
return false;
}
@@ -236,19 +236,22 @@ bool XmrigManager::generateConfig(const Config& cfg, const std::string& outPath)
bool XmrigManager::start(const Config& cfg) {
if (state_ == State::Running || state_ == State::Starting) {
last_error_ = "Already running";
setLastError("Already running");
DEBUG_LOGF("[WARN] XmrigManager: %s\n", last_error_.c_str());
return false;
}
state_ = State::Starting;
should_stop_ = false;
last_error_.clear();
setLastError(std::string());
{
std::lock_guard<std::mutex> lk(output_mutex_);
process_output_.clear();
}
stats_ = PoolStats{};
{
std::lock_guard<std::mutex> lk(stats_mutex_);
stats_ = PoolStats{};
}
// Extract pool hostname for stats API queries
{
@@ -265,7 +268,7 @@ bool XmrigManager::start(const Config& cfg) {
// Find binary
std::string binary = findXmrigBinary();
if (binary.empty()) {
last_error_ = "xmrig binary not found";
setLastError("xmrig binary not found");
state_ = State::Error;
DEBUG_LOGF("[ERROR] XmrigManager: xmrig binary not found\n");
return false;
@@ -368,7 +371,7 @@ bool XmrigManager::startProcess(const std::string& xmrigPath, const std::string&
HANDLE hRead = nullptr, hWrite = nullptr;
if (!CreatePipe(&hRead, &hWrite, &sa, 0)) {
last_error_ = "CreatePipe failed";
setLastError("CreatePipe failed");
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
return false;
}
@@ -400,7 +403,7 @@ bool XmrigManager::startProcess(const std::string& xmrigPath, const std::string&
char errBuf[256];
FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
NULL, err, 0, errBuf, sizeof(errBuf), NULL);
last_error_ = "CreateProcess failed for xmrig (error " + std::to_string(err) + "): " + errBuf;
setLastError("CreateProcess failed for xmrig (error " + std::to_string(err) + "): " + errBuf);
DEBUG_LOGF("[ERROR] XmrigManager: %s\nCommand: %s\n", last_error_.c_str(), cmdLine.c_str());
return false;
}
@@ -451,14 +454,14 @@ void XmrigManager::drainOutput() {
bool XmrigManager::startProcess(const std::string& xmrigPath, const std::string& cfgPath, int threads) {
int pipefd[2];
if (pipe(pipefd) != 0) {
last_error_ = "pipe() failed";
setLastError("pipe() failed");
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
return false;
}
pid_t pid = fork();
if (pid < 0) {
last_error_ = "fork() failed";
setLastError("fork() failed");
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
close(pipefd[0]);
close(pipefd[1]);
@@ -629,7 +632,7 @@ void XmrigManager::monitorProcess() {
if (GetExitCodeProcess(process_handle_, &exitCode) && exitCode != STILL_ACTIVE) {
DEBUG_LOGF("[ERROR] XmrigManager: process exited (code %lu)\n", exitCode);
state_ = State::Error;
last_error_ = "xmrig process exited unexpectedly";
setLastError("xmrig process exited unexpectedly");
break;
}
}
@@ -640,7 +643,7 @@ void XmrigManager::monitorProcess() {
if (ret == process_pid_ || ret < 0) {
DEBUG_LOGF("[ERROR] XmrigManager: process exited (waitpid=%d)\n", ret);
state_ = State::Error;
last_error_ = "xmrig process exited unexpectedly";
setLastError("xmrig process exited unexpectedly");
break;
}
}
+7 -2
View File
@@ -86,8 +86,10 @@ public:
bool isRunning() const;
State getState() const { return state_.load(std::memory_order_relaxed); }
const PoolStats& getStats() const { return stats_; }
const std::string& getLastError() const { return last_error_; }
// Return COPIES under lock: stats_ and last_error_ are mutated by the monitor thread while the UI
// thread reads them, so handing out a reference is a torn-read / use-after-free race (M-03, M-04).
PoolStats getStats() const { std::lock_guard<std::mutex> lk(stats_mutex_); return stats_; }
std::string getLastError() const { std::lock_guard<std::mutex> lk(error_mutex_); return last_error_; }
/// Thread count requested at start() — available immediately, unlike
/// PoolStats::threads_active which requires an API response.
@@ -156,11 +158,14 @@ private:
void monitorProcess();
void drainOutput();
void appendOutput(const char* data, size_t len);
// Set last_error_ under error_mutex_ (writers run on both the main thread and the monitor thread).
void setLastError(std::string e) { std::lock_guard<std::mutex> lk(error_mutex_); last_error_ = std::move(e); }
void fetchStatsHttp(); // Blocking HTTP call — runs on monitor thread only
void fetchPoolApiStats(); // Fetch pool-side stats (hashrate) from pool HTTP API
std::atomic<State> state_{State::Stopped};
std::string last_error_;
mutable std::mutex error_mutex_; // guards last_error_ (written by main + monitor threads)
mutable std::mutex output_mutex_;
std::string process_output_;