1
0

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

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