tMerge pull request #807 from btchip/btchip - electrum - Electrum Bitcoin wallet HTML git clone https://git.parazyd.org/electrum DIR Log DIR Files DIR Refs DIR Submodules --- DIR commit cb2c2f0b9f46552a161f16128e8e0cb60b1f6417 DIR parent c7f667e2edec8d18ac6d6b0f664d833704b61977 HTML 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: A plugins/btchipwallet.py | 475 +++++++++++++++++++++++++++++++ 1 file changed, 475 insertions(+), 0 deletions(-) --- DIR diff --git a/plugins/btchipwallet.py b/plugins/btchipwallet.py t@@ -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