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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user