From 83ce73f26b2e613ad6ed297ffe024fa4ea16b946 Mon Sep 17 00:00:00 2001 From: DanS Date: Mon, 19 Oct 2026 11:39:38 -0500 Subject: [PATCH] security(console): mask key-dump command RESULTS in the scrollback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app.cpp | 3 ++ src/ui/windows/console_tab.cpp | 50 ++++++++++++++++++++++++-- src/ui/windows/console_tab.h | 13 +++++++ src/ui/windows/console_tab_helpers.cpp | 19 ++++++++++ src/ui/windows/console_tab_helpers.h | 6 ++++ src/util/i18n.cpp | 5 +++ 6 files changed, 94 insertions(+), 2 deletions(-) diff --git a/src/app.cpp b/src/app.cpp index cbac4ea..5fa0f68 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -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(this); else console_exec_ = std::make_unique(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 diff --git a/src/ui/windows/console_tab.cpp b/src/ui/windows/console_tab.cpp index bfcf8ad..731662d 100644 --- a/src/ui/windows/console_tab.cpp +++ b/src/ui/windows/console_tab.cpp @@ -6,6 +6,7 @@ // tab completion, daemon log display, and color-coded output. #include "console_tab.h" +#include // 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(); diff --git a/src/ui/windows/console_tab.h b/src/ui/windows/console_tab.h index 6a02515..016b3e8 100644 --- a/src/ui/windows/console_tab.h +++ b/src/ui/windows/console_tab.h @@ -20,6 +20,8 @@ #include #include #include +#include +#include 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 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 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) diff --git a/src/ui/windows/console_tab_helpers.cpp b/src/ui/windows/console_tab_helpers.cpp index b376946..71893aa 100644 --- a/src/ui/windows/console_tab_helpers.cpp +++ b/src/ui/windows/console_tab_helpers.cpp @@ -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; diff --git a/src/ui/windows/console_tab_helpers.h b/src/ui/windows/console_tab_helpers.h index 473a95f..120e0ba 100644 --- a/src/ui/windows/console_tab_helpers.h +++ b/src/ui/windows/console_tab_helpers.h @@ -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); diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 60a4290..b369213 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -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";