feat(lite): harden seed restore + backup UX in Settings

- Restore: live "N / 24 words" count, a one-line birthday explanation, and a guard
  that rejects a restore unless all 24 words are entered (the secret scrubber still
  wipes the input on the early return).
- Backup: "Show seed" now also shows the birthday (needed to restore quickly) with a
  "back this up too" note, a stronger "only way to restore" warning, and a "Save to
  file" button that writes the seed + birthday to an owner-only (0600) file in the
  config dir via the atomic-write helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 11:12:56 -05:00
parent 8c51b092f8
commit 6ff1fda870

View File

@@ -120,6 +120,8 @@ struct SettingsPageState {
// import. lite_import_key is the import input buffer (wiped right after submission).
std::string lite_export_secret;
std::string lite_export_label;
bool lite_export_is_seed = false; // revealed secret is a seed (show birthday + save-to-file)
unsigned long long lite_export_birthday = 0; // seed birthday (shown with the seed)
char lite_import_key[512] = "";
std::string lite_backup_status;
// Encryption passphrase inputs (SECRET: zeroed right after each action). lite_enc_pass is
@@ -145,6 +147,18 @@ struct SettingsPageState {
static SettingsPageState s_settingsState;
// Count whitespace-separated words in a (seed) buffer — used to validate/guide restore input.
static int liteSeedWordCount(const char* s) {
int words = 0;
bool inWord = false;
for (; s && *s; ++s) {
const bool space = std::isspace(static_cast<unsigned char>(*s)) != 0;
if (space) inWord = false;
else if (!inWord) { inWord = true; ++words; }
}
return words;
}
static wallet::LiteWalletLifecycleOperation liteLifecycleOperationFromPageState() {
switch (s_settingsState.lite_lifecycle_operation) {
case 1: return wallet::LiteWalletLifecycleOperation::OpenExisting;
@@ -205,6 +219,18 @@ static void evaluateLiteLifecycleRequestFromPageState(App* app) {
}
} liteSecretScrubber{input};
// Restore needs a complete 24-word seed; reject early (the scrubber above still wipes the
// entered secret on this return path).
if (input.request.operation == wallet::LiteWalletLifecycleOperation::RestoreFromSeed) {
const int words = liteSeedWordCount(s_settingsState.lite_restore_seed);
if (words != 24) {
s_settingsState.lite_lifecycle_status =
"Enter all 24 seed words to restore (got " + std::to_string(words) + ")";
s_settingsState.lite_lifecycle_summary.clear();
return;
}
}
// When a linked lite backend is present, execute the operation for real through the
// App-owned controller. Otherwise fall back to the validation-only adapter.
if (auto* lite = app->liteWallet()) {
@@ -1531,6 +1557,14 @@ void RenderSettingsPage(App* app) {
ImGui::InputText("##LiteRestoreSeed", s_settingsState.lite_restore_seed,
sizeof(s_settingsState.lite_restore_seed),
ImGuiInputTextFlags_Password);
// Live 24-word count so the user knows the seed is complete before restoring.
{
const int wc = liteSeedWordCount(s_settingsState.lite_restore_seed);
char wbuf[32];
snprintf(wbuf, sizeof(wbuf), "%d / 24 words", wc);
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
Type().textColored(TypeStyle::Caption, wc == 24 ? Success() : Warning(), wbuf);
}
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
ImGui::AlignTextToFramePadding();
@@ -1539,6 +1573,9 @@ void RenderSettingsPage(App* app) {
ImGui::SetNextItemWidth(std::min(160.0f, liteInputW));
ImGui::InputInt("##LiteRestoreBirthday", &s_settingsState.lite_restore_birthday);
if (s_settingsState.lite_restore_birthday < 0) s_settingsState.lite_restore_birthday = 0;
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(),
"Block height to start scanning from. Leave 0 if unknown (slower full scan).");
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
ImGui::AlignTextToFramePadding();
@@ -1591,10 +1628,15 @@ void RenderSettingsPage(App* app) {
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_export_label =
"Seed phrase — the ONLY way to restore your wallet. "
"Write it down, store it offline, never share it.";
s_settingsState.lite_export_is_seed = true;
s_settingsState.lite_export_birthday = r.birthday;
s_settingsState.lite_backup_status.clear();
} else {
s_settingsState.lite_export_label.clear();
s_settingsState.lite_export_is_seed = false;
s_settingsState.lite_backup_status = r.error;
}
wallet::secureWipeLiteSecret(r.seedPhrase); // wipe the result copy
@@ -1603,6 +1645,7 @@ void RenderSettingsPage(App* app) {
if (TactileButton("Show private keys##LiteExportKeys", ImVec2(0, 0), S.resolveFont("button"))) {
auto r = app->liteWallet()->exportPrivateKeys();
wallet::secureWipeLiteSecret(s_settingsState.lite_export_secret);
s_settingsState.lite_export_is_seed = false; // keys, not a seed
if (r.ok) {
s_settingsState.lite_export_secret = r.privateKeysJson;
s_settingsState.lite_export_label = "Private keys — anyone with these can spend your funds";
@@ -1623,14 +1666,37 @@ void RenderSettingsPage(App* app) {
ImGui::PushTextWrapPos(0.0f);
ImGui::TextWrapped("%s", s_settingsState.lite_export_secret.c_str());
ImGui::PopTextWrapPos();
// The seed's birthday is needed to restore quickly — show + back it up too.
if (s_settingsState.lite_export_is_seed) {
char bday[64];
snprintf(bday, sizeof(bday), "Birthday: %llu (back this up too)",
s_settingsState.lite_export_birthday);
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), bday);
}
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());
// Save the seed (+ birthday) to an owner-only (0600) file in the config dir.
if (s_settingsState.lite_export_is_seed &&
TactileButton("Save to file##LiteExportSave", ImVec2(0, 0), S.resolveFont("button"))) {
std::string path = util::Platform::getConfigDir() + "/lite-seed-backup.txt";
std::string content = s_settingsState.lite_export_secret + "\nBirthday: " +
std::to_string(s_settingsState.lite_export_birthday) + "\n";
const bool ok = util::Platform::writeFileAtomically(
path, content, /*restrictPermissions=*/true);
wallet::secureWipeLiteSecret(content);
s_settingsState.lite_backup_status = ok
? ("Saved (plaintext, owner-only) to " + path)
: ("Could not write " + path);
}
if (s_settingsState.lite_export_is_seed) 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();
s_settingsState.lite_export_is_seed = false;
}
}