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