fix(lite): require exactly 24 words on first-run restore (crash on valid seed)

The lite first-run restore wizard enabled Restore for {12,15,18,21,24}-word
phrases, but the SDXL backend only accepts 24-word / 32-byte-entropy seeds:
LightWallet::new does copy_from_slice(&phrase.entropy()) into a [u8;32]
(lightwallet.rs:231), which panics on 16/20/24/28-byte entropy. Mnemonic::
from_phrase accepts the shorter valid phrase, and the restore FFI
litelib_initialize_new_from_phrase (lib.rs:127) has no catch_unwind (unlike
litelib_execute), so the panic unwinds across extern "C" -> process abort
(UB on the pinned rustc 1.63). A user restoring a legitimate 12-word seed
from another wallet crashed the app.

The Settings restore gate was already tightened to == 24 (6ff1fda) but the
first-run wizard gate (df14533) was never updated — same restore path, two
verdicts, crash only via the more-common first-run path.

Add shared util/seed_phrase.{h,cpp} as the single source of truth:
- normalizeSeedPhrase: fold NBSP/en/em/ideographic/narrow spaces to ASCII,
  strip zero-width marks, collapse+trim (word bytes untouched)
- seedPhraseWordCount
- isCompleteRecoveryPhrase(int) == 24  (the sole SDXL contract)

Both restore gates now count via the normalizer and gate via
isCompleteRecoveryPhrase, and both submit the normalized phrase. This closes
the crash, reconciles the two gates so they can't drift again, and — because
tiny-bip39 splits on literal ASCII space with no NFKD — makes an NBSP-pasted
24-word seed (common from PDFs/note apps) restore correctly instead of being
undercounted and rejected.

