diff --git a/packages/cli/src/commands/account/new.ts b/packages/cli/src/commands/account/new.ts index 11de24f7280..a4f66b43a0f 100644 --- a/packages/cli/src/commands/account/new.ts +++ b/packages/cli/src/commands/account/new.ts @@ -1,4 +1,5 @@ import { + formatNonAccentedCharacters, generateKeys, generateMnemonic, MnemonicLanguages, @@ -100,7 +101,8 @@ export default class NewAccount extends BaseCommand { const res = this.parse(NewAccount) let mnemonic = NewAccount.readFile(res.flags.mnemonicPath) if (mnemonic) { - if (!validateMnemonic(mnemonic, NewAccount.languageOptions(res.flags.language!))) { + mnemonic = formatNonAccentedCharacters(mnemonic) + if (!validateMnemonic(mnemonic)) { throw Error('Invalid mnemonic. Should be a bip39 mnemonic') } } else { diff --git a/packages/cli/src/commands/account/recover-old.ts b/packages/cli/src/commands/account/recover-old.ts index a53dc72b1a9..b15d363a975 100644 --- a/packages/cli/src/commands/account/recover-old.ts +++ b/packages/cli/src/commands/account/recover-old.ts @@ -1,4 +1,9 @@ -import { generateKeysFromSeed, generateSeed, validateMnemonic } from '@celo/utils/lib/account' +import { + formatNonAccentedCharacters, + generateKeysFromSeed, + generateSeed, + validateMnemonic, +} from '@celo/utils/lib/account' import { privateKeyToAddress } from '@celo/utils/lib/address' import { flags } from '@oclif/command' import { toChecksumAddress } from 'ethereumjs-util' @@ -29,9 +34,10 @@ export default class RecoverOld extends NewAccount { async run() { const res = this.parse(RecoverOld) - const mnemonic = NewAccount.readFile(res.flags.mnemonicPath) + let mnemonic = NewAccount.readFile(res.flags.mnemonicPath) if (mnemonic) { - if (!validateMnemonic(mnemonic, NewAccount.languageOptions(res.flags.language!))) { + mnemonic = formatNonAccentedCharacters(mnemonic) + if (!validateMnemonic(mnemonic)) { throw Error('Invalid mnemonic. Should be a bip39 mnemonic') } } else { diff --git a/packages/mobile/src/backup/utils.test.ts b/packages/mobile/src/backup/utils.test.ts index a0b211e9aa7..b5189771bbc 100644 --- a/packages/mobile/src/backup/utils.test.ts +++ b/packages/mobile/src/backup/utils.test.ts @@ -1,4 +1,4 @@ -import { MnemonicLanguages, validateMnemonic } from '@celo/utils/src/account' +import { formatNonAccentedCharacters, validateMnemonic } from '@celo/utils/src/account' import * as bip39 from 'react-native-bip39' import { createQuizWordList, @@ -97,7 +97,11 @@ describe('Mnemonic validation and formatting', () => { 'NFD' ) - const BAD_SPANISH_MNEMONIC = 'avance colmo poema momia cofre pata res verso secta cinco tuberia yacer eterno observar ojo tabaco seta ruina bebé oral miembro gato suelo violín'.normalize( + const SPANISH_MNEMONIC_NO_ACCENTS = 'avance colmo poema momia cofre pata res verso secta cinco tuberia yacer eterno observar ojo tabaco seta ruina bebe oral miembro gato suelo violin'.normalize( + 'NFD' + ) + + const BAD_SPANISH_MNEMONIC = 'avance colmo poema momia cofre pata res verso secta cinco tuberia yacer eterno observar ojo tabaco seta ruina bebé oralio miembro gato suelo violín'.normalize( 'NFD' ) @@ -106,8 +110,8 @@ describe('Mnemonic validation and formatting', () => { const MULTILINE_ENGLISH_MNEMONIC = `there resist cinnamon water salmon spare thumb explain equip uniform control -divorce mushroom head vote below -setup marriage oval topic husband +divorce mushroom head vote below +setup marriage oval topic husband inner surprise invest` const MULTILINE_ENGLISH_MNEMONIC_EXTRA_SPACES = MULTILINE_ENGLISH_MNEMONIC.replace( @@ -134,40 +138,36 @@ inner surprise invest` }) it('validates spanish successfully', () => { - expect( - validateMnemonic( - formatBackupPhraseOnSubmit(SPANISH_MNEMONIC), - MnemonicLanguages.spanish, - bip39 - ) - ).toBeTruthy() + const mnemonic = formatNonAccentedCharacters(formatBackupPhraseOnSubmit(SPANISH_MNEMONIC)) + expect(validateMnemonic(mnemonic, bip39)).toBeTruthy() + }) + + it('validates spanish successfully without mnemonic accents', () => { + const mnemonic = formatNonAccentedCharacters( + formatBackupPhraseOnSubmit(SPANISH_MNEMONIC_NO_ACCENTS) + ) + expect(validateMnemonic(mnemonic, bip39)).toBeTruthy() }) it('validates english successfully', () => { - expect( - validateMnemonic(formatBackupPhraseOnSubmit(ENGLISH_MNEMONIC), undefined, bip39) - ).toBeTruthy() + const mnemonic = formatNonAccentedCharacters(formatBackupPhraseOnSubmit(ENGLISH_MNEMONIC)) + expect(validateMnemonic(mnemonic, bip39)).toBeTruthy() }) it('validates english multiline successfully', () => { - expect( - validateMnemonic(formatBackupPhraseOnSubmit(MULTILINE_ENGLISH_MNEMONIC), undefined, bip39) - ).toBeTruthy() + const mnemonic = formatNonAccentedCharacters( + formatBackupPhraseOnSubmit(MULTILINE_ENGLISH_MNEMONIC) + ) + expect(validateMnemonic(mnemonic, bip39)).toBeTruthy() }) it('does not validate bad english', () => { - expect( - validateMnemonic(formatBackupPhraseOnSubmit(BAD_ENGLISH_MNEMONIC), undefined, bip39) - ).toBeFalsy() + const mnemonic = formatNonAccentedCharacters(formatBackupPhraseOnSubmit(BAD_ENGLISH_MNEMONIC)) + expect(validateMnemonic(mnemonic, bip39)).toBeFalsy() }) it('does not validate bad spanish', () => { - expect( - validateMnemonic( - formatBackupPhraseOnSubmit(BAD_SPANISH_MNEMONIC), - MnemonicLanguages.spanish, - bip39 - ) - ).toBeFalsy() + const mnemonic = formatNonAccentedCharacters(formatBackupPhraseOnSubmit(BAD_SPANISH_MNEMONIC)) + expect(validateMnemonic(mnemonic, bip39)).toBeFalsy() }) }) diff --git a/packages/mobile/src/import/saga.ts b/packages/mobile/src/import/saga.ts index 5e445f620fb..f5080e7d859 100644 --- a/packages/mobile/src/import/saga.ts +++ b/packages/mobile/src/import/saga.ts @@ -1,4 +1,8 @@ -import { generateKeys, validateMnemonic } from '@celo/utils/src/account' +import { + formatNonAccentedCharacters, + generateKeys, + validateMnemonic, +} from '@celo/utils/src/account' import { privateKeyToAddress } from '@celo/utils/src/address' import BigNumber from 'bignumber.js' import * as bip39 from 'react-native-bip39' @@ -29,14 +33,15 @@ export function* importBackupPhraseSaga({ phrase, useEmptyWallet }: ImportBackup Logger.debug(TAG + '@importBackupPhraseSaga', 'Importing backup phrase') yield call(waitWeb3LastBlock) try { - if (!validateMnemonic(phrase, undefined, bip39)) { + const mnemonic = formatNonAccentedCharacters(phrase) + if (!validateMnemonic(mnemonic, bip39)) { Logger.error(TAG + '@importBackupPhraseSaga', 'Invalid mnemonic') yield put(showError(ErrorMessages.INVALID_BACKUP_PHRASE)) yield put(importBackupPhraseFailure()) return } - const keys = yield call(generateKeys, phrase, undefined, undefined, undefined, bip39) + const keys = yield call(generateKeys, mnemonic, undefined, undefined, undefined, bip39) const privateKey = keys.privateKey if (!privateKey) { throw new Error('Failed to convert mnemonic to hex') @@ -65,13 +70,13 @@ export function* importBackupPhraseSaga({ phrase, useEmptyWallet }: ImportBackup } } - const account: string | null = yield call(assignAccountFromPrivateKey, privateKey, phrase) + const account: string | null = yield call(assignAccountFromPrivateKey, privateKey, mnemonic) if (!account) { throw new Error('Failed to assign account from private key') } // Set key in phone's secure store - yield call(storeMnemonic, phrase, account) + yield call(storeMnemonic, mnemonic, account) // Set backup complete so user isn't prompted to do backup flow yield put(setBackupCompleted()) // Set redeem invite complete so user isn't brought back into nux flow diff --git a/packages/sdk/base/src/string.ts b/packages/sdk/base/src/string.ts index fd52e4fa5e9..052145c853c 100644 --- a/packages/sdk/base/src/string.ts +++ b/packages/sdk/base/src/string.ts @@ -6,6 +6,12 @@ export function appendPath(baseUrl: string, path: string) { return baseUrl + '/' + path } +// https://stackoverflow.com/questions/990904/remove-accents-diacritics-in-a-string-in-javascript +export function normalizeAccents(str: string) { + return str.normalize('NFD').replace(/[\u0300-\u036f]/g, '') +} + export const StringBase = { appendPath, + normalizeAccents, } diff --git a/packages/sdk/utils/src/account.test.ts b/packages/sdk/utils/src/account.test.ts index 4162393fedc..2c81427f2af 100644 --- a/packages/sdk/utils/src/account.test.ts +++ b/packages/sdk/utils/src/account.test.ts @@ -1,7 +1,7 @@ import { + formatNonAccentedCharacters, generateKeys, generateMnemonic, - MnemonicLanguages, MnemonicStrength, validateMnemonic, } from './account' @@ -213,7 +213,41 @@ describe('Mnemonic validation', () => { const password = await generateKeys(mnemonics[i], 'password') expect({ derivation0, derivation1, password }).toEqual(expectedPrivateKeys[i]) } - expect(validateMnemonic(spanishMnemonic, MnemonicLanguages.english)).toBeFalsy() expect(validateMnemonic(spanishMnemonic)).toBeTruthy() }) + + it('should generate the same keys for a mnemonic with and without accents', async () => { + const spanishMnemonics = [ + 'avance colmo poema momia cofre pata res verso secta cinco tubería yacer eterno observar ojo tabaco seta ruina bebé oral miembro gato suelo violín', + 'avance colmo poema momia cofre pata res verso secta cinco tuberia yacer eterno observar ojo tabaco seta ruina bebe oral miembro gato suelo violin', + 'avance colmo poema momia cofre pata res verso secta cinco tubería yacer eterno observar ojo tabaco seta ruina bebé oral miembro gato suelo violin', + ] + + const expectedPrivateKeys = { + derivation0: { + address: '0xB49cb22C4e392b2A738B64D40Cc4C62793e4EAa0', + privateKey: '63ecbc2975ef76257a9a9571e4bdcd5b48363422d82ba7317a33499afae1b931', + publicKey: '0318ccbbb9fabe4505009735de92d10062880507cc556274d6ca8629e323488e53', + }, + derivation1: { + address: '0x49Bc3DE20a93eCd6469711Cf100ac7c2AC7C3Ada', + privateKey: '0ae744c1ea19c92b0078612ea5a832c037bc9c4591cc357e23e69f95bdee33ef', + publicKey: '02f95265f5e68529b67858c5353b51efecbfd3864c41355684b44c72d84316436d', + }, + password: { + address: '0xF34a69ACD7112591cAAfB0e9F2D54B00aEeb6073', + privateKey: 'bd26487c13a7c4fca1d960415c7f7cbb5e2814ac30ce82f084c04dfd503a09e7', + publicKey: '03ec45bfcf67678782e4c0e8bc9ceea0c2861f939db9433d0b513598baf3721d4d', + }, + } + + for (let mnemonic of spanishMnemonics) { + mnemonic = formatNonAccentedCharacters(mnemonic) + expect(validateMnemonic(mnemonic)).toBeTruthy() + const derivation0 = await generateKeys(mnemonic) + const derivation1 = await generateKeys(mnemonic, undefined, 0, 1) + const password = await generateKeys(mnemonic, 'password') + expect({ derivation0, derivation1, password }).toEqual(expectedPrivateKeys) + } + }) }) diff --git a/packages/sdk/utils/src/account.ts b/packages/sdk/utils/src/account.ts index 3ecd328ced3..04441b2df31 100644 --- a/packages/sdk/utils/src/account.ts +++ b/packages/sdk/utils/src/account.ts @@ -5,6 +5,7 @@ import { MnemonicStrength, RandomNumberGenerator, } from '@celo/base/lib/account' +import { normalizeAccents } from '@celo/base/lib/string' import * as bip32 from 'bip32' import * as bip39 from 'bip39' import { keccak256 } from 'ethereumjs-util' @@ -54,24 +55,75 @@ export async function generateMnemonic( return bip39ToUse.generateMnemonic(strength, undefined, getWordList(language)) } -export function validateMnemonic( - mnemonic: string, - defaultLanguage?: MnemonicLanguages, - bip39ToUse: Bip39 = bip39Wrapper -) { - const mnemonicWords = mnemonic.trim().split(' ') - const languages = defaultLanguage - ? [defaultLanguage] - : getAllLanguages().filter((lang) => lang !== defaultLanguage) +export function validateMnemonic(mnemonic: string, bip39ToUse: Bip39 = bip39Wrapper) { + const languages = getAllLanguages() for (const language of languages) { - const wordList = getWordList(language) - if (mnemonicWords.every((word) => wordList.includes(word))) { - return bip39ToUse.validateMnemonic(mnemonic, getWordList(language)) + if (bip39ToUse.validateMnemonic(mnemonic, getWordList(language))) { + return true } } + return false } +export function formatNonAccentedCharacters(mnemonic: string) { + const languages = getAllLanguages() + const normMnemonicArr = normalizeAccents(mnemonic) + .toLowerCase() + .trim() + .split(' ') + + for (const language of languages) { + if (isLatinBasedLanguage(language)) { + const wordList = getWordList(language) + const normWordListMap = createNormalizedWordListMap(wordList) + const languageMatches = arrayContainedInMap(normMnemonicArr, normWordListMap) + + if (languageMatches) { + return replaceIncorrectlyAccentedWords(mnemonic, normMnemonicArr, normWordListMap) + } + } + } + + return mnemonic +} + +const createNormalizedWordListMap = (wordList: string[]) => { + const normWordListMap = new Map() + for (const word of wordList) { + const noramlizedWord = normalizeAccents(word) + normWordListMap.set(noramlizedWord, word) + } + return normWordListMap +} + +const arrayContainedInMap = (array: string[], map: Map) => { + for (const item of array) { + if (!map.has(item)) { + return false + } + } + return true +} + +const replaceIncorrectlyAccentedWords = ( + mnemonic: string, + normMnemonicArr: string[], + normWordListMap: Map +) => { + const mnemonicArr = [...mnemonic.trim().split(' ')] + for (let i = 0; i < normMnemonicArr.length; i += 1) { + const noramlizedWord = normMnemonicArr[i] + const nonNormalizedWord = normWordListMap.get(noramlizedWord) + + if (nonNormalizedWord) { + mnemonicArr[i] = nonNormalizedWord + } + } + + return mnemonicArr.join(' ') +} + export async function generateKeys( mnemonic: string, password?: string, @@ -131,6 +183,18 @@ export function generateKeysFromSeed( } } +function isLatinBasedLanguage(language: MnemonicLanguages) { + if ( + language === MnemonicLanguages.chinese_simplified || + language === MnemonicLanguages.chinese_traditional || + language === MnemonicLanguages.japanese || + language === MnemonicLanguages.korean + ) { + return false + } + return true +} + // Unify the bip39.wordlists (otherwise depends on the instance of the bip39) function getWordList(language?: MnemonicLanguages) { switch (language) { diff --git a/packages/sdk/utils/src/string.ts b/packages/sdk/utils/src/string.ts index 296dcda888b..6aba2ea3e7d 100644 --- a/packages/sdk/utils/src/string.ts +++ b/packages/sdk/utils/src/string.ts @@ -1,7 +1,8 @@ -import { appendPath } from '@celo/base/lib/string' +import { appendPath, normalizeAccents } from '@celo/base/lib/string' // Exports moved to @celo/base, forwarding them // here for backwards compatibility -export { appendPath } from '@celo/base/lib/string' +export { appendPath, normalizeAccents } from '@celo/base/lib/string' export const StringUtils = { appendPath, + normalizeAccents, }