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
+53
View File
@@ -0,0 +1,53 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#pragma once
// Best-effort scrub of ImGui's internal InputText buffers, which retain un-zeroed copies of typed
// secrets (wallet passphrase / PIN / seed / private key) after a field is edited — the app's own
// char[] buffers are memzeroed, but ImGui also copies the text into its global edit/undo/deactivated
// state, which it only frees (not zeroes).
//
// This reaches into vendored imgui_internal state, so it is PINNED to the vendored ImGui version: a
// bump trips the #error below and forces a re-audit of the member names. It is BEST-EFFORT only —
// std::-style buffer reallocation, the system clipboard, and OS/IME layers can still hold copies, so
// this shrinks the exposure window but does not guarantee absence of secret material in a core dump.
#include "imgui.h"
#include "imgui_internal.h"
#include <sodium.h>
#include <cstddef>
#if IMGUI_VERSION_NUM != 19259
#error "imgui_secret_scrub.h is pinned to ImGui 1.92.6 (IMGUI_VERSION_NUM 19259). After bumping libs/imgui, re-audit ImGuiInputTextState / ImGuiInputTextDeactivatedState / ImGuiContext member names below, then update this guard."
#endif
namespace dragonx {
namespace ui {
// Zero ImGui's retained InputText buffers. Safe to call at a lock transition / when a secret dialog
// closes (it defocuses the active field). Do NOT call while a secret field is actively being typed.
inline void scrubImGuiInputTextState() {
ImGuiContext* ctx = ImGui::GetCurrentContext();
if (!ctx) return;
ImGuiInputTextState& st = ctx->InputTextState;
// Defocus first so the next frame can't re-read the buffer we're about to zero (ClearActiveID also
// refreshes the deactivated-state copy, which we then zero below).
if (st.ID != 0 && ctx->ActiveId == st.ID) ImGui::ClearActiveID();
auto zeroVec = [](ImVector<char>& v) {
if (v.Data && v.Size > 0) sodium_memzero(v.Data, static_cast<size_t>(v.Size));
};
zeroVec(st.TextA); // the live edit buffer
zeroVec(st.TextToRevertTo); // Escape-to-revert backup
zeroVec(st.CallbackTextBackup); // callback reconcile scratch
st.TextLen = 0;
// NOTE: st.Stb (the stb_textedit undo/redo char buffers) is deliberately NOT zeroed here — its type
// ImStbTexteditState is incomplete in imgui_internal.h (fully defined only inside ImGui's .cpp), so we
// can't sizeof it from a header. The primary copies above cover the committed text; the undo scratch
// is a residual gap in this best-effort scrub.
zeroVec(ctx->InputTextDeactivatedState.TextA); // the just-deactivated copy
ctx->InputTextDeactivatedState.ID = 0;
}
} // namespace ui
} // namespace dragonx
+5
View File
@@ -2484,11 +2484,16 @@ void ResetChatTab()
s_show_blocked = false;
// Drop the per-frame memoization caches so the next wallet doesn't briefly render the previous one's
// conversations/thread (store.revision() is monotonic and would rebuild anyway, but be explicit).
// Zero the decrypted plaintext the memo caches hold (conversation previews + full thread bodies)
// before dropping them, so the previous wallet's messages don't linger in freed heap.
auto wipeStr2 = [](std::string& s) { if (!s.empty()) sodium_memzero(&s[0], s.size()); s.clear(); };
for (auto& c : s_convs) wipeStr2(c.lastBody);
s_convs.clear();
s_convsHidden = 0;
s_convsKey = ~0ull;
s_threadCid.clear();
s_threadRev = ~0ull;
for (auto& m : s_threadMsgs) { wipeStr2(m.body); wipeStr2(m.fail_reason); wipeStr2(m.peer_zaddr); }
s_threadMsgs.clear();
s_threadFlags.clear(); s_threadRows.clear(); s_threadHeights.clear();
s_threadPrefix.assign(1, 0.0f); s_threadFailed.clear(); s_threadBodySizes.clear();
+17 -2
View File
@@ -8,6 +8,7 @@
#include <cstdio>
#include <string>
#include <vector>
#include <sodium.h>
#include "imgui.h"
#include "../layout.h" // Layout::dpiScale()
@@ -15,19 +16,31 @@
namespace dragonx {
namespace ui {
// Best-effort zero of a seed-word vector (each word holds part of the phrase). Call after rendering.
inline void WipeSeedWords(std::vector<std::string>& words)
{
for (auto& w : words) { if (!w.empty()) sodium_memzero(&w[0], w.size()); w.clear(); }
words.clear();
}
// Split a whitespace-separated seed phrase into its individual words.
inline std::vector<std::string> SplitSeedWords(const std::string& phrase)
{
std::vector<std::string> words;
words.reserve(32); // avoid reallocation stranding un-wiped moved-from copies in freed heap
std::string cur;
for (char c : phrase) {
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
if (!cur.empty()) { words.push_back(cur); cur.clear(); }
if (!cur.empty()) {
words.push_back(cur);
sodium_memzero(&cur[0], cur.size()); // zero the scratch copy before reuse
cur.clear();
}
} else {
cur.push_back(c);
}
}
if (!cur.empty()) words.push_back(cur);
if (!cur.empty()) { words.push_back(cur); sodium_memzero(&cur[0], cur.size()); cur.clear(); }
return words;
}
@@ -45,6 +58,7 @@ inline void RenderSeedWordGrid(const std::vector<std::string>& words, float colS
std::snprintf(cell, sizeof(cell), "%2zu. %s", i + 1, words[i].c_str());
const float need = ImGui::CalcTextSize(cell).x + 20.0f * dp; // cell width + inter-column gap
if (need > colStride) colStride = need;
sodium_memzero(cell, sizeof(cell)); // the cell held a seed word
}
for (size_t i = 0; i < words.size(); ++i) {
char cell[96];
@@ -52,6 +66,7 @@ inline void RenderSeedWordGrid(const std::vector<std::string>& words, float colS
ImGui::TextUnformatted(cell);
if ((i % 4) != 3 && i + 1 < words.size())
ImGui::SameLine(((i % 4) + 1) * colStride);
sodium_memzero(cell, sizeof(cell));
}
}