ledger new ui and mobile 2fa validation

This commit is contained in:
neocogent 2016-12-21 12:52:54 +07:00 committed by ThomasV
parent 59ed5932a8
commit da7e48f3a7
3 changed files with 417 additions and 60 deletions

347
plugins/ledger/auth2fa.py Normal file
View File

@ -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.<br><br>" \
"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.<br><br>" \
"Verify the transaction summary and type the PIN code here.<br><br>" \
"Before pressing enter, plug the device back into this computer.<br>" ),
_("Verify the address below.<br>Type the character from your security card corresponding to the <u><b>BOLD</b></u> 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.<br>"
"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] + '<u><b>' + addr[i:i+1] + '</u></b>' + 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)

View File

@ -13,14 +13,12 @@ from electrum.keystore import Hardware_KeyStore, parse_xpubkey
from ..hw_wallet import HW_PluginBase from ..hw_wallet import HW_PluginBase
from electrum.util import format_satoshis_plain, print_error from electrum.util import format_satoshis_plain, print_error
try: try:
import hid import hid
from btchip.btchipComm import HIDDongleHIDAPI, DongleWait from btchip.btchipComm import HIDDongleHIDAPI, DongleWait
from btchip.btchip import btchip from btchip.btchip import btchip
from btchip.btchipUtils import compress_public_key,format_transaction, get_regular_input_script, get_p2sh_input_script from btchip.btchipUtils import compress_public_key,format_transaction, get_regular_input_script, get_p2sh_input_script
from btchip.bitcoinTransaction import bitcoinTransaction from btchip.bitcoinTransaction import bitcoinTransaction
from btchip.btchipPersoWizard import StartBTChipPersoDialog
from btchip.btchipFirmwareWizard import checkFirmware, updateFirmware from btchip.btchipFirmwareWizard import checkFirmware, updateFirmware
from btchip.btchipException import BTChipException from btchip.btchipException import BTChipException
BTCHIP = True BTCHIP = True
@ -121,8 +119,7 @@ class Ledger_Client():
except BTChipException, e: except BTChipException, e:
if (e.sw == 0x6985): if (e.sw == 0x6985):
self.dongleObject.dongle.close() self.dongleObject.dongle.close()
dialog = StartBTChipPersoDialog() self.handler.get_setup( )
dialog.exec_()
# Acquire the new client on the next run # Acquire the new client on the next run
else: else:
raise e raise e
@ -172,6 +169,12 @@ class Ledger_KeyStore(Hardware_KeyStore):
# device reconnects # device reconnects
self.force_watching_only = False self.force_watching_only = False
self.signing = 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): def get_derivation(self):
return self.derivation return self.derivation
@ -209,18 +212,19 @@ class Ledger_KeyStore(Hardware_KeyStore):
info = self.get_client().signMessagePrepare(address_path, message) info = self.get_client().signMessagePrepare(address_path, message)
pin = "" pin = ""
if info['confirmationNeeded']: if info['confirmationNeeded']:
# TODO : handle different confirmation types. For the time being only supports keyboard 2FA pin = self.handler.get_auth( info ) # does the authenticate dialog and returns pin
confirmed, p, pin = self.password_dialog() if not pin:
if not confirmed: raise UserWarning(_('Cancelled by user'))
raise Exception('Aborted by user') pin = str(pin).encode()
pin = pin.encode()
#self.plugin.get_client(self, True, True)
signature = self.get_client().signMessageSign(pin) signature = self.get_client().signMessageSign(pin)
except BTChipException, e: except BTChipException, e:
if e.sw == 0x6a80: 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.") 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: else:
self.give_error(e, True) self.give_error(e, True)
except UserWarning:
self.handler.show_error(_('Cancelled by user'))
return ''
except Exception, e: except Exception, e:
self.give_error(e, True) self.give_error(e, True)
finally: finally:
@ -334,6 +338,7 @@ class Ledger_KeyStore(Hardware_KeyStore):
firstTransaction = True firstTransaction = True
inputIndex = 0 inputIndex = 0
rawTx = tx.serialize() rawTx = tx.serialize()
self.get_client().enableAlternate2fa(False)
while inputIndex < len(inputs): while inputIndex < len(inputs):
self.get_client().startUntrustedTransaction(firstTransaction, inputIndex, self.get_client().startUntrustedTransaction(firstTransaction, inputIndex,
chipInputs, redeemScripts[inputIndex]) chipInputs, redeemScripts[inputIndex])
@ -348,44 +353,24 @@ class Ledger_KeyStore(Hardware_KeyStore):
if firstTransaction: if firstTransaction:
transactionOutput = outputData['outputData'] transactionOutput = outputData['outputData']
if outputData['confirmationNeeded']: if outputData['confirmationNeeded']:
# TODO : handle different confirmation types. For the time being only supports keyboard 2FA outputData['address'] = output
self.handler.clear_dialog() self.handler.clear_dialog()
if 'keycardData' in outputData: pin = self.handler.get_auth( outputData ) # does the authenticate dialog and returns pin
pin2 = "" if not pin:
for keycardIndex in range(len(outputData['keycardData'])): raise UserWarning()
msg = "Do not enter your device PIN here !\r\n\r\n" + \ if pin != 'paired':
"Your Ledger Wallet wants to talk to you and tell you a unique second factor code.\r\n" + \ self.handler.show_message(_("Confirmed. Signing Transaction..."))
"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 ...")
else: else:
# Sign input with the provided PIN # Sign input with the provided PIN
inputSignature = self.get_client().untrustedHashSign(inputsPaths[inputIndex], inputSignature = self.get_client().untrustedHashSign(inputsPaths[inputIndex], pin)
pin)
inputSignature[0] = 0x30 # force for 1.4.9+ inputSignature[0] = 0x30 # force for 1.4.9+
signatures.append(inputSignature) signatures.append(inputSignature)
inputIndex = inputIndex + 1 inputIndex = inputIndex + 1
firstTransaction = False if pin != 'paired':
firstTransaction = False
except UserWarning:
self.handler.show_error(_('Cancelled by user'))
return
except BaseException as e: except BaseException as e:
traceback.print_exc(file=sys.stdout) traceback.print_exc(file=sys.stdout)
self.give_error(e, True) self.give_error(e, True)
@ -412,23 +397,6 @@ class Ledger_KeyStore(Hardware_KeyStore):
tx.update_signatures(updatedTransaction) tx.update_signatures(updatedTransaction)
self.signing = False 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): class LedgerPlugin(HW_PluginBase):
libraries_available = BTCHIP libraries_available = BTCHIP

