Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Mnemonic validation flexibility within Valora #6372

Merged
merged 14 commits into from
Jan 7, 2021
Merged
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
4 changes: 3 additions & 1 deletion packages/cli/src/commands/account/new.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
formatNonAccentedCharacters,
generateKeys,
generateMnemonic,
MnemonicLanguages,
Expand Down Expand Up @@ -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 {
Expand Down
12 changes: 9 additions & 3 deletions packages/cli/src/commands/account/recover-old.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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 {
Expand Down
54 changes: 27 additions & 27 deletions packages/mobile/src/backup/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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'
)

Expand All @@ -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(
Expand All @@ -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()
})
})
15 changes: 10 additions & 5 deletions packages/mobile/src/import/saga.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions packages/sdk/base/src/string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
38 changes: 36 additions & 2 deletions packages/sdk/utils/src/account.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {
formatNonAccentedCharacters,
generateKeys,
generateMnemonic,
MnemonicLanguages,
MnemonicStrength,
validateMnemonic,
} from './account'
Expand Down Expand Up @@ -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)
}
})
})
88 changes: 76 additions & 12 deletions packages/sdk/utils/src/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -54,24 +55,75 @@ export async function generateMnemonic(
return bip39ToUse.generateMnemonic(strength, undefined, getWordList(language))
}

export function validateMnemonic(
mnemonic: string,
defaultLanguage?: MnemonicLanguages,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not entire sure of removing these. In a way, if you know the language, you will avoid to search in every dictionary.
I'm more into the idea of searching in the dictionary that the user said, or in all of them otherwise (nil/null).

Thinking more of a common utils side, and not our use case in Valora

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about my approach this way:

Good - eliminate the possibility of user error (i.e., if there is a mismatch between defaultLanguage and mnemonic language)

Bad - slightly less efficient as it defaults to checking all languages. However, there are not very many languages and bip39.validateMnemonic appears very optimized and will fail quickly on a language mismatch

What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I agree. We could eventually have another function that requires an specific language if we see that we need to optimise that (which are not the actual cases due to we are only using these to create/update accounts, and won't be called too often)

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<string, string>) => {
for (const item of array) {
if (!map.has(item)) {
return false
}
}
return true
}

const replaceIncorrectlyAccentedWords = (
mnemonic: string,
normMnemonicArr: string[],
normWordListMap: Map<string, string>
) => {
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,
Expand Down Expand Up @@ -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
) {
tarikbellamine marked this conversation as resolved.
Show resolved Hide resolved
return false
}
return true
}

// Unify the bip39.wordlists (otherwise depends on the instance of the bip39)
function getWordList(language?: MnemonicLanguages) {
switch (language) {
Expand Down
5 changes: 3 additions & 2 deletions packages/sdk/utils/src/string.ts
Original file line number Diff line number Diff line change
@@ -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,
}