security(hygiene): best-effort scrubbing of residual in-memory secret copies (Tier 4)

Defense-in-depth against a local attacker / core dump reading un-zeroed heap
copies of secrets (threat model is local, not remote). Best-effort by nature —
std::string SSO/reallocation and ImGui buffer growth can still strand copies —
so these shrink the exposure window rather than guarantee absence:
- rpc_client performCall: a scope guard now sodium_memzeroes the request body +
  payload tree (walletpassphrase / *_importkey args) after the call and detaches
  CURLOPT_POSTFIELDS. Request content sent to curl is unchanged.
- New ui::scrubImGuiInputTextState() zeroes ImGui's retained InputText buffers
  (live/revert/callback/deactivated) — the app memzeroed its own char[]s but
  ImGui kept copies. Called from wipeSecrets()/wipeDisplayedSecrets(). PINNED to
  ImGui 1.92.6 via a #error guard so a vendored bump forces a re-audit (the stb
  undo buffer is an incomplete type in the header, so it's left as a noted gap).
- Lite exportSeed/exportPrivateKeys wipe result.value + the parsed JSON tree.
- Seed-word grid (SplitSeedWords/RenderSeedWordGrid) zeroes its per-frame word
  and cell copies; the wizard word/chip vectors are zeroed before clearing.
- ChatStore::clear() + ResetChatTab() zero decrypted message bodies on
  lock/switch/teardown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 12:05:04 -05:00
co-authored by Claude Opus 4.8
parent d16bbf3695
commit c0d10e9664
7 changed files with 130 additions and 11 deletions
+14 -5
View File
@@ -40,6 +40,7 @@
#include "ui/windows/faq_dialog.h"
#include "embedded/IconsMaterialDesign.h"
#include "ui/windows/key_export_dialog.h"
#include "ui/imgui_secret_scrub.h" // scrubImGuiInputTextState — zero ImGui's retained secret buffers
#include "ui/windows/transaction_details_dialog.h"
#include "ui/windows/qr_popup_dialog.h"
#include "ui/windows/validate_address_dialog.h"
@@ -143,6 +144,7 @@ void App::wipeSecrets()
sodium_memzero(export_result_, sizeof(export_result_)); // exported WIF/z-key (SECRET)
sodium_memzero(import_key_input_, sizeof(import_key_input_)); // pasted private key (SECRET)
sodium_memzero(import_clip_preview_, sizeof(import_clip_preview_)); // clipboard preview (SECRET)
ui::scrubImGuiInputTextState(); // zero ImGui's retained copies of whatever secret was last typed
}
// Scrub + close any secret that is currently ON SCREEN (revealed seed phrase, migration seed, exported
@@ -163,6 +165,7 @@ void App::wipeDisplayedSecrets()
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
ui::scrubImGuiInputTextState(); // zero ImGui's retained copy of the last-typed passphrase/PIN
}
// Enqueue a blocking xmrig start/stop op onto the dedicated serialized control thread so the render thread
@@ -3486,14 +3489,20 @@ void App::renderLiteFirstRunPrompt()
ImGui::SetNextWindowPos(ImVec2(vp->Pos.x + vp->Size.x * 0.5f, vp->Pos.y + vp->Size.y * 0.5f),
ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
// Best-effort zero of the seed-word statics (they hold the whole phrase) before they're dropped.
auto wipeStr = [](std::string& s) { if (!s.empty()) sodium_memzero(&s[0], s.size()); s.clear(); };
auto wipeWords = [&](std::vector<std::string>& v) { for (auto& w : v) wipeStr(w); v.clear(); };
auto wipeChips = [&](std::vector<std::pair<std::string, bool>>& v) {
for (auto& c : v) { wipeStr(c.first); c.second = false; } v.clear();
};
auto finish = [&]() {
wallet::secureWipeLiteSecret(seed);
sodium_memzero(restoreSeed, sizeof(restoreSeed));
restoreBirthday = 0;
restoring = false;
restoreErr.clear();
words.clear();
chips.clear();
wipeWords(words);
wipeChips(chips);
progress = 0;
step = 0;
skipConfirm = false;
@@ -3601,7 +3610,7 @@ void App::renderLiteFirstRunPrompt()
ImGui::Spacing(); ImGui::Spacing();
if (ui::material::TactileButton(TR("appx_ive_written_it_down"), ImVec2(btnW, 0))) {
chips.clear();
wipeChips(chips);
for (const auto& w : words) chips.emplace_back(w, false);
std::mt19937 rng{std::random_device{}()};
std::shuffle(chips.begin(), chips.end(), rng);
@@ -4734,7 +4743,7 @@ void App::renderSeedBackupDialog()
ImGui::Spacing();
ImGui::TextWrapped("%s", TR("seed_backup_intro"));
ImGui::Spacing();
ui::RenderSeedWordGrid(ui::SplitSeedWords(seed_backup_phrase_));
{ auto words = ui::SplitSeedWords(seed_backup_phrase_); ui::RenderSeedWordGrid(words); ui::WipeSeedWords(words); }
ImGui::Spacing();
if (!seed_backup_status_.empty()) {
ImGui::PushTextWrapPos(0.0f);
@@ -4904,7 +4913,7 @@ void App::renderSeedMigrationDialog()
// distinct artifact, not body copy. Purely visual — the words/derivation/logic are untouched.
{
ui::material::GlassSectionScope seedPanel;
ui::RenderSeedWordGrid(ui::SplitSeedWords(seed_migration_seed_));
{ auto words = ui::SplitSeedWords(seed_migration_seed_); ui::RenderSeedWordGrid(words); ui::WipeSeedWords(words); }
}
ImGui::Spacing();
ImGui::TextColored(kMedium, "%s", TR("mig_receive_addr"));