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
+52 -22
View File
@@ -238,6 +238,41 @@ private:
// daemon off the main thread (to avoid stalling the UI), or ask the user to
// restart an external daemon. Shared by encryptWalletWithPassphrase() and
// processDeferredEncryption(); must be called on the main thread.
// Zero (overwrite) then delete a plaintext key export so a full cleartext dump of every private key is
// never left readable on disk. Idempotent + error-tolerant (safe on a missing/locked file). (H-02)
void App::scrubAndRemoveExport(const std::string& path)
{
if (path.empty()) return;
std::error_code ec;
const auto sz = std::filesystem::file_size(path, ec);
if (!ec && sz > 0) {
std::fstream scrub(path, std::ios::binary | std::ios::in | std::ios::out);
if (scrub) {
const std::vector<char> zeros(static_cast<size_t>(sz), 0);
scrub.write(zeros.data(), static_cast<std::streamsize>(sz));
scrub.flush();
}
}
std::filesystem::remove(path, ec);
}
// Startup net for H-02: a crash/kill/early-return between exporting the cleartext keys and scrubbing them
// could leave an obsidiandecryptexport* file behind. Purge any found in the data dir on launch.
void App::sweepStaleDecryptExports()
{
std::error_code ec;
const std::string dir = util::Platform::getDragonXDataDir();
std::filesystem::directory_iterator it(dir, ec), end;
for (; it != end; it.increment(ec)) {
if (ec) break;
const std::string name = it->path().filename().string();
if (name.rfind("obsidiandecryptexport", 0) == 0) {
scrubAndRemoveExport(it->path().string());
DEBUG_LOGF("[decrypt] swept stale plaintext key export: %s\n", name.c_str());
}
}
}
void App::restartDaemonAfterEncryption(const char* taskName, bool announceRestartStatus) {
if (isUsingEmbeddedDaemon()) {
if (announceRestartStatus) {
@@ -1344,9 +1379,13 @@ void App::renderEncryptWalletDialog() {
enc_dlg_pin_status_.clear();
std::string savedPass = enc_dlg_saved_passphrase_;
if (worker_ && vault_) {
worker_->post([this, pinStr, savedPass]() -> rpc::RPCWorker::MainCb {
worker_->post([this, pinStr, savedPass]() mutable -> rpc::RPCWorker::MainCb {
// Argon2id runs here (worker thread)
bool ok = vault_->store(pinStr, savedPass);
// Scrub the captured PIN + passphrase copies (they live in the worker's task
// queue until this runs); the source member is scrubbed in the MainCb. (L-03)
if (!savedPass.empty()) util::SecureVault::secureZero(&savedPass[0], savedPass.size());
if (!pinStr.empty()) util::SecureVault::secureZero(&pinStr[0], pinStr.size());
return [this, ok]() {
if (ok) {
settings_->setPinEnabled(true);
@@ -1588,6 +1627,11 @@ void App::renderDecryptWalletDialog() {
std::chrono::steady_clock::now());
auto restartAndImport = [this, exportPath](const util::AsyncTaskManager::Token& token) {
// Scrub + delete the plaintext key export (obsidiandecryptexport…) on EVERY exit path —
// success, a restart-failure early return, or an exception. A full cleartext dump of all
// private keys must never outlive this step. The startup sweep is a further net for a
// crash/kill mid-flight. (H-02)
struct ExportScrub { std::string p; ~ExportScrub() { App::scrubAndRemoveExport(p); } } exportScrub{exportPath};
WalletSecurityDaemonAdapter daemonAdapter(*this, token);
WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(),
[this](rpc::RPCClient& client, const char* context) {
@@ -1641,26 +1685,7 @@ void App::renderDecryptWalletDialog() {
WalletSecurityImportRpcAdapter importAdapter(rpc_.get(), saved_config_);
auto importResult = services::WalletSecurityWorkflowExecutor::importWallet(
importAdapter, exportPath);
// The plaintext key export (obsidiandecryptexport…) has served its purpose now
// that the import attempt has resolved — scrub and remove it so a full cleartext
// dump of every private key isn't left on disk forever. Recovery, if ever needed,
// is the encrypted backup (wallet.dat.encrypted.bak), never this file.
{
std::error_code delEc;
const auto sz = std::filesystem::file_size(exportPath, delEc);
if (!delEc && sz > 0) {
std::fstream scrub(exportPath,
std::ios::binary | std::ios::in | std::ios::out);
if (scrub) {
const std::vector<char> zeros(static_cast<size_t>(sz), 0);
scrub.write(zeros.data(), static_cast<std::streamsize>(sz));
scrub.flush();
}
}
std::filesystem::remove(exportPath, delEc);
DEBUG_LOGF("[decrypt] removed plaintext key export after import\n");
}
// (exportScrub scrubs + deletes the plaintext key export on scope exit — H-02)
if (!importResult.ok) {
std::string err = importResult.error;
@@ -1930,12 +1955,14 @@ void App::renderPinDialogs() {
memset(pin_confirm_buf_, 0, sizeof(pin_confirm_buf_));
if (rpc_ && rpc_->isConnected() && worker_) {
worker_->post([this, passphrase, pin]() -> rpc::RPCWorker::MainCb {
worker_->post([this, passphrase, pin]() mutable -> rpc::RPCWorker::MainCb {
// Verify passphrase via RPC (worker thread)
try {
rpc::RPCClient::TraceScope trace("Security / PIN setup");
rpc_->call("walletpassphrase", {passphrase, 5});
} catch (const std::exception& e) {
if (!passphrase.empty()) util::SecureVault::secureZero(&passphrase[0], passphrase.size());
if (!pin.empty()) util::SecureVault::secureZero(&pin[0], pin.size());
return [this]() {
pin_status_ = "Incorrect passphrase";
pin_in_progress_ = false;
@@ -1944,6 +1971,9 @@ void App::renderPinDialogs() {
// Passphrase correct — store in vault (Argon2id, worker thread)
bool storeOk = vault_ && vault_->store(pin, passphrase);
// Captured passphrase + PIN are no longer needed — scrub the worker-queue copies. (M-01)
if (!passphrase.empty()) util::SecureVault::secureZero(&passphrase[0], passphrase.size());
if (!pin.empty()) util::SecureVault::secureZero(&pin[0], pin.size());
// Lock wallet back
try {