Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 15 additions & 8 deletions extension/popup/screens/onboarding/ImportScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -51,7 +55,7 @@ export function ImportScreen() {
const fileInputRef = useRef<HTMLInputElement>(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);
Expand All @@ -70,7 +74,7 @@ export function ImportScreen() {
function handlePaste(index: number, e: React.ClipboardEvent<HTMLInputElement>) {
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();
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down
12 changes: 7 additions & 5 deletions extension/shared/vault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
generateMnemonic,
deriveAddress,
deriveAddressFromMaster,
validateMnemonic,
normalizeAndValidateMnemonic,
} from './wallet-crypto';
import {
ERROR_CODES,
Expand Down Expand Up @@ -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 };
}

Expand Down Expand Up @@ -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 };
}

Expand Down
35 changes: 35 additions & 0 deletions extension/shared/wallet-crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down