feat(settings): migrate Export Private Key modal + fix outside-click secret leak

Phase 2 (first security modal), presentation-only + the review's one finding.

Migration: struct-form BlurFloat overlay (was legacy positional); full i18n
(reusing export_private_key / key_export_private_warning / key_export_private_key /
select_address / key_export_fetching / export / copy / close, plus one new
key_export_failed); DialogWarningHeader; centered DialogActionFooter (Export +
Close) + a Copy TactileButton; async UX (export_in_progress_ spinner, export_error_
line). The exported key stays in the char[256] buffer shown via a read-only
InputTextMultiline (deliberately NOT AddressCopyField, which would copy the secret
into a lingering std::string). exportPrivateKey is untouched.

Security fix (found by the adversarial review): the 0a "outside-click/Esc wipe" via
the Begin-returns-false early-return was UNREACHABLE — BeginOverlayDialog clears
show_export_key_ mid-frame but still returns true, so an outside-click/Esc dismiss
left the exported spending key un-wiped in export_result_. Add the in-body
`if (!show_export_key_)` teardown before EndOverlayDialog (the proven import-dialog
pattern), which is the real wipe point for every dismiss path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-13 23:47:11 -05:00
parent 63d5c817aa
commit 8a30578872
11 changed files with 82 additions and 53 deletions

View File

