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.
This commit is contained in:
Neil Booth 2016-01-05 06:47:14 +09:00
parent 5b8e096d57
commit 3d9f321cae
6 changed files with 331 additions and 186 deletions

View File

@ -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__
@ -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

View File

@ -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

View File

@ -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.'''
@ -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
@ -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))
@ -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)

View File

@ -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
@ -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):
@ -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)
@ -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)
@ -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.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()
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 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)
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
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:
transport = self.HidTransport(path)
client.ping('t')
except BaseException as e:
# We were probably just disconnected; never mind
self.print_error("cannot connect at", path, str(e))
continue
self.print_error("ping failed", str(e))
# Remove it from the manager's cache
self.device_manager().close_client(client)
client = None
self.print_error("connected to device at", path[0])
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)
try:
client = self.client_class(transport, path, self)
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)
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
@ -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)
@ -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]
@ -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

View File

@ -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
@ -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
@ -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):
@ -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,
@ -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():
@ -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 "
@ -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():

View File

@ -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