diff options
initial trezor support
Diffstat (limited to 'app')
-rw-r--r-- | app/_locales/en/messages.json | 9 | ||||
-rw-r--r-- | app/images/connect-icon.svg | 11 | ||||
-rw-r--r-- | app/scripts/lib/trezor-connect.js | 1138 | ||||
-rw-r--r-- | app/scripts/lib/trezorKeyring.js | 255 | ||||
-rw-r--r-- | app/scripts/metamask-controller.js | 63 |
5 files changed, 1475 insertions, 1 deletions
diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 457c3c3b1..339dd8da2 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -119,6 +119,12 @@ "confirmTransaction": { "message": "Confirm Transaction" }, + "connectHardware": { + "message": "Connect Hardware" + }, + "connect": { + "message": "Connect" + }, "continue": { "message": "Continue" }, @@ -930,6 +936,9 @@ "unknownNetworkId": { "message": "Unknown network ID" }, + "unlock": { + "message": "Unlock" + }, "unlockMessage": { "message": "The decentralized web awaits" }, diff --git a/app/images/connect-icon.svg b/app/images/connect-icon.svg new file mode 100644 index 000000000..84540999a --- /dev/null +++ b/app/images/connect-icon.svg @@ -0,0 +1,11 @@ +<svg width="288" height="288" xmlns="http://www.w3.org/2000/svg"> + + <g> + <title>background</title> + <rect fill="none" id="canvas_background" height="402" width="582" y="-1" x="-1"/> + </g> + <g> + <title>Layer 1</title> + <path fill="#ffffff" id="svg_1" d="m122,25l15,-21c4,-5 10,-5 14,0l16,22c4,5 2,10 -5,10l-12,0l0,118c0,3 3,3 5,1l25,-25c4,-4 6,-10 6,-16l0,-24c-7,0 -12,-5 -12,-12l0,-12c0,-6 5,-12 12,-12l12,0c7,0 12,5 12,12l0,12c0,7 -5,12 -12,12l0,24c0,10 -3,18 -10,25l-31,31c-4,4 -7,6 -7,16l0,49c12,3 21,13 21,26c0,15 -12,27 -27,27s-27,-12 -27,-27c0,-13 9,-23 21,-26l0,-13c0,-10 -3,-13 -7,-17l-31,-31c-6,-6 -10,-14 -10,-24l0,-25c-7,-2 -12,-9 -12,-17c0,-10 8,-18 18,-18s18,8 18,18c0,8 -5,15 -12,17l0,25c0,7 3,12 7,16l25,25c2,2 4,2 4,-1l0,-154l-12,0c-7,0 -9,-5 -4,-11z"/> + </g> +</svg>
\ No newline at end of file diff --git a/app/scripts/lib/trezor-connect.js b/app/scripts/lib/trezor-connect.js new file mode 100644 index 000000000..574e88104 --- /dev/null +++ b/app/scripts/lib/trezor-connect.js @@ -0,0 +1,1138 @@ +/* eslint-disable */ +/* prettier-ignore */ + +/** + * (C) 2017 SatoshiLabs + * + * GPLv3 + */ +var TREZOR_CONNECT_VERSION = 4; + +if (!Array.isArray) { + Array.isArray = function(arg) { + return Object.prototype.toString.call(arg) === "[object Array]"; + }; +} + +var HD_HARDENED = 0x80000000; + +// react sometimes adds some other parameters that should not be there +function _fwStrFix(obj, fw) { + if (typeof fw === "string") { + obj.requiredFirmware = fw; + } + return obj; +} + +("use strict"); + +var chrome = window.chrome; +var IS_CHROME_APP = chrome && chrome.app && chrome.app.window; + +var ERR_TIMED_OUT = "Loading timed out"; +var ERR_WINDOW_CLOSED = "Window closed"; +var ERR_WINDOW_BLOCKED = "Window blocked"; +var ERR_ALREADY_WAITING = "Already waiting for a response"; +var ERR_CHROME_NOT_CONNECTED = "Internal Chrome popup is not responding."; + +var DISABLE_LOGIN_BUTTONS = window.TREZOR_DISABLE_LOGIN_BUTTONS || false; +var CHROME_URL = window.TREZOR_CHROME_URL || "./chrome/wrapper.html"; +var POPUP_ORIGIN = window.TREZOR_POPUP_ORIGIN || "https://connect.trezor.io"; +var POPUP_PATH = + window.TREZOR_POPUP_PATH || POPUP_ORIGIN + "/" + TREZOR_CONNECT_VERSION; +var POPUP_URL = + window.TREZOR_POPUP_URL || + POPUP_PATH + "/popup/popup.html?v=" + new Date().getTime(); + +var POPUP_INIT_TIMEOUT = 15000; + +/** + * Public API. + */ +function TrezorConnect() { + var manager = new PopupManager(); + + /** + * Popup errors. + */ + this.ERR_TIMED_OUT = ERR_TIMED_OUT; + this.ERR_WINDOW_CLOSED = ERR_WINDOW_CLOSED; + this.ERR_WINDOW_BLOCKED = ERR_WINDOW_BLOCKED; + this.ERR_ALREADY_WAITING = ERR_ALREADY_WAITING; + this.ERR_CHROME_NOT_CONNECTED = ERR_CHROME_NOT_CONNECTED; + + /** + * Open the popup for further communication. All API functions open the + * popup automatically, but if you need to generate some parameters + * asynchronously, use `open` first to avoid popup blockers. + * @param {function(?Error)} callback + */ + this.open = function(callback) { + var onchannel = function(result) { + if (result instanceof Error) { + callback(result); + } else { + callback(); + } + }; + manager.waitForChannel(onchannel); + }; + + /** + * Close the opened popup, if any. + */ + this.close = function() { + manager.close(); + }; + + /** + * Enable or disable closing the opened popup after a successful call. + * @param {boolean} value + */ + this.closeAfterSuccess = function(value) { + manager.closeAfterSuccess = value; + }; + + /** + * Enable or disable closing the opened popup after a failed call. + * @param {boolean} value + */ + this.closeAfterFailure = function(value) { + manager.closeAfterFailure = value; + }; + + /** + * Set bitcore server + * @param {string|Array<string>} value + */ + this.setBitcoreURLS = function(value) { + if (typeof value === "string") { + manager.bitcoreURLS = [value]; + } else if (value instanceof Array) { + manager.bitcoreURLS = value; + } + }; + + /** + * Set currency. Human readable coin name + * @param {string|Array<string>} value + */ + this.setCurrency = function(value) { + if (typeof value === "string") { + manager.currency = value; + } + }; + + /** + * Set currency units (mBTC, BTC) + * @param {string|Array<string>} value + */ + this.setCurrencyUnits = function(value) { + if (typeof value === "string") { + manager.currencyUnits = value; + } + }; + + /** + * Set coin info json url + * @param {string|Array<string>} value + */ + this.setCoinInfoURL = function(value) { + if (typeof value === "string") { + manager.coinInfoURL = value; + } + }; + + /** + * Set max. limit for account discovery + * @param {number} value + */ + this.setAccountDiscoveryLimit = function(value) { + if (!isNaN(value)) manager.accountDiscoveryLimit = value; + }; + + /** + * Set max. gap for account discovery + * @param {number} value + */ + this.setAccountDiscoveryGapLength = function(value) { + if (!isNaN(value)) manager.accountDiscoveryGapLength = value; + }; + + /** + * Set discovery BIP44 coin type + * @param {number} value + */ + this.setAccountDiscoveryBip44CoinType = function(value) { + if (!isNaN(value)) manager.accountDiscoveryBip44CoinType = value; + }; + + /** + * @typedef XPubKeyResult + * @param {boolean} success + * @param {?string} error + * @param {?string} xpubkey serialized extended public key + * @param {?string} path BIP32 serializd path of the key + */ + + /** + * Load BIP32 extended public key by path. + * + * Path can be specified either in the string form ("m/44'/1/0") or as + * raw integer array. In case you omit the path, user is asked to select + * a BIP32 account to export, and the result contains m/44'/0'/x' node + * of the account. + * + * @param {?(string|array<number>)} path + * @param {function(XPubKeyResult)} callback + * @param {?(string|array<number>)} requiredFirmware + */ + this.getXPubKey = function(path, callback, requiredFirmware) { + if (typeof path === "string") { + path = parseHDPath(path); + } + manager.sendWithChannel( + _fwStrFix( + { + type: "xpubkey", + path: path + }, + requiredFirmware + ), + callback + ); + }; + + this.getFreshAddress = function(callback, requiredFirmware) { + var wrapperCallback = function(result) { + if (result.success) { + callback({ success: true, address: result.freshAddress }); + } else { + callback(result); + } + }; + + manager.sendWithChannel( + _fwStrFix( + { + type: "accountinfo" + }, + requiredFirmware + ), + wrapperCallback + ); + }; + + this.getAccountInfo = function(input, callback, requiredFirmware) { + try { + manager.sendWithChannel( + _fwStrFix( + { + type: "accountinfo", + description: input + }, + requiredFirmware + ), + callback + ); + } catch (e) { + callback({ success: false, error: e }); + } + }; + + this.getAllAccountsInfo = function(callback, requiredFirmware) { + try { + manager.sendWithChannel( + _fwStrFix( + { + type: "allaccountsinfo", + description: "all" + }, + requiredFirmware + ), + callback + ); + } catch (e) { + callback({ success: false, error: e }); + } + }; + + this.getBalance = function(callback, requiredFirmware) { + manager.sendWithChannel( + _fwStrFix( + { + type: "accountinfo" + }, + requiredFirmware + ), + callback + ); + }; + + /** + * @typedef SignTxResult + * @param {boolean} success + * @param {?string} error + * @param {?string} serialized_tx serialized tx, in hex, including signatures + * @param {?array<string>} signatures array of input signatures, in hex + */ + + /** + * Sign a transaction in the device and return both serialized + * transaction and the signatures. + * + * @param {array<TxInputType>} inputs + * @param {array<TxOutputType>} outputs + * @param {function(SignTxResult)} callback + * @param {?(string|array<number>)} requiredFirmware + * + * @see https://github.com/trezor/trezor-common/blob/master/protob/types.proto + */ + this.signTx = function(inputs, outputs, callback, requiredFirmware, coin) { + manager.sendWithChannel( + _fwStrFix( + { + type: "signtx", + inputs: inputs, + outputs: outputs, + coin: coin + }, + requiredFirmware + ), + callback + ); + }; + + // new implementation with ethereum at beginnig + this.ethereumSignTx = function() { + this.signEthereumTx.apply(this, arguments); + }; + + // old fallback + this.signEthereumTx = function( + address_n, + nonce, + gas_price, + gas_limit, + to, + value, + data, + chain_id, + callback, + requiredFirmware + ) { + if (requiredFirmware == null) { + requiredFirmware = "1.4.0"; // first firmware that supports ethereum + } + if (typeof address_n === "string") { + address_n = parseHDPath(address_n); + } + manager.sendWithChannel( + _fwStrFix( + { + type: "signethtx", + address_n: address_n, + nonce: nonce, + gas_price: gas_price, + gas_limit: gas_limit, + to: to, + value: value, + data: data, + chain_id: chain_id + }, + requiredFirmware + ), + callback + ); + }; + + /** + * @typedef TxRecipient + * @param {number} amount the amount to send, in satoshis + * @param {string} address the address of the recipient + */ + + /** + * Compose a transaction by doing BIP-0044 discovery, letting the user + * select an account, and picking UTXO by internal preferences. + * Transaction is then signed and returned in the same format as + * `signTx`. Only supports BIP-0044 accounts (single-signature). + * + * @param {array<TxRecipient>} recipients + * @param {function(SignTxResult)} callback + * @param {?(string|array<number>)} requiredFirmware + */ + this.composeAndSignTx = function(recipients, callback, requiredFirmware) { + manager.sendWithChannel( + _fwStrFix( + { + type: "composetx", + recipients: recipients + }, + requiredFirmware + ), + callback + ); + }; + + /** + * @typedef RequestLoginResult + * @param {boolean} success + * @param {?string} error + * @param {?string} public_key public key used for signing, in hex + * @param {?string} signature signature, in hex + */ + + /** + * Sign a login challenge for active origin. + * + * @param {?string} hosticon + * @param {string} challenge_hidden + * @param {string} challenge_visual + * @param {string|function(RequestLoginResult)} callback + * @param {?(string|array<number>)} requiredFirmware + * + * @see https://github.com/trezor/trezor-common/blob/master/protob/messages.proto + */ + this.requestLogin = function( + hosticon, + challenge_hidden, + challenge_visual, + callback, + requiredFirmware + ) { + if (typeof callback === "string") { + // special case for a login through <trezor:login> button. + // `callback` is name of global var + callback = window[callback]; + } + if (!callback) { + throw new TypeError("TrezorConnect: login callback not found"); + } + manager.sendWithChannel( + _fwStrFix( + { + type: "login", + icon: hosticon, + challenge_hidden: challenge_hidden, + challenge_visual: challenge_visual + }, + requiredFirmware + ), + callback + ); + }; + + /** + * @typedef SignMessageResult + * @param {boolean} success + * @param {?string} error + * @param {?string} address address (in base58check) + * @param {?string} signature signature, in base64 + */ + + /** + * Sign a message + * + * @param {string|array} path + * @param {string} message to sign (ascii) + * @param {string|function(SignMessageResult)} callback + * @param {?string} opt_coin - (optional) name of coin (default Bitcoin) + * @param {?(string|array<number>)} requiredFirmware + * + */ + this.signMessage = function( + path, + message, + callback, + opt_coin, + requiredFirmware + ) { + if (typeof path === "string") { + path = parseHDPath(path); + } + if (!opt_coin) { + opt_coin = "Bitcoin"; + } + if (!callback) { + throw new TypeError("TrezorConnect: callback not found"); + } + manager.sendWithChannel( + _fwStrFix( + { + type: "signmsg", + path: path, + message: message, + coin: opt_coin + }, + requiredFirmware + ), + callback + ); + }; + + /** + * Sign an Ethereum message + * + * @param {string|array} path + * @param {string} message to sign (ascii) + * @param {string|function(SignMessageResult)} callback + * @param {?(string|array<number>)} requiredFirmware + * + */ + this.ethereumSignMessage = function( + path, + message, + callback, + requiredFirmware + ) { + if (typeof path === "string") { + path = parseHDPath(path); + } + if (!callback) { + throw new TypeError("TrezorConnect: callback not found"); + } + manager.sendWithChannel( + _fwStrFix( + { + type: "signethmsg", + path: path, + message: message + }, + requiredFirmware + ), + callback + ); + }; + + /** + * Verify message + * + * @param {string} address + * @param {string} signature (base64) + * @param {string} message (string) + * @param {string|function()} callback + * @param {?string} opt_coin - (optional) name of coin (default Bitcoin) + * @param {?(string|array<number>)} requiredFirmware + * + */ + this.verifyMessage = function( + address, + signature, + message, + callback, + opt_coin, + requiredFirmware + ) { + if (!opt_coin) { + opt_coin = "Bitcoin"; + } + if (!callback) { + throw new TypeError("TrezorConnect: callback not found"); + } + manager.sendWithChannel( + _fwStrFix( + { + type: "verifymsg", + address: address, + signature: signature, + message: message, + coin: { coin_name: opt_coin } + }, + requiredFirmware + ), + callback + ); + }; + + /** + * Verify ethereum message + * + * @param {string} address + * @param {string} signature (base64) + * @param {string} message (string) + * @param {string|function()} callback + * @param {?(string|array<number>)} requiredFirmware + * + */ + this.ethereumVerifyMessage = function( + address, + signature, + message, + callback, + requiredFirmware + ) { + if (!callback) { + throw new TypeError("TrezorConnect: callback not found"); + } + manager.sendWithChannel( + _fwStrFix( + { + type: "verifyethmsg", + address: address, + signature: signature, + message: message + }, + requiredFirmware + ), + callback + ); + }; + + /** + * Symmetric key-value encryption + * + * @param {string|array} path + * @param {string} key to show on device display + * @param {string} value hexadecimal value, length a multiple of 16 bytes + * @param {boolean} encrypt / decrypt direction + * @param {boolean} ask_on_encrypt (should user confirm on encrypt?) + * @param {boolean} ask_on_decrypt (should user confirm on decrypt?) + * @param {string|function()} callback + * @param {?(string|array<number>)} requiredFirmware + * + */ + this.cipherKeyValue = function( + path, + key, + value, + encrypt, + ask_on_encrypt, + ask_on_decrypt, + callback, + requiredFirmware + ) { + if (typeof path === "string") { + path = parseHDPath(path); + } + if (typeof value !== "string") { + throw new TypeError("TrezorConnect: Value must be a string"); + } + if (!/^[0-9A-Fa-f]*$/.test(value)) { + throw new TypeError("TrezorConnect: Value must be hexadecimal"); + } + if (value.length % 32 !== 0) { + // 1 byte == 2 hex strings + throw new TypeError( + "TrezorConnect: Value length must be multiple of 16 bytes" + ); + } + if (!callback) { + throw new TypeError("TrezorConnect: callback not found"); + } + manager.sendWithChannel( + _fwStrFix( + { + type: "cipherkeyvalue", + path: path, + key: key, + value: value, + encrypt: !!encrypt, + ask_on_encrypt: !!ask_on_encrypt, + ask_on_decrypt: !!ask_on_decrypt + }, + requiredFirmware + ), + callback + ); + }; + + this.nemGetAddress = function( + address_n, + network, + callback, + requiredFirmware + ) { + if (requiredFirmware == null) { + requiredFirmware = "1.6.0"; // first firmware that supports NEM + } + if (typeof address_n === "string") { + address_n = parseHDPath(address_n); + } + manager.sendWithChannel( + _fwStrFix( + { + type: "nemGetAddress", + address_n: address_n, + network: network + }, + requiredFirmware + ), + callback + ); + }; + + this.nemSignTx = function( + address_n, + transaction, + callback, + requiredFirmware + ) { + if (requiredFirmware == null) { + requiredFirmware = "1.6.0"; // first firmware that supports NEM + } + if (typeof address_n === "string") { + address_n = parseHDPath(address_n); + } + manager.sendWithChannel( + _fwStrFix( + { + type: "nemSignTx", + address_n: address_n, + transaction: transaction + }, + requiredFirmware + ), + callback + ); + }; + + this.pushTransaction = function(rawTx, callback) { + if (!/^[0-9A-Fa-f]*$/.test(rawTx)) { + throw new TypeError("TrezorConnect: Transaction must be hexadecimal"); + } + if (!callback) { + throw new TypeError("TrezorConnect: callback not found"); + } + + manager.sendWithChannel( + { + type: "pushtx", + rawTx: rawTx + }, + callback + ); + }; + + /** + * Display address on device + * + * @param {array} address + * @param {string} coin + * @param {boolean} segwit + * @param {?(string|array<number>)} requiredFirmware + * + */ + this.getAddress = function( + address, + coin, + segwit, + callback, + requiredFirmware + ) { + if (typeof address === "string") { + address = parseHDPath(address); + } + + manager.sendWithChannel( + _fwStrFix( + { + type: "getaddress", + address_n: address, + coin: coin, + segwit: segwit + }, + requiredFirmware + ), + callback + ); + }; + + /** + * Display ethereum address on device + * + * @param {array} address + * @param {?(string|array<number>)} requiredFirmware + * + */ + this.ethereumGetAddress = function(address, callback, requiredFirmware) { + if (typeof address === "string") { + address = parseHDPath(address); + } + + manager.sendWithChannel( + _fwStrFix( + { + type: "ethgetaddress", + address_n: address + }, + requiredFirmware + ), + callback + ); + }; + + var LOGIN_CSS = + '<style>@import url("@connect_path@/login_buttons.css")</style>'; + + var LOGIN_ONCLICK = + "TrezorConnect.requestLogin(" + + "'@hosticon@','@challenge_hidden@','@challenge_visual@','@callback@'" + + ")"; + + var LOGIN_HTML = + '<div id="trezorconnect-wrapper">' + + ' <a id="trezorconnect-button" onclick="' + + LOGIN_ONCLICK + + '">' + + ' <span id="trezorconnect-icon"></span>' + + ' <span id="trezorconnect-text">@text@</span>' + + " </a>" + + ' <span id="trezorconnect-info">' + + ' <a id="trezorconnect-infolink" href="https://www.buytrezor.com/"' + + ' target="_blank">What is TREZOR?</a>' + + " </span>" + + "</div>"; + + /** + * Find <trezor:login> elements and replace them with login buttons. + * It's not required to use these special elements, feel free to call + * `TrezorConnect.requestLogin` directly. + */ + this.renderLoginButtons = function() { + var elements = document.getElementsByTagName("trezor:login"); + + for (var i = 0; i < elements.length; i++) { + var e = elements[i]; + var text = e.getAttribute("text") || "Sign in with TREZOR"; + var callback = e.getAttribute("callback") || ""; + var hosticon = e.getAttribute("icon") || ""; + var challenge_hidden = e.getAttribute("challenge_hidden") || ""; + var challenge_visual = e.getAttribute("challenge_visual") || ""; + + // it's not valid to put markup into attributes, so let users + // supply a raw text and make TREZOR bold + text = text.replace("TREZOR", "<strong>TREZOR</strong>"); + e.outerHTML = (LOGIN_CSS + LOGIN_HTML) + .replace("@text@", text) + .replace("@callback@", callback) + .replace("@hosticon@", hosticon) + .replace("@challenge_hidden@", challenge_hidden) + .replace("@challenge_visual@", challenge_visual) + .replace("@connect_path@", POPUP_PATH); + } + }; +} + +/* + * `getXPubKey()` + */ + +function parseHDPath(string) { + return string + .toLowerCase() + .split("/") + .filter(function(p) { + return p !== "m"; + }) + .map(function(p) { + var hardened = false; + if (p[p.length - 1] === "'") { + hardened = true; + p = p.substr(0, p.length - 1); + } + if (isNaN(p)) { + throw new Error("Not a valid path."); + } + var n = parseInt(p); + if (hardened) { + // hardened index + n = (n | 0x80000000) >>> 0; + } + return n; + }); +} + +/* + * Popup management + */ + +function ChromePopup(url, name, width, height) { + var left = (screen.width - width) / 2; + var top = (screen.height - height) / 2; + var opts = { + id: name, + innerBounds: { + width: width, + height: height, + left: left, + top: top + } + }; + + var closed = function() { + if (this.onclose) { + this.onclose(false); // never report as blocked + } + }.bind(this); + + var opened = function(w) { + this.window = w; + this.window.onClosed.addListener(closed); + }.bind(this); + + chrome.app.window.create(url, opts, opened); + + this.name = name; + this.window = null; + this.onclose = null; +} + +function ChromeChannel(popup, waiting) { + var port = null; + + var respond = function(data) { + if (waiting) { + var w = waiting; + waiting = null; + w(data); + } + }; + + var setup = function(p) { + if (p.name === popup.name) { + port = p; + port.onMessage.addListener(respond); + chrome.runtime.onConnect.removeListener(setup); + } + }; + + chrome.runtime.onConnect.addListener(setup); + + this.respond = respond; + + this.close = function() { + chrome.runtime.onConnect.removeListener(setup); + port.onMessage.removeListener(respond); + port.disconnect(); + port = null; + }; + + this.send = function(value, callback) { + if (waiting === null) { + waiting = callback; + + if (port) { + port.postMessage(value); + } else { + throw new Error(ERR_CHROME_NOT_CONNECTED); + } + } else { + throw new Error(ERR_ALREADY_WAITING); + } + }; +} + +function Popup(url, origin, name, width, height) { + var left = (screen.width - width) / 2; + var top = (screen.height - height) / 2; + var opts = + "width=" + + width + + ",height=" + + height + + ",left=" + + left + + ",top=" + + top + + ",menubar=no" + + ",toolbar=no" + + ",location=no" + + ",personalbar=no" + + ",status=no"; + var w = window.open(url, name, opts); + + var interval; + var blocked = w.closed; + var iterate = function() { + if (w.closed) { + clearInterval(interval); + if (this.onclose) { + this.onclose(blocked); + } + } + }.bind(this); + interval = setInterval(iterate, 100); + + this.window = w; + this.origin = origin; + this.onclose = null; +} + +function Channel(popup, waiting) { + var respond = function(data) { + if (waiting) { + var w = waiting; + waiting = null; + w(data); + } + }; + + var receive = function(event) { + var org1 = event.origin.match(/^.+\:\/\/[^\/]+/)[0]; + var org2 = popup.origin.match(/^.+\:\/\/[^\/]+/)[0]; + //if (event.source === popup.window && event.origin === popup.origin) { + if (event.source === popup.window && org1 === org2) { + respond(event.data); + } + }; + + window.addEventListener("message", receive); + + this.respond = respond; + + this.close = function() { + window.removeEventListener("message", receive); + }; + + this.send = function(value, callback) { + if (waiting === null) { + waiting = callback; + popup.window.postMessage(value, popup.origin); + } else { + throw new Error(ERR_ALREADY_WAITING); + } + }; +} + +function ConnectedChannel(p) { + var ready = function() { + clearTimeout(this.timeout); + this.popup.onclose = null; + this.ready = true; + this.onready(); + }.bind(this); + + var closed = function(blocked) { + clearTimeout(this.timeout); + this.channel.close(); + if (blocked) { + this.onerror(new Error(ERR_WINDOW_BLOCKED)); + } else { + this.onerror(new Error(ERR_WINDOW_CLOSED)); + } + }.bind(this); + + var timedout = function() { + this.popup.onclose = null; + if (this.popup.window) { + this.popup.window.close(); + } + this.channel.close(); + this.onerror(new Error(ERR_TIMED_OUT)); + }.bind(this); + + if (IS_CHROME_APP) { + this.popup = new ChromePopup(p.chromeUrl, p.name, p.width, p.height); + this.channel = new ChromeChannel(this.popup, ready); + } else { + this.popup = new Popup(p.url, p.origin, p.name, p.width, p.height); + this.channel = new Channel(this.popup, ready); + } + + this.timeout = setTimeout(timedout, POPUP_INIT_TIMEOUT); + + this.popup.onclose = closed; + + this.ready = false; + this.onready = null; + this.onerror = null; +} + +function PopupManager() { + var cc = null; + + var closed = function() { + cc.channel.respond(new Error(ERR_WINDOW_CLOSED)); + cc.channel.close(); + cc = null; + }; + + var open = function(callback) { + cc = new ConnectedChannel({ + name: "trezor-connect", + width: 600, + height: 500, + origin: POPUP_ORIGIN, + path: POPUP_PATH, + url: POPUP_URL, + chromeUrl: CHROME_URL + }); + cc.onready = function() { + cc.popup.onclose = closed; + callback(cc.channel); + }; + cc.onerror = function(error) { + cc = null; + callback(error); + }; + }.bind(this); + + this.closeAfterSuccess = true; + this.closeAfterFailure = true; + + this.close = function() { + if (cc && cc.popup.window) { + cc.popup.window.close(); + } + }; + + this.waitForChannel = function(callback) { + if (cc) { + if (cc.ready) { + callback(cc.channel); + } else { + callback(new Error(ERR_ALREADY_WAITING)); + } + } else { + try { + open(callback); + } catch (e) { + callback(new Error(ERR_WINDOW_BLOCKED)); + } + } + }; + + this.sendWithChannel = function(message, callback) { + message.bitcoreURLS = this.bitcoreURLS || null; + message.currency = this.currency || null; + message.currencyUnits = this.currencyUnits || null; + message.coinInfoURL = this.coinInfoURL || null; + message.accountDiscoveryLimit = this.accountDiscoveryLimit || null; + message.accountDiscoveryGapLength = this.accountDiscoveryGapLength || null; + message.accountDiscoveryBip44CoinType = + this.accountDiscoveryBip44CoinType || null; + + var respond = function(response) { + var succ = response.success && this.closeAfterSuccess; + var fail = !response.success && this.closeAfterFailure; + if (succ || fail) { + this.close(); + } + callback(response); + }.bind(this); + + var onresponse = function(response) { + if (response instanceof Error) { + var error = response; + respond({ success: false, error: error.message }); + } else { + respond(response); + } + }; + + var onchannel = function(channel) { + if (channel instanceof Error) { + var error = channel; + respond({ success: false, error: error.message }); + } else { + channel.send(message, onresponse); + } + }; + + this.waitForChannel(onchannel); + }; +} + +const connect = new TrezorConnect(); +module.exports = connect;
\ No newline at end of file diff --git a/app/scripts/lib/trezorKeyring.js b/app/scripts/lib/trezorKeyring.js new file mode 100644 index 000000000..d99f384f2 --- /dev/null +++ b/app/scripts/lib/trezorKeyring.js @@ -0,0 +1,255 @@ +const { EventEmitter } = require('events') +const ethUtil = require('ethereumjs-util') +// const sigUtil = require('eth-sig-util') +//const { Lock } = require('semaphore-async-await') + +const hdPathString = `m/44'/60'/0'/0` +const keyringType = 'Trezor Hardware Keyring' + +const TrezorConnect = require('./trezor-connect.js') +const HDKey = require('hdkey') +const TREZOR_FIRMWARE_VERSION = '1.4.0' +const log = require('loglevel') + +class TrezorKeyring extends EventEmitter { + constructor (opts = {}) { + super() + this.type = keyringType + //this.lock = new Lock() + this.accounts = [] + this.hdk = new HDKey() + this.deserialize(opts) + this.page = 0 + this.perPage = 5 + } + + serialize () { + return Promise.resolve({ hdPath: this.hdPath, accounts: this.accounts }) + } + + deserialize (opts = {}) { + this.hdPath = opts.hdPath || hdPathString + this.accounts = opts.accounts || [] + return Promise.resolve() + } + + unlock () { + if (this.hdk.publicKey) return Promise.resolve() + + return new Promise((resolve, reject) => { + TrezorConnect.getXPubKey( + this.hdPath, + response => { + log.debug('TREZOR CONNECT RESPONSE: ') + log.debug(response) + if (response.success) { + this.hdk.publicKey = new Buffer(response.publicKey, 'hex') + this.hdk.chainCode = new Buffer(response.chainCode, 'hex') + resolve() + } else { + reject(response.error || 'Unknown error') + } + }, + TREZOR_FIRMWARE_VERSION + ) + }) + } + + addAccounts (n = 1) { + return new Promise((resolve, reject) => { + return this.unlock() + .then(_ => { + const pathBase = 'm' + const from = n + const to = n + 1 + + this.accounts = [] + + for (let i = from; i < to; i++) { + const dkey = this.hdk.derive(`${pathBase}/${i}`) + const address = ethUtil + .publicToAddress(dkey.publicKey, true) + .toString('hex') + this.accounts.push(ethUtil.toChecksumAddress(address)) + this.page = 0 + } + resolve(this.accounts) + }) + .catch(e => { + reject(e) + }) + }) + } + + async getPage () { + return new Promise((resolve, reject) => { + return this.unlock() + .then(_ => { + const pathBase = 'm' + const from = this.page === 0 ? 0 : (this.page - 1) * this.perPage + const to = from + this.perPage + + const accounts = [] + + for (let i = from; i < to; i++) { + const dkey = this.hdk.derive(`${pathBase}/${i}`) + const address = ethUtil + .publicToAddress(dkey.publicKey, true) + .toString('hex') + accounts.push({ + address: ethUtil.toChecksumAddress(address), + balance: 0, + index: i, + }) + } + resolve(accounts) + }) + .catch(e => { + reject(e) + }) + }) + } + + async getPrevAccountSet () { + this.page-- + return await this.getPage() + } + + async getNextAccountSet () { + this.page++ + return await this.getPage() + } + + getAccounts () { + return Promise.resolve(this.accounts.slice()) + } + + // tx is an instance of the ethereumjs-transaction class. + async signTransaction (address, tx) { + throw new Error('Not supported on this device') + /* + await this.lock.acquire() + try { + + // Look before we leap + await this._checkCorrectTrezorAttached() + + let accountId = await this._findAddressId(address) + let eth = await this._getEth() + tx.v = tx._chainId + let TrezorSig = await eth.signTransaction( + this._derivePath(accountId), + tx.serialize().toString('hex') + ) + tx.v = parseInt(TrezorSig.v, 16) + tx.r = '0x' + TrezorSig.r + tx.s = '0x' + TrezorSig.s + + // Since look before we leap check is racy, also check that signature is for account expected + let addressSignedWith = ethUtil.bufferToHex(tx.getSenderAddress()) + if (addressSignedWith.toLowerCase() !== address.toLowerCase()) { + throw new Error( + `Signature is for ${addressSignedWith} but expected ${address} - is the correct Trezor device attached?` + ) + } + + return tx + + } finally { + await this.lock.release() + }*/ + } + + async signMessage (withAccount, data) { + throw new Error('Not supported on this device') + } + + // For personal_sign, we need to prefix the message: + async signPersonalMessage (withAccount, message) { + throw new Error('Not supported on this device') + /* + await this.lock.acquire() + try { + // Look before we leap + await this._checkCorrectTrezorAttached() + + let accountId = await this._findAddressId(withAccount) + let eth = await this._getEth() + let msgHex = ethUtil.stripHexPrefix(message) + let TrezorSig = await eth.signPersonalMessage( + this._derivePath(accountId), + msgHex + ) + let signature = this._personalToRawSig(TrezorSig) + + // Since look before we leap check is racy, also check that signature is for account expected + let addressSignedWith = sigUtil.recoverPersonalSignature({ + data: message, + sig: signature, + }) + if (addressSignedWith.toLowerCase() !== withAccount.toLowerCase()) { + throw new Error( + `Signature is for ${addressSignedWith} but expected ${withAccount} - is the correct Trezor device attached?` + ) + } + + return signature + + } finally { + await this.lock.release() + } */ + } + + async signTypedData (withAccount, typedData) { + throw new Error('Not supported on this device') + } + + async exportAccount (address) { + throw new Error('Not supported on this device') + } + + async _findAddressId (addr) { + const result = this.accounts.indexOf(addr) + if (result === -1) throw new Error('Unknown address') + else return result + } + + async _addressFromId (i) { + /* Must be called with lock acquired + const eth = await this._getEth() + return (await eth.getAddress(this._derivePath(i))).address*/ + const result = this.accounts[i] + if (!result) throw new Error('Unknown address') + else return result + } + + async _checkCorrectTrezorAttached () { + return true + /* Must be called with lock acquired + if (this.accounts.length > 0) { + const expectedFirstAccount = this.accounts[0] + let actualFirstAccount = await this._addressFromId(0) + if (expectedFirstAccount !== actualFirstAccount) { + throw new Error( + `Incorrect Trezor device attached - expected device containg account ${expectedFirstAccount}, but found ${actualFirstAccount}` + ) + } + }*/ + } + + _derivePath (i) { + return this.hdPath + '/' + i + } + + _personalToRawSig (TrezorSig) { + var v = TrezorSig['v'] - 27 + v = v.toString(16) + if (v.length < 2) { + v = '0' + v + } + return '0x' + TrezorSig['r'] + TrezorSig['s'] + v + } +} + +TrezorKeyring.type = keyringType +module.exports = TrezorKeyring
\ No newline at end of file diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index a362e3826..dd5a5616f 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -48,6 +48,7 @@ const seedPhraseVerifier = require('./lib/seed-phrase-verifier') const cleanErrorStack = require('./lib/cleanErrorStack') const DiagnosticsReporter = require('./lib/diagnostics-reporter') const log = require('loglevel') +const TrezorKeyring = require("./lib/trezorKeyring"); module.exports = class MetamaskController extends EventEmitter { @@ -130,9 +131,11 @@ module.exports = class MetamaskController extends EventEmitter { provider: this.provider, blockTracker: this.blockTracker, }) - + // key mgmt + const additionalKeyrings = [TrezorKeyring] this.keyringController = new KeyringController({ + keyringTypes: additionalKeyrings, initState: initState.KeyringController, getNetwork: this.networkController.getNetworkState.bind(this.networkController), encryptor: opts.encryptor || undefined, @@ -363,6 +366,10 @@ module.exports = class MetamaskController extends EventEmitter { resetAccount: nodeify(this.resetAccount, this), importAccountWithStrategy: nodeify(this.importAccountWithStrategy, this), + // trezor + connectHardware: nodeify(this.connectHardware, this), + unlockTrezorAccount: nodeify(this.unlockTrezorAccount, this), + // vault management submitPassword: nodeify(this.submitPassword, this), @@ -524,6 +531,60 @@ module.exports = class MetamaskController extends EventEmitter { } // + // Hardware + // + + /** + * Fetch account list from a trezor device. + * + * @returns [] accounts + */ + async connectHardware (deviceName, page) { + const keyringController = this.keyringController + const keyring = await keyringController.getKeyringsByType( + 'Trezor Hardware Keyring' + )[0] + if (!keyring) { + throw new Error('MetamaskController - No Trezor Hardware Keyring found') + } + + const accounts = page === -1 ? await keyring.getPrevAccountSet() : await keyring.getNextAccountSet() + + return accounts + + } + + /** + * Imports an account from a trezor device. + * + * @returns {} keyState + */ + async unlockTrezorAccount (index) { + const keyringController = this.keyringController + const keyring = await keyringController.getKeyringsByType( + 'Trezor Hardware Keyring' + )[0] + if (!keyring) { + throw new Error('MetamaskController - No Trezor Hardware Keyring found') + } + + const oldAccounts = await keyringController.getAccounts() + const keyState = await keyringController.addNewAccount(keyring) + const newAccounts = await keyringController.getAccounts() + + this.preferencesController.setAddresses(newAccounts) + newAccounts.forEach(address => { + if (!oldAccounts.includes(address)) { + this.preferencesController.setSelectedAddress(address) + } + }) + + const { identities } = this.preferencesController.store.getState() + return { ...keyState, identities } + } + + + // // Account Management // |