MEW-01-004 - Stronger Keystores (#981)

* Add better password checking, confirm password, feedback, and up the minimum to 12.

* Move wallet generation off to a web worker, and bump up the n value to 8192. Refactor workers a wee bit.

* tscheck cleanup

* Make keystore password a form. Replace text with spinner on load.

* Center align again.

* Hard code n factor of test wallet, fix some misspelled type definitions for IV3Wallet.
This commit is contained in:
William O'Beirne 2018-02-02 01:01:30 -05:00 committed by Daniel Ternyak
parent ca2284b20e
commit 174dea8a29
19 changed files with 282 additions and 110 deletions

View File

@ -1,5 +1,4 @@
import { PaperWallet } from 'components';
import { IFullWallet } from 'ethereumjs-wallet';
import React from 'react';
import { translateRaw } from 'translations';
import printElement from 'utils/printElement';
@ -26,23 +25,23 @@ export const print = (address: string, privateKey: string) => () =>
`
});
const PrintableWallet: React.SFC<{ wallet: IFullWallet }> = ({ wallet }) => {
const address = wallet.getAddressString();
const privateKey = stripHexPrefix(wallet.getPrivateKeyString());
interface Props {
address: string;
privateKey: string;
}
if (!address || !privateKey) {
return null;
}
const PrintableWallet: React.SFC<Props> = ({ address, privateKey }) => {
const pkey = stripHexPrefix(privateKey);
return (
<div>
<PaperWallet address={address} privateKey={privateKey} />
<PaperWallet address={address} privateKey={pkey} />
<a
role="button"
aria-label={translateRaw('x_Print')}
aria-describedby="x_PrintDesc"
className="btn btn-lg btn-primary btn-block"
onClick={print(address, privateKey)}
onClick={print(address, pkey)}
style={{ margin: '10px auto 0', maxWidth: '260px' }}
>
{translateRaw('x_Print')}

View File

@ -15,6 +15,7 @@ interface Props {
toggleAriaLabel?: string;
isValid?: boolean;
isVisible?: boolean;
validity?: 'valid' | 'invalid' | 'semivalid';
// Textarea-only props
isTextareaWhenVisible?: boolean;
@ -23,6 +24,8 @@ interface Props {
// Shared callbacks
onChange?(ev: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>): void;
onFocus?(ev: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>): void;
onBlur?(ev: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>): void;
handleToggleVisibility?(): void;
}
@ -48,14 +51,19 @@ export default class TogglablePassword extends React.PureComponent<Props, State>
name,
disabled,
ariaLabel,
toggleAriaLabel,
validity,
isTextareaWhenVisible,
isValid,
onChange,
onFocus,
onBlur,
handleToggleVisibility
} = this.props;
const { isVisible } = this.state;
const validClass =
isValid === null || isValid === undefined ? '' : isValid ? 'is-valid' : 'is-invalid';
const validClass = validity
? `is-${validity}`
: isValid === null || isValid === undefined ? '' : isValid ? 'is-valid' : 'is-invalid';
return (
<div className="TogglablePassword input-group">
@ -67,6 +75,8 @@ export default class TogglablePassword extends React.PureComponent<Props, State>
disabled={disabled}
onChange={onChange}
onKeyDown={this.handleTextareaKeyDown}
onFocus={onFocus}
onBlur={onBlur}
placeholder={placeholder}
rows={this.props.rows || 3}
aria-label={ariaLabel}
@ -80,12 +90,14 @@ export default class TogglablePassword extends React.PureComponent<Props, State>
className={`form-control ${validClass}`}
placeholder={placeholder}
onChange={onChange}
onFocus={onFocus}
onBlur={onBlur}
aria-label={ariaLabel}
/>
)}
<span
onClick={handleToggleVisibility || this.toggleVisibility}
aria-label="show private key"
aria-label={toggleAriaLabel}
role="button"
className="TogglablePassword-toggle input-group-addon"
>

View File

@ -5,7 +5,7 @@ export const languages = require('./languages.json');
// Displays in the header
export const VERSION = '4.0.0 (Alpha 0.1.0)';
export const N_FACTOR = 1024;
export const N_FACTOR = 8192;
// Displays at the top of the site, make message empty string to remove.
// Type can be primary, warning, danger, success, or info.
@ -47,7 +47,7 @@ export const gasPriceDefaults = {
gasPriceMaxGwei: 60
};
export const MINIMUM_PASSWORD_LENGTH = 9;
export const MINIMUM_PASSWORD_LENGTH = 12;
export const knowledgeBaseURL = 'https://myetherwallet.github.io/knowledge-base';
export const bityReferralURL = 'https://bity.com/af/jshkb37v';

View File

@ -1,42 +1,28 @@
import { IFullWallet, IV3Wallet } from 'ethereumjs-wallet';
import { toChecksumAddress } from 'ethereumjs-util';
import { IV3Wallet } from 'ethereumjs-wallet';
import React, { Component } from 'react';
import translate from 'translations';
import { makeBlob } from 'utils/blob';
import './DownloadWallet.scss';
import Template from '../Template';
import { N_FACTOR } from 'config';
interface Props {
wallet: IFullWallet;
password: string;
keystore: IV3Wallet;
filename: string;
continue(): void;
}
interface State {
hasDownloadedWallet: boolean;
keystore: IV3Wallet | null;
}
export default class DownloadWallet extends Component<Props, State> {
public state: State = {
hasDownloadedWallet: false,
keystore: null
hasDownloadedWallet: false
};
public componentWillMount() {
this.setWallet(this.props.wallet, this.props.password);
}
public componentWillUpdate(nextProps: Props) {
if (this.props.wallet !== nextProps.wallet) {
this.setWallet(nextProps.wallet, nextProps.password);
}
}
public render() {
const { filename } = this.props;
const { hasDownloadedWallet } = this.state;
const filename = this.props.wallet.getV3Filename();
return (
<Template>
@ -82,20 +68,9 @@ export default class DownloadWallet extends Component<Props, State> {
);
}
public getBlob = () =>
(this.state.keystore && makeBlob('text/json;charset=UTF-8', this.state.keystore)) || undefined;
private markDownloaded = () =>
this.state.keystore && this.setState({ hasDownloadedWallet: true });
public getBlob = () => makeBlob('text/json;charset=UTF-8', this.props.keystore);
private handleContinue = () => this.state.hasDownloadedWallet && this.props.continue();
private setWallet(wallet: IFullWallet, password: string) {
const keystore = wallet.toV3(password, { n: N_FACTOR });
keystore.address = toChecksumAddress(keystore.address);
this.setState({ keystore });
}
private handleDownloadKeystore = (e: React.FormEvent<HTMLAnchorElement>) =>
this.state.keystore ? this.markDownloaded() : e.preventDefault();
private handleDownloadKeystore = () => this.setState({ hasDownloadedWallet: true });
}

View File

@ -1,22 +1,33 @@
@import "common/sass/variables";
$pw-max-width: 40rem;
.EnterPw {
&-title {
margin: $space auto $space * 2.5;
}
&-password {
max-width: 40rem;
position: relative;
max-width: $pw-max-width;
width: 100%;
margin: 0 auto $space;
&-label {
margin-bottom: $space;
}
&-feedback {
position: absolute;
bottom: -$space;
top: 100%;
text-align: left;
font-size: $font-size-small;
}
}
&-submit {
max-width: 16rem;
margin: 0 auto $space * 3;
max-width: $pw-max-width;
margin: $space auto $space * 3;
}
}

View File

@ -1,30 +1,42 @@
import React, { Component } from 'react';
import zxcvbn, { ZXCVBNResult } from 'zxcvbn';
import translate, { translateRaw } from 'translations';
import { MINIMUM_PASSWORD_LENGTH } from 'config';
import { TogglablePassword } from 'components';
import { Spinner } from 'components/ui';
import Template from '../Template';
import './EnterPassword.scss';
interface Props {
isGenerating: boolean;
continue(pw: string): void;
}
interface State {
password: string;
isPasswordValid: boolean;
confirmedPassword: string;
passwordValidation: ZXCVBNResult | null;
feedback: string;
}
export default class EnterPassword extends Component<Props, State> {
public state = {
public state: State = {
password: '',
isPasswordValid: false
confirmedPassword: '',
passwordValidation: null,
feedback: ''
};
public render() {
const { password, isPasswordValid } = this.state;
const { isGenerating } = this.props;
const { password, confirmedPassword, feedback } = this.state;
const passwordValidity = this.getPasswordValidity();
const isPasswordValid = passwordValidity === 'valid';
const isConfirmValid = confirmedPassword ? password === confirmedPassword : undefined;
const canSubmit = isPasswordValid && isConfirmValid && !isGenerating;
return (
<Template>
<div className="EnterPw">
<form className="EnterPw" onSubmit={canSubmit ? this.handleSubmit : undefined}>
<h1 className="EnterPw-title" aria-live="polite">
Generate a {translate('x_Keystore2')}
</h1>
@ -33,36 +45,114 @@ export default class EnterPassword extends Component<Props, State> {
<h4 className="EnterPw-password-label">{translate('GEN_Label_1')}</h4>
<TogglablePassword
value={password}
placeholder={translateRaw('GEN_Placeholder_1')}
placeholder={`Password must be uncommon and ${MINIMUM_PASSWORD_LENGTH}+ characters long`}
validity={passwordValidity}
ariaLabel={translateRaw('GEN_Aria_1')}
toggleAriaLabel={translateRaw('GEN_Aria_2')}
isValid={isPasswordValid}
onChange={this.onPasswordChange}
onBlur={this.showFeedback}
/>
{!isPasswordValid &&
feedback && (
<p className={`EnterPw-password-feedback help-block is-${passwordValidity}`}>
{feedback}
</p>
)}
</label>
<label className="EnterPw-password">
<h4 className="EnterPw-password-label">Confirm password</h4>
<TogglablePassword
value={confirmedPassword}
placeholder={translateRaw('GEN_Placeholder_1')}
ariaLabel="Confirm Password"
toggleAriaLabel="toggle confirm password visibility"
isValid={isConfirmValid}
onChange={this.onConfirmChange}
/>
</label>
<button
onClick={this.onClickGenerateFile}
disabled={!isPasswordValid}
className="EnterPw-submit btn btn-primary btn-block"
>
{translate('NAV_GenerateWallet')}
<button disabled={!canSubmit} className="EnterPw-submit btn btn-primary btn-lg btn-block">
{isGenerating ? <Spinner light={true} /> : translate('NAV_GenerateWallet')}
</button>
<p className="EnterPw-warning">{translate('x_PasswordDesc')}</p>
</div>
</form>
</Template>
);
}
private onClickGenerateFile = () => {
private getPasswordValidity(): 'valid' | 'invalid' | 'semivalid' | undefined {
const { password, passwordValidation } = this.state;
if (!password) {
return undefined;
}
if (password.length < MINIMUM_PASSWORD_LENGTH) {
return 'invalid';
}
if (passwordValidation && passwordValidation.score < 3) {
return 'semivalid';
}
return 'valid';
}
private getFeedback() {
let feedback = '';
const validity = this.getPasswordValidity();
if (validity !== 'valid') {
const { password, passwordValidation } = this.state;
if (password.length < MINIMUM_PASSWORD_LENGTH) {
feedback = `Password must be ${MINIMUM_PASSWORD_LENGTH}+ characters`;
} else if (passwordValidation && passwordValidation.feedback) {
feedback = `This password is not strong enough. ${passwordValidation.feedback.warning}.`;
} else {
feedback = 'There is something invalid about your password. Please try another.';
}
}
return feedback;
}
private handleSubmit = (ev: React.FormEvent<HTMLFormElement>) => {
ev.preventDefault();
this.props.continue(this.state.password);
};
private onPasswordChange = (e: any) => {
const password = e.target.value;
this.setState({
isPasswordValid: password.length >= MINIMUM_PASSWORD_LENGTH,
password
});
private onPasswordChange = (e: React.FormEvent<HTMLInputElement>) => {
const password = e.currentTarget.value;
const passwordValidation = password ? zxcvbn(password) : null;
this.setState(
{
password,
passwordValidation,
feedback: ''
},
() => {
if (password.length >= MINIMUM_PASSWORD_LENGTH) {
this.showFeedback();
}
}
);
};
private onConfirmChange = (e: React.FormEvent<HTMLInputElement>) => {
this.setState({ confirmedPassword: e.currentTarget.value });
};
private showFeedback = () => {
const { password, passwordValidation } = this.state;
if (!password) {
return;
}
const feedback = this.getFeedback();
this.setState({ passwordValidation, feedback });
};
}

View File

@ -1,5 +1,6 @@
import { generate, IFullWallet } from 'ethereumjs-wallet';
import { IV3Wallet } from 'ethereumjs-wallet';
import React, { Component } from 'react';
import { generateKeystore } from 'libs/web-workers';
import { WalletType } from '../../GenerateWallet';
import Template from '../Template';
import DownloadWallet from './DownloadWallet';
@ -17,36 +18,54 @@ export enum Steps {
interface State {
activeStep: Steps;
password: string;
wallet: IFullWallet | null | undefined;
keystore: IV3Wallet | null | undefined;
filename: string;
privateKey: string;
isGenerating: boolean;
}
export default class GenerateKeystore extends Component<{}, State> {
public state: State = {
activeStep: Steps.Password,
password: '',
wallet: null
keystore: null,
filename: '',
privateKey: '',
isGenerating: false
};
public render() {
const { activeStep, wallet, password } = this.state;
const { activeStep, keystore, privateKey, filename, isGenerating } = this.state;
let content;
switch (activeStep) {
case Steps.Password:
content = <EnterPassword continue={this.generateWalletAndContinue} />;
content = (
<EnterPassword continue={this.generateWalletAndContinue} isGenerating={isGenerating} />
);
break;
case Steps.Download:
if (wallet) {
if (keystore) {
content = (
<DownloadWallet wallet={wallet} password={password} continue={this.continueToPaper} />
<DownloadWallet
keystore={keystore}
filename={filename}
continue={this.continueToPaper}
/>
);
}
break;
case Steps.Paper:
if (wallet) {
content = <PaperWallet wallet={wallet} continue={this.continueToFinal} />;
if (keystore) {
content = (
<PaperWallet
keystore={keystore}
privateKey={privateKey}
continue={this.continueToFinal}
/>
);
}
break;
@ -66,10 +85,17 @@ export default class GenerateKeystore extends Component<{}, State> {
}
private generateWalletAndContinue = (password: string) => {
this.setState({
password,
activeStep: Steps.Download,
wallet: generate()
this.setState({ isGenerating: true });
generateKeystore(password).then(res => {
this.setState({
password,
activeStep: Steps.Download,
keystore: res.keystore,
filename: res.filename,
privateKey: res.privateKey,
isGenerating: false
});
});
};

View File

@ -1,5 +1,5 @@
import PrintableWallet from 'components/PrintableWallet';
import { IFullWallet } from 'ethereumjs-wallet';
import { IV3Wallet } from 'ethereumjs-wallet';
import React from 'react';
import translate from 'translations';
import { stripHexPrefix } from 'libs/values';
@ -7,7 +7,8 @@ import './PaperWallet.scss';
import Template from '../Template';
interface Props {
wallet: IFullWallet;
keystore: IV3Wallet;
privateKey: string;
continue(): void;
}
@ -18,7 +19,7 @@ const PaperWallet: React.SFC<Props> = props => (
<h1 className="GenPaper-title">{translate('GEN_Label_5')}</h1>
<input
className="GenPaper-private form-control"
value={stripHexPrefix(props.wallet.getPrivateKeyString())}
value={stripHexPrefix(props.privateKey)}
aria-label={translate('x_PrivKey')}
aria-describedby="x_PrivKeyDesc"
type="text"
@ -28,7 +29,7 @@ const PaperWallet: React.SFC<Props> = props => (
{/* Download Paper Wallet */}
<h1 className="GenPaper-title">{translate('x_Print')}</h1>
<div className="GenPaper-paper">
<PrintableWallet wallet={props.wallet} />
<PrintableWallet address={props.keystore.address} privateKey={props.privateKey} />
</div>
{/* Warning */}

View File

@ -2,7 +2,7 @@ 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 { fromV3 } from 'libs/web-workers';
import Web3Wallet from './web3';
import AddressOnlyWallet from './address';

View File

@ -1,17 +1,17 @@
import { IFullWallet, fromPrivateKey } from 'ethereumjs-wallet';
import { toBuffer } from 'ethereumjs-util';
import Worker from 'worker-loader!./workers/scrypt-worker.worker.ts';
import Worker from 'worker-loader!./workers/fromV3.worker.ts';
export const fromV3 = (
export default function fromV3(
keystore: string,
password: string,
nonStrict: boolean
): Promise<IFullWallet> => {
): Promise<IFullWallet> {
return new Promise((resolve, reject) => {
const scryptWorker = new Worker();
scryptWorker.postMessage({ keystore, password, nonStrict });
scryptWorker.onmessage = event => {
const data: string = event.data;
const worker = new Worker();
worker.postMessage({ keystore, password, nonStrict });
worker.onmessage = (ev: MessageEvent) => {
const data = ev.data;
try {
const wallet = fromPrivateKey(toBuffer(data));
resolve(wallet);
@ -20,4 +20,4 @@ export const fromV3 = (
}
};
});
};
}

View File

@ -0,0 +1,22 @@
import { IV3Wallet } from 'ethereumjs-wallet';
import { N_FACTOR } from 'config';
import Worker from 'worker-loader!./workers/generateKeystore.worker.ts';
interface KeystorePayload {
filename: string;
keystore: IV3Wallet;
privateKey: string;
}
export default function generateKeystore(password: string): Promise<KeystorePayload> {
return new Promise(resolve => {
const worker = new Worker();
worker.postMessage({ password, N_FACTOR });
worker.onmessage = (ev: MessageEvent) => {
const filename: string = ev.data.filename;
const privateKey: string = ev.data.privateKey;
const keystore: IV3Wallet = ev.data.keystore;
resolve({ keystore, filename, privateKey });
};
});
}

View File

@ -0,0 +1,2 @@
export { default as fromV3 } from './fromV3';
export { default as generateKeystore } from './generateKeystore';

View File

@ -1,18 +1,18 @@
import { fromV3, IFullWallet } from 'ethereumjs-wallet';
const scryptWorker: Worker = self as any;
const worker: Worker = self as any;
interface DecryptionParameters {
keystore: string;
password: string;
nonStrict: boolean;
}
scryptWorker.onmessage = (event: MessageEvent) => {
worker.onmessage = (event: MessageEvent) => {
const info: DecryptionParameters = event.data;
try {
const rawKeystore: IFullWallet = fromV3(info.keystore, info.password, info.nonStrict);
scryptWorker.postMessage(rawKeystore.getPrivateKeyString());
worker.postMessage(rawKeystore.getPrivateKeyString());
} catch (e) {
scryptWorker.postMessage(e.message);
worker.postMessage(e.message);
}
};

View File

@ -0,0 +1,19 @@
import { generate } from 'ethereumjs-wallet';
import { toChecksumAddress } from 'ethereumjs-util';
const worker: Worker = self as any;
interface GenerateParameters {
password: string;
N_FACTOR: number;
}
worker.onmessage = (event: MessageEvent) => {
const info: GenerateParameters = event.data;
const wallet = generate();
const filename = wallet.getV3Filename();
const privateKey = wallet.getPrivateKeyString();
const keystore = wallet.toV3(info.password, { n: info.N_FACTOR });
keystore.address = toChecksumAddress(keystore.address);
worker.postMessage({ keystore, filename, privateKey });
};

View File

@ -83,3 +83,17 @@ select.form-control {
@include form-control-state($brand-warning);
}
}
.help-block {
&.is-valid {
color: $brand-success;
}
&.is-invalid {
color: $brand-danger;
}
&.is-semivalid {
color: $brand-warning;
}
}

View File

@ -165,14 +165,14 @@ declare module 'ethereumjs-wallet' {
version: 3;
id: string;
address: string;
Crypto: {
crypto: {
ciphertext: string;
cipherParams: {
cipherparams: {
iv: string;
};
cipher: string | 'aes-128-ctr';
kdf: 'scrypt' | 'pbkdf2';
kfdparams: IScryptKdfParams | IPbkdf2KdfParams;
kdfparams: IScryptKdfParams | IPbkdf2KdfParams;
mac: string;
};
}

View File

@ -50,7 +50,8 @@
"scryptsy": "2.0.0",
"uuid": "3.2.1",
"wallet-address-validator": "0.1.1",
"whatwg-fetch": "2.0.3"
"whatwg-fetch": "2.0.3",
"zxcvbn": "4.4.2"
},
"devDependencies": {
"@types/classnames": "2.2.3",
@ -70,6 +71,7 @@
"@types/redux-promise-middleware": "0.0.9",
"@types/uuid": "3.4.3",
"@types/webpack-env": "1.13.4",
"@types/zxcvbn": "4.4.0",
"autodll-webpack-plugin": "0.3.8",
"awesome-typescript-loader": "3.4.1",
"babel-minify-webpack-plugin": "0.2.0",

View File

@ -14,7 +14,7 @@ import {
import { Wei } from 'libs/units';
import { changeNodeIntent, web3UnsetNode } from 'actions/config';
import { INode } from 'libs/nodes/INode';
import { initWeb3Node, Token, N_FACTOR } from 'config';
import { initWeb3Node, Token } from 'config';
import { apply, call, fork, put, select, take, cancel } from 'redux-saga/effects';
import { getNodeLib, getOffline } from 'selectors/config';
import { getWalletInst, getWalletConfigTokens } from 'selectors/wallet';
@ -36,7 +36,7 @@ import Web3Node from 'libs/nodes/web3';
import { cloneableGenerator, createMockTask } from 'redux-saga/utils';
import { showNotification } from 'actions/notifications';
import translate from 'translations';
import { IFullWallet, fromV3 } from 'ethereumjs-wallet';
import { IFullWallet, IV3Wallet, fromV3 } from 'ethereumjs-wallet';
// init module
configuredStore.getState();
@ -59,11 +59,11 @@ const token2: Token = {
};
const tokens = [token1, token2];
const utcKeystore = {
const utcKeystore: IV3Wallet = {
version: 3,
id: 'cb788af4-993d-43ad-851b-0d2031e52c61',
address: '25a24679f35e447f778cf54a3823facf39904a63',
Crypto: {
crypto: {
ciphertext: '4193915c560835d00b2b9ff5dd20f3e13793b2a3ca8a97df649286063f27f707',
cipherparams: {
iv: 'dccb8c009b11d1c6226ba19b557dce4c'
@ -73,7 +73,7 @@ const utcKeystore = {
kdfparams: {
dklen: 32,
salt: '037a53e520f2d00fb70f02f39b31b77374de9e0e1d35fd7cbe9c8a8b21d6b0ab',
n: N_FACTOR,
n: 1024,
r: 8,
p: 1
},

View File

@ -69,7 +69,6 @@ module.exports = {
'redux-promise-middleware',
'redux-saga',
'scryptsy',
'store2',
'uuid',
'wallet-address-validator',
'whatwg-fetch'