From a4a1fc5b728d09d5d7abe29f06723b7b97dca657 Mon Sep 17 00:00:00 2001 From: lukaw3d Date: Wed, 22 Nov 2023 02:16:50 +0100 Subject: [PATCH] Add utils for migration from V0 encrypted state to RootState --- .changelog/1771.internal.md | 1 + .../walletExtensionV0.test.ts.snap | 172 ++++++++++++++++++ src/utils/__tests__/walletExtensionV0.test.ts | 21 +++ src/utils/walletExtensionV0.ts | 164 +++++++++++++++++ 4 files changed, 358 insertions(+) create mode 100644 .changelog/1771.internal.md create mode 100644 src/utils/__tests__/__snapshots__/walletExtensionV0.test.ts.snap create mode 100644 src/utils/__tests__/walletExtensionV0.test.ts diff --git a/.changelog/1771.internal.md b/.changelog/1771.internal.md new file mode 100644 index 0000000000..429dc13e60 --- /dev/null +++ b/.changelog/1771.internal.md @@ -0,0 +1 @@ +Add utils for migration from V0 encrypted state to RootState diff --git a/src/utils/__tests__/__snapshots__/walletExtensionV0.test.ts.snap b/src/utils/__tests__/__snapshots__/walletExtensionV0.test.ts.snap new file mode 100644 index 0000000000..10e93e68ee --- /dev/null +++ b/src/utils/__tests__/__snapshots__/walletExtensionV0.test.ts.snap @@ -0,0 +1,172 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`decryptWithPasswordV0 1`] = ` +{ + "invalidPrivateKeys": [ + { + "address": "oasis1qpu7j3q3lhy0al2d4nspy0gmy9msy2s2yvap66r7", + "name": "bad privatekey1", + "privateKeyWithTypos": "1ab9bf560d4ca4044f31b99da44c5503d0e48f508c892cd82c5c4a9cfc76d1fb3e3a7c47f18a67b70410105d9444766269bb1a1e418b1cdf3a6aba8f18923d3a", + }, + ], + "language": "en", + "mnemonic": "among scrap refuse hungry remove unhappy crack horn half cruel skull project dentist poet design paper eternal stool tomato cabin helmet funny victory happy", + "state": { + "contacts": { + "oasis1qq3xrq0urs8qcffhvmhfhz4p0mu7ewc8rscnlwxe": { + "address": "oasis1qq3xrq0urs8qcffhvmhfhz4p0mu7ewc8rscnlwxe", + "name": "stakefish", + }, + "oasis1qqekv2ymgzmd8j2s2u7g0hhc7e77e654kvwqtjwm": { + "address": "oasis1qqekv2ymgzmd8j2s2u7g0hhc7e77e654kvwqtjwm", + "name": "Binance", + }, + }, + "evmAccounts": { + "0xbA1b346233E5bB5b44f5B4aC6bF224069f427b18": { + "ethAddress": "0xbA1b346233E5bB5b44f5B4aC6bF224069f427b18", + "ethPrivateKey": "6593a788d944bb3e25357df140fac5b0e6273f1500a3b37d6513bf9e9807afe2", + }, + }, + "network": { + "chainContext": "", + "epoch": 0, + "minimumStakingAmount": 0, + "selectedNetwork": "local", + "ticker": "", + }, + "theme": { + "selected": "light", + }, + "wallet": { + "selectedWallet": "oasis1qq30ejf9puuc6qnrazmy9dmn7f3gessveum5wnr6", + "wallets": { + "oasis1qpfltmpdyjvv88x9n7x0uvspjdtxycz9gc9zg429": { + "address": "oasis1qpfltmpdyjvv88x9n7x0uvspjdtxycz9gc9zg429", + "balance": { + "available": null, + "debonding": null, + "delegations": null, + "total": null, + }, + "name": "ledger5", + "path": [ + 44, + 474, + 0, + 0, + 5, + ], + "pathDisplay": "m/44'/474'/0'/0'/5'", + "publicKey": "75c515b91e582698550aede5bcff710394eb2d5e89780a254220f11f0976fd06", + "type": "ledger", + }, + "oasis1qpfq4k8s5r0yalyrjcrt8nu2agy4wcwen5xmukwk": { + "address": "oasis1qpfq4k8s5r0yalyrjcrt8nu2agy4wcwen5xmukwk", + "balance": { + "available": null, + "debonding": null, + "delegations": null, + "total": null, + }, + "name": "ledger1", + "path": [ + 44, + 474, + 0, + 0, + 0, + ], + "pathDisplay": "m/44'/474'/0'/0'/0'", + "publicKey": "c7875b0f3dc2fdcb7fb6c05a1a2d6c0638eed37888d593d8a90ff18190fbab44", + "type": "ledger", + }, + "oasis1qpw6nzr77u5nfucee5wjp544hzgmpjjj2gz5p6zq": { + "address": "oasis1qpw6nzr77u5nfucee5wjp544hzgmpjjj2gz5p6zq", + "balance": { + "available": null, + "debonding": null, + "delegations": null, + "total": null, + }, + "name": "Account 1", + "path": [ + 44, + 474, + 0, + ], + "pathDisplay": "m/44'/474'/0'", + "privateKey": "86b12cfbcd816983fa2ac199c21b9b217391a7345d85c0c8fc7b715fc8fae19b7d3f6555015b70642912966317a3d084d0d9670415c45084e750ff5378535737", + "publicKey": "7d3f6555015b70642912966317a3d084d0d9670415c45084e750ff5378535737", + "type": "mnemonic", + }, + "oasis1qpwlwv5y25e8h3cwd3z0glevj20y2mv5pvfw7pme": { + "address": "oasis1qpwlwv5y25e8h3cwd3z0glevj20y2mv5pvfw7pme", + "balance": { + "available": null, + "debonding": null, + "delegations": null, + "total": null, + }, + "name": "Account 2", + "path": [ + 44, + 474, + 1, + ], + "pathDisplay": "m/44'/474'/1'", + "privateKey": "c43b207bb525f5486649debeb0c8597c23db4fca0d60d6ba93b36c12b2a884186fef3b736a3286339c47f6c3fdeb0346aadc76677b7f889f6c78f768a289c1b5", + "publicKey": "6fef3b736a3286339c47f6c3fdeb0346aadc76677b7f889f6c78f768a289c1b5", + "type": "mnemonic", + }, + "oasis1qq30ejf9puuc6qnrazmy9dmn7f3gessveum5wnr6": { + "address": "oasis1qq30ejf9puuc6qnrazmy9dmn7f3gessveum5wnr6", + "balance": { + "available": null, + "debonding": null, + "delegations": null, + "total": null, + }, + "name": "short privatekey", + "privateKey": "0521f84460c6b4b18bbb1e7535dc7841e08688bf7aaea76285083c052e9b28fad8ed928b97756739db1eb2019abfaabe970b903a8e95b6dfc3e03c25559516ea", + "publicKey": "d8ed928b97756739db1eb2019abfaabe970b903a8e95b6dfc3e03c25559516ea", + "type": "private_key", + }, + "oasis1qrf4y7aelwuusc270e8qx04ysr45w3q0zyavrpdk": { + "address": "oasis1qrf4y7aelwuusc270e8qx04ysr45w3q0zyavrpdk", + "balance": { + "available": null, + "debonding": null, + "delegations": null, + "total": null, + }, + "name": "ledger5-1", + "path": [ + 44, + 474, + 0, + 0, + 6, + ], + "pathDisplay": "m/44'/474'/0'/0'/6'", + "publicKey": "603cb015cb9ec347b1f28ffa64b910b23c207c81a41f0a9b4cdbf5310b342bd3", + "type": "ledger", + }, + "oasis1qzp9vfeafqg8ejpcjl8m947weympxja4dqarmu52": { + "address": "oasis1qzp9vfeafqg8ejpcjl8m947weympxja4dqarmu52", + "balance": { + "available": null, + "debonding": null, + "delegations": null, + "total": null, + }, + "name": "private key1", + "privateKey": "0dd622997e9a8fdd7304bc1858857ac67291635bf741391a58920737b19dc717cb79bba4b4ee741688f9838ed03a5c3c8e6ae98f44fc5395cd04c75d251a90c0", + "publicKey": "cb79bba4b4ee741688f9838ed03a5c3c8e6ae98f44fc5395cd04c75d251a90c0", + "type": "private_key", + }, + }, + }, + }, +} +`; diff --git a/src/utils/__tests__/walletExtensionV0.test.ts b/src/utils/__tests__/walletExtensionV0.test.ts new file mode 100644 index 0000000000..1431a8e516 --- /dev/null +++ b/src/utils/__tests__/walletExtensionV0.test.ts @@ -0,0 +1,21 @@ +import { password, walletExtensionV0PersistedState, wrongPassword } from '../__fixtures__/test-inputs' +import { decryptWithPasswordV0 } from '../walletExtensionV0' + +let jsdomCrypto: any +beforeEach(() => { + jsdomCrypto = global.crypto + Object.defineProperty(global, 'crypto', { value: require('crypto') }) +}) + +afterEach(() => { + Object.defineProperty(global, 'crypto', { value: jsdomCrypto }) +}) + +test('decryptWithPasswordV0', async () => { + const migratedV0Fixture = await decryptWithPasswordV0(password, walletExtensionV0PersistedState) + expect(migratedV0Fixture).toMatchSnapshot() + + await expect(decryptWithPasswordV0(wrongPassword, walletExtensionV0PersistedState)).rejects.toThrow( + 'Password wrong', + ) +}) diff --git a/src/utils/walletExtensionV0.ts b/src/utils/walletExtensionV0.ts index 79e6256df3..6c55eb216b 100644 --- a/src/utils/walletExtensionV0.ts +++ b/src/utils/walletExtensionV0.ts @@ -1,4 +1,12 @@ import { runtimeIs } from '../config' +import { decrypt as metamaskDecrypt } from '@metamask/browser-passworder' +import { PersistedRootState } from '../app/state/persist/types' +import { initialState as initialNetworkState } from '../app/state/network' +import { Wallet, WalletType } from '../app/state/wallet/types' +import { hex2uint, uint2hex } from '../app/lib/helpers' +import { OasisKey } from '../app/lib/key' +import { PasswordWrongError } from '../types/errors' +import { LanguageKey } from '../locales/i18n' type EncryptedString = string & { encryptedType: T } export type StringifiedType = string & { stringifiedType: T } @@ -94,3 +102,159 @@ export async function readStorageV0() { }, } as WalletExtensionV0State } + +async function typedMetamaskDecrypt(password: string, encrypted: EncryptedString): Promise { + try { + return await metamaskDecrypt(password, encrypted) + } catch (error) { + throw new PasswordWrongError() + } +} + +function typedJsonParse(str: StringifiedType): T { + return JSON.parse(str) +} + +function validateAndExpandPrivateKey(privateKeyLongOrShortOrTyposHex: string): string | '' { + try { + return uint2hex(OasisKey.fromPrivateKey(hex2uint(privateKeyLongOrShortOrTyposHex))) + } catch (e) { + return '' + } +} + +export async function decryptWithPasswordV0(password: string, extensionV0State: WalletExtensionV0State) { + if (!extensionV0State.chromeStorageLocal.keyringData) throw new Error('No v0 encrypted data') + const keyringData = ( + await typedMetamaskDecrypt(password, extensionV0State.chromeStorageLocal.keyringData) + )[0] + const mnemonic = await typedMetamaskDecrypt(password, keyringData.mnemonic) + + const decryptedAccounts = await Promise.all( + keyringData.accounts.map(async acc => { + if (!('privateKey' in acc)) return acc + const privateKeyLongOrShortOrTypos = await typedMetamaskDecrypt(password, acc.privateKey) + if (acc.type === 'WALLET_OUTSIDE_SECP256K1') { + return { + ...acc, + privateKey: privateKeyLongOrShortOrTypos, + privateKeyLongOrShortOrTypos, + } + } + + const privateKey = validateAndExpandPrivateKey(privateKeyLongOrShortOrTypos) + return { + ...acc, + privateKey, + privateKeyLongOrShortOrTypos, + } + }), + ) + + const [validAccounts, invalidAccounts] = [ + decryptedAccounts + .map(acc => ('privateKey' in acc && acc.privateKey === '' ? undefined : acc)) + .filter((a): a is NonNullable => !!a), + decryptedAccounts + .map(acc => ('privateKey' in acc && acc.privateKey === '' ? acc : undefined)) + .filter((a): a is NonNullable => !!a), + ] + + const wallets = Object.fromEntries( + validAccounts + .map((acc): undefined | Wallet => { + if (acc.type === 'WALLET_INSIDE') { + return { + publicKey: acc.publicKey, + address: acc.address, + type: WalletType.Mnemonic, + path: [44, 474, acc.hdPath], + pathDisplay: `m/44'/474'/${acc.hdPath}'`, + privateKey: acc.privateKey, + balance: { + available: null, + debonding: null, + delegations: null, + total: null, + }, + name: acc.accountName, + } + } + if (acc.type === 'WALLET_OUTSIDE') { + return { + publicKey: acc.publicKey, + address: acc.address, + type: WalletType.PrivateKey, + privateKey: acc.privateKey, + balance: { + available: null, + debonding: null, + delegations: null, + total: null, + }, + name: acc.accountName, + } + } + if (acc.type === 'WALLET_LEDGER') { + return { + publicKey: acc.publicKey, + address: acc.address, + type: WalletType.Ledger, + path: [44, 474, 0, 0, acc.ledgerHdIndex], + pathDisplay: `m/44'/474'/0'/0'/${acc.ledgerHdIndex}'`, + balance: { + available: null, + debonding: null, + delegations: null, + total: null, + }, + name: acc.accountName, + } + } + return undefined + }) + .filter((a): a is NonNullable => !!a) + .map(a => [a.address, a]), + ) + + const invalidPrivateKeys = invalidAccounts.map(a => ({ + privateKeyWithTypos: a.privateKeyLongOrShortOrTypos, + name: a.accountName, + address: a.address, + })) + + const evmAccounts = Object.fromEntries( + validAccounts + .map(acc => { + if (acc.type !== 'WALLET_OUTSIDE_SECP256K1') return undefined + return { + ethPrivateKey: acc.privateKey, + ethAddress: acc.evmAddress, + } + }) + .filter((a): a is NonNullable => !!a) + .map(a => [a.ethAddress, a]), + ) + + const observedAccounts = validAccounts + .filter(acc => acc.type === 'WALLET_OBSERVE') + .map(acc => ({ name: acc.accountName, address: acc.address })) + const addressBookAccounts = typedJsonParse(extensionV0State.localStorage.ADDRESS_BOOK_CONFIG) + const contacts = Object.fromEntries([...observedAccounts, ...addressBookAccounts].map(a => [a.address, a])) + + const language: LanguageKey = extensionV0State.localStorage.LANGUAGE_CONFIG === 'zh_CN' ? 'zh_CN' : 'en' + + const state: PersistedRootState = { + contacts: contacts, + evmAccounts: evmAccounts, + network: initialNetworkState, + theme: { + selected: 'light', + }, + wallet: { + selectedWallet: keyringData.currentAddress, + wallets: wallets, + }, + } + return { mnemonic, invalidPrivateKeys, language, state } +}