diff --git a/electrum/gui/qml/auth.py b/electrum/gui/qml/auth.py index f9896efcfa8d..a259e781ecc0 100644 --- a/electrum/gui/qml/auth.py +++ b/electrum/gui/qml/auth.py @@ -1,9 +1,10 @@ from functools import wraps, partial -from PyQt5.QtCore import pyqtSignal, pyqtSlot, pyqtProperty +from PyQt5.QtCore import pyqtSignal, pyqtSlot from electrum.logging import get_logger + def auth_protect(func=None, reject=None, method='pin', message=''): if func is None: return partial(auth_protect, reject=reject, method=method, message=message) @@ -20,23 +21,28 @@ def wrapper(self, *args, **kwargs): return wrapper + class AuthMixin: _auth_logger = get_logger(__name__) authRequired = pyqtSignal([str, str], arguments=['method', 'authMessage']) @pyqtSlot() - def authProceed(self): + @pyqtSlot(str) + def authProceed(self, password=None): self._auth_logger.debug('Proceeding with authed fn()') try: self._auth_logger.debug(str(getattr(self, '__auth_fcall'))) - (func,args,kwargs,reject) = getattr(self, '__auth_fcall') - r = func(self, *args, **kwargs) + (func, args, kwargs, reject) = getattr(self, '__auth_fcall') + if password and 'password' in func.__code__.co_varnames: + r = func(self, *args, **dict(kwargs, password=password)) + else: + r = func(self, *args, **kwargs) return r except Exception as e: self._auth_logger.error(f'Error executing wrapped fn(): {repr(e)}') raise e finally: - delattr(self,'__auth_fcall') + delattr(self, '__auth_fcall') @pyqtSlot() def authCancel(self): @@ -45,7 +51,7 @@ def authCancel(self): return try: - (func,args,kwargs,reject) = getattr(self, '__auth_fcall') + (func, args, kwargs, reject) = getattr(self, '__auth_fcall') if reject is not None: if hasattr(self, reject): getattr(self, reject)() diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml index e49f88ec959a..bc0e9fc85af8 100644 --- a/electrum/gui/qml/components/main.qml +++ b/electrum/gui/qml/components/main.qml @@ -549,44 +549,80 @@ ApplicationWindow } } + // handle auth_protect decorator events. These MUST + // (eventually) end with a call to qtobject.authProceed() + // or qtobject.authCancel(). + // + // The following method types are defined: + // + // 'wallet_password': User must supply a password + // that matches the storage password (if set) + // or the keystore password. This forces password + // verification in all cases, even for wallets using + // keystore-only passwords (unless the storage and + // keystore are both unencrypted). + // It's primary use is password knowledge verification + // before presenting a secret (e.g. seed) or doing + // something irreversible (e.g. delete wallet) + // + // 'keystore': User must supply a password + // that matches the keystore password (if set). + // + // 'keystore_else_pin': User must supply a password + // that matches the keystore password (if set), unless + // the keystore is 'unlocked' which means the wallet password + // has been given when opening the wallet, and is the same as + // the keystore password (should always be the case). In that + // case a PIN is asked. + // This is mainly used when signing a transaction. + // + // 'pin': User must supply the configured PIN code + // + function handleAuthRequired(qtobject, method, authMessage) { console.log('auth using method ' + method) - if (method == 'wallet') { - if (Daemon.currentWallet.verifyPassword('')) { + if (method == 'wallet_password') { + if (!Daemon.currentWallet.isEncrypted + && Daemon.currentWallet.verifyKeystorePassword('')) { // wallet has no password qtobject.authProceed() } else { - var dialog = app.passwordDialog.createObject(app, {'title': qsTr('Enter current password')}) - dialog.accepted.connect(function() { - if (Daemon.currentWallet.verifyPassword(dialog.password)) { - qtobject.authProceed() - } else { - qtobject.authCancel() - } + if (!Daemon.currentWallet.isEncrypted) { + handleAuthVerifyPassword(qtobject, authMessage, function(password) { + return Daemon.currentWallet.verifyKeystorePassword(password) + }) + } else { + handleAuthVerifyPassword(qtobject, authMessage, function(password) { + return Daemon.currentWallet.verifyPassword(password) + }) + } + } + } else if (method == 'keystore_else_pin') { + if (!Daemon.currentWallet.canHaveKeystoreEncryption() + || Daemon.currentWallet.verifyKeystorePassword('')) { + handleAuthRequired(qtobject, 'pin', authMessage) + } else if (Daemon.currentWallet.isKeystorePasswordWalletPassword()) { + handleAuthRequired(qtobject, 'pin', authMessage) + } else { + handleAuthVerifyPassword(qtobject, authMessage, function(password) { + return Daemon.currentWallet.verifyKeystorePassword(password) }) - dialog.rejected.connect(function() { - qtobject.authCancel() + } + } else if (method == 'keystore') { + if (!Daemon.currentWallet.canHaveKeystoreEncryption() + || Daemon.currentWallet.verifyKeystorePassword('')) { + qtobject.authProceed() + } else { + handleAuthVerifyPassword(qtobject, authMessage, function(password) { + return Daemon.currentWallet.verifyKeystorePassword(password) }) - dialog.open() } } else if (method == 'pin') { if (Config.pinCode == '') { // no PIN configured handleAuthConfirmationOnly(qtobject, authMessage) } else { - var dialog = app.pinDialog.createObject(app, { - mode: 'check', - pincode: Config.pinCode, - authMessage: authMessage - }) - dialog.accepted.connect(function() { - qtobject.authProceed() - dialog.close() - }) - dialog.rejected.connect(function() { - qtobject.authCancel() - }) - dialog.open() + handleAuthVerifyPin(qtobject, authMessage) } } else { console.log('unknown auth method ' + method) @@ -594,6 +630,27 @@ ApplicationWindow } } + function handleAuthVerifyPassword(qtobject, authMessage, validator) { + var dialog = app.passwordDialog.createObject(app, { + title: authMessage ? authMessage : qsTr('Enter current password') + }) + dialog.accepted.connect(function() { + if (validator(dialog.password)) { + qtobject.authProceed(dialog.password) + } else { + qtobject.authCancel() + var fdialog = app.messageDialog.createObject(app, { + title: qsTr('Password incorrect') + }) + fdialog.open() + } + }) + dialog.rejected.connect(function() { + qtobject.authCancel() + }) + dialog.open() + } + function handleAuthConfirmationOnly(qtobject, authMessage) { if (!authMessage) { qtobject.authProceed() @@ -609,6 +666,22 @@ ApplicationWindow dialog.open() } + function handleAuthVerifyPin(qtobject, authMessage) { + var dialog = app.pinDialog.createObject(app, { + mode: 'check', + pincode: Config.pinCode, + authMessage: authMessage + }) + dialog.accepted.connect(function() { + qtobject.authProceed() + dialog.close() + }) + dialog.rejected.connect(function() { + qtobject.authCancel() + }) + dialog.open() + } + function startSwap() { var swapdialog = swapDialog.createObject(app) swapdialog.open() diff --git a/electrum/gui/qml/qechannelopener.py b/electrum/gui/qml/qechannelopener.py index fff270c00b2b..e60c2781cb03 100644 --- a/electrum/gui/qml/qechannelopener.py +++ b/electrum/gui/qml/qechannelopener.py @@ -165,7 +165,7 @@ def openChannel(self, confirm_backup_conflict=False): node_id=self._node_pubkey, fee_est=None) - acpt = lambda tx: self.do_open_channel(tx, self._connect_str_resolved, self._wallet.password) + acpt = lambda tx: self.do_open_channel(tx, self._connect_str_resolved) self._finalizer = QETxFinalizer(self, make_tx=mktx, accept=acpt) self._finalizer.canRbf = False @@ -173,8 +173,8 @@ def openChannel(self, confirm_backup_conflict=False): self._finalizer.wallet = self._wallet self.finalizerChanged.emit() - @auth_protect(message=_('Open Lightning channel?')) - def do_open_channel(self, funding_tx, conn_str, password): + @auth_protect(method='keystore_else_pin', message=_('Open Lightning channel?')) + def do_open_channel(self, funding_tx, conn_str, password=None): """ conn_str: a connection string that extract_nodeid can parse, i.e. cannot be a trampoline name """ @@ -183,6 +183,9 @@ def do_open_channel(self, funding_tx, conn_str, password): funding_sat = funding_tx.output_value_for_address(ln_dummy_address()) lnworker = self._wallet.wallet.lnworker + if password is None: + password = self._wallet.password + def open_thread(): error = None try: diff --git a/electrum/gui/qml/qeconfig.py b/electrum/gui/qml/qeconfig.py index 93e45460d00d..6a584f8e46d1 100644 --- a/electrum/gui/qml/qeconfig.py +++ b/electrum/gui/qml/qeconfig.py @@ -139,8 +139,10 @@ def pinCode(self, pin_code): self.config.set_key('pin_code', pin_code, True) self.pinCodeChanged.emit() - @auth_protect(method='wallet') - def pinCodeRemoveAuth(self): + # TODO: this allows disabling PIN unconditionally if wallet has no password + # (which should never be the case however) + @auth_protect(method='wallet_password') + def pinCodeRemoveAuth(self, password=None): self.config.set_key('pin_code', '', True) self.pinCodeChanged.emit() diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py index 093d6a124299..acdc0398691a 100644 --- a/electrum/gui/qml/qedaemon.py +++ b/electrum/gui/qml/qedaemon.py @@ -263,8 +263,8 @@ def checkThenDeleteWallet(self, wallet, confirm_requests=False, confirm_balance= self.delete_wallet(wallet) - @auth_protect(message=_('Really delete this wallet?')) - def delete_wallet(self, wallet): + @auth_protect(method='wallet_password', message=_('Really delete this wallet?')) + def delete_wallet(self, wallet, password=None): path = standardize_path(wallet.wallet.storage.path) self._logger.debug('deleting wallet with path %s' % path) self._current_wallet = None @@ -314,12 +314,15 @@ def suggestWalletName(self): return f'wallet_{i}' @pyqtSlot() - @auth_protect(method='wallet') def startChangePassword(self): if self._use_single_password: - self.requestNewPassword.emit() + self._do_start_change_all_passwords() else: - self.currentWallet.requestNewPassword.emit() + self.currentWallet.startChangePassword() + + @auth_protect(method='wallet_password') + def _do_start_change_all_passwords(self, password=None): + self.requestNewPassword.emit() @pyqtSlot(str, result=bool) def setPassword(self, password): diff --git a/electrum/gui/qml/qeswaphelper.py b/electrum/gui/qml/qeswaphelper.py index d75f5355db71..f9a605464abf 100644 --- a/electrum/gui/qml/qeswaphelper.py +++ b/electrum/gui/qml/qeswaphelper.py @@ -341,15 +341,19 @@ def fwd_swap_updatetx(self): self.miningfee = QEAmount(amount_sat=self._tx.get_fee()) if self._tx else QEAmount() self.check_valid(pay_amount, self._receive_amount) - def do_normal_swap(self, lightning_amount, onchain_amount): + def do_normal_swap(self, lightning_amount, onchain_amount, password): assert self._tx if lightning_amount is None or onchain_amount is None: return + + if password is None: + password = self._wallet.password + loop = get_asyncio_loop() coro = self._wallet.wallet.lnworker.swap_manager.normal_swap( lightning_amount_sat=lightning_amount, expected_onchain_amount_sat=onchain_amount, - password=self._wallet.password, + password=password, tx=self._tx, ) @@ -424,15 +428,19 @@ def executeSwap(self): if not self._wallet.wallet.network: self.error.emit(_("You are offline.")) return - self._do_execute_swap() - - @auth_protect(message=_('Confirm Lightning swap?')) - def _do_execute_swap(self): if self.isReverse: - lightning_amount = self._send_amount - onchain_amount = self._receive_amount - self.do_reverse_swap(lightning_amount, onchain_amount) + self._do_execute_reverse_swap() else: - lightning_amount = self._receive_amount - onchain_amount = self._send_amount - self.do_normal_swap(lightning_amount, onchain_amount) + self._do_execute_forward_swap() + + @auth_protect(method='pin', message=_('Confirm Lightning swap?')) + def _do_execute_reverse_swap(self): + lightning_amount = self._send_amount + onchain_amount = self._receive_amount + self.do_reverse_swap(lightning_amount, onchain_amount) + + @auth_protect(method='keystore_else_pin', message=_('Confirm Lightning swap?')) + def _do_execute_forward_swap(self, password=None): + lightning_amount = self._receive_amount + onchain_amount = self._send_amount + self.do_normal_swap(lightning_amount, onchain_amount, password) diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 8baa2a5504fb..9c017c4c3616 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -75,6 +75,7 @@ def getInstanceFor(cls, wallet): otpFailed = pyqtSignal([str,str], arguments=['code','message']) peersUpdated = pyqtSignal() seedRetrieved = pyqtSignal() + paymentAuthRejected = pyqtSignal() _network_signal = pyqtSignal(str, object) @@ -486,26 +487,30 @@ def enableLightning(self): self.isLightningChanged.emit() self.dataChanged.emit() - @auth_protect() - def sign(self, tx, *, broadcast: bool = False, on_success: Callable[[Transaction], None] = None, on_failure: Callable[[], None] = None): + @auth_protect(method='keystore_else_pin') + def sign(self, tx, *, + broadcast: bool = False, + on_success: Callable[[Transaction], None] = None, + on_failure: Callable[[], None] = None, + password = None): sign_hook = run_hook('tc_sign_wrapper', self.wallet, tx, partial(self.on_sign_complete, broadcast, on_success), partial(self.on_sign_failed, on_failure)) if sign_hook: - success = self.do_sign(tx, False) + success = self.do_sign(tx, False, password) if success: self._logger.debug('plugin needs to sign tx too') sign_hook(tx) return else: - success = self.do_sign(tx, broadcast) + success = self.do_sign(tx, broadcast, password) if success: if on_success: on_success(tx) else: if on_failure: on_failure() - def do_sign(self, tx, broadcast): + def do_sign(self, tx, broadcast, password=None): try: - tx = self.wallet.sign_transaction(tx, self.password) + tx = self.wallet.sign_transaction(tx, self.password if password is None else password) except BaseException as e: self._logger.error(f'{e!r}') self.signFailed.emit(str(e)) @@ -595,11 +600,10 @@ def save_tx(self, tx: 'PartialTransaction'): self.saveTxError.emit(tx.txid(), 'error', str(e)) return False - paymentAuthRejected = pyqtSignal() def ln_auth_rejected(self): self.paymentAuthRejected.emit() - @auth_protect(message=_('Pay lightning invoice?'), reject='ln_auth_rejected') + @auth_protect(method='pin', message=_('Pay lightning invoice?'), reject='ln_auth_rejected') def pay_lightning_invoice(self, invoice: 'QEInvoice'): amount_msat = invoice.get_amount_msat() @@ -668,12 +672,56 @@ def deleteInvoice(self, key: str): @pyqtSlot(str, result=bool) def verifyPassword(self, password): + if not password: + password = None try: self.wallet.check_password(password) return True except InvalidPassword as e: return False + @pyqtSlot(result=bool) + def canHaveKeystoreEncryption(self): + return self.wallet.can_have_keystore_encryption() + + @pyqtSlot(str, result=bool) + def verifyKeystorePassword(self, password): + if not password: + password = None + + if self.wallet.has_keystore_encryption() and not password: + return False + + try: + self.wallet.keystore.check_password(password) + return True + except InvalidPassword as e: + return False + + @pyqtSlot(result=bool) + def isKeystorePasswordWalletPassword(self): + try: + self.wallet.keystore.check_password(self.password) + return True + except InvalidPassword as e: + return False + + @pyqtSlot() + def startChangePassword(self): + if not self.canHaveKeystoreEncryption(): + self._do_start_change_wallet_password() + else: + self._do_start_change_wallet_password_with_encryptable_keystore() + + @auth_protect(method='wallet_password') + def _do_start_change_wallet_password(self, password=None): + self.requestNewPassword.emit() + + @auth_protect(method='keystore') + def _do_start_change_wallet_password_with_encryptable_keystore(self, password=None): + self._keystore_password = password + self.requestNewPassword.emit() + @pyqtSlot(str, result=bool) def setPassword(self, password): if password == '': @@ -685,10 +733,12 @@ def setPassword(self, password): if storage.is_encrypted_with_hw_device(): return False - current_password = self.password if self.password != '' else None + keystore_password = getattr(self, '_keystore_password', None) + + current_password = self.password if self.password else (keystore_password if keystore_password else None) try: - self._logger.info('setting new password') + self._logger.info('Initiating password change') self.wallet.update_password(current_password, password, encrypt_storage=True) self.password = password return True @@ -729,10 +779,10 @@ def isValidChannelBackup(self, backup_str): def requestShowSeed(self): self.retrieve_seed() - @auth_protect(method='wallet') - def retrieve_seed(self): + @auth_protect(method='wallet_password', message=_('Enter password to reveal seed')) + def retrieve_seed(self, password=None): try: - self._seed = self.wallet.get_seed(self.password) + self._seed = self.wallet.get_seed(password) self.seedRetrieved.emit() except Exception: self._seed = ''