diff --git a/electrum/gui/qt/wizard/wallet.py b/electrum/gui/qt/wizard/wallet.py index 5c63612fa..f73647cd1 100644 --- a/electrum/gui/qt/wizard/wallet.py +++ b/electrum/gui/qt/wizard/wallet.py @@ -27,7 +27,8 @@ from electrum.gui.qt.bip39_recovery_dialog import Bip39RecoveryDialog from electrum.gui.qt.password_dialog import PasswordLayout, PW_NEW, MSG_ENTER_PASSWORD, PasswordLayoutForHW from electrum.gui.qt.seed_dialog import SeedLayout, MSG_PASSPHRASE_WARN_ISSUE4566, KeysLayout from electrum.gui.qt.util import (PasswordLineEdit, char_width_in_lineedit, WWLabel, InfoButton, font_height, - ChoiceWidget, MessageBoxMixin) + ChoiceWidget, MessageBoxMixin, WindowModalDialog, ChoicesLayout, CancelButton, + Buttons, OkButton) if TYPE_CHECKING: from electrum.simple_config import SimpleConfig @@ -220,6 +221,22 @@ class QENewWalletWizard(NewWalletWizard, QEAbstractWizard, MessageBoxMixin): if on_finished: on_finished() + def query_choice(self, msg, choices, title=None, default_choice=None): + # Needed by QtHandler for hardware wallets + if title is None: + title = _('Question') + dialog = WindowModalDialog(self.top_level_window(), title=title) + dialog.setMinimumWidth(400) + clayout = ChoicesLayout(msg, choices, checked_index=default_choice) + vbox = QVBoxLayout(dialog) + vbox.addLayout(clayout.layout()) + cancel_button = CancelButton(dialog) + vbox.addLayout(Buttons(cancel_button, OkButton(dialog))) + cancel_button.setFocus() + if not dialog.exec_(): + return None + return clayout.selected_index() + class WCWalletName(WizardComponent, Logger): def __init__(self, parent, wizard): @@ -1030,22 +1047,15 @@ class WCChooseHWDevice(WizardComponent, Logger): self.device_list.setLayout(self.device_list_layout) self.choice_w = None - self.rescan_button = QPushButton(_('Rescan devices')) - self.rescan_button.clicked.connect(self.on_rescan) - self.layout().addWidget(self.error_l) self.layout().addWidget(self.device_list) self.layout().addStretch(1) - self.layout().addWidget(self.rescan_button) self.c_values = [] def on_ready(self): self.scan_devices() - def on_rescan(self): - self.scan_devices() - def on_scan_failed(self, code, message): self.error_l.setText(message) self.error_l.setVisible(True) @@ -1080,8 +1090,6 @@ class WCChooseHWDevice(WizardComponent, Logger): if self.valid: self.wizard.next_button.setFocus() - else: - self.rescan_button.setFocus() def failed_getting_device_infos(self, debug_msg, name, e): # nonlocal debug_msg @@ -1141,8 +1149,7 @@ class WCChooseHWDevice(WizardComponent, Logger): if not debug_msg: debug_msg = ' {}'.format(_('No exceptions encountered.')) if not devices: - msg = (_('No hardware device detected.') + '\n' + - _('To trigger a rescan, press \'Rescan devices\'.') + '\n\n') + msg = (_('No hardware device detected.') + '\n\n') if sys.platform == 'win32': msg += _('If your device is not detected on Windows, go to "Settings", "Devices", "Connected devices", ' 'and do "Remove device". Then, plug your device again.') + '\n' diff --git a/electrum/gui/qt/wizard/wizard.py b/electrum/gui/qt/wizard/wizard.py index 02806a3ae..49d538018 100644 --- a/electrum/gui/qt/wizard/wizard.py +++ b/electrum/gui/qt/wizard/wizard.py @@ -22,6 +22,7 @@ class QEAbstractWizard(QDialog, MessageBoxMixin): _logger = get_logger(__name__) requestNext = pyqtSignal() + requestPrev = pyqtSignal() def __init__(self, config: 'SimpleConfig', app: 'QElectrumApplication', *, start_viewstate: 'WizardViewState' = None): QDialog.__init__(self, None) @@ -42,6 +43,7 @@ class QEAbstractWizard(QDialog, MessageBoxMixin): self.next_button = QPushButton(_("Next"), self) self.next_button.clicked.connect(self.on_next_button_clicked) self.next_button.setDefault(True) + self.requestPrev.connect(self.on_back_button_clicked) self.requestNext.connect(self.on_next_button_clicked) self.logo = QLabel() @@ -57,9 +59,9 @@ class QEAbstractWizard(QDialog, MessageBoxMixin): error_layout = QVBoxLayout() error_layout.addStretch(1) - error_l = QLabel(_("Error!")) - error_l.setAlignment(Qt.AlignCenter) - error_layout.addWidget(error_l) + # error_l = QLabel(_("Error!")) + # error_l.setAlignment(Qt.AlignCenter) + # error_layout.addWidget(error_l) self.error_msg = WWLabel() self.error_msg.setAlignment(Qt.AlignCenter) error_layout.addWidget(self.error_msg) diff --git a/electrum/plugins/digitalbitbox/digitalbitbox.py b/electrum/plugins/digitalbitbox/digitalbitbox.py index 12faabf7e..474cf53cd 100644 --- a/electrum/plugins/digitalbitbox/digitalbitbox.py +++ b/electrum/plugins/digitalbitbox/digitalbitbox.py @@ -35,6 +35,7 @@ from electrum.logging import get_logger from electrum.plugin import runs_in_hwd_thread, run_in_hwd_thread from ..hw_wallet import HW_PluginBase, HardwareClientBase, HardwareHandlerBase +from ..hw_wallet.plugin import OperationCancelled if TYPE_CHECKING: from electrum.plugin import DeviceInfo @@ -50,11 +51,14 @@ except ImportError as e: DIGIBOX = False +class DeviceErased(UserFacingException): + pass # ---------------------------------------------------------------------------------- # USB HID interface # + def to_hexstr(s): return binascii.hexlify(s).decode('ascii') @@ -64,6 +68,7 @@ def derive_keys(x): h = hashlib.sha512(h).digest() return (h[:32],h[32:]) + MIN_MAJOR_VERSION = 5 ENCRYPTION_PRIVKEY_KEY = 'encryptionprivkey' @@ -78,7 +83,7 @@ class DigitalBitbox_Client(HardwareClientBase): self.password = None self.isInitialized = False self.setupRunning = False - self.usbReportSize = 64 # firmware > v2.0.0 + self.usbReportSize = 64 # firmware > v2.0.0 def device_model_name(self) -> Optional[str]: return 'Digital BitBox' @@ -92,15 +97,12 @@ class DigitalBitbox_Client(HardwareClientBase): pass self.opened = False - def is_pairable(self): return True - def is_initialized(self): return self.dbb_has_password() - def is_paired(self): return self.password is not None @@ -142,11 +144,9 @@ class DigitalBitbox_Client(HardwareClientBase): return True return False - def stretch_key(self, key: bytes): return to_hexstr(hashlib.pbkdf2_hmac('sha512', key, b'Digital Bitbox', iterations = 20480)) - def backup_password_dialog(self): msg = _("Enter the password used when the backup was created:") while True: @@ -162,7 +162,6 @@ class DigitalBitbox_Client(HardwareClientBase): else: return password.encode('utf8') - def password_dialog(self, msg): while True: password = self.handler.get_passphrase(msg, False) @@ -178,7 +177,7 @@ class DigitalBitbox_Client(HardwareClientBase): self.password = password.encode('utf8') return True - def check_device_dialog(self): + def check_firmware_version(self): match = re.search(r'v([0-9])+\.[0-9]+\.[0-9]+', run_in_hwd_thread(self.dbb_hid.get_serial_number_string)) if match is None: @@ -186,6 +185,9 @@ class DigitalBitbox_Client(HardwareClientBase): major_version = int(match.group(1)) if major_version < MIN_MAJOR_VERSION: raise Exception("Please upgrade to the newest firmware using the BitBox Desktop app: https://shiftcrypto.ch/start") + + def check_device_dialog(self): + self.check_firmware_version() # Set password if fresh device if self.password is None and not self.dbb_has_password(): if not self.setupRunning: @@ -230,29 +232,23 @@ class DigitalBitbox_Client(HardwareClientBase): self.mobile_pairing_dialog() return self.isInitialized - def recover_or_erase_dialog(self): msg = _("The Digital Bitbox is already seeded. Choose an option:") + "\n" choices = [ (_("Create a wallet using the current seed")), - (_("Load a wallet from the micro SD card (the current seed is overwritten)")), (_("Erase the Digital Bitbox")) ] reply = self.handler.query_choice(msg, choices) if reply is None: - return # user cancelled - if reply == 2: + raise UserCancelled() + if reply == 1: self.dbb_erase() - elif reply == 1: - if not self.dbb_load_backup(): - return else: if self.hid_send_encrypt(b'{"device":"info"}')['device']['lock']: raise UserFacingException(_("Full 2FA enabled. This is not supported yet.")) # Use existing seed self.isInitialized = True - def seed_device_dialog(self): msg = _("Choose how to initialize your Digital Bitbox:") + "\n" choices = [ @@ -261,7 +257,7 @@ class DigitalBitbox_Client(HardwareClientBase): ] reply = self.handler.query_choice(msg, choices) if reply is None: - return # user cancelled + raise UserCancelled() if reply == 0: self.dbb_generate_wallet() else: @@ -301,7 +297,7 @@ class DigitalBitbox_Client(HardwareClientBase): ] reply = self.handler.query_choice(_('Mobile pairing options'), choices) if reply is None: - return # user cancelled + raise UserCancelled() if reply == 0: if self.plugin.is_mobile_paired(): @@ -321,7 +317,6 @@ class DigitalBitbox_Client(HardwareClientBase): if 'error' in reply: raise UserFacingException(reply['error']['message']) - def dbb_erase(self): self.handler.show_message(_("Are you sure you want to erase the Digital Bitbox?") + "\n\n" + _("To continue, touch the Digital Bitbox's light for 3 seconds.") + "\n\n" + @@ -329,11 +324,12 @@ class DigitalBitbox_Client(HardwareClientBase): hid_reply = self.hid_send_encrypt(b'{"reset":"__ERASE__"}') self.handler.finished() if 'error' in hid_reply: + if hid_reply['error'].get('code') in (600, 601): + raise OperationCancelled() raise UserFacingException(hid_reply['error']['message']) else: self.password = None - raise UserFacingException('Device erased') - + raise DeviceErased('Device erased') def dbb_load_backup(self, show_msg=True): backups = self.hid_send_encrypt(b'{"backup":"list"}') @@ -341,10 +337,10 @@ class DigitalBitbox_Client(HardwareClientBase): raise UserFacingException(backups['error']['message']) f = self.handler.query_choice(_("Choose a backup file:"), backups['backup']) if f is None: - return False # user cancelled + raise UserCancelled() key = self.backup_password_dialog() if key is None: - raise Exception('Canceled by user') + raise UserCancelled('No backup password provided') key = self.stretch_key(key) if show_msg: self.handler.show_message(_("Loading backup...") + "\n\n" + @@ -354,6 +350,8 @@ class DigitalBitbox_Client(HardwareClientBase): hid_reply = self.hid_send_encrypt(msg) self.handler.finished() if 'error' in hid_reply: + if hid_reply['error'].get('code') in (600, 601): + raise OperationCancelled() raise UserFacingException(hid_reply['error']['message']) return True @@ -459,11 +457,9 @@ class DigitalBitbox_KeyStore(Hardware_KeyStore): def give_error(self, message): raise Exception(message) - def decrypt_message(self, pubkey, message, password): raise RuntimeError(_('Encryption and decryption are currently not supported for {}').format(self.device)) - def sign_message(self, sequence, message, password, *, script_type=None): sig = None try: @@ -519,12 +515,10 @@ class DigitalBitbox_KeyStore(Hardware_KeyStore): else: raise Exception(_("Could not sign message")) - except BaseException as e: self.give_error(e) return sig - def sign_transaction(self, tx, password): if tx.is_complete(): return @@ -753,11 +747,9 @@ class DigitalBitboxPlugin(HW_PluginBase): } self.comserver_post_notification(verify_request_payload, handler=keystore.handler) - # new wizard - def wizard_entry_for_device(self, device_info: 'DeviceInfo', *, new_wallet=True) -> str: if new_wallet: - return 'dbitbox_start' if device_info.initialized else 'dbitbox_not_initialized' + return 'dbitbox_start' else: return 'dbitbox_unlock' @@ -772,7 +764,6 @@ class DigitalBitboxPlugin(HW_PluginBase): 'accept': wizard.maybe_master_pubkey, 'last': lambda d: wizard.is_single_password() and wizard.last_cosigner(d) }, - 'dbitbox_not_initialized': {}, 'dbitbox_unlock': { 'last': True }, diff --git a/electrum/plugins/digitalbitbox/qt.py b/electrum/plugins/digitalbitbox/qt.py index ed356e7e8..96f94d501 100644 --- a/electrum/plugins/digitalbitbox/qt.py +++ b/electrum/plugins/digitalbitbox/qt.py @@ -1,14 +1,20 @@ +import threading from functools import partial from typing import TYPE_CHECKING +from PyQt5.QtCore import pyqtSignal + from electrum.i18n import _ from electrum.plugin import hook from electrum.wallet import Standard_Wallet, Abstract_Wallet +from electrum.util import UserCancelled, UserFacingException + +from electrum.plugins.hw_wallet.qt import QtHandlerBase, QtPluginBase +from electrum.plugins.hw_wallet.plugin import only_hook_if_libraries_available, OperationCancelled -from ..hw_wallet.qt import QtHandlerBase, QtPluginBase -from ..hw_wallet.plugin import only_hook_if_libraries_available -from .digitalbitbox import DigitalBitboxPlugin -from electrum.gui.qt.wizard.wallet import WCScriptAndDerivation, WCHWXPub, WCHWUninitialized, WCHWUnlock +from electrum.gui.qt.wizard.wallet import WCScriptAndDerivation, WCHWXPub, WCHWUnlock + +from .digitalbitbox import DigitalBitboxPlugin, DeviceErased if TYPE_CHECKING: from electrum.gui.qt.wizard.wallet import QENewWalletWizard @@ -38,6 +44,7 @@ class Plugin(DigitalBitboxPlugin, QtPluginBase): addr = addrs[0] if wallet.get_txin_type(addr) != 'p2pkh': return + def show_address(): keystore.thread.add(partial(self.show_address, wallet, addr, keystore)) @@ -51,15 +58,60 @@ class Plugin(DigitalBitboxPlugin, QtPluginBase): def extend_wizard(self, wizard: 'QENewWalletWizard'): super().extend_wizard(wizard) views = { - 'dbitbox_start': {'gui': WCScriptAndDerivation}, + 'dbitbox_start': {'gui': WCDigitalBitboxScriptAndDerivation}, 'dbitbox_xpub': {'gui': WCHWXPub}, - 'dbitbox_not_initialized': {'gui': WCHWUninitialized}, 'dbitbox_unlock': {'gui': WCHWUnlock} } wizard.navmap_merge(views) class DigitalBitbox_Handler(QtHandlerBase): - def __init__(self, win): super(DigitalBitbox_Handler, self).__init__(win, 'Digital Bitbox') + + +class WCDigitalBitboxScriptAndDerivation(WCScriptAndDerivation): + requestRecheck = pyqtSignal() + + def __init__(self, parent, wizard): + WCScriptAndDerivation.__init__(self, parent, wizard) + self._busy = True + self.title = '' + self.client = None + + self.requestRecheck.connect(self.check_device) + + def on_ready(self): + super().on_ready() + _name, _info = self.wizard_data['hardware_device'] + plugin = self.wizard.plugins.get_plugin(_info.plugin_name) + + device_id = _info.device.id_ + self.client = self.wizard.plugins.device_manager.client_by_id(device_id, scan_now=False) + if not self.client.handler: + self.client.handler = plugin.create_handler(self.wizard) + self.client.setupRunning = True + self.check_device() + + def check_device(self): + self.error = None + self.busy = True + + def check_task(): + try: + self.client.check_device_dialog() + self.title = _('Script type and Derivation path') + self.valid = True + except (UserCancelled, OperationCancelled): + self.error = _('Cancelled') + self.wizard.requestPrev.emit() + except DeviceErased: + self.error = _('Device erased') + self.requestRecheck.emit() + except UserFacingException as e: + self.error = str(e) + finally: + self.busy = False + + t = threading.Thread(target=check_task, daemon=True) + t.start() diff --git a/electrum/plugins/hw_wallet/plugin.py b/electrum/plugins/hw_wallet/plugin.py index be206e6bc..e1b522c56 100644 --- a/electrum/plugins/hw_wallet/plugin.py +++ b/electrum/plugins/hw_wallet/plugin.py @@ -361,3 +361,7 @@ class OutdatedHwFirmwareException(UserFacingException): return str(self) + "\n\n" + suffix else: return suffix + + +class OperationCancelled(UserFacingException): + pass