tUse a shared device manager - electrum - Electrum Bitcoin wallet (HTM) git clone https://git.parazyd.org/electrum (DIR) Log (DIR) Files (DIR) Refs (DIR) Submodules --- (DIR) commit 3d9f321cae28f5806e5d75d9032d3c89a7459dd2 (DIR) parent 5b8e096d5709e051024c91a1b637ff2bc9b2cb74 (HTM) Author: Neil Booth <kyuupichan@gmail.com> Date: Tue, 5 Jan 2016 06:47:14 +0900 Use a shared device manager Use a shared device manager across USB devices (not yet taken advantage of by ledger). This reduces USB scans and abstracts device management cleanly. We no longer scan at regular intervals in a background thread. Diffstat: M lib/plugins.py | 198 +++++++++++++++++++++++++++++++ M plugins/keepkey/keepkey.py | 4 ++-- M plugins/trezor/client.py | 32 ++++++++++++++++++++++++++++++- M plugins/trezor/plugin.py | 264 +++++++++++--------------------- M plugins/trezor/qt_generic.py | 23 ++++++++++++++--------- M plugins/trezor/trezor.py | 2 +- 6 files changed, 334 insertions(+), 189 deletions(-) --- (DIR) diff --git a/lib/plugins.py b/lib/plugins.py t@@ -44,6 +44,8 @@ class Plugins(DaemonThread): self.plugins = {} self.gui_name = gui_name self.descriptions = [] + self.device_manager = DeviceMgr() + for loader, name, ispkg in pkgutil.iter_modules([self.pkgpath]): m = loader.find_module(name).load_module(name) d = m.__dict__ t@@ -212,3 +214,199 @@ class BasePlugin(PrintError): def settings_dialog(self): pass + + +class DeviceMgr(PrintError): + '''Manages hardware clients. A client communicates over a hardware + channel with the device. A client is a pair: a device ID (serial + number) and hardware port. If either change then a different + client is instantiated. + + In addition to tracking device IDs, the device manager tracks + hardware wallets and manages wallet pairing. A device ID may be + paired with a wallet when it is confirmed that the hardware device + matches the wallet, i.e. they have the same master public key. A + device ID can be unpaired if e.g. it is wiped. + + Because of hotplugging, a wallet must request its client + dynamically each time it is required, rather than caching it + itself. + + The device manager is shared across plugins, so just one place + does hardware scans when needed. By tracking device serial + numbers the number of necessary hardware scans is reduced, e.g. if + a device is plugged into a different port the wallet is + automatically re-paired. + + Wallets are informed on connect / disconnect / unpairing events. + It must implement connected(), disconnected() and unpaired() + callbacks. Being connected implies a pairing. Being disconnected + doesn't. Callbacks can happen in any thread context, and we do + them without holding the lock. + + This plugin is thread-safe. Currently only USB is implemented. + ''' + + # Client lookup types. CACHED will look up in our client cache + # only. PRESENT will do a scan if there is no client in the cache. + # PAIRED will try and pair the wallet, which will involve requesting + # a PIN and passphrase if they are enabled + (CACHED, PRESENT, PAIRED) = range(3) + + def __init__(self): + super(DeviceMgr, self).__init__() + # Keyed by wallet. The value is the device_id if the wallet + # has been paired, and None otherwise. + self.wallets = {} + # A list of clients. We create a client for every device present + # that is of a registered hardware type + self.clients = [] + # What we recognise. Keyed by (vendor_id, product_id) pairs, + # the value is a handler for those devices. The handler must + # implement + self.recognised_hardware = {} + # For synchronization + self.lock = threading.RLock() + + def register_devices(self, handler, device_pairs): + for pair in device_pairs: + self.recognised_hardware[pair] = handler + + def close_client(self, client): + with self.lock: + if client in self.clients: + self.clients.remove(client) + client.close() + + def close_wallet(self, wallet): + # Remove the wallet from our list; close any client + with self.lock: + device_id = self.wallets.pop(wallet, None) + self.close_client(self.client_by_device_id(device_id)) + + def clients_of_type(self, classinfo): + with self.lock: + return [client for client in self.clients + if isinstance(client, classinfo)] + + def client_by_device_id(self, device_id): + with self.lock: + for client in self.clients: + if client.device_id() == device_id: + return client + return None + + def wallet_by_device_id(self, device_id): + with self.lock: + for wallet, wallet_device_id in self.wallets.items(): + if wallet_device_id == device_id: + return wallet + return None + + def paired_wallets(self): + with self.lock: + return [wallet for (wallet, device_id) in self.wallets.items() + if device_id is not None] + + def pair_wallet(self, wallet, client): + assert client in self.clients + self.print_error("paired:", wallet, client) + self.wallets[wallet] = client.device_id() + client.pair_wallet(wallet) + wallet.connected() + + def scan_devices(self): + # All currently supported hardware libraries use hid, so we + # assume it here. This can be easily abstracted if necessary. + # Note this import must be local so those without hardware + # wallet libraries are not affected. + import hid + + self.print_error("scanning devices...") + + # First see what's connected that we know about + devices = {} + for d in hid.enumerate(0, 0): + product_key = (d['vendor_id'], d['product_id']) + device_id = d['serial_number'] + path = d['path'] + + handler = self.recognised_hardware.get(product_key) + if handler: + devices[device_id] = (handler, path, product_key) + + # Now find out what was disconnected + with self.lock: + disconnected = [client for client in self.clients + if not client.device_id() in devices] + + # Close disconnected clients after informing their wallets + for client in disconnected: + wallet = self.wallet_by_device_id(client.device_id()) + if wallet: + wallet.disconnected() + self.close_client(client) + + # Now see if any new devices are present. + for device_id, (handler, path, product_key) in devices.items(): + try: + client = handler.create_client(path, product_key) + except BaseException as e: + self.print_error("could not create client", str(e)) + client = None + if client: + self.print_error("client created for", path) + with self.lock: + self.clients.append(client) + # Inform re-paired wallet + wallet = self.wallet_by_device_id(device_id) + if wallet: + self.pair_wallet(wallet, client) + + def get_client(self, wallet, lookup=PAIRED): + '''Returns a client for the wallet, or None if one could not be + found.''' + with self.lock: + device_id = self.wallets.get(wallet) + client = self.client_by_device_id(device_id) + if client: + return client + + if lookup == DeviceMgr.CACHED: + return None + + first_address, derivation = wallet.first_address() + # Wallets don't have a first address in the install wizard + # until account creation + if not first_address: + self.print_error("no first address for ", wallet) + return None + + # We didn't find it, so scan for new devices. We scan as + # little as possible: some people report a USB scan is slow on + # Linux when a Trezor is plugged in + self.scan_devices() + + with self.lock: + # Maybe the scan found it? If the wallet has a device_id + # from a prior pairing, we can determine success now. + if device_id: + return self.client_by_device_id(device_id) + + # Stop here if no wake and we couldn't find it. + if lookup == DeviceMgr.PRESENT: + return None + + # The wallet has not been previously paired, so get the + # first address of all unpaired clients and compare. + for client in self.clients: + # If already paired skip it + if self.wallet_by_device_id(client.device_id()): + continue + # This will trigger a PIN/passphrase entry request + if client.first_address(wallet, derivation) == first_address: + self.pair_wallet(wallet, client) + return client + + # Not found + return None (DIR) diff --git a/plugins/keepkey/keepkey.py b/plugins/keepkey/keepkey.py t@@ -17,7 +17,7 @@ class KeepKeyPlugin(TrezorCompatiblePlugin): client_class = trezor_client_class(ProtocolMixin, BaseClient, proto) import keepkeylib.ckd_public as ckd_public from keepkeylib.client import types - from keepkeylib.transport_hid import HidTransport + from keepkeylib.transport_hid import HidTransport, DEVICE_IDS libraries_available = True - except: + except ImportError: libraries_available = False (DIR) diff --git a/plugins/trezor/client.py b/plugins/trezor/client.py t@@ -77,7 +77,7 @@ def trezor_client_class(protocol_mixin, base_client, proto): self.msg_code_override = None def __str__(self): - return "%s/%s/%s" % (self.label(), self.device_id(), self.path[0]) + return "%s/%s/%s" % (self.label(), self.device_id(), self.path) def label(self): '''The name given by the user to the device.''' t@@ -91,6 +91,9 @@ def trezor_client_class(protocol_mixin, base_client, proto): '''True if initialized, False if wiped.''' return self.features.initialized + def pair_wallet(self, wallet): + self.wallet = wallet + def handler(self): assert self.wallet and self.wallet.handler return self.wallet.handler t@@ -111,6 +114,15 @@ def trezor_client_class(protocol_mixin, base_client, proto): path.append(abs(int(x)) | prime) return path + def first_address(self, wallet, derivation): + assert not self.wallet + # Assign the wallet so we have a handler + self.wallet = wallet + try: + return self.address_from_derivation(derivation) + finally: + self.wallet = None + def address_from_derivation(self, derivation): return self.get_address('Bitcoin', self.expand_path(derivation)) t@@ -128,6 +140,24 @@ def trezor_client_class(protocol_mixin, base_client, proto): finally: self.msg_code_override = None + 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) + try: + super(TrezorClient, 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 close(self): + '''Called when Our wallet was closed or the device removed.''' + self.print_error("disconnected") + 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) (DIR) diff --git a/plugins/trezor/plugin.py b/plugins/trezor/plugin.py t@@ -13,14 +13,19 @@ from electrum.transaction import (deserialize, is_extended_pubkey, Transaction, x_to_xpub) from electrum.wallet import BIP32_HD_Wallet, BIP44_Wallet from electrum.util import ThreadJob +from electrum.plugins import DeviceMgr class DeviceDisconnectedError(Exception): pass +class OutdatedFirmwareError(Exception): + pass + class TrezorCompatibleWallet(BIP44_Wallet): # Extend BIP44 Wallet as required by hardware implementation. # Derived classes must set: # - device + # - DEVICE_IDS # - wallet_type restore_wallet_class = BIP44_Wallet t@@ -76,14 +81,20 @@ class TrezorCompatibleWallet(BIP44_Wallet): '''The wallet is watching-only if its trezor device is not connected, or if it is connected but uninitialized.''' assert not self.has_seed() - client = self.plugin.lookup_client(self) + client = self.get_client(DeviceMgr.CACHED) return not (client and client.is_initialized()) def can_change_password(self): return False - def client(self): - return self.plugin.client(self) + def get_client(self, lookup=DeviceMgr.PAIRED): + return self.plugin.get_client(self, lookup) + + def first_address(self): + '''Used to check a hardware wallet matches a software wallet''' + account = self.accounts.get('0') + derivation = self.address_derivation('0', 0, 0) + return (account.first_address()[0] if account else None, derivation) def derive_xkeys(self, root, derivation, password): if self.master_public_keys.get(root): t@@ -96,7 +107,7 @@ class TrezorCompatibleWallet(BIP44_Wallet): return xpub, None def get_public_key(self, bip32_path): - client = self.client() + client = self.get_client() address_n = client.expand_path(bip32_path) node = client.get_public_node(address_n).node xpub = ("0488B21E".decode('hex') + chr(node.depth) t@@ -111,7 +122,7 @@ class TrezorCompatibleWallet(BIP44_Wallet): raise RuntimeError(_('Decrypt method is not implemented')) def sign_message(self, address, message, password): - client = self.client() + client = self.get_client() address_path = self.address_id(address) address_n = client.expand_path(address_path) msg_sig = client.sign_message('Bitcoin', address_n, message) t@@ -152,96 +163,89 @@ class TrezorCompatiblePlugin(BasePlugin, ThreadJob): # libraries_available, libraries_URL, minimum_firmware, # wallet_class, ckd_public, types, HidTransport - # This plugin automatically keeps track of attached devices, and - # connects to anything attached creating a new Client instance. - # When disconnected, the client is informed via a callback. - # As a device can be disconnected and/or reconnected in a different - # USB port (giving it a new path), the wallet must be dynamic in - # asking for its client. - # If a wallet is successfully paired with a given device, the plugin - # stores its serial number in the wallet so it can be automatically - # re-paired if the same device is connected elsewhere. - # Approaching things this way permits several devices to be connected - # simultaneously and handled smoothly. - def __init__(self, parent, config, name): BasePlugin.__init__(self, parent, config, name) self.device = self.wallet_class.device self.wallet_class.plugin = self self.prevent_timeout = time.time() + 3600 * 24 * 365 - # A set of client instances to USB paths - self.clients = set() - # The device wallets we have seen to inform on reconnection - self.paired_wallets = set() - self.last_scan = 0 + self.device_manager().register_devices(self, self.DEVICE_IDS) + + def is_enabled(self): + return self.libraries_available + + def device_manager(self): + return self.parent.device_manager def thread_jobs(self): - # Scan connected devices every second. The test for libraries - # available is necessary to recover wallets on machines without - # libraries + # Thread job to handle device timeouts return [self] if self.libraries_available else [] def run(self): - '''Runs in the context of the Plugins thread.''' + '''Handle device timeouts. Runs in the context of the Plugins + thread.''' now = time.time() - if now > self.last_scan + 1: - self.last_scan = now - self.scan_devices() - - for wallet in self.paired_wallets: - if now > wallet.last_operation + wallet.session_timeout: - client = self.lookup_client(wallet) - if client: - wallet.last_operation = self.prevent_timeout - self.clear_session(client) - wallet.timeout() - - def scan_devices(self): - '''Scan devices. Runs in the context of the Plugins thread.''' - paths = self.HidTransport.enumerate() - connected = set([c for c in self.clients if c.path in paths]) - disconnected = self.clients - connected - - self.clients = connected - - # Inform clients and wallets they were disconnected - for client in disconnected: - self.print_error("device disconnected:", client) - if client.wallet: - client.wallet.disconnected() - - for path in paths: - # Look for new paths - if any(c.path == path for c in connected): - continue - - try: - transport = self.HidTransport(path) - except BaseException as e: - # We were probably just disconnected; never mind - self.print_error("cannot connect at", path, str(e)) - continue + for wallet in self.device_manager().paired_wallets(): + if (isinstance(wallet, self.wallet_class) + and hasattr(wallet, 'last_operation') + and now > wallet.last_operation + wallet.session_timeout): + client = self.get_client(wallet, DeviceMgr.CACHED) + if client: + wallet.last_operation = self.prevent_timeout + client.clear_session() + wallet.timeout() + + def create_client(self, path, product_key): + pair = ((None, path) if self.HidTransport._detect_debuglink(path) + else (path, None)) + try: + transport = self.HidTransport(pair) + except BaseException as e: + # We were probably just disconnected; never mind + self.print_error("cannot connect at", path, str(e)) + return None + self.print_error("connected to device at", path) + return self.client_class(transport, path, self) - self.print_error("connected to device at", path[0]) + def get_client(self, wallet, lookup=DeviceMgr.PAIRED, check_firmware=True): + '''check_firmware is ignored unless doing a PAIRED lookup.''' + client = self.device_manager().get_client(wallet, lookup) + # Try a ping if doing at least a PRESENT lookup + if client and lookup != DeviceMgr.CACHED: + self.print_error("set last_operation") + wallet.last_operation = time.time() try: - client = self.client_class(transport, path, self) + client.ping('t') except BaseException as e: - self.print_error("cannot create client for", path, str(e)) - else: - self.clients.add(client) - self.print_error("new device:", client) + self.print_error("ping failed", str(e)) + # Remove it from the manager's cache + self.device_manager().close_client(client) + client = None + + if lookup == DeviceMgr.PAIRED: + assert wallet.handler + if not client: + msg = (_('Could not connect to your %s. Verify the ' + 'cable is connected and that no other app is ' + 'using it.\nContinuing in watching-only mode ' + 'until the device is re-connected.') % self.device) + wallet.handler.show_error(msg) + raise DeviceDisconnectedError(msg) + + if (check_firmware and 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)) + wallet.handler.show_error(msg) + raise OutdatedFirmwareError(msg) - # Inform reconnected wallets - for wallet in self.paired_wallets: - if wallet.device_id == client.features.device_id: - client.wallet = wallet - wallet.connected() + return client - def clear_session(self, client): - # Clearing the session forces pin re-entry - self.print_error("clear session:", client) - client.clear_session() + @hook + def close_wallet(self, wallet): + if isinstance(wallet, self.wallet_class): + self.device_manager().close_wallet(wallet) def initialize_device(self, wallet, wizard): # Prevent timeouts during initialization t@@ -254,105 +258,25 @@ class TrezorCompatiblePlugin(BasePlugin, ThreadJob): strength = 64 * (strength + 2) # 128, 192 or 256 language = '' - client = self.client(wallet) + client = self.get_client(wallet) client.reset_device(True, strength, passphrase_protection, pin_protection, label, language) - def select_device(self, wallet, wizard): '''Called when creating a new wallet. Select the device to use. If the device is uninitialized, go through the intialization process.''' - clients = list(self.clients) + self.device_manager().scan_devices() + clients = self.device_manager().clients_of_type(self.client_class) suffixes = [_("An unnamed device (wiped)"), _(" (initialized)")] labels = [client.label() + suffixes[client.is_initialized()] for client in clients] msg = _("Please select which %s device to use:") % self.device client = clients[wizard.query_choice(msg, labels)] - self.pair_wallet(wallet, client) + self.device_manager().pair_wallet(wallet, client) if not client.is_initialized(): self.initialize_device(wallet, wizard) - def operated_on(self, wallet): - self.print_error("set last_operation") - wallet.last_operation = time.time() - - def pair_wallet(self, wallet, client): - self.print_error("pairing wallet %s to device %s" % (wallet, client)) - self.operated_on(wallet) - self.paired_wallets.add(wallet) - wallet.device_id = client.features.device_id - wallet.last_operation = time.time() - client.wallet = wallet - wallet.connected() - - def try_to_pair_wallet(self, wallet): - '''Call this when loading an existing wallet to find if the - associated device is connected.''' - account = '0' - if not account in wallet.accounts: - self.print_error("try pair_wallet: wallet has no accounts") - return None - - first_address = wallet.accounts[account].first_address()[0] - derivation = wallet.address_derivation(account, 0, 0) - for client in self.clients: - if client.wallet: - continue - - if not client.atleast_version(*self.minimum_firmware): - wallet.handler.show_error( - _('Outdated %s firmware for device labelled %s. Please ' - 'download the updated firmware from %s') % - (self.device, client.label(), self.firmware_URL)) - continue - - # This gives us a handler - client.wallet = wallet - device_address = None - try: - device_address = client.address_from_derivation(derivation) - finally: - client.wallet = None - - if first_address == device_address: - self.pair_wallet(wallet, client) - return client - - return None - - def lookup_client(self, wallet): - for client in self.clients: - if client.features.device_id == wallet.device_id: - return client - return None - - def client(self, wallet): - '''Returns a wrapped client which handles cleanup in case of - thrown exceptions, etc.''' - assert isinstance(wallet, self.wallet_class) - assert wallet.handler != None - - self.operated_on(wallet) - if wallet.device_id is None: - client = self.try_to_pair_wallet(wallet) - else: - client = self.lookup_client(wallet) - - if not client: - msg = (_('Could not connect to your %s. Verify the ' - 'cable is connected and that no other app is ' - 'using it.\nContinuing in watching-only mode ' - 'until the device is re-connected.') % self.device) - if not self.clients: - wallet.handler.show_error(msg) - raise DeviceDisconnectedError(msg) - - return client - - def is_enabled(self): - return self.libraries_available - def on_restore_wallet(self, wallet, wizard): assert isinstance(wallet, self.wallet_class) t@@ -371,22 +295,10 @@ class TrezorCompatiblePlugin(BasePlugin, ThreadJob): wallet.create_main_account(password) return wallet - @hook - def close_wallet(self, wallet): - if isinstance(wallet, self.wallet_class): - # Don't retain references to a closed wallet - self.paired_wallets.discard(wallet) - client = self.lookup_client(wallet) - if client: - self.clear_session(client) - # Release the device - self.clients.discard(client) - client.transport.close() - def sign_transaction(self, wallet, tx, prev_tx, xpub_path): self.prev_tx = prev_tx self.xpub_path = xpub_path - client = self.client(wallet) + client = self.get_client(wallet) inputs = self.tx_inputs(tx, True) outputs = self.tx_outputs(wallet, tx) signed_tx = client.sign_tx('Bitcoin', inputs, outputs)[1] t@@ -394,7 +306,7 @@ class TrezorCompatiblePlugin(BasePlugin, ThreadJob): tx.update_signatures(raw) def show_address(self, wallet, address): - client = self.client(wallet) + client = self.get_client(wallet) if not client.atleast_version(1, 3): wallet.handler.show_error(_("Your device firmware is too old")) return (DIR) diff --git a/plugins/trezor/qt_generic.py b/plugins/trezor/qt_generic.py t@@ -10,7 +10,7 @@ from electrum_gui.qt.util import * from plugin import TrezorCompatiblePlugin from electrum.i18n import _ -from electrum.plugins import hook +from electrum.plugins import hook, DeviceMgr from electrum.util import PrintError from electrum.wallet import BIP44_Wallet t@@ -132,7 +132,7 @@ def qt_plugin_class(base_plugin_class): window.statusBar().addPermanentWidget(window.tzb) wallet.handler = self.create_handler(window) # Trigger a pairing - self.client(wallet) + self.get_client(wallet) def on_create_wallet(self, wallet, wizard): assert type(wallet) == self.wallet_class t@@ -148,8 +148,8 @@ def qt_plugin_class(base_plugin_class): def settings_dialog(self, window): - def client(): - return self.client(wallet) + def get_client(lookup=DeviceMgr.PAIRED): + return self.get_client(wallet, lookup) def add_rows_to_layout(layout, rows): for row_num, items in enumerate(rows): t@@ -158,7 +158,7 @@ def qt_plugin_class(base_plugin_class): layout.addWidget(widget, row_num, col_num) def refresh(): - features = client().features + features = get_client(DeviceMgr.PAIRED).features bl_hash = features.bootloader_hash.encode('hex').upper() bl_hash = "%s...%s" % (bl_hash[:10], bl_hash[-10:]) version = "%d.%d.%d" % (features.major_version, t@@ -184,11 +184,11 @@ def qt_plugin_class(base_plugin_class): response = QInputDialog().getText(dialog, title, msg) if not response[1]: return - client().change_label(str(response[0])) + get_client().change_label(str(response[0])) refresh() def set_pin(): - client().set_pin(remove=False) + get_client().set_pin(remove=False) refresh() def clear_pin(): t@@ -198,10 +198,11 @@ def qt_plugin_class(base_plugin_class): "Are you certain you want to remove your PIN?") % device if not dialog.question(msg, title=title): return - client().set_pin(remove=True) + get_client().set_pin(remove=True) refresh() def wipe_device(): + # FIXME: cannot yet wipe a device that is only plugged in title = _("Confirm Device Wipe") msg = _("Are you sure you want to wipe the device? " "You should make sure you have a copy of your recovery " t@@ -215,7 +216,11 @@ def qt_plugin_class(base_plugin_class): if not dialog.question(msg, title=title, icon=QMessageBox.Critical): return - client().wipe_device() + # Note: we use PRESENT so that a user who has forgotten + # their PIN is not prevented from wiping their device + get_client(DeviceMgr.PRESENT).wipe_device() + wallet.wiped() + self.device_manager().close_wallet(wallet) refresh() def slider_moved(): (DIR) diff --git a/plugins/trezor/trezor.py b/plugins/trezor/trezor.py t@@ -17,7 +17,7 @@ class TrezorPlugin(TrezorCompatiblePlugin): client_class = trezor_client_class(ProtocolMixin, BaseClient, proto) import trezorlib.ckd_public as ckd_public from trezorlib.client import types - from trezorlib.transport_hid import HidTransport + from trezorlib.transport_hid import HidTransport, DEVICE_IDS libraries_available = True except ImportError: libraries_available = False