security: wipe RPC creds, lock down generated conf, auto-clear secret clipboard (audit #4-6)

- rpc_client: wipe the plaintext "user:password" temporary with sodium_memzero after
  base64-encoding it into the auth header (std::string doesn't zero its buffer on
  destruction).
- connection: the auto-generated DRAGONX.conf holds rpcuser/rpcpassword in plaintext but
  was written with the default umask (often world-readable 0644). Restrict it to owner
  read/write after creation so another local user can't read the credentials.
- app: copying a seed phrase / private key to the clipboard now arms an auto-clear —
  App::copySecretToClipboard() copies the secret and, after 45s, wipes the clipboard IF it
  still holds that secret (compared via a stored hash, never the plaintext). Wired into the
  lite first-run wizard's seed Copy and the Settings export-secret Copy, with a
  "clipboard auto-clears in 45s" notice. pumpSecretClipboardClear() runs each frame.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 14:00:45 -05:00
parent e40962cdf2
commit 094771af81
5 changed files with 58 additions and 4 deletions

View File

@@ -566,6 +566,9 @@ void App::update()
using RefreshTimer = services::NetworkRefreshService::Timer;
network_refresh_.tick(io.DeltaTime);
// Wipe a secret (seed/private key) from the clipboard once its auto-clear delay elapses.
pumpSecretClipboardClear();
// Full-node RPC refreshes gate on ACTUAL RPC connectivity, not state_.connected. In lite
// builds state_.connected is the lite-wallet "online" proxy (true when a wallet is open, to
// enable the wallet UI), but there is no RPC daemon — so RPC polls (mining/balance/peers/txs)
@@ -2072,7 +2075,7 @@ void App::renderLiteFirstRunPrompt()
step = 2;
}
ImGui::SameLine();
if (ImGui::Button("Copy", ImVec2(80, 0))) ImGui::SetClipboardText(seed.c_str());
if (ImGui::Button("Copy", ImVec2(80, 0))) copySecretToClipboard(seed);
ImGui::SameLine();
if (ImGui::Button("Skip", ImVec2(80, 0))) {
ui::Notifications::instance().success(TR("lite_welcome_created"), 6.0f);
@@ -3659,6 +3662,33 @@ std::string App::transactionRefreshProgressText() const
return TR("tx_loading_fetching_transparent");
}
void App::copySecretToClipboard(const std::string& secret)
{
ImGui::SetClipboardText(secret.c_str());
// Keep only an FNV-1a hash of the secret so we can later confirm the clipboard still holds it
// (and shouldn't be clobbered) WITHOUT retaining the plaintext.
std::uint64_t h = 1469598103934665603ULL;
for (unsigned char c : secret) h = (h ^ c) * 1099511628211ULL;
clipboard_secret_hash_ = secret.empty() ? 0 : h;
clipboard_clear_deadline_ = secret.empty() ? 0.0 : (ImGui::GetTime() + 45.0);
if (!secret.empty())
ui::Notifications::instance().info("Copied — clipboard auto-clears in 45s", 4.0f);
}
void App::pumpSecretClipboardClear()
{
if (clipboard_clear_deadline_ <= 0.0) return;
if (ImGui::GetTime() < clipboard_clear_deadline_) return;
// Only clear if the clipboard STILL holds our secret (the user may have copied something else).
if (const char* cb = ImGui::GetClipboardText()) {
std::uint64_t h = 1469598103934665603ULL;
for (const char* p = cb; *p; ++p) h = (h ^ static_cast<unsigned char>(*p)) * 1099511628211ULL;
if (h == clipboard_secret_hash_) ImGui::SetClipboardText("");
}
clipboard_clear_deadline_ = 0.0;
clipboard_secret_hash_ = 0;
}
void App::maybeFinishTransactionSendProgress()
{
using Job = services::NetworkRefreshService::Job;

View File

@@ -389,6 +389,13 @@ public:
bool hasTransactionSendProgress() const { return send_progress_active_ || send_submissions_in_flight_ > 0 || !pending_opids_.empty(); }
std::string transactionSendProgressText() const;
std::string transactionRefreshProgressText() const;
// Copy a SECRET (seed phrase, private key) to the clipboard and arm an auto-clear: after a
// short delay the clipboard is wiped IF it still holds this secret (so we don't clobber
// something the user copied afterwards). Only a hash of the secret is retained, never the
// plaintext. Call pumpSecretClipboardClear() each frame to action the clear.
void copySecretToClipboard(const std::string& secret);
void pumpSecretClipboardClear();
bool isTransactionRefreshInProgress() const {
return network_refresh_.jobInProgress(services::NetworkRefreshService::Job::Transactions);
}
@@ -512,6 +519,9 @@ private:
int daemon_wait_attempts_ = 0;
bool daemon_start_error_shown_ = false;
int daemon_last_seen_crashes_ = 0; // surface each new embedded-daemon crash reason once
// Auto-clear for secrets copied to the clipboard. Only a hash of the copied secret is kept.
std::uint64_t clipboard_secret_hash_ = 0;
double clipboard_clear_deadline_ = 0.0;
float loading_timer_ = 0.0f; // spinner animation for loading overlay
// Current page (sidebar navigation)

View File

@@ -331,7 +331,18 @@ bool Connection::createDefaultConfig(const std::string& path)
file << "addnode=node4.dragonx.is\n";
file.close();
// The file holds the freshly-generated rpcuser/rpcpassword in plaintext. ofstream creates it
// with the process umask (typically world-readable 0644), so restrict it to owner read/write
// before another local user can read the credentials.
{
namespace fs = std::filesystem;
std::error_code ec;
fs::permissions(path, fs::perms::owner_read | fs::perms::owner_write,
fs::perm_options::replace, ec);
if (ec) DEBUG_LOGF("Could not restrict config permissions on %s: %s\n", path.c_str(), ec.message().c_str());
}
DEBUG_LOGF("Created default config file: %s\n", path.c_str());
return true;
}

View File

@@ -11,6 +11,7 @@
#include "../util/base64.h"
#include <curl/curl.h>
#include <sodium.h>
#include <atomic>
#include <cstdio>
#include <cstring>
@@ -145,9 +146,11 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
port_ = port;
last_connect_info_ = json();
// Create Basic auth header with proper base64 encoding
// Create Basic auth header with proper base64 encoding, then wipe the plaintext
// "user:password" temporary (std::string does not zero its buffer on destruction).
std::string credentials = user + ":" + password;
auth_ = util::base64_encode(credentials);
if (!credentials.empty()) sodium_memzero(credentials.data(), credentials.size());
impl_->url = std::string(useTls ? "https://" : "http://") + host + ":" + port + "/";
VERBOSE_LOGF("Connecting to dragonxd at %s\n", impl_->url.c_str());

View File

@@ -1709,7 +1709,7 @@ void RenderSettingsPage(App* app) {
}
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
if (TactileButton("Copy##LiteExportCopy", ImVec2(0, 0), S.resolveFont("button"))) {
ImGui::SetClipboardText(s_settingsState.lite_export_secret.c_str());
app->copySecretToClipboard(s_settingsState.lite_export_secret);
}
ImGui::SameLine(0, Layout::spacingSm());
// Save the seed (+ birthday) to an owner-only (0600) file in the config dir.