diff --git a/electrum/gui/qt/__init__.py b/electrum/gui/qt/__init__.py index 33cc3af74..5907ded6f 100644 --- a/electrum/gui/qt/__init__.py +++ b/electrum/gui/qt/__init__.py @@ -495,12 +495,18 @@ class ElectrumGui(BaseElectrumGui, Logger): if window.should_stop_wallet_on_close: self.daemon.stop_wallet(window.wallet.storage.path) + def reload_window(self, window): + # bump counter so that we do not close the app + self._num_wizards_in_progress += 1 + wallet = window.wallet + window.should_stop_wallet_on_close = False + window.close() + self._create_window_for_wallet(wallet) + self._num_wizards_in_progress -= 1 + def reload_windows(self): for window in list(self.windows): - wallet = window.wallet - window.should_stop_wallet_on_close = False - window.close() - self._create_window_for_wallet(wallet) + self.reload_window(window) def ask_terms_of_use(self): """Ask the user to accept the terms of use. diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index 286dc6ac4..3d29e8aeb 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -143,7 +143,7 @@ def protected(func): parent = self.top_level_window() password = None msg = kwargs.get('message') - while self.wallet.has_keystore_encryption(): + while self._protected_requires_password(): password = self.wallet.get_unlocked_password() or self.password_dialog(parent=parent, msg=msg) if password is None: # User cancelled password input @@ -176,6 +176,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, Logger, QtEventListener): self.gui_thread = gui_object.gui_thread assert wallet, "no wallet" self.wallet = wallet + self._protected_requires_password = self.wallet.has_keystore_encryption if wallet.has_lightning() and not self.config.cv.GUI_QT_SHOW_TAB_CHANNELS.is_set(): self.config.GUI_QT_SHOW_TAB_CHANNELS = True # override default, but still allow disabling tab manually diff --git a/electrum/gui/qt/wallet_info_dialog.py b/electrum/gui/qt/wallet_info_dialog.py index 97f063f12..66d0498c9 100644 --- a/electrum/gui/qt/wallet_info_dialog.py +++ b/electrum/gui/qt/wallet_info_dialog.py @@ -4,20 +4,23 @@ import os from typing import TYPE_CHECKING +from functools import partial from PyQt6.QtCore import Qt -from PyQt6.QtWidgets import (QLabel, QVBoxLayout, QGridLayout, - QHBoxLayout, QPushButton, QWidget, QStackedWidget) +from PyQt6.QtWidgets import ( + QLabel, QVBoxLayout, QGridLayout, + QHBoxLayout, QPushButton, QWidget, QTabWidget) from electrum.plugin import run_hook from electrum.i18n import _ from electrum.wallet import Multisig_Wallet -from electrum.util import ChoiceItem +from .main_window import protected +from electrum.gui.qt.wizard.wallet import QEKeystoreWizard from .qrtextedit import ShowQRTextEdit from .util import ( read_QIcon, WindowModalDialog, Buttons, - WWLabel, CloseButton, HelpButton, font_height, ShowQRLineEdit, ChoiceWidget, + WWLabel, CloseButton, HelpButton, font_height, ShowQRLineEdit ) if TYPE_CHECKING: @@ -29,7 +32,10 @@ class WalletInfoDialog(WindowModalDialog): def __init__(self, parent: QWidget, *, window: 'ElectrumWindow'): WindowModalDialog.__init__(self, parent, _("Wallet Information")) self.setMinimumSize(800, 100) - wallet = window.wallet + self.window = window + self.wallet = wallet = window.wallet + # required for @protected decorator + self._protected_requires_password = lambda: self.wallet.has_keystore_encryption() or self.wallet.storage.is_encrypted_with_user_pw() config = window.config vbox = QVBoxLayout() wallet_type = wallet.db.get('wallet_type', '') @@ -107,32 +113,18 @@ class WalletInfoDialog(WindowModalDialog): if wallet.is_deterministic(): keystores = wallet.get_keystores() - ks_stack = QStackedWidget() + self.keystore_tabs = QTabWidget() - def select_ks(index): - ks_stack.setCurrentIndex(index) - - # only show the combobox in case multiple accounts are available - if len(keystores) > 1: - def label(idx, ks): - if isinstance(wallet, Multisig_Wallet) and hasattr(ks, 'label'): - return _("cosigner") + f' {idx+1}: {ks.get_type_text()} {ks.label}' - else: - return _("keystore") + f' {idx+1}' - - labels = [ChoiceItem(key=idx, label=label(idx, ks)) - for idx, ks in enumerate(wallet.get_keystores())] - - keystore_choice = ChoiceWidget(message=_("Select keystore"), choices=labels) - keystore_choice.itemSelected.connect(lambda x: select_ks(x)) - vbox.addWidget(keystore_choice) - - for ks in keystores: + for idx, ks in enumerate(keystores): ks_w = QWidget() ks_vbox = QVBoxLayout() - ks_vbox.setContentsMargins(0, 0, 0, 0) ks_w.setLayout(ks_vbox) + status_label = _('This keystore is watching-only (disabled)') if ks.is_watching_only() else _('This keystore is active (enabled)') + ks_vbox.addWidget(QLabel(status_label)) + label = f'{ks.label}' if hasattr(ks, 'label') and ks.label else '' + ks_vbox.addWidget(QLabel(_('Type') + ': ' + f'{ks.get_type_text()}' + ' ' + label)) + mpk_text = ShowQRTextEdit(ks.get_master_public_key(), config=config) mpk_text.setMaximumHeight(max(150, 10 * font_height())) mpk_text.addCopyButton() @@ -157,18 +149,64 @@ class WalletInfoDialog(WindowModalDialog): bip32fp_hbox.addWidget(bip32fp_text) bip32fp_hbox.addStretch() ks_vbox.addLayout(bip32fp_hbox) - - ks_stack.addWidget(ks_w) - - select_ks(0) - vbox.addWidget(ks_stack) + ks_buttons = [] + if not ks.is_watching_only(): + rm_keystore_button = QPushButton('Disable keystore') + rm_keystore_button.clicked.connect(partial(self.disable_keystore, ks)) + ks_buttons.insert(0, rm_keystore_button) + else: + add_keystore_button = QPushButton('Enable Keystore') + add_keystore_button.clicked.connect(self.enable_keystore) + ks_buttons.insert(0, add_keystore_button) + ks_vbox.addLayout(Buttons(*ks_buttons)) + tab_label = _("Cosigner") + f' {idx+1}' if len(keystores) > 1 else _("Keystore") + index = self.keystore_tabs.addTab(ks_w, tab_label) + if not ks.is_watching_only(): + self.keystore_tabs.setTabIcon(index, read_QIcon('confirmed.svg')) + vbox.addWidget(self.keystore_tabs) vbox.addStretch(1) + + buttons = [CloseButton(self)] btn_export_info = run_hook('wallet_info_buttons', window, self) if btn_export_info is None: btn_export_info = [] + buttons = btn_export_info + buttons - btn_close = CloseButton(self) - btns = Buttons(*btn_export_info, btn_close) + btns = Buttons(*buttons) vbox.addLayout(btns) self.setLayout(vbox) + + def disable_keystore(self, keystore): + if self.wallet.has_channels(): + self.window.show_message(_('Cannot disable keystore: You have active lightning channels')) + return + + msg = _('Disable keystore? This will make the keytore watching-only.') + if self.wallet.storage.is_encrypted_with_hw_device(): + msg += '\n\n' + _('Note that this will disable wallet file encryption, because it uses your hardware wallet device.') + if not self.window.question(msg): + return + self.accept() + self.wallet.disable_keystore(keystore) + self.window.gui_object.reload_windows() + + def enable_keystore(self, b: bool): + dialog = QEKeystoreWizard(self.window.config, self.window.wallet.wallet_type, self.window.gui_object.app, self.window.gui_object.plugins) + result = dialog.run() + if not result: + return + keystore, is_hardware = result + for k in self.wallet.get_keystores(): + if k.xpub == keystore.xpub: + break + else: + self.window.show_error(_('Keystore not found in this wallet')) + return + self._enable_keystore(keystore, is_hardware) + + @protected + def _enable_keystore(self, keystore, is_hardware, password): + self.accept() + self.wallet.enable_keystore(keystore, is_hardware, password) + self.window.gui_object.reload_windows() diff --git a/electrum/gui/qt/wizard/wallet.py b/electrum/gui/qt/wizard/wallet.py index 824f538db..18e2b61ef 100644 --- a/electrum/gui/qt/wizard/wallet.py +++ b/electrum/gui/qt/wizard/wallet.py @@ -17,13 +17,13 @@ from electrum.keystore import bip44_derivation, bip39_to_seed, purpose48_derivat from electrum.plugin import run_hook, HardwarePluginLibraryUnavailable from electrum.storage import StorageReadWriteError from electrum.util import WalletFileException, get_new_wallet_name, UserFacingException, InvalidPassword -from electrum.util import is_subpath, ChoiceItem +from electrum.util import is_subpath, ChoiceItem, multisig_type from electrum.wallet import wallet_types from .wizard import QEAbstractWizard, WizardComponent from electrum.logging import get_logger, Logger from electrum import WalletStorage, mnemonic, keystore from electrum.wallet_db import WalletDB -from electrum.wizard import NewWalletWizard +from electrum.wizard import NewWalletWizard, KeystoreWizard from electrum.gui.qt.bip39_recovery_dialog import Bip39RecoveryDialog from electrum.gui.qt.password_dialog import PasswordLayout, PW_NEW, MSG_ENTER_PASSWORD, PasswordLayoutForHW @@ -49,6 +49,33 @@ MSG_HW_STORAGE_ENCRYPTION = _("Set wallet file encryption.") + '\n'\ +class QEKeystoreWizard(KeystoreWizard, QEAbstractWizard, MessageBoxMixin): + _logger = get_logger(__name__) + + def __init__(self, config: 'SimpleConfig', wallet_type: str, app: 'QElectrumApplication', plugins: 'Plugins', *, start_viewstate=None): + KeystoreWizard.__init__(self, plugins) + QEAbstractWizard.__init__(self, config, app, start_viewstate=start_viewstate) + self._wallet_type = wallet_type + self.window_title = _('Extend wallet keystore') + # attach gui classes to views + self.navmap_merge({ + 'keystore_type': {'gui': WCExtendKeystore}, + 'enterseed': {'gui': WCHaveSeed}, + 'choose_hardware_device': {'gui': WCChooseHWDevice}, + 'script_and_derivation': {'gui': WCScriptAndDerivation}, + 'wallet_password': {'gui': WCWalletPassword}, + 'wallet_password_hardware': {'gui': WCWalletPasswordHardware}, + }) + + def is_single_password(self): + return True + + def run(self): + if self.exec() == QDialog.DialogCode.Rejected: + return + return self._result + + class QENewWalletWizard(NewWalletWizard, QEAbstractWizard, MessageBoxMixin): _logger = get_logger(__name__) @@ -411,6 +438,7 @@ class WCWalletType(WalletWizardComponent): class WCKeystoreType(WalletWizardComponent): + def __init__(self, parent, wizard): WalletWizardComponent.__init__(self, parent, wizard, title=_('Keystore')) message = _('Do you want to create a new seed, or to restore a wallet using an existing seed?') @@ -420,7 +448,6 @@ class WCKeystoreType(WalletWizardComponent): ChoiceItem(key='masterkey', label=_('Use a master key')), ChoiceItem(key='hardware', label=_('Use a hardware device')), ] - self.choice_w = ChoiceWidget(message=message, choices=choices) self.layout().addWidget(self.choice_w) self.layout().addStretch(1) @@ -430,6 +457,32 @@ class WCKeystoreType(WalletWizardComponent): self.wizard_data['keystore_type'] = self.choice_w.selected_key + +class WCExtendKeystore(WalletWizardComponent): + + def __init__(self, parent, wizard): + WalletWizardComponent.__init__(self, parent, wizard, title=_('Keystore')) + message = _('What type of signing method do you want to add?') + choices = [ + ChoiceItem(key='haveseed', label=_('Enter seed')), + ChoiceItem(key='hardware', label=_('Use a hardware device')), + ] + self.choice_w = ChoiceWidget(message=message, choices=choices) + self.layout().addWidget(self.choice_w) + self.layout().addStretch(1) + self._valid = True + self.wizard_data['wallet_type'] = self._wallet_type = wizard._wallet_type + + def apply(self): + self.wizard_data['wallet_type'] = self._wallet_type + self.wizard_data['keystore_type'] = self.choice_w.selected_key + if multisig_type(self._wallet_type): + self.wizard_data['wallet_type'] = self._wallet_type = 'multisig' + self.wizard_data['multisig_participants'] = 2 + self.wizard_data['multisig_signatures'] = 2 + self.wizard_data['multisig_cosigner_data'] = {} + + class WCCreateSeed(WalletWizardComponent): def __init__(self, parent, wizard): WalletWizardComponent.__init__(self, parent, wizard, title=_('Wallet Seed')) diff --git a/electrum/wallet.py b/electrum/wallet.py index f76bcb85b..6b8b2b1b0 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -3035,7 +3035,7 @@ class Abstract_Wallet(ABC, Logger, EventListener): return False def has_password(self) -> bool: - return self.has_keystore_encryption() or self.has_storage_encryption() + return self.has_keystore_encryption() or self.has_storage_encryption() #and self.storage.is_encrypted_with_user_pw()) def can_have_keystore_encryption(self): return self.keystore and self.keystore.may_have_password() @@ -3951,6 +3951,22 @@ class Deterministic_Wallet(Abstract_Wallet): def get_txin_type(self, address=None): return self.txin_type + def enable_keystore(self, keystore, is_hardware_keystore: bool, password): + if not is_hardware_keystore and self.storage.is_encrypted_with_user_pw(): + keystore.update_password(None, password) + self._update_keystore(keystore) + + def disable_keystore(self, keystore): + from .keystore import BIP32_KeyStore + assert not self.has_channels() + if keystore.thread: + keystore.thread.stop() + if self.storage.is_encrypted_with_hw_device(): + password = keystore.get_password_for_storage_encryption() + self.update_password(password, None, encrypt_storage=False) + new = BIP32_KeyStore({'xpub':keystore.xpub}) + self._update_keystore(new) + class Standard_Wallet(Simple_Wallet, Deterministic_Wallet): """ Deterministic Wallet with a single pubkey per address """ @@ -3989,6 +4005,12 @@ class Standard_Wallet(Simple_Wallet, Deterministic_Wallet): tx.add_info_from_wallet(self) self.keystore.add_slip_19_ownership_proofs_to_tx(tx=tx, password=None) + def _update_keystore(self, keystore): + assert self.keystore.xpub == keystore.xpub + self.keystore = keystore + self.save_keystore() + + class Multisig_Wallet(Deterministic_Wallet): # generic m of n @@ -4039,6 +4061,16 @@ class Multisig_Wallet(Deterministic_Wallet): def get_keystores(self): return [self.keystores[i] for i in sorted(self.keystores.keys())] + def _update_keystore(self, keystore): + for name, k in self.keystores.items(): + if k.xpub == keystore.xpub: + break + else: + raise Exception('keystore not found') + self.keystores[name] = keystore + self.keystore = keystore + self.save_keystore() + def can_have_keystore_encryption(self): return any([k.may_have_password() for k in self.get_keystores()]) diff --git a/electrum/wizard.py b/electrum/wizard.py index 8d38f06ae..de18382aa 100644 --- a/electrum/wizard.py +++ b/electrum/wizard.py @@ -422,7 +422,8 @@ class NewWalletWizard(AbstractWizard): if data['keystore_type'] in ['createseed', 'haveseed'] and 'seed' in data: seed_extension = data['seed_extra_words'] if data['seed_extend'] else '' if data['seed_variant'] == 'electrum': - return keystore.from_seed(data['seed'], passphrase=seed_extension, for_multisig=True) + for_multisig = wallet_type in ['multisig'] + return keystore.from_seed(data['seed'], passphrase=seed_extension, for_multisig=for_multisig) elif data['seed_variant'] == 'bip39': root_seed = keystore.bip39_to_seed(data['seed'], passphrase=seed_extension) derivation = normalize_bip32_derivation(data['derivation_path']) @@ -800,3 +801,55 @@ class TermsOfUseWizard(AbstractWizard): self._current = WizardViewState(start_view, initial_data, params) return self._current + +class KeystoreWizard(NewWalletWizard): + + _logger = get_logger(__name__) + + def __init__(self, plugins): + AbstractWizard.__init__(self) + self.plugins = plugins + self.navmap = { + 'keystore_type': { + 'next': self.on_keystore_type + }, + 'enterseed': { + 'accept': self.update_keystore, + 'last': True + }, + 'choose_hardware_device': { + 'next': self.on_hardware_device, + }, + } + + def maybe_master_pubkey(self, wizard_data): + self.update_keystore(wizard_data) + + def update_keystore(self, wizard_data): + wallet_type = wizard_data['wallet_type'] + keystore = self.keystore_from_data(wallet_type, wizard_data) + self._result = keystore, (wizard_data['keystore_type'] == 'hardware') + + def on_keystore_type(self, wizard_data: dict) -> str: + t = wizard_data['keystore_type'] + return { + 'haveseed': 'enterseed', + 'hardware': 'choose_hardware_device' + }.get(t) + + def is_multisig(self, wizard_data: dict) -> bool: + return wizard_data['wallet_type'] == 'multisig' + + def last_cosigner(self, wizard_data: dict) -> bool: + # one at a time + return True + + def start(self, initial_data: dict = None) -> WizardViewState: + if initial_data is None: + initial_data = {} + self.reset() + start_view = 'keystore_type' + params = self.navmap[start_view].get('params', {}) + self._current = WizardViewState(start_view, initial_data, params) + return self._current +