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
+22 -4
View File
@@ -1235,6 +1235,14 @@ LiteImportResult LiteWalletController::importKey(std::string spendingOrViewingKe
return importKeyCore(*bridge_, spendingOrViewingKey); // wipes the key
}
// Recursively zero every string node of a parsed secret JSON tree (seed / private keys) so the parse
// copies don't linger in freed heap. Best-effort (nlohmann's own lexer scratch isn't reachable).
static void wipeJsonStrings(nlohmann::json& j)
{
if (j.is_string()) { secureWipeLiteSecret(j.get_ref<nlohmann::json::string_t&>()); }
else if (j.is_object() || j.is_array()) { for (auto& el : j) wipeJsonStrings(el); }
}
LiteExportResult LiteWalletController::exportPrivateKeys(const std::string& optionalAddress)
{
LiteExportResult out;
@@ -1243,13 +1251,18 @@ LiteExportResult LiteWalletController::exportPrivateKeys(const std::string& opti
return out;
}
// Empty address -> export keys for all addresses; otherwise just the given address.
const auto result = bridge_->execute("export", optionalAddress);
auto result = bridge_->execute("export", optionalAddress); // non-const: result.value is a secret
if (!result.ok) {
out.error = result.error.empty() ? "export failed" : result.error;
return out;
}
nlohmann::json j;
// result.value + the parse tree hold the private keys; wipe them on every return path. out already
// has its own copy (out.privateKeysJson) which the caller wipes after use.
struct Scrub { std::string& v; nlohmann::json& tree; ~Scrub() { secureWipeLiteSecret(v); wipeJsonStrings(tree); } }
scrub{result.value, j};
try {
const auto j = nlohmann::json::parse(result.value);
j = nlohmann::json::parse(result.value);
if (j.is_object() && j.contains("error")) {
out.error = extractJsonError(j);
return out;
@@ -1270,13 +1283,18 @@ LiteSeedResult LiteWalletController::exportSeed()
out.error = "no wallet is open";
return out;
}
const auto result = bridge_->execute("seed", "");
auto result = bridge_->execute("seed", ""); // non-const: result.value holds the seed
if (!result.ok) {
out.error = result.error.empty() ? "seed export failed" : result.error;
return out;
}
nlohmann::json j;
// result.value + the parse tree contain the seed phrase; wipe them on every return path (out.seedPhrase
// is extracted separately and returned to the caller).
struct Scrub { std::string& v; nlohmann::json& tree; ~Scrub() { secureWipeLiteSecret(v); wipeJsonStrings(tree); } }
scrub{result.value, j};
try {
const auto j = nlohmann::json::parse(result.value);
j = nlohmann::json::parse(result.value);
if (j.is_object()) {
if (j.contains("error")) {
out.error = extractJsonError(j);