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:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user