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:
32
src/app.cpp
32
src/app.cpp
@@ -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;
|
||||
|
||||
10
src/app.h
10
src/app.h
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user