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:
@@ -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();
|
||||
|
||||
@@ -160,10 +160,10 @@ const ConsoleCommandEntry kWalletCommands[] = {
|
||||
"z_sendmany \"RfromAddr\" [{\"address\":\"zs1toAddr\",\"amount\":1.0}]", "send pay private shielded transfer money", true},
|
||||
{"z_shieldcoinbase", "Shield transparent coinbase funds to a z-address", "\"fromaddress\" \"tozaddress\" [fee] [limit]",
|
||||
"Moves newly mined (coinbase) transparent funds into a private shielded z-address, since mined rewards must be shielded before they can be spent normally. Runs in the background and returns an operation id.",
|
||||
"z_shieldcoinbase \"RyourMiningAddr\" \"zs1yourShieldedAddr\"", "shield mining rewards coinbase private hide mined funds move to shielded"},
|
||||
"z_shieldcoinbase \"RyourMiningAddr\" \"zs1yourShieldedAddr\"", "shield mining rewards coinbase private hide mined funds move to shielded", true},
|
||||
{"z_mergetoaddress", "Merge multiple UTXOs/notes to one address", "[\"fromaddress\",...] \"toaddress\" [fee] [limit]",
|
||||
"Combines many small balances (from transparent and/or shielded addresses) into a single destination address in one transaction, to consolidate funds. Runs in the background and returns an operation id.",
|
||||
"z_mergetoaddress [\"RyourAddr\",\"zs1yourShieldedAddr\"] \"zs1destShieldedAddr\"", "merge combine consolidate funds sweep small balances into one address"},
|
||||
"z_mergetoaddress [\"RyourAddr\",\"zs1yourShieldedAddr\"] \"zs1destShieldedAddr\"", "merge combine consolidate funds sweep small balances into one address", true},
|
||||
{"listtransactions", "List recent wallet transactions", "[\"account\"] [count] [from]",
|
||||
"Your most recent wallet transactions, newest first \xE2\x80\x94 amounts, addresses and confirmations.",
|
||||
"listtransactions", "transactions history recent payments received sent"},
|
||||
|
||||
@@ -1406,10 +1406,13 @@ void ConsoleTab::renderInput(ConsoleCommandExecutor& exec)
|
||||
ImGui::PopItemWidth();
|
||||
ImGui::PopFont();
|
||||
|
||||
// Auto-focus on input
|
||||
if (reclaim_focus) {
|
||||
// Auto-focus on input — after submitting a command (reclaim), or once when the Console tab is opened
|
||||
// (focus_input_pending_, set by requestInputFocus() and gated on the console_auto_focus setting).
|
||||
// Skip while a command is running: SetKeyboardFocusHere can't focus the disabled field anyway.
|
||||
if ((reclaim_focus || focus_input_pending_) && !busy) {
|
||||
ImGui::SetKeyboardFocusHere(-1);
|
||||
}
|
||||
focus_input_pending_ = false;
|
||||
}
|
||||
|
||||
bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::string& cmd)
|
||||
|
||||
@@ -65,6 +65,10 @@ public:
|
||||
*/
|
||||
void clear();
|
||||
|
||||
// Ask the console to place the keyboard focus in the command input on the next render (consumed once).
|
||||
// Called when the user switches to the Console tab, gated by the console_auto_focus setting.
|
||||
void requestInputFocus() { focus_input_pending_ = true; }
|
||||
|
||||
// Scanline effect toggle (set from settings)
|
||||
static bool s_scanline_enabled;
|
||||
|
||||
@@ -156,6 +160,7 @@ private:
|
||||
int history_index_ = -1;
|
||||
char input_buffer_[4096] = {0};
|
||||
bool stop_confirm_pending_ = false; // 'stop' typed once, awaiting a confirming second 'stop'
|
||||
bool focus_input_pending_ = false; // one-shot: focus the command input next render (tab-open auto-focus)
|
||||
// (log-ingestion cursors + result queue moved to the ConsoleCommandExecutor)
|
||||
|
||||
// Auto-scroll state machine (pin-to-bottom, wheel-up cooldown, new-line backlog count).
|
||||
|
||||
@@ -160,6 +160,20 @@ static bool IsValidTransparentAddr(const char* a) {
|
||||
return a && dragonx::util::isTransparentAddress(a);
|
||||
}
|
||||
|
||||
// Source-address balance caption for the "sending from" dropdown: the TOTAL balance is the headline (so
|
||||
// it matches the Overview figure and a source whose change is still confirming never looks like it lost
|
||||
// funds), with the confirmed-spendable amount shown as a smaller "(N available)" note ONLY when a pending
|
||||
// send/receive makes it differ. The Max button + validation still cap spends at the spendable amount.
|
||||
static std::string FormatSourceBalance(double total, double spendable) {
|
||||
char b[128];
|
||||
if (total - spendable > 1e-9)
|
||||
snprintf(b, sizeof(b), "%.8f %s (%.8f %s)", total, DRAGONX_TICKER,
|
||||
spendable, TR("send_available_note"));
|
||||
else
|
||||
snprintf(b, sizeof(b), "%.8f %s", total, DRAGONX_TICKER);
|
||||
return b;
|
||||
}
|
||||
|
||||
static std::string timeAgo(int64_t timestamp) {
|
||||
return dragonx::util::formatTimeAgoShort(timestamp);
|
||||
}
|
||||
@@ -253,8 +267,9 @@ static void RenderSourceDropdown(App* app, float width) {
|
||||
const char* tag = isZ ? "[Z]" : "[T]";
|
||||
std::string trunc = util::truncateMiddle(addr.address,
|
||||
static_cast<int>(std::max(S.drawElement("tabs.send", "addr-preview-trunc-min").size, width / S.drawElement("tabs.send", "addr-preview-trunc-divisor").size)));
|
||||
snprintf(buf, sizeof(buf), "%s %s — %.8f %s",
|
||||
tag, trunc.c_str(), addr.spendableBalance, DRAGONX_TICKER);
|
||||
snprintf(buf, sizeof(buf), "%s %s — %s",
|
||||
tag, trunc.c_str(),
|
||||
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
|
||||
s_source_preview = buf;
|
||||
} else {
|
||||
s_source_preview = TR("send_select_source");
|
||||
@@ -267,8 +282,17 @@ static void RenderSourceDropdown(App* app, float width) {
|
||||
if (!app->isConnected() || state.addresses.empty()) {
|
||||
ImGui::TextDisabled("%s", TR("no_addresses_available"));
|
||||
} else {
|
||||
// Sort by balance descending, only show spendable addresses with balance
|
||||
std::vector<size_t> sortedIdx = sortedSpendableAddressIndices(state.addresses);
|
||||
// List every address that HOLDS a balance (confirmed or still-confirming), sorted by total
|
||||
// descending — so a source whose change is pending stays visible with its real total instead
|
||||
// of vanishing. The spend gates (Max / validation) below still cap at the spendable amount.
|
||||
std::vector<size_t> sortedIdx;
|
||||
sortedIdx.reserve(state.addresses.size());
|
||||
for (size_t i = 0; i < state.addresses.size(); ++i)
|
||||
if (state.addresses[i].isSpendable() && state.addresses[i].balance > 0.0)
|
||||
sortedIdx.push_back(i);
|
||||
std::sort(sortedIdx.begin(), sortedIdx.end(), [&](size_t a, size_t b) {
|
||||
return state.addresses[a].balance > state.addresses[b].balance;
|
||||
});
|
||||
|
||||
if (sortedIdx.empty()) {
|
||||
ImGui::TextDisabled("%s", TR("send_no_balance"));
|
||||
@@ -283,8 +307,9 @@ static void RenderSourceDropdown(App* app, float width) {
|
||||
const char* tag = isZ ? "[Z]" : "[T]";
|
||||
|
||||
std::string trunc = util::truncateMiddle(addr.address, (int)addrTruncLen);
|
||||
snprintf(buf, sizeof(buf), "%s %s — %.8f %s",
|
||||
tag, trunc.c_str(), addr.spendableBalance, DRAGONX_TICKER);
|
||||
snprintf(buf, sizeof(buf), "%s %s — %s",
|
||||
tag, trunc.c_str(),
|
||||
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
|
||||
|
||||
ImGui::PushID(static_cast<int>(i));
|
||||
if (ImGui::Selectable(buf, isCurrent)) {
|
||||
@@ -293,8 +318,9 @@ static void RenderSourceDropdown(App* app, float width) {
|
||||
addr.address.c_str());
|
||||
}
|
||||
if (ImGui::IsItemHovered()) {
|
||||
material::Tooltip("%s\nBalance: %.8f %s",
|
||||
addr.address.c_str(), addr.spendableBalance, DRAGONX_TICKER);
|
||||
material::Tooltip("%s\n%s",
|
||||
addr.address.c_str(),
|
||||
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
|
||||
}
|
||||
ImGui::PopID();
|
||||
}
|
||||
|
||||
@@ -594,8 +594,9 @@ private:
|
||||
// Each external wallet gets its own STABLE link name derived from its path — so switching between two
|
||||
// of them is a real -wallet=<different name> switch (not a no-op on one shared name), and the wallet
|
||||
// index tracks each separately (correct per-wallet rescan + cached data). Hidden from the list.
|
||||
static constexpr const char* kLinkPrefix = "wallet-ip-";
|
||||
static bool isLinkName(const std::string& n) { return n.rfind(kLinkPrefix, 0) == 0; }
|
||||
// Single source of truth lives in util/wallet_file_probe.h (shared with the offline enumeration helper).
|
||||
static constexpr const char* kLinkPrefix = util::kInPlaceLinkPrefix;
|
||||
static bool isLinkName(const std::string& n) { return util::isInPlaceLinkName(n); }
|
||||
|
||||
// Stable per-target bare link name, e.g. "wallet-ip-1a2b3c4d.dat" (FNV-1a of the absolute path — a
|
||||
// deterministic, cross-platform, cross-run hash, unlike std::hash). Feed it a canonicalOf() path so the
|
||||
|
||||
Reference in New Issue
Block a user