From af2e0b69e1cd307d91d25edd310ae064b8fa308b Mon Sep 17 00:00:00 2001 From: aitrean Date: Thu, 11 Jan 2018 01:44:13 -0500 Subject: [PATCH] Web Worker Decrypt (#680) 1. Attempt an empty password every time a keystore is uploaded. 2. Delegate scrypt decryption (ie ethereumjs-wallet.fromV3) to its own web worker and interface with it through an async typescript function that gets handled in the wallet saga. This keeps the UI unblocked when scrypt takes a long time to decrypt. 3. Add logic to show a spinner x number of milliseconds after file upload so the user will understand when a wallet is being decrypted. --- common/actions/wallet/actionCreators.ts | 13 ++++++ common/actions/wallet/actionTypes.ts | 13 +++++- common/actions/wallet/constants.ts | 5 ++- .../WalletDecrypt/WalletDecrypt.tsx | 20 ++++++++- .../WalletDecrypt/components/Keystore.tsx | 26 ++++++++--- .../libs/wallet/non-deterministic/helpers.ts | 16 +++++-- .../libs/wallet/non-deterministic/wallets.ts | 6 +-- common/libs/web-workers/scrypt-wrapper.ts | 23 ++++++++++ .../workers/scrypt-worker.worker.ts | 18 ++++++++ common/reducers/wallet.ts | 17 +++++++ common/sagas/wallet/wallet.ts | 44 ++++++++++++++++--- common/typescript/worker-loader.d.ts | 6 +++ jest_config/__mocks__/workerMock.js | 1 + jest_config/jest.config.json | 5 ++- package.json | 3 +- spec/sagas/wallet.spec.tsx | 27 ++++++++++-- webpack_config/webpack.base.js | 4 ++ 17 files changed, 219 insertions(+), 28 deletions(-) create mode 100644 common/libs/web-workers/scrypt-wrapper.ts create mode 100644 common/libs/web-workers/workers/scrypt-worker.worker.ts create mode 100644 common/typescript/worker-loader.d.ts create mode 100644 jest_config/__mocks__/workerMock.js diff --git a/common/actions/wallet/actionCreators.ts b/common/actions/wallet/actionCreators.ts index ce25069d..23ee04d4 100644 --- a/common/actions/wallet/actionCreators.ts +++ b/common/actions/wallet/actionCreators.ts @@ -43,12 +43,25 @@ export function setWallet(value: IWallet): types.SetWalletAction { }; } +export function setWalletPending(loadingStatus: boolean): types.SetWalletPendingAction { + return { + type: TypeKeys.WALLET_SET_PENDING, + payload: loadingStatus + }; +} + export function setBalancePending(): types.SetBalancePendingAction { return { type: TypeKeys.WALLET_SET_BALANCE_PENDING }; } +export function setPasswordPrompt(): types.SetPasswordPendingAction { + return { + type: TypeKeys.WALLET_SET_PASSWORD_PENDING + }; +} + export type TSetBalance = typeof setBalanceFullfilled; export function setBalanceFullfilled(value: Wei): types.SetBalanceFullfilledAction { return { diff --git a/common/actions/wallet/actionTypes.ts b/common/actions/wallet/actionTypes.ts index 2f02b354..f154f2e8 100644 --- a/common/actions/wallet/actionTypes.ts +++ b/common/actions/wallet/actionTypes.ts @@ -32,6 +32,11 @@ export interface ResetWalletAction { type: TypeKeys.WALLET_RESET; } +export interface SetWalletPendingAction { + type: TypeKeys.WALLET_SET_PENDING; + payload: boolean; +} + /*** Set Balance ***/ export interface SetBalancePendingAction { type: TypeKeys.WALLET_SET_BALANCE_PENDING; @@ -116,10 +121,15 @@ export interface SetWalletConfigAction { payload: WalletConfig; } +export interface SetPasswordPendingAction { + type: TypeKeys.WALLET_SET_PASSWORD_PENDING; +} + /*** Union Type ***/ export type WalletAction = | UnlockPrivateKeyAction | SetWalletAction + | SetWalletPendingAction | ResetWalletAction | SetBalancePendingAction | SetBalanceFullfilledAction @@ -132,4 +142,5 @@ export type WalletAction = | SetTokenBalanceRejectedAction | ScanWalletForTokensAction | SetWalletTokensAction - | SetWalletConfigAction; + | SetWalletConfigAction + | SetPasswordPendingAction; diff --git a/common/actions/wallet/constants.ts b/common/actions/wallet/constants.ts index bf2c547b..c1c2ff9d 100644 --- a/common/actions/wallet/constants.ts +++ b/common/actions/wallet/constants.ts @@ -10,11 +10,14 @@ export enum TypeKeys { WALLET_SET_TOKEN_BALANCES_PENDING = 'WALLET_SET_TOKEN_BALANCES_PENDING', WALLET_SET_TOKEN_BALANCES_FULFILLED = 'WALLET_SET_TOKEN_BALANCES_FULFILLED', WALLET_SET_TOKEN_BALANCES_REJECTED = 'WALLET_SET_TOKEN_BALANCES_REJECTED', + WALLET_SET_PENDING = 'WALLET_SET_PENDING', + WALLET_SET_NOT_PENDING = 'WALLET_SET_NOT_PENDING', WALLET_SET_TOKEN_BALANCE_PENDING = 'WALLET_SET_TOKEN_BALANCE_PENDING', WALLET_SET_TOKEN_BALANCE_FULFILLED = 'WALLET_SET_TOKEN_BALANCE_FULFILLED', WALLET_SET_TOKEN_BALANCE_REJECTED = 'WALLET_SET_TOKEN_BALANCE_REJECTED', WALLET_SCAN_WALLET_FOR_TOKENS = 'WALLET_SCAN_WALLET_FOR_TOKENS', WALLET_SET_WALLET_TOKENS = 'WALLET_SET_WALLET_TOKENS', WALLET_SET_CONFIG = 'WALLET_SET_CONFIG', - WALLET_RESET = 'WALLET_RESET' + WALLET_RESET = 'WALLET_RESET', + WALLET_SET_PASSWORD_PENDING = 'WALLET_SET_PASSWORD_PENDING' } diff --git a/common/components/WalletDecrypt/WalletDecrypt.tsx b/common/components/WalletDecrypt/WalletDecrypt.tsx index 9a7191eb..8e0539e1 100644 --- a/common/components/WalletDecrypt/WalletDecrypt.tsx +++ b/common/components/WalletDecrypt/WalletDecrypt.tsx @@ -33,6 +33,7 @@ import { import { AppState } from 'reducers'; import { knowledgeBaseURL, isWeb3NodeAvailable } from 'config/data'; import { IWallet } from 'libs/wallet'; +import { showNotification, TShowNotification } from 'actions/notifications'; import DigitalBitboxIcon from 'assets/images/wallets/digital-bitbox.svg'; import LedgerIcon from 'assets/images/wallets/ledger.svg'; import MetamaskIcon from 'assets/images/wallets/metamask.svg'; @@ -49,10 +50,13 @@ interface Props { setWallet: TSetWallet; unlockWeb3: TUnlockWeb3; resetWallet: TResetWallet; + showNotification: TShowNotification; wallet: IWallet; hidden?: boolean; offline: boolean; disabledWallets?: string[]; + isWalletPending: AppState['wallet']['isWalletPending']; + isPasswordPending: AppState['wallet']['isPasswordPending']; } interface State { @@ -210,6 +214,15 @@ export class WalletDecrypt extends Component { value={this.state.value} onChange={this.onChange} onUnlock={this.onUnlock} + showNotification={this.props.showNotification} + isWalletPending={ + this.state.selectedWalletKey === 'keystore-file' ? this.props.isWalletPending : undefined + } + isPasswordPending={ + this.state.selectedWalletKey === 'keystore-file' + ? this.props.isPasswordPending + : undefined + } /> ); } @@ -376,7 +389,9 @@ export class WalletDecrypt extends Component { function mapStateToProps(state: AppState) { return { offline: state.config.offline, - wallet: state.wallet.inst + wallet: state.wallet.inst, + isWalletPending: state.wallet.isWalletPending, + isPasswordPending: state.wallet.isPasswordPending }; } @@ -387,5 +402,6 @@ export default connect(mapStateToProps, { unlockWeb3, setWallet, resetWallet, - resetTransactionState: reset + resetTransactionState: reset, + showNotification })(WalletDecrypt); diff --git a/common/components/WalletDecrypt/components/Keystore.tsx b/common/components/WalletDecrypt/components/Keystore.tsx index 98b95a2a..0b938124 100644 --- a/common/components/WalletDecrypt/components/Keystore.tsx +++ b/common/components/WalletDecrypt/components/Keystore.tsx @@ -1,6 +1,8 @@ import { isKeystorePassRequired } from 'libs/wallet'; import React, { Component } from 'react'; import translate, { translateRaw } from 'translations'; +import Spinner from 'components/ui/Spinner'; +import { TShowNotification } from 'actions/notifications'; export interface KeystoreValue { file: string; @@ -18,15 +20,23 @@ function isPassRequired(file: string): boolean { return passReq; } +function isValidFile(rawFile: File): boolean { + const fileType = rawFile.type; + return fileType === '' || fileType === 'application/json'; +} + export class KeystoreDecrypt extends Component { public props: { value: KeystoreValue; + isWalletPending: boolean; + isPasswordPending: boolean; onChange(value: KeystoreValue): void; onUnlock(): void; + showNotification(level: string, message: string): TShowNotification; }; public render() { - const { file, password } = this.props.value; + const { isWalletPending, isPasswordPending, value: { file, password } } = this.props; const passReq = isPassRequired(file); const unlockDisabled = !file || (passReq && !password); @@ -44,7 +54,8 @@ export class KeystoreDecrypt extends Component { {translate('ADD_Radio_2_short')} -
+ {isWalletPending ? : ''} +

{translate('ADD_Label_3')}

0 ? 'is-valid' : 'is-invalid'}`} @@ -97,10 +108,15 @@ export class KeystoreDecrypt extends Component { this.props.onChange({ ...this.props.value, file: keystore, - valid: keystore.length && !passReq + valid: keystore.length && !passReq, + password: '' }); + this.props.onUnlock(); }; - - fileReader.readAsText(inputFile, 'utf-8'); + if (isValidFile(inputFile)) { + fileReader.readAsText(inputFile, 'utf-8'); + } else { + this.props.showNotification('danger', translateRaw('ERROR_3')); + } }; } diff --git a/common/libs/wallet/non-deterministic/helpers.ts b/common/libs/wallet/non-deterministic/helpers.ts index f4645e6b..a51a6181 100644 --- a/common/libs/wallet/non-deterministic/helpers.ts +++ b/common/libs/wallet/non-deterministic/helpers.ts @@ -58,6 +58,10 @@ const isKeystorePassRequired = (file: string): boolean => { ); }; +const getUtcWallet = (file: string, password: string): Promise => { + return UtcWallet(file, password); +}; + const getPrivKeyWallet = (key: string, password: string) => key.length === 64 ? PrivKeyWallet(Buffer.from(key, 'hex')) @@ -79,12 +83,16 @@ const getKeystoreWallet = (file: string, password: string) => { case KeystoreTypes.v2Unencrypted: return PrivKeyWallet(Buffer.from(parsed.privKey, 'hex')); - case KeystoreTypes.utc: - return UtcWallet(file, password); - default: throw Error('Unknown wallet'); } }; -export { isKeystorePassRequired, getPrivKeyWallet, getKeystoreWallet }; +export { + isKeystorePassRequired, + determineKeystoreType, + getPrivKeyWallet, + getKeystoreWallet, + getUtcWallet, + KeystoreTypes +}; diff --git a/common/libs/wallet/non-deterministic/wallets.ts b/common/libs/wallet/non-deterministic/wallets.ts index 9227aed5..3d1260fc 100644 --- a/common/libs/wallet/non-deterministic/wallets.ts +++ b/common/libs/wallet/non-deterministic/wallets.ts @@ -1,7 +1,8 @@ -import { fromPrivateKey, fromEthSale, fromV3 } from 'ethereumjs-wallet'; +import { fromPrivateKey, fromEthSale } from 'ethereumjs-wallet'; import { fromEtherWallet } from 'ethereumjs-wallet/thirdparty'; import { signWrapper } from './helpers'; import { decryptPrivKey } from 'libs/decrypt'; +import { fromV3 } from 'libs/web-workers/scrypt-wrapper'; import Web3Wallet from './web3'; import AddressOnlyWallet from './address'; @@ -16,8 +17,7 @@ const MewV1Wallet = (keystore: string, password: string) => const PrivKeyWallet = (privkey: Buffer) => signWrapper(fromPrivateKey(privkey)); -const UtcWallet = (keystore: string, password: string) => - signWrapper(fromV3(keystore, password, true)); +const UtcWallet = (keystore: string, password: string) => fromV3(keystore, password, true); export { EncryptedPrivateKeyWallet, diff --git a/common/libs/web-workers/scrypt-wrapper.ts b/common/libs/web-workers/scrypt-wrapper.ts new file mode 100644 index 00000000..4e6bf37b --- /dev/null +++ b/common/libs/web-workers/scrypt-wrapper.ts @@ -0,0 +1,23 @@ +import { IFullWallet, fromPrivateKey } from 'ethereumjs-wallet'; +import { toBuffer } from 'ethereumjs-util'; +import Worker from 'worker-loader!./workers/scrypt-worker.worker.ts'; + +export const fromV3 = ( + keystore: string, + password: string, + nonStrict: boolean +): Promise => { + return new Promise((resolve, reject) => { + const scryptWorker = new Worker(); + scryptWorker.postMessage({ keystore, password, nonStrict }); + scryptWorker.onmessage = event => { + const data: string = event.data; + try { + const wallet = fromPrivateKey(toBuffer(data)); + resolve(wallet); + } catch (e) { + reject(e); + } + }; + }); +}; diff --git a/common/libs/web-workers/workers/scrypt-worker.worker.ts b/common/libs/web-workers/workers/scrypt-worker.worker.ts new file mode 100644 index 00000000..38df1d1d --- /dev/null +++ b/common/libs/web-workers/workers/scrypt-worker.worker.ts @@ -0,0 +1,18 @@ +import { fromV3, IFullWallet } from 'ethereumjs-wallet'; + +const scryptWorker: Worker = self as any; +interface DecryptionParameters { + keystore: string; + password: string; + nonStrict: boolean; +} + +scryptWorker.onmessage = (event: MessageEvent) => { + const info: DecryptionParameters = event.data; + try { + const rawKeystore: IFullWallet = fromV3(info.keystore, info.password, info.nonStrict); + scryptWorker.postMessage(rawKeystore.getPrivateKeyString()); + } catch (e) { + scryptWorker.postMessage(e.message); + } +}; diff --git a/common/reducers/wallet.ts b/common/reducers/wallet.ts index b9581c89..2f001c88 100644 --- a/common/reducers/wallet.ts +++ b/common/reducers/wallet.ts @@ -4,6 +4,7 @@ import { SetWalletAction, WalletAction, SetWalletConfigAction, + SetWalletPendingAction, TypeKeys, SetTokenBalanceFulfilledAction } from 'actions/wallet'; @@ -21,7 +22,9 @@ export interface State { error: string | null; }; }; + isWalletPending: boolean; isTokensLoading: boolean; + isPasswordPending: boolean; tokensError: string | null; hasSavedWalletTokens: boolean; } @@ -31,6 +34,8 @@ export const INITIAL_STATE: State = { config: null, balance: { isPending: false, wei: null }, tokens: {}, + isWalletPending: false, + isPasswordPending: false, isTokensLoading: false, tokensError: null, hasSavedWalletTokens: true @@ -61,6 +66,14 @@ function setBalanceRejected(state: State): State { return { ...state, balance: { ...state.balance, isPending: false } }; } +function setWalletPending(state: State, action: SetWalletPendingAction): State { + return { ...state, isWalletPending: action.payload }; +} + +function setPasswordPending(state: State): State { + return { ...state, isPasswordPending: true }; +} + function setTokenBalancesPending(state: State): State { return { ...state, @@ -143,6 +156,8 @@ export function wallet(state: State = INITIAL_STATE, action: WalletAction): Stat return setBalanceFullfilled(state, action); case TypeKeys.WALLET_SET_BALANCE_REJECTED: return setBalanceRejected(state); + case TypeKeys.WALLET_SET_PENDING: + return setWalletPending(state, action); case TypeKeys.WALLET_SET_TOKEN_BALANCES_PENDING: return setTokenBalancesPending(state); case TypeKeys.WALLET_SET_TOKEN_BALANCES_FULFILLED: @@ -161,6 +176,8 @@ export function wallet(state: State = INITIAL_STATE, action: WalletAction): Stat return setWalletTokens(state); case TypeKeys.WALLET_SET_CONFIG: return setWalletConfig(state, action); + case TypeKeys.WALLET_SET_PASSWORD_PENDING: + return setPasswordPending(state); default: return state; } diff --git a/common/sagas/wallet/wallet.ts b/common/sagas/wallet/wallet.ts index dee1dc1a..ef647cbf 100644 --- a/common/sagas/wallet/wallet.ts +++ b/common/sagas/wallet/wallet.ts @@ -7,6 +7,7 @@ import { setTokenBalancesFulfilled, setTokenBalancesRejected, setWallet, + setWalletPending, setWalletConfig, UnlockKeystoreAction, UnlockMnemonicAction, @@ -16,7 +17,8 @@ import { TypeKeys, SetTokenBalancePendingAction, setTokenBalanceFulfilled, - setTokenBalanceRejected + setTokenBalanceRejected, + setPasswordPrompt } from 'actions/wallet'; import { Wei } from 'libs/units'; import { changeNodeIntent, web3UnsetNode, TypeKeys as ConfigTypeKeys } from 'actions/config'; @@ -27,12 +29,16 @@ import { MnemonicWallet, getPrivKeyWallet, getKeystoreWallet, + determineKeystoreType, + KeystoreTypes, + getUtcWallet, + signWrapper, Web3Wallet, WalletConfig } from 'libs/wallet'; import { NODES, initWeb3Node, Token } from 'config/data'; -import { SagaIterator } from 'redux-saga'; -import { apply, call, fork, put, select, takeEvery, take } from 'redux-saga/effects'; +import { SagaIterator, delay, Task } from 'redux-saga'; +import { apply, call, fork, put, select, takeEvery, take, cancel } from 'redux-saga/effects'; import { getNodeLib, getAllTokens } from 'selectors/config'; import { getTokens, @@ -168,18 +174,44 @@ export function* unlockPrivateKey(action: UnlockPrivateKeyAction): SagaIterator yield put(setWallet(wallet)); } +export function* startLoadingSpinner(): SagaIterator { + yield call(delay, 400); + yield put(setWalletPending(true)); +} + +export function* stopLoadingSpinner(loadingFork: Task | null): SagaIterator { + if (loadingFork !== null && loadingFork !== undefined) { + yield cancel(loadingFork); + } + yield put(setWalletPending(false)); +} + export function* unlockKeystore(action: UnlockKeystoreAction): SagaIterator { const { file, password } = action.payload; let wallet: null | IWallet = null; - + let spinnerTask: null | Task = null; try { - wallet = getKeystoreWallet(file, password); + if (determineKeystoreType(file) === KeystoreTypes.utc) { + spinnerTask = yield fork(startLoadingSpinner); + wallet = signWrapper(yield call(getUtcWallet, file, password)); + } else { + wallet = getKeystoreWallet(file, password); + } } catch (e) { - yield put(showNotification('danger', translate('ERROR_6'))); + yield call(stopLoadingSpinner, spinnerTask); + if ( + password === '' && + e.message === 'Private key does not satisfy the curve requirements (ie. it is invalid)' + ) { + yield put(setPasswordPrompt()); + } else { + yield put(showNotification('danger', translate('ERROR_6'))); + } return; } // TODO: provide a more descriptive error than the two 'ERROR_6' (invalid pass) messages above + yield call(stopLoadingSpinner, spinnerTask); yield put(setWallet(wallet)); } diff --git a/common/typescript/worker-loader.d.ts b/common/typescript/worker-loader.d.ts new file mode 100644 index 00000000..a25733f2 --- /dev/null +++ b/common/typescript/worker-loader.d.ts @@ -0,0 +1,6 @@ +declare module 'worker-loader!*' { + class WebpackWorker extends Worker { + constructor(); + } + export = WebpackWorker; +} diff --git a/jest_config/__mocks__/workerMock.js b/jest_config/__mocks__/workerMock.js new file mode 100644 index 00000000..7462fae2 --- /dev/null +++ b/jest_config/__mocks__/workerMock.js @@ -0,0 +1 @@ +module.exports = Object.create(null); \ No newline at end of file diff --git a/jest_config/jest.config.json b/jest_config/jest.config.json index 7833cd97..b93875a1 100644 --- a/jest_config/jest.config.json +++ b/jest_config/jest.config.json @@ -5,11 +5,12 @@ }, "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", "moduleDirectories": ["node_modules", "common"], - "moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json"], + "moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "worker.ts"], "moduleNameMapper": { "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/jest_config/__mocks__/fileMock.ts", - "\\.(css|scss|less)$": "/jest_config/__mocks__/styleMock.ts" + "\\.(css|scss|less)$": "/jest_config/__mocks__/styleMock.ts", + "\\.worker.ts":"/jest_config/__mocks__/workerMock.js" }, "testPathIgnorePatterns": ["/common/config"], "setupFiles": [ diff --git a/package.json b/package.json index 0e746389..c4c1efa8 100644 --- a/package.json +++ b/package.json @@ -118,7 +118,8 @@ "webpack": "3.10.0", "webpack-dev-middleware": "2.0.4", "webpack-hot-middleware": "2.21.0", - "webpack-sources": "1.0.1" + "webpack-sources": "1.0.1", + "worker-loader": "1.1.0" }, "scripts": { "freezer": "webpack --config=./webpack_config/webpack.freezer.js && node ./dist/freezer.js", diff --git a/spec/sagas/wallet.spec.tsx b/spec/sagas/wallet.spec.tsx index 8f233226..e9e741d1 100644 --- a/spec/sagas/wallet.spec.tsx +++ b/spec/sagas/wallet.spec.tsx @@ -25,14 +25,17 @@ import { unlockKeystore, unlockMnemonic, unlockWeb3, - getTokenBalances + getTokenBalances, + startLoadingSpinner, + stopLoadingSpinner } from 'sagas/wallet'; -import { PrivKeyWallet } from 'libs/wallet/non-deterministic'; +import { getUtcWallet, PrivKeyWallet } from 'libs/wallet'; import { TypeKeys as ConfigTypeKeys } from 'actions/config/constants'; import Web3Node from 'libs/nodes/web3'; -import { cloneableGenerator } from 'redux-saga/utils'; +import { cloneableGenerator, createMockTask } from 'redux-saga/utils'; import { showNotification } from 'actions/notifications'; import translate from 'translations'; +import { IFullWallet, fromV3 } from 'ethereumjs-wallet'; // init module configuredStore.getState(); @@ -206,6 +209,24 @@ describe('unlockKeystore*', () => { password: 'testtesttest' }); const gen = unlockKeystore(action); + const mockTask = createMockTask(); + const spinnerFork = fork(startLoadingSpinner); + + it('should fork startLoadingSpinner', () => { + expect(gen.next().value).toEqual(spinnerFork); + }); + + it('should call getUtcWallet', () => { + expect(gen.next(mockTask).value).toEqual( + call(getUtcWallet, action.payload.file, action.payload.password) + ); + }); + + //keystore in this case decrypts quickly, so use fromV3 in ethjs-wallet to avoid testing with promises + it('should call stopLoadingSpinner', () => { + const mockWallet: IFullWallet = fromV3(action.payload.file, action.payload.password, true); + expect(gen.next(mockWallet).value).toEqual(call(stopLoadingSpinner, mockTask)); + }); it('should match put setWallet snapshot', () => { expect(gen.next().value).toMatchSnapshot(); diff --git a/webpack_config/webpack.base.js b/webpack_config/webpack.base.js index 3fb56ad1..798248a0 100644 --- a/webpack_config/webpack.base.js +++ b/webpack_config/webpack.base.js @@ -35,6 +35,10 @@ const webpackConfig = { .map(dir => path.resolve(__dirname, `../common/${dir}`)) .concat([path.resolve(__dirname, '../node_modules')]) }, + { + test: /\.worker\.js$/, + loader: 'worker-loader' + }, { include: [ path.resolve(__dirname, '../common/assets'),