fix: security-audit remediation (15 findings), empty-wallet warning, and send/chat/console/shutdown UX

Security audit remediation (15 confirmed findings from the codebase audit):
- H-02: scrub+delete the decrypt-flow plaintext key export on ALL exit paths
  (RAII guard) and purge stale obsidiandecryptexport* files at startup.
- M-01/L-03/L-04/L-05/L-07: sodium_memzero the Set-PIN and encrypt-PIN worker
  passphrase/PIN copies, the RPC Basic-auth string (auth_), the exported/imported
  key buffers (App::wipeSecrets, called from ~App and before main's _Exit), and
  the first-run wizard "Skip" buffers.
- M-03/M-04/M-05/L-06: return locked COPIES from XmrigManager/EmbeddedDaemon
  getters (dedicated error_mutex_; DaemonController::lastError now by value),
  route xmrig last_error_ writes through a locked setter, and wrap
  shutdown_status_/wizard_stop_status_ in a locking GuardedStatus
  (wizard_stopping_external_ -> std::atomic).
- M-02: persist after a console send/shield/import in the lite backend.
- L-01: require the confirm click for z_shieldcoinbase/z_mergetoaddress.
- L-02: quote/escape each Windows daemon argv per the MSDN CommandLineToArgvW rules.
- L-08: pin json/tomlplusplus/libwebp FetchContent to immutable commit SHAs.
- I-01: extract updater archives from the already-verified in-memory buffer
  (no disk re-read TOCTOU).

Feature: warn once (full-node) when the active wallet loads empty while a sibling
wallet file in the datadir holds keys. A funded salvage wallet.<ts>.bak routes to
the recovery/Restore flow; a funded sibling .dat routes to the wallet manager.
Per-wallet-file dismissal; gated on synced + address-list-loaded to avoid false
positives on warm reconnect / spent-down wallets.

UX fixes:
- send: show the TOTAL balance (with a spendable "available" note) in the source
  dropdown and keep pending-change addresses visible.
- chat: insert emoji at the cursor position; restrict new-chat recipients to
  shielded (z) addresses.
- console: optional auto-focus of the command input on tab open (off by default).
- shutdown: when "stop external daemon" is on, keep the shutdown screen up until
  the external node actually exits, showing live status.

Adversarially reviewed; verified across full-node, lite, and Windows builds; tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-18 13:18:49 -05:00
co-authored by Claude Opus 4.8
parent ea26c0cbbb
commit 6ee81a5abe
40 changed files with 745 additions and 90 deletions
+36 -7
View File
@@ -10,6 +10,7 @@
#include "../../data/address_book.h"
#include "../../chat/chat_service.h"
#include "../../util/i18n.h"
#include "../../util/address_validation.h" // isShieldedAddress — chat requires a z-address recipient
#include "../../util/platform.h" // getConfigDir + writeFileAtomically — conversation export (Q11)
#include "../../config/settings.h" // per-conversation mute (Q10)
#include "../material/colors.h"
@@ -59,6 +60,10 @@ bool s_msgsel_dragging = false;
// Composer + new-conversation UI state.
char s_compose[512] = "";
std::string s_compose_cid; // the conversation s_compose is a draft for; draft is wiped when it changes
// Live byte offset of the composer's text caret, kept in sync by composeInputCallback while the composer
// is active (the callback only fires then). The emoji picker uses it to splice a glyph at the cursor
// instead of always appending. -1 = unknown/never-focused => append at the end.
int s_composeCursor = -1;
// On-chain chat body cap in bytes = (512 − len("utf8:"))/2 − secretstream ABYTES (see chat_outgoing.cpp).
// The composer hard-caps input to this; the emoji picker respects it too.
constexpr int kChatBodyMaxBytes = (512 - 5) / 2 - 17; // = 236
@@ -70,11 +75,16 @@ float s_composerTargetH = 0.0f; // target height measured in the composer block
// neither the plain-Enter (submit) nor the Ctrl+Enter shortcut — ImGui does nothing with it. We insert the
// newline ourselves here (running under CallbackAlways), respecting the on-chain byte cap.
int composeInputCallback(ImGuiInputTextCallbackData* data) {
// Track the live caret so the emoji picker can insert at the cursor. This callback runs under
// CallbackAlways, which ImGui only invokes while the field is active — so when the composer loses
// focus (e.g. to the emoji picker) s_composeCursor keeps the last edit position.
s_composeCursor = data->CursorPos;
ImGuiIO& io = ImGui::GetIO();
if (io.KeyShift
&& (ImGui::IsKeyPressed(ImGuiKey_Enter) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter))
&& data->BufTextLen < kChatBodyMaxBytes) {
data->InsertChars(data->CursorPos, "\n");
s_composeCursor = data->CursorPos; // InsertChars advanced the caret past the newline
}
return 0;
}
@@ -625,16 +635,22 @@ static const EmojiEntry kEmoji[] = {
// Emoji picker overlay: fills the conversation-list pane (cancel + keyword search at the top, then a
// grid). Clicking an emoji appends its UTF-8 bytes to `buf` (the composer), respecting the buffer.
void renderEmojiPickerOverlay(char* buf, std::size_t bufSize, ImTextureID drgxTex) {
// Insert a token (emoji glyph or the ":drgx:" shortcode) at the end of the draft, prepending a space
// when the draft isn't empty and doesn't already end in whitespace. Respects the on-chain byte cap.
// Insert a token (emoji glyph or the ":drgx:" shortcode) at the composer's caret (s_composeCursor,
// kept live by composeInputCallback; -1 => end of draft), prepending a space when the char before the
// caret is a non-space word char so the emoji doesn't fuse onto it. Respects the on-chain byte cap.
// The composer is inactive whenever the picker is open, so it renders straight from buf — splicing
// here shows immediately.
auto insertToken = [&](const char* tok) {
const std::size_t cur = std::strlen(buf), add = std::strlen(tok);
const bool needsSpace = cur > 0 && static_cast<unsigned char>(buf[cur - 1]) > ' ';
const std::size_t pos = (s_composeCursor < 0)
? cur : std::min(static_cast<std::size_t>(s_composeCursor), cur);
const bool needsSpace = pos > 0 && static_cast<unsigned char>(buf[pos - 1]) > ' ';
const std::size_t pad = needsSpace ? 1 : 0;
if (cur + pad + add <= static_cast<std::size_t>(kChatBodyMaxBytes) && cur + pad + add < bufSize) {
if (needsSpace) buf[cur] = ' ';
std::memcpy(buf + cur + pad, tok, add);
buf[cur + pad + add] = '\0';
std::memmove(buf + pos + pad + add, buf + pos, (cur - pos) + 1); // shift tail right (incl NUL)
if (needsSpace) buf[pos] = ' ';
std::memcpy(buf + pos + pad, tok, add);
s_composeCursor = static_cast<int>(pos + pad + add); // keep the caret after the inserted token
}
};
if (ImGui::SmallButton(TR("chat_cancel"))) { s_show_emoji_picker = false; s_emoji_search[0] = '\0'; return; }
@@ -747,6 +763,7 @@ void RenderChatTab(App* app)
// for one contact can't be sent to another (B5).
if (s_selected_cid != s_compose_cid) {
sodium_memzero(s_compose, sizeof(s_compose));
s_composeCursor = -1; // fresh draft — next emoji appends until the caret is known again
s_compose_cid = s_selected_cid;
s_composerAnimH = 0.0f; // re-arm the first-frame snap so the box doesn't animate-collapse on switch
}
@@ -1705,6 +1722,7 @@ void RenderChatTab(App* app)
if (submit && s_compose[0] != '\0' && !overCap) {
app->sendChatMessage(sel->cid, s_compose);
sodium_memzero(s_compose, sizeof(s_compose));
s_composeCursor = -1;
s_scroll_to_cid = sel->cid;
s_composerAnimH = 0.0f; // snap back to collapsed instead of animating while unfocused
// Sending closes the emoji picker (it takes over the conversation-list pane) so the list
@@ -1726,6 +1744,17 @@ void RenderChatTab(App* app)
if (material::BeginOverlayDialog(ov)) {
const float fieldW = ImGui::GetContentRegionAvail().x;
material::LabeledInput(TR("chat_new_zaddr"), "##newz", s_new_zaddr, sizeof(s_new_zaddr), fieldW);
// Chat rides on encrypted memos, which only shielded (z) addresses carry — a transparent (t)
// address can't receive one. Contacts can hold t-addresses, so guard the manual field too:
// warn when the entry isn't a valid z-address and keep Send disabled below.
const bool newAddrIsZ = dragonx::util::isShieldedAddress(s_new_zaddr);
if (s_new_zaddr[0] != '\0' && !newAddrIsZ) {
ImGui::PushStyleColor(ImGuiCol_Text, material::Warning());
ImGui::PushTextWrapPos(0.0f);
ImGui::TextUnformatted(TR("chat_new_needs_zaddr"));
ImGui::PopTextWrapPos();
ImGui::PopStyleColor();
}
// Or pick from contacts — chat needs a shielded z-address, so only z-addr contacts are listed.
// Selecting one fills the field above (manual paste still works).
ImGui::SetNextItemWidth(fieldW);
@@ -1757,7 +1786,7 @@ void RenderChatTab(App* app)
material::LabeledInput(TR("chat_new_message"), "##newm", s_new_msg, sizeof(s_new_msg), fieldW);
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
const bool canSend = s_new_zaddr[0] != '\0' && s_new_msg[0] != '\0';
const bool canSend = newAddrIsZ && s_new_msg[0] != '\0';
const float actionW = std::max(130.0f * dp,
ImGui::CalcTextSize(TR("chat_new_send")).x + ImGui::GetStyle().FramePadding.x * 2.0f + 24.0f * dp);
const float actionGap = Layout::spacingSm();