feat(lite): backup & keys UI — export seed/keys + import (Settings)

Add a "Backup & keys" section to the lite Settings page, shown only for an open
wallet, wiring the M4 controller backup/import surface into the GUI:

- "Show seed" / "Show private keys" -> exportSeed() / exportPrivateKeys();
  the revealed secret is displayed read-only (TextWrapped, no extra copies) with
  Copy and "Hide & wipe" controls.
- "Import key" (password input) -> importKey() (auto-detects WIF vs shielded);
  do_import_sk just records the key + saves (no synchronous rescan), so this is
  safe on the UI thread — history appears after the next sync.

Secret hygiene: the revealed-backup buffer is sodium-wiped via
secureWipeLiteSecret on hide, on a new export (overwrite), and if the wallet
closes while revealed; each export also wipes the controller's result copy; the
import input buffer is zeroed immediately after submission.

Lite app + full-node variant build/link clean; controller methods already
covered by testLiteWalletControllerM4; hygiene clean. GUI behavior itself isn't
auto-verifiable here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 13:40:17 -05:00
parent eb6114ee19
commit db4778e6a7

View File

@@ -121,6 +121,13 @@ struct SettingsPageState {
bool lite_restore_overwrite = false;
std::string lite_lifecycle_status;
std::string lite_lifecycle_summary;
// Backup & keys (only populated for an open lite wallet). lite_export_secret holds the
// revealed seed/private-keys backup and is SECRET: securely wiped on hide / new export /
// import. lite_import_key is the import input buffer (wiped right after submission).
std::string lite_export_secret;
std::string lite_export_label;
char lite_import_key[512] = "";
std::string lite_backup_status;
bool mine_when_idle = false;
int mine_idle_delay = 120;
bool idle_thread_scaling = false;
@@ -1724,6 +1731,89 @@ void RenderSettingsPage(App* app) {
s_settingsState.lite_lifecycle_summary.c_str());
}
}
// ---- Backup & keys (open wallet only) ----------------------------------
if (app->liteWallet() && app->liteWallet()->walletOpen()) {
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
Type().text(TypeStyle::Body2, "Backup & keys");
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
if (TactileButton("Show seed##LiteExportSeed", ImVec2(0, 0), S.resolveFont("button"))) {
auto r = app->liteWallet()->exportSeed();
wallet::secureWipeLiteSecret(s_settingsState.lite_export_secret);
if (r.ok) {
s_settingsState.lite_export_secret = r.seedPhrase;
s_settingsState.lite_export_label = "Seed phrase — write it down, never share it";
s_settingsState.lite_backup_status.clear();
} else {
s_settingsState.lite_export_label.clear();
s_settingsState.lite_backup_status = r.error;
}
wallet::secureWipeLiteSecret(r.seedPhrase); // wipe the result copy
}
ImGui::SameLine(0, Layout::spacingSm());
if (TactileButton("Show private keys##LiteExportKeys", ImVec2(0, 0), S.resolveFont("button"))) {
auto r = app->liteWallet()->exportPrivateKeys();
wallet::secureWipeLiteSecret(s_settingsState.lite_export_secret);
if (r.ok) {
s_settingsState.lite_export_secret = r.privateKeysJson;
s_settingsState.lite_export_label = "Private keys — anyone with these can spend your funds";
s_settingsState.lite_backup_status.clear();
} else {
s_settingsState.lite_export_label.clear();
s_settingsState.lite_backup_status = r.error;
}
wallet::secureWipeLiteSecret(r.privateKeysJson);
}
// Revealed secret: shown read-only (no extra copies), with copy + wipe.
if (!s_settingsState.lite_export_secret.empty()) {
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(),
s_settingsState.lite_export_label.c_str());
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
ImGui::PushTextWrapPos(0.0f);
ImGui::TextWrapped("%s", s_settingsState.lite_export_secret.c_str());
ImGui::PopTextWrapPos();
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
if (TactileButton("Copy##LiteExportCopy", ImVec2(0, 0), S.resolveFont("button"))) {
ImGui::SetClipboardText(s_settingsState.lite_export_secret.c_str());
}
ImGui::SameLine(0, Layout::spacingSm());
if (TactileButton("Hide & wipe##LiteExportHide", ImVec2(0, 0), S.resolveFont("button"))) {
wallet::secureWipeLiteSecret(s_settingsState.lite_export_secret);
s_settingsState.lite_export_label.clear();
}
}
// Import a spending/viewing key (history appears after the next sync).
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted("Import key");
ImGui::SameLine(leftX - sectionOrigin.x + liteLabelW);
ImGui::SetNextItemWidth(liteInputW);
ImGui::InputText("##LiteImportKey", s_settingsState.lite_import_key,
sizeof(s_settingsState.lite_import_key),
ImGuiInputTextFlags_Password);
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
if (TactileButton("Import##LiteImportKeyBtn", ImVec2(0, 0), S.resolveFont("button"))) {
const auto r = app->liteWallet()->importKey(s_settingsState.lite_import_key);
sodium_memzero(s_settingsState.lite_import_key, sizeof(s_settingsState.lite_import_key));
s_settingsState.lite_backup_status =
r.ok ? "Key imported — run a sync to scan its history" : r.error;
}
if (!s_settingsState.lite_backup_status.empty()) {
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(),
s_settingsState.lite_backup_status.c_str());
}
} else if (!s_settingsState.lite_export_secret.empty()) {
// Wallet closed while a backup was revealed — don't leave it in memory.
wallet::secureWipeLiteSecret(s_settingsState.lite_export_secret);
s_settingsState.lite_export_label.clear();
}
} else {
float rpcLblW = std::max(