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
+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();