View File

@ -9,6 +9,9 @@ from .ledger import LedgerPlugin
from ..hw_wallet.qt import QtHandlerBase, QtPluginBase from ..hw_wallet.qt import QtHandlerBase, QtPluginBase
from electrum_gui.qt.util import * from electrum_gui.qt.util import *
from btchip.btchipPersoWizard import StartBTChipPersoDialog
from .auth2fa import LedgerAuthDialog
class Plugin(LedgerPlugin, QtPluginBase): class Plugin(LedgerPlugin, QtPluginBase):
icon_unpaired = ":icons/ledger_unpaired.png" icon_unpaired = ":icons/ledger_unpaired.png"
@ -17,11 +20,14 @@ class Plugin(LedgerPlugin, QtPluginBase):
def create_handler(self, window): def create_handler(self, window):
return Ledger_Handler(window) return Ledger_Handler(window)
class Ledger_Handler(QtHandlerBase): class Ledger_Handler(QtHandlerBase):
setup_signal = pyqtSignal()
auth_signal = pyqtSignal(object)
def __init__(self, win): def __init__(self, win):
super(Ledger_Handler, self).__init__(win, 'Ledger') 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): def word_dialog(self, msg):
response = QInputDialog.getText(self.top_level_window(), "Ledger Wallet Authentication", msg, QLineEdit.Password) response = QInputDialog.getText(self.top_level_window(), "Ledger Wallet Authentication", msg, QLineEdit.Password)
@ -30,3 +36,39 @@ class Ledger_Handler(QtHandlerBase):
else: else:
self.word = str(response[0]) self.word = str(response[0])
self.done.set() 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_()