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).
|
// import. lite_import_key is the import input buffer (wiped right after submission).
|
||||||
std::string lite_export_secret;
|
std::string lite_export_secret;
|
||||||
std::string lite_export_label;
|
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] = "";
|
char lite_import_key[512] = "";
|
||||||
std::string lite_backup_status;
|
std::string lite_backup_status;
|
||||||
// Encryption passphrase inputs (SECRET: zeroed right after each action). lite_enc_pass is
|
// Encryption passphrase inputs (SECRET: zeroed right after each action). lite_enc_pass is
|
||||||
@@ -145,6 +147,18 @@ struct SettingsPageState {
|
|||||||
|
|
||||||
static SettingsPageState s_settingsState;
|
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() {
|
static wallet::LiteWalletLifecycleOperation liteLifecycleOperationFromPageState() {
|
||||||
switch (s_settingsState.lite_lifecycle_operation) {
|
switch (s_settingsState.lite_lifecycle_operation) {
|
||||||
case 1: return wallet::LiteWalletLifecycleOperation::OpenExisting;
|
case 1: return wallet::LiteWalletLifecycleOperation::OpenExisting;
|
||||||
@@ -205,6 +219,18 @@ static void evaluateLiteLifecycleRequestFromPageState(App* app) {
|
|||||||
}
|
}
|
||||||
} liteSecretScrubber{input};
|
} 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
|
// 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.
|
// App-owned controller. Otherwise fall back to the validation-only adapter.
|
||||||
if (auto* lite = app->liteWallet()) {
|
if (auto* lite = app->liteWallet()) {
|
||||||
@@ -1531,6 +1557,14 @@ void RenderSettingsPage(App* app) {
|
|||||||
ImGui::InputText("##LiteRestoreSeed", s_settingsState.lite_restore_seed,
|
ImGui::InputText("##LiteRestoreSeed", s_settingsState.lite_restore_seed,
|
||||||
sizeof(s_settingsState.lite_restore_seed),
|
sizeof(s_settingsState.lite_restore_seed),
|
||||||
ImGuiInputTextFlags_Password);
|
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::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
|
||||||
ImGui::AlignTextToFramePadding();
|
ImGui::AlignTextToFramePadding();
|
||||||
@@ -1539,6 +1573,9 @@ void RenderSettingsPage(App* app) {
|
|||||||
ImGui::SetNextItemWidth(std::min(160.0f, liteInputW));
|
ImGui::SetNextItemWidth(std::min(160.0f, liteInputW));
|
||||||
ImGui::InputInt("##LiteRestoreBirthday", &s_settingsState.lite_restore_birthday);
|
ImGui::InputInt("##LiteRestoreBirthday", &s_settingsState.lite_restore_birthday);
|
||||||
if (s_settingsState.lite_restore_birthday < 0) s_settingsState.lite_restore_birthday = 0;
|
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::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
|
||||||
ImGui::AlignTextToFramePadding();
|
ImGui::AlignTextToFramePadding();
|
||||||
@@ -1591,10 +1628,15 @@ void RenderSettingsPage(App* app) {
|
|||||||
wallet::secureWipeLiteSecret(s_settingsState.lite_export_secret);
|
wallet::secureWipeLiteSecret(s_settingsState.lite_export_secret);
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
s_settingsState.lite_export_secret = r.seedPhrase;
|
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();
|
s_settingsState.lite_backup_status.clear();
|
||||||
} else {
|
} else {
|
||||||
s_settingsState.lite_export_label.clear();
|
s_settingsState.lite_export_label.clear();
|
||||||
|
s_settingsState.lite_export_is_seed = false;
|
||||||
s_settingsState.lite_backup_status = r.error;
|
s_settingsState.lite_backup_status = r.error;
|
||||||
}
|
}
|
||||||
wallet::secureWipeLiteSecret(r.seedPhrase); // wipe the result copy
|
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"))) {
|
if (TactileButton("Show private keys##LiteExportKeys", ImVec2(0, 0), S.resolveFont("button"))) {
|
||||||
auto r = app->liteWallet()->exportPrivateKeys();
|
auto r = app->liteWallet()->exportPrivateKeys();
|
||||||
wallet::secureWipeLiteSecret(s_settingsState.lite_export_secret);
|
wallet::secureWipeLiteSecret(s_settingsState.lite_export_secret);
|
||||||
|
s_settingsState.lite_export_is_seed = false; // keys, not a seed
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
s_settingsState.lite_export_secret = r.privateKeysJson;
|
s_settingsState.lite_export_secret = r.privateKeysJson;
|
||||||
s_settingsState.lite_export_label = "Private keys — anyone with these can spend your funds";
|
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::PushTextWrapPos(0.0f);
|
||||||
ImGui::TextWrapped("%s", s_settingsState.lite_export_secret.c_str());
|
ImGui::TextWrapped("%s", s_settingsState.lite_export_secret.c_str());
|
||||||
ImGui::PopTextWrapPos();
|
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));
|
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
|
||||||
if (TactileButton("Copy##LiteExportCopy", ImVec2(0, 0), S.resolveFont("button"))) {
|
if (TactileButton("Copy##LiteExportCopy", ImVec2(0, 0), S.resolveFont("button"))) {
|
||||||
ImGui::SetClipboardText(s_settingsState.lite_export_secret.c_str());
|
ImGui::SetClipboardText(s_settingsState.lite_export_secret.c_str());
|
||||||
}
|
}
|
||||||
ImGui::SameLine(0, Layout::spacingSm());
|
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"))) {
|
if (TactileButton("Hide & wipe##LiteExportHide", ImVec2(0, 0), S.resolveFont("button"))) {
|
||||||
wallet::secureWipeLiteSecret(s_settingsState.lite_export_secret);
|
wallet::secureWipeLiteSecret(s_settingsState.lite_export_secret);
|
||||||
s_settingsState.lite_export_label.clear();
|
s_settingsState.lite_export_label.clear();
|
||||||
|
s_settingsState.lite_export_is_seed = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user