security(console): mask key-dump command RESULTS in the scrollback

The console redacted secret command ARGS but showed the OUTPUT of key-revealing
commands (dumpprivkey / z_exportkey / z_exportviewingkey / z_exportmnemonic /
dumpwallet / z_exportwallet, and the lite seed/export verbs) in full — visible
in the scrollback, Copy All, Ctrl+C and the filter. Now, when the submitted
command is key-revealing, its result is held ONLY in a member buffer (never in
the scrollback model, so Copy-All etc. can't leak it) and a masked placeholder
is shown. An explicit "Copy secret" button routes through the auto-clearing
clipboard; the buffer auto-wipes after 60s, on Clear, and on wallet lock
(via wipeDisplayedSecrets). The command string passed to the executor is
byte-identical. New pure helper ConsoleCommandRevealsSecret is unit-testable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 11:39:38 -05:00
co-authored by Claude Opus 4.8
parent 31e9c2ee2c
commit 83ce73f26b
6 changed files with 94 additions and 2 deletions
+3
View File
@@ -162,6 +162,7 @@ void App::wipeDisplayedSecrets()
show_seed_backup_ = false;
sodium_memzero(export_result_, sizeof(export_result_));
ui::KeyExportDialog::hide(); // zeros its static s_key via secureWipeLiteSecret
console_tab_.wipeSecretResult(); // drop any masked key-dump result held in the console
}
// Enqueue a blocking xmrig start/stop op onto the dedicated serialized control thread so the render thread
@@ -2411,6 +2412,8 @@ void App::render()
console_exec_ = std::make_unique<ui::LiteConsoleExecutor>(this);
else
console_exec_ = std::make_unique<ui::FullNodeConsoleExecutor>(this);
// One-time: route the console's "Copy secret" through the auto-clearing clipboard.
console_tab_.setCopySecretHandler([this](const std::string& s) { copySecretToClipboard(s); });
}
console_tab_.render(*console_exec_);
// Persist the console toolbar toggles (ConsoleTab has no settings pointer). Save only when
+48 -2
View File
@@ -6,6 +6,7 @@
// tab completion, daemon log display, and color-coded output.
#include "console_tab.h"
#include <sodium.h> // sodium_memzero — scrub masked secret-result plaintext
#include "console_command_executor.h"
#include "console_command_reference.h"
#include "console_input_model.h"
@@ -298,7 +299,26 @@ void ConsoleTab::render(ConsoleCommandExecutor& exec)
{
std::string result;
bool isError = false;
while (exec.pollResult(result, isError)) addFormattedResult(result, isError);
while (exec.pollResult(result, isError)) {
const bool secret = secret_result_armed_ && !isError;
secret_result_armed_ = false; // the first (non-error) result consumes the arming
if (secret) {
// Hold the plaintext ONLY in secret_result_ and show a masked placeholder — it never
// enters the scrollback model, so Copy All / Ctrl+C / the filter can't leak it.
if (!secret_result_.empty()) sodium_memzero(&secret_result_[0], secret_result_.size());
secret_result_ = result;
secret_result_deadline_ = std::chrono::steady_clock::now() + std::chrono::seconds(60);
addLine(TR("console_secret_result_hidden"), ConsoleChannel::Warning);
} else {
addFormattedResult(result, isError);
}
if (!result.empty()) sodium_memzero(&result[0], result.size());
}
}
// Auto-wipe a revealed secret result after its deadline so it doesn't linger for the session.
if (!secret_result_.empty() && std::chrono::steady_clock::now() >= secret_result_deadline_) {
wipeSecretResult();
addLine(TR("console_secret_result_expired"), ConsoleChannel::Info);
}
// Drain the ingest queue into the visible model once per frame, after this frame's own
@@ -547,7 +567,21 @@ void ConsoleTab::renderToolbar(ConsoleCommandExecutor& exec)
if (ImGui::IsItemHovered()) {
material::Tooltip("%s", selection_.active() ? TR("console_copy_selected") : TR("console_copy_all"));
}
// When a key-revealing result is masked, offer an explicit Copy-secret (through the auto-clearing
// clipboard) and a Hide action. The secret is never in the model, so the normal Copy above is safe.
if (hasSecretResult()) {
ImGui::SameLine();
if (TactileButton(TR("console_copy_secret"), ImVec2(0, 0), schema::UI().resolveFont("button"))) {
if (copy_secret_cb_) copy_secret_cb_(secret_result_);
else ImGui::SetClipboardText(secret_result_.c_str());
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("console_copy_secret_tip"));
ImGui::SameLine();
if (TactileButton(TR("console_hide_secret"), ImVec2(0, 0), schema::UI().resolveFont("button")))
wipeSecretResult();
}
ImGui::SameLine();
// Commands reference button — shown whenever the backend offers a reference table (full-node
@@ -1565,6 +1599,7 @@ bool IsDestructiveConsoleCommand(const std::string& firstLower, bool fullNode)
bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::string& cmd)
{
if (cmd.empty()) return false;
secret_result_armed_ = false; // cleared each submit so a no-result command can't mask the next one
// Redact secret-bearing commands (walletpassphrase, z_importkey, …) before they reach the visible
// log and the recall history. The real `cmd` below is still executed unredacted.
@@ -1622,6 +1657,8 @@ bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::s
addLine(TR("console_destructive_confirm"), ConsoleChannel::Warning);
} else {
destructive_confirm_cmd_.clear();
// Arm result-masking for key-revealing commands so their OUTPUT is hidden in the scrollback.
secret_result_armed_ = ConsoleCommandRevealsSecret(cmd, exec.hasRpcReference());
exec.submit(cmd);
}
return true;
@@ -2202,8 +2239,17 @@ void ConsoleTab::addRpcTraceLine(const std::string& source, const std::string& m
addLine("[" + rpcTraceTimestamp() + "] [" + source + "] " + method, ConsoleChannel::Rpc);
}
void ConsoleTab::wipeSecretResult()
{
if (!secret_result_.empty()) sodium_memzero(&secret_result_[0], secret_result_.size());
secret_result_.clear();
secret_result_armed_ = false;
secret_result_deadline_ = {};
}
void ConsoleTab::clear()
{
wipeSecretResult(); // the clear command / toolbar button also drops any held secret result
// View-only clear (main thread). The executor keeps its own log cursors, so new output
// still appends. The "cleared" line is ingested and appears on the next frame's drain.
model_.clear();
+13
View File
@@ -20,6 +20,8 @@
#include <vector>
#include <deque>
#include <mutex>
#include <functional>
#include <chrono>
namespace dragonx {
namespace ui {
@@ -66,6 +68,13 @@ public:
*/
void clear();
// Masked secret-result support: the plaintext of a key-revealing command's output is held ONLY here
// (never in the scrollback model), so Copy All / Ctrl+C / the filter can't leak it. An explicit
// "Copy secret" action routes through this handler (the app wires it to the auto-clearing clipboard).
void setCopySecretHandler(std::function<void(const std::string&)> f) { copy_secret_cb_ = std::move(f); }
bool hasSecretResult() const { return !secret_result_.empty(); }
void wipeSecretResult(); // zero + drop the buffer, disarm, reset the deadline (call on lock/clear)
// Ask the console to place the keyboard focus in the command input on the next render (consumed once).
// Called when the user switches to the Console tab, gated by the console_auto_focus setting.
void requestInputFocus() { focus_input_pending_ = true; }
@@ -162,6 +171,10 @@ private:
char input_buffer_[4096] = {0};
bool stop_confirm_pending_ = false; // 'stop' typed once, awaiting a confirming second 'stop'
std::string destructive_confirm_cmd_; // a destructive command typed once, awaiting an identical re-entry
bool secret_result_armed_ = false; // the just-submitted command reveals key material in its result
std::string secret_result_; // plaintext of the masked result (member-only; never in model_)
std::chrono::steady_clock::time_point secret_result_deadline_{}; // auto-wipe the buffer at this time
std::function<void(const std::string&)> copy_secret_cb_; // routes "Copy secret" to the app
bool focus_input_pending_ = false; // one-shot: focus the command input next render (tab-open auto-focus)
// (log-ingestion cursors + result queue moved to the ConsoleCommandExecutor)
+19
View File
@@ -71,6 +71,25 @@ bool ConsoleCommandCarriesSecret(const std::string& cmd)
return false;
}
bool ConsoleCommandRevealsSecret(const std::string& cmd, bool fullNode)
{
size_t end = 0;
const std::string name = firstConsoleTokenLower(cmd, end);
if (name.empty()) return false;
if (fullNode) {
static const char* const kFull[] = {
"dumpprivkey", "z_exportkey", "z_exportviewingkey",
"z_exportmnemonic", "dumpwallet", "z_exportwallet",
};
for (const char* s : kFull) if (name == s) return true;
} else {
// Lite backend verbs whose output is the seed phrase / private keys.
static const char* const kLite[] = { "seed", "export" };
for (const char* s : kLite) if (name == s) return true;
}
return false;
}
std::string RedactConsoleCommand(const std::string& cmd)
{
size_t end = 0;
+6
View File
@@ -29,6 +29,12 @@ bool ShouldPrintDaemonErrorLine(bool wasError, bool isError,
// z_exportkey, z_exportmnemonic) are NOT covered — their secret is in the result, a separate concern.
bool ConsoleCommandCarriesSecret(const std::string& cmd);
// True when the command's RESULT (not its args) reveals key material — dumpprivkey / z_exportkey /
// z_exportviewingkey / z_exportmnemonic / dumpwallet / z_exportwallet on the full node, or the lite
// `seed` / `export` verbs. Used to mask the output in the console scrollback. `fullNode` selects the
// command set (the bare lite verbs `seed`/`export` must not match full-node commands).
bool ConsoleCommandRevealsSecret(const std::string& cmd, bool fullNode);
// A display/history-safe copy of `cmd`: the command name with its arguments replaced by "****" when
// it carries a secret, else `cmd` unchanged. The real command is still executed unredacted.
std::string RedactConsoleCommand(const std::string& cmd);
+5
View File
@@ -2031,6 +2031,11 @@ void I18n::loadBuiltinEnglish()
strings_["console_clear"] = "Clear";
strings_["console_clear_console"] = "Clear Console";
strings_["console_cleared"] = "Console cleared";
strings_["console_secret_result_hidden"] = "[result hidden — contains private key material. Use \"Copy secret\" to copy it, or it clears automatically.]";
strings_["console_secret_result_expired"] = "[hidden secret result cleared]";
strings_["console_copy_secret"] = "Copy secret";
strings_["console_copy_secret_tip"] = "Copy the hidden key/seed result to the clipboard (auto-clears after ~45s).";
strings_["console_hide_secret"] = "Hide";
strings_["console_click_commands"] = "Click commands above to insert them";
strings_["console_click_insert"] = "Click to insert";
strings_["console_click_insert_params"] = "Click to insert with parameters";