diff --git a/electrum/gui/qt/password_dialog.py b/electrum/gui/qt/password_dialog.py index 017a348e5..fd4d4849b 100644 --- a/electrum/gui/qt/password_dialog.py +++ b/electrum/gui/qt/password_dialog.py @@ -136,6 +136,7 @@ class PasswordLayout(object): and not force_disable_encrypt_cb) self.new_pw.textChanged.connect(enable_OK) self.conf_pw.textChanged.connect(enable_OK) + enable_OK() self.vbox = vbox diff --git a/electrum/gui/qt/wizard/wallet.py b/electrum/gui/qt/wizard/wallet.py index c1bba473e..ccc241dd2 100644 --- a/electrum/gui/qt/wizard/wallet.py +++ b/electrum/gui/qt/wizard/wallet.py @@ -1,7 +1,10 @@ import os +import sys +import threading + from typing import TYPE_CHECKING -from PyQt5.QtCore import Qt, QTimer, QRect +from PyQt5.QtCore import Qt, QTimer, QRect, pyqtSignal from PyQt5.QtGui import QPen, QPainter, QPalette from PyQt5.QtWidgets import (QApplication, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QWidget, QFileDialog, QSlider, QGridLayout) @@ -10,16 +13,16 @@ from electrum.bip32 import is_bip32_derivation, BIP32Node, normalize_bip32_deriv from electrum.daemon import Daemon from electrum.i18n import _ from electrum.keystore import bip44_derivation, bip39_to_seed, purpose48_derivation -from electrum.plugin import run_hook +from electrum.plugin import run_hook, HardwarePluginLibraryUnavailable from electrum.storage import StorageReadWriteError from electrum.util import WalletFileException, get_new_wallet_name from electrum.wallet import wallet_types from .wizard import QEAbstractWizard, WizardComponent -from electrum.logging import get_logger +from electrum.logging import get_logger, Logger from electrum import WalletStorage, mnemonic, keystore from electrum.wizard import NewWalletWizard from ..bip39_recovery_dialog import Bip39RecoveryDialog -from ..password_dialog import PasswordLayout, PW_NEW, MSG_ENTER_PASSWORD +from ..password_dialog import PasswordLayout, PW_NEW, MSG_ENTER_PASSWORD, PasswordLayoutForHW from ..seed_dialog import SeedLayout, MSG_PASSPHRASE_WARN_ISSUE4566, KeysLayout from ..util import ChoicesLayout, PasswordLineEdit, char_width_in_lineedit, WWLabel, InfoButton, font_height @@ -35,12 +38,17 @@ WIF_HELP_TEXT = (_('WIF keys are typed in Electrum, based on script type.') + '\ 'p2wpkh-p2sh:KxZcY47uGp9a... \t-> 3NhNeZQXF...\n' + 'p2wpkh:KxZcY47uGp9a... \t-> bc1q3fjfk...') +MSG_HW_STORAGE_ENCRYPTION = _("Set wallet file encryption.") + '\n'\ + + _("Your wallet file does not contain secrets, mostly just metadata. ") \ + + _("It also contains your master public key that allows watching your addresses.") + '\n\n'\ + + _("Note: If you enable this setting, you will need your hardware device to open your wallet.") + class QENewWalletWizard(NewWalletWizard, QEAbstractWizard): _logger = get_logger(__name__) def __init__(self, config: 'SimpleConfig', app: 'QElectrumApplication', plugins: 'Plugins', daemon: Daemon, path, parent=None): - NewWalletWizard.__init__(self, daemon) + NewWalletWizard.__init__(self, daemon, plugins) QEAbstractWizard.__init__(self, config, app, plugins, daemon) self._daemon = daemon # TODO: dedupe self._path = path @@ -53,15 +61,18 @@ class QENewWalletWizard(NewWalletWizard, QEAbstractWizard): 'create_seed': { 'gui': WCCreateSeed }, 'confirm_seed': { 'gui': WCConfirmSeed }, 'have_seed': { 'gui': WCHaveSeed }, + 'choose_hardware_device': { 'gui': WCChooseHWDevice }, 'script_and_derivation': { 'gui': WCScriptAndDerivation}, 'have_master_key': { 'gui': WCHaveMasterKey }, 'multisig': { 'gui': WCMultisig }, 'multisig_cosigner_keystore': { 'gui': WCCosignerKeystore }, 'multisig_cosigner_key': { 'gui': WCHaveMasterKey }, 'multisig_cosigner_seed': { 'gui': WCHaveSeed }, + 'multisig_cosigner_hardware': { 'gui': WCChooseHWDevice }, 'multisig_cosigner_script_and_derivation': { 'gui': WCScriptAndDerivation}, 'imported': { 'gui': WCImport }, - 'wallet_password': { 'gui': WCWalletPassword } + 'wallet_password': { 'gui': WCWalletPassword }, + 'wallet_password_hardware': { 'gui': WCWalletPasswordHardware } }) # modify default flow, insert seed extension entry/confirm as separate views @@ -101,7 +112,7 @@ class QENewWalletWizard(NewWalletWizard, QEAbstractWizard): 'next': self.on_have_cosigner_seed, 'last': lambda d: self.is_single_password() and self.last_cosigner(d) and not self.needs_derivation_path(d), 'gui': WCEnterExt - } + }, }) run_hook('init_wallet_wizard', self) @@ -565,7 +576,7 @@ class WCScriptAndDerivation(WizardComponent): ('p2wpkh', 'native segwit (p2wpkh)', bip44_derivation(0, bip43_purpose=84)), ] - if self.wizard_data['wallet_type'] == 'standard': + if self.wizard_data['wallet_type'] == 'standard' and not self.wizard_data['keystore_type'] == 'hardware': button = QPushButton(_("Detect Existing Accounts")) passphrase = self.wizard_data['seed_extra_words'] if self.wizard_data['seed_extend'] else '' @@ -619,7 +630,12 @@ class WCScriptAndDerivation(WizardComponent): cosigner_data = self._current_cosigner(self.wizard_data) derivation_valid = is_bip32_derivation(cosigner_data['derivation_path']) - if derivation_valid and self.wizard_data['wallet_type'] == 'multisig': + # TODO: refactor to wizard.current_cosigner_is_hardware + cosigner_is_hardware = cosigner_data == self.wizard_data and self.wizard_data['keystore_type'] == 'hardware' + if 'cosigner_keystore_type' in self.wizard_data and self.wizard_data['cosigner_keystore_type'] == 'hardware': + cosigner_is_hardware = True + + if self.wizard.is_multisig(self.wizard_data) and derivation_valid and not cosigner_is_hardware: if self.wizard.has_duplicate_masterkeys(self.wizard_data): self._logger.debug('Duplicate master keys!') # TODO: user feedback @@ -645,7 +661,7 @@ class WCCosignerKeystore(WizardComponent): choices = [ ('key', _('Enter cosigner key')), ('seed', _('Enter cosigner seed')), - ('hw_device', _('Cosign with hardware device')) + ('hardware', _('Cosign with hardware device')) ] self.c_values = [x[0] for x in choices] @@ -929,3 +945,184 @@ class CosignWidget(QWidget): qp.setBrush(Qt.green if i < self.m else Qt.gray) qp.drawPie(self.R, alpha, alpha2) qp.end() + + +class WCChooseHWDevice(WizardComponent, Logger): + scanFailed = pyqtSignal([str, str], arguments=['code', 'message']) + scanComplete = pyqtSignal() + + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Choose Hardware Device')) + Logger.__init__(self) + self.scanFailed.connect(self.on_scan_failed) + self.scanComplete.connect(self.on_scan_complete) + self.plugins = wizard.plugins + + self.error_l = WWLabel() + self.error_l.setVisible(False) + + self.device_list = QWidget() + self.device_list_layout = QVBoxLayout() + self.device_list.setLayout(self.device_list_layout) + self.clayout = 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) + self.device_list.setVisible(False) + + self.valid = False + + def on_scan_complete(self): + self.error_l.setVisible(False) + self.device_list.setVisible(True) + + choices = [] + for name, info in self.devices: + state = _("initialized") if info.initialized else _("wiped") + label = info.label or _("An unnamed {}").format(name) + try: + transport_str = info.device.transport_ui_string[:20] + except Exception: + transport_str = 'unknown transport' + descr = f"{label} [{info.model_name or name}, {state}, {transport_str}]" + choices.append(((name, info), descr)) + msg = _('Select a device') + ':' + self.c_values = [x[0] for x in choices] + c_titles = [x[1] for x in choices] + # remove old component before adding anew + a = self.device_list.layout().itemAt(0) + self.device_list.layout().removeItem(a) + + self.clayout = ChoicesLayout(msg, c_titles) + self.device_list_layout = self.clayout.layout() + self.device_list.layout().addLayout(self.device_list_layout) + + self.valid = True + + def failed_getting_device_infos(self, debug_msg, name, e): + # nonlocal debug_msg + err_str_oneline = ' // '.join(str(e).splitlines()) + self.logger.warning(f'error getting device infos for {name}: {err_str_oneline}') + indented_error_msg = ' '.join([''] + str(e).splitlines(keepends=True)) + debug_msg += f' {name}: (error getting device infos)\n{indented_error_msg}\n' + + def scan_devices(self): + self.valid = False + self.busy_msg = _('Scanning devices...') + self.busy = True + + def scan_task(): + # check available plugins + supported_plugins = self.plugins.get_hardware_support() + devices = [] # type: List[Tuple[str, DeviceInfo]] + devmgr = self.plugins.device_manager + debug_msg = '' + + # scan devices + try: + # scanned_devices = self.run_task_without_blocking_gui(task=devmgr.scan_devices, + # msg=_("Scanning devices...")) + scanned_devices = devmgr.scan_devices() + except BaseException as e: + self.logger.info('error scanning devices: {}'.format(repr(e))) + debug_msg = ' {}:\n {}'.format(_('Error scanning devices'), e) + else: + for splugin in supported_plugins: + name, plugin = splugin.name, splugin.plugin + # plugin init errored? + if not plugin: + e = splugin.exception + indented_error_msg = ' '.join([''] + str(e).splitlines(keepends=True)) + debug_msg += f' {name}: (error during plugin init)\n' + debug_msg += ' {}\n'.format(_('You might have an incompatible library.')) + debug_msg += f'{indented_error_msg}\n' + continue + # see if plugin recognizes 'scanned_devices' + try: + # FIXME: side-effect: this sets client.handler + device_infos = devmgr.list_pairable_device_infos( + handler=None, plugin=plugin, devices=scanned_devices, include_failing_clients=True) + except HardwarePluginLibraryUnavailable as e: + self.failed_getting_device_infos(debug_msg, name, e) + continue + except BaseException as e: + self.logger.exception('') + self.failed_getting_device_infos(debug_msg, name, e) + continue + device_infos_failing = list(filter(lambda di: di.exception is not None, device_infos)) + for di in device_infos_failing: + self.failed_getting_device_infos(debug_msg, name, di.exception) + device_infos_working = list(filter(lambda di: di.exception is None, device_infos)) + devices += list(map(lambda x: (name, x), device_infos_working)) + 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') + 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' + msg += _('While this is less than ideal, it might help if you run Electrum as Administrator.') + '\n' + else: + msg += _('On Linux, you might have to add a new permission to your udev rules.') + '\n' + msg += '\n\n' + msg += _('Debug message') + '\n' + debug_msg + + self.scanFailed.emit('no_devices', msg) + self.busy = False + return + + # select device + self.devices = devices + self.scanComplete.emit() + self.busy = False + + t = threading.Thread(target=scan_task, daemon=True) + t.start() + + def apply(self): + if self.clayout: + # TODO: data is not (de)serializable yet, wizard_data cannot be persisted + self.wizard_data['hardware_device'] = self.c_values[self.clayout.selected_index()] + self.logger.debug(repr(self.wizard_data['hardware_device'])) + + +class WCWalletPasswordHardware(WizardComponent): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Password HW')) + self.plugins = wizard.plugins + + self.playout = PasswordLayoutForHW(MSG_HW_STORAGE_ENCRYPTION) + self.playout.encrypt_cb.setChecked(True) + self.layout().addLayout(self.playout.layout()) + self.layout().addStretch(1) + + self._valid = True + + def apply(self): + self.wizard_data['encrypt'] = self.playout.encrypt_cb.isChecked() + if self.playout.encrypt_cb.isChecked(): + _name, _info = self.wizard_data['hardware_device'] + device_id = _info.device.id_ + client = self.plugins.device_manager.client_by_id(device_id, scan_now=False) + # client.handler = self.plugin.create_handler(self.wizard) + self.wizard_data['password'] = client.get_password_for_storage_encryption() + + diff --git a/electrum/gui/qt/wizard/wizard.py b/electrum/gui/qt/wizard/wizard.py index 9af4171d0..67ad5340a 100644 --- a/electrum/gui/qt/wizard/wizard.py +++ b/electrum/gui/qt/wizard/wizard.py @@ -1,3 +1,4 @@ +import threading from abc import abstractmethod from typing import TYPE_CHECKING @@ -8,7 +9,7 @@ from PyQt5.QtWidgets import (QDialog, QPushButton, QWidget, QLabel, QVBoxLayout, from electrum.i18n import _ from electrum.logging import get_logger -from electrum.gui.qt.util import Buttons, icon_path +from electrum.gui.qt.util import Buttons, icon_path, MessageBoxMixin if TYPE_CHECKING: from electrum.simple_config import SimpleConfig @@ -17,7 +18,7 @@ if TYPE_CHECKING: from electrum.gui.qt import QElectrumApplication -class QEAbstractWizard(QDialog): +class QEAbstractWizard(QDialog, MessageBoxMixin): _logger = get_logger(__name__) # def __init__(self, config: 'SimpleConfig', app: QApplication, plugins: 'Plugins', *, gui_object: 'ElectrumGui'): @@ -25,8 +26,11 @@ class QEAbstractWizard(QDialog): QDialog.__init__(self, None) self.app = app self.config = config - self.plugins = plugins - # self.gui_thread = gui_object.gui_thread + # self.plugins = plugins + + # compat + self.gui_thread = threading.current_thread() + self.setMinimumSize(600, 400) self.title = QLabel() @@ -43,9 +47,9 @@ class QEAbstractWizard(QDialog): please_wait_layout = QVBoxLayout() please_wait_layout.addStretch(1) - please_wait_l = QLabel(_("Please wait...")) - please_wait_l.setAlignment(Qt.AlignCenter) - please_wait_layout.addWidget(please_wait_l) + self.please_wait_l = QLabel(_("Please wait...")) + self.please_wait_l.setAlignment(Qt.AlignCenter) + please_wait_layout.addWidget(self.please_wait_l) please_wait_layout.addStretch(1) self.please_wait = QWidget() self.please_wait.setLayout(please_wait_layout) @@ -156,6 +160,7 @@ class QEAbstractWizard(QDialog): self.next_button.setEnabled(page.valid) self.main_widget.setVisible(not page.busy and not bool(page.error)) self.please_wait.setVisible(page.busy) + self.please_wait_l.setText(page.busy_msg if page.busy_msg else _("Please wait...")) self.error_msg.setText(str(page.error)) self.error.setVisible(not page.busy and bool(page.error)) icon = page.params.get('icon', icon_path('electrum.png')) @@ -213,6 +218,7 @@ class WizardComponent(QWidget): self.setLayout(layout if layout else QVBoxLayout(self)) self.wizard_data = {} self.title = title if title is not None else 'No title' + self.busy_msg = '' self.wizard = wizard self._error = '' self._valid = False @@ -262,6 +268,7 @@ class WizardComponent(QWidget): self.updated.emit(self) # returns (sub)dict of current cosigner (or root if first) + # TODO: maybe just always expose self.cosigner_data in wizardcomponent so we can avoid this call def _current_cosigner(self, wizard_data): wdata = wizard_data if wizard_data['wallet_type'] == 'multisig' and 'multisig_current_cosigner' in wizard_data: diff --git a/electrum/plugins/trezor/qt.py b/electrum/plugins/trezor/qt.py index a555dc626..0699578b2 100644 --- a/electrum/plugins/trezor/qt.py +++ b/electrum/plugins/trezor/qt.py @@ -8,7 +8,7 @@ from PyQt5.QtWidgets import (QVBoxLayout, QLabel, QGridLayout, QPushButton, QMessageBox, QFileDialog, QSlider, QTabWidget) from electrum.gui.qt.util import (WindowModalDialog, WWLabel, Buttons, CancelButton, - OkButton, CloseButton, PasswordLineEdit, getOpenFileName) + OkButton, CloseButton, PasswordLineEdit, getOpenFileName, ChoicesLayout) from electrum.i18n import _ from electrum.plugin import hook @@ -16,7 +16,9 @@ from ..hw_wallet.qt import QtHandlerBase, QtPluginBase from ..hw_wallet.plugin import only_hook_if_libraries_available from .trezor import (TrezorPlugin, TIM_NEW, TIM_RECOVER, TrezorInitSettings, PASSPHRASE_ON_DEVICE, Capability, BackupType, RecoveryDeviceType) - +from ...gui.qt.wizard.wallet import WCScriptAndDerivation +from ...gui.qt.wizard.wizard import WizardComponent +from ...logging import Logger PASSPHRASE_HELP_SHORT =_( "Passphrases allow you to access new wallets, each " @@ -255,10 +257,25 @@ class QtPlugin(QtPluginBase): keystore.thread.add(connect, on_success=show_dialog) def request_trezor_init_settings(self, wizard, method, device_id): - vbox = QVBoxLayout() - next_enabled = True + vbox = InitSettingsLayout(self.device_manager(), method, device_id) + + wizard.exec_layout(vbox) + + return TrezorInitSettings( + word_count=vbox.bg_numwords.checkedId(), + label=vbox.name.text(), + pin_enabled=vbox.cb_pin.isChecked(), + passphrase_enabled=vbox.cb_phrase.isChecked(), + recovery_type=vbox.bg_rectype.checkedId() if vbox.bg_rectype else None, + backup_type=vbox.bg_backuptype.checkedId(), + no_backup=vbox.cb_no_backup.isChecked() if vbox.cb_no_backup else False, + ) + + +class InitSettingsLayout(QVBoxLayout): + def __init__(self, devmgr, method, device_id) -> QVBoxLayout: + super().__init__() - devmgr = self.device_manager() client = devmgr.client_by_id(device_id) if not client: raise Exception(_("The device was disconnected.")) @@ -269,40 +286,40 @@ class QtPlugin(QtPluginBase): # label label = QLabel(_("Enter a label to name your device:")) - name = QLineEdit() + self.name = QLineEdit() hl = QHBoxLayout() hl.addWidget(label) - hl.addWidget(name) + hl.addWidget(self.name) hl.addStretch(1) - vbox.addLayout(hl) + self.addLayout(hl) # Backup type gb_backuptype = QGroupBox() hbox_backuptype = QHBoxLayout() gb_backuptype.setLayout(hbox_backuptype) - vbox.addWidget(gb_backuptype) + self.addWidget(gb_backuptype) gb_backuptype.setTitle(_('Select backup type:')) - bg_backuptype = QButtonGroup() + self.bg_backuptype = QButtonGroup() rb_single = QRadioButton(gb_backuptype) rb_single.setText(_('Single seed (BIP39)')) - bg_backuptype.addButton(rb_single) - bg_backuptype.setId(rb_single, BackupType.Bip39) + self.bg_backuptype.addButton(rb_single) + self.bg_backuptype.setId(rb_single, BackupType.Bip39) hbox_backuptype.addWidget(rb_single) rb_single.setChecked(True) rb_shamir = QRadioButton(gb_backuptype) rb_shamir.setText(_('Shamir')) - bg_backuptype.addButton(rb_shamir) - bg_backuptype.setId(rb_shamir, BackupType.Slip39_Basic) + self.bg_backuptype.addButton(rb_shamir) + self.bg_backuptype.setId(rb_shamir, BackupType.Slip39_Basic) hbox_backuptype.addWidget(rb_shamir) rb_shamir.setEnabled(Capability.Shamir in capabilities) rb_shamir.setVisible(False) # visible with "expert settings" rb_shamir_groups = QRadioButton(gb_backuptype) rb_shamir_groups.setText(_('Super Shamir')) - bg_backuptype.addButton(rb_shamir_groups) - bg_backuptype.setId(rb_shamir_groups, BackupType.Slip39_Advanced) + self.bg_backuptype.addButton(rb_shamir_groups) + self.bg_backuptype.setId(rb_shamir_groups, BackupType.Slip39_Advanced) hbox_backuptype.addWidget(rb_shamir_groups) rb_shamir_groups.setEnabled(Capability.ShamirGroups in capabilities) rb_shamir_groups.setVisible(False) # visible with "expert settings" @@ -313,15 +330,15 @@ class QtPlugin(QtPluginBase): gb_numwords = QGroupBox() hbox1 = QHBoxLayout() gb_numwords.setLayout(hbox1) - vbox.addWidget(gb_numwords) + self.addWidget(gb_numwords) gb_numwords.setTitle(_("Select seed/share length:")) - bg_numwords = QButtonGroup() + self.bg_numwords = QButtonGroup() for count in (12, 18, 20, 24, 33): rb = QRadioButton(gb_numwords) word_count_buttons[count] = rb rb.setText(_("{:d} words").format(count)) - bg_numwords.addButton(rb) - bg_numwords.setId(rb, count) + self.bg_numwords.addButton(rb) + self.bg_numwords.setId(rb, count) hbox1.addWidget(rb) rb.setChecked(True) @@ -348,7 +365,7 @@ class QtPlugin(QtPluginBase): for c, btn in word_count_buttons.items(): btn.setVisible(c in valid_word_counts) - bg_backuptype.buttonClicked.connect(configure_word_counts) + self.bg_backuptype.buttonClicked.connect(configure_word_counts) configure_word_counts() # set up conditional visibility: @@ -359,10 +376,10 @@ class QtPlugin(QtPluginBase): gb_numwords.setVisible(False) # PIN - cb_pin = QCheckBox(_('Enable PIN protection')) - cb_pin.setChecked(True) - vbox.addWidget(WWLabel(RECOMMEND_PIN)) - vbox.addWidget(cb_pin) + self.cb_pin = QCheckBox(_('Enable PIN protection')) + self.cb_pin.setChecked(True) + self.addWidget(WWLabel(RECOMMEND_PIN)) + self.addWidget(self.cb_pin) # "expert settings" button expert_vbox = QVBoxLayout() @@ -376,66 +393,55 @@ class QtPlugin(QtPluginBase): rb_shamir.setVisible(True) rb_shamir_groups.setVisible(True) expert_button.clicked.connect(show_expert_settings) - vbox.addWidget(expert_button) + self.addWidget(expert_button) # passphrase 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) + self.cb_phrase = QCheckBox(_('Enable passphrases')) + self.cb_phrase.setChecked(False) expert_vbox.addWidget(passphrase_msg) expert_vbox.addWidget(passphrase_warning) - expert_vbox.addWidget(cb_phrase) + expert_vbox.addWidget(self.cb_phrase) # ask for recovery type (random word order OR matrix) - bg_rectype = None + self.bg_rectype = None if method == TIM_RECOVER and model == '1': gb_rectype = QGroupBox() hbox_rectype = QHBoxLayout() gb_rectype.setLayout(hbox_rectype) expert_vbox.addWidget(gb_rectype) gb_rectype.setTitle(_("Select recovery type:")) - bg_rectype = QButtonGroup() + self.bg_rectype = QButtonGroup() rb1 = QRadioButton(gb_rectype) rb1.setText(_('Scrambled words')) - bg_rectype.addButton(rb1) - bg_rectype.setId(rb1, RecoveryDeviceType.ScrambledWords) + self.bg_rectype.addButton(rb1) + self.bg_rectype.setId(rb1, RecoveryDeviceType.ScrambledWords) hbox_rectype.addWidget(rb1) rb1.setChecked(True) rb2 = QRadioButton(gb_rectype) rb2.setText(_('Matrix')) - bg_rectype.addButton(rb2) - bg_rectype.setId(rb2, RecoveryDeviceType.Matrix) + self.bg_rectype.addButton(rb2) + self.bg_rectype.setId(rb2, RecoveryDeviceType.Matrix) hbox_rectype.addWidget(rb2) # no backup - cb_no_backup = None + self.cb_no_backup = None if method == TIM_NEW: - cb_no_backup = QCheckBox(f'''{_('Enable seedless mode')}''') - cb_no_backup.setChecked(False) + self.cb_no_backup = QCheckBox(f'''{_('Enable seedless mode')}''') + self.cb_no_backup.setChecked(False) if (model == '1' and fw_version >= (1, 7, 1) or model == 'T' and fw_version >= (2, 0, 9)): - cb_no_backup.setToolTip(SEEDLESS_MODE_WARNING) + self.cb_no_backup.setToolTip(SEEDLESS_MODE_WARNING) else: - cb_no_backup.setEnabled(False) - cb_no_backup.setToolTip(_('Firmware version too old.')) - expert_vbox.addWidget(cb_no_backup) + self.cb_no_backup.setEnabled(False) + self.cb_no_backup.setToolTip(_('Firmware version too old.')) + expert_vbox.addWidget(self.cb_no_backup) - vbox.addWidget(expert_widget) - wizard.exec_layout(vbox, next_enabled=next_enabled) - - return TrezorInitSettings( - word_count=bg_numwords.checkedId(), - label=name.text(), - pin_enabled=cb_pin.isChecked(), - passphrase_enabled=cb_phrase.isChecked(), - recovery_type=bg_rectype.checkedId() if bg_rectype else None, - backup_type=bg_backuptype.checkedId(), - no_backup=cb_no_backup.isChecked() if cb_no_backup else False, - ) + self.addWidget(expert_widget) class Plugin(TrezorPlugin, QtPlugin): @@ -450,6 +456,22 @@ class Plugin(TrezorPlugin, QtPlugin): from trezorlib.qt.pinmatrix import PinMatrixWidget return PinMatrixWidget + @hook + def init_wallet_wizard(self, wizard: 'QEWalletWizard'): + self.extend_wizard(wizard) + + # insert trezor pages in new wallet wizard + def extend_wizard(self, wizard: 'NewWalletWizard'): + super().extend_wizard(wizard) + views = { + 'trezor_start': { 'gui': WCScriptAndDerivation }, + 'trezor_xpub': { 'gui': WCTrezorXPub }, + 'trezor_not_initialized': { 'gui': WCTrezorInitMethod }, + 'trezor_choose_new_recover': { 'gui': WCTrezorInitParams }, + 'trezor_do_init': { 'gui': WCTrezorInit }, + } + wizard.navmap_merge(views) + class SettingsDialog(WindowModalDialog): '''This dialog doesn't require a device be paired with a wallet. @@ -767,3 +789,151 @@ class SettingsDialog(WindowModalDialog): # Update information invoke_client(None) + + +class WCTrezorXPub(WizardComponent, Logger): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Hardware wallet information')) + Logger.__init__(self) + self.plugins = wizard.plugins + self.plugin = self.plugins.get_plugin('trezor') + self._busy = True + self.xpub = None + + self.ok_l = WWLabel('Retrieved Hardware Information') + self.ok_l.setAlignment(Qt.AlignCenter) + self.layout().addWidget(self.ok_l) + + def on_ready(self): + _name, _info = self.wizard_data['hardware_device'] + device_id = _info.device.id_ + client = self.plugins.device_manager.client_by_id(device_id, scan_now=False) + client.handler = self.plugin.create_handler(self.wizard) + + cosigner = self._current_cosigner(self.wizard_data) + xtype = cosigner['script_type'] + derivation = cosigner['derivation_path'] + + def get_xpub_task(client, derivation, xtype): + try: + self.xpub = client.get_xpub(derivation, xtype) + self.root_fingerprint = client.request_root_fingerprint_from_device() + self.label = client.label() + self.soft_device_id = client.get_soft_device_id() + except Exception as e: + # TODO: handle user interaction exceptions (e.g. invalid pin) more gracefully + self.error = repr(e) + self.logger.error(repr(e)) + self.xpub_done() + + t = threading.Thread(target=get_xpub_task, args=(client, derivation, xtype), daemon=True) + t.start() + + def xpub_done(self): + self.logger.debug(f'Done retrieve xpub: {self.xpub}') + self.busy = False + self.validate() + + def validate(self): + if self.xpub and not self.error: + self.valid = True + else: + self.valid = False + + def apply(self): + if self.valid: + cosigner_data = self._current_cosigner(self.wizard_data) + cosigner_data['hw_type'] = 'trezor' + cosigner_data['master_key'] = self.xpub + cosigner_data['root_fingerprint'] = self.root_fingerprint + cosigner_data['label'] = self.label + cosigner_data['soft_device_id'] = self.soft_device_id + + +class WCTrezorInitMethod(WizardComponent, Logger): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('HW Setup')) + Logger.__init__(self) + + def on_ready(self): + _name, _info = self.wizard_data['hardware_device'] + message = _('Choose how you want to initialize your {}.').format(_info.model_name) + 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")), + ] + self.c_values = [x[0] for x in choices] + c_titles = [x[1] for x in choices] + self.clayout = ChoicesLayout(message, c_titles) + self.layout().addLayout(self.clayout.layout()) + self.layout().addStretch(1) + + self._valid = True + + def apply(self): + self.wizard_data['trezor_init'] = self.c_values[self.clayout.selected_index()] + + +class WCTrezorInitParams(WizardComponent): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Set-up trezor')) + self.plugins = wizard.plugins + self._busy = True + + def on_ready(self): + _name, _info = self.wizard_data['hardware_device'] + self.settings_layout = InitSettingsLayout(self.plugins.device_manager, self.wizard_data['trezor_init'], _info.device.id_) + self.layout().addLayout(self.settings_layout) + self.layout().addStretch(1) + + self.valid = True + self.busy = False + + def apply(self): + vbox = self.settings_layout + trezor_settings = TrezorInitSettings( + word_count=vbox.bg_numwords.checkedId(), + label=vbox.name.text(), + pin_enabled=vbox.cb_pin.isChecked(), + passphrase_enabled=vbox.cb_phrase.isChecked(), + recovery_type=vbox.bg_rectype.checkedId() if vbox.bg_rectype else None, + backup_type=vbox.bg_backuptype.checkedId(), + no_backup=vbox.cb_no_backup.isChecked() if vbox.cb_no_backup else False, + ) + self.wizard_data['trezor_settings'] = trezor_settings + + +class WCTrezorInit(WizardComponent, Logger): + def __init__(self, parent, wizard): + WizardComponent.__init__(self, parent, wizard, title=_('Set-up trezor')) + Logger.__init__(self) + self.plugins = wizard.plugins + self.plugin = self.plugins.get_plugin('trezor') + self._busy = True + + def on_ready(self): + settings = self.wizard_data['trezor_settings'] + method = self.wizard_data['trezor_init'] + _name, _info = self.wizard_data['hardware_device'] + device_id = _info.device.id_ + client = self.plugins.device_manager.client_by_id(device_id, scan_now=False) + client.handler = self.plugin.create_handler(self.wizard) + + def initialize_device_task(settings, method, device_id, wizard, handler): + self.plugin._initialize_device(settings, method, device_id, wizard, handler) + self.init_done() + + t = threading.Thread( + target=initialize_device_task, + args=(settings, method, device_id, None, client.handler), + daemon=True) + t.start() + + def init_done(self): + self.logger.info('Done initialize device') + self.busy = False + self.layout().addWidget(WWLabel('Done')) + + def apply(self): + pass diff --git a/electrum/plugins/trezor/trezor.py b/electrum/plugins/trezor/trezor.py index 6184d8601..5529de48d 100644 --- a/electrum/plugins/trezor/trezor.py +++ b/electrum/plugins/trezor/trezor.py @@ -524,3 +524,30 @@ class TrezorPlugin(HW_PluginBase): for o in tx.outputs() ] return t + + # new wizard + + def wizard_entry_for_device(self, device_info: 'DeviceInfo') -> str: + return 'trezor_not_initialized' if not device_info.initialized else 'trezor_start' + + # insert trezor pages in new wallet wizard + def extend_wizard(self, wizard: 'NewWalletWizard'): + views = { + 'trezor_start': { + 'next': 'trezor_xpub', + }, + 'trezor_xpub': { + 'next': lambda d: wizard.wallet_password_view(d) if wizard.last_cosigner(d) else 'multisig_cosigner_keystore', + 'last': lambda d: wizard.is_single_password() and wizard.last_cosigner(d) + }, + 'trezor_not_initialized': { + 'next': 'trezor_choose_new_recover', + }, + 'trezor_choose_new_recover': { + 'next': 'trezor_do_init', + }, + 'trezor_do_init': { + 'next': 'trezor_start', + }, + } + wizard.navmap_merge(views) diff --git a/electrum/wizard.py b/electrum/wizard.py index 22a037401..ed1a17457 100644 --- a/electrum/wizard.py +++ b/electrum/wizard.py @@ -3,7 +3,9 @@ import os from typing import List, NamedTuple, Any, Dict, Optional +from electrum.keystore import hardware_keystore from electrum.logging import get_logger +from electrum.plugin import run_hook from electrum.slip39 import EncryptedSeed from electrum.storage import WalletStorage, StorageEncryptionVersion from electrum.wallet_db import WalletDB @@ -74,8 +76,6 @@ class AbstractWizard: if 'next' not in nav: # finished is_finished = True - # self.finished(wizard_data) - # return WizardViewState(None, wizard_data, {}) new_view = WizardViewState(None, wizard_data, {}) else: view_next = nav['next'] @@ -185,7 +185,7 @@ class NewWalletWizard(AbstractWizard): _logger = get_logger(__name__) - def __init__(self, daemon): + def __init__(self, daemon, plugins): AbstractWizard.__init__(self) self.navmap = { 'wallet_name': { @@ -211,13 +211,16 @@ class NewWalletWizard(AbstractWizard): 'last': lambda d: self.is_single_password() and not (self.needs_derivation_path(d) or self.is_multisig(d)) }, + 'choose_hardware_device': { + 'next': self.on_hardware_device, + }, 'script_and_derivation': { - 'next': lambda d: 'wallet_password' if not self.is_multisig(d) else 'multisig_cosigner_keystore', + 'next': lambda d: self.wallet_password_view(d) if not self.is_multisig(d) else 'multisig_cosigner_keystore', 'accept': self.maybe_master_pubkey, 'last': lambda d: self.is_single_password() and not self.is_multisig(d) }, 'have_master_key': { - 'next': lambda d: 'wallet_password' if not self.is_multisig(d) else 'multisig_cosigner_keystore', + 'next': lambda d: self.wallet_password_view(d) if not self.is_multisig(d) else 'multisig_cosigner_keystore', 'accept': self.maybe_master_pubkey, 'last': lambda d: self.is_single_password() and not self.is_multisig(d) }, @@ -228,15 +231,18 @@ class NewWalletWizard(AbstractWizard): 'next': self.on_cosigner_keystore_type }, 'multisig_cosigner_key': { - 'next': lambda d: 'wallet_password' if self.last_cosigner(d) else 'multisig_cosigner_keystore', + 'next': lambda d: self.wallet_password_view(d) if self.last_cosigner(d) else 'multisig_cosigner_keystore', 'last': lambda d: self.is_single_password() and self.last_cosigner(d) }, 'multisig_cosigner_seed': { 'next': self.on_have_cosigner_seed, 'last': lambda d: self.is_single_password() and self.last_cosigner(d) and not self.needs_derivation_path(d) }, + 'multisig_cosigner_hardware': { + 'next': self.on_hardware_device, + }, 'multisig_cosigner_script_and_derivation': { - 'next': lambda d: 'wallet_password' if self.last_cosigner(d) else 'multisig_cosigner_keystore', + 'next': lambda d: self.wallet_password_view(d) if self.last_cosigner(d) else 'multisig_cosigner_keystore', 'last': lambda d: self.is_single_password() and self.last_cosigner(d) }, 'imported': { @@ -245,9 +251,13 @@ class NewWalletWizard(AbstractWizard): }, 'wallet_password': { 'last': True + }, + 'wallet_password_hardware': { + 'last': True } } self._daemon = daemon + self.plugins = plugins def start(self, initial_data=None): if initial_data is None: @@ -292,9 +302,22 @@ class NewWalletWizard(AbstractWizard): return { 'createseed': 'create_seed', 'haveseed': 'have_seed', - 'masterkey': 'have_master_key' + 'masterkey': 'have_master_key', + 'hardware': 'choose_hardware_device' }.get(t) + def is_hardware(self, wizard_data): + return wizard_data['keystore_type'] == 'hardware' + + def wallet_password_view(self, wizard_data): + return 'wallet_password_hardware' if self.is_hardware(wizard_data) else 'wallet_password' + + def on_hardware_device(self, wizard_data): + _type, _info = wizard_data['hardware_device'] + run_hook('init_wallet_wizard', self) + plugin = self.plugins.get_plugin(_type) + return plugin.wizard_entry_for_device(_info) + def on_have_or_confirm_seed(self, wizard_data): if self.needs_derivation_path(wizard_data): return 'script_and_derivation' @@ -315,7 +338,8 @@ class NewWalletWizard(AbstractWizard): t = wizard_data['cosigner_keystore_type'] return { 'key': 'multisig_cosigner_key', - 'seed': 'multisig_cosigner_seed' + 'seed': 'multisig_cosigner_seed', + 'hardware': 'multisig_cosigner_hardware' }.get(t) def on_have_cosigner_seed(self, wizard_data): @@ -330,6 +354,9 @@ class NewWalletWizard(AbstractWizard): def last_cosigner(self, wizard_data): # check if we have the final number of cosigners. Doesn't check if cosigner data itself is complete # (should be validated by wizardcomponents) + if not self.is_multisig(wizard_data): + return True + if len(wizard_data['multisig_cosigner_data']) < (wizard_data['multisig_participants'] - 1): return False @@ -337,8 +364,7 @@ class NewWalletWizard(AbstractWizard): def has_duplicate_masterkeys(self, wizard_data) -> bool: """Multisig wallets need distinct master keys. If True, need to prevent wallet-creation.""" - xpubs = [] - xpubs.append(self.keystore_from_data(wizard_data['wallet_type'], wizard_data).get_master_public_key()) + xpubs = [self.keystore_from_data(wizard_data['wallet_type'], wizard_data).get_master_public_key()] for cosigner in wizard_data['multisig_cosigner_data']: data = wizard_data['multisig_cosigner_data'][cosigner] xpubs.append(self.keystore_from_data(wizard_data['wallet_type'], data).get_master_public_key()) @@ -492,13 +518,30 @@ class NewWalletWizard(AbstractWizard): pass else: raise Exception(f"unexpected keystore type: {type(keystore)}") + elif data['keystore_type'] == 'hardware': # TODO: prelim impl + k = self.hw_keystore(data) + if isinstance(k, keystore.Xpub): # has xpub + t1 = xpub_type(k.xpub) + if data['wallet_type'] == 'multisig': + if t1 not in ['standard', 'p2wsh', 'p2wsh-p2sh']: + raise Exception('wrong key type %s' % t1) + else: + if t1 not in ['standard', 'p2wpkh', 'p2wpkh-p2sh']: + raise Exception('wrong key type %s' % t1) + # elif isinstance(k, keystore.Old_KeyStore): + # pass + else: + raise Exception(f"unexpected keystore type: {type(keystore)}") else: raise Exception('unsupported/unknown keystore_type %s' % data['keystore_type']) if data['encrypt']: if k and k.may_have_password(): k.update_password(None, data['password']) - storage.set_password(data['password'], enc_version=StorageEncryptionVersion.USER_PASSWORD) + enc_version = StorageEncryptionVersion.USER_PASSWORD + if data['keystore_type'] == 'hardware': + enc_version = StorageEncryptionVersion.XPUB_PASSWORD + storage.set_password(data['password'], enc_version=enc_version) db = WalletDB('', storage=storage, manual_upgrades=False) db.set_keystore_encryption(bool(data['password']) and data['encrypt']) @@ -546,6 +589,17 @@ class NewWalletWizard(AbstractWizard): db.load_plugins() db.write() + def hw_keystore(self, data): + return hardware_keystore({ + 'type': 'hardware', + 'hw_type': data['hw_type'], + 'derivation': data['derivation_path'], + 'root_fingerprint': data['root_fingerprint'], + 'xpub': data['master_key'], + 'label': data['label'], + 'soft_device_id': data['soft_device_id'] + }) + class ServerConnectWizard(AbstractWizard):