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:
+52
-22
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user