fix(chat,rpc): address adversarial review of the chat backlog

Four confirmed findings from the review pass:

SECURITY — B7 was incomplete:
- The single-key Export dialog (key_export_dialog) still used plain call() for
  z_exportkey/dumpprivkey/z_exportviewingkey — a live spending-key leak on the
  most common per-address export path, missed by the B7 commit.
- callSecret() zeros the raw body but the parsed json holds its OWN heap copy of
  the secret; several callers did .get<string>() on a temporary json and freed
  that copy un-wiped.
  Fix: add RPCClient::callSecretString() — returns the bare-string result with
  BOTH the raw body AND the json node zeroed, so callers can't forget. Route
  key_export_dialog (×2), exportPrivateKey, and export_all_keys (×2) through it;
  scrub the z_exportmnemonic json node in seed_wallet_creator (object result);
  also wipe the transient key copies, the displayed s_key on reset, and the
  aggregated export-all `keys` buffer.

CHAT:
- Jump-to-latest pill: SetCursorScreenPos moved the parent cursor and never
  restored it, so the composer footer rendered ~8px too high while scrolled up.
  Save + restore the cursor around the pill.
- New-message toast: gating on a chatUnreadCount() watermark delta could be
  swallowed when an outgoing echo (wall-clock) pushed the seen-watermark past a
  later reply's block time. ingest() now reports the cids it appended; the toast
  fires when any is a non-muted conversation — skew-proof, still mute-aware.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-16 17:11:31 -05:00
parent 9cb91415d9
commit fac0245297
9 changed files with 99 additions and 38 deletions

View File

@@ -525,14 +525,18 @@ void RenderChatTab(App* app)
atBottom = ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 4.0f;
}
ImGui::EndChild();
// Jump-to-latest pill when scrolled up (Q9).
// Jump-to-latest pill when scrolled up (Q9). SetCursorScreenPos moves the parent cursor to
// the child's bottom edge; save + restore it so the composer footer below stays anchored
// (otherwise it renders ~8px too high only while the pill is shown).
if (!atBottom) {
const ImVec2 savedCursor = ImGui::GetCursorScreenPos();
const ImVec2 pill(msgWinMin.x + msgWinSize.x - 84.0f * dp, msgWinMin.y + msgWinSize.y - 34.0f * dp);
ImGui::SetCursorScreenPos(pill);
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 220)));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Primary()));
if (ImGui::Button(TR("chat_jump_latest"), ImVec2(74.0f * dp, 26.0f * dp))) s_scroll_to_cid = sel->cid;
ImGui::PopStyleColor(2);
ImGui::SetCursorScreenPos(savedCursor);
}
// Composer footer: message input + send (only once we know the peer's key), else a hint.

View File

@@ -4,6 +4,7 @@
#include "export_all_keys_dialog.h"
#include "../../app.h"
#include <sodium.h> // sodium_memzero — wipe exported key material from memory (B7)
#include "../../rpc/rpc_client.h"
#include "../../rpc/rpc_worker.h"
#include "../../util/i18n.h"
@@ -180,13 +181,12 @@ void ExportAllKeysDialog::render(App* app)
for (const auto& addr : z_addrs) {
try {
rpc::RPCClient::TraceScope trace("Settings / Export all keys");
auto result = rpc->callSecret("z_exportkey", {addr}); // zero the raw body too (B7)
if (result.is_string()) {
keys += "# Address: " + addr + "\n";
keys += result.get<std::string>() + "\n\n";
exported++; // count only real successes (locked/failed keys don't count)
}
} catch (...) {}
std::string k = rpc->callSecretString("z_exportkey", {addr}); // scrubs raw body + json node (B7)
keys += "# Address: " + addr + "\n";
keys += k + "\n\n";
exported++; // count only real successes (locked/failed keys don't count)
if (!k.empty()) sodium_memzero(&k[0], k.size()); // wipe the transient copy
} catch (...) {} // non-string / locked key → skip (not counted)
}
}
@@ -196,13 +196,12 @@ void ExportAllKeysDialog::render(App* app)
for (const auto& addr : t_addrs) {
try {
rpc::RPCClient::TraceScope trace("Settings / Export all keys");
auto result = rpc->callSecret("dumpprivkey", {addr}); // zero the raw body too (B7)
if (result.is_string()) {
keys += "# Address: " + addr + "\n";
keys += result.get<std::string>() + "\n\n";
exported++; // count only real successes (locked/failed keys don't count)
}
} catch (...) {}
std::string k = rpc->callSecretString("dumpprivkey", {addr}); // scrubs raw body + json node (B7)
keys += "# Address: " + addr + "\n";
keys += k + "\n\n";
exported++; // count only real successes (locked/failed keys don't count)
if (!k.empty()) sodium_memzero(&k[0], k.size()); // wipe the transient copy
} catch (...) {} // non-string / locked key → skip (not counted)
}
}
@@ -219,6 +218,7 @@ void ExportAllKeysDialog::render(App* app)
writeOk = true;
}
}
if (!keys.empty()) sodium_memzero(&keys[0], keys.size()); // don't leave every key in freed heap
return [exported, total, filepath, writeOk]() {
s_exported_count = exported;

View File

@@ -5,6 +5,7 @@
#include "key_export_dialog.h"
#include "../../app.h"
#include "../../wallet/lite_wallet_controller.h"
#include <sodium.h> // sodium_memzero — wipe transient secret copies (B7)
#include <nlohmann/json.hpp>
#include "../../rpc/rpc_client.h"
#include "../../rpc/rpc_worker.h"
@@ -49,7 +50,7 @@ void KeyExportDialog::show(const std::string& address, KeyType type)
releaseQr();
s_key_type = type;
s_address = address;
s_key.clear();
wallet::secureWipeLiteSecret(s_key); // zero any prior secret before reuse
s_error.clear();
}
@@ -62,7 +63,7 @@ void KeyExportDialog::hide()
{
s_open = false;
s_fetching = false;
s_key.clear();
wallet::secureWipeLiteSecret(s_key); // zero the displayed secret, don't just drop the buffer
s_show_key = false;
s_error.clear();
releaseQr();
@@ -187,18 +188,18 @@ void KeyExportDialog::render(App* app)
std::string error;
try {
rpc::RPCClient::TraceScope trace("Settings / Export key");
auto result = rpc->call(method, {addr});
key = result.get<std::string>();
key = rpc->callSecretString(method, {addr}); // scrubs raw body + json node (B7)
} catch (const std::exception& e) {
error = e.what();
}
return [key, error]() {
return [key = std::move(key), error]() mutable {
if (error.empty()) {
s_key = key;
s_show_key = false; // Don't show by default
} else {
s_error = error;
}
if (!key.empty()) sodium_memzero(&key[0], key.size()); // wipe the last transient copy
s_fetching = false;
};
});
@@ -213,18 +214,18 @@ void KeyExportDialog::render(App* app)
std::string error;
try {
rpc::RPCClient::TraceScope trace("Settings / Export viewing key");
auto result = rpc->call("z_exportviewingkey", {addr});
key = result.get<std::string>();
key = rpc->callSecretString("z_exportviewingkey", {addr}); // scrubs raw body + json node (B7)
} catch (const std::exception& e) {
error = e.what();
}
return [key, error]() {
return [key = std::move(key), error]() mutable {
if (error.empty()) {
s_key = key;
s_show_key = true; // Viewing keys are less sensitive
} else {
s_error = error;
}
if (!key.empty()) sodium_memzero(&key[0], key.size()); // wipe the last transient copy
s_fetching = false;
};
});