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.
This commit is contained in:
aitrean 2018-01-11 01:44:13 -05:00 committed by Daniel Ternyak
parent a84a6e98fc
commit af2e0b69e1
17 changed files with 219 additions and 28 deletions

View File

@ -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 {

View File

@ -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;

View File

@ -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'
}

View File

@ -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<Props, State> {
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<Props, State> {
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);

View File

@ -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')}
</a>
</label>
<div className={file.length && passReq ? '' : 'hidden'}>
{isWalletPending ? <Spinner /> : ''}
<div className={file.length && isPasswordPending ? '' : 'hidden'}>
<p>{translate('ADD_Label_3')}</p>
<input
className={`form-control ${password.length > 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'));
}
};
}

View File

@ -58,6 +58,10 @@ const isKeystorePassRequired = (file: string): boolean => {
);
};
const getUtcWallet = (file: string, password: string): Promise<IFullWallet> => {
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
};

View File

@ -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,

View File

@ -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<IFullWallet> => {
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);
}
};
});
};

View File

@ -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);
}
};

View File

@ -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;
}

View File

@ -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));
}

6
common/typescript/worker-loader.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
declare module 'worker-loader!*' {
class WebpackWorker extends Worker {
constructor();
}
export = WebpackWorker;
}

View File

@ -0,0 +1 @@
module.exports = Object.create(null);

View File

@ -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)$":
"<rootDir>/jest_config/__mocks__/fileMock.ts",
"\\.(css|scss|less)$": "<rootDir>/jest_config/__mocks__/styleMock.ts"
"\\.(css|scss|less)$": "<rootDir>/jest_config/__mocks__/styleMock.ts",
"\\.worker.ts":"<rootDir>/jest_config/__mocks__/workerMock.js"
},
"testPathIgnorePatterns": ["<rootDir>/common/config"],
"setupFiles": [

View File

@ -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",

View File

@ -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();

View File

@ -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'),