@@ -3192,81 +3192,99 @@ void App::renderImportKeyDialog()
void App::renderExportKeyDialog()
{
auto dlg = ui::schema::UI().drawElement("inline-dialogs", "export-key");
auto dlgF = [&](const char* key, float fb) -> float {
auto it = dlg.extraFloats.find(key);
return it != dlg.extraFloats.end() ? it->second : fb;
};
int btnFont = (int)dlgF("button-font", 1);
if (!ui::material::BeginOverlayDialog("Export Private Key", &show_export_key_, dlgF("width", 600.0f), 0.94f)) {
// Closed via outside-click / Esc (the overload flips show_export_key_ then returns false here,
// so this is the only wipe point for that path) — scrub the exported secret.
namespace m = ui::material;
float dp = ui::Layout::dpiScale();
m::OverlayDialogSpec ov;
ov.title = TR("export_private_key");
ov.p_open = &show_export_key_;
ov.style = m::OverlayStyle::BlurFloat;
ov.cardWidth = 600.0f;
ov.idSuffix = "exportkey";
if (!m::BeginOverlayDialog(ov)) {
// Defensive scrub for a frame where the overlay renders nothing (Begin returns false). The
// actual dismiss-path wipe (Close / outside-click / Esc) is the in-body block near EndOverlayDialog.
sodium_memzero(export_result_, sizeof(export_result_));
export_address_.clear();
export_in_progress_ = false;
export_error_ = false;
return;
}
ImGui::TextColored(ImVec4(0.9f, 0.4f, 0.4f, 1.0f),
"WARNING: Anyone with this key can spend your coins!");
m::DialogWarningHeader(TR("key_export_private_warning"));
ImGui::Spacing();
// Address selector
ImGui::Text("Select Address:");
// Address selector (a legitimate need here, unlike the import dialog).
std::vector<std::string> all_addrs;
for (const auto& a : state_.t_addresses) all_addrs.push_back(a.address);
for (const auto& a : state_.z_addresses) all_addrs.push_back(a.address);
int addrFrontLen = (int)dlgF("addr-front-len", 20);
int addrBackLen = (int)dlgF("addr-back-len", 8);
if (ImGui::BeginCombo("##exportaddr", export_address_.empty() ? "Select address..." : export_address_.c_str())) {
ImGui::SetNextItemWidth(-1);
const std::string comboLabel = export_address_.empty() ? std::string(TR("select_address"))
: util::truncateMiddle(export_address_, 24, 10);
if (ImGui::BeginCombo("##exportaddr", comboLabel.c_str())) {
for (const auto& addr : all_addrs) {
bool selected = (export_address_ == addr);
std::string display = util::truncateMiddle(addr, addrFrontLen, addrBackLen);
if (ImGui::Selectable(display.c_str(), selected)) {
const bool selected = (export_address_ == addr);
if (ImGui::Selectable(util::truncateMiddle(addr, 28, 12).c_str(), selected)) {
export_address_ = addr;
sodium_memzero(export_result_, sizeof(export_result_));
sodium_memzero(export_result_, sizeof(export_result_)); // new address → old key stale
export_error_ = false;
}
}
ImGui::EndCombo();
}
ImGui::SameLine();
if (ui::material::StyledButton("Export", ImVec2(0, 0), ui::material::resolveButtonFont(btnFont))) {
if (!export_address_.empty()) {
exportPrivateKey(export_address_, [this](const std::string& key) {
// Don't populate the secret buffer if the dialog was closed before the async
// export returned (it would otherwise linger un-wiped while the dialog is shut).
if (show_export_key_)
snprintf(export_result_, sizeof(export_result_), "%s", key.c_str());
});
}
}
ImGui::Spacing();
if (export_result_[0]) {
ImGui::Text("Private Key:");
// Progress / result / error. The secret key is shown in a read-only char buffer (NOT
// widgets::AddressCopyField, which would copy it into a lingering std::string) + a Copy button.
if (export_in_progress_) {
ImGui::Spacing();
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::Primary()), "%s%s",
TR("key_export_fetching"), m::LoadingDots());
} else if (export_result_[0]) {
ImGui::Spacing();
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("key_export_private_key"));
ImGui::InputTextMultiline("##exportresult", export_result_, sizeof(export_result_),
ImVec2(-1, dlgF("key-display-height", 60.0f)), ImGuiInputTextFlags_ReadOnly);
if (ui::material::StyledButton("Copy to Clipboard", ImVec2(0, 0), ui::material::resolveButtonFont(btnFont))) {
ImVec2(-1, 60.0f * dp), ImGuiInputTextFlags_ReadOnly);
if (m::TactileButton(TR("copy")))
ImGui::SetClipboardText(export_result_);
}
} else if (export_error_) {
ImGui::Spacing();
ImGui::PushTextWrapPos(0.0f);
ImGui::TextColored(ImVec4(0.8f, 0.3f, 0.3f, 1.0f), "%s", TR("key_export_failed"));
ImGui::PopTextWrapPos();
}
ImGui::Spacing();
ImGui::Separator();
int closeBtnFont = (int)dlgF("close-button-font", -1);
if (closeBtnFont < 0) closeBtnFont = btnFont;
if (ui::material::StyledButton("Close", ImVec2(dlgF("close-button-width", 120.0f), 0), ui::material::resolveButtonFont(closeBtnFont))) {
show_export_key_ = false;
bool doExport = false, doClose = false;
const bool canExport = !export_address_.empty() && !export_in_progress_;
m::DialogActionFooter(TR("export"), canExport, TR("close"), doExport, doClose);
if (doClose) show_export_key_ = false;
if (doExport) {
export_in_progress_ = true;
export_error_ = false;
sodium_memzero(export_result_, sizeof(export_result_));
exportPrivateKey(export_address_, [this](const std::string& key) {
// Don't populate the secret buffer if the dialog was closed before the async export
// returned (it would otherwise linger un-wiped while the dialog is shut).
export_in_progress_ = false;
if (!show_export_key_) return;
if (key.empty()) export_error_ = true;
else snprintf(export_result_, sizeof(export_result_), "%s", key.c_str());
});
}
// Scrub the secret whenever the dialog closes THIS frame — Close button OR outside-click/Esc, which
// BeginOverlayDialog clears mid-frame while still returning true (so the early-return above is not
// reached on that path). This in-body teardown mirrors the import dialog and is the actual wipe
// point for the dismiss paths; the early-return only covers a Begin-returns-false frame.
if (!show_export_key_) {
sodium_memzero(export_result_, sizeof(export_result_));
export_address_.clear();
export_in_progress_ = false;
export_error_ = false;
}
ui::material::EndOverlayDialog();
m::EndOverlayDialog();
}
void App::renderBackupDialog()

View File

@@ -809,6 +809,8 @@ private:
// Export/Import state
char export_result_[256] = {0}; // SECRET exported key — fixed buffer so it can be sodium_memzero'd
bool export_in_progress_ = false; // async key fetch running (spinner + disable Export)
bool export_error_ = false; // last export returned no key (locked wallet / failure)
char import_key_input_[512] = {0};
std::string export_address_;
std::string import_status_;

View File

@@ -1393,6 +1393,7 @@ void I18n::loadBuiltinEnglish()
// --- Key Export Dialog ---
strings_["key_export_fetching"] = "Fetching key from wallet...";
strings_["key_export_failed"] = "Couldn't export the key \xE2\x80\x94 unlock the wallet (if encrypted) and try again.";
strings_["key_export_private_key"] = "Private Key:";
strings_["key_export_private_warning"] = "Keep this key SECRET! Anyone with this key can spend your funds. Never share it online or with untrusted parties.";
strings_["key_export_reveal"] = "Reveal Key";