diff --git a/extension/popup/screens/onboarding/ImportScreen.tsx b/extension/popup/screens/onboarding/ImportScreen.tsx index 60b37b88..d20e6dfc 100644 --- a/extension/popup/screens/onboarding/ImportScreen.tsx +++ b/extension/popup/screens/onboarding/ImportScreen.tsx @@ -14,7 +14,11 @@ import { EyeIcon } from '../../components/icons/EyeIcon'; import { EyeOffIcon } from '../../components/icons/EyeOffIcon'; import { InfoIcon } from '../../components/icons/InfoIcon'; import { importKeyfile, type Keyfile } from '../../../shared/keyfile'; -import { validateMnemonic } from '../../../shared/wallet-crypto'; +import { normalizeAndValidateMnemonic } from '../../../shared/wallet-crypto'; + +function lowercaseAscii(value: string): string { + return value.replace(/[A-Z]/g, character => character.toLowerCase()); +} export function ImportScreen() { const { @@ -51,7 +55,7 @@ export function ImportScreen() { const fileInputRef = useRef(null); function handleWordChange(index: number, value: string) { - const trimmedValue = value.trim().toLowerCase(); + const trimmedValue = lowercaseAscii(value.trim()); const newWords = [...words]; newWords[index] = trimmedValue; setWords(newWords); @@ -70,7 +74,7 @@ export function ImportScreen() { function handlePaste(index: number, e: React.ClipboardEvent) { if (index === 0) { const pasteData = e.clipboardData.getData('text'); - const pastedWords = pasteData.trim().toLowerCase().split(/\s+/); + const pastedWords = lowercaseAscii(pasteData.trim()).split(/\s+/); if (pastedWords.length === UI_CONSTANTS.MNEMONIC_WORD_COUNT) { e.preventDefault(); @@ -117,7 +121,7 @@ export function ImportScreen() { async function handleImport(mnemonicOverride?: string) { // Use stored mnemonic (set either by manual entry or keyfile import) - const mnemonic = mnemonicOverride || onboardingMnemonic || words.join(' ').trim(); + const mnemonicCandidate = mnemonicOverride || onboardingMnemonic || words.join(' ').trim(); if (!isAddSeedFlow) { // Validate password @@ -137,11 +141,14 @@ export function ImportScreen() { } } + const mnemonic = normalizeAndValidateMnemonic(mnemonicCandidate); + if (!mnemonic) { + setIsSubmitting(false); + setError('Invalid secret phrase. Please check your words and try again.'); + return; + } + if (isAddSeedFlow) { - if (!validateMnemonic(mnemonic)) { - setError('Invalid secret phrase. Please check your words and try again.'); - return; - } setIsSubmitting(true); try { const result = await createMnemonicSeedSource(mnemonic, undefined, true); diff --git a/extension/shared/vault.ts b/extension/shared/vault.ts index 4cce04c0..cd6345a1 100644 --- a/extension/shared/vault.ts +++ b/extension/shared/vault.ts @@ -8,7 +8,7 @@ import { generateMnemonic, deriveAddress, deriveAddressFromMaster, - validateMnemonic, + normalizeAndValidateMnemonic, } from './wallet-crypto'; import { ERROR_CODES, @@ -531,8 +531,9 @@ export class Vault { mnemonic?: string ): Promise<{ ok: boolean; address: string; mnemonic: string } | { error: string }> { // Generate or validate mnemonic - const words = mnemonic ? mnemonic.trim() : generateMnemonic(); - if (mnemonic && !validateMnemonic(words)) { + const mnemonicCandidate = mnemonic ? mnemonic : generateMnemonic(); + const words = normalizeAndValidateMnemonic(mnemonicCandidate); + if (!words) { return { error: ERROR_CODES.INVALID_MNEMONIC }; } @@ -1050,8 +1051,9 @@ export class Vault { return { error: ERROR_CODES.LOCKED }; } - const words = mnemonic ? mnemonic.trim() : generateMnemonic(); - if (mnemonic && !validateMnemonic(words)) { + const mnemonicCandidate = mnemonic ? mnemonic : generateMnemonic(); + const words = normalizeAndValidateMnemonic(mnemonicCandidate); + if (!words) { return { error: ERROR_CODES.INVALID_MNEMONIC }; } diff --git a/extension/shared/wallet-crypto.ts b/extension/shared/wallet-crypto.ts index 8f39f353..f32b6d83 100644 --- a/extension/shared/wallet-crypto.ts +++ b/extension/shared/wallet-crypto.ts @@ -12,6 +12,8 @@ import wasm from './sdk-wasm.js'; import { publicKeyToPKH } from './address-encoding'; import { ensureWasmInitialized as ensureWasmInit } from './wasm-utils'; +const englishWords = new Set(wordlist); + /** * Generates a BIP-39 mnemonic (24 words) * Uses 256 bits of entropy for maximum security @@ -29,6 +31,39 @@ export function validateMnemonic(mnemonic: string): boolean { return validateMnemonicScure(mnemonic, wordlist); } +export function normalizeAndValidateMnemonic(mnemonic: string): string | null { + const trimmed = mnemonic.trim(); + if (!trimmed) return null; + + const canonicalWords: string[] = []; + for (const rawToken of trimmed.split(/\s+/)) { + if (!/^[A-Za-z]+$/.test(rawToken)) return null; + + const token = rawToken.toLowerCase(); + + // Exact words take precedence over four-character prefix expansion. + if (englishWords.has(token)) { + canonicalWords.push(token); + continue; + } + + if (token.length !== 4) return null; + + let prefixMatch: string | null = null; + for (const word of wordlist) { + if (!word.startsWith(token)) continue; + if (prefixMatch !== null) return null; + prefixMatch = word; + } + + if (prefixMatch === null) return null; + canonicalWords.push(prefixMatch); + } + + const canonicalMnemonic = canonicalWords.join(' '); + return validateMnemonic(canonicalMnemonic) ? canonicalMnemonic : null; +} + /** * Derives a Nockchain v1 PKH address from the master key (no child derivation) * This matches the CLI wallet behavior