diff --git a/js/package.json b/js/package.json index 82807e820..0585a1681 100644 --- a/js/package.json +++ b/js/package.json @@ -176,7 +176,7 @@ "geopattern": "1.2.3", "isomorphic-fetch": "2.2.1", "js-sha3": "0.5.5", - "keythereum": "0.4.3", + "keythereum": "0.4.6", "lodash": "4.17.2", "loglevel": "1.4.1", "marked": "0.3.6", diff --git a/js/src/api/local/accounts/account.js b/js/src/api/local/accounts/account.js index da9de1359..94e923f45 100644 --- a/js/src/api/local/accounts/account.js +++ b/js/src/api/local/accounts/account.js @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License // along with Parity. If not, see . -import { keythereum } from '../ethkey'; +import { createKeyObject, decryptPrivateKey } from '../ethkey'; export default class Account { constructor (persist, data) { @@ -31,12 +31,14 @@ export default class Account { } isValidPassword (password) { - try { - keythereum.recover(Buffer.from(password), this._keyObject); - return true; - } catch (e) { - return false; - } + return decryptPrivateKey(this._keyObject, password) + .then((privateKey) => { + if (!privateKey) { + return false; + } + + return true; + }); } get address () { @@ -68,21 +70,23 @@ export default class Account { } decryptPrivateKey (password) { - return keythereum.recover(Buffer.from(password), this._keyObject); + return decryptPrivateKey(this._keyObject, password); + } + + changePassword (key, password) { + return createKeyObject(key, password).then((keyObject) => { + this._keyObject = keyObject; + + this._persist(); + }); } static fromPrivateKey (persist, key, password) { - const iv = keythereum.crypto.randomBytes(16); - const salt = keythereum.crypto.randomBytes(32); + return createKeyObject(key, password).then((keyObject) => { + const account = new Account(persist, { keyObject }); - // Keythereum will fail if `password` is an empty string - password = Buffer.from(password); - - const keyObject = keythereum.dump(password, key, salt, iv); - - const account = new Account(persist, { keyObject }); - - return account; + return account; + }); } toJSON () { diff --git a/js/src/api/local/accounts/accounts.js b/js/src/api/local/accounts/accounts.js index 576addcb1..e7e5cc397 100644 --- a/js/src/api/local/accounts/accounts.js +++ b/js/src/api/local/accounts/accounts.js @@ -38,14 +38,23 @@ export default class Accounts { create (secret, password) { const privateKey = Buffer.from(secret.slice(2), 'hex'); - const account = Account.fromPrivateKey(this.persist, privateKey, password); - this._store.push(account); - this.lastAddress = account.address; + return Account + .fromPrivateKey(this.persist, privateKey, password) + .then((account) => { + const { address } = account; - this.persist(); + if (this._store.find((account) => account.address === address)) { + throw new Error(`Account ${address} already exists!`); + } - return account.address; + this._store.push(account); + this.lastAddress = address; + + this.persist(); + + return account.address; + }); } set lastAddress (value) { @@ -73,28 +82,41 @@ export default class Accounts { remove (address, password) { address = address.toLowerCase(); + const account = this.get(address); + + if (!account) { + return false; + } + + return account + .isValidPassword(password) + .then((isValid) => { + if (!isValid) { + return false; + } + + if (address === this.lastAddress) { + this.lastAddress = NULL_ADDRESS; + } + + this.removeUnsafe(address); + + return true; + }); + } + + removeUnsafe (address) { + address = address.toLowerCase(); + const index = this._store.findIndex((account) => account.address === address); if (index === -1) { - return false; - } - - const account = this._store[index]; - - if (!account.isValidPassword(password)) { - console.log('invalid password'); - return false; - } - - if (address === this.lastAddress) { - this.lastAddress = NULL_ADDRESS; + return; } this._store.splice(index, 1); this.persist(); - - return true; } mapArray (mapper) { diff --git a/js/src/api/local/ethkey/index.js b/js/src/api/local/ethkey/index.js index ac2efa72e..a6967da25 100644 --- a/js/src/api/local/ethkey/index.js +++ b/js/src/api/local/ethkey/index.js @@ -14,31 +14,35 @@ // You should have received a copy of the GNU General Public License // along with Parity. If not, see . -// Allow a web worker in the browser, with a fallback for Node.js -const hasWebWorkers = typeof Worker !== 'undefined'; -const KeyWorker = hasWebWorkers ? require('worker-loader!./worker') - : require('./worker').KeyWorker; +import workerPool from './workerPool'; -// Local accounts should never be used outside of the browser -export let keythereum = null; +export function createKeyObject (key, password) { + return workerPool.getWorker().action('createKeyObject', { key, password }) + .then((obj) => JSON.parse(obj)); +} -if (hasWebWorkers) { - require('keythereum/dist/keythereum'); +export function decryptPrivateKey (keyObject, password) { + return workerPool + .getWorker() + .action('decryptPrivateKey', { keyObject, password }) + .then((privateKey) => { + if (privateKey) { + return Buffer.from(privateKey); + } - keythereum = window.keythereum; + return null; + }); } export function phraseToAddress (phrase) { - return phraseToWallet(phrase).then((wallet) => wallet.address); + return phraseToWallet(phrase) + .then((wallet) => wallet.address); } export function phraseToWallet (phrase) { - return new Promise((resolve, reject) => { - const worker = new KeyWorker(); - - worker.postMessage(phrase); - worker.onmessage = ({ data }) => { - resolve(data); - }; - }); + return workerPool.getWorker().action('phraseToWallet', phrase); +} + +export function verifySecret (secret) { + return workerPool.getWorker().action('verifySecret', secret); } diff --git a/js/src/api/local/ethkey/worker.js b/js/src/api/local/ethkey/worker.js index a472ee29a..00f4a0bed 100644 --- a/js/src/api/local/ethkey/worker.js +++ b/js/src/api/local/ethkey/worker.js @@ -14,58 +14,104 @@ // You should have received a copy of the GNU General Public License // along with Parity. If not, see . -import { keccak_256 as keccak256 } from 'js-sha3'; import secp256k1 from 'secp256k1/js'; +import { keccak_256 as keccak256 } from 'js-sha3'; +import { bytesToHex } from '~/api/util/format'; + +const isWorker = typeof self !== 'undefined'; // Stay compatible between environments -if (typeof self !== 'object') { +if (!isWorker) { const scope = typeof global === 'undefined' ? window : global; scope.self = scope; } -function bytesToHex (bytes) { - return '0x' + Array.from(bytes).map(n => ('0' + n.toString(16)).slice(-2)).join(''); +// keythereum should never be used outside of the browser +let keythereum = null; + +if (isWorker) { + require('keythereum/dist/keythereum'); + + keythereum = self.keythereum; } -// Logic ported from /ethkey/src/brain.rs -function phraseToWallet (phrase) { - let secret = keccak256.array(phrase); - - for (let i = 0; i < 16384; i++) { - secret = keccak256.array(secret); +function route ({ action, payload }) { + if (action in actions) { + return actions[action](payload); } - while (true) { - secret = keccak256.array(secret); + return null; +} - const secretBuf = Buffer.from(secret); +const actions = { + phraseToWallet (phrase) { + let secret = keccak256.array(phrase); - if (secp256k1.privateKeyVerify(secretBuf)) { - // No compression, slice out last 64 bytes - const publicBuf = secp256k1.publicKeyCreate(secretBuf, false).slice(-64); - const address = keccak256.array(publicBuf).slice(12); + for (let i = 0; i < 16384; i++) { + secret = keccak256.array(secret); + } - if (address[0] !== 0) { - continue; + while (true) { + secret = keccak256.array(secret); + + const secretBuf = Buffer.from(secret); + + if (secp256k1.privateKeyVerify(secretBuf)) { + // No compression, slice out last 64 bytes + const publicBuf = secp256k1.publicKeyCreate(secretBuf, false).slice(-64); + const address = keccak256.array(publicBuf).slice(12); + + if (address[0] !== 0) { + continue; + } + + const wallet = { + secret: bytesToHex(secretBuf), + public: bytesToHex(publicBuf), + address: bytesToHex(address) + }; + + return wallet; } + } + }, - const wallet = { - secret: bytesToHex(secretBuf), - public: bytesToHex(publicBuf), - address: bytesToHex(address) - }; + verifySecret (secret) { + const key = Buffer.from(secret.slice(2), 'hex'); - return wallet; + return secp256k1.privateKeyVerify(key); + }, + + createKeyObject ({ key, password }) { + key = Buffer.from(key); + password = Buffer.from(password); + + const iv = keythereum.crypto.randomBytes(16); + const salt = keythereum.crypto.randomBytes(32); + const keyObject = keythereum.dump(password, key, salt, iv); + + return JSON.stringify(keyObject); + }, + + decryptPrivateKey ({ keyObject, password }) { + password = Buffer.from(password); + + try { + const key = keythereum.recover(password, keyObject); + + // Convert to array to safely send from the worker + return Array.from(key); + } catch (e) { + return null; } } -} +}; self.onmessage = function ({ data }) { - const wallet = phraseToWallet(data); + const result = route(data); - postMessage(wallet); - close(); + postMessage(result); }; // Emulate a web worker in Node.js @@ -73,9 +119,9 @@ class KeyWorker { postMessage (data) { // Force async setTimeout(() => { - const wallet = phraseToWallet(data); + const result = route(data); - this.onmessage({ data: wallet }); + this.onmessage({ data: result }); }, 0); } diff --git a/js/src/api/local/ethkey/workerPool.js b/js/src/api/local/ethkey/workerPool.js new file mode 100644 index 000000000..ff5315898 --- /dev/null +++ b/js/src/api/local/ethkey/workerPool.js @@ -0,0 +1,61 @@ +// Copyright 2015-2017 Parity Technologies (UK) Ltd. +// This file is part of Parity. + +// Parity is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Parity is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Parity. If not, see . + +// Allow a web worker in the browser, with a fallback for Node.js +const hasWebWorkers = typeof Worker !== 'undefined'; +const KeyWorker = hasWebWorkers ? require('worker-loader!./worker') + : require('./worker').KeyWorker; + +class WorkerContainer { + busy = false; + _worker = new KeyWorker(); + + action (action, payload) { + if (this.busy) { + throw new Error('Cannot issue an action on a busy worker!'); + } + + this.busy = true; + + return new Promise((resolve, reject) => { + this._worker.postMessage({ action, payload }); + this._worker.onmessage = ({ data }) => { + this.busy = false; + resolve(data); + }; + }); + } +} + +class WorkerPool { + pool = []; + + getWorker () { + let container = this.pool.find((container) => !container.busy); + + if (container) { + return container; + } + + container = new WorkerContainer(); + + this.pool.push(container); + + return container; + } +} + +export default new WorkerPool(); diff --git a/js/src/api/local/middleware.js b/js/src/api/local/middleware.js index d5997c60a..36a8cd2cf 100644 --- a/js/src/api/local/middleware.js +++ b/js/src/api/local/middleware.js @@ -19,7 +19,7 @@ import accounts from './accounts'; import transactions from './transactions'; import { Middleware } from '../transport'; import { inNumber16 } from '../format/input'; -import { phraseToWallet, phraseToAddress } from './ethkey'; +import { phraseToWallet, phraseToAddress, verifySecret } from './ethkey'; import { randomPhrase } from '@parity/wordlist'; export default class LocalAccountsMiddleware extends Middleware { @@ -57,6 +57,22 @@ export default class LocalAccountsMiddleware extends Middleware { }); }); + register('parity_changePassword', ([address, oldPassword, newPassword]) => { + const account = accounts.get(address); + + return account + .decryptPrivateKey(oldPassword) + .then((privateKey) => { + if (!privateKey) { + return false; + } + + account.changePassword(privateKey, newPassword); + + return true; + }); + }); + register('parity_checkRequest', ([id]) => { return transactions.hash(id) || Promise.resolve(null); }); @@ -84,6 +100,17 @@ export default class LocalAccountsMiddleware extends Middleware { }); }); + register('parity_newAccountFromSecret', ([secret, password]) => { + return verifySecret(secret) + .then((isValid) => { + if (!isValid) { + throw new Error('Invalid secret key'); + } + + return accounts.create(secret, password); + }); + }); + register('parity_setAccountMeta', ([address, meta]) => { accounts.get(address).meta = meta; @@ -127,6 +154,12 @@ export default class LocalAccountsMiddleware extends Middleware { return accounts.remove(address, password); }); + register('parity_testPassword', ([address, password]) => { + const account = accounts.get(address); + + return account.isValidPassword(password); + }); + register('signer_confirmRequest', ([id, modify, password]) => { const { gasPrice, @@ -137,30 +170,33 @@ export default class LocalAccountsMiddleware extends Middleware { data } = Object.assign(transactions.get(id), modify); - return this - .rpcRequest('parity_nextNonce', [from]) - .then((nonce) => { - const tx = new EthereumTx({ - nonce, - to, - data, - gasLimit: inNumber16(gasLimit), - gasPrice: inNumber16(gasPrice), - value: inNumber16(value) - }); - const account = accounts.get(from); + const account = accounts.get(from); - tx.sign(account.decryptPrivateKey(password)); - - const serializedTx = `0x${tx.serialize().toString('hex')}`; - - return this.rpcRequest('eth_sendRawTransaction', [serializedTx]); - }) - .then((hash) => { - transactions.confirm(id, hash); - - return {}; + return Promise.all([ + this.rpcRequest('parity_nextNonce', [from]), + account.decryptPrivateKey(password) + ]) + .then(([nonce, privateKey]) => { + const tx = new EthereumTx({ + nonce, + to, + data, + gasLimit: inNumber16(gasLimit), + gasPrice: inNumber16(gasPrice), + value: inNumber16(value) }); + + tx.sign(privateKey); + + const serializedTx = `0x${tx.serialize().toString('hex')}`; + + return this.rpcRequest('eth_sendRawTransaction', [serializedTx]); + }) + .then((hash) => { + transactions.confirm(id, hash); + + return {}; + }); }); register('signer_rejectRequest', ([id]) => { diff --git a/js/src/api/transport/jsonRpcBase.js b/js/src/api/transport/jsonRpcBase.js index 46df718a7..573204c3e 100644 --- a/js/src/api/transport/jsonRpcBase.js +++ b/js/src/api/transport/jsonRpcBase.js @@ -80,12 +80,16 @@ export default class JsonRpcBase extends EventEmitter { const res = middleware.handle(method, params); if (res != null) { - const result = this._wrapSuccessResult(res); - const json = this.encode(method, params); + // If `res` isn't a promise, we need to wrap it + return Promise.resolve(res) + .then((res) => { + const result = this._wrapSuccessResult(res); + const json = this.encode(method, params); - Logging.send(method, params, { json, result }); + Logging.send(method, params, { json, result }); - return res; + return res; + }); } } diff --git a/js/src/api/util/format.js b/js/src/api/util/format.js index c7594b692..61fc9d32c 100644 --- a/js/src/api/util/format.js +++ b/js/src/api/util/format.js @@ -17,7 +17,7 @@ import { range } from 'lodash'; export function bytesToHex (bytes) { - return '0x' + bytes.map((b) => ('0' + b.toString(16)).slice(-2)).join(''); + return '0x' + Buffer.from(bytes).toString('hex'); } export function cleanupValue (value, type) { diff --git a/js/src/modals/CreateAccount/NewAccount/newAccount.js b/js/src/modals/CreateAccount/NewAccount/newAccount.js index 04f2f272a..9c6be9f6e 100644 --- a/js/src/modals/CreateAccount/NewAccount/newAccount.js +++ b/js/src/modals/CreateAccount/NewAccount/newAccount.js @@ -23,6 +23,7 @@ import { RadioButton, RadioButtonGroup } from 'material-ui/RadioButton'; import { Form, Input, IdentityIcon } from '~/ui'; import PasswordStrength from '~/ui/Form/PasswordStrength'; import { RefreshIcon } from '~/ui/Icons'; +import Loading from '~/ui/Loading'; import ChangeVault from '../ChangeVault'; import styles from '../createAccount.css'; @@ -170,7 +171,9 @@ export default class CreateAccount extends Component { const { accounts } = this.state; if (!accounts) { - return null; + return ( + + ); } const identities = Object @@ -205,6 +208,14 @@ export default class CreateAccount extends Component { createIdentities = () => { const { createStore } = this.props; + this.setState({ + accounts: null, + selectedAddress: '' + }); + + createStore.setAddress(''); + createStore.setPhrase(''); + return createStore .createIdentities() .then((accounts) => { diff --git a/js/src/modals/CreateAccount/NewAccount/newAccount.spec.js b/js/src/modals/CreateAccount/NewAccount/newAccount.spec.js index 87c7ba3fc..935fe5b80 100644 --- a/js/src/modals/CreateAccount/NewAccount/newAccount.spec.js +++ b/js/src/modals/CreateAccount/NewAccount/newAccount.spec.js @@ -58,12 +58,12 @@ describe('modals/CreateAccount/NewAccount', () => { return instance.componentWillMount(); }); - it('creates initial accounts', () => { - expect(Object.keys(instance.state.accounts).length).to.equal(7); + it('resets the accounts', () => { + expect(instance.state.accounts).to.be.null; }); - it('sets the initial selected value', () => { - expect(instance.state.selectedAddress).to.equal(Object.keys(instance.state.accounts)[0]); + it('resets the initial selected value', () => { + expect(instance.state.selectedAddress).to.equal(''); }); }); }); diff --git a/js/src/modals/CreateAccount/store.js b/js/src/modals/CreateAccount/store.js index 52dddac80..9bc60d9af 100644 --- a/js/src/modals/CreateAccount/store.js +++ b/js/src/modals/CreateAccount/store.js @@ -69,7 +69,7 @@ export default class Store { return !(this.nameError || this.walletFileError); case 'fromNew': - return !(this.nameError || this.passwordRepeatError); + return !(this.nameError || this.passwordRepeatError) && this.hasAddress; case 'fromPhrase': return !(this.nameError || this.passwordRepeatError); @@ -85,6 +85,10 @@ export default class Store { } } + @computed get hasAddress () { + return !!(this.address); + } + @computed get passwordRepeatError () { return this.password === this.passwordRepeat ? null diff --git a/js/src/modals/CreateAccount/store.spec.js b/js/src/modals/CreateAccount/store.spec.js index b02f013b6..9d7bc10a2 100644 --- a/js/src/modals/CreateAccount/store.spec.js +++ b/js/src/modals/CreateAccount/store.spec.js @@ -329,6 +329,7 @@ describe('modals/CreateAccount/Store', () => { describe('createType === fromNew', () => { beforeEach(() => { store.setCreateType('fromNew'); + store.setAddress('0x0000000000000000000000000000000000000000'); }); it('returns true on no errors', () => { @@ -337,11 +338,13 @@ describe('modals/CreateAccount/Store', () => { it('returns false on nameError', () => { store.setName(''); + expect(store.canCreate).to.be.false; }); it('returns false on passwordRepeatError', () => { store.setPassword('testing'); + expect(store.canCreate).to.be.false; }); });