commit cb2c2f0b9f46552a161f16128e8e0cb60b1f6417
parent c7f667e2edec8d18ac6d6b0f664d833704b61977
Author: ThomasV <thomasv1@gmx.de>
Date: Sat, 30 Aug 2014 17:17:19 +0200
Merge pull request #807 from btchip/btchip
Add BTChip wallet plugin
Diffstat:
1 file changed, 475 insertions(+), 0 deletions(-)
diff --git a/plugins/btchipwallet.py b/plugins/btchipwallet.py
@@ -0,0 +1,475 @@
+from PyQt4.Qt import QApplication, QMessageBox, QDialog, QVBoxLayout, QLabel, QThread, SIGNAL
+import PyQt4.QtCore as QtCore
+from binascii import unhexlify
+from binascii import hexlify
+from struct import pack,unpack
+from sys import stderr
+from time import sleep
+from base64 import b64encode, b64decode
+
+from electrum_gui.qt.password_dialog import make_password_dialog, run_password_dialog
+from electrum_gui.qt.util import ok_cancel_buttons
+from electrum.account import BIP32_Account
+from electrum.bitcoin import EncodeBase58Check, DecodeBase58Check, public_key_to_bc_address, bc_address_to_hash_160
+from electrum.i18n import _
+from electrum.plugins import BasePlugin
+from electrum.transaction import deserialize
+from electrum.wallet import NewWallet
+
+from electrum.util import format_satoshis
+import hashlib
+
+try:
+ from usb.core import USBError
+ from btchip.btchipComm import getDongle, DongleWait
+ from btchip.btchip import btchip
+ from btchip.btchipUtils import compress_public_key,format_transaction, get_regular_input_script
+ from btchip.bitcoinTransaction import bitcoinTransaction
+ from btchip.btchipPersoWizard import StartBTChipPersoDialog
+ from btchip.btchipException import BTChipException
+ BTCHIP = True
+except ImportError:
+ BTCHIP = False
+
+def log(msg):
+ stderr.write("%s\n" % msg)
+ stderr.flush()
+
+def give_error(message):
+ QMessageBox.warning(QDialog(), _('Warning'), _(message), _('OK'))
+ raise Exception(message)
+
+class Plugin(BasePlugin):
+
+ def fullname(self): return 'BTChip Wallet'
+
+ def description(self): return 'Provides support for BTChip hardware wallet\n\nRequires github.com/btchip/btchip-python'
+
+ def __init__(self, gui, name):
+ BasePlugin.__init__(self, gui, name)
+ self._is_available = self._init()
+ self.wallet = None
+
+ def _init(self):
+ return BTCHIP
+
+ def is_available(self):
+ #if self.wallet is None:
+ # return self._is_available
+ #if self.wallet.storage.get('wallet_type') == 'btchip':
+ # return True
+ #return False
+ return self._is_available
+
+ def set_enabled(self, enabled):
+ self.wallet.storage.put('use_' + self.name, enabled)
+
+ def is_enabled(self):
+ if not self.is_available():
+ return False
+
+ if not self.wallet or self.wallet.storage.get('wallet_type') == 'btchip':
+ return True
+
+ return self.wallet.storage.get('use_' + self.name) is True
+
+ def enable(self):
+ return BasePlugin.enable(self)
+
+ def load_wallet(self, wallet):
+ self.wallet = wallet
+
+ def add_wallet_types(self, wallet_types):
+ wallet_types.append(('btchip', _("BTChip wallet"), BTChipWallet))
+
+ def installwizard_restore(self, wizard, storage):
+ wallet = BTChipWallet(storage)
+ try:
+ wallet.create_main_account(None)
+ except BaseException as e:
+ QMessageBox.information(None, _('Error'), str(e), _('OK'))
+ return
+ return wallet
+
+ def send_tx(self, tx):
+ try:
+ self.wallet.sign_transaction(tx, None, None)
+ except Exception as e:
+ tx.error = str(e)
+
+
+class BTChipWallet(NewWallet):
+ wallet_type = 'btchip'
+
+ def __init__(self, storage):
+ NewWallet.__init__(self, storage)
+ self.transport = None
+ self.client = None
+ self.mpk = None
+ self.device_checked = False
+
+ def get_action(self):
+ if not self.accounts:
+ return 'create_accounts'
+
+ def can_create_accounts(self):
+ return True
+
+ def can_change_password(self):
+ return False
+
+ def has_seed(self):
+ return False
+
+ def is_watching_only(self):
+ return False
+
+ def get_client(self, noPin=False):
+ if not BTCHIP:
+ give_error('please install github.com/btchip/btchip-python')
+
+ aborted = False
+ if not self.client or self.client.bad:
+ try:
+ d = getDongle(True)
+ d.setWaitImpl(DongleWaitQT(d))
+ self.client = btchip(d)
+ firmware = self.client.getFirmwareVersion()['version'].split(".")
+ if int(firmware[0]) <> 1 or int(firmware[1]) <> 4:
+ aborted = True
+ raise Exception("Unsupported firmware version")
+ if int(firmware[2]) < 9:
+ aborted = True
+ raise Exception("Please update your firmware - 1.4.9 or higher is necessary")
+ try:
+ self.client.getOperationMode()
+ except BTChipException, e:
+ if (e.sw == 0x6985):
+ d.close()
+ dialog = StartBTChipPersoDialog()
+ dialog.exec_()
+ # Then fetch the reference again as it was invalidated
+ d = getDongle(True)
+ d.setWaitImpl(DongleWaitQT(d))
+ self.client = btchip(d)
+ else:
+ raise e
+ if not noPin:
+ # Immediately prompts for the PIN
+ remaining_attempts = self.client.getVerifyPinRemainingAttempts()
+ if remaining_attempts <> 1:
+ msg = "Enter your BTChip PIN - remaining attempts : " + str(remaining_attempts)
+ else:
+ msg = "Enter your BTChip PIN - WARNING : LAST ATTEMPT. If the PIN is not correct, the dongle will be wiped."
+ confirmed, p, pin = self.password_dialog(msg)
+ if not confirmed:
+ aborted = True
+ raise Exception('Aborted by user - please unplug the dongle and plug it again before retrying')
+ pin = pin.encode()
+ self.client.verifyPin(pin)
+
+ except BTChipException, e:
+ try:
+ self.client.dongle.close()
+ except:
+ pass
+ self.client = None
+ if (e.sw == 0x6faa):
+ raise Exception("Dongle is temporarily locked - please unplug it and replug it again")
+ if ((e.sw & 0xFFF0) == 0x63c0):
+ raise Exception("Invalid PIN - please unplug the dongle and plug it again before retrying")
+ raise e
+ except Exception, e:
+ try:
+ self.client.dongle.close()
+ except:
+ pass
+ self.client = None
+ if not aborted:
+ raise Exception("Could not connect to your BTChip dongle. Please verify access permissions or PIN")
+ else:
+ raise e
+ self.client.bad = False
+ self.device_checked = False
+ self.proper_device = False
+ return self.client
+
+ def address_id(self, address):
+ account_id, (change, address_index) = self.get_address_index(address)
+ return "44'/0'/%s'/%d/%d" % (account_id, change, address_index)
+
+ def create_main_account(self, password):
+ self.create_account('Main account', None) #name, empty password
+
+ def derive_xkeys(self, root, derivation, password):
+ derivation = derivation.replace(self.root_name,"44'/0'/")
+ xpub = self.get_public_key(derivation)
+ return xpub, None
+
+ def get_public_key(self, bip32_path):
+ # S-L-O-W - we don't handle the fingerprint directly, so compute it manually from the previous node
+ # This only happens once so it's bearable
+ self.get_client() # prompt for the PIN before displaying the dialog if necessary
+ waitDialog.start("Computing master public key")
+ try:
+ splitPath = bip32_path.split('/')
+ fingerprint = 0
+ if len(splitPath) > 1:
+ prevPath = "/".join(splitPath[0:len(splitPath) - 1])
+ nodeData = self.get_client().getWalletPublicKey(prevPath)
+ publicKey = compress_public_key(nodeData['publicKey'])
+ h = hashlib.new('ripemd160')
+ h.update(hashlib.sha256(publicKey).digest())
+ fingerprint = unpack(">I", h.digest()[0:4])[0]
+ nodeData = self.get_client().getWalletPublicKey(bip32_path)
+ publicKey = compress_public_key(nodeData['publicKey'])
+ depth = len(splitPath)
+ lastChild = splitPath[len(splitPath) - 1].split('\'')
+ if len(lastChild) == 1:
+ childnum = int(lastChild[0])
+ else:
+ childnum = 0x80000000 | int(lastChild[0])
+ xpub = "0488B21E".decode('hex') + chr(depth) + self.i4b(fingerprint) + self.i4b(childnum) + str(nodeData['chainCode']) + str(publicKey)
+ except Exception, e:
+ give_error(e)
+ finally:
+ waitDialog.emit(SIGNAL('dongle_done'))
+
+ return EncodeBase58Check(xpub)
+
+ def get_master_public_key(self):
+ if not self.mpk:
+ self.mpk = self.get_public_key("44'/0'")
+ return self.mpk
+
+ def i4b(self, x):
+ return pack('>I', x)
+
+ def add_keypairs(self, tx, keypairs, password):
+ #do nothing - no priv keys available
+ pass
+
+ def decrypt_message(self, pubkey, message, password):
+ give_error("Not supported")
+
+ def sign_message(self, address, message, password):
+ use2FA = False
+ self.get_client() # prompt for the PIN before displaying the dialog if necessary
+ if not self.check_proper_device():
+ give_error('Wrong device or password')
+ address_path = self.address_id(address)
+ waitDialog.start("Signing Message ...")
+ try:
+ 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
+ use2FA = True
+ confirmed, p, pin = self.password_dialog()
+ if not confirmed:
+ raise Exception('Aborted by user')
+ pin = pin.encode()
+ self.client.bad = True
+ self.get_client(True)
+ signature = self.get_client().signMessageSign(pin)
+ except Exception, e:
+ give_error(e)
+ finally:
+ if waitDialog.waiting:
+ waitDialog.emit(SIGNAL('dongle_done'))
+ self.client.bad = use2FA
+
+ # Parse the ASN.1 signature
+
+ rLength = signature[3]
+ r = signature[4 : 4 + rLength]
+ sLength = signature[4 + rLength + 1]
+ s = signature[4 + rLength + 2:]
+ if rLength == 33:
+ r = r[1:]
+ if sLength == 33:
+ s = s[1:]
+ r = str(r)
+ s = str(s)
+
+ # And convert it
+
+ return b64encode(chr(27 + 4 + (signature[0] & 0x01)) + r + s)
+
+ def choose_tx_inputs( self, amount, fixed_fee, num_outputs, domain = None, coins = None ):
+ # Overloaded to get the fee, as BTChip recomputes the change amount
+ inputs, total, fee = super(BTChipWallet, self).choose_tx_inputs(amount, fixed_fee, num_outputs, domain, coins)
+ self.lastFee = fee
+ return inputs, total, fee
+
+ def sign_transaction(self, tx, keypairs, password):
+ if tx.error or tx.is_complete():
+ return
+ inputs = []
+ inputsPaths = []
+ pubKeys = []
+ trustedInputs = []
+ redeemScripts = []
+ signatures = []
+ preparedTrustedInputs = []
+ changePath = ""
+ changeAmount = None
+ output = None
+ outputAmount = None
+ use2FA = False
+ pin = ""
+ # Fetch inputs of the transaction to sign
+ for txinput in tx.inputs:
+ if ('is_coinbase' in txinput and txinput['is_coinbase']):
+ give_error("Coinbase not supported") # should never happen
+ inputs.append([ self.transactions[txinput['prevout_hash']].raw,
+ txinput['prevout_n'] ])
+ address = txinput['address']
+ inputsPaths.append(self.address_id(address))
+ pubKeys.append(self.get_public_keys(address))
+
+ # Recognize outputs - only one output and one change is authorized
+ if len(tx.outputs) > 2: # should never happen
+ give_error("Transaction with more than 2 outputs not supported")
+ for type, address, amount in tx.outputs:
+ assert type == 'address'
+ if self.is_change(address):
+ changePath = self.address_id(address)
+ changeAmount = amount
+ else:
+ if output <> None: # should never happen
+ give_error("Multiple outputs with no change not supported")
+ output = address
+ outputAmount = amount
+
+ self.get_client() # prompt for the PIN before displaying the dialog if necessary
+ if not self.check_proper_device():
+ give_error('Wrong device or password')
+
+ waitDialog.start("Signing Transaction ...")
+ try:
+ # Get trusted inputs from the original transactions
+ for utxo in inputs:
+ txtmp = bitcoinTransaction(bytearray(utxo[0].decode('hex')))
+ trustedInputs.append(self.get_client().getTrustedInput(txtmp, utxo[1]))
+ # TODO : Support P2SH later
+ redeemScripts.append(txtmp.outputs[utxo[1]].script)
+ # Sign all inputs
+ firstTransaction = True
+ inputIndex = 0
+ while inputIndex < len(inputs):
+ self.get_client().startUntrustedTransaction(firstTransaction, inputIndex,
+ trustedInputs, redeemScripts[inputIndex])
+ outputData = self.get_client().finalizeInput(output, format_satoshis(outputAmount),
+ format_satoshis(self.lastFee), changePath)
+ if firstTransaction:
+ transactionOutput = outputData['outputData']
+ if outputData['confirmationNeeded']:
+ use2FA = True
+ # TODO : handle different confirmation types. For the time being only supports keyboard 2FA
+ waitDialog.emit(SIGNAL('dongle_done'))
+ confirmed, p, pin = self.password_dialog()
+ if not confirmed:
+ raise Exception('Aborted by user')
+ pin = pin.encode()
+ self.client.bad = True
+ self.get_client(True)
+ waitDialog.start("Signing ...")
+ else:
+ # Sign input with the provided 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
+ except Exception, e:
+ give_error(e)
+ finally:
+ if waitDialog.waiting:
+ waitDialog.emit(SIGNAL('dongle_done'))
+
+ # Reformat transaction
+ inputIndex = 0
+ while inputIndex < len(inputs):
+ # TODO : Support P2SH later
+ inputScript = get_regular_input_script(signatures[inputIndex], pubKeys[inputIndex][0].decode('hex'))
+ preparedTrustedInputs.append([ trustedInputs[inputIndex]['value'], inputScript ])
+ inputIndex = inputIndex + 1
+ updatedTransaction = format_transaction(transactionOutput, preparedTrustedInputs)
+ updatedTransaction = hexlify(updatedTransaction)
+ tx.update(updatedTransaction)
+ self.client.bad = use2FA
+
+ def check_proper_device(self):
+ pubKey = DecodeBase58Check(self.master_public_keys["x/0'"])[45:]
+ if not self.device_checked:
+ waitDialog.start("Checking device")
+ try:
+ nodeData = self.get_client().getWalletPublicKey("44'/0'/0'")
+ except Exception, e:
+ give_error(e)
+ finally:
+ waitDialog.emit(SIGNAL('dongle_done'))
+ pubKeyDevice = compress_public_key(nodeData['publicKey'])
+ self.device_checked = True
+ if pubKey != pubKeyDevice:
+ self.proper_device = False
+ else:
+ self.proper_device = True
+
+ return self.proper_device
+
+ def password_dialog(self, msg=None):
+ if not msg:
+ msg = _("Disconnect your BTChip, read the unique second factor PIN, reconnect it and enter the unique second factor PIN")
+
+ d = QDialog()
+ d.setModal(1)
+ d.setLayout( make_password_dialog(d, None, msg, False) )
+ return run_password_dialog(d, None, None)
+
+class DongleWaitingDialog(QThread):
+ def __init__(self):
+ QThread.__init__(self)
+ self.waiting = False
+
+ def start(self, message):
+ self.d = QDialog()
+ self.d.setModal(1)
+ self.d.setWindowTitle('Please Wait')
+ self.d.setWindowFlags(self.d.windowFlags() | QtCore.Qt.WindowStaysOnTopHint)
+ l = QLabel(message)
+ vbox = QVBoxLayout(self.d)
+ vbox.addWidget(l)
+ self.d.show()
+ if not self.waiting:
+ self.waiting = True
+ self.d.connect(waitDialog, SIGNAL('dongle_done'), self.stop)
+
+ def stop(self):
+ self.d.hide()
+ self.waiting = False
+
+if BTCHIP:
+ waitDialog = DongleWaitingDialog()
+
+# Tickle the UI a bit while waiting
+class DongleWaitQT(DongleWait):
+ def __init__(self, dongle):
+ self.dongle = dongle
+
+ def waitFirstResponse(self, timeout):
+ customTimeout = 0
+ while customTimeout < timeout:
+ try:
+ response = self.dongle.waitFirstResponse(200)
+ return response
+ except USBError, e:
+ if e.backend_error_code == -7:
+ QApplication.processEvents()
+ customTimeout = customTimeout + 100
+ pass
+ else:
+ raise e
+ raise e