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:
124
src/app.cpp
124
src/app.cpp
@@ -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()
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user