From fecc9015fb760b2ec9f1f81a6d6b537be5af29d6 Mon Sep 17 00:00:00 2001 From: DanS Date: Thu, 16 Jul 2026 16:45:41 -0500 Subject: [PATCH] feat(rpc): scrub the raw response body for secret-bearing calls (B7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The secret exports (z_exportmnemonic / z_exportkey / dumpprivkey) already scrub the parsed value at the call site, but the raw HTTP response string those RPCs build — the curl write buffer, which holds the same secret in the clear — was freed without zeroing. That's the "fuller fix belongs in the RPC layer" the identity-fetch comment flagged. Add RPCClient::callSecret(), a call() variant that sodium_memzeros the raw response body after parsing (on success and on throw). NRVO makes the returned string the very buffer curl wrote into, so one wipe covers it. Route every secret-bearing export through it: chat identity (mnemonic + z_exportkey fallback), Settings seed-phrase + single-key export, Export-all-keys, and the migrate-to-seed isolated-node mnemonic export. Purely additive — the parsed result is byte-identical, so no behavior change (safe for the fund-critical migrate path). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app_network.cpp | 8 ++++---- src/daemon/seed_wallet_creator.cpp | 2 +- src/rpc/rpc_client.cpp | 21 +++++++++++++++++++++ src/rpc/rpc_client.h | 11 +++++++++++ src/ui/windows/export_all_keys_dialog.cpp | 4 ++-- 5 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/app_network.cpp b/src/app_network.cpp index fb4ccbc..a7c9393 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -2632,7 +2632,7 @@ void App::exportPrivateKey(const std::string& address, std::functioncall(method, {address}).get(); + key = rpc_->callSecret(method, {address}).get(); // zero the raw body too (B7) } catch (const std::exception& e) { err = e.what(); } @@ -2758,7 +2758,7 @@ void App::maybeProvisionChatIdentity() bool unavailable = false; // no usable secret — stop retrying this session rpc::RPCClient::TraceScope trace("HushChat / identity"); try { - auto response = rpc_->call("z_exportmnemonic"); + auto response = rpc_->callSecret("z_exportmnemonic"); // zero the raw body too (B7) if (response.contains("mnemonic") && response["mnemonic"].is_string()) { // Scrub the json's own copy of the seed after taking ours (the rest of the RPC // response chain is unmanaged — a fuller fix belongs in the RPC layer). @@ -2774,7 +2774,7 @@ void App::maybeProvisionChatIdentity() try { // Mirror the mnemonic path: take our copy, then scrub the json's own copy of the // spending key so it isn't left in freed heap (B4). z_exportkey returns a bare string. - auto keyResp = rpc_->call("z_exportkey", {fallbackZaddr}); + auto keyResp = rpc_->callSecret("z_exportkey", {fallbackZaddr}); // zero the raw body too (B7) if (keyResp.is_string()) { auto& key = keyResp.get_ref(); secret = key; @@ -3374,7 +3374,7 @@ void App::exportSeedPhrase(std::functioncall("z_exportmnemonic"); + auto response = rpc_->callSecret("z_exportmnemonic"); // zero the raw body too (B7) if (response.contains("mnemonic") && response["mnemonic"].is_string()) { auto& m = response["mnemonic"].get_ref(); phrase = m; diff --git a/src/daemon/seed_wallet_creator.cpp b/src/daemon/seed_wallet_creator.cpp index bcad883..9d0d992 100644 --- a/src/daemon/seed_wallet_creator.cpp +++ b/src/daemon/seed_wallet_creator.cpp @@ -110,7 +110,7 @@ SeedWalletResult SeedWalletCreator::create(bool keepDatadir, // 6. Export the new seed phrase + a fresh shielded receive address (the future sweep target). try { - auto m = cli.call("z_exportmnemonic"); + auto m = cli.callSecret("z_exportmnemonic"); // zero the raw body too (B7) if (m.contains("mnemonic") && m["mnemonic"].is_string()) r.seedPhrase = m["mnemonic"].get(); r.destAddress = cli.call("z_getnewaddress").get(); diff --git a/src/rpc/rpc_client.cpp b/src/rpc/rpc_client.cpp index 0aa931a..ee9df39 100644 --- a/src/rpc/rpc_client.cpp +++ b/src/rpc/rpc_client.cpp @@ -370,6 +370,27 @@ json RPCClient::call(const std::string& method, const json& params) return parseRpcResult(http_code, response_data); } +json RPCClient::callSecret(const std::string& method, const json& params) +{ + std::lock_guard lk(curl_mutex_); + if (!impl_->curl) { + throw std::runtime_error("Not connected"); + } + + // Zero the raw response body whether parsing succeeds or throws — it holds the secret in the + // clear (the curl write buffer performCall returns), and would otherwise be freed un-wiped (B7). + long http_code = 0; + std::string response_data = performCall(method, params, http_code); + try { + json result = parseRpcResult(http_code, response_data); + if (!response_data.empty()) sodium_memzero(&response_data[0], response_data.size()); + return result; + } catch (...) { + if (!response_data.empty()) sodium_memzero(&response_data[0], response_data.size()); + throw; + } +} + json RPCClient::call(const std::string& method, const json& params, long timeoutSec) { std::lock_guard lk(curl_mutex_); diff --git a/src/rpc/rpc_client.h b/src/rpc/rpc_client.h index e5d588d..a1335fc 100644 --- a/src/rpc/rpc_client.h +++ b/src/rpc/rpc_client.h @@ -133,6 +133,17 @@ public: */ json call(const std::string& method, const json& params = json::array()); + /** + * @brief Like call(), but zeroes the raw HTTP response body after parsing (B7). + * + * For RPCs whose response carries a secret (z_exportmnemonic / z_exportkey), the parsed + * value is scrubbed by the caller — but the raw response string this method builds also + * holds that secret and is otherwise freed without zeroing. Use this variant so the largest, + * longest-lived plaintext copy doesn't linger in freed heap. Slightly slower (an extra wipe); + * only worth it for secret-bearing calls. + */ + json callSecret(const std::string& method, const json& params = json::array()); + /** * @brief Make a raw RPC call with a custom timeout * @param method RPC method name diff --git a/src/ui/windows/export_all_keys_dialog.cpp b/src/ui/windows/export_all_keys_dialog.cpp index 1821bb8..254f335 100644 --- a/src/ui/windows/export_all_keys_dialog.cpp +++ b/src/ui/windows/export_all_keys_dialog.cpp @@ -180,7 +180,7 @@ void ExportAllKeysDialog::render(App* app) for (const auto& addr : z_addrs) { try { rpc::RPCClient::TraceScope trace("Settings / Export all keys"); - auto result = rpc->call("z_exportkey", {addr}); + auto result = rpc->callSecret("z_exportkey", {addr}); // zero the raw body too (B7) if (result.is_string()) { keys += "# Address: " + addr + "\n"; keys += result.get() + "\n\n"; @@ -196,7 +196,7 @@ void ExportAllKeysDialog::render(App* app) for (const auto& addr : t_addrs) { try { rpc::RPCClient::TraceScope trace("Settings / Export all keys"); - auto result = rpc->call("dumpprivkey", {addr}); + auto result = rpc->callSecret("dumpprivkey", {addr}); // zero the raw body too (B7) if (result.is_string()) { keys += "# Address: " + addr + "\n"; keys += result.get() + "\n\n";