From aac0176ca2523bd3f4391fe0e051ea3740121b17 Mon Sep 17 00:00:00 2001 From: skubakdj Date: Thu, 5 Oct 2017 19:29:14 -0400 Subject: [PATCH] Wallet Decrypt - Ledger (#238) * add static vendor js libraries * add ledger config * add ledger components * add ledger wallet * bugfix: trezor, set dPath on change * add rlp type package, update types * change address to public * update tslint script to exclude all files in directory * revert to private address and use getAddress() * remove unnecessary eslint line out of library files * remove IWallet import * Fix ts errors * Remove version controlled vendor files from DLL --- .../components/WalletDecrypt/LedgerNano.scss | 26 + .../components/WalletDecrypt/LedgerNano.tsx | 151 +++- common/components/WalletDecrypt/Trezor.tsx | 1 + common/components/WalletDecrypt/index.tsx | 21 +- common/config/dpaths.ts | 5 +- common/libs/wallet/deterministic.ts | 2 - common/libs/wallet/index.ts | 1 + common/libs/wallet/ledger.ts | 91 ++ common/typescript/ethereumjs-tx.d.ts | 2 + common/typescript/ethereumjs-util.d.ts | 1 + common/vendor/ledger-eth.js | 283 ++++++ common/vendor/ledger3.js | 87 ++ common/vendor/u2f-api.js | 832 ++++++++++++++++++ common/vendors.js | 1 - package.json | 3 +- tsconfig.json | 6 +- 16 files changed, 1477 insertions(+), 36 deletions(-) create mode 100644 common/components/WalletDecrypt/LedgerNano.scss create mode 100644 common/libs/wallet/ledger.ts create mode 100644 common/vendor/ledger-eth.js create mode 100644 common/vendor/ledger3.js create mode 100644 common/vendor/u2f-api.js diff --git a/common/components/WalletDecrypt/LedgerNano.scss b/common/components/WalletDecrypt/LedgerNano.scss new file mode 100644 index 00000000..edfd864e --- /dev/null +++ b/common/components/WalletDecrypt/LedgerNano.scss @@ -0,0 +1,26 @@ +.LedgerDecrypt { + text-align: center; + padding-top: 30px; + + &-decrypt { + width: 100%; + } + + &-help { + margin-top: 10px; + font-size: 13px; + } + + &-error { + opacity: 0; + transition: none; + + &.is-showing { + opacity: 1; + } + } + + &-buy { + margin-top: 10px; + } +} \ No newline at end of file diff --git a/common/components/WalletDecrypt/LedgerNano.tsx b/common/components/WalletDecrypt/LedgerNano.tsx index b17fa0db..02d4b195 100644 --- a/common/components/WalletDecrypt/LedgerNano.tsx +++ b/common/components/WalletDecrypt/LedgerNano.tsx @@ -1,29 +1,150 @@ +import './LedgerNano.scss'; import React, { Component } from 'react'; -import translate from 'translations'; +import translate, { translateRaw } from 'translations'; +import DeterministicWalletsModal from './DeterministicWalletsModal'; +import LedgerWallet from 'libs/wallet/ledger'; +import Ledger3 from 'vendor/ledger3'; +import LedgerEth from 'vendor/ledger-eth'; +import DPATHS from 'config/dpaths'; + +const DEFAULT_PATH = DPATHS.LEDGER[0].value; + +interface Props { + onUnlock(param: any): void; +} + +interface State { + publicKey: string; + chainCode: string; + dPath: string; + error: string | null; + isLoading: boolean; +} + +export default class LedgerNanoSDecrypt extends Component { + public state: State = { + publicKey: '', + chainCode: '', + dPath: DEFAULT_PATH, + error: null, + isLoading: false + }; -export default class LedgerNanoSDecrypt extends Component { public render() { + const { dPath, publicKey, chainCode, error, isLoading } = this.state; + const showErr = error ? 'is-showing' : ''; + return ( -
-
-

- {translate('ADD_Radio_2_alt')} -

- -
- +
+ + + +
+ {error || '-'} +
+ + + {translate('Don’t have a Ledger? Order one now!')} + + +
); } + + private handlePathChange = (dPath: string) => { + this.handleConnect(dPath); + }; + + private handleConnect = (dPath: string = this.state.dPath) => { + this.setState({ + isLoading: true, + error: null + }); + + const ledger = new Ledger3('w0w'); + const ethApp = new LedgerEth(ledger); + + ethApp.getAddress( + dPath, + (res, err) => { + if (err) { + err = ethApp.getError(err); + } + + if (res) { + this.setState({ + publicKey: res.publicKey, + chainCode: res.chainCode, + isLoading: false + }); + } else { + this.setState({ + error: err, + isLoading: false + }); + } + }, + false, + true + ); + }; + + private handleCancel = () => { + this.setState({ + publicKey: '', + chainCode: '', + dPath: DEFAULT_PATH + }); + }; + + private handleUnlock = (address: string, index: number) => { + this.props.onUnlock(new LedgerWallet(address, this.state.dPath, index)); + }; + + private handleNullConnect = (): void => { + return this.handleConnect(); + }; } diff --git a/common/components/WalletDecrypt/Trezor.tsx b/common/components/WalletDecrypt/Trezor.tsx index cd29ebef..39861db6 100644 --- a/common/components/WalletDecrypt/Trezor.tsx +++ b/common/components/WalletDecrypt/Trezor.tsx @@ -81,6 +81,7 @@ export default class TrezorDecrypt extends Component { } private handlePathChange = (dPath: string) => { + this.setState({ dPath }); this.handleConnect(dPath); }; diff --git a/common/components/WalletDecrypt/index.tsx b/common/components/WalletDecrypt/index.tsx index 15ff4b4f..37642b34 100644 --- a/common/components/WalletDecrypt/index.tsx +++ b/common/components/WalletDecrypt/index.tsx @@ -51,7 +51,9 @@ const WALLETS = { 'ledger-nano-s': { lid: 'x_Ledger', component: LedgerNanoSDecrypt, - disabled: true + initialParams: {}, + unlock: setWallet, + disabled: false }, trezor: { lid: 'x_Trezor', @@ -120,9 +122,7 @@ export class WalletDecrypt extends Component { onChange={this.handleDecryptionChoiceChange} disabled={wallet.disabled} /> - - {translate(wallet.lid)} - + {translate(wallet.lid)} ); }); @@ -149,19 +149,15 @@ export class WalletDecrypt extends Component { return (
-

- {translate('decrypt_Access')} -

+

{translate('decrypt_Access')}

{this.buildWalletOptions()}
{decryptionComponent} - {!!(this.state.value as PrivateKeyValue).valid && + {!!(this.state.value as PrivateKeyValue).valid && (
-

- {translate('ADD_Label_6')} -

+

{translate('ADD_Label_6')}

-
} +
+ )} ); } diff --git a/common/config/dpaths.ts b/common/config/dpaths.ts index b73ba329..3e1d1d6a 100644 --- a/common/config/dpaths.ts +++ b/common/config/dpaths.ts @@ -44,7 +44,10 @@ const MNEMONIC = [ EXPANSE ]; +const LEDGER = [ETH_LEDGER, ETC_LEDGER, TESTNET]; + export default { TREZOR, - MNEMONIC + MNEMONIC, + LEDGER }; diff --git a/common/libs/wallet/deterministic.ts b/common/libs/wallet/deterministic.ts index 0f6078d9..f64b2bfd 100644 --- a/common/libs/wallet/deterministic.ts +++ b/common/libs/wallet/deterministic.ts @@ -1,5 +1,3 @@ -import { IWallet } from './IWallet'; - export default class DeterministicWallet { private address: string; private dPath: string; diff --git a/common/libs/wallet/index.ts b/common/libs/wallet/index.ts index f56456d1..c254c502 100644 --- a/common/libs/wallet/index.ts +++ b/common/libs/wallet/index.ts @@ -5,3 +5,4 @@ export { default as PresaleWallet } from './presale'; export { default as MewV1Wallet } from './mewv1'; export { default as UtcWallet } from './utc'; export { default as MnemonicWallet } from './mnemonic'; +export { default as LedgerWallet } from './ledger'; diff --git a/common/libs/wallet/ledger.ts b/common/libs/wallet/ledger.ts new file mode 100644 index 00000000..4af47c3a --- /dev/null +++ b/common/libs/wallet/ledger.ts @@ -0,0 +1,91 @@ +import Ledger3 from 'vendor/ledger3'; +import LedgerEth from 'vendor/ledger-eth'; +import EthTx from 'ethereumjs-tx'; +import { addHexPrefix, rlp } from 'ethereumjs-util'; +import DeterministicWallet from './deterministic'; +import { IWallet } from './IWallet'; +import { RawTransaction } from 'libs/transaction'; + +export default class LedgerWallet extends DeterministicWallet + implements IWallet { + private ledger: any; + private ethApp: any; + + constructor(address: string, dPath: string, index: number) { + super(address, dPath, index); + this.ledger = new Ledger3('w0w'); + this.ethApp = new LedgerEth(this.ledger); + } + + // modeled after + // https://github.com/kvhnuke/etherwallet/blob/3f7ff809e5d02d7ea47db559adaca1c930025e24/app/scripts/uiFuncs.js#L58 + public signRawTransaction(rawTx: RawTransaction): Promise { + return new Promise((resolve, reject) => { + const eTx = new EthTx({ + ...rawTx, + v: Buffer.from([rawTx.chainId]), + r: 0, + s: 0 + }); + + this.ethApp.signTransaction( + this.getPath(), + rlp.encode(eTx.raw).toString('hex'), + (result, error) => { + if (error) { + return reject(this.ethApp.getError(error)); + } + + const txToSerialize = { + ...rawTx, + v: addHexPrefix(result.v), + r: addHexPrefix(result.r), + s: addHexPrefix(result.s) + }; + + const serializedTx = new EthTx(txToSerialize) + .serialize() + .toString('hex'); + + resolve(addHexPrefix(serializedTx)); + } + ); + }); + } + + // modeled after + // https://github.com/kvhnuke/etherwallet/blob/3f7ff809e5d02d7ea47db559adaca1c930025e24/app/scripts/controllers/signMsgCtrl.js#L53 + public signMessage(msg: string): Promise { + return new Promise((resolve, reject) => { + const msgHex = Buffer.from(msg).toString('hex'); + + this.ethApp.signPersonalMessage_async( + this.getPath(), + msgHex, + async (signed, error) => { + if (error) { + return reject(this.ethApp.getError(error)); + } + + try { + const combined = signed.r + signed.s + signed.v; + const combinedHex = combined.toString('hex'); + const signedMsg = JSON.stringify( + { + address: await this.getAddress(), + msg, + sig: addHexPrefix(combinedHex), + version: '2' + }, + null, + 2 + ); + resolve(signedMsg); + } catch (err) { + reject(err); + } + } + ); + }); + } +} diff --git a/common/typescript/ethereumjs-tx.d.ts b/common/typescript/ethereumjs-tx.d.ts index e20d3b4d..3b72cc1c 100644 --- a/common/typescript/ethereumjs-tx.d.ts +++ b/common/typescript/ethereumjs-tx.d.ts @@ -40,6 +40,8 @@ declare module 'ethereumjs-tx' { export = ITx; class ITx { + public raw: Buffer; + constructor(data: Data); /** * If the tx's `to` is to the creation address diff --git a/common/typescript/ethereumjs-util.d.ts b/common/typescript/ethereumjs-util.d.ts index 15947168..3e5d812f 100644 --- a/common/typescript/ethereumjs-util.d.ts +++ b/common/typescript/ethereumjs-util.d.ts @@ -1,6 +1,7 @@ declare module 'ethereumjs-util' { import { Buffer } from 'buffer'; import BN = require('bn.js'); + export import rlp = require('rlp'); interface Signature { v: number; diff --git a/common/vendor/ledger-eth.js b/common/vendor/ledger-eth.js new file mode 100644 index 00000000..42a2f84f --- /dev/null +++ b/common/vendor/ledger-eth.js @@ -0,0 +1,283 @@ +/* prettier-ignore */ + +/******************************************************************************** + * Ledger Communication toolkit + * (c) 2016 Ledger + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + ********************************************************************************/ + +'use strict'; + +// MEW - Require u2f instead of expecting it in global scope +var u2f = require('./u2f-api'); + +var LedgerEth = function(comm) { + this.comm = comm; +}; + +//MEW - Add error handling method +LedgerEth.prototype.getError = function(err) { + return err.errorCode + ? u2f.getErrorByCode(err.errorCode) + : err; +}; + +LedgerEth.splitPath = function(path) { + var result = []; + var components = path.split('/'); + components.forEach(function(element, index) { + var number = parseInt(element, 10); + if (isNaN(number)) { + return; + } + if (element.length > 1 && element[element.length - 1] == "'") { + number += 0x80000000; + } + result.push(number); + }); + return result; +}; + +// callback is function(response, error) +LedgerEth.prototype.getAddress = function( + path, + callback, + boolDisplay, + boolChaincode +) { + var splitPath = LedgerEth.splitPath(path); + var buffer = new Buffer(5 + 1 + splitPath.length * 4); + buffer[0] = 0xe0; + buffer[1] = 0x02; + buffer[2] = boolDisplay ? 0x01 : 0x00; + buffer[3] = boolChaincode ? 0x01 : 0x00; + buffer[4] = 1 + splitPath.length * 4; + buffer[5] = splitPath.length; + splitPath.forEach(function(element, index) { + buffer.writeUInt32BE(element, 6 + 4 * index); + }); + var self = this; + var localCallback = function(response, error) { + if (typeof error != 'undefined') { + callback(undefined, error); + } else { + var result = {}; + response = new Buffer(response, 'hex'); + var sw = response.readUInt16BE(response.length - 2); + if (sw != 0x9000) { + callback( + undefined, + 'Invalid status ' + + sw.toString(16) + + '. Check to make sure the right application is selected ?' + ); + return; + } + var publicKeyLength = response[0]; + var addressLength = response[1 + publicKeyLength]; + result['publicKey'] = response + .slice(1, 1 + publicKeyLength) + .toString('hex'); + result['address'] = + '0x' + + response + .slice( + 1 + publicKeyLength + 1, + 1 + publicKeyLength + 1 + addressLength + ) + .toString('ascii'); + if (boolChaincode) { + result['chainCode'] = response + .slice( + 1 + publicKeyLength + 1 + addressLength, + 1 + publicKeyLength + 1 + addressLength + 32 + ) + .toString('hex'); + } + callback(result); + } + }; + this.comm.exchange(buffer.toString('hex'), localCallback); +}; + +// callback is function(response, error) +LedgerEth.prototype.signTransaction = function(path, rawTxHex, callback) { + var splitPath = LedgerEth.splitPath(path); + var offset = 0; + var rawTx = new Buffer(rawTxHex, 'hex'); + var apdus = []; + while (offset != rawTx.length) { + var maxChunkSize = offset == 0 ? 150 - 1 - splitPath.length * 4 : 150; + var chunkSize = + offset + maxChunkSize > rawTx.length + ? rawTx.length - offset + : maxChunkSize; + var buffer = new Buffer( + offset == 0 ? 5 + 1 + splitPath.length * 4 + chunkSize : 5 + chunkSize + ); + buffer[0] = 0xe0; + buffer[1] = 0x04; + buffer[2] = offset == 0 ? 0x00 : 0x80; + buffer[3] = 0x00; + buffer[4] = offset == 0 ? 1 + splitPath.length * 4 + chunkSize : chunkSize; + if (offset == 0) { + buffer[5] = splitPath.length; + splitPath.forEach(function(element, index) { + buffer.writeUInt32BE(element, 6 + 4 * index); + }); + rawTx.copy(buffer, 6 + 4 * splitPath.length, offset, offset + chunkSize); + } else { + rawTx.copy(buffer, 5, offset, offset + chunkSize); + } + apdus.push(buffer.toString('hex')); + offset += chunkSize; + } + var self = this; + var localCallback = function(response, error) { + if (typeof error != 'undefined') { + callback(undefined, error); + } else { + response = new Buffer(response, 'hex'); + var sw = response.readUInt16BE(response.length - 2); + if (sw != 0x9000) { + callback( + undefined, + 'Invalid status ' + + sw.toString(16) + + '. Check to make sure contract data is on ?' + ); + return; + } + if (apdus.length == 0) { + var result = {}; + result['v'] = response.slice(0, 1).toString('hex'); + result['r'] = response.slice(1, 1 + 32).toString('hex'); + result['s'] = response.slice(1 + 32, 1 + 32 + 32).toString('hex'); + callback(result); + } else { + self.comm.exchange(apdus.shift(), localCallback); + } + } + }; + self.comm.exchange(apdus.shift(), localCallback); +}; + +// callback is function(response, error) +LedgerEth.prototype.getAppConfiguration = function(callback) { + var buffer = new Buffer(5); + buffer[0] = 0xe0; + buffer[1] = 0x06; + buffer[2] = 0x00; + buffer[3] = 0x00; + buffer[4] = 0x00; + var localCallback = function(response, error) { + if (typeof error != 'undefined') { + callback(undefined, error); + } else { + response = new Buffer(response, 'hex'); + var result = {}; + var sw = response.readUInt16BE(response.length - 2); + if (sw != 0x9000) { + callback( + undefined, + 'Invalid status ' + + sw.toString(16) + + '. Check to make sure the right application is selected ?' + ); + return; + } + result['arbitraryDataEnabled'] = response[0] & 0x01; + result['version'] = + '' + response[1] + '.' + response[2] + '.' + response[3]; + callback(result); + } + }; + this.comm.exchange(buffer.toString('hex'), localCallback); +}; + +LedgerEth.prototype.signPersonalMessage_async = function( + path, + messageHex, + callback +) { + var splitPath = LedgerEth.splitPath(path); + var offset = 0; + var message = new Buffer(messageHex, 'hex'); + var apdus = []; + var response = []; + var self = this; + while (offset != message.length) { + var maxChunkSize = offset == 0 ? 150 - 1 - splitPath.length * 4 - 4 : 150; + var chunkSize = + offset + maxChunkSize > message.length + ? message.length - offset + : maxChunkSize; + var buffer = new Buffer( + offset == 0 ? 5 + 1 + splitPath.length * 4 + 4 + chunkSize : 5 + chunkSize + ); + buffer[0] = 0xe0; + buffer[1] = 0x08; + buffer[2] = offset == 0 ? 0x00 : 0x80; + buffer[3] = 0x00; + buffer[4] = + offset == 0 ? 1 + splitPath.length * 4 + 4 + chunkSize : chunkSize; + if (offset == 0) { + buffer[5] = splitPath.length; + splitPath.forEach(function(element, index) { + buffer.writeUInt32BE(element, 6 + 4 * index); + }); + buffer.writeUInt32BE(message.length, 6 + 4 * splitPath.length); + message.copy( + buffer, + 6 + 4 * splitPath.length + 4, + offset, + offset + chunkSize + ); + } else { + message.copy(buffer, 5, offset, offset + chunkSize); + } + apdus.push(buffer.toString('hex')); + offset += chunkSize; + } + var self = this; + var localCallback = function(response, error) { + if (typeof error != 'undefined') { + callback(undefined, error); + } else { + response = new Buffer(response, 'hex'); + var sw = response.readUInt16BE(response.length - 2); + if (sw != 0x9000) { + callback( + undefined, + 'Invalid status ' + + sw.toString(16) + + '. Check to make sure the right application is selected ?' + ); + return; + } + if (apdus.length == 0) { + var result = {}; + result['v'] = response.slice(0, 1).toString('hex'); + result['r'] = response.slice(1, 1 + 32).toString('hex'); + result['s'] = response.slice(1 + 32, 1 + 32 + 32).toString('hex'); + callback(result); + } else { + self.comm.exchange(apdus.shift(), localCallback); + } + } + }; + self.comm.exchange(apdus.shift(), localCallback); +}; + +module.exports = LedgerEth; diff --git a/common/vendor/ledger3.js b/common/vendor/ledger3.js new file mode 100644 index 00000000..86d3ca23 --- /dev/null +++ b/common/vendor/ledger3.js @@ -0,0 +1,87 @@ +/* prettier-ignore */ + +/******************************************************************************** + * Ledger Communication toolkit + * (c) 2016 Ledger + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + ********************************************************************************/ + +'use strict'; + +// MEW - Require u2f instead of expecting it in global scope +var u2f = require('./u2f-api'); + +var Ledger3 = function(scrambleKey, timeoutSeconds) { + this.scrambleKey = new Buffer(scrambleKey, 'ascii'); + this.timeoutSeconds = timeoutSeconds; +}; + +Ledger3.wrapApdu = function(apdu, key) { + var result = new Buffer(apdu.length); + for (var i = 0; i < apdu.length; i++) { + result[i] = apdu[i] ^ key[i % key.length]; + } + return result; +}; + +// Convert from normal to web-safe, strip trailing "="s +Ledger3.webSafe64 = function(base64) { + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +}; + +// Convert from web-safe to normal, add trailing "="s +Ledger3.normal64 = function(base64) { + return ( + base64.replace(/\-/g, '+').replace(/_/g, '/') + + '=='.substring(0, 3 * base64.length % 4) + ); +}; + +Ledger3.prototype.u2fCallback = function(response, callback) { + if (typeof response['signatureData'] != 'undefined') { + var data = new Buffer( + Ledger3.normal64(response['signatureData']), + 'base64' + ); + callback(data.toString('hex', 5)); + } else { + callback(undefined, response); + } +}; + +// callback is function(response, error) +Ledger3.prototype.exchange = function(apduHex, callback) { + var apdu = new Buffer(apduHex, 'hex'); + var keyHandle = Ledger3.wrapApdu(apdu, this.scrambleKey); + var challenge = new Buffer( + '0000000000000000000000000000000000000000000000000000000000000000', + 'hex' + ); + var key = {}; + key['version'] = 'U2F_V2'; + key['keyHandle'] = Ledger3.webSafe64(keyHandle.toString('base64')); + var self = this; + var localCallback = function(result) { + self.u2fCallback(result, callback); + }; + u2f.sign( + location.origin, + Ledger3.webSafe64(challenge.toString('base64')), + [key], + localCallback, + this.timeoutSeconds + ); +}; + +module.exports = Ledger3; diff --git a/common/vendor/u2f-api.js b/common/vendor/u2f-api.js new file mode 100644 index 00000000..1fdfe41c --- /dev/null +++ b/common/vendor/u2f-api.js @@ -0,0 +1,832 @@ +/* prettier-ignore */ + +//Copyright 2014-2015 Google Inc. All rights reserved. + +//Use of this source code is governed by a BSD-style +//license that can be found in the LICENSE file or at +//https://developers.google.com/open-source/licenses/bsd + +/** + * @fileoverview The U2F api. + */ +'use strict'; + +/** + * Namespace for the U2F api. + * @type {Object} + */ +var u2f = u2f || {}; + +/** + * FIDO U2F Javascript API Version + * @number + */ +var js_api_version; + +/** + * The U2F extension id + * @const {string} + */ +// The Chrome packaged app extension ID. +// Uncomment this if you want to deploy a server instance that uses +// the package Chrome app and does not require installing the U2F Chrome extension. +u2f.EXTENSION_ID = 'kmendfapggjehodndflmmgagdbamhnfd'; +// The U2F Chrome extension ID. +// Uncomment this if you want to deploy a server instance that uses +// the U2F Chrome extension to authenticate. +// u2f.EXTENSION_ID = 'pfboblefjcgdjicmnffhdgionmgcdmne'; + +/** + * Message types for messsages to/from the extension + * @const + * @enum {string} + */ +u2f.MessageTypes = { + U2F_REGISTER_REQUEST: 'u2f_register_request', + U2F_REGISTER_RESPONSE: 'u2f_register_response', + U2F_SIGN_REQUEST: 'u2f_sign_request', + U2F_SIGN_RESPONSE: 'u2f_sign_response', + U2F_GET_API_VERSION_REQUEST: 'u2f_get_api_version_request', + U2F_GET_API_VERSION_RESPONSE: 'u2f_get_api_version_response' +}; + +/** + * Response status codes + * @const + * @enum {number} + */ +u2f.ErrorCodes = { + OK: 0, + OTHER_ERROR: 1, + BAD_REQUEST: 2, + CONFIGURATION_UNSUPPORTED: 3, + DEVICE_INELIGIBLE: 4, + TIMEOUT: 5 +}; + +u2f.getErrorByCode = function(code) { + for (var prop in u2f.ErrorCodes) { + if (u2f.ErrorCodes.hasOwnProperty(prop)) { + if (u2f.ErrorCodes[prop] === code) return prop; + } + } +}; + +/** + * A message for registration requests + * @typedef {{ + * type: u2f.MessageTypes, + * appId: ?string, + * timeoutSeconds: ?number, + * requestId: ?number + * }} + */ +u2f.U2fRequest; + +/** + * A message for registration responses + * @typedef {{ + * type: u2f.MessageTypes, + * responseData: (u2f.Error | u2f.RegisterResponse | u2f.SignResponse), + * requestId: ?number + * }} + */ +u2f.U2fResponse; + +/** + * An error object for responses + * @typedef {{ + * errorCode: u2f.ErrorCodes, + * errorMessage: ?string + * }} + */ +u2f.Error; + +/** + * Data object for a single sign request. + * @typedef {enum {BLUETOOTH_RADIO, BLUETOOTH_LOW_ENERGY, USB, NFC}} + */ +u2f.Transport; + +/** + * Data object for a single sign request. + * @typedef {Array} + */ +u2f.Transports; + +/** + * Data object for a single sign request. + * @typedef {{ + * version: string, + * challenge: string, + * keyHandle: string, + * appId: string + * }} + */ +u2f.SignRequest; + +/** + * Data object for a sign response. + * @typedef {{ + * keyHandle: string, + * signatureData: string, + * clientData: string + * }} + */ +u2f.SignResponse; + +/** + * Data object for a registration request. + * @typedef {{ + * version: string, + * challenge: string + * }} + */ +u2f.RegisterRequest; + +/** + * Data object for a registration response. + * @typedef {{ + * version: string, + * keyHandle: string, + * transports: Transports, + * appId: string + * }} + */ +u2f.RegisterResponse; + +/** + * Data object for a registered key. + * @typedef {{ + * version: string, + * keyHandle: string, + * transports: ?Transports, + * appId: ?string + * }} + */ +u2f.RegisteredKey; + +/** + * Data object for a get API register response. + * @typedef {{ + * js_api_version: number + * }} + */ +u2f.GetJsApiVersionResponse; + +//Low level MessagePort API support + +/** + * Sets up a MessagePort to the U2F extension using the + * available mechanisms. + * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback + */ +u2f.getMessagePort = function(callback) { + if (typeof chrome != 'undefined' && chrome.runtime) { + // The actual message here does not matter, but we need to get a reply + // for the callback to run. Thus, send an empty signature request + // in order to get a failure response. + var msg = { + type: u2f.MessageTypes.U2F_SIGN_REQUEST, + signRequests: [] + }; + chrome.runtime.sendMessage(u2f.EXTENSION_ID, msg, function() { + if (!chrome.runtime.lastError) { + // We are on a whitelisted origin and can talk directly + // with the extension. + u2f.getChromeRuntimePort_(callback); + } else { + // chrome.runtime was available, but we couldn't message + // the extension directly, use iframe + u2f.getIframePort_(callback); + } + }); + } else if (u2f.isAndroidChrome_()) { + u2f.getAuthenticatorPort_(callback); + } else if (u2f.isIosChrome_()) { + u2f.getIosPort_(callback); + } else { + // chrome.runtime was not available at all, which is normal + // when this origin doesn't have access to any extensions. + u2f.getIframePort_(callback); + } +}; + +/** + * Detect chrome running on android based on the browser's useragent. + * @private + */ +u2f.isAndroidChrome_ = function() { + var userAgent = navigator.userAgent; + return ( + userAgent.indexOf('Chrome') != -1 && userAgent.indexOf('Android') != -1 + ); +}; + +/** + * Detect chrome running on iOS based on the browser's platform. + * @private + */ +u2f.isIosChrome_ = function() { + return $.inArray(navigator.platform, ['iPhone', 'iPad', 'iPod']) > -1; +}; + +/** + * Connects directly to the extension via chrome.runtime.connect. + * @param {function(u2f.WrappedChromeRuntimePort_)} callback + * @private + */ +u2f.getChromeRuntimePort_ = function(callback) { + var port = chrome.runtime.connect(u2f.EXTENSION_ID, { + includeTlsChannelId: true + }); + setTimeout(function() { + callback(new u2f.WrappedChromeRuntimePort_(port)); + }, 0); +}; + +/** + * Return a 'port' abstraction to the Authenticator app. + * @param {function(u2f.WrappedAuthenticatorPort_)} callback + * @private + */ +u2f.getAuthenticatorPort_ = function(callback) { + setTimeout(function() { + callback(new u2f.WrappedAuthenticatorPort_()); + }, 0); +}; + +/** + * Return a 'port' abstraction to the iOS client app. + * @param {function(u2f.WrappedIosPort_)} callback + * @private + */ +u2f.getIosPort_ = function(callback) { + setTimeout(function() { + callback(new u2f.WrappedIosPort_()); + }, 0); +}; + +/** + * A wrapper for chrome.runtime.Port that is compatible with MessagePort. + * @param {Port} port + * @constructor + * @private + */ +u2f.WrappedChromeRuntimePort_ = function(port) { + this.port_ = port; +}; + +/** + * Format and return a sign request compliant with the JS API version supported by the extension. + * @param {Array} signRequests + * @param {number} timeoutSeconds + * @param {number} reqId + * @return {Object} + */ +u2f.formatSignRequest_ = function( + appId, + challenge, + registeredKeys, + timeoutSeconds, + reqId +) { + if (js_api_version === undefined || js_api_version < 1.1) { + // Adapt request to the 1.0 JS API + var signRequests = []; + for (var i = 0; i < registeredKeys.length; i++) { + signRequests[i] = { + version: registeredKeys[i].version, + challenge: challenge, + keyHandle: registeredKeys[i].keyHandle, + appId: appId + }; + } + return { + type: u2f.MessageTypes.U2F_SIGN_REQUEST, + signRequests: signRequests, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; + } + // JS 1.1 API + return { + type: u2f.MessageTypes.U2F_SIGN_REQUEST, + appId: appId, + challenge: challenge, + registeredKeys: registeredKeys, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; +}; + +/** + * Format and return a register request compliant with the JS API version supported by the extension.. + * @param {Array} signRequests + * @param {Array} signRequests + * @param {number} timeoutSeconds + * @param {number} reqId + * @return {Object} + */ +u2f.formatRegisterRequest_ = function( + appId, + registeredKeys, + registerRequests, + timeoutSeconds, + reqId +) { + if (js_api_version === undefined || js_api_version < 1.1) { + // Adapt request to the 1.0 JS API + for (var i = 0; i < registerRequests.length; i++) { + registerRequests[i].appId = appId; + } + var signRequests = []; + for (var i = 0; i < registeredKeys.length; i++) { + signRequests[i] = { + version: registeredKeys[i].version, + challenge: registerRequests[0], + keyHandle: registeredKeys[i].keyHandle, + appId: appId + }; + } + return { + type: u2f.MessageTypes.U2F_REGISTER_REQUEST, + signRequests: signRequests, + registerRequests: registerRequests, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; + } + // JS 1.1 API + return { + type: u2f.MessageTypes.U2F_REGISTER_REQUEST, + appId: appId, + registerRequests: registerRequests, + registeredKeys: registeredKeys, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; +}; + +/** + * Posts a message on the underlying channel. + * @param {Object} message + */ +u2f.WrappedChromeRuntimePort_.prototype.postMessage = function(message) { + this.port_.postMessage(message); +}; + +/** + * Emulates the HTML 5 addEventListener interface. Works only for the + * onmessage event, which is hooked up to the chrome.runtime.Port.onMessage. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedChromeRuntimePort_.prototype.addEventListener = function( + eventName, + handler +) { + var name = eventName.toLowerCase(); + if (name == 'message' || name == 'onmessage') { + this.port_.onMessage.addListener(function(message) { + // Emulate a minimal MessageEvent object + handler({ data: message }); + }); + } else { + console.error('WrappedChromeRuntimePort only supports onMessage'); + } +}; + +/** + * Wrap the Authenticator app with a MessagePort interface. + * @constructor + * @private + */ +u2f.WrappedAuthenticatorPort_ = function() { + this.requestId_ = -1; + this.requestObject_ = null; +}; + +/** + * Launch the Authenticator intent. + * @param {Object} message + */ +u2f.WrappedAuthenticatorPort_.prototype.postMessage = function(message) { + var intentUrl = + u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ + + ';S.request=' + + encodeURIComponent(JSON.stringify(message)) + + ';end'; + document.location = intentUrl; +}; + +/** + * Tells what type of port this is. + * @return {String} port type + */ +u2f.WrappedAuthenticatorPort_.prototype.getPortType = function() { + return 'WrappedAuthenticatorPort_'; +}; + +/** + * Emulates the HTML 5 addEventListener interface. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedAuthenticatorPort_.prototype.addEventListener = function( + eventName, + handler +) { + var name = eventName.toLowerCase(); + if (name == 'message') { + var self = this; + /* Register a callback to that executes when + * chrome injects the response. */ + window.addEventListener( + 'message', + self.onRequestUpdate_.bind(self, handler), + false + ); + } else { + console.error('WrappedAuthenticatorPort only supports message'); + } +}; + +/** + * Callback invoked when a response is received from the Authenticator. + * @param function({data: Object}) callback + * @param {Object} message message Object + */ +u2f.WrappedAuthenticatorPort_.prototype.onRequestUpdate_ = function( + callback, + message +) { + var messageObject = JSON.parse(message.data); + var intentUrl = messageObject['intentURL']; + + var errorCode = messageObject['errorCode']; + var responseObject = null; + if (messageObject.hasOwnProperty('data')) { + responseObject /** @type {Object} */ = JSON.parse(messageObject['data']); + } + + callback({ data: responseObject }); +}; + +/** + * Base URL for intents to Authenticator. + * @const + * @private + */ +u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ = + 'intent:#Intent;action=com.google.android.apps.authenticator.AUTHENTICATE'; + +/** + * Wrap the iOS client app with a MessagePort interface. + * @constructor + * @private + */ +u2f.WrappedIosPort_ = function() {}; + +/** + * Launch the iOS client app request + * @param {Object} message + */ +u2f.WrappedIosPort_.prototype.postMessage = function(message) { + var str = JSON.stringify(message); + var url = 'u2f://auth?' + encodeURI(str); + location.replace(url); +}; + +/** + * Tells what type of port this is. + * @return {String} port type + */ +u2f.WrappedIosPort_.prototype.getPortType = function() { + return 'WrappedIosPort_'; +}; + +/** + * Emulates the HTML 5 addEventListener interface. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedIosPort_.prototype.addEventListener = function(eventName, handler) { + var name = eventName.toLowerCase(); + if (name !== 'message') { + console.error('WrappedIosPort only supports message'); + } +}; + +/** + * Sets up an embedded trampoline iframe, sourced from the extension. + * @param {function(MessagePort)} callback + * @private + */ +u2f.getIframePort_ = function(callback) { + // Create the iframe + var iframeOrigin = 'chrome-extension://' + u2f.EXTENSION_ID; + var iframe = document.createElement('iframe'); + iframe.src = iframeOrigin + '/u2f-comms.html'; + iframe.setAttribute('style', 'display:none'); + document.body.appendChild(iframe); + + var channel = new MessageChannel(); + var ready = function(message) { + if (message.data == 'ready') { + channel.port1.removeEventListener('message', ready); + callback(channel.port1); + } else { + console.error('First event on iframe port was not "ready"'); + } + }; + channel.port1.addEventListener('message', ready); + channel.port1.start(); + + iframe.addEventListener('load', function() { + // Deliver the port to the iframe and initialize + iframe.contentWindow.postMessage('init', iframeOrigin, [channel.port2]); + }); +}; + +//High-level JS API + +/** + * Default extension response timeout in seconds. + * @const + */ +u2f.EXTENSION_TIMEOUT_SEC = 30; + +/** + * A singleton instance for a MessagePort to the extension. + * @type {MessagePort|u2f.WrappedChromeRuntimePort_} + * @private + */ +u2f.port_ = null; + +/** + * Callbacks waiting for a port + * @type {Array} + * @private + */ +u2f.waitingForPort_ = []; + +/** + * A counter for requestIds. + * @type {number} + * @private + */ +u2f.reqCounter_ = 0; + +/** + * A map from requestIds to client callbacks + * @type {Object.} + * @private + */ +u2f.callbackMap_ = {}; + +/** + * Creates or retrieves the MessagePort singleton to use. + * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback + * @private + */ +u2f.getPortSingleton_ = function(callback) { + if (u2f.port_) { + callback(u2f.port_); + } else { + if (u2f.waitingForPort_.length == 0) { + u2f.getMessagePort(function(port) { + u2f.port_ = port; + u2f.port_.addEventListener( + 'message', + /** @type {function(Event)} */ u2f.responseHandler_ + ); + + // Careful, here be async callbacks. Maybe. + while (u2f.waitingForPort_.length) + u2f.waitingForPort_.shift()(u2f.port_); + }); + } + u2f.waitingForPort_.push(callback); + } +}; + +/** + * Handles response messages from the extension. + * @param {MessageEvent.} message + * @private + */ +u2f.responseHandler_ = function(message) { + var response = message.data; + var reqId = response['requestId']; + if (!reqId || !u2f.callbackMap_[reqId]) { + console.error('Unknown or missing requestId in response.'); + return; + } + var cb = u2f.callbackMap_[reqId]; + delete u2f.callbackMap_[reqId]; + cb(response['responseData']); +}; + +/** + * Dispatches an array of sign requests to available U2F tokens. + * If the JS API version supported by the extension is unknown, it first sends a + * message to the extension to find out the supported API version and then it sends + * the sign request. + * @param {string=} appId + * @param {string=} challenge + * @param {Array} registeredKeys + * @param {function((u2f.Error|u2f.SignResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.sign = function( + appId, + challenge, + registeredKeys, + callback, + opt_timeoutSeconds +) { + if (js_api_version === undefined) { + // Send a message to get the extension to JS API version, then send the actual sign request. + u2f.getApiVersion(function(response) { + js_api_version = + response['js_api_version'] === undefined + ? 0 + : response['js_api_version']; + console.log('Extension JS API Version: ', js_api_version); + u2f.sendSignRequest( + appId, + challenge, + registeredKeys, + callback, + opt_timeoutSeconds + ); + }); + } else { + // We know the JS API version. Send the actual sign request in the supported API version. + u2f.sendSignRequest( + appId, + challenge, + registeredKeys, + callback, + opt_timeoutSeconds + ); + } +}; + +/** + * Dispatches an array of sign requests to available U2F tokens. + * @param {string=} appId + * @param {string=} challenge + * @param {Array} registeredKeys + * @param {function((u2f.Error|u2f.SignResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.sendSignRequest = function( + appId, + challenge, + registeredKeys, + callback, + opt_timeoutSeconds +) { + u2f.getPortSingleton_(function(port) { + var reqId = ++u2f.reqCounter_; + u2f.callbackMap_[reqId] = callback; + var timeoutSeconds = + typeof opt_timeoutSeconds !== 'undefined' + ? opt_timeoutSeconds + : u2f.EXTENSION_TIMEOUT_SEC; + var req = u2f.formatSignRequest_( + appId, + challenge, + registeredKeys, + timeoutSeconds, + reqId + ); + port.postMessage(req); + }); +}; + +/** + * Dispatches register requests to available U2F tokens. An array of sign + * requests identifies already registered tokens. + * If the JS API version supported by the extension is unknown, it first sends a + * message to the extension to find out the supported API version and then it sends + * the register request. + * @param {string=} appId + * @param {Array} registerRequests + * @param {Array} registeredKeys + * @param {function((u2f.Error|u2f.RegisterResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.register = function( + appId, + registerRequests, + registeredKeys, + callback, + opt_timeoutSeconds +) { + if (js_api_version === undefined) { + // Send a message to get the extension to JS API version, then send the actual register request. + u2f.getApiVersion(function(response) { + js_api_version = + response['js_api_version'] === undefined + ? 0 + : response['js_api_version']; + console.log('Extension JS API Version: ', js_api_version); + u2f.sendRegisterRequest( + appId, + registerRequests, + registeredKeys, + callback, + opt_timeoutSeconds + ); + }); + } else { + // We know the JS API version. Send the actual register request in the supported API version. + u2f.sendRegisterRequest( + appId, + registerRequests, + registeredKeys, + callback, + opt_timeoutSeconds + ); + } +}; + +/** + * Dispatches register requests to available U2F tokens. An array of sign + * requests identifies already registered tokens. + * @param {string=} appId + * @param {Array} registerRequests + * @param {Array} registeredKeys + * @param {function((u2f.Error|u2f.RegisterResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.sendRegisterRequest = function( + appId, + registerRequests, + registeredKeys, + callback, + opt_timeoutSeconds +) { + u2f.getPortSingleton_(function(port) { + var reqId = ++u2f.reqCounter_; + u2f.callbackMap_[reqId] = callback; + var timeoutSeconds = + typeof opt_timeoutSeconds !== 'undefined' + ? opt_timeoutSeconds + : u2f.EXTENSION_TIMEOUT_SEC; + var req = u2f.formatRegisterRequest_( + appId, + registeredKeys, + registerRequests, + timeoutSeconds, + reqId + ); + port.postMessage(req); + }); +}; + +/** + * Dispatches a message to the extension to find out the supported + * JS API version. + * If the user is on a mobile phone and is thus using Google Authenticator instead + * of the Chrome extension, don't send the request and simply return 0. + * @param {function((u2f.Error|u2f.GetJsApiVersionResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.getApiVersion = function(callback, opt_timeoutSeconds) { + u2f.getPortSingleton_(function(port) { + // If we are using Android Google Authenticator or iOS client app, + // do not fire an intent to ask which JS API version to use. + if (port.getPortType) { + var apiVersion; + switch (port.getPortType()) { + case 'WrappedIosPort_': + case 'WrappedAuthenticatorPort_': + apiVersion = 1.1; + break; + + default: + apiVersion = 0; + break; + } + callback({ js_api_version: apiVersion }); + return; + } + var reqId = ++u2f.reqCounter_; + u2f.callbackMap_[reqId] = callback; + var req = { + type: u2f.MessageTypes.U2F_GET_API_VERSION_REQUEST, + timeoutSeconds: + typeof opt_timeoutSeconds !== 'undefined' + ? opt_timeoutSeconds + : u2f.EXTENSION_TIMEOUT_SEC, + requestId: reqId + }; + port.postMessage(req); + }); +}; +module.exports = u2f; diff --git a/common/vendors.js b/common/vendors.js index d72c6af6..341697fa 100644 --- a/common/vendors.js +++ b/common/vendors.js @@ -24,4 +24,3 @@ require('qrcode'); require('qrcode.react'); require('bignumber.js'); require('classnames'); -require('./vendor/trezor-connect'); diff --git a/package.json b/package.json index 24769962..9a57ffe5 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "tslint-config-prettier": "^1.5.0", "tslint-react": "^3.2.0", "types-bn": "0.0.1", + "types-rlp": "0.0.1", "typescript": "^2.5.2", "url-loader": "^0.5.8", "webpack": "^3.6.0", @@ -120,7 +121,7 @@ "dev:https": "HTTPS=true node webpack_config/server.js", "predev:https": "check-node-version --package", "derivation-checker": "webpack --config=./webpack_config/webpack.derivation-checker.js && node ./dist/derivation-checker.js", - "tslint": "tslint --project . --exclude common/vendor/*", + "tslint": "tslint --project . --exclude common/vendor/**/*", "postinstall": "webpack --config=./webpack_config/webpack.dll.js", "start": "npm run dev", "precommit": "lint-staged" diff --git a/tsconfig.json b/tsconfig.json index f5f06914..c3dc0955 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,16 +8,14 @@ "target": "es5", "allowJs": true, "baseUrl": "./common/", - "lib": [ - "es2017", - "dom" - ], + "lib": ["es2017", "dom"], "allowSyntheticDefaultImports": true, "moduleResolution": "node", "noEmitOnError": false }, "include": [ "./common/", + "./node_modules/types-rlp/index.d.ts", "./node_modules/types-bn/index.d.ts" ], "awesomeTypescriptLoaderOptions": {