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;
|
show_seed_backup_ = false;
|
||||||
sodium_memzero(export_result_, sizeof(export_result_));
|
sodium_memzero(export_result_, sizeof(export_result_));
|
||||||
ui::KeyExportDialog::hide(); // zeros its static s_key via secureWipeLiteSecret
|
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
|
// 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);
|
console_exec_ = std::make_unique<ui::LiteConsoleExecutor>(this);
|
||||||
else
|
else
|
||||||
console_exec_ = std::make_unique<ui::FullNodeConsoleExecutor>(this);
|
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_);
|
console_tab_.render(*console_exec_);
|
||||||
// Persist the console toolbar toggles (ConsoleTab has no settings pointer). Save only when
|
// 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.
|
// tab completion, daemon log display, and color-coded output.
|
||||||
|
|
||||||
#include "console_tab.h"
|
#include "console_tab.h"
|
||||||
|
#include <sodium.h> // sodium_memzero — scrub masked secret-result plaintext
|
||||||
#include "console_command_executor.h"
|
#include "console_command_executor.h"
|
||||||
#include "console_command_reference.h"
|
#include "console_command_reference.h"
|
||||||
#include "console_input_model.h"
|
#include "console_input_model.h"
|
||||||
@@ -298,7 +299,26 @@ void ConsoleTab::render(ConsoleCommandExecutor& exec)
|
|||||||
{
|
{
|
||||||
std::string result;
|
std::string result;
|
||||||
bool isError = false;
|
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
|
// 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()) {
|
if (ImGui::IsItemHovered()) {
|
||||||
material::Tooltip("%s", selection_.active() ? TR("console_copy_selected") : TR("console_copy_all"));
|
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();
|
ImGui::SameLine();
|
||||||
|
|
||||||
// Commands reference button — shown whenever the backend offers a reference table (full-node
|
// 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)
|
bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::string& cmd)
|
||||||
{
|
{
|
||||||
if (cmd.empty()) return false;
|
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
|
// 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.
|
// 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);
|
addLine(TR("console_destructive_confirm"), ConsoleChannel::Warning);
|
||||||
} else {
|
} else {
|
||||||
destructive_confirm_cmd_.clear();
|
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);
|
exec.submit(cmd);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -2202,8 +2239,17 @@ void ConsoleTab::addRpcTraceLine(const std::string& source, const std::string& m
|
|||||||
addLine("[" + rpcTraceTimestamp() + "] [" + source + "] " + method, ConsoleChannel::Rpc);
|
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()
|
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
|
// 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.
|
// still appends. The "cleared" line is ingested and appears on the next frame's drain.
|
||||||
model_.clear();
|
model_.clear();
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
#include <deque>
|
#include <deque>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
|
#include <functional>
|
||||||
|
#include <chrono>
|
||||||
|
|
||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
namespace ui {
|
namespace ui {
|
||||||
@@ -66,6 +68,13 @@ public:
|
|||||||
*/
|
*/
|
||||||
void clear();
|
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).
|
// 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.
|
// Called when the user switches to the Console tab, gated by the console_auto_focus setting.
|
||||||
void requestInputFocus() { focus_input_pending_ = true; }
|
void requestInputFocus() { focus_input_pending_ = true; }
|
||||||
@@ -162,6 +171,10 @@ private:
|
|||||||
char input_buffer_[4096] = {0};
|
char input_buffer_[4096] = {0};
|
||||||
bool stop_confirm_pending_ = false; // 'stop' typed once, awaiting a confirming second 'stop'
|
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
|
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)
|
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)
|
// (log-ingestion cursors + result queue moved to the ConsoleCommandExecutor)
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,25 @@ bool ConsoleCommandCarriesSecret(const std::string& cmd)
|
|||||||
return false;
|
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)
|
std::string RedactConsoleCommand(const std::string& cmd)
|
||||||
{
|
{
|
||||||
size_t end = 0;
|
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.
|
// z_exportkey, z_exportmnemonic) are NOT covered — their secret is in the result, a separate concern.
|
||||||
bool ConsoleCommandCarriesSecret(const std::string& cmd);
|
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
|
// 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.
|
// it carries a secret, else `cmd` unchanged. The real command is still executed unredacted.
|
||||||
std::string RedactConsoleCommand(const std::string& cmd);
|
std::string RedactConsoleCommand(const std::string& cmd);
|
||||||
|
|||||||
@@ -2031,6 +2031,11 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["console_clear"] = "Clear";
|
strings_["console_clear"] = "Clear";
|
||||||
strings_["console_clear_console"] = "Clear Console";
|
strings_["console_clear_console"] = "Clear Console";
|
||||||
strings_["console_cleared"] = "Console cleared";
|
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_commands"] = "Click commands above to insert them";
|
||||||
strings_["console_click_insert"] = "Click to insert";
|
strings_["console_click_insert"] = "Click to insert";
|
||||||
strings_["console_click_insert_params"] = "Click to insert with parameters";
|
strings_["console_click_insert_params"] = "Click to insert with parameters";
|
||||||
|
|||||||
Reference in New Issue
Block a user