tseparate trezor and keepkey codebase - electrum - Electrum Bitcoin wallet HTML git clone https://git.parazyd.org/electrum DIR Log DIR Files DIR Refs DIR Submodules --- DIR commit 958b794bc9e38343d9a3738f4c22afc3a3b8e917 DIR parent a03a5c00e3d81373e00540606e96160547a46c84 HTML Author: ThomasV <thomasv@electrum.org> Date: Mon, 4 Dec 2017 14:06:37 +0100 separate trezor and keepkey codebase Diffstat: M plugins/keepkey/client.py | 8 ++++---- A plugins/keepkey/clientbase.py | 234 +++++++++++++++++++++++++++++++ M plugins/keepkey/keepkey.py | 8 ++++---- A plugins/keepkey/plugin.py | 377 +++++++++++++++++++++++++++++++ M plugins/keepkey/qt.py | 2 +- A plugins/keepkey/qt_generic.py | 588 +++++++++++++++++++++++++++++++ 6 files changed, 1208 insertions(+), 9 deletions(-) --- DIR diff --git a/plugins/keepkey/client.py b/plugins/keepkey/client.py t@@ -1,14 +1,14 @@ from keepkeylib.client import proto, BaseClient, ProtocolMixin -from ..trezor.clientbase import TrezorClientBase +from .clientbase import KeepKeyClientBase -class KeepKeyClient(TrezorClientBase, ProtocolMixin, BaseClient): +class KeepKeyClient(KeepKeyClientBase, ProtocolMixin, BaseClient): def __init__(self, transport, handler, plugin): BaseClient.__init__(self, transport) ProtocolMixin.__init__(self, transport) - TrezorClientBase.__init__(self, handler, plugin, proto) + KeepKeyClientBase.__init__(self, handler, plugin, proto) def recovery_device(self, *args): ProtocolMixin.recovery_device(self, False, *args) -TrezorClientBase.wrap_methods(KeepKeyClient) +KeepKeyClientBase.wrap_methods(KeepKeyClient) DIR diff --git a/plugins/keepkey/clientbase.py b/plugins/keepkey/clientbase.py t@@ -0,0 +1,234 @@ +import time +from struct import pack + +from electrum.i18n import _ +from electrum.util import PrintError, UserCancelled +from electrum.keystore import bip39_normalize_passphrase +from electrum.bitcoin import serialize_xpub + + +class GuiMixin(object): + # Requires: self.proto, self.device + + messages = { + 3: _("Confirm the transaction output on your %s device"), + 4: _("Confirm internal entropy on your %s device to begin"), + 5: _("Write down the seed word shown on your %s"), + 6: _("Confirm on your %s that you want to wipe it clean"), + 7: _("Confirm on your %s device the message to sign"), + 8: _("Confirm the total amount spent and the transaction fee on your " + "%s device"), + 10: _("Confirm wallet address on your %s device"), + 'default': _("Check your %s device to continue"), + } + + def callback_Failure(self, msg): + # BaseClient's unfortunate call() implementation forces us to + # raise exceptions on failure in order to unwind the stack. + # However, making the user acknowledge they cancelled + # gets old very quickly, so we suppress those. The NotInitialized + # one is misnamed and indicates a passphrase request was cancelled. + if msg.code in (self.types.Failure_PinCancelled, + self.types.Failure_ActionCancelled, + self.types.Failure_NotInitialized): + raise UserCancelled() + raise RuntimeError(msg.message) + + def callback_ButtonRequest(self, msg): + message = self.msg + if not message: + message = self.messages.get(msg.code, self.messages['default']) + self.handler.show_message(message % self.device, self.cancel) + return self.proto.ButtonAck() + + def callback_PinMatrixRequest(self, msg): + if msg.type == 2: + msg = _("Enter a new PIN for your %s:") + elif msg.type == 3: + msg = (_("Re-enter the new PIN for your %s.\n\n" + "NOTE: the positions of the numbers have changed!")) + else: + msg = _("Enter your current %s PIN:") + pin = self.handler.get_pin(msg % self.device) + if not pin: + return self.proto.Cancel() + return self.proto.PinMatrixAck(pin=pin) + + def callback_PassphraseRequest(self, req): + if self.creating_wallet: + msg = _("Enter a passphrase to generate this wallet. Each time " + "you use this wallet your %s will prompt you for the " + "passphrase. If you forget the passphrase you cannot " + "access the bitcoins in the wallet.") % self.device + else: + msg = _("Enter the passphrase to unlock this wallet:") + passphrase = self.handler.get_passphrase(msg, self.creating_wallet) + if passphrase is None: + return self.proto.Cancel() + passphrase = bip39_normalize_passphrase(passphrase) + return self.proto.PassphraseAck(passphrase=passphrase) + + def callback_WordRequest(self, msg): + self.step += 1 + msg = _("Step %d/24. Enter seed word as explained on " + "your %s:") % (self.step, self.device) + word = self.handler.get_word(msg) + # Unfortunately the device can't handle self.proto.Cancel() + return self.proto.WordAck(word=word) + + def callback_CharacterRequest(self, msg): + char_info = self.handler.get_char(msg) + if not char_info: + return self.proto.Cancel() + return self.proto.CharacterAck(**char_info) + + +class TrezorClientBase(GuiMixin, PrintError): + + def __init__(self, handler, plugin, proto): + assert hasattr(self, 'tx_api') # ProtocolMixin already constructed? + self.proto = proto + self.device = plugin.device + self.handler = handler + self.tx_api = plugin + self.types = plugin.types + self.msg = None + self.creating_wallet = False + self.used() + + def __str__(self): + return "%s/%s" % (self.label(), self.features.device_id) + + def label(self): + '''The name given by the user to the device.''' + return self.features.label + + def is_initialized(self): + '''True if initialized, False if wiped.''' + return self.features.initialized + + def is_pairable(self): + return not self.features.bootloader_mode + + def used(self): + self.last_operation = time.time() + + def prevent_timeouts(self): + self.last_operation = float('inf') + + def timeout(self, cutoff): + '''Time out the client if the last operation was before cutoff.''' + if self.last_operation < cutoff: + self.print_error("timed out") + self.clear_session() + + @staticmethod + def expand_path(n): + '''Convert bip32 path to list of uint32 integers with prime flags + 0/-1/1' -> [0, 0x80000001, 0x80000001]''' + # This code is similar to code in trezorlib where it unforunately + # is not declared as a staticmethod. Our n has an extra element. + PRIME_DERIVATION_FLAG = 0x80000000 + path = [] + for x in n.split('/')[1:]: + prime = 0 + if x.endswith("'"): + x = x.replace('\'', '') + prime = PRIME_DERIVATION_FLAG + if x.startswith('-'): + prime = PRIME_DERIVATION_FLAG + path.append(abs(int(x)) | prime) + return path + + def cancel(self): + '''Provided here as in keepkeylib but not trezorlib.''' + self.transport.write(self.proto.Cancel()) + + def i4b(self, x): + return pack('>I', x) + + def get_xpub(self, bip32_path, xtype): + address_n = self.expand_path(bip32_path) + creating = False + node = self.get_public_node(address_n, creating).node + return serialize_xpub(xtype, node.chain_code, node.public_key, node.depth, self.i4b(node.fingerprint), self.i4b(node.child_num)) + + def toggle_passphrase(self): + if self.features.passphrase_protection: + self.msg = _("Confirm on your %s device to disable passphrases") + else: + self.msg = _("Confirm on your %s device to enable passphrases") + enabled = not self.features.passphrase_protection + self.apply_settings(use_passphrase=enabled) + + def change_label(self, label): + self.msg = _("Confirm the new label on your %s device") + self.apply_settings(label=label) + + def change_homescreen(self, homescreen): + self.msg = _("Confirm on your %s device to change your home screen") + self.apply_settings(homescreen=homescreen) + + def set_pin(self, remove): + if remove: + self.msg = _("Confirm on your %s device to disable PIN protection") + elif self.features.pin_protection: + self.msg = _("Confirm on your %s device to change your PIN") + else: + self.msg = _("Confirm on your %s device to set a PIN") + self.change_pin(remove) + + def clear_session(self): + '''Clear the session to force pin (and passphrase if enabled) + re-entry. Does not leak exceptions.''' + self.print_error("clear session:", self) + self.prevent_timeouts() + try: + super(TrezorClientBase, self).clear_session() + except BaseException as e: + # If the device was removed it has the same effect... + self.print_error("clear_session: ignoring error", str(e)) + pass + + def get_public_node(self, address_n, creating): + self.creating_wallet = creating + return super(TrezorClientBase, self).get_public_node(address_n) + + def close(self): + '''Called when Our wallet was closed or the device removed.''' + self.print_error("closing client") + self.clear_session() + # Release the device + self.transport.close() + + def firmware_version(self): + f = self.features + return (f.major_version, f.minor_version, f.patch_version) + + def atleast_version(self, major, minor=0, patch=0): + return self.firmware_version() >= (major, minor, patch) + + @staticmethod + def wrapper(func): + '''Wrap methods to clear any message box they opened.''' + + def wrapped(self, *args, **kwargs): + try: + self.prevent_timeouts() + return func(self, *args, **kwargs) + finally: + self.used() + self.handler.finished() + self.creating_wallet = False + self.msg = None + + return wrapped + + @staticmethod + def wrap_methods(cls): + for method in ['apply_settings', 'change_pin', + 'get_address', 'get_public_node', + 'load_device_by_mnemonic', 'load_device_by_xprv', + 'recovery_device', 'reset_device', 'sign_message', + 'sign_tx', 'wipe_device']: + setattr(cls, method, cls.wrapper(getattr(cls, method))) DIR diff --git a/plugins/keepkey/keepkey.py b/plugins/keepkey/keepkey.py t@@ -1,12 +1,12 @@ -from ..trezor.plugin import TrezorCompatiblePlugin, TrezorCompatibleKeyStore +from .plugin import KeepKeyCompatiblePlugin, KeepKeyCompatibleKeyStore -class KeepKey_KeyStore(TrezorCompatibleKeyStore): +class KeepKey_KeyStore(KeepKeyCompatibleKeyStore): hw_type = 'keepkey' device = 'KeepKey' -class KeepKeyPlugin(TrezorCompatiblePlugin): +class KeepKeyPlugin(KeepKeyCompatiblePlugin): firmware_URL = 'https://www.keepkey.com' libraries_URL = 'https://github.com/keepkey/python-keepkey' minimum_firmware = (1, 0, 0) t@@ -25,7 +25,7 @@ class KeepKeyPlugin(TrezorCompatiblePlugin): self.libraries_available = True except ImportError: self.libraries_available = False - TrezorCompatiblePlugin.__init__(self, *args) + KeepKeyCompatiblePlugin.__init__(self, *args) def hid_transport(self, pair): from keepkeylib.transport_hid import HidTransport DIR diff --git a/plugins/keepkey/plugin.py b/plugins/keepkey/plugin.py t@@ -0,0 +1,377 @@ +import threading + +from binascii import hexlify, unhexlify + +from electrum.util import bfh, bh2u +from electrum.bitcoin import (b58_address_to_hash160, xpub_from_pubkey, + TYPE_ADDRESS, TYPE_SCRIPT, NetworkConstants) +from electrum.i18n import _ +from electrum.plugins import BasePlugin +from electrum.transaction import deserialize +from electrum.keystore import Hardware_KeyStore, is_xpubkey, parse_xpubkey + +from ..hw_wallet import HW_PluginBase + + +# TREZOR initialization methods +TIM_NEW, TIM_RECOVER, TIM_MNEMONIC, TIM_PRIVKEY = range(0, 4) + +class KeepKeyCompatibleKeyStore(Hardware_KeyStore): + + def get_derivation(self): + return self.derivation + + def is_segwit(self): + return self.derivation.startswith("m/49'/") + + def get_client(self, force_pair=True): + return self.plugin.get_client(self, force_pair) + + def decrypt_message(self, sequence, message, password): + raise RuntimeError(_('Encryption and decryption are not implemented by %s') % self.device) + + def sign_message(self, sequence, message, password): + client = self.get_client() + address_path = self.get_derivation() + "/%d/%d"%sequence + address_n = client.expand_path(address_path) + msg_sig = client.sign_message(self.plugin.get_coin_name(), address_n, message) + return msg_sig.signature + + def sign_transaction(self, tx, password): + if tx.is_complete(): + return + # previous transactions used as inputs + prev_tx = {} + # path of the xpubs that are involved + xpub_path = {} + for txin in tx.inputs(): + pubkeys, x_pubkeys = tx.get_sorted_pubkeys(txin) + tx_hash = txin['prevout_hash'] + prev_tx[tx_hash] = txin['prev_tx'] + for x_pubkey in x_pubkeys: + if not is_xpubkey(x_pubkey): + continue + xpub, s = parse_xpubkey(x_pubkey) + if xpub == self.get_master_public_key(): + xpub_path[xpub] = self.get_derivation() + + self.plugin.sign_transaction(self, tx, prev_tx, xpub_path) + + +class TrezorCompatiblePlugin(HW_PluginBase): + # Derived classes provide: + # + # class-static variables: client_class, firmware_URL, handler_class, + # libraries_available, libraries_URL, minimum_firmware, + # wallet_class, ckd_public, types, HidTransport + + MAX_LABEL_LEN = 32 + + def __init__(self, parent, config, name): + HW_PluginBase.__init__(self, parent, config, name) + self.main_thread = threading.current_thread() + # FIXME: move to base class when Ledger is fixed + if self.libraries_available: + self.device_manager().register_devices(self.DEVICE_IDS) + + def _try_hid(self, device): + self.print_error("Trying to connect over USB...") + if device.interface_number == 1: + pair = [None, device.path] + else: + pair = [device.path, None] + + try: + return self.hid_transport(pair) + except BaseException as e: + # see fdb810ba622dc7dbe1259cbafb5b28e19d2ab114 + # raise + self.print_error("cannot connect at", device.path, str(e)) + return None + + def _try_bridge(self, device): + self.print_error("Trying to connect over Trezor Bridge...") + try: + return self.bridge_transport({'path': hexlify(device.path)}) + except BaseException as e: + self.print_error("cannot connect to bridge", str(e)) + return None + + def create_client(self, device, handler): + # disable bridge because it seems to never returns if keepkey is plugged + #transport = self._try_bridge(device) or self._try_hid(device) + transport = self._try_hid(device) + if not transport: + self.print_error("cannot connect to device") + return + + self.print_error("connected to device at", device.path) + + client = self.client_class(transport, handler, self) + + # Try a ping for device sanity + try: + client.ping('t') + except BaseException as e: + self.print_error("ping failed", str(e)) + return None + + if not client.atleast_version(*self.minimum_firmware): + msg = (_('Outdated %s firmware for device labelled %s. Please ' + 'download the updated firmware from %s') % + (self.device, client.label(), self.firmware_URL)) + self.print_error(msg) + handler.show_error(msg) + return None + + return client + + def get_client(self, keystore, force_pair=True): + devmgr = self.device_manager() + handler = keystore.handler + with devmgr.hid_lock: + client = devmgr.client_for_keystore(self, handler, keystore, force_pair) + # returns the client for a given keystore. can use xpub + if client: + client.used() + return client + + def get_coin_name(self): + return "Testnet" if NetworkConstants.TESTNET else "Bitcoin" + + def initialize_device(self, device_id, wizard, handler): + # Initialization method + msg = _("Choose how you want to initialize your %s.\n\n" + "The first two methods are secure as no secret information " + "is entered into your computer.\n\n" + "For the last two methods you input secrets on your keyboard " + "and upload them to your %s, and so you should " + "only do those on a computer you know to be trustworthy " + "and free of malware." + ) % (self.device, self.device) + choices = [ + # Must be short as QT doesn't word-wrap radio button text + (TIM_NEW, _("Let the device generate a completely new seed randomly")), + (TIM_RECOVER, _("Recover from a seed you have previously written down")), + (TIM_MNEMONIC, _("Upload a BIP39 mnemonic to generate the seed")), + (TIM_PRIVKEY, _("Upload a master private key")) + ] + def f(method): + import threading + settings = self.request_trezor_init_settings(wizard, method, self.device) + t = threading.Thread(target = self._initialize_device, args=(settings, method, device_id, wizard, handler)) + t.setDaemon(True) + t.start() + wizard.loop.exec_() + wizard.choice_dialog(title=_('Initialize Device'), message=msg, choices=choices, run_next=f) + + def _initialize_device(self, settings, method, device_id, wizard, handler): + item, label, pin_protection, passphrase_protection = settings + + if method == TIM_RECOVER and self.device == 'TREZOR': + # Warn user about firmware lameness + handler.show_error(_( + "You will be asked to enter 24 words regardless of your " + "seed's actual length. If you enter a word incorrectly or " + "misspell it, you cannot change it or go back - you will need " + "to start again from the beginning.\n\nSo please enter " + "the words carefully!")) + + language = 'english' + devmgr = self.device_manager() + client = devmgr.client_by_id(device_id) + + if method == TIM_NEW: + strength = 64 * (item + 2) # 128, 192 or 256 + args = [True, strength, passphrase_protection, + pin_protection, label, language] + if self.device == 'TREZOR': + args.extend([0, False]) # u2f_counter, skip_backup + client.reset_device(*args) + elif method == TIM_RECOVER: + word_count = 6 * (item + 2) # 12, 18 or 24 + client.step = 0 + client.recovery_device(word_count, passphrase_protection, + pin_protection, label, language) + elif method == TIM_MNEMONIC: + pin = pin_protection # It's the pin, not a boolean + client.load_device_by_mnemonic(str(item), pin, + passphrase_protection, + label, language) + else: + pin = pin_protection # It's the pin, not a boolean + client.load_device_by_xprv(item, pin, passphrase_protection, + label, language) + wizard.loop.exit(0) + + def setup_device(self, device_info, wizard): + '''Called when creating a new wallet. Select the device to use. If + the device is uninitialized, go through the intialization + process.''' + devmgr = self.device_manager() + device_id = device_info.device.id_ + client = devmgr.client_by_id(device_id) + # fixme: we should use: client.handler = wizard + client.handler = self.create_handler(wizard) + if not device_info.initialized: + self.initialize_device(device_id, wizard, client.handler) + client.get_xpub('m', 'standard') + client.used() + + def get_xpub(self, device_id, derivation, xtype, wizard): + devmgr = self.device_manager() + client = devmgr.client_by_id(device_id) + client.handler = wizard + xpub = client.get_xpub(derivation, xtype) + client.used() + return xpub + + def sign_transaction(self, keystore, tx, prev_tx, xpub_path): + self.prev_tx = prev_tx + self.xpub_path = xpub_path + client = self.get_client(keystore) + inputs = self.tx_inputs(tx, True, keystore.is_segwit()) + outputs = self.tx_outputs(keystore.get_derivation(), tx, keystore.is_segwit()) + signed_tx = client.sign_tx(self.get_coin_name(), inputs, outputs, lock_time=tx.locktime)[1] + raw = bh2u(signed_tx) + tx.update_signatures(raw) + + def show_address(self, wallet, address): + client = self.get_client(wallet.keystore) + if not client.atleast_version(1, 3): + wallet.keystore.handler.show_error(_("Your device firmware is too old")) + return + change, index = wallet.get_address_index(address) + derivation = wallet.keystore.derivation + address_path = "%s/%d/%d"%(derivation, change, index) + address_n = client.expand_path(address_path) + segwit = wallet.keystore.is_segwit() + script_type = self.types.SPENDP2SHWITNESS if segwit else self.types.SPENDADDRESS + client.get_address(self.get_coin_name(), address_n, True, script_type=script_type) + + def tx_inputs(self, tx, for_sig=False, segwit=False): + inputs = [] + for txin in tx.inputs(): + txinputtype = self.types.TxInputType() + if txin['type'] == 'coinbase': + prev_hash = "\0"*32 + prev_index = 0xffffffff # signed int -1 + else: + if for_sig: + x_pubkeys = txin['x_pubkeys'] + if len(x_pubkeys) == 1: + x_pubkey = x_pubkeys[0] + xpub, s = parse_xpubkey(x_pubkey) + xpub_n = self.client_class.expand_path(self.xpub_path[xpub]) + txinputtype.address_n.extend(xpub_n + s) + txinputtype.script_type = self.types.SPENDP2SHWITNESS if segwit else self.types.SPENDADDRESS + else: + def f(x_pubkey): + if is_xpubkey(x_pubkey): + xpub, s = parse_xpubkey(x_pubkey) + else: + xpub = xpub_from_pubkey(0, bfh(x_pubkey)) + s = [] + node = self.ckd_public.deserialize(xpub) + return self.types.HDNodePathType(node=node, address_n=s) + pubkeys = map(f, x_pubkeys) + multisig = self.types.MultisigRedeemScriptType( + pubkeys=pubkeys, + signatures=map(lambda x: bfh(x)[:-1] if x else b'', txin.get('signatures')), + m=txin.get('num_sig'), + ) + script_type = self.types.SPENDP2SHWITNESS if segwit else self.types.SPENDMULTISIG + txinputtype = self.types.TxInputType( + script_type=script_type, + multisig=multisig + ) + # find which key is mine + for x_pubkey in x_pubkeys: + if is_xpubkey(x_pubkey): + xpub, s = parse_xpubkey(x_pubkey) + if xpub in self.xpub_path: + xpub_n = self.client_class.expand_path(self.xpub_path[xpub]) + txinputtype.address_n.extend(xpub_n + s) + break + + prev_hash = unhexlify(txin['prevout_hash']) + prev_index = txin['prevout_n'] + + if 'value' in txin: + txinputtype.amount = txin['value'] + txinputtype.prev_hash = prev_hash + txinputtype.prev_index = prev_index + + if 'scriptSig' in txin: + script_sig = bfh(txin['scriptSig']) + txinputtype.script_sig = script_sig + + txinputtype.sequence = txin.get('sequence', 0xffffffff - 1) + + inputs.append(txinputtype) + + return inputs + + def tx_outputs(self, derivation, tx, segwit=False): + outputs = [] + has_change = False + + for _type, address, amount in tx.outputs(): + info = tx.output_info.get(address) + if info is not None and not has_change: + has_change = True # no more than one change address + addrtype, hash_160 = b58_address_to_hash160(address) + index, xpubs, m = info + if len(xpubs) == 1: + script_type = self.types.PAYTOP2SHWITNESS if segwit else self.types.PAYTOADDRESS + address_n = self.client_class.expand_path(derivation + "/%d/%d"%index) + txoutputtype = self.types.TxOutputType( + amount = amount, + script_type = script_type, + address_n = address_n, + ) + else: + script_type = self.types.PAYTOP2SHWITNESS if segwit else self.types.PAYTOMULTISIG + address_n = self.client_class.expand_path("/%d/%d"%index) + nodes = map(self.ckd_public.deserialize, xpubs) + pubkeys = [ self.types.HDNodePathType(node=node, address_n=address_n) for node in nodes] + multisig = self.types.MultisigRedeemScriptType( + pubkeys = pubkeys, + signatures = [b''] * len(pubkeys), + m = m) + txoutputtype = self.types.TxOutputType( + multisig = multisig, + amount = amount, + address_n = self.client_class.expand_path(derivation + "/%d/%d"%index), + script_type = script_type) + else: + txoutputtype = self.types.TxOutputType() + txoutputtype.amount = amount + if _type == TYPE_SCRIPT: + txoutputtype.script_type = self.types.PAYTOOPRETURN + txoutputtype.op_return_data = address[2:] + elif _type == TYPE_ADDRESS: + txoutputtype.script_type = self.types.PAYTOADDRESS + txoutputtype.address = address + + outputs.append(txoutputtype) + + return outputs + + def electrum_tx_to_txtype(self, tx): + t = self.types.TransactionType() + d = deserialize(tx.raw) + t.version = d['version'] + t.lock_time = d['lockTime'] + inputs = self.tx_inputs(tx) + t.inputs.extend(inputs) + for vout in d['outputs']: + o = t.bin_outputs.add() + o.amount = vout['value'] + o.script_pubkey = bfh(vout['scriptPubKey']) + return t + + # This function is called from the trezor libraries (via tx_api) + def get_tx(self, tx_hash): + tx = self.prev_tx[tx_hash] + return self.electrum_tx_to_txtype(tx) DIR diff --git a/plugins/keepkey/qt.py b/plugins/keepkey/qt.py t@@ -1,4 +1,4 @@ -from ..trezor.qt_generic import QtPlugin +from .qt_generic import QtPlugin from .keepkey import KeepKeyPlugin DIR diff --git a/plugins/keepkey/qt_generic.py b/plugins/keepkey/qt_generic.py t@@ -0,0 +1,588 @@ +from functools import partial +import threading + +from PyQt5.Qt import Qt +from PyQt5.Qt import QGridLayout, QInputDialog, QPushButton +from PyQt5.Qt import QVBoxLayout, QLabel +from electrum_gui.qt.util import * +from .plugin import TIM_NEW, TIM_RECOVER, TIM_MNEMONIC +from ..hw_wallet.qt import QtHandlerBase, QtPluginBase + +from electrum.i18n import _ +from electrum.plugins import hook, DeviceMgr +from electrum.util import PrintError, UserCancelled, bh2u +from electrum.wallet import Wallet, Standard_Wallet + +PASSPHRASE_HELP_SHORT =_( + "Passphrases allow you to access new wallets, each " + "hidden behind a particular case-sensitive passphrase.") +PASSPHRASE_HELP = PASSPHRASE_HELP_SHORT + " " + _( + "You need to create a separate Electrum wallet for each passphrase " + "you use as they each generate different addresses. Changing " + "your passphrase does not lose other wallets, each is still " + "accessible behind its own passphrase.") +RECOMMEND_PIN = _( + "You should enable PIN protection. Your PIN is the only protection " + "for your bitcoins if your device is lost or stolen.") +PASSPHRASE_NOT_PIN = _( + "If you forget a passphrase you will be unable to access any " + "bitcoins in the wallet behind it. A passphrase is not a PIN. " + "Only change this if you are sure you understand it.") +CHARACTER_RECOVERY = ( + "Use the recovery cipher shown on your device to input your seed words. " + "The cipher changes with every keypress.\n" + "After at most 4 letters the device will auto-complete a word.\n" + "Press SPACE or the Accept Word button to accept the device's auto-" + "completed word and advance to the next one.\n" + "Press BACKSPACE to go back a character or word.\n" + "Press ENTER or the Seed Entered button once the last word in your " + "seed is auto-completed.") + +class CharacterButton(QPushButton): + def __init__(self, text=None): + QPushButton.__init__(self, text) + + def keyPressEvent(self, event): + event.setAccepted(False) # Pass through Enter and Space keys + + +class CharacterDialog(WindowModalDialog): + + def __init__(self, parent): + super(CharacterDialog, self).__init__(parent) + self.setWindowTitle(_("KeepKey Seed Recovery")) + self.character_pos = 0 + self.word_pos = 0 + self.loop = QEventLoop() + self.word_help = QLabel() + self.char_buttons = [] + + vbox = QVBoxLayout(self) + vbox.addWidget(WWLabel(CHARACTER_RECOVERY)) + hbox = QHBoxLayout() + hbox.addWidget(self.word_help) + for i in range(4): + char_button = CharacterButton('*') + char_button.setMaximumWidth(36) + self.char_buttons.append(char_button) + hbox.addWidget(char_button) + self.accept_button = CharacterButton(_("Accept Word")) + self.accept_button.clicked.connect(partial(self.process_key, 32)) + self.rejected.connect(partial(self.loop.exit, 1)) + hbox.addWidget(self.accept_button) + hbox.addStretch(1) + vbox.addLayout(hbox) + + self.finished_button = QPushButton(_("Seed Entered")) + self.cancel_button = QPushButton(_("Cancel")) + self.finished_button.clicked.connect(partial(self.process_key, + Qt.Key_Return)) + self.cancel_button.clicked.connect(self.rejected) + buttons = Buttons(self.finished_button, self.cancel_button) + vbox.addSpacing(40) + vbox.addLayout(buttons) + self.refresh() + self.show() + + def refresh(self): + self.word_help.setText("Enter seed word %2d:" % (self.word_pos + 1)) + self.accept_button.setEnabled(self.character_pos >= 3) + self.finished_button.setEnabled((self.word_pos in (11, 17, 23) + and self.character_pos >= 3)) + for n, button in enumerate(self.char_buttons): + button.setEnabled(n == self.character_pos) + if n == self.character_pos: + button.setFocus() + + def is_valid_alpha_space(self, key): + # Auto-completion requires at least 3 characters + if key == ord(' ') and self.character_pos >= 3: + return True + # Firmware aborts protocol if the 5th character is non-space + if self.character_pos >= 4: + return False + return (key >= ord('a') and key <= ord('z') + or (key >= ord('A') and key <= ord('Z'))) + + def process_key(self, key): + self.data = None + if key == Qt.Key_Return and self.finished_button.isEnabled(): + self.data = {'done': True} + elif key == Qt.Key_Backspace and (self.word_pos or self.character_pos): + self.data = {'delete': True} + elif self.is_valid_alpha_space(key): + self.data = {'character': chr(key).lower()} + if self.data: + self.loop.exit(0) + + def keyPressEvent(self, event): + self.process_key(event.key()) + if not self.data: + QDialog.keyPressEvent(self, event) + + def get_char(self, word_pos, character_pos): + self.word_pos = word_pos + self.character_pos = character_pos + self.refresh() + if self.loop.exec_(): + self.data = None # User cancelled + + +class QtHandler(QtHandlerBase): + + char_signal = pyqtSignal(object) + pin_signal = pyqtSignal(object) + + def __init__(self, win, pin_matrix_widget_class, device): + super(QtHandler, self).__init__(win, device) + self.char_signal.connect(self.update_character_dialog) + self.pin_signal.connect(self.pin_dialog) + self.pin_matrix_widget_class = pin_matrix_widget_class + self.character_dialog = None + + def get_char(self, msg): + self.done.clear() + self.char_signal.emit(msg) + self.done.wait() + data = self.character_dialog.data + if not data or 'done' in data: + self.character_dialog.accept() + self.character_dialog = None + return data + + def get_pin(self, msg): + self.done.clear() + self.pin_signal.emit(msg) + self.done.wait() + return self.response + + def pin_dialog(self, msg): + # Needed e.g. when resetting a device + self.clear_dialog() + dialog = WindowModalDialog(self.top_level_window(), _("Enter PIN")) + matrix = self.pin_matrix_widget_class() + vbox = QVBoxLayout() + vbox.addWidget(QLabel(msg)) + vbox.addWidget(matrix) + vbox.addLayout(Buttons(CancelButton(dialog), OkButton(dialog))) + dialog.setLayout(vbox) + dialog.exec_() + self.response = str(matrix.get_value()) + self.done.set() + + def update_character_dialog(self, msg): + if not self.character_dialog: + self.character_dialog = CharacterDialog(self.top_level_window()) + self.character_dialog.get_char(msg.word_pos, msg.character_pos) + self.done.set() + + + +class QtPlugin(QtPluginBase): + # Derived classes must provide the following class-static variables: + # icon_file + # pin_matrix_widget_class + + def create_handler(self, window): + return QtHandler(window, self.pin_matrix_widget_class(), self.device) + + @hook + def receive_menu(self, menu, addrs, wallet): + if type(wallet) is not Standard_Wallet: + return + keystore = wallet.get_keystore() + if type(keystore) == self.keystore_class and len(addrs) == 1: + def show_address(): + keystore.thread.add(partial(self.show_address, wallet, addrs[0])) + menu.addAction(_("Show on %s") % self.device, show_address) + + def show_settings_dialog(self, window, keystore): + device_id = self.choose_device(window, keystore) + if device_id: + SettingsDialog(window, self, keystore, device_id).exec_() + + def request_trezor_init_settings(self, wizard, method, device): + vbox = QVBoxLayout() + next_enabled = True + label = QLabel(_("Enter a label to name your device:")) + name = QLineEdit() + hl = QHBoxLayout() + hl.addWidget(label) + hl.addWidget(name) + hl.addStretch(1) + vbox.addLayout(hl) + + def clean_text(widget): + text = widget.toPlainText().strip() + return ' '.join(text.split()) + + if method in [TIM_NEW, TIM_RECOVER]: + gb = QGroupBox() + hbox1 = QHBoxLayout() + gb.setLayout(hbox1) + # KeepKey recovery doesn't need a word count + if method == TIM_NEW or self.device == 'TREZOR': + vbox.addWidget(gb) + gb.setTitle(_("Select your seed length:")) + bg = QButtonGroup() + for i, count in enumerate([12, 18, 24]): + rb = QRadioButton(gb) + rb.setText(_("%d words") % count) + bg.addButton(rb) + bg.setId(rb, i) + hbox1.addWidget(rb) + rb.setChecked(True) + cb_pin = QCheckBox(_('Enable PIN protection')) + cb_pin.setChecked(True) + else: + text = QTextEdit() + text.setMaximumHeight(60) + if method == TIM_MNEMONIC: + msg = _("Enter your BIP39 mnemonic:") + else: + msg = _("Enter the master private key beginning with xprv:") + def set_enabled(): + from electrum.keystore import is_xprv + wizard.next_button.setEnabled(is_xprv(clean_text(text))) + text.textChanged.connect(set_enabled) + next_enabled = False + + vbox.addWidget(QLabel(msg)) + vbox.addWidget(text) + pin = QLineEdit() + pin.setValidator(QRegExpValidator(QRegExp('[1-9]{0,10}'))) + pin.setMaximumWidth(100) + hbox_pin = QHBoxLayout() + hbox_pin.addWidget(QLabel(_("Enter your PIN (digits 1-9):"))) + hbox_pin.addWidget(pin) + hbox_pin.addStretch(1) + + if method in [TIM_NEW, TIM_RECOVER]: + vbox.addWidget(WWLabel(RECOMMEND_PIN)) + vbox.addWidget(cb_pin) + else: + vbox.addLayout(hbox_pin) + + passphrase_msg = WWLabel(PASSPHRASE_HELP_SHORT) + passphrase_warning = WWLabel(PASSPHRASE_NOT_PIN) + passphrase_warning.setStyleSheet("color: red") + cb_phrase = QCheckBox(_('Enable passphrases')) + cb_phrase.setChecked(False) + vbox.addWidget(passphrase_msg) + vbox.addWidget(passphrase_warning) + vbox.addWidget(cb_phrase) + + wizard.exec_layout(vbox, next_enabled=next_enabled) + + if method in [TIM_NEW, TIM_RECOVER]: + item = bg.checkedId() + pin = cb_pin.isChecked() + else: + item = ' '.join(str(clean_text(text)).split()) + pin = str(pin.text()) + + return (item, name.text(), pin, cb_phrase.isChecked()) + + + + +class SettingsDialog(WindowModalDialog): + '''This dialog doesn't require a device be paired with a wallet. + We want users to be able to wipe a device even if they've forgotten + their PIN.''' + + def __init__(self, window, plugin, keystore, device_id): + title = _("%s Settings") % plugin.device + super(SettingsDialog, self).__init__(window, title) + self.setMaximumWidth(540) + + devmgr = plugin.device_manager() + config = devmgr.config + handler = keystore.handler + thread = keystore.thread + hs_rows, hs_cols = (64, 128) + + def invoke_client(method, *args, **kw_args): + unpair_after = kw_args.pop('unpair_after', False) + + def task(): + client = devmgr.client_by_id(device_id) + if not client: + raise RuntimeError("Device not connected") + if method: + getattr(client, method)(*args, **kw_args) + if unpair_after: + devmgr.unpair_id(device_id) + return client.features + + thread.add(task, on_success=update) + + def update(features): + self.features = features + set_label_enabled() + bl_hash = bh2u(features.bootloader_hash) + bl_hash = "\n".join([bl_hash[:32], bl_hash[32:]]) + noyes = [_("No"), _("Yes")] + endis = [_("Enable Passphrases"), _("Disable Passphrases")] + disen = [_("Disabled"), _("Enabled")] + setchange = [_("Set a PIN"), _("Change PIN")] + + version = "%d.%d.%d" % (features.major_version, + features.minor_version, + features.patch_version) + coins = ", ".join(coin.coin_name for coin in features.coins) + + device_label.setText(features.label) + pin_set_label.setText(noyes[features.pin_protection]) + passphrases_label.setText(disen[features.passphrase_protection]) + bl_hash_label.setText(bl_hash) + label_edit.setText(features.label) + device_id_label.setText(features.device_id) + initialized_label.setText(noyes[features.initialized]) + version_label.setText(version) + coins_label.setText(coins) + clear_pin_button.setVisible(features.pin_protection) + clear_pin_warning.setVisible(features.pin_protection) + pin_button.setText(setchange[features.pin_protection]) + pin_msg.setVisible(not features.pin_protection) + passphrase_button.setText(endis[features.passphrase_protection]) + language_label.setText(features.language) + + def set_label_enabled(): + label_apply.setEnabled(label_edit.text() != self.features.label) + + def rename(): + invoke_client('change_label', label_edit.text()) + + def toggle_passphrase(): + title = _("Confirm Toggle Passphrase Protection") + currently_enabled = self.features.passphrase_protection + if currently_enabled: + msg = _("After disabling passphrases, you can only pair this " + "Electrum wallet if it had an empty passphrase. " + "If its passphrase was not empty, you will need to " + "create a new wallet with the install wizard. You " + "can use this wallet again at any time by re-enabling " + "passphrases and entering its passphrase.") + else: + msg = _("Your current Electrum wallet can only be used with " + "an empty passphrase. You must create a separate " + "wallet with the install wizard for other passphrases " + "as each one generates a new set of addresses.") + msg += "\n\n" + _("Are you sure you want to proceed?") + if not self.question(msg, title=title): + return + invoke_client('toggle_passphrase', unpair_after=currently_enabled) + + def change_homescreen(): + from PIL import Image # FIXME + dialog = QFileDialog(self, _("Choose Homescreen")) + filename, __ = dialog.getOpenFileName() + if filename: + im = Image.open(str(filename)) + if im.size != (hs_cols, hs_rows): + raise Exception('Image must be 64 x 128 pixels') + im = im.convert('1') + pix = im.load() + img = '' + for j in range(hs_rows): + for i in range(hs_cols): + img += '1' if pix[i, j] else '0' + img = ''.join(chr(int(img[i:i + 8], 2)) + for i in range(0, len(img), 8)) + invoke_client('change_homescreen', img) + + def clear_homescreen(): + invoke_client('change_homescreen', '\x00') + + def set_pin(): + invoke_client('set_pin', remove=False) + + def clear_pin(): + invoke_client('set_pin', remove=True) + + def wipe_device(): + wallet = window.wallet + if wallet and sum(wallet.get_balance()): + title = _("Confirm Device Wipe") + msg = _("Are you SURE you want to wipe the device?\n" + "Your wallet still has bitcoins in it!") + if not self.question(msg, title=title, + icon=QMessageBox.Critical): + return + invoke_client('wipe_device', unpair_after=True) + + def slider_moved(): + mins = timeout_slider.sliderPosition() + timeout_minutes.setText(_("%2d minutes") % mins) + + def slider_released(): + config.set_session_timeout(timeout_slider.sliderPosition() * 60) + + # Information tab + info_tab = QWidget() + info_layout = QVBoxLayout(info_tab) + info_glayout = QGridLayout() + info_glayout.setColumnStretch(2, 1) + device_label = QLabel() + pin_set_label = QLabel() + passphrases_label = QLabel() + version_label = QLabel() + device_id_label = QLabel() + bl_hash_label = QLabel() + bl_hash_label.setWordWrap(True) + coins_label = QLabel() + coins_label.setWordWrap(True) + language_label = QLabel() + initialized_label = QLabel() + rows = [ + (_("Device Label"), device_label), + (_("PIN set"), pin_set_label), + (_("Passphrases"), passphrases_label), + (_("Firmware Version"), version_label), + (_("Device ID"), device_id_label), + (_("Bootloader Hash"), bl_hash_label), + (_("Supported Coins"), coins_label), + (_("Language"), language_label), + (_("Initialized"), initialized_label), + ] + for row_num, (label, widget) in enumerate(rows): + info_glayout.addWidget(QLabel(label), row_num, 0) + info_glayout.addWidget(widget, row_num, 1) + info_layout.addLayout(info_glayout) + + # Settings tab + settings_tab = QWidget() + settings_layout = QVBoxLayout(settings_tab) + settings_glayout = QGridLayout() + + # Settings tab - Label + label_msg = QLabel(_("Name this %s. If you have mutiple devices " + "their labels help distinguish them.") + % plugin.device) + label_msg.setWordWrap(True) + label_label = QLabel(_("Device Label")) + label_edit = QLineEdit() + label_edit.setMinimumWidth(150) + label_edit.setMaxLength(plugin.MAX_LABEL_LEN) + label_apply = QPushButton(_("Apply")) + label_apply.clicked.connect(rename) + label_edit.textChanged.connect(set_label_enabled) + settings_glayout.addWidget(label_label, 0, 0) + settings_glayout.addWidget(label_edit, 0, 1, 1, 2) + settings_glayout.addWidget(label_apply, 0, 3) + settings_glayout.addWidget(label_msg, 1, 1, 1, -1) + + # Settings tab - PIN + pin_label = QLabel(_("PIN Protection")) + pin_button = QPushButton() + pin_button.clicked.connect(set_pin) + settings_glayout.addWidget(pin_label, 2, 0) + settings_glayout.addWidget(pin_button, 2, 1) + pin_msg = QLabel(_("PIN protection is strongly recommended. " + "A PIN is your only protection against someone " + "stealing your bitcoins if they obtain physical " + "access to your %s.") % plugin.device) + pin_msg.setWordWrap(True) + pin_msg.setStyleSheet("color: red") + settings_glayout.addWidget(pin_msg, 3, 1, 1, -1) + + # Settings tab - Homescreen + if plugin.device != 'KeepKey': # Not yet supported by KK firmware + homescreen_layout = QHBoxLayout() + homescreen_label = QLabel(_("Homescreen")) + homescreen_change_button = QPushButton(_("Change...")) + homescreen_clear_button = QPushButton(_("Reset")) + homescreen_change_button.clicked.connect(change_homescreen) + homescreen_clear_button.clicked.connect(clear_homescreen) + homescreen_msg = QLabel(_("You can set the homescreen on your " + "device to personalize it. You must " + "choose a %d x %d monochrome black and " + "white image.") % (hs_rows, hs_cols)) + homescreen_msg.setWordWrap(True) + settings_glayout.addWidget(homescreen_label, 4, 0) + settings_glayout.addWidget(homescreen_change_button, 4, 1) + settings_glayout.addWidget(homescreen_clear_button, 4, 2) + settings_glayout.addWidget(homescreen_msg, 5, 1, 1, -1) + + # Settings tab - Session Timeout + timeout_label = QLabel(_("Session Timeout")) + timeout_minutes = QLabel() + timeout_slider = QSlider(Qt.Horizontal) + timeout_slider.setRange(1, 60) + timeout_slider.setSingleStep(1) + timeout_slider.setTickInterval(5) + timeout_slider.setTickPosition(QSlider.TicksBelow) + timeout_slider.setTracking(True) + timeout_msg = QLabel( + _("Clear the session after the specified period " + "of inactivity. Once a session has timed out, " + "your PIN and passphrase (if enabled) must be " + "re-entered to use the device.")) + timeout_msg.setWordWrap(True) + timeout_slider.setSliderPosition(config.get_session_timeout() // 60) + slider_moved() + timeout_slider.valueChanged.connect(slider_moved) + timeout_slider.sliderReleased.connect(slider_released) + settings_glayout.addWidget(timeout_label, 6, 0) + settings_glayout.addWidget(timeout_slider, 6, 1, 1, 3) + settings_glayout.addWidget(timeout_minutes, 6, 4) + settings_glayout.addWidget(timeout_msg, 7, 1, 1, -1) + settings_layout.addLayout(settings_glayout) + settings_layout.addStretch(1) + + # Advanced tab + advanced_tab = QWidget() + advanced_layout = QVBoxLayout(advanced_tab) + advanced_glayout = QGridLayout() + + # Advanced tab - clear PIN + clear_pin_button = QPushButton(_("Disable PIN")) + clear_pin_button.clicked.connect(clear_pin) + clear_pin_warning = QLabel( + _("If you disable your PIN, anyone with physical access to your " + "%s device can spend your bitcoins.") % plugin.device) + clear_pin_warning.setWordWrap(True) + clear_pin_warning.setStyleSheet("color: red") + advanced_glayout.addWidget(clear_pin_button, 0, 2) + advanced_glayout.addWidget(clear_pin_warning, 1, 0, 1, 5) + + # Advanced tab - toggle passphrase protection + passphrase_button = QPushButton() + passphrase_button.clicked.connect(toggle_passphrase) + passphrase_msg = WWLabel(PASSPHRASE_HELP) + passphrase_warning = WWLabel(PASSPHRASE_NOT_PIN) + passphrase_warning.setStyleSheet("color: red") + advanced_glayout.addWidget(passphrase_button, 3, 2) + advanced_glayout.addWidget(passphrase_msg, 4, 0, 1, 5) + advanced_glayout.addWidget(passphrase_warning, 5, 0, 1, 5) + + # Advanced tab - wipe device + wipe_device_button = QPushButton(_("Wipe Device")) + wipe_device_button.clicked.connect(wipe_device) + wipe_device_msg = QLabel( + _("Wipe the device, removing all data from it. The firmware " + "is left unchanged.")) + wipe_device_msg.setWordWrap(True) + wipe_device_warning = QLabel( + _("Only wipe a device if you have the recovery seed written down " + "and the device wallet(s) are empty, otherwise the bitcoins " + "will be lost forever.")) + wipe_device_warning.setWordWrap(True) + wipe_device_warning.setStyleSheet("color: red") + advanced_glayout.addWidget(wipe_device_button, 6, 2) + advanced_glayout.addWidget(wipe_device_msg, 7, 0, 1, 5) + advanced_glayout.addWidget(wipe_device_warning, 8, 0, 1, 5) + advanced_layout.addLayout(advanced_glayout) + advanced_layout.addStretch(1) + + tabs = QTabWidget(self) + tabs.addTab(info_tab, _("Information")) + tabs.addTab(settings_tab, _("Settings")) + tabs.addTab(advanced_tab, _("Advanced")) + dialog_vbox = QVBoxLayout(self) + dialog_vbox.addWidget(tabs) + dialog_vbox.addLayout(Buttons(CloseButton(self))) + + # Update information + invoke_client(None)