Adds testSeedPhraseHelpers. Suite green (1/1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-09 01:32:40 -05:00
parent 5b6ba5094b
commit a1d3964e34
6 changed files with 181 additions and 27 deletions

View File

@@ -544,6 +544,7 @@ set(APP_SOURCES
src/util/async_task_manager.cpp src/util/async_task_manager.cpp
src/util/amount_format.cpp src/util/amount_format.cpp
src/util/address_validation.cpp src/util/address_validation.cpp
src/util/seed_phrase.cpp
src/util/base64.cpp src/util/base64.cpp
src/util/single_instance.cpp src/util/single_instance.cpp
src/util/i18n.cpp src/util/i18n.cpp
@@ -1122,6 +1123,7 @@ if(BUILD_TESTING)
src/util/payment_uri.cpp src/util/payment_uri.cpp
src/util/amount_format.cpp src/util/amount_format.cpp
src/util/address_validation.cpp src/util/address_validation.cpp
src/util/seed_phrase.cpp
src/util/i18n.cpp src/util/i18n.cpp
src/util/text_format.cpp src/util/text_format.cpp
src/data/wallet_state.cpp src/data/wallet_state.cpp

View File

@@ -74,6 +74,7 @@
#include "util/platform.h" #include "util/platform.h"
#include "util/text_format.h" #include "util/text_format.h"
#include "util/payment_uri.h" #include "util/payment_uri.h"
#include "util/seed_phrase.h"
#include "util/texture_loader.h" #include "util/texture_loader.h"
#include "util/svg_texture.h" #include "util/svg_texture.h"
#include "ui/material/colors.h" #include "ui/material/colors.h"
@@ -3074,22 +3075,16 @@ void App::renderLiteFirstRunPrompt()
} }
ImGui::Spacing(); ImGui::Spacing(); ImGui::Spacing(); ImGui::Spacing();
// Trim surrounding whitespace from the entered seed. // Normalize the entered seed (trim, fold exotic Unicode whitespace like NBSP to plain
std::string seedTrim(restoreSeed); // spaces) so the word count and the phrase we submit agree regardless of paste source.
while (!seedTrim.empty() && std::isspace((unsigned char)seedTrim.front())) seedTrim.erase(seedTrim.begin()); std::string seedTrim = util::normalizeSeedPhrase(restoreSeed);
while (!seedTrim.empty() && std::isspace((unsigned char)seedTrim.back())) seedTrim.pop_back();
// Require a valid BIP39 word count before enabling Restore — otherwise a truncated or // Require a COMPLETE 24-word phrase before enabling Restore. The SDXL backend only
// garbage phrase (previously any non-empty text passed) is submitted and fails opaquely. // accepts 24-word / 32-byte-entropy seeds; a shorter valid-BIP39 phrase (12/15/18/21)
int seedWords = 0; // panics it uncaught across the restore FFI, so it must be refused here (matches the
{ bool inWord = false; // Settings restore gate — both go through util::isCompleteRecoveryPhrase).
for (char c : seedTrim) { int seedWords = util::seedPhraseWordCount(seedTrim);
bool sp = (c == ' ' || c == '\t' || c == '\n' || c == '\r'); bool seedLenOk = util::isCompleteRecoveryPhrase(seedWords);
if (!sp && !inWord) { seedWords++; inWord = true; }
else if (sp) inWord = false;
} }
bool seedLenOk = (seedWords == 12 || seedWords == 15 || seedWords == 18 ||
seedWords == 21 || seedWords == 24);
if (!seedTrim.empty() && !seedLenOk) { if (!seedTrim.empty() && !seedLenOk) {
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::Warning())); ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::Warning()));
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f); ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
@@ -3103,7 +3098,7 @@ void App::renderLiteFirstRunPrompt()
ImGui::BeginDisabled(!seedLenOk); ImGui::BeginDisabled(!seedLenOk);
if (ui::material::TactileButton(TR("lite_restore_btn"), ImVec2(btnW, 0))) { if (ui::material::TactileButton(TR("lite_restore_btn"), ImVec2(btnW, 0))) {
wallet::LiteWalletRestoreRequest req; wallet::LiteWalletRestoreRequest req;
req.seedPhrase = seedTrim; req.seedPhrase = seedTrim; // normalized: NBSP-glued pastes restore correctly
req.birthday = static_cast<unsigned long long>(std::max(0, restoreBirthday)); req.birthday = static_cast<unsigned long long>(std::max(0, restoreBirthday));
req.overwrite = lite_wallet_->walletExists(); // replace any existing wallet file req.overwrite = lite_wallet_->walletExists(); // replace any existing wallet file
if (lite_wallet_->beginRestoreWalletAsync(std::move(req))) { if (lite_wallet_->beginRestoreWalletAsync(std::move(req))) {

View File

@@ -16,6 +16,7 @@
#include "../windows/console_tab.h" #include "../windows/console_tab.h"
#include "../../util/i18n.h" #include "../../util/i18n.h"
#include "../../util/platform.h" #include "../../util/platform.h"
#include "../../util/seed_phrase.h"
#include "../../resources/embedded_resources.h" #include "../../resources/embedded_resources.h"
#include <ctime> #include <ctime>
#include "../../rpc/rpc_client.h" #include "../../rpc/rpc_client.h"
@@ -231,16 +232,12 @@ static void exitLowSpec(bool applyEffects) {
s_settingsState.low_spec_snapshot.valid = false; s_settingsState.low_spec_snapshot.valid = false;
} }
// Count whitespace-separated words in a (seed) buffer — used to validate/guide restore input. // Count words in a (seed) buffer — used to validate/guide restore input. Normalizes exotic Unicode
// whitespace (NBSP etc.) first so the count matches the phrase actually submitted (shared with the
// first-run restore gate via util::seed_phrase).
static int liteSeedWordCount(const char* s) { static int liteSeedWordCount(const char* s) {
int words = 0; return dragonx::util::seedPhraseWordCount(
bool inWord = false; dragonx::util::normalizeSeedPhrase(s ? std::string(s) : std::string()));
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() {
@@ -277,7 +274,9 @@ static void evaluateLiteLifecycleRequestFromPageState(App* app) {
break; break;
case wallet::LiteWalletLifecycleOperation::RestoreFromSeed: case wallet::LiteWalletLifecycleOperation::RestoreFromSeed:
input.request.restoreRequest.walletPath = s_settingsState.lite_wallet_path; input.request.restoreRequest.walletPath = s_settingsState.lite_wallet_path;
input.request.restoreRequest.seedPhrase = s_settingsState.lite_restore_seed; // Normalize (fold NBSP/exotic whitespace to plain spaces) so an NBSP-pasted phrase the
// gate counted as 24 words also restores correctly at the backend.
input.request.restoreRequest.seedPhrase = dragonx::util::normalizeSeedPhrase(s_settingsState.lite_restore_seed);
input.request.restoreRequest.passphrase = s_settingsState.lite_lifecycle_passphrase; input.request.restoreRequest.passphrase = s_settingsState.lite_lifecycle_passphrase;
input.request.restoreRequest.birthday = static_cast<unsigned long long>(std::max(0, s_settingsState.lite_restore_birthday)); input.request.restoreRequest.birthday = static_cast<unsigned long long>(std::max(0, s_settingsState.lite_restore_birthday));
input.request.restoreRequest.account = static_cast<unsigned long long>(std::max(0, s_settingsState.lite_restore_account)); input.request.restoreRequest.account = static_cast<unsigned long long>(std::max(0, s_settingsState.lite_restore_account));
@@ -320,7 +319,7 @@ static void evaluateLiteLifecycleRequestFromPageState(App* app) {
// entered secret on this return path). // entered secret on this return path).
if (input.request.operation == wallet::LiteWalletLifecycleOperation::RestoreFromSeed) { if (input.request.operation == wallet::LiteWalletLifecycleOperation::RestoreFromSeed) {
const int words = liteSeedWordCount(s_settingsState.lite_restore_seed); const int words = liteSeedWordCount(s_settingsState.lite_restore_seed);
if (words != 24) { if (!dragonx::util::isCompleteRecoveryPhrase(words)) {
s_settingsState.lite_lifecycle_status = s_settingsState.lite_lifecycle_status =
"Enter all 24 seed words to restore (got " + std::to_string(words) + ")"; "Enter all 24 seed words to restore (got " + std::to_string(words) + ")";
s_settingsState.lite_lifecycle_summary.clear(); s_settingsState.lite_lifecycle_summary.clear();

77
src/util/seed_phrase.cpp Normal file
View File

@@ -0,0 +1,77 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "seed_phrase.h"
#include <cctype>
namespace dragonx {
namespace util {
std::string normalizeSeedPhrase(const std::string& raw)
{
// Unicode whitespace encoded as UTF-8, each mapped to a single ASCII space; and zero-width marks
// to strip. We substitute only these EXACT byte sequences, so ordinary (ASCII) word bytes are
// never touched — the common all-ASCII phrase just gets its spacing collapsed and trimmed.
static const char* const kSpaces[] = {
"\xC2\xA0", // U+00A0 NBSP
"\xC2\x85", // U+0085 NEL
"\xE1\x9A\x80", // U+1680 ogham space
"\xE2\x80\x80", "\xE2\x80\x81", "\xE2\x80\x82", "\xE2\x80\x83", // U+20002003
"\xE2\x80\x84", "\xE2\x80\x85", "\xE2\x80\x86", "\xE2\x80\x87", // U+20042007
"\xE2\x80\x88", "\xE2\x80\x89", "\xE2\x80\x8A", // U+2008200A
"\xE2\x80\xAF", // U+202F narrow NBSP
"\xE2\x81\x9F", // U+205F math space
"\xE3\x80\x80", // U+3000 ideographic
};
static const char* const kZeroWidth[] = {
"\xE2\x80\x8B", "\xE2\x80\x8C", "\xE2\x80\x8D", // U+200B/C/D
"\xEF\xBB\xBF", // U+FEFF BOM / ZWNBSP
};
std::string s = raw;
auto replaceAll = [&s](const std::string& from, const std::string& to) {
if (from.empty()) return;
std::size_t pos = 0;
while ((pos = s.find(from, pos)) != std::string::npos) {
s.replace(pos, from.size(), to);
pos += to.size();
}
};
for (const char* zw : kZeroWidth) replaceAll(zw, "");
for (const char* sp : kSpaces) replaceAll(sp, " ");
// Collapse ASCII whitespace runs to a single space and trim ends.
std::string out;
out.reserve(s.size());
bool pendingSpace = false;
bool sawWord = false;
for (unsigned char c : s) {
if (std::isspace(c)) { pendingSpace = sawWord; continue; }
if (pendingSpace) { out.push_back(' '); pendingSpace = false; }
out.push_back(static_cast<char>(c));
sawWord = true;
}
return out;
}
int seedPhraseWordCount(const std::string& phrase)
{
int words = 0;
bool inWord = false;
for (unsigned char c : phrase) {
const bool space = std::isspace(c) != 0;
if (space) inWord = false;
else if (!inWord) { inWord = true; ++words; }
}
return words;
}
bool isCompleteRecoveryPhrase(int words)
{
return words == 24;
}
} // namespace util
} // namespace dragonx

38
src/util/seed_phrase.h Normal file
View File

@@ -0,0 +1,38 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// seed_phrase.h — shared, pure helpers for validating a pasted BIP39 recovery phrase.
// One source of truth for the seed-length contract so the lite-restore gates (first-run
// wizard + Settings) cannot drift apart. No I/O, no secrets retained — safe for both variants.
#pragma once
#include <string>
namespace dragonx {
namespace util {
// Normalize a pasted recovery phrase for consistent word counting AND backend submission:
// - every run of Unicode/ASCII whitespace (incl. NBSP U+00A0, en/em spaces U+2000200A,
// U+202F, U+205F, ideographic U+3000, NEL, tab/newline) collapses to a single ASCII space,
// - zero-width marks (U+200B/C/D, U+FEFF BOM) are stripped,
// - leading/trailing space is trimmed.
// Word bytes are copied verbatim — only these exact whitespace byte-sequences are substituted.
// The lite backend (tiny-bip39) splits on the literal ASCII space and does NO Unicode folding, so
// a phrase pasted with NBSPs (common from PDFs/note apps) is otherwise unrestorable; normalizing
// before submit makes the words space-separated and recoverable.
std::string normalizeSeedPhrase(const std::string& raw);
// Count ASCII-whitespace-separated words. Pair with normalizeSeedPhrase so exotic spacing counts right.
int seedPhraseWordCount(const std::string& phrase);
// True if `words` is a complete recovery phrase the DragonX backends accept. DragonX seeds are
// 24-word / 256-bit / 32-byte-entropy ONLY: the SDXL lite backend's LightWallet::new copies the
// phrase entropy into a fixed [u8;32] (a shorter valid-BIP39 phrase — 12/15/18/21 words — makes it
// panic, uncaught, across the restore FFI), and the full-node daemon likewise generates 24 words.
// Both lite-restore gates MUST use this so a crash-inducing length is refused client-side.
bool isCompleteRecoveryPhrase(int words);
} // namespace util
} // namespace dragonx

View File

@@ -37,6 +37,7 @@
#include "ui/notifications.h" #include "ui/notifications.h"
#include "data/seed_migration_resume.h" #include "data/seed_migration_resume.h"
#include "util/address_validation.h" #include "util/address_validation.h"
#include "util/seed_phrase.h"
#include "util/amount_format.h" #include "util/amount_format.h"
#include "util/payment_uri.h" #include "util/payment_uri.h"
#include "util/platform.h" #include "util/platform.h"
@@ -6044,6 +6045,47 @@ void testPrivateKeyImportRecognition()
EXPECT_FALSE(WalletSecurityController::isRecognizedImportKey("hello world")); EXPECT_FALSE(WalletSecurityController::isRecognizedImportKey("hello world"));
} }
// Seed-phrase normalization + word count + the 24-word completeness gate. Guards the lite-restore
// crash fix (only 24-word/32-byte-entropy seeds are safe for the SDXL backend) and the NBSP-paste
// recovery fix. Both restore gates (first-run wizard + Settings) route through these.
void testSeedPhraseHelpers()
{
using dragonx::util::normalizeSeedPhrase;
using dragonx::util::seedPhraseWordCount;
using dragonx::util::isCompleteRecoveryPhrase;
// --- completeness gate: 24 words only (12/15/18/21 valid-BIP39 lengths crash the backend) ---
EXPECT_TRUE(isCompleteRecoveryPhrase(24));
EXPECT_FALSE(isCompleteRecoveryPhrase(12));
EXPECT_FALSE(isCompleteRecoveryPhrase(15));
EXPECT_FALSE(isCompleteRecoveryPhrase(21));
EXPECT_FALSE(isCompleteRecoveryPhrase(23));
EXPECT_FALSE(isCompleteRecoveryPhrase(25));
EXPECT_FALSE(isCompleteRecoveryPhrase(0));
// --- plain ASCII: trim, collapse runs, count exactly; the common case must be untouched otherwise ---
EXPECT_EQ(normalizeSeedPhrase(" alpha beta\tgamma\ndelta "), std::string("alpha beta gamma delta"));
EXPECT_EQ(seedPhraseWordCount(normalizeSeedPhrase("alpha beta gamma")), 3);
EXPECT_EQ(seedPhraseWordCount(""), 0);
EXPECT_EQ(seedPhraseWordCount(normalizeSeedPhrase(" \t \n ")), 0); // whitespace-only
// --- NBSP (U+00A0 = 0xC2 0xA0) between words must fold to a real space, not glue the words ---
EXPECT_EQ(normalizeSeedPhrase("alpha\xC2\xA0" "beta"), std::string("alpha beta"));
EXPECT_EQ(seedPhraseWordCount(normalizeSeedPhrase("alpha\xC2\xA0" "beta")), 2);
// Other Unicode spaces: en space U+2002, ideographic U+3000, narrow NBSP U+202F.
EXPECT_EQ(normalizeSeedPhrase("a\xE2\x80\x82" "b\xE3\x80\x80" "c\xE2\x80\xAF" "d"), std::string("a b c d"));
// Zero-width chars (U+200B, U+FEFF BOM) are stripped, not treated as separators.
EXPECT_EQ(normalizeSeedPhrase("\xEF\xBB\xBF" "alpha\xE2\x80\x8B beta"), std::string("alpha beta"));
// --- a full 24-word phrase pasted with NBSP separators counts as 24 (regression for the fix) ---
std::string words24;
for (int i = 0; i < 24; ++i) { if (i) words24 += "\xC2\xA0"; words24 += "word"; }
EXPECT_EQ(seedPhraseWordCount(normalizeSeedPhrase(words24)), 24);
EXPECT_TRUE(isCompleteRecoveryPhrase(seedPhraseWordCount(normalizeSeedPhrase(words24))));
// The normalized form is plain single-space separated (what the backend's split(" ") needs).
EXPECT_EQ(normalizeSeedPhrase(words24).find("\xC2\xA0"), std::string::npos);
}
// Live probe of a real lite server (env-gated). Validates CONNECT_ONLY latency + IP capture. // Live probe of a real lite server (env-gated). Validates CONNECT_ONLY latency + IP capture.
void testLiteServerProbeLive() void testLiteServerProbeLive()
{ {
@@ -7286,6 +7328,7 @@ int main()
testWalletFileProbe(); testWalletFileProbe();
testAddressChecksumValidation(); testAddressChecksumValidation();
testPrivateKeyImportRecognition(); testPrivateKeyImportRecognition();
testSeedPhraseHelpers();
testLiteServerProbeLive(); testLiteServerProbeLive();
testXmrigLiveInstall(); testXmrigLiveInstall();
testGeneratedResourceBehavior(); testGeneratedResourceBehavior();