From da7e48f3a7e00689e71628f9a39242e10c7b45d4 Mon Sep 17 00:00:00 2001 From: neocogent Date: Wed, 21 Dec 2016 12:52:54 +0700 Subject: [PATCH] ledger new ui and mobile 2fa validation --- plugins/ledger/auth2fa.py | 347 ++++++++++++++++++++++++++++++++++++++ plugins/ledger/ledger.py | 86 +++------- plugins/ledger/qt.py | 44 ++++- 3 files changed, 417 insertions(+), 60 deletions(-) create mode 100644 plugins/ledger/auth2fa.py diff --git a/plugins/ledger/auth2fa.py b/plugins/ledger/auth2fa.py new file mode 100644 index 000000000..4ce52f591 --- /dev/null +++ b/plugins/ledger/auth2fa.py @@ -0,0 +1,347 @@ +import threading + +from PyQt4.Qt import (QDialog, QInputDialog, QLineEdit, QTextEdit, QVBoxLayout, QLabel, SIGNAL) +import PyQt4.QtCore as QtCore + +from electrum.i18n import _ +from electrum_gui.qt.util import * +from electrum.util import print_msg + +import os, hashlib, websocket, threading, logging, json, copy +from electrum_gui.qt.qrcodewidget import QRCodeWidget, QRDialog +from btchip.btchip import * + +DEBUG = False + +helpTxt = [_("Your Ledger Wallet wants tell you a one-time PIN code.

" \ + "For best security you should unplug your device, open a text editor on another computer, " \ + "put your cursor into it, and plug your device into that computer. " \ + "It will output a summary of the transaction being signed and a one-time PIN.

" \ + "Verify the transaction summary and type the PIN code here.

" \ + "Before pressing enter, plug the device back into this computer.
" ), + _("Verify the address below.
Type the character from your security card corresponding to the BOLD character."), + _("Waiting for authentication on your mobile phone"), + _("Transaction accepted by mobile phone. Waiting for confirmation."), + _("Click Pair button to begin pairing a mobile phone."), + _("Scan this QR code with your LedgerWallet phone app to pair it with this Ledger device.
" + "To complete pairing you will need your security card to answer a challenge." ) + ] + +class LedgerAuthDialog(QDialog): + def __init__(self, handler, data): + '''Ask user for 2nd factor authentication. Support text, security card and paired mobile methods. + Use last method from settings, but support new pairing and downgrade. + ''' + QDialog.__init__(self, handler.top_level_window()) + self.handler = handler + self.txdata = data + self.idxs = self.txdata['keycardData'] if self.txdata['confirmationType'] > 1 else '' + self.setMinimumWidth(600) + self.setWindowTitle(_("Ledger Wallet Authentication")) + self.cfg = copy.deepcopy(self.handler.win.wallet.get_keystore().cfg) + self.dongle = self.handler.win.wallet.get_keystore().get_client().dongle + self.ws = None + self.pin = '' + + self.devmode = self.getDevice2FAMode() + if self.devmode == 0x11 or self.txdata['confirmationType'] == 1: + self.cfg['mode'] = 0 + + vbox = QVBoxLayout() + self.setLayout(vbox) + + def on_change_mode(idx): + if idx < 2 and self.ws: + self.ws.stop() + self.ws = None + self.cfg['mode'] = 0 if self.devmode == 0x11 else idx if idx > 0 else 1 + if self.cfg['mode'] > 1 and self.cfg['pair'] and not self.ws: + self.req_validation() + if self.cfg['mode'] > 0: + self.handler.win.wallet.get_keystore().cfg = self.cfg + self.handler.win.wallet.save_keystore() + self.update_dlg() + def add_pairing(): + self.do_pairing() + def return_pin(): + self.pin = self.pintxt.text() if self.txdata['confirmationType'] == 1 else self.cardtxt.text() + if self.cfg['mode'] == 1: + self.pin = ''.join(chr(int(str(i),16)) for i in self.pin) + self.accept() + + self.modebox = QWidget() + modelayout = QHBoxLayout() + self.modebox.setLayout(modelayout) + modelayout.addWidget(QLabel(_("Method:"))) + self.modes = QComboBox() + modelayout.addWidget(self.modes, 2) + self.addPair = QPushButton(_("Pair")) + self.addPair.setMaximumWidth(60) + modelayout.addWidget(self.addPair) + modelayout.addStretch(1) + self.modebox.setMaximumHeight(50) + vbox.addWidget(self.modebox) + + self.populate_modes() + self.modes.currentIndexChanged.connect(on_change_mode) + self.addPair.clicked.connect(add_pairing) + + self.helpmsg = QTextEdit() + self.helpmsg.setStyleSheet("QTextEdit { background-color: lightgray; }") + self.helpmsg.setReadOnly(True) + vbox.addWidget(self.helpmsg) + + self.pinbox = QWidget() + pinlayout = QHBoxLayout() + self.pinbox.setLayout(pinlayout) + self.pintxt = QLineEdit() + self.pintxt.setEchoMode(2) + self.pintxt.setMaxLength(4) + self.pintxt.returnPressed.connect(return_pin) + pinlayout.addWidget(QLabel(_("Enter PIN:"))) + pinlayout.addWidget(self.pintxt) + pinlayout.addWidget(QLabel(_("NOT DEVICE PIN - see above"))) + pinlayout.addStretch(1) + self.pinbox.setVisible(self.cfg['mode'] == 0) + vbox.addWidget(self.pinbox) + + self.cardbox = QWidget() + card = QVBoxLayout() + self.cardbox.setLayout(card) + self.addrtext = QTextEdit() + self.addrtext.setStyleSheet("QTextEdit { color:blue; background-color:lightgray; padding:15px 10px; border:none; font-size:20pt; }") + self.addrtext.setReadOnly(True) + self.addrtext.setMaximumHeight(120) + card.addWidget(self.addrtext) + + def pin_changed(s): + if len(s) < len(self.idxs): + i = self.idxs[len(s)] + addr = self.txdata['address'] + addr = addr[:i] + '' + addr[i:i+1] + '' + addr[i+1:] + self.addrtext.setHtml(str(addr)) + else: + self.addrtext.setHtml(_("Press Enter")) + + pin_changed('') + cardpin = QHBoxLayout() + cardpin.addWidget(QLabel(_("Enter PIN:"))) + self.cardtxt = QLineEdit() + self.cardtxt.setEchoMode(2) + self.cardtxt.setMaxLength(len(self.idxs)) + self.cardtxt.textChanged.connect(pin_changed) + self.cardtxt.returnPressed.connect(return_pin) + cardpin.addWidget(self.cardtxt) + cardpin.addWidget(QLabel(_("NOT DEVICE PIN - see above"))) + cardpin.addStretch(1) + card.addLayout(cardpin) + self.cardbox.setVisible(self.cfg['mode'] == 1) + vbox.addWidget(self.cardbox) + + self.pairbox = QWidget() + pairlayout = QVBoxLayout() + self.pairbox.setLayout(pairlayout) + pairhelp = QTextEdit(helpTxt[5]) + pairhelp.setStyleSheet("QTextEdit { background-color: lightgray; }") + pairhelp.setReadOnly(True) + pairlayout.addWidget(pairhelp, 1) + self.pairqr = QRCodeWidget() + pairlayout.addWidget(self.pairqr, 4) + self.pairbox.setVisible(False) + vbox.addWidget(self.pairbox) + self.update_dlg() + + if self.cfg['mode'] > 1 and not self.ws: + self.req_validation() + + def populate_modes(self): + self.modes.blockSignals(True) + self.modes.clear() + self.modes.addItem(_("Summary Text PIN (requires dongle replugging)") if self.txdata['confirmationType'] == 1 else _("Summary Text PIN is Disabled")) + if self.txdata['confirmationType'] > 1: + self.modes.addItem(_("Security Card Challenge")) + if not self.cfg['pair']: + self.modes.addItem(_("Mobile - Not paired")) + else: + self.modes.addItem(_("Mobile - %s") % self.cfg['pair'][1]) + self.modes.blockSignals(False) + + def update_dlg(self): + self.modes.setCurrentIndex(self.cfg['mode']) + self.modebox.setVisible(True) + self.addPair.setText(_("Pair") if not self.cfg['pair'] else _("Re-Pair")) + self.addPair.setVisible(self.txdata['confirmationType'] > 2) + self.helpmsg.setText(helpTxt[self.cfg['mode'] if self.cfg['mode'] < 2 else 2 if self.cfg['pair'] else 4]) + self.helpmsg.setMinimumHeight(180 if self.txdata['confirmationType'] == 1 else 100) + self.pairbox.setVisible(False) + self.helpmsg.setVisible(True) + self.pinbox.setVisible(self.cfg['mode'] == 0) + self.cardbox.setVisible(self.cfg['mode'] == 1) + self.pintxt.setFocus(True) if self.cfg['mode'] == 0 else self.cardtxt.setFocus(True) + self.setMaximumHeight(200) + + def do_pairing(self): + rng = os.urandom(16) + pairID = rng.encode('hex') + hashlib.sha256(rng).digest()[0].encode('hex') + self.pairqr.setData(pairID) + self.modebox.setVisible(False) + self.helpmsg.setVisible(False) + self.pinbox.setVisible(False) + self.cardbox.setVisible(False) + self.pairbox.setVisible(True) + self.pairqr.setMinimumSize(300,300) + if self.ws: + self.ws.stop() + self.ws = LedgerWebSocket(self, pairID) + self.ws.pairing_done.connect(self.pairing_done) + self.ws.start() + + def pairing_done(self, data): + if data is not None: + self.cfg['pair'] = [ data['pairid'], data['name'], data['platform'] ] + self.cfg['mode'] = 2 + self.handler.win.wallet.get_keystore().cfg = self.cfg + self.handler.win.wallet.save_keystore() + self.pin = 'paired' + self.accept() + + def req_validation(self): + if self.cfg['pair'] and 'secureScreenData' in self.txdata: + if self.ws: + self.ws.stop() + self.ws = LedgerWebSocket(self, self.cfg['pair'][0], self.txdata) + self.ws.req_updated.connect(self.req_updated) + self.ws.start() + + def req_updated(self, pin): + if pin == 'accepted': + self.helpmsg.setText(helpTxt[3]) + else: + self.pin = str(pin) + self.accept() + + def getDevice2FAMode(self): + apdu = [0xe0, 0x24, 0x01, 0x00, 0x00, 0x01] # get 2fa mode + try: + mode = self.dongle.exchange( bytearray(apdu) ) + return mode + except BTChipException, e: + debug_msg('Device getMode Failed') + return 0x11 + + def closeEvent(self, evnt): + debug_msg("CLOSE - Stop WS") + if self.ws: + self.ws.stop() + if self.pairbox.isVisible(): + evnt.ignore() + self.update_dlg() + +class LedgerWebSocket(QThread): + pairing_done = pyqtSignal(object) + req_updated = pyqtSignal(str) + + def __init__(self, dlg, pairID, txdata=None): + QThread.__init__(self) + self.stopping = False + self.pairID = pairID + self.txreq = '{"type":"request","second_factor_data":"' + str(txdata['secureScreenData']).encode('hex') + '"}' if txdata else None + self.dlg = dlg + self.dongle = self.dlg.dongle + self.data = None + + #websocket.enableTrace(True) + logging.basicConfig(level=logging.INFO) + self.ws = websocket.WebSocketApp('wss://ws.ledgerwallet.com/2fa/channels', + on_message = self.on_message, on_error = self.on_error, + on_close = self.on_close, on_open = self.on_open) + + def run(self): + while not self.stopping: + self.ws.run_forever() + def stop(self): + debug_msg("WS: Stopping") + self.stopping = True + self.ws.close() + + def on_message(self, ws, msg): + data = json.loads(msg) + if data['type'] == 'identify': + debug_msg('Identify') + apdu = [0xe0, 0x12, 0x01, 0x00, 0x41] # init pairing + apdu.extend(data['public_key'].decode('hex')) + try: + challenge = self.dongle.exchange( bytearray(apdu) ) + ws.send( '{"type":"challenge","data":"%s" }' % str(challenge).encode('hex') ) + self.data = data + except BTChipException, e: + debug_msg('Identify Failed') + + if data['type'] == 'challenge': + debug_msg('Challenge') + apdu = [0xe0, 0x12, 0x02, 0x00, 0x10] # confirm pairing + apdu.extend(data['data'].decode('hex')) + try: + self.dongle.exchange( bytearray(apdu) ) + debug_msg('Pairing Successful') + ws.send( '{"type":"pairing","is_successful":"true"}' ) + self.data['pairid'] = self.pairID + self.pairing_done.emit(self.data) + except BTChipException, e: + debug_msg('Pairing Failed') + ws.send( '{"type":"pairing","is_successful":"false"}' ) + self.pairing_done.emit(None) + ws.send( '{"type":"disconnect"}' ) + self.stopping = True + ws.close() + + if data['type'] == 'accept': + debug_msg('Accepted') + self.req_updated.emit('accepted') + if data['type'] == 'response': + debug_msg('Responded', data) + self.req_updated.emit(str(data['pin']) if data['is_accepted'] else '') + self.txreq = None + self.stopping = True + ws.close() + + if data['type'] == 'repeat': + debug_msg('Repeat') + if self.txreq: + ws.send( self.txreq ) + debug_msg("Req Sent", self.txreq) + if data['type'] == 'connect': + debug_msg('Connected') + if self.txreq: + ws.send( self.txreq ) + debug_msg("Req Sent", self.txreq) + if data['type'] == 'disconnect': + debug_msg('Disconnected') + ws.close() + + def on_error(self, ws, error): + message = getattr(error, 'strerror', '') + if not message: + message = getattr(error, 'message', '') + debug_msg("WS: %s" % message) + + def on_close(self, ws): + debug_msg("WS: ### socket closed ###") + + def on_open(self, ws): + debug_msg("WS: ### socket open ###") + debug_msg("Joining with pairing ID", self.pairID) + ws.send( '{"type":"join","room":"%s"}' % self.pairID ) + ws.send( '{"type":"repeat"}' ) + if self.txreq: + ws.send( self.txreq ) + debug_msg("Req Sent", self.txreq) + +def debug_msg(*args): + if DEBUG: + print_msg(*args) + + + + + diff --git a/plugins/ledger/ledger.py b/plugins/ledger/ledger.py index 4b8bbd6c3..01f5361aa 100644 --- a/plugins/ledger/ledger.py +++ b/plugins/ledger/ledger.py @@ -13,14 +13,12 @@ from electrum.keystore import Hardware_KeyStore, parse_xpubkey from ..hw_wallet import HW_PluginBase from electrum.util import format_satoshis_plain, print_error - try: import hid from btchip.btchipComm import HIDDongleHIDAPI, DongleWait from btchip.btchip import btchip from btchip.btchipUtils import compress_public_key,format_transaction, get_regular_input_script, get_p2sh_input_script from btchip.bitcoinTransaction import bitcoinTransaction - from btchip.btchipPersoWizard import StartBTChipPersoDialog from btchip.btchipFirmwareWizard import checkFirmware, updateFirmware from btchip.btchipException import BTChipException BTCHIP = True @@ -121,8 +119,7 @@ class Ledger_Client(): except BTChipException, e: if (e.sw == 0x6985): self.dongleObject.dongle.close() - dialog = StartBTChipPersoDialog() - dialog.exec_() + self.handler.get_setup( ) # Acquire the new client on the next run else: raise e @@ -172,6 +169,12 @@ class Ledger_KeyStore(Hardware_KeyStore): # device reconnects self.force_watching_only = False self.signing = False + self.cfg = d.get('cfg', {'mode':0,'pair':''}) + + def dump(self): + obj = Hardware_KeyStore.dump(self) + obj['cfg'] = self.cfg + return obj def get_derivation(self): return self.derivation @@ -209,18 +212,19 @@ class Ledger_KeyStore(Hardware_KeyStore): info = self.get_client().signMessagePrepare(address_path, message) pin = "" if info['confirmationNeeded']: - # TODO : handle different confirmation types. For the time being only supports keyboard 2FA - confirmed, p, pin = self.password_dialog() - if not confirmed: - raise Exception('Aborted by user') - pin = pin.encode() - #self.plugin.get_client(self, True, True) + pin = self.handler.get_auth( info ) # does the authenticate dialog and returns pin + if not pin: + raise UserWarning(_('Cancelled by user')) + pin = str(pin).encode() signature = self.get_client().signMessageSign(pin) except BTChipException, e: if e.sw == 0x6a80: self.give_error("Unfortunately, this message cannot be signed by the Ledger wallet. Only alphanumerical messages shorter than 140 characters are supported. Please remove any extra characters (tab, carriage return) and retry.") else: self.give_error(e, True) + except UserWarning: + self.handler.show_error(_('Cancelled by user')) + return '' except Exception, e: self.give_error(e, True) finally: @@ -334,6 +338,7 @@ class Ledger_KeyStore(Hardware_KeyStore): firstTransaction = True inputIndex = 0 rawTx = tx.serialize() + self.get_client().enableAlternate2fa(False) while inputIndex < len(inputs): self.get_client().startUntrustedTransaction(firstTransaction, inputIndex, chipInputs, redeemScripts[inputIndex]) @@ -348,44 +353,24 @@ class Ledger_KeyStore(Hardware_KeyStore): if firstTransaction: transactionOutput = outputData['outputData'] if outputData['confirmationNeeded']: - # TODO : handle different confirmation types. For the time being only supports keyboard 2FA + outputData['address'] = output self.handler.clear_dialog() - if 'keycardData' in outputData: - pin2 = "" - for keycardIndex in range(len(outputData['keycardData'])): - msg = "Do not enter your device PIN here !\r\n\r\n" + \ - "Your Ledger Wallet wants to talk to you and tell you a unique second factor code.\r\n" + \ - "For this to work, please match the character between stars of the output address using your security card\r\n\r\n" + \ - "Output address : " - for index in range(len(output)): - if index == outputData['keycardData'][keycardIndex]: - msg = msg + "*" + output[index] + "*" - else: - msg = msg + output[index] - msg = msg + "\r\n" - confirmed, p, pin = self.password_dialog(msg) - if not confirmed: - raise Exception('Aborted by user') - try: - pin2 = pin2 + chr(int(pin[0], 16)) - except: - raise Exception('Invalid PIN character') - pin = pin2 - else: - confirmed, p, pin = self.password_dialog() - if not confirmed: - raise Exception('Aborted by user') - pin = pin.encode() - #self.plugin.get_client(self, True, True) - self.handler.show_message("Signing ...") + pin = self.handler.get_auth( outputData ) # does the authenticate dialog and returns pin + if not pin: + raise UserWarning() + if pin != 'paired': + self.handler.show_message(_("Confirmed. Signing Transaction...")) else: # Sign input with the provided PIN - inputSignature = self.get_client().untrustedHashSign(inputsPaths[inputIndex], - pin) + inputSignature = self.get_client().untrustedHashSign(inputsPaths[inputIndex], pin) inputSignature[0] = 0x30 # force for 1.4.9+ signatures.append(inputSignature) inputIndex = inputIndex + 1 - firstTransaction = False + if pin != 'paired': + firstTransaction = False + except UserWarning: + self.handler.show_error(_('Cancelled by user')) + return except BaseException as e: traceback.print_exc(file=sys.stdout) self.give_error(e, True) @@ -412,23 +397,6 @@ class Ledger_KeyStore(Hardware_KeyStore): tx.update_signatures(updatedTransaction) self.signing = False - def password_dialog(self, msg=None): - if not msg: - msg = _("Do not enter your device PIN here !\r\n\r\n" \ - "Your Ledger Wallet wants to talk to you and tell you a unique second factor code.\r\n" \ - "For this to work, please open a text editor " \ - "(on a different computer / device if you believe this computer is compromised) " \ - "and put your cursor into it, unplug your Ledger Wallet and plug it back in.\r\n" \ - "It should show itself to your computer as a keyboard " \ - "and output the second factor along with a summary of " \ - "the transaction being signed into the text-editor.\r\n\r\n" \ - "Check that summary and then enter the second factor code here.\r\n" \ - "Before clicking OK, re-plug the device once more (unplug it and plug it again if you read the second factor code on the same computer)") - response = self.handler.get_word(msg) - if response is None: - return False, None, None - return True, response, response - class LedgerPlugin(HW_PluginBase): libraries_available = BTCHIP diff --git a/plugins/ledger/qt.py b/plugins/ledger/qt.py index 55df66854..df7246128 100644 --- a/plugins/ledger/qt.py +++ b/plugins/ledger/qt.py @@ -9,6 +9,9 @@ from .ledger import LedgerPlugin from ..hw_wallet.qt import QtHandlerBase, QtPluginBase from electrum_gui.qt.util import * +from btchip.btchipPersoWizard import StartBTChipPersoDialog + +from .auth2fa import LedgerAuthDialog class Plugin(LedgerPlugin, QtPluginBase): icon_unpaired = ":icons/ledger_unpaired.png" @@ -17,11 +20,14 @@ class Plugin(LedgerPlugin, QtPluginBase): def create_handler(self, window): return Ledger_Handler(window) - class Ledger_Handler(QtHandlerBase): + setup_signal = pyqtSignal() + auth_signal = pyqtSignal(object) def __init__(self, win): super(Ledger_Handler, self).__init__(win, 'Ledger') + self.setup_signal.connect(self.setup_dialog) + self.auth_signal.connect(self.auth_dialog) def word_dialog(self, msg): response = QInputDialog.getText(self.top_level_window(), "Ledger Wallet Authentication", msg, QLineEdit.Password) @@ -30,3 +36,39 @@ class Ledger_Handler(QtHandlerBase): else: self.word = str(response[0]) self.done.set() + + def message_dialog(self, msg): + self.clear_dialog() + self.dialog = dialog = WindowModalDialog(self.top_level_window(), _("Ledger Status")) + l = QLabel(msg) + vbox = QVBoxLayout(dialog) + vbox.addWidget(l) + dialog.show() + + def auth_dialog(self, data): + dialog = LedgerAuthDialog(self, data) + dialog.exec_() + self.word = dialog.pin + self.done.set() + + def get_auth(self, data): + self.done.clear() + self.auth_signal.emit(data) + self.done.wait() + return self.word + + def get_setup(self): + self.done.clear() + self.setup_signal.emit() + self.done.wait() + return + + def setup_dialog(self): + dialog = StartBTChipPersoDialog() + dialog.exec_() + + + + + +