diff --git a/CMakeLists.txt b/CMakeLists.txt index c6c2cf6..31fd4fb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -544,6 +544,7 @@ set(APP_SOURCES src/util/async_task_manager.cpp src/util/amount_format.cpp src/util/address_validation.cpp + src/util/seed_phrase.cpp src/util/base64.cpp src/util/single_instance.cpp src/util/i18n.cpp @@ -1122,6 +1123,7 @@ if(BUILD_TESTING) src/util/payment_uri.cpp src/util/amount_format.cpp src/util/address_validation.cpp + src/util/seed_phrase.cpp src/util/i18n.cpp src/util/text_format.cpp src/data/wallet_state.cpp diff --git a/src/app.cpp b/src/app.cpp index 2ca34eb..47d08a5 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -74,6 +74,7 @@ #include "util/platform.h" #include "util/text_format.h" #include "util/payment_uri.h" +#include "util/seed_phrase.h" #include "util/texture_loader.h" #include "util/svg_texture.h" #include "ui/material/colors.h" @@ -3074,22 +3075,16 @@ void App::renderLiteFirstRunPrompt() } ImGui::Spacing(); ImGui::Spacing(); - // Trim surrounding whitespace from the entered seed. - std::string seedTrim(restoreSeed); - while (!seedTrim.empty() && std::isspace((unsigned char)seedTrim.front())) seedTrim.erase(seedTrim.begin()); - while (!seedTrim.empty() && std::isspace((unsigned char)seedTrim.back())) seedTrim.pop_back(); + // Normalize the entered seed (trim, fold exotic Unicode whitespace like NBSP to plain + // spaces) so the word count and the phrase we submit agree regardless of paste source. + std::string seedTrim = util::normalizeSeedPhrase(restoreSeed); - // Require a valid BIP39 word count before enabling Restore — otherwise a truncated or - // garbage phrase (previously any non-empty text passed) is submitted and fails opaquely. - int seedWords = 0; - { bool inWord = false; - for (char c : seedTrim) { - bool sp = (c == ' ' || c == '\t' || c == '\n' || c == '\r'); - if (!sp && !inWord) { seedWords++; inWord = true; } - else if (sp) inWord = false; - } } - bool seedLenOk = (seedWords == 12 || seedWords == 15 || seedWords == 18 || - seedWords == 21 || seedWords == 24); + // Require a COMPLETE 24-word phrase before enabling Restore. The SDXL backend only + // accepts 24-word / 32-byte-entropy seeds; a shorter valid-BIP39 phrase (12/15/18/21) + // panics it uncaught across the restore FFI, so it must be refused here (matches the + // Settings restore gate — both go through util::isCompleteRecoveryPhrase). + int seedWords = util::seedPhraseWordCount(seedTrim); + bool seedLenOk = util::isCompleteRecoveryPhrase(seedWords); if (!seedTrim.empty() && !seedLenOk) { ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::Warning())); ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f); @@ -3103,7 +3098,7 @@ void App::renderLiteFirstRunPrompt() ImGui::BeginDisabled(!seedLenOk); if (ui::material::TactileButton(TR("lite_restore_btn"), ImVec2(btnW, 0))) { wallet::LiteWalletRestoreRequest req; - req.seedPhrase = seedTrim; + req.seedPhrase = seedTrim; // normalized: NBSP-glued pastes restore correctly req.birthday = static_cast(std::max(0, restoreBirthday)); req.overwrite = lite_wallet_->walletExists(); // replace any existing wallet file if (lite_wallet_->beginRestoreWalletAsync(std::move(req))) { diff --git a/src/ui/pages/settings_page.cpp b/src/ui/pages/settings_page.cpp index d4d3dca..80bf8e2 100644 --- a/src/ui/pages/settings_page.cpp +++ b/src/ui/pages/settings_page.cpp @@ -16,6 +16,7 @@ #include "../windows/console_tab.h" #include "../../util/i18n.h" #include "../../util/platform.h" +#include "../../util/seed_phrase.h" #include "../../resources/embedded_resources.h" #include #include "../../rpc/rpc_client.h" @@ -231,16 +232,12 @@ static void exitLowSpec(bool applyEffects) { 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) { - int words = 0; - bool inWord = false; - for (; s && *s; ++s) { - const bool space = std::isspace(static_cast(*s)) != 0; - if (space) inWord = false; - else if (!inWord) { inWord = true; ++words; } - } - return words; + return dragonx::util::seedPhraseWordCount( + dragonx::util::normalizeSeedPhrase(s ? std::string(s) : std::string())); } static wallet::LiteWalletLifecycleOperation liteLifecycleOperationFromPageState() { @@ -277,7 +274,9 @@ static void evaluateLiteLifecycleRequestFromPageState(App* app) { break; case wallet::LiteWalletLifecycleOperation::RestoreFromSeed: 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.birthday = static_cast(std::max(0, s_settingsState.lite_restore_birthday)); input.request.restoreRequest.account = static_cast(std::max(0, s_settingsState.lite_restore_account)); @@ -320,7 +319,7 @@ static void evaluateLiteLifecycleRequestFromPageState(App* app) { // 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) { + if (!dragonx::util::isCompleteRecoveryPhrase(words)) { s_settingsState.lite_lifecycle_status = "Enter all 24 seed words to restore (got " + std::to_string(words) + ")"; s_settingsState.lite_lifecycle_summary.clear(); diff --git a/src/util/seed_phrase.cpp b/src/util/seed_phrase.cpp new file mode 100644 index 0000000..660ca1e --- /dev/null +++ b/src/util/seed_phrase.cpp @@ -0,0 +1,77 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 + +#include "seed_phrase.h" + +#include + +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+2000–2003 + "\xE2\x80\x84", "\xE2\x80\x85", "\xE2\x80\x86", "\xE2\x80\x87", // U+2004–2007 + "\xE2\x80\x88", "\xE2\x80\x89", "\xE2\x80\x8A", // U+2008–200A + "\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(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 diff --git a/src/util/seed_phrase.h b/src/util/seed_phrase.h new file mode 100644 index 0000000..d13ce05 --- /dev/null +++ b/src/util/seed_phrase.h @@ -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 + +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+2000–200A, +// 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 diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 810398b..7e1f6cb 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -37,6 +37,7 @@ #include "ui/notifications.h" #include "data/seed_migration_resume.h" #include "util/address_validation.h" +#include "util/seed_phrase.h" #include "util/amount_format.h" #include "util/payment_uri.h" #include "util/platform.h" @@ -6044,6 +6045,47 @@ void testPrivateKeyImportRecognition() 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. void testLiteServerProbeLive() { @@ -7286,6 +7328,7 @@ int main() testWalletFileProbe(); testAddressChecksumValidation(); testPrivateKeyImportRecognition(); + testSeedPhraseHelpers(); testLiteServerProbeLive(); testXmrigLiveInstall(); testGeneratedResourceBehavior();