From 6e791088f48a33b1d24624515eb183248516a94d Mon Sep 17 00:00:00 2001 From: ThomasV Date: Tue, 15 Sep 2026 13:34:34 +0200 Subject: [PATCH 1/3] Use Electrum mobile app as cosigner for 2FA wallets TrustedCoin is going to stop their cosigning service. To keep things running, we shall use the Electrum mobile app as cosigner. - Wallets keep the '2fa' type, because two xprvs are derived from a seed. - desktop wizard (restore only): show xprv2, xpub1, xpub3 in a '2fa_cosigner:' QR code. - mobile wizard: scan that QR code (xpub1, xprv2, xpub3). - desktop shows the PSBT as a QR code after signing; that dialog closes once the wallet sees the transaction. Existing 2fa wallets can set up the mobile cosigner from the status bar icon; missing x3 is derived. - remove server client, TOS, OTP, billing and related hooks (tc_sign_wrapper, abort_send, get_tx_extra_fee, get_action/WalletUnfinished, is_sweep). what is missing: - QR codes are size limited. We should implement animated QR or use nostr - not sure if we want a full wallet on the mobile app. We could as well store the (encrypted) xprv in the config file. --- electrum/bip32.py | 2 +- electrum/daemon.py | 4 +- .../gui/qml/components/ConfirmTxDialog.qml | 11 - electrum/gui/qml/components/Constants.qml | 3 +- electrum/gui/qml/components/OtpDialog.qml | 89 --- electrum/gui/qml/components/TxDetails.qml | 2 +- electrum/gui/qml/components/WalletDetails.qml | 63 +- .../gui/qml/components/WalletMainView.qml | 15 - .../gui/qml/components/controls/TxOutput.qml | 16 +- electrum/gui/qml/qeconfig.py | 11 - electrum/gui/qml/qeinvoice.py | 3 +- electrum/gui/qml/qetxdetails.py | 1 - electrum/gui/qml/qetxfinalizer.py | 22 +- electrum/gui/qml/qewallet.py | 56 +- electrum/gui/qt/__init__.py | 39 +- electrum/gui/qt/confirm_tx_dialog.py | 22 - electrum/gui/qt/main_window.py | 4 +- electrum/gui/qt/send_tab.py | 20 +- electrum/gui/qt/transaction_dialog.py | 11 +- .../timelock_recovery/timelock_recovery.py | 3 - electrum/plugins/trustedcoin/cmdline.py | 16 +- electrum/plugins/trustedcoin/common_qt.py | 256 ------- electrum/plugins/trustedcoin/manifest.json | 2 +- electrum/plugins/trustedcoin/qml.py | 150 ++-- .../plugins/trustedcoin/qml/ChooseSeed.qml | 18 +- .../plugins/trustedcoin/qml/KeepDisable.qml | 35 - .../trustedcoin/qml/ScanCosignerQR.qml | 87 +++ .../trustedcoin/qml/ShowConfirmOTP.qml | 166 ----- electrum/plugins/trustedcoin/qml/Terms.qml | 67 -- electrum/plugins/trustedcoin/qml/main.qml | 16 + electrum/plugins/trustedcoin/qt.py | 642 ++++-------------- electrum/plugins/trustedcoin/trustedcoin.py | 567 ++-------------- electrum/simple_config.py | 2 - electrum/wallet.py | 8 - electrum/wallet_db.py | 12 +- electrum/wizard.py | 25 +- tests/test_wallet_vertical.py | 1 - tests/test_wizard.py | 187 ++--- 38 files changed, 508 insertions(+), 2146 deletions(-) delete mode 100644 electrum/gui/qml/components/OtpDialog.qml delete mode 100644 electrum/plugins/trustedcoin/common_qt.py delete mode 100644 electrum/plugins/trustedcoin/qml/KeepDisable.qml create mode 100644 electrum/plugins/trustedcoin/qml/ScanCosignerQR.qml delete mode 100644 electrum/plugins/trustedcoin/qml/ShowConfirmOTP.qml delete mode 100644 electrum/plugins/trustedcoin/qml/Terms.qml create mode 100644 electrum/plugins/trustedcoin/qml/main.qml diff --git a/electrum/bip32.py b/electrum/bip32.py index 440d84f08461..29d7ffc9bd6d 100644 --- a/electrum/bip32.py +++ b/electrum/bip32.py @@ -90,7 +90,7 @@ def CKD_pub(parent_pubkey: bytes, parent_chaincode: bytes, child_index: int) -> # helper function, callable with arbitrary 'child_index' byte-string. -# i.e.: 'child_index' does not need to fit into 32 bits here! (c.f. trustedcoin billing) +# i.e.: 'child_index' does not need to fit into 32 bits here! (c.f. trustedcoin make_xpub) def _CKD_pub(parent_pubkey: bytes, parent_chaincode: bytes, child_index: bytes) -> Tuple[bytes, bytes]: I = hmac_oneshot(parent_chaincode, parent_pubkey + child_index, hashlib.sha512) pubkey = ecc.ECPrivkey(I[0:32]) + ecc.ECPubkey(parent_pubkey) diff --git a/electrum/daemon.py b/electrum/daemon.py index 9823bc365850..0c7df07ceb2c 100644 --- a/electrum/daemon.py +++ b/electrum/daemon.py @@ -49,7 +49,7 @@ ) from .wallet import Wallet, Abstract_Wallet from .storage import WalletStorage -from .wallet_db import WalletDB, WalletUnfinished +from .wallet_db import WalletDB from .commands import known_commands, Commands from .simple_config import SimpleConfig from .exchange_rate import FxThread @@ -554,8 +554,6 @@ def _load_wallet( storage.decrypt(password) # read data, pass it to db db = WalletDB(storage.read(), storage=storage, upgrade=upgrade) - if db.get_action(): - raise WalletUnfinished(db) wallet = Wallet(db, config=config) if force_check_password: wallet.check_password(password) diff --git a/electrum/gui/qml/components/ConfirmTxDialog.qml b/electrum/gui/qml/components/ConfirmTxDialog.qml index 78e563830a57..59db532c8ebe 100644 --- a/electrum/gui/qml/components/ConfirmTxDialog.qml +++ b/electrum/gui/qml/components/ConfirmTxDialog.qml @@ -134,17 +134,6 @@ ElDialog { id: feepicker width: parent.width finalizer: dialog.finalizer - - Label { - visible: !finalizer.extraFee.isEmpty - text: qsTr('Extra fee') - color: Material.accentColor - } - - FormattedAmount { - visible: !finalizer.extraFee.isEmpty - amount: finalizer.extraFee - } } } diff --git a/electrum/gui/qml/components/Constants.qml b/electrum/gui/qml/components/Constants.qml index ea08a2fac575..b2ada3ffa9a5 100644 --- a/electrum/gui/qml/components/Constants.qml +++ b/electrum/gui/qml/components/Constants.qml @@ -70,8 +70,7 @@ Item { property color colorAddressUsed: Qt.rgba(0.5,0.5,0.5,1) property color colorAddressUsedWithBalance: Qt.rgba(0.75,0.75,0.75,1) property color colorAddressFrozen: Qt.rgba(0.5,0.5,1,1) - property color colorAddressBilling: "#8cb3f2" - property color colorAddressSwap: colorAddressBilling + property color colorAddressSwap: "#8cb3f2" property color colorAddressAccounting: "#ff9b45" function colorAlpha(baseColor, alpha) { diff --git a/electrum/gui/qml/components/OtpDialog.qml b/electrum/gui/qml/components/OtpDialog.qml deleted file mode 100644 index 39b045f59954..000000000000 --- a/electrum/gui/qml/components/OtpDialog.qml +++ /dev/null @@ -1,89 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import QtQuick.Controls -import QtQuick.Controls.Material - -import org.electrum 1.0 - -import "controls" - -ElDialog { - id: dialog - - title: qsTr('Trustedcoin') - iconSource: Qt.resolvedUrl('../../../plugins/trustedcoin/trustedcoin-status.png') - - property string otpauth - - property bool _waiting: false - property string _otpError - property string passwordCharacter: '8' - focus: true - - ColumnLayout { - width: parent.width - - Label { - text: qsTr('Enter Authenticator code') - font.pixelSize: constants.fontSizeLarge - Layout.alignment: Qt.AlignHCenter - } - - TextField { - id: otpEdit - Layout.preferredWidth: leftPadding + rightPadding + fontMetrics.advanceWidth(passwordCharacter) * 6 - Layout.alignment: Qt.AlignHCenter - font.pixelSize: constants.fontSizeXXLarge - maximumLength: 6 - inputMethodHints: Qt.ImhSensitiveData | Qt.ImhDigitsOnly - echoMode: TextInput.Password - focus: true - enabled: !_waiting - Keys.onPressed: _otpError = '' - onTextChanged: { - if (text.length == 6) { - _waiting = true - Daemon.currentWallet.submitOtp(otpEdit.text) - } - } - } - - Label { - Layout.topMargin: constants.paddingMedium - Layout.bottomMargin: constants.paddingMedium - Layout.fillWidth: true - wrapMode: Text.Wrap - horizontalAlignment: Text.AlignHCenter - - text: _otpError - color: constants.colorError - - BusyIndicator { - anchors.centerIn: parent - width: constants.iconSizeXLarge - height: constants.iconSizeXLarge - visible: _waiting - running: _waiting - } - } - } - - Connections { - target: Daemon.currentWallet - function onOtpSuccess() { - _waiting = false - otpauth = otpEdit.text - dialog.accept() - } - function onOtpFailed(code, message) { - _waiting = false - _otpError = message - otpEdit.text = '' - } - } - - FontMetrics { - id: fontMetrics - font: otpEdit.font - } -} diff --git a/electrum/gui/qml/components/TxDetails.qml b/electrum/gui/qml/components/TxDetails.qml index 0a5d217be9ce..b353d47ccb24 100644 --- a/electrum/gui/qml/components/TxDetails.qml +++ b/electrum/gui/qml/components/TxDetails.qml @@ -447,7 +447,7 @@ Pane { } } else if (txdetails.wallet.isWatchOnly) { msg = qsTr('This transaction should be signed. Present this QR code to the signing device') - } else if (txdetails.wallet.isMultisig && txdetails.wallet.walletType != '2fa') { + } else if (txdetails.wallet.isMultisig) { if (txdetails.canSign) { msg = qsTr('Note: this wallet can sign, but has not signed this transaction yet') } else { diff --git a/electrum/gui/qml/components/WalletDetails.qml b/electrum/gui/qml/components/WalletDetails.qml index eaacb621be2b..71edf066bdef 100644 --- a/electrum/gui/qml/components/WalletDetails.qml +++ b/electrum/gui/qml/components/WalletDetails.qml @@ -299,70 +299,11 @@ Pane { Label { Layout.fillWidth: true visible: _is2fa - text: Daemon.currentWallet.canSignWithoutServer - ? qsTr('disabled (can sign without server)') + text: Daemon.currentWallet.canSignWithoutCosigner + ? qsTr('disabled (can sign without cosigner)') : qsTr('enabled') } - Label { - visible: _is2fa && !Daemon.currentWallet.canSignWithoutServer - text: qsTr('Remaining TX') - color: Material.accentColor - } - - Label { - Layout.fillWidth: true - visible: _is2fa && !Daemon.currentWallet.canSignWithoutServer - text: 'tx_remaining' in Daemon.currentWallet.billingInfo - ? Daemon.currentWallet.billingInfo['tx_remaining'] - : qsTr('unknown') - } - - Label { - Layout.columnSpan: 2 - Layout.topMargin: constants.paddingSmall - visible: _is2fa && !Daemon.currentWallet.canSignWithoutServer - text: qsTr('Billing') - color: Material.accentColor - } - - TextHighlightPane { - Layout.columnSpan: 2 - Layout.fillWidth: true - visible: _is2fa && !Daemon.currentWallet.canSignWithoutServer - - ColumnLayout { - spacing: 0 - - ButtonGroup { - id: billinggroup - onCheckedButtonChanged: { - Config.trustedcoinPrepay = checkedButton.value - } - } - - Repeater { - model: AppController.plugin('trustedcoin').billingModel - delegate: RowLayout { - RadioButton { - ButtonGroup.group: billinggroup - property string value: modelData.value - text: modelData.text - checked: modelData.value == Config.trustedcoinPrepay - } - Label { - text: Config.formatSats(modelData.sats_per_tx) - font.family: FixedFont - } - Label { - text: Config.baseUnit + '/tx' - color: Material.accentColor - } - } - } - } - } - Repeater { id: keystores model: Daemon.currentWallet.keystores diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml index 55f73519db45..0154180072d4 100644 --- a/electrum/gui/qml/components/WalletMainView.qml +++ b/electrum/gui/qml/components/WalletMainView.qml @@ -479,11 +479,6 @@ Item { }) dialog.open() } - function onOtpRequested() { - console.log('OTP requested') - var dialog = otpDialog.createObject(mainView) - dialog.open() - } function onBroadcastFailed(txid, code, message) { var dialog = app.messageDialog.createObject(app, { title: qsTr('Error'), @@ -748,16 +743,6 @@ Item { } } - Component { - id: otpDialog - OtpDialog { - width: parent.width * 2/3 - anchors.centerIn: parent - - onClosed: destroy() - } - } - Component { id: exportTxDialog ExportTxDialog { diff --git a/electrum/gui/qml/components/controls/TxOutput.qml b/electrum/gui/qml/components/controls/TxOutput.qml index f9309c0aceab..6f86a97caa5c 100644 --- a/electrum/gui/qml/components/controls/TxOutput.qml +++ b/electrum/gui/qml/components/controls/TxOutput.qml @@ -21,9 +21,7 @@ TextHighlightPane { : qsTr('mine') : model.is_swap ? qsTr('swap') - : model.is_billing - ? qsTr('billing') - : "" + : "" RowLayout { width: parent.width @@ -81,13 +79,11 @@ TextHighlightPane { ? model.is_change ? constants.colorAddressInternal : constants.colorAddressExternal - : model.is_billing - ? constants.colorAddressBilling - : model.is_swap - ? constants.colorAddressSwap - : model.is_accounting - ? constants.colorAddressAccounting - : Material.foreground + : model.is_swap + ? constants.colorAddressSwap + : model.is_accounting + ? constants.colorAddressAccounting + : Material.foreground TapHandler { enabled: allowClickAddress && model.is_mine onTapped: { diff --git a/electrum/gui/qml/qeconfig.py b/electrum/gui/qml/qeconfig.py index d4ca6b9d5aa0..a7506de8b5fc 100644 --- a/electrum/gui/qml/qeconfig.py +++ b/electrum/gui/qml/qeconfig.py @@ -226,17 +226,6 @@ def useRecoverableChannels(self, useRecoverableChannels): self.config.LIGHTNING_USE_RECOVERABLE_CHANNELS = useRecoverableChannels self.useRecoverableChannelsChanged.emit() - trustedcoinPrepayChanged = pyqtSignal() - @pyqtProperty(int, notify=trustedcoinPrepayChanged) - def trustedcoinPrepay(self): - return self.config.PLUGIN_TRUSTEDCOIN_NUM_PREPAY - - @trustedcoinPrepay.setter - def trustedcoinPrepay(self, num_prepay): - if num_prepay != self.config.PLUGIN_TRUSTEDCOIN_NUM_PREPAY: - self.config.PLUGIN_TRUSTEDCOIN_NUM_PREPAY = num_prepay - self.trustedcoinPrepayChanged.emit() - preferredRequestTypeChanged = pyqtSignal() @pyqtProperty(str, notify=preferredRequestTypeChanged) def preferredRequestType(self): diff --git a/electrum/gui/qml/qeinvoice.py b/electrum/gui/qml/qeinvoice.py index 65b4cda0b838..7cd1de3c00b8 100644 --- a/electrum/gui/qml/qeinvoice.py +++ b/electrum/gui/qml/qeinvoice.py @@ -449,8 +449,7 @@ def calc_max(address): make_tx = lambda fee_policy, *, confirmed_only=False: self._wallet.wallet.make_unsigned_transaction( coins=self._wallet.wallet.get_spendable_coins(None), outputs=outputs, - fee_policy=fee_policy, - is_sweep=False) + fee_policy=fee_policy) amount, message = self._wallet.determine_max(mktx=make_tx) if amount is None: self._amountOverride.isMax = False diff --git a/electrum/gui/qml/qetxdetails.py b/electrum/gui/qml/qetxdetails.py index a5e4dabe03fd..9a066aed9f4f 100644 --- a/electrum/gui/qml/qetxdetails.py +++ b/electrum/gui/qml/qetxdetails.py @@ -325,7 +325,6 @@ def update(self, from_txid: bool = False): 'short_id': '', # TODO 'is_mine': self._wallet.wallet.is_mine(x.get_ui_address_str()), 'is_change': self._wallet.wallet.is_change(x.get_ui_address_str()), - 'is_billing': self._wallet.wallet.is_billing_address(x.get_ui_address_str()), 'is_swap': False if not sm else sm.is_lockup_address_for_a_swap(x.get_ui_address_str()) or x.get_ui_address_str() == DummyAddress.SWAP, 'is_accounting': self._wallet.wallet.is_accounting_address(x.get_ui_address_str()) }, self._tx.outputs())) diff --git a/electrum/gui/qml/qetxfinalizer.py b/electrum/gui/qml/qetxfinalizer.py index 3480d759e43b..eddc0547da21 100644 --- a/electrum/gui/qml/qetxfinalizer.py +++ b/electrum/gui/qml/qetxfinalizer.py @@ -16,7 +16,6 @@ ) from electrum.wallet import CannotBumpFee, CannotDoubleSpendTx, CannotCPFP, BumpFeeStrategy, sweep_preparations from electrum import keystore -from electrum.plugin import run_hook from electrum.fee_policy import FeePolicy, FeeMethod from electrum.network import NetworkException @@ -360,7 +359,6 @@ def update_outputs_from_tx(self, tx: PartialTransaction): 'short_id': str(TxOutpoint(bytes.fromhex(tx.txid()), idx).short_name()) if tx.txid() else '', 'is_mine': self._wallet.wallet.is_mine(o.get_ui_address_str()), 'is_change': self._wallet.wallet.is_change(o.get_ui_address_str()), - 'is_billing': self._wallet.wallet.is_billing_address(o.get_ui_address_str()), 'is_swap': False if not sm else sm.is_lockup_address_for_a_swap(o.get_ui_address_str()) or o.get_ui_address_str() == DummyAddress.SWAP, 'is_accounting': self._wallet.wallet.is_accounting_address(o.get_ui_address_str()), 'is_reserve': o.is_utxo_reserve @@ -409,7 +407,6 @@ def __init__( self._address = '' self._amount = QEAmount() self._effectiveAmount = QEAmount() - self._extraFee = QEAmount() self._canRbf = False addressChanged = pyqtSignal() @@ -441,18 +438,6 @@ def amount(self, amount: QEAmount): def effectiveAmount(self): return self._effectiveAmount - extraFeeChanged = pyqtSignal() - @pyqtProperty(QVariant, notify=extraFeeChanged) - def extraFee(self) -> QEAmount: - return self._extraFee - - @extraFee.setter - def extraFee(self, extrafee: QEAmount): - assert extrafee is None or isinstance(extrafee, QEAmount) - if self._extraFee != extrafee: - self._extraFee.copyFrom(extrafee) - self.extraFeeChanged.emit() - canRbfChanged = pyqtSignal() @pyqtProperty(bool, notify=canRbfChanged) def canRbf(self): @@ -520,11 +505,6 @@ def update(self): self.update_from_tx(tx) - x_fee = run_hook('get_tx_extra_fee', self._wallet.wallet, tx) - if x_fee: - x_fee_address, x_fee_amount = x_fee - self.extraFee = QEAmount(amount_sat=x_fee_amount) - self.update_fee_warning_from_tx(tx=tx, invoice_amt=amount) if self._amount.isMax and not self.warning: @@ -1152,7 +1132,7 @@ def make_sweep_tx(self): outputs = [PartialTxOutput.from_address_and_value(address, value='!')] tx = self._wallet.wallet.make_unsigned_transaction( - coins=coins, outputs=outputs, fee_policy=self._fee_policy, rbf=self._rbf, is_sweep=True) + coins=coins, outputs=outputs, fee_policy=self._fee_policy, rbf=self._rbf) self._logger.debug('fee: %d, inputs: %d, outputs: %d' % (tx.get_fee(), len(tx.inputs()), len(tx.outputs()))) tx.sign(keypairs) diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index ed3ecf3fbf13..6630dce739f0 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -1,10 +1,8 @@ import asyncio import base64 import queue -import threading import time from typing import TYPE_CHECKING, Callable, Optional, Any, Tuple -from functools import partial from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QTimer @@ -18,7 +16,6 @@ UserFacingException, ) from electrum.lnutil import MIN_FUNDING_SAT -from electrum.plugin import run_hook from electrum.wallet import Multisig_Wallet from electrum.crypto import pw_decode_with_version_and_mac from electrum.fee_policy import FeePolicy, FixedFeePolicy @@ -76,9 +73,6 @@ def getInstanceFor(cls, wallet): saveTxSuccess = pyqtSignal([str], arguments=['txid']) saveTxError = pyqtSignal([str, str, str], arguments=['txid', 'code', 'message']) importChannelBackupFailed = pyqtSignal([str], arguments=['message']) - otpRequested = pyqtSignal() - otpSuccess = pyqtSignal() - otpFailed = pyqtSignal([str, str], arguments=['code', 'message']) peersUpdated = pyqtSignal() seedRetrieved = pyqtSignal() messageSigned = pyqtSignal([str], arguments=['signature']) @@ -114,8 +108,6 @@ def __init__(self, wallet: 'Abstract_Wallet', parent=None): self._seed = '' self._seed_passphrase = '' - self._otp_on_submit = None # type: Callable[[str], None] - self.tx_notification_queue = queue.Queue() self.tx_notification_last_time = 0 @@ -351,13 +343,6 @@ def name(self): def isLightning(self): return bool(self.wallet.lnworker) - billingInfoChanged = pyqtSignal() - @pyqtProperty('QVariantMap', notify=billingInfoChanged) - def billingInfo(self): - if self.wallet.wallet_type != '2fa': - return {} - return self.wallet.billing_info if self.wallet.billing_info is not None else {} - @pyqtProperty(bool, notify=dataChanged) def canHaveLightning(self): return self.wallet.can_have_lightning() @@ -446,15 +431,11 @@ def derivationPrefix(self): def masterPubkey(self): return self.wallet.get_master_public_key() - @pyqtProperty(bool, notify=dataChanged) - def canSignWithoutServer(self): - return self.wallet.can_sign_without_server() if self.wallet.wallet_type == '2fa' else True - @pyqtProperty(bool, notify=dataChanged) def canSignWithoutCosigner(self): if isinstance(self.wallet, Multisig_Wallet): - if self.wallet.wallet_type == '2fa': # 2fa is multisig, but it handles cosigning itself - return True + if self.wallet.wallet_type == '2fa': + return self.wallet.can_sign_without_cosigner() return self.wallet.m == 1 return True @@ -559,10 +540,6 @@ def sign(self, tx, *, self.do_sign(tx, False, on_success, on_failure) def do_sign(self, tx, broadcast, on_success: Callable[[Transaction], None] = None, on_failure: Callable[[Optional[Any]], None] = None): - # tc_sign_wrapper is only used by 2fa. don't pass on_failure handler, it is handled via otpFailed signal - sign_hook = run_hook('tc_sign_wrapper', self.wallet, tx, - partial(self.on_sign_complete, broadcast, on_success), - partial(self.on_sign_failed, None)) try: # ignore_warnings=True, because UI checks and asks user confirmation itself tx = self.wallet.sign_transaction(tx, self.password, ignore_warnings=True) @@ -578,11 +555,6 @@ def do_sign(self, tx, broadcast, on_success: Callable[[Transaction], None] = Non on_failure() return - if sign_hook: - self._logger.debug('plugin needs to sign tx too') - sign_hook(tx) - return - txid = tx.txid() self._logger.debug(f'do_sign(), txid={txid}') @@ -599,30 +571,6 @@ def do_sign(self, tx, broadcast, on_success: Callable[[Transaction], None] = Non if on_success: on_success(tx) - # this assumes a 2fa wallet, but there are no other tc_sign_wrapper hooks, so that's ok - def on_sign_complete(self, broadcast, cb: Callable[[Transaction], None] = None, tx: Transaction = None): - self.otpSuccess.emit() - if cb: - cb(tx) - if broadcast: - self.broadcast(tx) - - # this assumes a 2fa wallet, but there are no other tc_sign_wrapper hooks, so that's ok - def on_sign_failed(self, cb: Callable[[], None] | None = None, error: str | None = None): - self.otpFailed.emit('error', error) - if cb: - cb() - - def request_otp(self, on_submit: Callable[[str], None]): - self._otp_on_submit = on_submit - self.otpRequested.emit() - - @pyqtSlot(str) - def submitOtp(self, otp): - def submit_otp_task(): - self._otp_on_submit(otp) - threading.Thread(target=submit_otp_task, daemon=True).start() - def broadcast(self, tx): assert tx.is_complete() diff --git a/electrum/gui/qt/__init__.py b/electrum/gui/qt/__init__.py index 0f8214b4e14b..34f13f0a4ec4 100644 --- a/electrum/gui/qt/__init__.py +++ b/electrum/gui/qt/__init__.py @@ -72,12 +72,9 @@ WalletFileException, get_new_wallet_name, InvalidPassword, standardize_path, UserFacingException) from electrum.wallet import Wallet, Abstract_Wallet -from electrum.wallet_db import WalletRequiresSplit, WalletRequiresUpgrade, WalletUnfinished +from electrum.wallet_db import WalletRequiresSplit, WalletRequiresUpgrade from electrum.gui import BaseElectrumGui from electrum.simple_config import SimpleConfig -from electrum.wizard import WizardViewState -from electrum.keystore import load_keystore -from electrum.bip32 import is_xprv from electrum import constants from electrum.gui.common_qt.i18n import ElectrumTranslator @@ -396,8 +393,6 @@ def __handle_wallet_loading_exc(exc: Exception, pos): pass # open with wizard below except WalletRequiresUpgrade: pass # open with wizard below - except WalletUnfinished: - pass # open with wizard below except Exception as e: __handle_wallet_loading_exc(e, 1) # if app is starting, still let wizard appear @@ -470,8 +465,6 @@ def _start_wizard_to_select_or_create_wallet(self, path) -> Optional[Abstract_Wa if not d['wallet_exists']: self.logger.info('about to create wallet') wizard.create_storage() - if d['wallet_type'] == '2fa' and 'x3' not in d: - return wallet_file = wizard.path else: wallet_file = d['wallet_name'] @@ -484,36 +477,6 @@ def _start_wizard_to_select_or_create_wallet(self, path) -> Optional[Abstract_Wa except WalletRequiresSplit as e: wizard.run_split(wallet_file, e._split_data) return - except WalletUnfinished as e: - # wallet creation is not complete, 2fa online phase - db = e._wallet_db - action = db.get_action() - assert action[1] == 'accept_terms_of_use', 'only support for resuming trustedcoin split setup' - k1 = load_keystore(db, 'x1') - if password is not None: - xprv = k1.get_master_private_key(password) - else: - xprv = db.get('x1')['xprv'] - if not is_xprv(xprv): - xprv = k1 - _wiz_data_updates = { - 'wallet_name': wallet_file, - 'xprv1': xprv, - 'xpub1': db.get('x1')['xpub'], - 'xpub2': db.get('x2')['xpub'], - } - data = {**d, **_wiz_data_updates} - wizard = QENewWalletWizard(self.config, self.app, self.plugins, self.daemon, path, - start_viewstate=WizardViewState('trustedcoin_tos', data, {})) - result = wizard.exec() - if result == QDialog.DialogCode.Rejected: - self.logger.info('wizard dialog cancelled by user') - return - db.put('x3', wizard.get_wizard_data()['x3']) - db.write_and_force_consolidation() # TODO API for db is a bit weird: there should be a close method - - wallet = self.daemon.load_wallet(wallet_file, password, upgrade=True) - return wallet def close_window(self, window: ElectrumWindow): if window in self.windows: diff --git a/electrum/gui/qt/confirm_tx_dialog.py b/electrum/gui/qt/confirm_tx_dialog.py index 459c8fc1b50a..ddc8c4e73609 100644 --- a/electrum/gui/qt/confirm_tx_dialog.py +++ b/electrum/gui/qt/confirm_tx_dialog.py @@ -36,7 +36,6 @@ from electrum.i18n import _ from electrum.util import (UserCancelled, quantize_feerate, profiler, NotEnoughFunds, NoDynamicFeeEstimates, UserFacingException) -from electrum.plugin import run_hook from electrum.transaction import PartialTransaction, PartialTxOutput, Transaction from electrum.wallet import InternalAddressCorruption from electrum.bitcoin import DummyAddress @@ -652,7 +651,6 @@ def _update_widgets(self): self.locktime_e.set_locktime(self.tx.locktime) self.io_widget.update(self.tx) self.fee_label.setText(self.main_window.config.format_amount_and_units(self.tx.get_fee())) - self._update_extra_fees() if self.send_change_to_lightning_available(): self.change_to_ln_swap_providers_button.setVisible(True) @@ -722,9 +720,6 @@ def set_locktime(self): def _update_amount_label(self): pass - def _update_extra_fees(self): - pass - def _update_message(self): style = ColorScheme.RED if self.error else ColorScheme.BLUE message_str = '\n'.join(self.messages) if self.messages else '' @@ -1109,25 +1104,8 @@ def create_grid(self): grid.setColumnStretch(4, 1) - # extra fee - self.extra_fee_label = QLabel(_("Additional fees") + ": ") - self.extra_fee_label.setVisible(False) - self.extra_fee_value = QLabel('') - self.extra_fee_value.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) - self.extra_fee_value.setVisible(False) - grid.addWidget(self.extra_fee_label, 5, 0) - grid.addWidget(self.extra_fee_value, 5, 1) - # locktime editor grid.addWidget(self.locktime_label, 6, 0) grid.addWidget(self.locktime_e, 6, 1, 1, 2) return grid - - def _update_extra_fees(self): - x_fee = run_hook('get_tx_extra_fee', self.wallet, self.tx) - if x_fee: - x_fee_address, x_fee_amount = x_fee - self.extra_fee_label.setVisible(True) - self.extra_fee_value.setVisible(True) - self.extra_fee_value.setText(self.main_window.format_amount_and_units(x_fee_amount)) diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py index aeb7458ebed8..5321776ca461 100644 --- a/electrum/gui/qt/main_window.py +++ b/electrum/gui/qt/main_window.py @@ -1430,7 +1430,8 @@ def get_manually_selected_coins(self) -> Optional[Sequence[PartialTxInput]]: def broadcast_or_show(self, tx: Transaction, *, invoice: 'Invoice' = None): if not tx.is_complete(): - self.show_transaction(tx, invoice=invoice) + if not run_hook('show_incomplete_tx', self, tx): + self.show_transaction(tx, invoice=invoice) return if not self.network: self.show_error(_("You can't broadcast a transaction without a live network connection.")) @@ -1469,7 +1470,6 @@ def on_success(result): def on_failure(exc_info): self.on_error(exc_info) callback(False) - on_success = run_hook('tc_sign_wrapper', self.wallet, tx, on_success, on_failure) or on_success if external_keypairs: # can sign directly task = partial(tx.sign, external_keypairs) diff --git a/electrum/gui/qt/send_tab.py b/electrum/gui/qt/send_tab.py index 761ed9e67741..f157918ae407 100644 --- a/electrum/gui/qt/send_tab.py +++ b/electrum/gui/qt/send_tab.py @@ -246,16 +246,13 @@ def spend_max(self): if pi.type == PaymentIdentifierType.BIP21: assert 'amount' not in pi.bip21 - if run_hook('abort_send', self): - return outputs = pi.get_onchain_outputs('!') if not outputs: return make_tx = lambda fee_policy, *, confirmed_only=False: self.wallet.make_unsigned_transaction( fee_policy=fee_policy, coins=self.window.get_coins(), - outputs=outputs, - is_sweep=False) + outputs=outputs) try: try: tx = make_tx(FeePolicy(self.config.FEE_POLICY)) @@ -271,16 +268,11 @@ def spend_max(self): self.max_button.setChecked(True) amount = tx.output_value() - __, x_fee_amount = run_hook('get_tx_extra_fee', self.wallet, tx) or (None, 0) - amount_after_all_fees = amount - x_fee_amount - self.amount_e.setAmount(amount_after_all_fees) + self.amount_e.setAmount(amount) # show tooltip explaining max amount mining_fee = tx.get_fee() mining_fee_str = self.format_amount_and_units(mining_fee) msg = _("Mining fee: {} (can be adjusted on next screen)").format(mining_fee_str) - if x_fee_amount: - twofactor_fee_str = self.format_amount_and_units(x_fee_amount) - msg += "\n" + _("2fa fee: {} (for the next batch of transactions)").format(twofactor_fee_str) frozen_bal = self.wallet.get_frozen_balance_str() if frozen_bal: msg += "\n" + _("Some coins are frozen: {} (can be unfrozen in the Addresses or in the Coins tab)").format(frozen_bal) @@ -298,11 +290,6 @@ def pay_onchain_dialog( get_coins: Callable[..., Sequence[PartialTxInput]] = None, invoice: Optional[Invoice] = None ) -> None: - # trustedcoin requires this - if run_hook('abort_send', self): - return - - is_sweep = bool(external_keypairs) # we call get_coins inside make_tx, so that inputs can be changed dynamically if get_coins is None: get_coins = self.window.get_coins @@ -314,7 +301,6 @@ def make_tx(fee_policy, *, confirmed_only=False, base_tx=None): coins=coins, outputs=outputs, base_tx=base_tx, - is_sweep=is_sweep, send_change_to_lightning=self.config.WALLET_SEND_CHANGE_TO_LIGHTNING, merge_duplicate_outputs=self.config.WALLET_MERGE_DUPLICATE_OUTPUTS, ) @@ -347,8 +333,6 @@ def make_tx(fee_policy, *, confirmed_only=False, base_tx=None): tx, external_keypairs=external_keypairs, invoice=invoice, - show_sign_button=self.wallet.wallet_type != '2fa', - show_broadcast_button=self.wallet.wallet_type != '2fa', ) return self.save_pending_invoice() diff --git a/electrum/gui/qt/transaction_dialog.py b/electrum/gui/qt/transaction_dialog.py index 27a8e097f5f2..fd8697c583d8 100644 --- a/electrum/gui/qt/transaction_dialog.py +++ b/electrum/gui/qt/transaction_dialog.py @@ -135,8 +135,6 @@ def __init__(self, main_window: 'ElectrumWindow', wallet: 'Abstract_Wallet'): legend=_("Change Address"), color=ColorScheme.YELLOW, tooltip=_("Wallet change address")) self.txo_color_accounting = TxOutputColoring( legend=_("Accounting Address"), color=ColorScheme.ORANGE, tooltip=_("Address from which funds were swept to your wallet.")) - self.txo_color_2fa = TxOutputColoring( - legend=_("TrustedCoin (2FA) batch fee"), color=ColorScheme.BLUE, tooltip=_("TrustedCoin (2FA) fee for the next batch of transactions")) self.txo_color_swap = TxOutputColoring( legend=_("Submarine swap address"), color=ColorScheme.BLUE, tooltip=_("Submarine swap address")) self.outputs_header = QLabel() @@ -154,7 +152,6 @@ def __init__(self, main_window: 'ElectrumWindow', wallet: 'Abstract_Wallet'): outheader_hbox.addStretch(2) outheader_hbox.addWidget(self.txo_color_recv.legend_label) outheader_hbox.addWidget(self.txo_color_change.legend_label) - outheader_hbox.addWidget(self.txo_color_2fa.legend_label) outheader_hbox.addWidget(self.txo_color_swap.legend_label) outheader_hbox.addWidget(self.txo_color_accounting.legend_label) @@ -182,11 +179,11 @@ def update(self, tx: Optional[Transaction]): lnk.setToolTip(_('Click to open, right-click for menu')) lnk.setAnchor(True) lnk.setUnderlineStyle(QTextCharFormat.UnderlineStyle.SingleUnderline) - tf_used_recv, tf_used_change, tf_used_2fa, tf_used_swap = False, False, False, False + tf_used_recv, tf_used_change, tf_used_swap = False, False, False tf_used_accounting = False def addr_text_format(addr: str) -> QTextCharFormat: - nonlocal tf_used_recv, tf_used_change, tf_used_2fa, tf_used_swap, tf_used_accounting + nonlocal tf_used_recv, tf_used_change, tf_used_swap, tf_used_accounting sm = self.wallet.lnworker.swap_manager if self.wallet.lnworker else None if self.wallet.is_mine(addr): if self.wallet.is_change(addr): @@ -203,9 +200,6 @@ def addr_text_format(addr: str) -> QTextCharFormat: elif sm and sm.is_lockup_address_for_a_swap(addr) or addr == DummyAddress.SWAP: tf_used_swap = True return self.txo_color_swap.text_char_format - elif self.wallet.is_billing_address(addr): - tf_used_2fa = True - return self.txo_color_2fa.text_char_format elif self.wallet.is_accounting_address(addr): tf_used_accounting = True return self.txo_color_accounting.text_char_format @@ -304,7 +298,6 @@ def insert_tx_io( self.txo_color_recv.legend_label.setVisible(tf_used_recv) self.txo_color_change.legend_label.setVisible(tf_used_change) - self.txo_color_2fa.legend_label.setVisible(tf_used_2fa) self.txo_color_swap.legend_label.setVisible(tf_used_swap) self.txo_color_accounting.legend_label.setVisible(tf_used_accounting) diff --git a/electrum/plugins/timelock_recovery/timelock_recovery.py b/electrum/plugins/timelock_recovery/timelock_recovery.py index 9d42cf44f0ca..7835fd425a65 100644 --- a/electrum/plugins/timelock_recovery/timelock_recovery.py +++ b/electrum/plugins/timelock_recovery/timelock_recovery.py @@ -91,7 +91,6 @@ def make_unsigned_alert_tx(self, fee_policy) -> 'PartialTransaction': coins=self.wallet.get_spendable_coins(confirmed_only=False), outputs=alert_tx_outputs, fee_policy=fee_policy, - is_sweep=False, locktime=self.alert_tx.locktime if self.alert_tx else None, ) @@ -125,7 +124,6 @@ def make_unsigned_recovery_tx(self, fee_policy) -> 'PartialTransaction': coins=[recovery_tx_input], outputs=[output for output in self.outputs if output.value != 0], fee_policy=fee_policy, - is_sweep=False, locktime=self.recovery_tx.locktime if self.recovery_tx else None, ) @@ -150,7 +148,6 @@ def make_unsigned_cancellation_tx(self, fee_policy) -> 'PartialTransaction': PartialTxOutput(scriptpubkey=address_to_script(self.get_cancellation_address()), value='!'), ], fee_policy=fee_policy, - is_sweep=False, locktime=self.cancellation_tx.locktime if self.cancellation_tx else None, ) diff --git a/electrum/plugins/trustedcoin/cmdline.py b/electrum/plugins/trustedcoin/cmdline.py index 2ad281aae48d..64395056c51d 100644 --- a/electrum/plugins/trustedcoin/cmdline.py +++ b/electrum/plugins/trustedcoin/cmdline.py @@ -23,22 +23,8 @@ # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from electrum.i18n import _ from .trustedcoin import TrustedCoinPlugin class Plugin(TrustedCoinPlugin): - - def prompt_user_for_otp(self, wallet, tx): # FIXME this is broken - if not isinstance(wallet, self.wallet_class): - return - if not wallet.can_sign_without_server(): - self.logger.info("twofactor:sign_tx") - auth_code = None - if wallet.keystores['x3'].can_sign(tx, ignore_watching_only=True): - msg = _('Please enter your Google Authenticator code:') - auth_code = int(input(msg)) - else: - self.logger.info("twofactor: xpub3 not needed") - wallet.auth_code = auth_code - + pass diff --git a/electrum/plugins/trustedcoin/common_qt.py b/electrum/plugins/trustedcoin/common_qt.py deleted file mode 100644 index 272421fd2987..000000000000 --- a/electrum/plugins/trustedcoin/common_qt.py +++ /dev/null @@ -1,256 +0,0 @@ -import threading -import socket -import base64 -from typing import TYPE_CHECKING - -from PyQt6.QtCore import pyqtSignal, pyqtProperty, pyqtSlot - -from electrum.i18n import _ -from electrum.bip32 import BIP32Node -from electrum import bitcoin - -from .trustedcoin import (server, ErrorConnectingServer, MOBILE_DISCLAIMER, TrustedCoinException) -from electrum.gui.common_qt.plugins import PluginQObject - -if TYPE_CHECKING: - from electrum.wizard import NewWalletWizard - - -class TrustedcoinPluginQObject(PluginQObject): - canSignWithoutServerChanged = pyqtSignal() - termsAndConditionsRetrieved = pyqtSignal([str], arguments=['message']) - termsAndConditionsError = pyqtSignal([str], arguments=['message']) - otpError = pyqtSignal([str], arguments=['message']) - otpSuccess = pyqtSignal() - disclaimerChanged = pyqtSignal() - keystoreChanged = pyqtSignal() - otpSecretChanged = pyqtSignal() - shortIdChanged = pyqtSignal() - billingModelChanged = pyqtSignal() - - remoteKeyStateChanged = pyqtSignal() - remoteKeyError = pyqtSignal([str], arguments=['message']) - - requestOtp = pyqtSignal() - - def __init__(self, plugin, wizard: 'NewWalletWizard', parent): - super().__init__(plugin, parent) - self.wizard = wizard - self._canSignWithoutServer = False - self._otpSecret = '' - self._shortId = '' - self._billingModel = [] - self._remoteKeyState = '' - self._verifyingOtp = False - - @pyqtProperty(str, notify=disclaimerChanged) - def disclaimer(self): - return '\n\n'.join(MOBILE_DISCLAIMER) - - @pyqtProperty(bool, notify=canSignWithoutServerChanged) - def canSignWithoutServer(self): - return self._canSignWithoutServer - - @pyqtProperty('QVariantMap', notify=keystoreChanged) - def keystore(self): - return self._keystore - - @pyqtProperty(str, notify=otpSecretChanged) - def otpSecret(self): - return self._otpSecret - - @pyqtProperty(str, notify=shortIdChanged) - def shortId(self): - return self._shortId - - @pyqtProperty(str, notify=remoteKeyStateChanged) - def remoteKeyState(self): - return self._remoteKeyState - - @remoteKeyState.setter - def remoteKeyState(self, new_state): - if self._remoteKeyState != new_state: - self._remoteKeyState = new_state - self.remoteKeyStateChanged.emit() - - @pyqtProperty('QVariantList', notify=billingModelChanged) - def billingModel(self): - return self._billingModel - - def updateBillingInfo(self, wallet): - billing_model = [] - - price_per_tx = wallet.price_per_tx - for k, v in sorted(price_per_tx.items()): - if k == 1: - continue - item = { - 'text': 'Pay every %d transactions' % k, - 'value': k, - 'sats_per_tx': v / k - } - billing_model.append(item) - - self._billingModel = billing_model - self.billingModelChanged.emit() - - @pyqtSlot() - def fetchTermsAndConditions(self): - def fetch_task(): - try: - self.plugin.logger.debug('TOS') - tos = server.get_terms_of_service() - except ErrorConnectingServer as e: - self.termsAndConditionsError.emit(_('Error connecting to server')) - except Exception as e: - self.termsAndConditionsError.emit('%s: %s' % (_('Error'), repr(e))) - else: - self.termsAndConditionsRetrieved.emit(tos) - finally: - self._busy = False - self.busyChanged.emit() - - self._busy = True - self.busyChanged.emit() - t = threading.Thread(target=fetch_task) - t.daemon = True - t.start() - - @pyqtSlot() - def createKeystore(self): - email = 'dummy@electrum.org' - - self.remoteKeyState = '' - self._otpSecret = '' - self.otpSecretChanged.emit() - - wizard_data = self.wizard.get_wizard_data() - - xprv1, xpub1, xprv2, xpub2, xpub3, short_id = self.plugin.create_keys(wizard_data) - - def create_remote_key_task(): - try: - self.plugin.logger.debug('create remote key') - r = server.create(xpub1, xpub2, email) - - otp_secret = r['otp_secret'] - _xpub3 = r['xpubkey_cosigner'] - _id = r['id'] - except (socket.error, ErrorConnectingServer) as e: - self.remoteKeyState = 'error' - self.remoteKeyError.emit(f'Network error: {str(e)}') - except TrustedCoinException as e: - if e.status_code == 409: - self.remoteKeyState = 'wallet_known' - self._shortId = short_id - self.shortIdChanged.emit() - else: - self.remoteKeyState = 'error' - self.logger.warning(str(e)) - self.remoteKeyError.emit(f'Service error: {str(e)}') - except (KeyError, TypeError) as e: # catch any assumptions - self.remoteKeyState = 'error' - self.remoteKeyError.emit(f'Error: {str(e)}') - self.logger.error(str(e)) - else: - if short_id != _id: - self.remoteKeyState = 'error' - self.logger.error("unexpected trustedcoin short_id: expected {}, received {}".format(short_id, _id)) - self.remoteKeyError.emit('Unexpected short_id') - return - if xpub3 != _xpub3: - self.remoteKeyState = 'error' - self.logger.error("unexpected trustedcoin xpub3: expected {}, received {}".format(xpub3, _xpub3)) - self.remoteKeyError.emit('Unexpected trustedcoin xpub3') - return - self.remoteKeyState = 'new' - self._otpSecret = otp_secret - self.otpSecretChanged.emit() - self._shortId = short_id - self.shortIdChanged.emit() - finally: - self._busy = False - self.busyChanged.emit() - - self._busy = True - self.busyChanged.emit() - - t = threading.Thread(target=create_remote_key_task) - t.daemon = True - t.start() - - @pyqtSlot() - def resetOtpSecret(self): - self.remoteKeyState = '' - - wizard_data = self.wizard.get_wizard_data() - - xprv1, xpub1, xprv2, xpub2, xpub3, short_id = self.plugin.create_keys(wizard_data) - - def reset_otp_task(): - try: - self.plugin.logger.debug('reset_otp') - r = server.get_challenge(short_id) - challenge = r.get('challenge') - message = 'TRUSTEDCOIN CHALLENGE: ' + challenge - - def f(xprv): - rootnode = BIP32Node.from_xkey(xprv) - key = rootnode.subkey_at_private_derivation((0, 0)).eckey - sig = bitcoin.ecdsa_sign_usermessage(key, message, is_compressed=True) - return base64.b64encode(sig).decode() - - signatures = [f(x) for x in [xprv1, xprv2]] - r = server.reset_auth(short_id, challenge, signatures) - otp_secret = r.get('otp_secret') - except (socket.error, ErrorConnectingServer) as e: - self.remoteKeyState = 'error' - self.remoteKeyError.emit(f'Network error: {str(e)}') - except Exception as e: - self.remoteKeyState = 'error' - self.remoteKeyError.emit(f'Error: {str(e)}') - else: - self.remoteKeyState = 'reset' - self._otpSecret = otp_secret - self.otpSecretChanged.emit() - finally: - self._busy = False - self.busyChanged.emit() - - self._busy = True - self.busyChanged.emit() - - t = threading.Thread(target=reset_otp_task, daemon=True) - t.start() - - @pyqtSlot(str, int) - def checkOtp(self, short_id, otp): - assert type(otp) is int # make sure this doesn't fail subtly - - def check_otp_task(): - try: - self.plugin.logger.debug(f'check OTP, shortId={short_id}, otp={otp}') - server.auth(short_id, otp) - except TrustedCoinException as e: - if e.status_code == 400: # invalid OTP - self.plugin.logger.debug('Invalid one-time password.') - self.otpError.emit(_('Invalid one-time password.')) - else: - self.plugin.logger.error(str(e)) - self.otpError.emit(f'Service error: {str(e)}') - except Exception as e: - self.plugin.logger.error(str(e)) - self.otpError.emit(f'Error: {str(e)}') - else: - self.plugin.logger.debug('OTP verify success') - self.otpSuccess.emit() - finally: - self._busy = False - self.busyChanged.emit() - self._verifyingOtp = False - - self._verifyingOtp = True - self._busy = True - self.busyChanged.emit() - t = threading.Thread(target=check_otp_task, daemon=True) - t.start() diff --git a/electrum/plugins/trustedcoin/manifest.json b/electrum/plugins/trustedcoin/manifest.json index 7eb5631c06a2..31e8bdb0aa57 100644 --- a/electrum/plugins/trustedcoin/manifest.json +++ b/electrum/plugins/trustedcoin/manifest.json @@ -1,7 +1,7 @@ { "name": "trustedcoin", "fullname": "Two Factor Authentication", - "description": "This plugin adds two-factor authentication to your wallet.
For more information, visit https://api.trustedcoin.com/#/electrum-help", + "description": "This plugin adds two-factor authentication to your wallet, with the Electrum app on your phone as cosigner. It replaces the discontinued TrustedCoin cosigning service.", "requires_wallet_type": ["2fa"], "registers_wallet_type": "2fa", "icon":"trustedcoin-status.png", diff --git a/electrum/plugins/trustedcoin/qml.py b/electrum/plugins/trustedcoin/qml.py index 76c7a1217b50..28088a21b004 100644 --- a/electrum/plugins/trustedcoin/qml.py +++ b/electrum/plugins/trustedcoin/qml.py @@ -1,62 +1,56 @@ from functools import partial -from typing import TYPE_CHECKING, Callable +from typing import TYPE_CHECKING + +from PyQt6.QtCore import pyqtSignal, pyqtProperty, pyqtSlot -from electrum.i18n import _ from electrum.plugin import hook -from electrum.util import UserFacingException -from electrum.gui.qml.qewallet import QEWallet +from electrum.gui.common_qt.plugins import PluginQObject from electrum.gui.qml.qedaemon import QEDaemon -from .common_qt import TrustedcoinPluginQObject -from .trustedcoin import TrustedCoinPlugin, TrustedCoinException, Wallet_2fa +from .trustedcoin import TrustedCoinPlugin, MOBILE_DISCLAIMER, parse_cosigner_qr_data if TYPE_CHECKING: from electrum.gui.qml import ElectrumQmlApplication - from electrum.wallet import Abstract_Wallet - from electrum.wizard import NewWalletWizard - from electrum.transaction import PartialTransaction + from electrum.gui.qml.qewizard import QENewWalletWizard + + +class TrustedcoinPluginQObject(PluginQObject): + cosignerWalletCreated = pyqtSignal() + + @pyqtProperty(str) + def loader(self): + return 'main.qml' + + @pyqtProperty(str, constant=True) + def disclaimer(self): + return '\n\n'.join(MOBILE_DISCLAIMER) + + @pyqtSlot(str, result=bool) + def isCosignerQr(self, data: str) -> bool: + try: + parse_cosigner_qr_data(data) + except ValueError: + return False + return True class Plugin(TrustedCoinPlugin): def __init__(self, *args): super().__init__(*args) - self._app = None # type: ElectrumQmlApplication self.so = None # type: TrustedcoinPluginQObject - @hook - def load_wallet(self, wallet: 'Abstract_Wallet'): - if not isinstance(wallet, self.wallet_class): - return - self.logger.debug(f'plugin enabled for wallet "{str(wallet)}"') - if wallet.can_sign_without_server(): - self.so._canSignWithoutServer = True - self.so.canSignWithoutServerChanged.emit() - - msg = ' '.join([ - _('This wallet was restored from seed, and it contains two master private keys.'), - _('Therefore, two-factor authentication is disabled.') - ]) - self.logger.info(msg) - self.start_request_thread(wallet) - @hook def init_qml(self, app: 'ElectrumQmlApplication'): self.logger.debug(f'init_qml hook called, gui={str(type(app))}') - self._app = app wizard = QEDaemon.instance.newWalletWizard # important: TrustedcoinPluginQObject needs to be parented, as keeping a ref # in the plugin is not enough to avoid gc - # Note: storing the trustedcoin qt helper in the plugin is different from the desktop client, - # which stores the helper in the wizard object. As the mobile client only shows a single wizard - # at a time, this is ok for now. - self.so = TrustedcoinPluginQObject(self, wizard, self._app) - # extend wizard + self.so = TrustedcoinPluginQObject(self, app) self.extend_wizard(wizard) + wizard.createSuccess.connect(partial(self.on_wallet_created, wizard)) - # wizard support functions - - def extend_wizard(self, wizard: 'NewWalletWizard'): + def extend_wizard(self, wizard: 'QENewWalletWizard'): super().extend_wizard(wizard) views = { 'trustedcoin_start': { @@ -65,86 +59,26 @@ def extend_wizard(self, wizard: 'NewWalletWizard'): 'trustedcoin_choose_seed': { 'gui': '../../../../plugins/trustedcoin/qml/ChooseSeed', }, - 'trustedcoin_create_seed': { - 'gui': 'WCCreateSeed', - }, - 'trustedcoin_create_ext': { - 'gui': 'WCEnterExt', - }, - 'trustedcoin_confirm_seed': { - 'gui': 'WCConfirmSeed', - }, - 'trustedcoin_confirm_ext': { - 'gui': 'WCConfirmExt', + 'trustedcoin_scan_cosigner_qr': { + 'gui': '../../../../plugins/trustedcoin/qml/ScanCosignerQR', }, + # on mobile, restoring from seed disables two-factor authentication 'trustedcoin_have_seed': { 'gui': 'WCHaveSeed', + 'next': lambda d: 'trustedcoin_have_ext' if wizard.wants_ext(d) else 'wallet_password', + 'accept': lambda d: None if wizard.wants_ext(d) else self.recovery_disable(d), + 'last': lambda d: wizard.is_single_password() and not wizard.wants_ext(d), }, 'trustedcoin_have_ext': { 'gui': 'WCEnterExt', + 'next': 'wallet_password', + 'accept': self.recovery_disable, + 'last': lambda d: wizard.is_single_password(), }, - 'trustedcoin_keep_disable': { - 'gui': '../../../../plugins/trustedcoin/qml/KeepDisable', - }, - 'trustedcoin_tos': { - 'gui': '../../../../plugins/trustedcoin/qml/Terms', - }, - 'trustedcoin_keystore_unlock': { - # TODO when QML can import external wallet files - }, - 'trustedcoin_show_confirm_otp': { - 'gui': '../../../../plugins/trustedcoin/qml/ShowConfirmOTP', - } } wizard.navmap_merge(views) - # running wallet functions - - def prompt_user_for_otp( - self, - wallet: Wallet_2fa, - tx: 'PartialTransaction', - on_success: Callable[['PartialTransaction'], None], - on_failure: Callable[[str], None], - ): - self.logger.debug('prompt_user_for_otp') - qewallet = QEWallet.getInstanceFor(wallet) - qewallet.request_otp(partial(self.on_otp, wallet, tx, on_success=on_success, on_failure=on_failure)) - - def on_otp( - self, - wallet: Wallet_2fa, - tx: 'PartialTransaction', - otp, - *, - on_success: Callable[['PartialTransaction'], None], - on_failure: Callable[[str], None] = None - ): - self.logger.debug('on_otp') - assert wallet and isinstance(wallet, Wallet_2fa) - - on_failure = on_failure if on_failure else lambda x: self.logger.error(x) - - if not otp: - on_failure(_('No auth code')) - return - - try: - wallet.on_otp(tx, otp) - except UserFacingException as e: - on_failure(_('Invalid one-time password.')) - except TrustedCoinException as e: - if e.status_code == 400: # invalid OTP - on_failure(_('Invalid one-time password.')) - else: - on_failure(_('Service Error') + ':\n' + str(e)) - except Exception as e: - on_failure(_('Error') + ':\n' + str(e)) - else: - on_success(tx) - - def billing_info_retrieved(self, wallet): - self.logger.info('billing_info_retrieved') - qewallet = QEWallet.getInstanceFor(wallet) - qewallet.billingInfoChanged.emit() - self.so.updateBillingInfo(wallet) + def on_wallet_created(self, wizard: 'QENewWalletWizard'): + wizard_data = wizard.get_wizard_data() + if 'trustedcoin_cosigner_qr' in wizard_data: + self.so.cosignerWalletCreated.emit() diff --git a/electrum/plugins/trustedcoin/qml/ChooseSeed.qml b/electrum/plugins/trustedcoin/qml/ChooseSeed.qml index 8665dee27c94..6f53c8558c49 100644 --- a/electrum/plugins/trustedcoin/qml/ChooseSeed.qml +++ b/electrum/plugins/trustedcoin/qml/ChooseSeed.qml @@ -3,11 +3,12 @@ import QtQuick.Layouts 1.0 import QtQuick.Controls 2.1 import "../../../gui/qml/components/wizard" +import "../../../gui/qml/components/controls" WizardComponent { valid: keystoregroup.checkedButton !== null - onAccept: { + function apply() { wizard_data['keystore_type'] = keystoregroup.checkedButton.keystoretype } @@ -18,21 +19,22 @@ WizardComponent { ColumnLayout { width: parent.width Label { - text: qsTr('Do you want to create a new seed, or restore a wallet using an existing seed?') + text: qsTr('How do you want to set up your 2FA wallet?') Layout.preferredWidth: parent.width wrapMode: Text.Wrap } - RadioButton { + ElRadioButton { + Layout.fillWidth: true ButtonGroup.group: keystoregroup - property string keystoretype: 'createseed' + property string keystoretype: 'cosigner_qr' checked: true - text: qsTr('Create a new seed') + text: qsTr('Scan the cosigner QR code displayed by Electrum desktop') } - RadioButton { + ElRadioButton { + Layout.fillWidth: true ButtonGroup.group: keystoregroup property string keystoretype: 'haveseed' - text: qsTr('I already have a seed') + text: qsTr('Restore from my 2FA seed, with two-factor authentication disabled') } } } - diff --git a/electrum/plugins/trustedcoin/qml/KeepDisable.qml b/electrum/plugins/trustedcoin/qml/KeepDisable.qml deleted file mode 100644 index 3e10d5175739..000000000000 --- a/electrum/plugins/trustedcoin/qml/KeepDisable.qml +++ /dev/null @@ -1,35 +0,0 @@ -import QtQuick 2.6 -import QtQuick.Layouts 1.0 -import QtQuick.Controls 2.1 - -import "../../../gui/qml/components/wizard" - -WizardComponent { - valid: keepordisablegroup.checkedButton - - function apply() { - wizard_data['trustedcoin_keepordisable'] = keepordisablegroup.checkedButton.keepordisable - } - - ButtonGroup { - id: keepordisablegroup - onCheckedButtonChanged: checkIsLast() - } - - ColumnLayout { - Label { - text: qsTr('Restore 2FA wallet') - } - RadioButton { - ButtonGroup.group: keepordisablegroup - property string keepordisable: 'keep' - checked: true - text: qsTr('Keep') - } - RadioButton { - ButtonGroup.group: keepordisablegroup - property string keepordisable: 'disable' - text: qsTr('Disable') - } - } -} diff --git a/electrum/plugins/trustedcoin/qml/ScanCosignerQR.qml b/electrum/plugins/trustedcoin/qml/ScanCosignerQR.qml new file mode 100644 index 000000000000..043635c3e907 --- /dev/null +++ b/electrum/plugins/trustedcoin/qml/ScanCosignerQR.qml @@ -0,0 +1,87 @@ +import QtQuick 2.6 +import QtQuick.Layouts 1.0 +import QtQuick.Controls 2.1 + +import org.electrum 1.0 + +import "../../../gui/qml/components/wizard" +import "../../../gui/qml/components/controls" + +WizardComponent { + id: root + securePage: true + + valid: false + + property QtObject plugin + property string _qrdata + + function apply() { + wizard_data['trustedcoin_cosigner_qr'] = _qrdata + } + + ColumnLayout { + width: parent.width + + Label { + Layout.fillWidth: true + wrapMode: Text.Wrap + text: [ + qsTr('On Electrum desktop, restore your 2FA seed, and choose to keep two-factor authentication with Electrum on your phone as cosigner.'), + qsTr('Then, scan the QR code displayed by the desktop wizard.') + ].join(' ') + } + + Button { + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: constants.paddingLarge + icon.source: '../../../gui/icons/qrcode.png' + text: qsTr('Scan QR code') + onClicked: { + var dialog = app.scanDialog.createObject(app, { + hint: qsTr('Scan the cosigner QR code displayed by Electrum desktop') + }) + dialog.onFoundText.connect(function(data) { + dialog.close() + if (plugin.isCosignerQr(data)) { + _qrdata = data + valid = true + } else { + _qrdata = '' + valid = false + errorBox.text = qsTr('This is not a 2FA cosigner QR code.') + } + }) + dialog.open() + } + } + + InfoTextArea { + id: errorBox + Layout.fillWidth: true + Layout.topMargin: constants.paddingLarge + iconStyle: InfoTextArea.IconStyle.Error + visible: !valid && text + } + + Label { + Layout.fillWidth: true + Layout.topMargin: constants.paddingLarge + visible: valid + wrapMode: Text.Wrap + text: qsTr('QR code scanned. Electrum will create the cosigner wallet on this device.') + } + + Image { + Layout.alignment: Qt.AlignHCenter + source: '../../../gui/icons/confirmed.png' + visible: valid + Layout.preferredWidth: constants.iconSizeXLarge + Layout.preferredHeight: constants.iconSizeXLarge + } + } + + Component.onCompleted: { + plugin = AppController.plugin('trustedcoin') + } +} diff --git a/electrum/plugins/trustedcoin/qml/ShowConfirmOTP.qml b/electrum/plugins/trustedcoin/qml/ShowConfirmOTP.qml deleted file mode 100644 index 8b2f818f6634..000000000000 --- a/electrum/plugins/trustedcoin/qml/ShowConfirmOTP.qml +++ /dev/null @@ -1,166 +0,0 @@ -import QtQuick 2.6 -import QtQuick.Layouts 1.0 -import QtQuick.Controls 2.1 - -import "../../../gui/qml/components/wizard" -import "../../../gui/qml/components/controls" - -WizardComponent { - valid: otpVerified - - property QtObject plugin - - property bool otpVerified: false - - ColumnLayout { - width: parent.width - - Label { - text: qsTr('Authenticator secret') - } - - InfoTextArea { - id: errorBox - Layout.fillWidth: true - iconStyle: InfoTextArea.IconStyle.Error - visible: !otpVerified && plugin.remoteKeyState == 'error' - } - - InfoTextArea { - Layout.fillWidth: true - iconStyle: InfoTextArea.IconStyle.Warn - visible: plugin.remoteKeyState == 'wallet_known' - text: qsTr('This wallet is already registered with TrustedCoin. ') - + qsTr('To finalize wallet creation, please enter your Google Authenticator Code. ') - } - - QRImage { - Layout.alignment: Qt.AlignHCenter - visible: plugin.remoteKeyState == 'new' || plugin.remoteKeyState == 'reset' - qrdata: encodeURI('otpauth://totp/Electrum 2FA ' + wizard_data['wallet_name'] - + '?secret=' + plugin.otpSecret + '&digits=6') - render: plugin.otpSecret - onClicked: { - if (plugin.otpSecret) { - AppController.textToClipboard(plugin.otpSecret) - toaster.show(this, qsTr('Copied!')) - // On Android the app will get killed when switching to the authenticator app, - // losing the wizard state. TODO: re-enable once we have means to keep app alive in background. - // if (AppController.isAndroid()) { - // Qt.openUrlExternally(qrdata) - // } else { - // AppController.textToClipboard(plugin.otpSecret) - // toaster.show(this, qsTr('Copied!')) - // } - } - } - } - - Item { - Layout.alignment: Qt.AlignHCenter - visible: plugin.otpSecret - implicitWidth: otpSecretPane.implicitWidth - implicitHeight: otpSecretPane.implicitHeight - TextHighlightPane { - id: otpSecretPane - Label { - text: plugin.otpSecret - font.family: FixedFont - font.bold: true - } - } - MouseArea { - anchors.fill: parent - onClicked: { - AppController.textToClipboard(plugin.otpSecret) - toaster.show(otpSecretPane, qsTr('Copied!')) - } - } - } - - Label { - Layout.fillWidth: true - visible: !otpVerified && plugin.otpSecret - wrapMode: Text.Wrap - text: qsTr('Enter or scan into authenticator app. Then authenticate below') - } - - Label { - Layout.fillWidth: true - visible: !otpVerified && plugin.remoteKeyState == 'wallet_known' - wrapMode: Text.Wrap - text: qsTr('If you still have your OTP secret, then authenticate below') - } - - TextField { - id: otp_auth - visible: !otpVerified && (plugin.otpSecret || plugin.remoteKeyState == 'wallet_known') - Layout.alignment: Qt.AlignHCenter - focus: true - inputMethodHints: Qt.ImhSensitiveData | Qt.ImhDigitsOnly - validator: IntValidator {bottom: 0; top: 999999;} - font.family: FixedFont - font.pixelSize: constants.fontSizeLarge - onTextChanged: { - if (text.length >= 6) { - plugin.checkOtp(plugin.shortId, otp_auth.text) - text = '' - } - } - } - - Label { - Layout.fillWidth: true - visible: !otpVerified && plugin.remoteKeyState == 'wallet_known' - wrapMode: Text.Wrap - text: qsTr('Otherwise, you can request your OTP secret from the server, by pressing the button below') - } - - Button { - Layout.alignment: Qt.AlignHCenter - visible: plugin.remoteKeyState == 'wallet_known' && !otpVerified - text: qsTr('Request OTP secret') - onClicked: plugin.resetOtpSecret() - } - - Image { - Layout.alignment: Qt.AlignHCenter - source: '../../../gui/icons/confirmed.png' - visible: otpVerified - Layout.preferredWidth: constants.iconSizeXLarge - Layout.preferredHeight: constants.iconSizeXLarge - } - } - - BusyIndicator { - anchors.centerIn: parent - visible: plugin ? plugin.busy : false - running: visible - } - - Component.onCompleted: { - plugin = AppController.plugin('trustedcoin') - plugin.createKeystore() - otp_auth.forceActiveFocus() - } - - Toaster { - id: toaster - } - - Connections { - target: plugin - function onOtpError(message) { - console.log('OTP verify error') - errorBox.text = message - } - function onOtpSuccess() { - console.log('OTP verify success') - otpVerified = true - } - function onRemoteKeyError(message) { - errorBox.text = message - } - } -} - diff --git a/electrum/plugins/trustedcoin/qml/Terms.qml b/electrum/plugins/trustedcoin/qml/Terms.qml deleted file mode 100644 index b22836efb008..000000000000 --- a/electrum/plugins/trustedcoin/qml/Terms.qml +++ /dev/null @@ -1,67 +0,0 @@ -import QtQuick 2.6 -import QtQuick.Layouts 1.0 -import QtQuick.Controls 2.1 - -import org.electrum 1.0 - -import "../../../gui/qml/components/wizard" -import "../../../gui/qml/components/controls" - -WizardComponent { - valid: !plugin ? false - : tosShown - - property QtObject plugin - property bool tosShown: false - - ColumnLayout { - anchors.fill: parent - - Label { - text: qsTr('Terms and conditions') - } - - TextHighlightPane { - Layout.fillWidth: true - Layout.fillHeight: true - rightPadding: 0 - - Flickable { - anchors.fill: parent - contentHeight: termsText.height - clip: true - boundsBehavior: Flickable.StopAtBounds - - Label { - id: termsText - width: parent.width - rightPadding: constants.paddingSmall - wrapMode: Text.Wrap - } - ScrollIndicator.vertical: ScrollIndicator { } - } - - BusyIndicator { - anchors.centerIn: parent - visible: plugin ? plugin.busy : false - running: visible - } - } - } - - Component.onCompleted: { - plugin = AppController.plugin('trustedcoin') - plugin.fetchTermsAndConditions() - } - - Connections { - target: plugin - function onTermsAndConditionsRetrieved(message) { - termsText.text = message - tosShown = true - } - function onTermsAndConditionsError(message) { - termsText.text = message - } - } -} diff --git a/electrum/plugins/trustedcoin/qml/main.qml b/electrum/plugins/trustedcoin/qml/main.qml new file mode 100644 index 000000000000..7dff00d81476 --- /dev/null +++ b/electrum/plugins/trustedcoin/qml/main.qml @@ -0,0 +1,16 @@ +import QtQuick + +import org.electrum + +Item { + Connections { + target: AppController ? AppController.plugin('trustedcoin') : null + function onCosignerWalletCreated() { + var dialog = app.messageDialog.createObject(app, { + title: qsTr('Two-factor authentication'), + text: qsTr('This device is now a cosigner of your desktop wallet.') + }) + dialog.open() + } + } +} diff --git a/electrum/plugins/trustedcoin/qt.py b/electrum/plugins/trustedcoin/qt.py index 9b76d7482287..084a100f60d3 100644 --- a/electrum/plugins/trustedcoin/qt.py +++ b/electrum/plugins/trustedcoin/qt.py @@ -24,80 +24,40 @@ # SOFTWARE. from functools import partial -import os from typing import TYPE_CHECKING -from PyQt6.QtGui import QPixmap, QMovie, QColor -from PyQt6.QtCore import QObject, pyqtSignal, QSize, Qt -from PyQt6.QtWidgets import (QTextEdit, QVBoxLayout, QLabel, QGridLayout, QHBoxLayout, - QRadioButton, QCheckBox, QPushButton, QWidget) +import qrcode + +from PyQt6.QtWidgets import QDialog from electrum.i18n import _ from electrum.plugin import hook -from electrum.util import InvalidPassword, ChoiceItem -from electrum.logging import Logger, get_logger -from electrum import keystore - -from electrum.gui.qt.util import (WindowModalDialog, WaitingDialog, OkButton, CancelButton, Buttons, icon_path, - internal_plugin_icon_path, WWLabel, CloseButton, ColorScheme, - ChoiceWidget, PasswordLineEdit, char_width_in_lineedit) -from electrum.gui.qt.qrcodewidget import QRCodeWidget -from electrum.gui.qt.amountedit import AmountEdit +from electrum.util import ChoiceItem +from electrum.wizard import WizardViewState + +from electrum.gui.common_qt.util import QtEventListener, qt_event_listener +from electrum.gui.qt.util import (internal_plugin_icon_path, WWLabel, ColorScheme, ChoiceWidget, + read_QIcon_from_bytes) +from electrum.gui.qt.qrcodewidget import QRCodeWidget, QRDialog from electrum.gui.qt.main_window import StatusBarButton -from electrum.gui.qt.wizard.wallet import (WCCreateSeed, WCConfirmSeed, WCHaveSeed, WCEnterExt, WCConfirmExt, - WalletWizardComponent) -from electrum.gui.qt.util import read_QIcon_from_bytes +from electrum.gui.qt.wizard.wallet import (WCHaveSeed, WCEnterExt, WalletWizardComponent, QEKeystoreWizard) -from .common_qt import TrustedcoinPluginQObject -from .trustedcoin import TrustedCoinPlugin, DISCLAIMER +from .trustedcoin import TrustedCoinPlugin, DISCLAIMER, make_cosigner_qr_data if TYPE_CHECKING: from electrum.gui.qt.main_window import ElectrumWindow from electrum.wallet import Abstract_Wallet + from electrum.transaction import PartialTransaction from electrum.gui.qt.wizard.wallet import QENewWalletWizard -class TOS(QTextEdit): - tos_signal = pyqtSignal() - error_signal = pyqtSignal(object) - - -class HandlerTwoFactor(QObject, Logger): - - def __init__(self, plugin, window): - QObject.__init__(self) - self.plugin = plugin - self.window = window - Logger.__init__(self) - - def prompt_user_for_otp(self, wallet, tx, on_success, on_failure): - if not isinstance(wallet, self.plugin.wallet_class): - return - if wallet.can_sign_without_server(): - return - if not wallet.keystores['x3'].can_sign(tx, ignore_watching_only=True): - self.logger.info("twofactor: xpub3 not needed") - return - window = self.window.top_level_window() - auth_code = self.plugin.auth_dialog(window) - WaitingDialog(parent=window, - message=_('Waiting for TrustedCoin server to sign transaction...'), - task=lambda: wallet.on_otp(tx, auth_code), - on_success=lambda *args: on_success(tx), - on_error=on_failure) - - class Plugin(TrustedCoinPlugin): - def __init__(self, parent, config, name): - super().__init__(parent, config, name) - @hook def load_wallet(self, wallet: 'Abstract_Wallet', window: 'ElectrumWindow'): if not isinstance(wallet, self.wallet_class): return - wallet.handler_2fa = HandlerTwoFactor(self, window) - if wallet.can_sign_without_server(): + if wallet.can_sign_without_cosigner(): msg = ' '.join([ _('This wallet was restored from seed, and it contains two master private keys.'), _('Therefore, two-factor authentication is disabled.') @@ -108,218 +68,129 @@ def load_wallet(self, wallet: 'Abstract_Wallet', window: 'ElectrumWindow'): action = partial(self.settings_dialog, window) icon = read_QIcon_from_bytes(self.read_file("trustedcoin-status.png")) sb = window.statusBar() - button = StatusBarButton(icon, _("TrustedCoin"), action, sb.height()) + button = StatusBarButton(icon, _("Two-factor authentication"), action, sb.height()) sb.addPermanentWidget(button) - self.start_request_thread(window.wallet) - - def auth_dialog(self, window): - d = WindowModalDialog(window, _("Authorization")) - vbox = QVBoxLayout(d) - pw = AmountEdit(None, is_int=True) - msg = _('Please enter your Google Authenticator code') - vbox.addWidget(QLabel(msg)) - grid = QGridLayout() - grid.setSpacing(8) - grid.addWidget(QLabel(_('Code')), 1, 0) - grid.addWidget(pw, 1, 1) - vbox.addLayout(grid) - msg = _('If you have lost your second factor, you need to restore your wallet from seed in order to request a new code.') - label = QLabel(msg) - label.setWordWrap(1) - vbox.addWidget(label) - vbox.addLayout(Buttons(CancelButton(d), OkButton(d))) - if not d.exec(): - return - return pw.get_amount() - - def prompt_user_for_otp(self, wallet, tx, on_success, on_failure): - wallet.handler_2fa.prompt_user_for_otp(wallet, tx, on_success, on_failure) - - def waiting_dialog_for_billing_info(self, window, *, on_finished=None): - def task(): - return self.request_billing_info(window.wallet, suppress_connection_error=False) - - def on_error(exc_info): - e = exc_info[1] - window.show_error("{header}\n{exc}\n\n{tor}" - .format(header=_('Error getting TrustedCoin account info.'), - exc=repr(e), - tor=_('If you keep experiencing network problems, try using a Tor proxy.'))) - return WaitingDialog(parent=window, - message=_('Requesting account info from TrustedCoin server...'), - task=task, - on_success=on_finished, - on_error=on_error) @hook - def abort_send(self, window): - wallet = window.wallet - if not isinstance(wallet, self.wallet_class): - return - if wallet.can_sign_without_server(): - return - if wallet.billing_info is None: - self.waiting_dialog_for_billing_info(window) + def show_incomplete_tx(self, window: 'ElectrumWindow', tx: 'PartialTransaction') -> bool: + # after signing with the first key, show the transaction to the mobile cosigner + if not isinstance(window.wallet, self.wallet_class): + return False + help_text = ' '.join([ + _('Scan this QR code with the Electrum app on your phone, then sign and broadcast the transaction there.'), + _('If your phone is not set up as cosigner yet, click on the two-factor authentication icon in the status bar.'), + ]) + try: + dialog = PsbtQRDialog(tx, wallet=window.wallet, parent=window, config=self.config, help_text=help_text) + except qrcode.exceptions.DataOverflowError: + window.show_error('\n'.join([ + _('This transaction is too large to fit in a QR code.'), + _('Try to spend fewer coins at once.'), + ])) return True - return False + dialog.exec() + return True + + def settings_dialog(self, window: 'ElectrumWindow'): + msg = '\n\n'.join([ + _('This wallet is protected by two-factor authentication.'), + ' '.join([ + _('The TrustedCoin service has been discontinued: transactions are now co-signed by the Electrum app on your phone.'), + _('After you sign a transaction, scan the QR code displayed by Electrum with your phone, then sign and broadcast it there.'), + ]), + _('Do you want to set up your phone as cosigner of this wallet? You will need your 2FA seed.'), + ]) + if window.question(msg, title=_('Two-factor authentication')): + self.setup_cosigner(window) - def settings_dialog(self, window): - self.waiting_dialog_for_billing_info(window, - on_finished=partial(self.show_settings_dialog, window)) + def setup_cosigner(self, window: 'ElectrumWindow'): + # for existing wallets: the second master private key is not in the wallet file + wallet = window.wallet + params = {'icon': self.icon_path('trustedcoin-wizard.png')} + data = { + 'wallet_type': '2fa', + 'keystore_type': 'haveseed', + 'trustedcoin_wallet_xpubs': [wallet.keystores[k].get_master_public_key() for k in ['x1', 'x2']], + } + wizard = QEKeystoreWizard( + config=self.config, app=window.gui_object.app, plugins=window.gui_object.plugins, + start_viewstate=WizardViewState('trustedcoin_have_seed', data, params)) + wizard.window_title = _('Set up mobile cosigner') + wizard.navmap_merge({ + 'trustedcoin_have_seed': { + 'gui': WCHaveSeed, + 'params': params, + 'next': lambda d: 'trustedcoin_have_ext' if wizard.wants_ext(d) else 'trustedcoin_show_cosigner_qr', + }, + 'trustedcoin_have_ext': { + 'gui': WCEnterExt, + 'params': params, + 'next': 'trustedcoin_show_cosigner_qr', + }, + 'trustedcoin_show_cosigner_qr': { + 'gui': WCShowCosignerQR, + 'params': params, + 'last': True, + }, + }) + if wizard.exec() == QDialog.DialogCode.Accepted: + window.show_message(_('Your phone is now set up as cosigner of this wallet.')) def icon_path(self, name): return internal_plugin_icon_path(self.name, name) - def show_settings_dialog(self, window, success): - if not success: - window.show_message(_('Server not reachable.')) - return - - wallet = window.wallet - d = WindowModalDialog(window, _("TrustedCoin Information")) - d.setMinimumSize(500, 200) - vbox = QVBoxLayout(d) - hbox = QHBoxLayout() - - logo = QLabel() - logo.setPixmap(QPixmap(self.icon_path("trustedcoin-status.png"))) - msg = _('This wallet is protected by TrustedCoin\'s two-factor authentication.') + '
'\ - + _("For more information, visit") + " https://api.trustedcoin.com/#/electrum-help" - label = QLabel(msg) - label.setOpenExternalLinks(1) - - hbox.addStretch(10) - hbox.addWidget(logo) - hbox.addStretch(10) - hbox.addWidget(label) - hbox.addStretch(10) - - vbox.addLayout(hbox) - vbox.addStretch(10) - - msg = _('TrustedCoin charges a small fee to co-sign transactions. The fee depends on how many prepaid transactions you buy. An extra output is added to your transaction every time you run out of prepaid transactions.') + '
' - label = QLabel(msg) - label.setWordWrap(1) - vbox.addWidget(label) - - vbox.addStretch(10) - grid = QGridLayout() - vbox.addLayout(grid) - - price_per_tx = wallet.price_per_tx - n_prepay = wallet.num_prepay() - i = 0 - for k, v in sorted(price_per_tx.items()): - if k == 1: - continue - grid.addWidget(QLabel("Pay every %d transactions:"%k), i, 0) - grid.addWidget(QLabel(window.format_amount(v/k) + ' ' + window.base_unit() + "/tx"), i, 1) - b = QRadioButton() - b.setChecked(k == n_prepay) - - def on_click(b, k): - self.config.PLUGIN_TRUSTEDCOIN_NUM_PREPAY = k - b.clicked.connect(partial(on_click, k=k)) - grid.addWidget(b, i, 2) - i += 1 - - n = wallet.billing_info.get('tx_remaining', 0) - grid.addWidget(QLabel(_("Your wallet has {} prepaid transactions.").format(n)), i, 0) - vbox.addLayout(Buttons(CloseButton(d))) - d.exec() - @hook def init_wallet_wizard(self, wizard: 'QENewWalletWizard'): - wizard.trustedcoin_qhelper = TrustedcoinPluginQObject(self, wizard, None) self.extend_wizard(wizard) - if wizard.start_viewstate and wizard.start_viewstate.view.startswith('trustedcoin_'): - wizard.start_viewstate.params.update({'icon': self.icon_path('trustedcoin-wizard.png')}) def extend_wizard(self, wizard: 'QENewWalletWizard'): super().extend_wizard(wizard) + params = {'icon': self.icon_path('trustedcoin-wizard.png')} views = { 'trustedcoin_start': { 'gui': WCDisclaimer, - 'params': {'icon': self.icon_path('trustedcoin-wizard.png')}, - }, - 'trustedcoin_choose_seed': { - 'gui': WCChooseSeed, - 'params': {'icon': self.icon_path('trustedcoin-wizard.png')}, - }, - 'trustedcoin_create_seed': { - 'gui': WCCreateSeed, - 'params': {'icon': self.icon_path('trustedcoin-wizard.png')}, - }, - 'trustedcoin_create_ext': { - 'gui': WCEnterExt, - 'params': {'icon': self.icon_path('trustedcoin-wizard.png')}, - }, - 'trustedcoin_confirm_seed': { - 'gui': WCConfirmSeed, - 'params': {'icon': self.icon_path('trustedcoin-wizard.png')}, - }, - 'trustedcoin_confirm_ext': { - 'gui': WCConfirmExt, - 'params': {'icon': self.icon_path('trustedcoin-wizard.png')}, + 'params': params, + 'next': 'trustedcoin_have_seed', }, 'trustedcoin_have_seed': { 'gui': WCHaveSeed, - 'params': {'icon': self.icon_path('trustedcoin-wizard.png')}, + 'params': params, }, 'trustedcoin_have_ext': { 'gui': WCEnterExt, - 'params': {'icon': self.icon_path('trustedcoin-wizard.png')}, + 'params': params, }, 'trustedcoin_keep_disable': { 'gui': WCKeepDisable, - 'params': {'icon': self.icon_path('trustedcoin-wizard.png')}, - }, - 'trustedcoin_tos': { - 'gui': WCTerms, - 'params': {'icon': self.icon_path('trustedcoin-wizard.png')}, + 'params': params, }, - 'trustedcoin_keystore_unlock': { - 'gui': WCKeystorePassword, - 'params': {'icon': self.icon_path('trustedcoin-wizard.png')}, + 'trustedcoin_show_cosigner_qr': { + 'gui': WCShowCosignerQR, + 'params': params, }, - 'trustedcoin_show_confirm_otp': { - 'gui': WCShowConfirmOTP, - 'params': {'icon': self.icon_path('trustedcoin-wizard.png')}, - } } wizard.navmap_merge(views) - # insert page offering choice to go online or continue on another system - ext_online = { - 'trustedcoin_continue_online': { - 'gui': WCContinueOnline, - 'params': {'icon': self.icon_path('trustedcoin-wizard.png')}, - 'next': lambda d: 'trustedcoin_tos' if d['trustedcoin_go_online'] else 'wallet_password', - 'accept': self.on_continue_online, - 'last': lambda d: not d['trustedcoin_go_online'] and wizard.is_single_password() - }, - 'trustedcoin_confirm_seed': { - 'next': lambda d: 'trustedcoin_confirm_ext' if wizard.wants_ext(d) else 'trustedcoin_continue_online' - }, - 'trustedcoin_confirm_ext': { - 'next': 'trustedcoin_continue_online', - }, - 'trustedcoin_keep_disable': { - 'next': lambda d: 'trustedcoin_continue_online' if d['trustedcoin_keepordisable'] != 'disable' - else 'wallet_password', - } - } - wizard.navmap_merge(ext_online) - def on_continue_online(self, wizard_data): - if not wizard_data['trustedcoin_go_online']: - self.logger.debug('Staying offline, create keystores here') - xprv1, xpub1, xprv2, xpub2, xpub3, short_id = self.create_keys(wizard_data) - k1 = keystore.from_xprv(xprv1) - k2 = keystore.from_xpub(xpub2) +class PsbtQRDialog(QRDialog, QtEventListener): + """Shows a transaction to the mobile cosigner, until the wallet sees it.""" - wizard_data['x1'] = k1.dump() - wizard_data['x2'] = k2.dump() + def __init__(self, tx: 'PartialTransaction', *, wallet: 'Abstract_Wallet', parent, config, help_text: str): + qr_data, __ = tx.to_qr_data() + QRDialog.__init__(self, data=qr_data, parent=parent, + title=_('Partially signed transaction'), help_text=help_text, config=config) + self.wallet = wallet + self.prevouts = {txin.prevout.to_str() for txin in tx.inputs()} + self.finished.connect(lambda: self.unregister_callbacks()) + self.register_callbacks() + + @qt_event_listener + def on_event_adb_added_tx(self, adb, tx_hash, tx): + if adb != self.wallet.adb: + return + # the cosigner broadcast the transaction, so there is no point in showing it anymore + if any(txin.prevout.to_str() in self.prevouts for txin in tx.inputs()): + self.accept() class WCDisclaimer(WalletWizardComponent): @@ -332,18 +203,22 @@ def __init__(self, parent, wizard): self._valid = True def apply(self): - pass + # the desktop wizard can only restore 2fa wallets from seed + self.wizard_data['keystore_type'] = 'haveseed' -class WCChooseSeed(WalletWizardComponent): +class WCKeepDisable(WalletWizardComponent): def __init__(self, parent, wizard): - WalletWizardComponent.__init__(self, parent, wizard, title=_('Create or restore')) - message = _('Do you want to create a new seed, or restore a wallet using an existing seed?') + WalletWizardComponent.__init__(self, parent, wizard, title=_('Restore 2FA wallet')) + message = ' '.join([ + _('You are going to restore a wallet protected with two-factor authentication.'), + _('Do you want to keep using two-factor authentication with this wallet, with the Electrum app on your phone as cosigner,'), + _('or do you want to disable it, and have two master private keys in your wallet?'), + ]) choices = [ - ChoiceItem(key='createseed', label=_('Create a new seed')), - ChoiceItem(key='haveseed', label=_('I already have a seed')), + ChoiceItem(key='keep', label=_('Keep, with Electrum on my phone as cosigner')), + ChoiceItem(key='disable', label=_('Disable')), ] - self.choice_w = ChoiceWidget(message=message, choices=choices) self.layout().addWidget(self.choice_w) self.layout().addStretch(1) @@ -351,116 +226,37 @@ def __init__(self, parent, wizard): self._valid = True def apply(self): - self.wizard_data['keystore_type'] = self.choice_w.selected_key + self.wizard_data['trustedcoin_keepordisable'] = self.choice_w.selected_key -class WCTerms(WalletWizardComponent): +class WCShowCosignerQR(WalletWizardComponent): def __init__(self, parent, wizard): - WalletWizardComponent.__init__(self, parent, wizard, title=_('Terms and conditions')) - self._has_tos = False - self.tos_e = TOS() - self.tos_e.setReadOnly(True) - self.layout().addWidget(self.tos_e) - - def on_ready(self): - self.fetch_terms_and_conditions() - - def fetch_terms_and_conditions(self): - self.wizard.trustedcoin_qhelper.busyChanged.connect(self.on_busy_changed) - self.wizard.trustedcoin_qhelper.termsAndConditionsRetrieved.connect(self.on_terms_retrieved) - self.wizard.trustedcoin_qhelper.termsAndConditionsError.connect(self.on_terms_error) - self.wizard.trustedcoin_qhelper.fetchTermsAndConditions() - - def on_busy_changed(self): - self.busy = self.wizard.trustedcoin_qhelper.busy - - def on_terms_retrieved(self, tos: str) -> None: - self._has_tos = True - self.tos_e.setText(tos) - self.validate() + WalletWizardComponent.__init__(self, parent, wizard, title=_('Mobile cosigner')) - def on_terms_error(self, error: str) -> None: - self.error = error - - def validate(self): - self.valid = self._has_tos - - def apply(self): - pass - - -class WCShowConfirmOTP(WalletWizardComponent): - _logger = get_logger(__name__) - - def __init__(self, parent, wizard): - WalletWizardComponent.__init__(self, parent, wizard, title=_('Authenticator secret')) - self._otp_verified = False - self._is_online_continuation = False - - self.new_otp = QWidget() - new_otp_layout = QVBoxLayout() - scanlabel = WWLabel(_('Enter or scan into authenticator app. Then authenticate below')) - new_otp_layout.addWidget(scanlabel) + self.layout().addWidget(WWLabel(' '.join([ + _('On your phone, open Electrum and create a new wallet.'), + _("Choose 'Wallet with two-factor authentication', then 'Scan cosigner QR code', and scan this QR code:"), + ]))) self.qr = QRCodeWidget('') - new_otp_layout.addWidget(self.qr) - self.secretlabel = WWLabel() - new_otp_layout.addWidget(self.secretlabel) - self.new_otp.setLayout(new_otp_layout) - - self.exist_otp = QWidget() - exist_otp_layout = QVBoxLayout() - knownlabel = WWLabel(_('This wallet is already registered with TrustedCoin.')) - exist_otp_layout.addWidget(knownlabel) - self.knownsecretlabel = WWLabel(_('If you still have your OTP secret, then authenticate below to finalize wallet creation')) - exist_otp_layout.addWidget(self.knownsecretlabel) - self.exist_otp.setLayout(exist_otp_layout) - - self.authlabelnew = WWLabel(_('Then, enter your Google Authenticator code:')) - self.authlabelexist = WWLabel(_('Google Authenticator code:')) - - self.spinner = QMovie(icon_path('spinner.gif')) - self.spinner.setScaledSize(QSize(24, 24)) - self.spinner.setBackgroundColor(QColor('black')) - self.spinner_l = QLabel() - self.spinner_l.setMargin(5) - self.spinner_l.setVisible(False) - self.spinner_l.setMovie(self.spinner) - - self.otp_status_l = QLabel() - self.otp_status_l.setAlignment(Qt.AlignmentFlag.AlignHCenter) - self.otp_status_l.setVisible(False) - - self.resetlabel = WWLabel(_('If you have lost your OTP secret, click the button below to request a new secret from the server.')) - self.button = QPushButton('Request OTP secret') - self.button.clicked.connect(self.on_request_otp) - - hbox = QHBoxLayout() - hbox.addWidget(self.authlabelnew) - hbox.addWidget(self.authlabelexist) - hbox.addStretch(1) - hbox.addWidget(self.spinner_l) - self.otp_e = AmountEdit(None, is_int=True) - self.otp_e.setFocus() - self.otp_e.setMaximumWidth(150) - self.otp_e.textEdited.connect(self.on_otp_edited) - hbox.addWidget(self.otp_e) - - self.layout().addWidget(self.new_otp) - self.layout().addWidget(self.exist_otp) - self.layout().addLayout(hbox) - self.layout().addWidget(self.otp_status_l) - self.layout().addWidget(self.resetlabel) - self.layout().addWidget(self.button) + self.layout().addWidget(self.qr) + warning_l = WWLabel(_('This QR code contains a private key of your wallet. Do not show it to anyone else.')) + warning_l.setStyleSheet(ColorScheme.RED.as_stylesheet()) + self.layout().addWidget(warning_l) self.layout().addStretch(1) + self._valid = True + def on_ready(self): - self.wizard.trustedcoin_qhelper.busyChanged.connect(self.on_busy_changed) - self.wizard.trustedcoin_qhelper.remoteKeyError.connect(self.on_remote_key_error) - self.wizard.trustedcoin_qhelper.otpSuccess.connect(self.on_otp_success) - self.wizard.trustedcoin_qhelper.otpError.connect(self.on_otp_error) - self.wizard.trustedcoin_qhelper.remoteKeyError.connect(self.on_remote_key_error) + plugin = self.wizard.plugins.get_plugin('trustedcoin') + xprv1, xpub1, xprv2, xpub2, xpub3 = plugin.create_keys(self.wizard_data) + wallet_xpubs = self.wizard_data.get('trustedcoin_wallet_xpubs') + if wallet_xpubs is not None and wallet_xpubs != [xpub1, xpub2]: + self.error = _('This seed does not match this wallet.') + self.valid = False + return + self.qr.setData(make_cosigner_qr_data(xprv2, xpub1, xpub3)) - # set higher minHeight so the qr code and the input field are shown without scrolling + # set higher minHeight so the qr code is shown without scrolling prev_height = self.wizard.height() prev_min_height = self.wizard.minimumHeight() def restore_prev_height(): @@ -468,173 +264,9 @@ def restore_prev_height(): self.wizard.resize(self.wizard.width(), prev_height) self.wizard.next_button.clicked.disconnect(restore_prev_height) self.wizard.back_button.clicked.disconnect(restore_prev_height) - self.wizard.setMinimumHeight(530) + self.wizard.setMinimumHeight(600) self.wizard.next_button.clicked.connect(restore_prev_height) self.wizard.back_button.clicked.connect(restore_prev_height) - self._is_online_continuation = 'seed' not in self.wizard_data - if self._is_online_continuation: - self.knownsecretlabel.setText(_('Authenticate below to finalize wallet creation')) - - self.wizard.trustedcoin_qhelper.createKeystore() - - def update(self): - is_new = bool(self.wizard.trustedcoin_qhelper.remoteKeyState != 'wallet_known') - self.new_otp.setVisible(is_new) - self.exist_otp.setVisible(not is_new) - self.authlabelnew.setVisible(is_new) - self.authlabelexist.setVisible(not is_new) - self.authlabelexist.setEnabled(not self._otp_verified) - self.otp_e.setEnabled(not self._otp_verified) - self.resetlabel.setVisible(not is_new and not self._otp_verified and not self._is_online_continuation) - self.button.setVisible(not is_new and not self._otp_verified and not self._is_online_continuation) - - if self.wizard.trustedcoin_qhelper.otpSecret: - self.secretlabel.setText(self.wizard.trustedcoin_qhelper.otpSecret) - uri = 'otpauth://totp/Electrum 2FA %s?secret=%s&digits=6' % ( - os.path.basename(self.wizard_data['wallet_name']), self.wizard.trustedcoin_qhelper.otpSecret) - self.qr.setData(uri) - - def on_busy_changed(self): - if not self.wizard.trustedcoin_qhelper._verifyingOtp: - self.busy = self.wizard.trustedcoin_qhelper.busy - if not self.busy: - self.update() - - def on_remote_key_error(self, text): - self._logger.error(text) - self.error = text - - def on_request_otp(self): - self.otp_status_l.setVisible(False) - self.wizard.trustedcoin_qhelper.resetOtpSecret() - self.update() - - def on_otp_success(self): - self._otp_verified = True - self.otp_status_l.setText('Valid!') - self.otp_status_l.setVisible(True) - self.otp_status_l.setStyleSheet(ColorScheme.GREEN.as_stylesheet(False)) - self.setEnabled(True) - self.spinner_l.setVisible(False) - self.spinner.stop() - - self.valid = True - - def on_otp_error(self, message): - self.otp_status_l.setText(message) - self.otp_status_l.setVisible(True) - self.otp_status_l.setStyleSheet(ColorScheme.RED.as_stylesheet(False)) - self.setEnabled(True) - self.spinner_l.setVisible(False) - self.spinner.stop() - - def on_otp_edited(self): - self.otp_status_l.setVisible(False) - text = self.otp_e.text() - if len(text) > 0: - try: - otp_int = int(text) - except ValueError: - return - if len(text) == 6: - # verify otp - self.wizard.trustedcoin_qhelper.checkOtp(self.wizard.trustedcoin_qhelper.shortId, otp_int) - self.setEnabled(False) - self.spinner_l.setVisible(True) - self.spinner.start() - self.otp_e.setText('') - def apply(self): pass - - -class WCKeepDisable(WalletWizardComponent): - def __init__(self, parent, wizard): - WalletWizardComponent.__init__(self, parent, wizard, title=_('Restore 2FA wallet')) - message = ' '.join([ - 'You are going to restore a wallet protected with two-factor authentication.', - 'Do you want to keep using two-factor authentication with this wallet,', - 'or do you want to disable it, and have two master private keys in your wallet?' - ]) - choices = [ - ChoiceItem(key='keep', label=_('Keep')), - ChoiceItem(key='disable', label=_('Disable')), - ] - self.choice_w = ChoiceWidget(message=message, choices=choices) - self.layout().addWidget(self.choice_w) - self.layout().addStretch(1) - - self._valid = True - - def apply(self): - self.wizard_data['trustedcoin_keepordisable'] = self.choice_w.selected_key - - -class WCContinueOnline(WalletWizardComponent): - def __init__(self, parent, wizard): - WalletWizardComponent.__init__(self, parent, wizard, title=_('Continue Online')) - self.cb_online = QCheckBox(_('Go online to complete wallet creation')) - - def on_ready(self): - path = os.path.join(os.path.dirname(self.wizard._daemon.config.get_wallet_path()), self.wizard_data['wallet_name']) - msg = [ - _("Your wallet file is: {}.").format(path), - _("You need to be online in order to complete the creation of " - "your wallet. If you want to continue online, keep the checkbox " - "checked and press Next."), - _("If you want this system to stay offline " - "and continue the completion of the wallet on an online system, " - "uncheck the checkbox and press Finish.") - ] - - self.layout().addWidget(WWLabel('\n\n'.join(msg))) - self.layout().addStretch(1) - - self.cb_online.setChecked(True) - self.cb_online.stateChanged.connect(self.on_updated) - # self.cb_online.setToolTip(_("Check this box to request a new secret. You will need to retype your seed.")) - self.layout().addWidget(self.cb_online) - self.layout().setAlignment(self.cb_online, Qt.AlignmentFlag.AlignHCenter) - self.layout().addStretch(1) - - self._valid = True - - def apply(self): - self.wizard_data['trustedcoin_go_online'] = self.cb_online.isChecked() - - -class WCKeystorePassword(WalletWizardComponent): - def __init__(self, parent, wizard): - WalletWizardComponent.__init__(self, parent, wizard, title=_('Unlock Keystore')) - self.layout().addStretch(1) - - hbox2 = QHBoxLayout() - hbox2.addStretch(1) - self.pw_e = PasswordLineEdit('', self) - self.pw_e.setFixedWidth(17 * char_width_in_lineedit()) - self.pw_e.textEdited.connect(self.on_text) - pw_label = QLabel(_('Password') + ':') - hbox2.addWidget(pw_label) - hbox2.addWidget(self.pw_e) - hbox2.addStretch(1) - self.layout().addLayout(hbox2) - self.layout().addStretch(1) - - self.ks = None - - def on_ready(self): - self.ks = self.wizard_data['xprv1'] - - def on_text(self): - try: - self.ks.check_password(self.pw_e.text()) - except InvalidPassword: - self.valid = False - return - self.valid = True - - def apply(self): - if self.valid: - self.wizard_data['xprv1'] = self.ks.get_master_private_key(self.pw_e.text()) - self.wizard_data['password'] = self.pw_e.text() diff --git a/electrum/plugins/trustedcoin/trustedcoin.py b/electrum/plugins/trustedcoin/trustedcoin.py index b308e316abbc..dbd244234684 100644 --- a/electrum/plugins/trustedcoin/trustedcoin.py +++ b/electrum/plugins/trustedcoin/trustedcoin.py @@ -23,28 +23,18 @@ # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -import json -import time import hashlib -from typing import Dict, Union, Sequence, List, TYPE_CHECKING -from urllib.parse import urljoin -from urllib.parse import quote - -from aiohttp import ClientResponse +from typing import Tuple, TYPE_CHECKING import electrum_ecc as ecc -from electrum import constants, keystore, version, bip32, bitcoin -from electrum.bip32 import BIP32Node, xpub_type, is_xprv +from electrum import constants, keystore, bip32 +from electrum.bip32 import BIP32Node, xpub_type, is_xprv, is_xpub from electrum.crypto import sha256 -from electrum.transaction import PartialTxOutput, PartialTxInput, PartialTransaction, Transaction from electrum.mnemonic import Mnemonic, calc_seed_type, is_any_2fa_seed_type from electrum.wallet import Multisig_Wallet, Deterministic_Wallet from electrum.i18n import _ -from electrum.plugin import BasePlugin, hook -from electrum.util import NotEnoughFunds, UserFacingException, error_text_str_to_safe_str -from electrum.network import Network -from electrum.logging import Logger +from electrum.plugin import BasePlugin from electrum.keystore import KeyStore if TYPE_CHECKING: @@ -64,187 +54,45 @@ def get_signing_xpub(xtype): return node._replace(xtype=xtype).to_xpub() -def get_billing_xpub(): - if constants.net.TESTNET: - return "tpubD6NzVbkrYhZ4X11EJFTJujsYbUmVASAYY7gXsEt4sL97AMBdypiH1E9ZVTpdXXEy3Kj9Eqd1UkxdGtvDt5z23DKsh6211CfNJo8bLLyem5r" - else: - return "xpub6DTBdtBB8qUmH5c77v8qVGVoYk7WjJNpGvutqjLasNG1mbux6KsojaLrYf2sRhXAVU4NaFuHhbD9SvVPRt1MB1MaMooRuhHcAZH1yhQ1qDU" - - DESKTOP_DISCLAIMER = [ - _("Two-factor authentication is a service provided by TrustedCoin. " - "It uses a multi-signature wallet, where you own 2 of 3 keys. " - "The third key is stored on a remote server that signs transactions on " - "your behalf. To use this service, you will need a smartphone with " - "Google Authenticator installed."), - _("A small fee will be charged on each transaction that uses the " - "remote server. You may check and modify your billing preferences " - "once the installation is complete."), - _("Note that your coins are not locked in this service. You may withdraw " - "your funds at any time and at no cost, without the remote server, by " - "using the 'restore wallet' option with your wallet seed."), - _("The next step will generate the seed of your wallet. This seed will " - "NOT be saved in your computer, and it must be stored on paper. " - "To be safe from malware, you may want to do this on an offline " - "computer, and move your wallet later to an online computer."), + _("The two-factor authentication service of TrustedCoin has been discontinued. " + "Your coins are not locked: they can be recovered with your 2FA seed."), + _("A 2FA wallet is a multi-signature wallet, where two of the three keys are derived from your seed. " + "You may keep using two-factor authentication, with the Electrum app on your phone as cosigner: " + "this computer will hold the first key, and your phone will hold the second key."), + _("Alternatively, you may disable two-factor authentication, and have both keys in this wallet."), ] DISCLAIMER = DESKTOP_DISCLAIMER MOBILE_DISCLAIMER = [ - _("Two-factor authentication is a service provided by TrustedCoin. " - "To use it, you must have a separate device with Google Authenticator."), - _("This service uses a multi-signature wallet, where you own 2 of 3 keys. " - "The third key is stored on a remote server that signs transactions on " - "your behalf. A small fee will be charged on each transaction that uses the " - "remote server."), - _("Note that your coins are not locked in this service. You may withdraw " - "your funds at any time and at no cost, without the remote server, by " - "using the 'restore wallet' option with your wallet seed."), + _("The two-factor authentication service of TrustedCoin has been discontinued. " + "Your coins are not locked: they can be recovered with your 2FA seed."), + _("To use this device as the cosigner of a 2FA wallet on Electrum desktop, " + "restore your 2FA seed on desktop, and scan the QR code it displays."), + _("Alternatively, you may restore your 2FA seed on this device, with two-factor authentication disabled."), ] -RESTORE_MSG = _("Enter the seed for your 2-factor wallet:") - - -class TrustedCoinException(Exception): - def __init__(self, message, *, status_code=0): - # note: 'message' is arbitrary text coming from the server - safer_message = ( - f"Received error from 2FA server\n" - f"[DO NOT TRUST THIS MESSAGE]:\n\n" - f"status_code={status_code}\n\n" - f"{error_text_str_to_safe_str(message)}") - Exception.__init__(self, safer_message) - self.status_code = status_code - - -class ErrorConnectingServer(Exception): - def __init__(self, reason: Union[str, Exception] = None): - self.reason = reason - - def __str__(self): - header = _("Error connecting to {} server").format('TrustedCoin') - reason = self.reason - if isinstance(reason, BaseException): - reason = repr(reason) - return f"{header}:\n{reason}" if reason else header - - -class TrustedCoinCosignerClient(Logger): - def __init__(self, user_agent=None, base_url='https://api.trustedcoin.com/2/'): - self.base_url = base_url - self.debug = False - self.user_agent = user_agent - Logger.__init__(self) - - async def handle_response(self, resp: ClientResponse): - if resp.status != 200: - try: - r = await resp.json() - message = r['message'] - except Exception: - message = await resp.text() - raise TrustedCoinException(message, status_code=resp.status) - try: - return await resp.json() - except Exception: - return await resp.text() - - def send_request(self, method, relative_url, data=None, *, timeout=None): - network = Network.get_instance() - if not network: - raise ErrorConnectingServer('You are offline.') - url = urljoin(self.base_url, relative_url) - if self.debug: - self.logger.debug(f'<-- {method} {url} {data}') - headers = {} - if self.user_agent: - headers['user-agent'] = self.user_agent - try: - if method == 'get': - response = Network.send_http_on_proxy(method, url, - params=data, - headers=headers, - on_finish=self.handle_response, - timeout=timeout) - elif method == 'post': - response = Network.send_http_on_proxy(method, url, - json=data, - headers=headers, - on_finish=self.handle_response, - timeout=timeout) - else: - raise Exception(f"unexpected {method=!r}") - except TrustedCoinException: - raise - except Exception as e: - raise ErrorConnectingServer(e) - else: - if self.debug: - self.logger.debug(f'--> {response}') - return response - - def get_terms_of_service(self, billing_plan='electrum-per-tx-otp'): - """ - Returns the TOS for the given billing plan as a plain/text unicode string. - :param billing_plan: the plan to return the terms for - """ - payload = {'billing_plan': billing_plan} - return self.send_request('get', 'tos', payload) - - def create(self, xpubkey1, xpubkey2, email, billing_plan='electrum-per-tx-otp'): - """ - Creates a new cosigner resource. - :param xpubkey1: a bip32 extended public key (customarily the hot key) - :param xpubkey2: a bip32 extended public key (customarily the cold key) - :param email: a contact email - :param billing_plan: the billing plan for the cosigner - """ - payload = { - 'email': email, - 'xpubkey1': xpubkey1, - 'xpubkey2': xpubkey2, - 'billing_plan': billing_plan, - } - return self.send_request('post', 'cosigner', payload) - - def auth(self, id, otp): - """ - Attempt to authenticate for a particular cosigner. - :param id: the id of the cosigner - :param otp: the one time password - """ - payload = {'otp': otp} - return self.send_request('post', 'cosigner/%s/auth' % quote(id), payload) - - def get(self, id): - """ Get billing info """ - return self.send_request('get', 'cosigner/%s' % quote(id)) - - def get_challenge(self, id): - """ Get challenge to reset Google Auth secret """ - return self.send_request('get', 'cosigner/%s/otp_secret' % quote(id)) - - def reset_auth(self, id, challenge, signatures): - """ Reset Google Auth secret """ - payload = {'challenge': challenge, 'signatures': signatures} - return self.send_request('post', 'cosigner/%s/otp_secret' % quote(id), payload) - - def sign(self, id, transaction, otp): - """ - Attempt to authenticate for a particular cosigner. - :param id: the id of the cosigner - :param transaction: the hex encoded [partially signed] compact transaction to sign - :param otp: the one time password - """ - payload = { - 'otp': otp, - 'transaction': transaction - } - return self.send_request('post', 'cosigner/%s/sign' % quote(id), payload, - timeout=60) + +# The desktop wizard displays the second master private key in a QR code, +# along with the two other master public keys, for the mobile app to scan. +COSIGNER_QR_PREFIX = '2fa_cosigner:' + + +def make_cosigner_qr_data(xprv2: str, xpub1: str, xpub3: str) -> str: + return COSIGNER_QR_PREFIX + ':'.join([xprv2, xpub1, xpub3]) -server = TrustedCoinCosignerClient(user_agent="Electrum/" + version.ELECTRUM_VERSION) +def parse_cosigner_qr_data(data: str) -> Tuple[str, str, str]: + """Returns (xprv2, xpub1, xpub3). Raises ValueError.""" + if not data.startswith(COSIGNER_QR_PREFIX): + raise ValueError('not a 2fa cosigner QR code') + keys = data[len(COSIGNER_QR_PREFIX):].split(':') + if len(keys) != 3: + raise ValueError('unexpected number of keys in 2fa cosigner QR code') + xprv2, xpub1, xpub3 = keys + if not (is_xprv(xprv2) and is_xpub(xpub1) and is_xpub(xpub3)): + raise ValueError('invalid keys in 2fa cosigner QR code') + return xprv2, xpub1, xpub3 class Wallet_2fa(Multisig_Wallet): @@ -253,137 +101,14 @@ class Wallet_2fa(Multisig_Wallet): def __init__(self, db, *, config): self.m, self.n = 2, 3 + if not db.get('x3'): + # wallet created offline, and never completed online with the TrustedCoin server + xpub3 = get_xpub3(db.get('x1')['xpub'], db.get('x2')['xpub']) + db.put('x3', keystore.from_xpub(xpub3).dump()) Deterministic_Wallet.__init__(self, db, config=config) - self.is_billing = False - self.billing_info = None - self._load_billing_addresses() - - def _load_billing_addresses(self): - billing_addresses = { - 'legacy': self.db.get('trustedcoin_billing_addresses', {}), - 'segwit': self.db.get('trustedcoin_billing_addresses_segwit', {}) - } - self._billing_addresses = {} # type: Dict[str, Dict[int, str]] # addr_type -> index -> addr - self._billing_addresses_set = set() # set of addrs - for addr_type, d in list(billing_addresses.items()): - self._billing_addresses[addr_type] = {} - # convert keys from str to int - for index, addr in d.items(): - self._billing_addresses[addr_type][int(index)] = addr - self._billing_addresses_set.add(addr) - - def can_sign_without_server(self): - return not self.keystores['x2'].is_watching_only() - - def get_user_id(self): - return get_user_id(self.db) - - def min_prepay(self): - return min(self.price_per_tx.keys()) - - def num_prepay(self): - default_fallback = self.min_prepay() - num = self.config.PLUGIN_TRUSTEDCOIN_NUM_PREPAY - if num not in self.price_per_tx: - num = default_fallback - return num - - def extra_fee(self): - if self.can_sign_without_server(): - return 0 - if self.billing_info is None: - self.plugin.start_request_thread(self) - return 0 - if self.billing_info.get('tx_remaining'): - return 0 - if self.is_billing: - return 0 - n = self.num_prepay() - price = int(self.price_per_tx[n]) - # sanity check: price capped at 0.5 mBTC per tx or 20 mBTC total - # (note that the server can influence our choice of n by sending unexpected values) - if price > min(50_000 * n, 2_000_000): - raise Exception(f"too high trustedcoin fee ({price} for {n} txns)") - return price - - def make_unsigned_transaction( - self, *, - outputs: List[PartialTxOutput], - is_sweep=False, - **kwargs, - ) -> PartialTransaction: - - mk_tx = lambda o: Multisig_Wallet.make_unsigned_transaction( - self, outputs=o, **kwargs) - extra_fee = self.extra_fee() if not is_sweep else 0 - if extra_fee: - address = self.billing_info['billing_address_segwit'] - fee_output = PartialTxOutput.from_address_and_value(address, extra_fee) - try: - tx = mk_tx(outputs + [fee_output]) - except NotEnoughFunds: - # TrustedCoin won't charge if the total inputs is - # lower than their fee - tx = mk_tx(outputs) - if tx.input_value() >= extra_fee: - raise - self.logger.info("not charging for this tx") - else: - tx = mk_tx(outputs) - return tx - - def on_otp(self, tx: PartialTransaction, otp): - if not otp: - self.logger.info("sign_transaction: no auth code") - return - otp = int(otp) - long_user_id, short_id = self.get_user_id() - raw_tx = tx.serialize_as_bytes().hex() - assert raw_tx[:10] == "70736274ff", f"bad magic. {raw_tx[:10]}" - try: - r = server.sign(short_id, raw_tx, otp) - except TrustedCoinException as e: - if e.status_code == 400: # invalid OTP - raise UserFacingException(_('Invalid one-time password.')) from e - else: - raise - if r: - received_raw_tx = r.get('transaction') - received_tx = Transaction(received_raw_tx) - tx.combine_with_other_psbt(received_tx) - self.logger.info(f"twofactor: is complete {tx.is_complete()}") - # reset billing_info - self.billing_info = None - self.plugin.start_request_thread(self) - - def add_new_billing_address(self, billing_index: int, address: str, addr_type: str): - billing_addresses_of_this_type = self._billing_addresses[addr_type] - saved_addr = billing_addresses_of_this_type.get(billing_index) - if saved_addr is not None: - if saved_addr == address: - return # already saved this address - else: - raise Exception('trustedcoin billing address inconsistency.. ' - 'for index {}, already saved {}, now got {}' - .format(billing_index, saved_addr, address)) - if billing_index > 50_000: # otherwise DOS against CPU/memory/disk - raise Exception(f"trustedcoin billing_index too high. got {billing_index} > 50_000") - # do we have all prior indices? (are we synced?) - largest_index_we_have = max(billing_addresses_of_this_type) if billing_addresses_of_this_type else -1 - if largest_index_we_have + 1 < billing_index: # need to sync - for i in range(largest_index_we_have + 1, billing_index): - addr = make_billing_address(self, i, addr_type=addr_type) - billing_addresses_of_this_type[i] = addr - self._billing_addresses_set.add(addr) - # save this address; and persist to disk - billing_addresses_of_this_type[billing_index] = address - self._billing_addresses_set.add(address) - self._billing_addresses[addr_type] = billing_addresses_of_this_type - self.db.put('trustedcoin_billing_addresses', self._billing_addresses['legacy']) - self.db.put('trustedcoin_billing_addresses_segwit', self._billing_addresses['segwit']) - - def is_billing_address(self, addr: str) -> bool: - return addr in self._billing_addresses_set + + def can_sign_without_cosigner(self) -> bool: + return not self.keystores['x1'].is_watching_only() and not self.keystores['x2'].is_watching_only() def can_enable_disable_keystore(self, ks: KeyStore) -> bool: return False @@ -418,37 +143,18 @@ def make_xpub(xpub, s) -> str: return child_node.to_xpub() -def make_billing_address(wallet, num, addr_type): - long_id, short_id = wallet.get_user_id() - xpub = make_xpub(get_billing_xpub(), long_id) - usernode = BIP32Node.from_xkey(xpub) - child_node = usernode.subkey_at_public_derivation([num]) - pubkey = child_node.eckey.get_public_key_bytes(compressed=True) - if addr_type == 'legacy': - return bitcoin.public_key_to_p2pkh(pubkey) - elif addr_type == 'segwit': - return bitcoin.public_key_to_p2wpkh(pubkey) - else: - raise ValueError(f'unexpected billing type: {addr_type}') - - -def finish_requesting(func): - def f(self, *args, **kwargs): - try: - return func(self, *args, **kwargs) - finally: - self.requesting = False - return f +def get_xpub3(xpub1: str, xpub2: str) -> str: + """The third key is derived from the TrustedCoin signing key.""" + long_user_id, short_id = get_user_id({'x1': {'xpub': xpub1}, 'x2': {'xpub': xpub2}}) + return make_xpub(get_signing_xpub(xpub_type(xpub1)), long_user_id) class TrustedCoinPlugin(BasePlugin): wallet_class = Wallet_2fa - disclaimer_msg = DISCLAIMER def __init__(self, parent, config, name): BasePlugin.__init__(self, parent, config, name) self.wallet_class.plugin = self - self.requesting = False def is_available(self): return True @@ -459,86 +165,6 @@ def is_enabled(self): def can_user_disable(self): return False - @hook - def tc_sign_wrapper(self, wallet, tx, on_success, on_failure): - if not isinstance(wallet, self.wallet_class): - return - if tx.is_complete(): - return - if wallet.can_sign_without_server(): - return - if not wallet.keystores['x3'].can_sign(tx, ignore_watching_only=True): - self.logger.info("twofactor: xpub3 not needed") - return - - def wrapper(tx): - assert tx - self.prompt_user_for_otp(wallet, tx, on_success, on_failure) - - return wrapper - - def prompt_user_for_otp(self, wallet, tx, on_success, on_failure) -> None: - raise NotImplementedError() - - @hook - def get_tx_extra_fee(self, wallet, tx: Transaction): - if type(wallet) is not Wallet_2fa: - return - for o in tx.outputs(): - if wallet.is_billing_address(o.address): - return o.address, o.value - - @finish_requesting - def request_billing_info(self, wallet: 'Wallet_2fa', *, suppress_connection_error=True): - if wallet.can_sign_without_server(): - return - self.logger.info("request billing info") - try: - billing_info = server.get(wallet.get_user_id()[1]) - except ErrorConnectingServer as e: - if suppress_connection_error: - self.logger.info(repr(e)) - return - raise - billing_index = billing_info['billing_index'] - # add segwit billing address; this will be used for actual billing - billing_address = make_billing_address(wallet, billing_index, addr_type='segwit') - if billing_address != billing_info['billing_address_segwit']: - raise Exception(f'unexpected trustedcoin billing address: ' - f'calculated {billing_address}, received {billing_info["billing_address_segwit"]}') - wallet.add_new_billing_address(billing_index, billing_address, addr_type='segwit') - # also add legacy billing address; only used for detecting past payments in GUI - billing_address = make_billing_address(wallet, billing_index, addr_type='legacy') - wallet.add_new_billing_address(billing_index, billing_address, addr_type='legacy') - - wallet.billing_info = billing_info - wallet.price_per_tx = dict(billing_info['price_per_tx']) - wallet.price_per_tx.pop(1, None) - self.billing_info_retrieved(wallet) - return True - - def billing_info_retrieved(self, wallet): - # override to handle billing info when it becomes available - pass - - def start_request_thread(self, wallet): - from threading import Thread - if self.requesting is False: - self.requesting = True - t = Thread(target=self.request_billing_info, args=(wallet,)) - t.daemon = True - t.start() - return t - - def make_seed(self, seed_type): - if not is_any_2fa_seed_type(seed_type): - raise Exception(f'unexpected seed type: {seed_type!r}') - return Mnemonic('english').make_seed(seed_type=seed_type) - - @hook - def do_clear(self, window): - window.wallet.is_billing = False - @classmethod def get_xkeys(cls, seed, t, passphrase, derivation): assert is_any_2fa_seed_type(t) @@ -576,17 +202,6 @@ def xkeys_from_seed(cls, seed, passphrase): raise Exception(f'unexpected seed type: {t!r}') return xprv1, xpub1, xprv2, xpub2 - @hook - def get_action(self, db): - if db.get('wallet_type') != '2fa': - return - if not db.get('x1'): - return self, 'show_disclaimer' - if not db.get('x2'): - return self, 'show_disclaimer' - if not db.get('x3'): - return self, 'accept_terms_of_use' - # insert trustedcoin pages in new wallet wizard def extend_wizard(self, wizard: 'NewWalletWizard'): views = { @@ -594,20 +209,8 @@ def extend_wizard(self, wizard: 'NewWalletWizard'): 'next': 'trustedcoin_choose_seed', }, 'trustedcoin_choose_seed': { - 'next': lambda d: 'trustedcoin_create_seed' if d['keystore_type'] == 'createseed' - else 'trustedcoin_have_seed' - }, - 'trustedcoin_create_seed': { - 'next': lambda d: 'trustedcoin_create_ext' if wizard.wants_ext(d) else 'trustedcoin_confirm_seed', - }, - 'trustedcoin_create_ext': { - 'next': 'trustedcoin_confirm_seed', - }, - 'trustedcoin_confirm_seed': { - 'next': lambda d: 'trustedcoin_confirm_ext' if wizard.wants_ext(d) else 'trustedcoin_tos', - }, - 'trustedcoin_confirm_ext': { - 'next': 'trustedcoin_tos', + 'next': lambda d: 'trustedcoin_have_seed' if d['keystore_type'] == 'haveseed' + else 'trustedcoin_scan_cosigner_qr', }, 'trustedcoin_have_seed': { 'next': lambda d: 'trustedcoin_have_ext' if wizard.wants_ext(d) else 'trustedcoin_keep_disable', @@ -616,66 +219,42 @@ def extend_wizard(self, wizard: 'NewWalletWizard'): 'next': 'trustedcoin_keep_disable', }, 'trustedcoin_keep_disable': { - 'next': lambda d: 'trustedcoin_tos' if d['trustedcoin_keepordisable'] != 'disable' + 'next': lambda d: 'trustedcoin_show_cosigner_qr' if d['trustedcoin_keepordisable'] != 'disable' else 'wallet_password', - 'accept': self.recovery_disable, + 'accept': lambda d: self.recovery_disable(d) if d['trustedcoin_keepordisable'] == 'disable' else None, 'last': lambda d: wizard.is_single_password() and d['trustedcoin_keepordisable'] == 'disable' }, - 'trustedcoin_tos': { - 'next': lambda d: 'trustedcoin_show_confirm_otp' if 'xprv1' not in d or is_xprv(d['xprv1']) - else 'trustedcoin_keystore_unlock' - }, - 'trustedcoin_keystore_unlock': { - 'next': 'trustedcoin_show_confirm_otp' + # desktop: show xprv2 to the mobile cosigner, keep xprv1 + 'trustedcoin_show_cosigner_qr': { + 'accept': self.on_accept_cosigner_qr, + 'next': 'wallet_password', + 'last': lambda d: wizard.is_single_password() }, - 'trustedcoin_show_confirm_otp': { - 'accept': self.on_accept_otp_secret, + # mobile: scan xprv2 from the desktop wizard + 'trustedcoin_scan_cosigner_qr': { + 'accept': self.on_scan_cosigner_qr, 'next': 'wallet_password', - 'last': lambda d: wizard.is_single_password() or 'xprv1' in d - } + 'last': lambda d: wizard.is_single_password() + }, } wizard.navmap_merge(views) - # combined create_keystore and create_remote_key pre - def create_keys(self, wizard_data): - if 'seed' not in wizard_data: - # online continuation - xprv1, xpub1, xprv2, xpub2 = (wizard_data['xprv1'], wizard_data['xpub1'], None, wizard_data['xpub2']) - else: - seed_extension = wizard_data['seed_extra_words'] if wizard_data['seed_extend'] else '' - xprv1, xpub1, xprv2, xpub2 = self.xkeys_from_seed(wizard_data['seed'], seed_extension) - - data = {'x1': {'xpub': xpub1}, 'x2': {'xpub': xpub2}} + def create_keys(self, wizard_data) -> Tuple[str, str, str, str, str]: + seed_extension = wizard_data['seed_extra_words'] if wizard_data['seed_extend'] else '' + xprv1, xpub1, xprv2, xpub2 = self.xkeys_from_seed(wizard_data['seed'], seed_extension) + return xprv1, xpub1, xprv2, xpub2, get_xpub3(xpub1, xpub2) - # Generate third key deterministically. - long_user_id, short_id = get_user_id(data) - xtype = xpub_type(xpub1) - xpub3 = make_xpub(get_signing_xpub(xtype), long_user_id) + def on_accept_cosigner_qr(self, wizard_data): + self.logger.debug('mobile cosigner confirmed, creating keystores') + xprv1, xpub1, xprv2, xpub2, xpub3 = self.create_keys(wizard_data) + wizard_data.update({'x1': xprv1, 'x2': xpub2, 'x3': xpub3}) - return xprv1, xpub1, xprv2, xpub2, xpub3, short_id - - def on_accept_otp_secret(self, wizard_data): - self.logger.debug('OTP secret accepted, creating keystores') - xprv1, xpub1, xprv2, xpub2, xpub3, short_id = self.create_keys(wizard_data) - k1 = keystore.from_xprv(xprv1) - k2 = keystore.from_xpub(xpub2) - k3 = keystore.from_xpub(xpub3) - - wizard_data['x1'] = k1.dump() - wizard_data['x2'] = k2.dump() - wizard_data['x3'] = k3.dump() + def on_scan_cosigner_qr(self, wizard_data): + self.logger.debug('cosigner QR code scanned, creating keystores') + xprv2, xpub1, xpub3 = parse_cosigner_qr_data(wizard_data['trustedcoin_cosigner_qr']) + wizard_data.update({'x1': xpub1, 'x2': xprv2, 'x3': xpub3}) def recovery_disable(self, wizard_data): - if wizard_data['trustedcoin_keepordisable'] != 'disable': - return - self.logger.debug('2fa disabled, creating keystores') - xprv1, xpub1, xprv2, xpub2, xpub3, short_id = self.create_keys(wizard_data) - k1 = keystore.from_xprv(xprv1) - k2 = keystore.from_xprv(xprv2) - k3 = keystore.from_xpub(xpub3) - - wizard_data['x1'] = k1.dump() - wizard_data['x2'] = k2.dump() - wizard_data['x3'] = k3.dump() - + xprv1, xpub1, xprv2, xpub2, xpub3 = self.create_keys(wizard_data) + wizard_data.update({'x1': xprv1, 'x2': xprv2, 'x3': xpub3}) diff --git a/electrum/simple_config.py b/electrum/simple_config.py index a4fed3f9064a..aa657aeb2f35 100644 --- a/electrum/simple_config.py +++ b/electrum/simple_config.py @@ -993,8 +993,6 @@ def __setattr__(self, name, value): # connect to remote WT WATCHTOWER_CLIENT_URL = ConfigVar('watchtower_url', default=None, type_=str) - PLUGIN_TRUSTEDCOIN_NUM_PREPAY = ConfigVar('trustedcoin_prepay', default=20, type_=int) - def read_user_config(path: Optional[str]) -> Dict[str, Any]: """Parse and store the user config settings in electrum.conf into user_config[].""" diff --git a/electrum/wallet.py b/electrum/wallet.py index 6f0b9854bf01..ff1459b0fda6 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -2009,7 +2009,6 @@ def make_unsigned_transaction( inputs: Optional[List[PartialTxInput]] = None, fee_policy: FeePolicy, change_addr: str | None = None, - is_sweep: bool = False, # used by Wallet_2fa subclass rbf: bool = True, BIP69_sort: Optional[bool] = True, base_tx: Optional[Transaction] = None, @@ -2575,9 +2574,6 @@ def _bump_fee_through_decreasing_payment( return PartialTransaction.from_io(inputs, outputs) def _is_rbf_allowed_to_touch_tx_output(self, txout: TxOutput) -> bool: - # 2fa fee outputs if present, should not be removed or have their value decreased - if self.is_billing_address(txout.address): - return False # submarine swap funding outputs must not be decreased if self.lnworker and self.lnworker.swap_manager.is_lockup_address_for_a_swap(txout.address): return False @@ -3413,10 +3409,6 @@ def coin_price(self, txid, price_func, ccy, txin_value) -> Decimal: p = self.price_at_timestamp(txid, price_func) return p * txin_value/Decimal(COIN) - def is_billing_address(self, addr): - # overridden for TrustedCoin wallets - return False - @abstractmethod def is_watching_only(self) -> bool: pass diff --git a/electrum/wallet_db.py b/electrum/wallet_db.py index 7aa8e73c7404..d26dc11cebb9 100644 --- a/electrum/wallet_db.py +++ b/electrum/wallet_db.py @@ -46,7 +46,7 @@ from .json_db import JsonDB, locked, modifier from . import stored_dict from .stored_dict import StoredObject, stored_at, register_key, register_name -from .plugin import run_hook, plugin_loaders +from .plugin import plugin_loaders from .version import ELECTRUM_VERSION from .i18n import _ @@ -64,12 +64,6 @@ def __init__(self, split_data): self._split_data = split_data -class WalletUnfinished(WalletFileException): - def __init__(self, wallet_db: 'WalletDB'): - super().__init__() - self._wallet_db = wallet_db - - # seed_version is now used for the version of the wallet file OLD_SEED_VERSION = 4 # electrum versions < 2.0 NEW_SEED_VERSION = 11 # electrum versions >= 2.0 @@ -2090,10 +2084,6 @@ def split_accounts(klass, root_path, split_data): file_list.append(path) return file_list - def get_action(self): - action = run_hook('get_action', self) - return action - def load_plugins(self): wallet_type = self.get('wallet_type') if wallet_type in plugin_loaders: diff --git a/electrum/wizard.py b/electrum/wizard.py index 6b2e7b65d2cd..db15d0bfb294 100644 --- a/electrum/wizard.py +++ b/electrum/wizard.py @@ -180,7 +180,7 @@ def sanitize_stack_item(self, _stack_item) -> dict: # multisig: "multisig_participants", "multisig_signatures", "multisig_current_cosigner", "cosigner_keystore_type", # trustedcoin: - "trustedcoin_keepordisable", "trustedcoin_go_online", + "trustedcoin_keepordisable", ] def sanitize(_dict): @@ -693,7 +693,9 @@ def create_storage(self, path: str, data: dict): # TODO: refactor using self.keystore_from_data k = None - if 'keystore_type' not in data: + if data['wallet_type'] == '2fa': + pass # keystores were created by the trustedcoin plugin, see below + elif 'keystore_type' not in data: assert data['wallet_type'] == 'imported' addresses = {} if 'private_key_list' in data: @@ -734,9 +736,6 @@ def create_storage(self, path: str, data: dict): else: script = data['script_type'] if data['script_type'] != 'p2pkh' else 'standard' k = keystore.from_bip43_rootseed(root_seed, derivation=derivation, xtype=script) - elif is_any_2fa_seed_type(data['seed_type']): - self._logger.debug('creating keystore from 2fa seed') - k = keystore.from_xprv(data['x1']['xprv']) else: raise NotImplementedError('unsupported/unknown seed_type %s' % data['seed_type']) elif data['keystore_type'] == 'masterkey': @@ -788,17 +787,11 @@ def create_storage(self, path: str, data: dict): if data['wallet_type'] == 'standard': db.put('keystore', k.dump()) elif data['wallet_type'] == '2fa': - db.put('x1', k.dump()) - if 'trustedcoin_keepordisable' in data and data['trustedcoin_keepordisable'] == 'disable': - k2 = keystore.from_xprv(data['x2']['xprv']) - if data['encrypt'] and k2.may_have_password(): - k2.update_password(None, data['password']) - db.put('x2', k2.dump()) - else: - db.put('x2', data['x2']) - if 'x3' in data: - db.put('x3', data['x3']) - db.put('use_trustedcoin', True) + for name in ['x1', 'x2', 'x3']: + k2fa = keystore.from_master_key(data[name]) + if data['password'] and k2fa.may_have_password(): + k2fa.update_password(None, data['password']) + db.put(name, k2fa.dump()) elif data['wallet_type'] == 'multisig': if not isinstance(k, keystore.Xpub): raise TypeError(f'unexpected keystore(main) type={type(k)} in multisig. not bip32.') diff --git a/tests/test_wallet_vertical.py b/tests/test_wallet_vertical.py index ac774d8c8976..b4e6221602cc 100644 --- a/tests/test_wallet_vertical.py +++ b/tests/test_wallet_vertical.py @@ -2599,7 +2599,6 @@ async def get_transaction(self, txid): coins=coins, outputs=[PartialTxOutput.from_address_and_value(dest_addr, value='!')], fee_policy=FixedFeePolicy(500), - is_sweep=True, ) tx.sign(keypairs) self.assertTrue(tx.is_complete()) diff --git a/tests/test_wizard.py b/tests/test_wizard.py index 7c5b740a8611..5ae6872f583f 100644 --- a/tests/test_wizard.py +++ b/tests/test_wizard.py @@ -7,7 +7,13 @@ from electrum.plugin import Plugins, DeviceInfo, Device from electrum.wizard import ServerConnectWizard, NewWalletWizard, WizardViewState, KeystoreWizard from electrum.daemon import Daemon -from electrum.wallet import Abstract_Wallet, Deterministic_Wallet +from electrum.wallet import Abstract_Wallet, Deterministic_Wallet, Wallet +from electrum.wallet_db import WalletDB +from electrum.address_synchronizer import TX_HEIGHT_UNCONFIRMED +from electrum.bitcoin import address_to_script +from electrum.fee_policy import FixedFeePolicy +from electrum.transaction import Transaction, PartialTxOutput, tx_from_any +from electrum.plugins.trustedcoin import trustedcoin from electrum import util from electrum import slip39 from electrum.bip32 import KeyOriginInfo @@ -784,29 +790,48 @@ async def test_create_standard_wallet_haveseed_slip39_passphrase(self): v = w.resolve_next(v.view, d) self._set_password_and_check_address(v=v, w=w, recv_addr="bc1qs2svwhfz47qv9qju2waa6prxzv5f522fc4p06t") - async def test_2fa_createseed(self): + def _wizard_for_2fa_haveseed( + self, + *, + name: str = "mywallet", + keepordisable: str, + seed_extra_words: str | None = None, + ) -> tuple[NewWalletWizard, WizardViewState]: + """Restores a 2fa wallet from seed, up to the 'wallet_password' view.""" self.assertTrue(self.config.get('enable_plugin_trustedcoin')) - w = self._wizard_for(wallet_type='2fa') + w = self._wizard_for(name=name, wallet_type='2fa') v = w._current d = v.wizard_data self.assertEqual('trustedcoin_start', v.view) + v = w.resolve_next(v.view, d) self.assertEqual('trustedcoin_choose_seed', v.view) - d.update({'keystore_type': 'createseed'}) + d.update({'keystore_type': 'haveseed'}) v = w.resolve_next(v.view, d) - self.assertEqual('trustedcoin_create_seed', v.view) + self.assertEqual('trustedcoin_have_seed', v.view) d.update({ 'seed': 'oblige basket safe educate whale bacon celery demand novel slice various awkward', - 'seed_type': '2fa', 'seed_extend': False, 'seed_variant': 'electrum', + 'seed_type': '2fa', 'seed_extend': seed_extra_words is not None, 'seed_variant': 'electrum', }) v = w.resolve_next(v.view, d) - self.assertEqual('trustedcoin_confirm_seed', v.view) - v = w.resolve_next(v.view, d) - self.assertEqual('trustedcoin_tos', v.view) - v = w.resolve_next(v.view, d) - self.assertEqual('trustedcoin_show_confirm_otp', v.view) + if seed_extra_words is not None: + self.assertEqual('trustedcoin_have_ext', v.view) + d.update({'seed_extra_words': seed_extra_words}) + v = w.resolve_next(v.view, d) + self.assertEqual('trustedcoin_keep_disable', v.view) + d.update({'trustedcoin_keepordisable': keepordisable}) v = w.resolve_next(v.view, d) + if keepordisable == 'keep': + self.assertEqual('trustedcoin_show_cosigner_qr', v.view) + v = w.resolve_next(v.view, d) + return w, v + + async def test_2fa_haveseed_keep2FAenabled(self): + w, v = self._wizard_for_2fa_haveseed(keepordisable='keep') wallet = self._set_password_and_check_address(v=v, w=w, recv_addr="bc1qnf5qafvpx0afk47433j3tt30pqkxp5wa263m77wt0pvyqq67rmfs522m94") + self.assertFalse(wallet.keystores['x1'].is_watching_only()) + self.assertTrue(wallet.keystores['x2'].is_watching_only()) + self.assertFalse(wallet.can_sign_without_cosigner()) with self.subTest(msg="2fa wallet cannot enable/disable keystore"): for ks in wallet.get_keystores(): @@ -818,82 +843,86 @@ async def test_2fa_createseed(self): wallet.enable_keystore(ks, False, None) self.assertTrue("2fa wallet cannot" in ctx.exception.args[0]) - async def test_2fa_haveseed_keep2FAenabled(self): - self.assertTrue(self.config.get('enable_plugin_trustedcoin')) - w = self._wizard_for(wallet_type='2fa') - v = w._current - d = v.wizard_data - self.assertEqual('trustedcoin_start', v.view) - - v = w.resolve_next(v.view, d) - self.assertEqual('trustedcoin_choose_seed', v.view) - d.update({'keystore_type': 'haveseed'}) - v = w.resolve_next(v.view, d) - self.assertEqual('trustedcoin_have_seed', v.view) - d.update({ - 'seed': 'oblige basket safe educate whale bacon celery demand novel slice various awkward', - 'seed_type': '2fa', 'seed_extend': False, 'seed_variant': 'electrum', - }) - v = w.resolve_next(v.view, d) - self.assertEqual('trustedcoin_keep_disable', v.view) - d.update({'trustedcoin_keepordisable': 'keep'}) - v = w.resolve_next(v.view, d) - self.assertEqual('trustedcoin_tos', v.view) - v = w.resolve_next(v.view, d) - self.assertEqual('trustedcoin_show_confirm_otp', v.view) - v = w.resolve_next(v.view, d) - self._set_password_and_check_address(v=v, w=w, recv_addr="bc1qnf5qafvpx0afk47433j3tt30pqkxp5wa263m77wt0pvyqq67rmfs522m94") - async def test_2fa_haveseed_disable2FA(self): - self.assertTrue(self.config.get('enable_plugin_trustedcoin')) - w = self._wizard_for(wallet_type='2fa') - v = w._current - d = v.wizard_data - self.assertEqual('trustedcoin_start', v.view) - - v = w.resolve_next(v.view, d) - self.assertEqual('trustedcoin_choose_seed', v.view) - d.update({'keystore_type': 'haveseed'}) - v = w.resolve_next(v.view, d) - self.assertEqual('trustedcoin_have_seed', v.view) - d.update({ - 'seed': 'oblige basket safe educate whale bacon celery demand novel slice various awkward', - 'seed_type': '2fa', 'seed_extend': False, 'seed_variant': 'electrum', - }) - v = w.resolve_next(v.view, d) - self.assertEqual('trustedcoin_keep_disable', v.view) - d.update({'trustedcoin_keepordisable': 'disable'}) - v = w.resolve_next(v.view, d) - self._set_password_and_check_address(v=v, w=w, recv_addr="bc1qnf5qafvpx0afk47433j3tt30pqkxp5wa263m77wt0pvyqq67rmfs522m94") + w, v = self._wizard_for_2fa_haveseed(keepordisable='disable') + wallet = self._set_password_and_check_address(v=v, w=w, recv_addr="bc1qnf5qafvpx0afk47433j3tt30pqkxp5wa263m77wt0pvyqq67rmfs522m94") + self.assertTrue(wallet.can_sign_without_cosigner()) async def test_2fa_haveseed_passphrase(self): - self.assertTrue(self.config.get('enable_plugin_trustedcoin')) - w = self._wizard_for(wallet_type='2fa') + w, v = self._wizard_for_2fa_haveseed(keepordisable='keep', seed_extra_words=UNICODE_HORROR) + self._set_password_and_check_address(v=v, w=w, recv_addr="bc1qcnu9ay4v3w0tawuxe6wlh6mh33rrpauqnufdgkxx7we8vpx3e6wqa25qud") + + async def test_2fa_mobile_cosigner(self): + recv_addr = "bc1qnf5qafvpx0afk47433j3tt30pqkxp5wa263m77wt0pvyqq67rmfs522m94" + # desktop: restore the 2fa seed, and display the cosigner QR code + w, v = self._wizard_for_2fa_haveseed(name='desktop', keepordisable='keep') + xprv1, xpub1, xprv2, xpub2, xpub3 = w.plugins.get_plugin('trustedcoin').create_keys(v.wizard_data) + cosigner_qr = trustedcoin.make_cosigner_qr_data(xprv2, xpub1, xpub3) + self.assertEqual( + '2fa_cosigner:' + 'ZprvAkSth4YNtt4491XR5QabACZmFzdDV7XPN2KiYEaJsbMBtKJDEsji7cNDMEEusUbvqYtze57R9UvKsBn2g5LLAjhQaVSNTMKagZfQKSe8KBA:' + 'Zpub6ySF6a5GjFcMJW1bx83wzKoke47zD6ouqGZ9zfBBne6htiwJtqPpvxfcWn9HsJF8wsKVzzMCunJA1Ux7Cm9AAtKY658yzheEV7usDVXa9E7:' + 'Zpub6vZyhw1ShkEwNuzEqzZx6oLjntiSVTtdNZwVuKFPkYTN8o1nq2UK4e6HnucfhgLm3UVJ1ZWrnfmN8swnT7bYJ5e7sGUqHsTghP8Wc7MJ5ji', + cosigner_qr) + desktop_wallet = self._set_password_and_check_address(v=v, w=w, recv_addr=recv_addr, password='desktop') + + # mobile: scan the cosigner QR code + w = self._wizard_for(name='mobile', wallet_type='2fa') v = w._current d = v.wizard_data self.assertEqual('trustedcoin_start', v.view) - v = w.resolve_next(v.view, d) self.assertEqual('trustedcoin_choose_seed', v.view) - d.update({'keystore_type': 'haveseed'}) - v = w.resolve_next(v.view, d) - self.assertEqual('trustedcoin_have_seed', v.view) - d.update({ - 'seed': 'oblige basket safe educate whale bacon celery demand novel slice various awkward', - 'seed_type': '2fa', 'seed_extend': True, 'seed_variant': 'electrum', - }) - v = w.resolve_next(v.view, d) - self.assertEqual('trustedcoin_have_ext', v.view) - d.update({'seed_extra_words': UNICODE_HORROR}) - v = w.resolve_next(v.view, d) - self.assertEqual('trustedcoin_keep_disable', v.view) - d.update({'trustedcoin_keepordisable': 'keep'}) - v = w.resolve_next(v.view, d) - self.assertEqual('trustedcoin_tos', v.view) - v = w.resolve_next(v.view, d) - self.assertEqual('trustedcoin_show_confirm_otp', v.view) - v = w.resolve_next(v.view, d) - self._set_password_and_check_address(v=v, w=w, recv_addr="bc1qcnu9ay4v3w0tawuxe6wlh6mh33rrpauqnufdgkxx7we8vpx3e6wqa25qud") + d.update({'keystore_type': 'cosigner_qr'}) + v = w.resolve_next(v.view, d) + self.assertEqual('trustedcoin_scan_cosigner_qr', v.view) + d.update({'trustedcoin_cosigner_qr': cosigner_qr}) + v = w.resolve_next(v.view, d) + mobile_wallet = self._set_password_and_check_address(v=v, w=w, recv_addr=recv_addr, password='mobile') + + self.assertTrue(isinstance(mobile_wallet, trustedcoin.Wallet_2fa)) + self.assertTrue(mobile_wallet.keystores['x1'].is_watching_only()) + self.assertFalse(mobile_wallet.keystores['x2'].is_watching_only()) + self.assertFalse(mobile_wallet.can_sign_without_cosigner()) + + with self.subTest(msg="desktop signs, mobile cosigns"): + spk = address_to_script(recv_addr) + funding_tx = Transaction( + '0200000001' + 32 * '11' + '0000000000ffffffff01' + + (100_000).to_bytes(8, 'little').hex() + bytes([len(spk)]).hex() + spk.hex() + '00000000') + for wallet in (desktop_wallet, mobile_wallet): + wallet.adb.receive_tx_callback(funding_tx, tx_height=TX_HEIGHT_UNCONFIRMED) + outputs = [PartialTxOutput.from_address_and_value('bc1qs2svwhfz47qv9qju2waa6prxzv5f522fc4p06t', 50_000)] + tx = desktop_wallet.make_unsigned_transaction(outputs=outputs, fee_policy=FixedFeePolicy(1000)) + desktop_wallet.sign_transaction(tx, password='desktop') + self.assertFalse(tx.is_complete()) + qr_data, __ = tx.to_qr_data() + tx = tx_from_any(qr_data) # scanned by the mobile app + mobile_wallet.sign_transaction(tx, password='mobile') + self.assertTrue(tx.is_complete()) + + with self.subTest(msg="invalid cosigner QR codes"): + for data in [ + xprv2, + trustedcoin.make_cosigner_qr_data(xpub2, xpub1, xpub3), + cosigner_qr + ':' + xpub3, + ]: + with self.assertRaises(ValueError): + trustedcoin.parse_cosigner_qr_data(data) + + async def test_2fa_wallet_without_x3(self): + # created offline, and never completed online with the TrustedCoin server + recv_addr = "bc1qnf5qafvpx0afk47433j3tt30pqkxp5wa263m77wt0pvyqq67rmfs522m94" + w, v = self._wizard_for_2fa_haveseed(keepordisable='keep') + wallet = self._set_password_and_check_address(v=v, w=w, recv_addr=recv_addr) + xpub3 = wallet.keystores['x3'].get_master_public_key() + + storage = WalletStorage(wallet.storage.path) + db = WalletDB(storage.read(), storage=storage, upgrade=True) + db.put('x3', None) + wallet = Wallet(db, config=self.config) + self.assertEqual(xpub3, wallet.keystores['x3'].get_master_public_key()) + self.assertEqual(recv_addr, wallet.get_receiving_addresses()[0]) async def test_create_standard_wallet_trezor(self): # bip39 seed for trezor: "history six okay anchor sheriff flock atom tomorrow foster aerobic eternal foam" From 950b85eb33d37b62ccfb8876c55b66ff60c7dfee Mon Sep 17 00:00:00 2001 From: ThomasV Date: Wed, 16 Sep 2026 11:57:48 +0200 Subject: [PATCH 2/3] cosigner: wallet-less transaction signing A mobile device can sign without a wallet file. This is setup by scanning a 'cosigner' QR code containing xprv/xpubs and the multisig threshold; enough to rebuild the scripts of that wallet, single-sig or multisig. Keys are stored in a 'keystore' json file. Keystore values are encrypted with the password of the device and indexed by bip32 fingerprint, so that a tx can be matched without decrypting anything. Storing a new key and signing both require authentication. An output counts as change only if it pays to a script we recompute. The trustedcoin plugin only keeps what is specific to 2fa: the two keys derived from one seed, and displaying the corresponding cosigner QR code. --- electrum/cosigner.py | 232 ++++++++++++++++++ electrum/daemon.py | 5 + electrum/descriptor.py | 35 +++ electrum/gui/qml/components/CosignDialog.qml | 124 ++++++++++ .../gui/qml/components/CosignerHandler.qml | 156 ++++++++++++ electrum/gui/qml/components/SendDialog.qml | 4 +- .../gui/qml/components/WalletMainView.qml | 4 +- electrum/gui/qml/components/main.qml | 21 +- .../qml/components/wizard/WCWalletName.qml | 12 + electrum/gui/qml/qeapp.py | 3 + electrum/gui/qml/qecosigner.py | 200 +++++++++++++++ electrum/gui/qml/qedaemon.py | 17 +- electrum/plugins/trustedcoin/qml.py | 33 +-- .../plugins/trustedcoin/qml/ChooseSeed.qml | 40 --- .../plugins/trustedcoin/qml/Disclaimer.qml | 4 + .../trustedcoin/qml/ScanCosignerQR.qml | 87 ------- electrum/plugins/trustedcoin/qml/main.qml | 16 -- electrum/plugins/trustedcoin/qt.py | 9 +- electrum/plugins/trustedcoin/trustedcoin.py | 46 +--- electrum/wallet.py | 23 +- tests/test_cosigner.py | 130 ++++++++++ tests/test_daemon.py | 42 +++- tests/test_wizard.py | 103 +++++--- 23 files changed, 1066 insertions(+), 280 deletions(-) create mode 100644 electrum/cosigner.py create mode 100644 electrum/gui/qml/components/CosignDialog.qml create mode 100644 electrum/gui/qml/components/CosignerHandler.qml create mode 100644 electrum/gui/qml/qecosigner.py delete mode 100644 electrum/plugins/trustedcoin/qml/ChooseSeed.qml delete mode 100644 electrum/plugins/trustedcoin/qml/ScanCosignerQR.qml delete mode 100644 electrum/plugins/trustedcoin/qml/main.qml create mode 100644 tests/test_cosigner.py diff --git a/electrum/cosigner.py b/electrum/cosigner.py new file mode 100644 index 000000000000..678eee738912 --- /dev/null +++ b/electrum/cosigner.py @@ -0,0 +1,232 @@ +# Copyright (C) 2026 The Electrum developers +# Distributed under the MIT software license, see the accompanying +# file LICENCE or http://www.opensource.org/licenses/mit-license.php + +"""Keys of the wallets this device cosigns for, stored in the 'keystores' file. + +This device can sign for a wallet it does not have: a key is scanned from a QR +code, together with the master public keys of the other cosigners, which are +needed in order to rebuild the scripts of that wallet. What we sign is checked +against those scripts, because the transaction we are given may lie about what +it does. + +A cosigner is indexed by the fingerprint that its key uses in transactions (the +root fingerprint of its key origin), so that a transaction can be matched against +that file without decrypting anything. Everything else is encrypted with the +password of the device, so that whoever reads the file cannot derive the addresses +of those wallets, and thus cannot see their history. +""" + +import json +import os +import stat +from typing import Dict, List, Optional, Sequence, Union, TYPE_CHECKING + +from . import descriptor, keystore +from .bip32 import is_xprv, is_xpub, xpub_type +from .crypto import pw_encode_with_version_and_mac, pw_decode_with_version_and_mac +from .transaction import PartialTransaction, PartialTxInput, PartialTxOutput +from .util import os_chmod + +if TYPE_CHECKING: + from .simple_config import SimpleConfig + + +KEYSTORES_FILE_NAME = 'keystores' + +# The device that holds the other keys of the wallet displays one of them in a +# QR code, for this device to scan. +COSIGNER_QR_PREFIX = 'cosigner:' + + +class Cosigner: + """A key this device signs with, and the wallet that key belongs to. + + The wallet is described the way Electrum describes one: the master public keys + of the other cosigners, and the number of signatures it requires. The type of + its scripts follows from the header of the master keys ('xpub', 'Ypub', 'Zpub'...). + """ + + def __init__(self, *, xprv: str, xpubs: Sequence[str] = (), m: int = 1): + self.xprv = xprv + self.xpubs = list(xpubs) + self.m = m + self.keystore = keystore.from_xprv(xprv) + # the order of the keys does not matter: Electrum sorts them in the script + self.keystores = [self.keystore] + [keystore.from_xpub(xpub) for xpub in self.xpubs] + if not 1 <= m <= len(self.keystores): + raise ValueError(f'{m} signatures for {len(self.keystores)} keys') + if len({ks.xpub for ks in self.keystores}) != len(self.keystores): + raise ValueError('duplicate keys') + if len({xpub_type(ks.xpub) for ks in self.keystores}) != 1: + raise ValueError('the keys of a wallet must have the same type') + + @classmethod + def from_qr_data(cls, data: str) -> 'Cosigner': + """Reads the QR code that sets up this device as cosigner. Raises ValueError.""" + if not data.startswith(COSIGNER_QR_PREFIX): + raise ValueError('not a cosigner QR code') + m, __, keys = data[len(COSIGNER_QR_PREFIX):].partition(':') + xprv, *xpubs = keys.split(':') + if not (m.isdigit() and is_xprv(xprv) and all(is_xpub(xpub) for xpub in xpubs)): + raise ValueError('invalid keys in cosigner QR code') + return cls(xprv=xprv, xpubs=xpubs, m=int(m)) + + def to_qr_data(self) -> str: + """The QR code that sets up another device as cosigner of this wallet.""" + return COSIGNER_QR_PREFIX + ':'.join([str(self.m), self.xprv, *self.xpubs]) + + @classmethod + def from_dict(cls, d: Dict) -> 'Cosigner': + return cls(xprv=d['xprv'], xpubs=d['xpubs'], m=d['m']) + + def to_dict(self) -> Dict: + return {'m': self.m, 'xprv': self.xprv, 'xpubs': self.xpubs} + + @property + def cosigner_id(self) -> str: + """A cosigner is indexed by the fingerprint its key uses in transactions.""" + return self.keystore.get_root_fingerprint() + + @property + def script_type(self) -> str: + # as Standard_Wallet and Multisig_Wallet do, in load_keystore + xtype = xpub_type(self.keystore.xpub) + if len(self.keystores) == 1: + return 'p2pkh' if xtype == 'standard' else xtype + return 'p2sh' if xtype == 'standard' else xtype + + def get_script_descriptor(self, der_suffix: Sequence[int]) -> descriptor.Descriptor: + pubkeys = [ks.get_pubkey_provider(der_suffix) for ks in self.keystores] + return descriptor.from_legacy_electrum_script_type(self.script_type, pubkeys=pubkeys, m=self.m) + + def claims_our_key(self, txinout: Union[PartialTxInput, PartialTxOutput]) -> bool: + """Whether the transaction says that input or output uses our key.""" + return self.keystore.find_my_pubkey_in_txinout(txinout)[0] is not None + + def get_wallet_script( + self, + txinout: Union[PartialTxInput, PartialTxOutput], + ) -> Optional[descriptor.Descriptor]: + """The script of the wallet for that input or output, None if it does not belong to it. + The derivation found in the transaction is only a hint: whoever created the transaction + can claim our key for a script they own, so the script is recomputed from the keys. + """ + __, der_suffix = self.keystore.find_my_pubkey_in_txinout(txinout, only_der_suffix=True) + if der_suffix is None: + return None + desc = self.get_script_descriptor(der_suffix) + return desc if txinout.scriptpubkey == desc.expand().output_script else None + + def is_wallet_output(self, txout: PartialTxOutput) -> bool: + """Whether that output pays back to the wallet.""" + return self.get_wallet_script(txout) is not None + + def sign_transaction(self, tx: PartialTransaction) -> None: + """Signs the transaction with our key. Raises ValueError if it cannot be verified.""" + # add the scripts of the wallet, which a signer would otherwise get from its wallet + for txin in tx.inputs(): + if not self.claims_our_key(txin): + continue + desc = self.get_wallet_script(txin) + if desc is None: + raise ValueError('input does not spend from this wallet') + txin.script_descriptor = desc + self.keystore.sign_transaction(tx, None) + + +def get_cosigner_ids(config: 'SimpleConfig') -> List[str]: + """The fingerprints of the cosigners this device signs for.""" + return list(_read_keystores(config)) + + +def get_cosigner(config: 'SimpleConfig', cosigner_id: str, password: str) -> Cosigner: + """Raises InvalidPassword.""" + cosigner = Cosigner.from_dict(_decrypt(_read_keystores(config)[cosigner_id], password)) + if cosigner.cosigner_id != cosigner_id: + raise ValueError(f'cosigner {cosigner_id} does not match its keys') + return cosigner + + +def add_cosigner(config: 'SimpleConfig', cosigner: Cosigner, password: str) -> None: + assert password, 'the keys of a cosigner must be encrypted' + cosigners = _read_keystores(config) + cosigners[cosigner.cosigner_id] = _encrypt(cosigner.to_dict(), password) + _write_keystores(config, cosigners) + + +def find_cosigner_id_for_tx(config: 'SimpleConfig', tx: PartialTransaction) -> Optional[str]: + """The cosigner that transaction needs, None if this device does not sign for it. + Nothing is decrypted, so the password of the device is not needed here. + """ + fingerprints = {fp.hex() for txin in tx.inputs() for fp, __ in txin.bip32_paths.values()} + for cosigner_id in get_cosigner_ids(config): + if cosigner_id in fingerprints: + return cosigner_id + return None + + +def find_cosigner_for_tx(config: 'SimpleConfig', tx: PartialTransaction, password: str) -> Optional[Cosigner]: + """The keys that transaction needs, None if this device does not sign for it. + Raises InvalidPassword. + """ + cosigner_id = find_cosigner_id_for_tx(config, tx) + return get_cosigner(config, cosigner_id, password) if cosigner_id is not None else None + + +def check_cosigners_password(config: 'SimpleConfig', password: str) -> None: + """Whether the keys of the cosigners can be read with that password. + Raises InvalidPassword. + """ + for blob in _read_keystores(config).values(): + _decrypt(blob, password) + + +def update_cosigners_password(config: 'SimpleConfig', old_password: str, new_password: str) -> None: + """Re-encrypts the keys, after the password of the device has changed. + Raises InvalidPassword, without writing anything. + """ + cosigners = _read_keystores(config) + if not cosigners: + # this device cosigns for nothing; do not create the file + return + _write_keystores(config, { + cosigner_id: _encrypt(_decrypt(blob, old_password), new_password) + for cosigner_id, blob in cosigners.items() + }) + + +def _encrypt(data: Dict, password: str) -> str: + return pw_encode_with_version_and_mac(json.dumps(data).encode('utf8'), password) + + +def _decrypt(blob: str, password: str) -> Dict: + return json.loads(pw_decode_with_version_and_mac(blob, password).decode('utf8')) + + +def _keystores_path(config: 'SimpleConfig') -> str: + return os.path.join(config.path, KEYSTORES_FILE_NAME) + + +def _read_keystores(config: 'SimpleConfig') -> Dict[str, str]: + path = _keystores_path(config) + if not os.path.exists(path): + return {} + with open(path, 'r', encoding='utf-8') as f: + cosigners = json.loads(f.read()) + if not isinstance(cosigners, dict): + raise ValueError(f'invalid keystores file at {path}') + return cosigners + + +def _write_keystores(config: 'SimpleConfig', cosigners: Dict[str, str]) -> None: + # written through a temporary file: a partial write would lose the keys of + # the other wallets this device cosigns for + path = _keystores_path(config) + temp_path = f'{path}.tmp.{os.getpid()}' + with open(temp_path, 'w', encoding='utf-8') as f: + os_chmod(temp_path, stat.S_IREAD | stat.S_IWRITE) # set restrictive perms *before* we write data + f.write(json.dumps(cosigners, indent=4, sort_keys=True)) + f.flush() + os.fsync(f.fileno()) + os.replace(temp_path, path) diff --git a/electrum/daemon.py b/electrum/daemon.py index 0c7df07ceb2c..11fbf7b97e76 100644 --- a/electrum/daemon.py +++ b/electrum/daemon.py @@ -50,6 +50,7 @@ from .wallet import Wallet, Abstract_Wallet from .storage import WalletStorage from .wallet_db import WalletDB +from .cosigner import check_cosigners_password, update_cosigners_password from .commands import known_commands, Commands from .simple_config import SimpleConfig from .exchange_rate import FxThread @@ -763,8 +764,12 @@ def update_password_for_directory( return False if is_unified and old_password == new_password: return True + # check the cosigner keys before any wallet is touched, so that a failure + # cannot leave them encrypted with a password that is no longer used + check_cosigners_password(self.config, old_password) self.check_password_for_directory( old_password=old_password, new_password=new_password, wallet_dir=wallet_dir) + update_cosigners_password(self.config, old_password, new_password) return True def update_recently_opened_wallets(self, wallet_path, *, remove: bool = False) -> None: diff --git a/electrum/descriptor.py b/electrum/descriptor.py index d23213e64d26..77f5afb69f85 100644 --- a/electrum/descriptor.py +++ b/electrum/descriptor.py @@ -1009,6 +1009,41 @@ def parse_tree(tree_str): raise ValueError("{} is not a valid descriptor function".format(func)) +def from_legacy_electrum_script_type( + script_type: str, + *, + pubkeys: List['PubkeyProvider'], + m: Optional[int] = None, +) -> 'Descriptor': + """Builds the descriptor of a script type Electrum names, as wallets do. + Inverse of Descriptor.to_legacy_electrum_script_type. m is the number of + signatures a multisig script requires. + + :raises: NotImplementedError: if the script type is unknown + """ + if script_type == 'p2pk': + return PKDescriptor(pubkey=pubkeys[0]) + elif script_type == 'p2pkh': + return PKHDescriptor(pubkey=pubkeys[0]) + elif script_type == 'p2wpkh': + return WPKHDescriptor(pubkey=pubkeys[0]) + elif script_type == 'p2wpkh-p2sh': + wpkh = WPKHDescriptor(pubkey=pubkeys[0]) + return SHDescriptor(subdescriptor=wpkh) + elif script_type == 'p2sh': + multi = MultisigDescriptor(pubkeys=pubkeys, thresh=m, is_sorted=True) + return SHDescriptor(subdescriptor=multi) + elif script_type == 'p2wsh': + multi = MultisigDescriptor(pubkeys=pubkeys, thresh=m, is_sorted=True) + return WSHDescriptor(subdescriptor=multi) + elif script_type == 'p2wsh-p2sh': + multi = MultisigDescriptor(pubkeys=pubkeys, thresh=m, is_sorted=True) + wsh = WSHDescriptor(subdescriptor=multi) + return SHDescriptor(subdescriptor=wsh) + else: + raise NotImplementedError(f"unexpected {script_type=}") + + def parse_descriptor(desc: str) -> 'Descriptor': """ Parse a descriptor string into a :class:`Descriptor`. diff --git a/electrum/gui/qml/components/CosignDialog.qml b/electrum/gui/qml/components/CosignDialog.qml new file mode 100644 index 000000000000..44fce8308c32 --- /dev/null +++ b/electrum/gui/qml/components/CosignDialog.qml @@ -0,0 +1,124 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import QtQuick.Controls.Material + +import org.electrum + +import "controls" + +ElDialog { + id: dialog + + title: qsTr('Sign transaction') + iconSource: Qt.resolvedUrl('../../icons/key.png') + + property var summary + property string psbt + property string password + + anchors.centerIn: parent + width: parent.width * 4/5 + padding: 0 + + function signAndBroadcast() { + // the password was needed to read the transaction, so we have it already + Cosigner.signAndBroadcast(psbt, password) + dialog.close() + } + + ColumnLayout { + width: parent.width + spacing: 0 + + ColumnLayout { + Layout.margins: constants.paddingLarge + Layout.fillWidth: true + + Label { + Layout.fillWidth: true + wrapMode: Text.Wrap + text: qsTr('This transaction was created by a wallet that this device signs for.') + } + + InfoTextArea { + Layout.fillWidth: true + Layout.topMargin: constants.paddingMedium + visible: summary['warning'] + iconStyle: InfoTextArea.IconStyle.Warn + text: summary['warning'] ? summary['warning'] : '' + } + + Label { + Layout.topMargin: constants.paddingMedium + text: qsTr('Outputs') + color: Material.accentColor + } + + Repeater { + model: summary['outputs'] + delegate: RowLayout { + Layout.fillWidth: true + Label { + Layout.fillWidth: true + text: modelData['is_change'] + ? modelData['address'] + ' (' + qsTr('change') + ')' + : modelData['address'] + font.family: FixedFont + font.pixelSize: constants.fontSizeSmall + wrapMode: Text.WrapAnywhere + } + Label { + text: modelData['value'] + font.family: FixedFont + } + } + } + + GridLayout { + Layout.topMargin: constants.paddingMedium + Layout.fillWidth: true + columns: 2 + + Label { + text: qsTr('Amount') + color: Material.accentColor + } + Label { + Layout.fillWidth: true + text: summary['amount'] + font.family: FixedFont + } + Label { + text: qsTr('Mining fee') + color: Material.accentColor + } + Label { + Layout.fillWidth: true + text: summary['fee'] + font.family: FixedFont + } + } + } + + DialogButtonContainer { + Layout.fillWidth: true + + FlatButton { + Layout.fillWidth: true + Layout.preferredWidth: 1 + text: qsTr('Cancel') + icon.source: Qt.resolvedUrl('../../icons/closebutton.png') + onClicked: dialog.close() + } + + FlatButton { + Layout.fillWidth: true + Layout.preferredWidth: 1 + text: qsTr('Sign and broadcast') + icon.source: Qt.resolvedUrl('../../icons/key.png') + onClicked: dialog.signAndBroadcast() + } + } + } +} diff --git a/electrum/gui/qml/components/CosignerHandler.qml b/electrum/gui/qml/components/CosignerHandler.qml new file mode 100644 index 000000000000..0b94baa8c6e5 --- /dev/null +++ b/electrum/gui/qml/components/CosignerHandler.qml @@ -0,0 +1,156 @@ +import QtQuick + +import org.electrum + +// Scanned QR codes that concern the wallets this device signs for without having them. +Item { + id: root + + // called by the scan dialogs of the app. Returns true if the data was for us. + function handleScannedData(data) { + if (Cosigner.isCosignerQr(data)) { + confirmSetup(data) + return true + } + if (Cosigner.canCosign(data)) { + if (Cosigner.canUseAppPassword()) { + signTransaction(data, '') + } else { + // the keys of the wallets we sign for are encrypted + var dialog = app.passwordDialog.createObject(app, { + infotext: qsTr('Enter the password of this device to read the transaction.') + }) + dialog.passwordEntered.connect(function(password) { + if (!Cosigner.verifyPassword(password)) { + dialog.clearPassword() + dialog.errorMessage = qsTr('Invalid Password') + return + } + dialog.close() + signTransaction(data, password) + }) + dialog.open() + } + return true + } + return false + } + + function scanQrCode() { + var scanner = app.scanDialog.createObject(app, { + hint: qsTr('Scan a QR code displayed by Electrum desktop') + }) + scanner.onFoundText.connect(function(data) { + scanner.close() + if (!handleScannedData(data.trim())) + showError(qsTr('This is not a cosigner QR code.')) + }) + scanner.open() + } + + function confirmSetup(data) { + var dialog = app.messageDialog.createObject(app, { + title: qsTr('Cosigner'), + text: qsTr('Set up this device as the cosigner of your Electrum desktop wallet?'), + yesno: true + }) + dialog.accepted.connect(function() { + setupCosigner(data) + }) + dialog.open() + } + + function setupCosigner(data) { + if (Cosigner.canUseAppPassword() || Cosigner.hasWallets()) { + // the password of the app encrypts the cosigner key. If it is not + // available, Cosigner explains what the user has to do first. + Cosigner.setupCosigner(data, '') + return + } + // There is no wallet on this device. If it already holds keys, they use the password + // of the app, and the user types that one: letting them choose another password here + // would leave this device with two of them. Otherwise, they choose it now. + var dialog = Cosigner.hasCosigners() + ? app.passwordDialog.createObject(app, { + infotext: qsTr('Enter the password of this device to store this key.') + }) + : app.passwordDialog.createObject(app, { + confirmPassword: true, + infotext: [ + qsTr('Choose a password for Electrum.'), + qsTr('It encrypts the cosigner key, and the wallets you create on this device.') + ].join(' ') + }) + dialog.passwordEntered.connect(function(password) { + // if this device already holds keys, the password must be the one they use + if (!Cosigner.verifyPassword(password)) { + dialog.clearPassword() + dialog.errorMessage = qsTr('Invalid Password') + return + } + dialog.close() + Cosigner.setupCosigner(data, password) + }) + dialog.open() + } + + function signTransaction(data, password) { + var summary = Cosigner.loadPsbt(data, password ? password : '') + if (summary['error']) { + showError(summary['error']) + return + } + var dialog = cosignDialog.createObject(app, { + summary: summary, + psbt: data, + password: password ? password : '' + }) + dialog.open() + } + + function showError(message) { + var dialog = app.messageDialog.createObject(app, { + title: qsTr('Cosigner'), + iconSource: Qt.resolvedUrl('../../icons/warning.png'), + text: message + }) + dialog.open() + } + + Connections { + target: Cosigner + function onAuthRequired(method, authMessage) { + app.handleAuthRequired(Cosigner, method, authMessage) + } + function onCosignerAdded() { + var dialog = app.messageDialog.createObject(app, { + title: qsTr('Cosigner'), + text: qsTr('This device is now a cosigner of your desktop wallet.') + }) + dialog.open() + } + function onSetupFailed(message) { + showError(message) + } + function onSignFailed(message) { + showError(message) + } + function onSignSuccess(txid) { + var dialog = app.messageDialog.createObject(app, { + title: qsTr('Cosigner'), + text: [ + qsTr('The transaction was signed and broadcast.'), + txid + ].join('\n\n') + }) + dialog.open() + } + } + + Component { + id: cosignDialog + CosignDialog { + onClosed: destroy() + } + } +} diff --git a/electrum/gui/qml/components/SendDialog.qml b/electrum/gui/qml/components/SendDialog.qml index 6244823f8860..5ae3d9f00386 100644 --- a/electrum/gui/qml/components/SendDialog.qml +++ b/electrum/gui/qml/components/SendDialog.qml @@ -32,7 +32,9 @@ ElDialog { function dispatch(data) { data = data.trim() - if (bitcoin.isRawTx(data)) { + if (app.cosignerHandler.handleScannedData(data)) { + // a key for this device to sign with, or a transaction to sign + } else if (bitcoin.isRawTx(data)) { txFound(data) } else if (Daemon.currentWallet.isValidChannelBackup(data)) { channelBackupFound(data) diff --git a/electrum/gui/qml/components/WalletMainView.qml b/electrum/gui/qml/components/WalletMainView.qml index 0154180072d4..d86567f8970a 100644 --- a/electrum/gui/qml/components/WalletMainView.qml +++ b/electrum/gui/qml/components/WalletMainView.qml @@ -49,7 +49,9 @@ Item { }) scanner.onFoundText.connect(function(data) { data = data.trim() - if (bitcoin.isRawTx(data)) { + if (app.cosignerHandler.handleScannedData(data)) { + // a key for this device to sign with, or a transaction to sign + } else if (bitcoin.isRawTx(data)) { app.stack.push(Qt.resolvedUrl('TxDetails.qml'), { rawtx: data }) } else if (Daemon.currentWallet.isValidChannelBackup(data)) { var dialog = app.messageDialog.createObject(app, { diff --git a/electrum/gui/qml/components/main.qml b/electrum/gui/qml/components/main.qml index 2ea3eb41ad90..6e62acaf86c9 100644 --- a/electrum/gui/qml/components/main.qml +++ b/electrum/gui/qml/components/main.qml @@ -529,6 +529,11 @@ ApplicationWindow width: parent.width } + property alias cosignerHandler: _cosignerHandler + CosignerHandler { + id: _cosignerHandler + } + Component.onCompleted: { coverTimer.start() @@ -645,7 +650,7 @@ ApplicationWindow let qtobject = app._pendingBiometricAuth.qtobject let method = app._pendingBiometricAuth.method - if (Daemon.currentWallet.verifyPassword(password)) { + if (app.verifyPassword(password)) { qtobject.authProceed() } else { console.warn("Biometric password invalid falling back to manual input") @@ -844,6 +849,14 @@ ApplicationWindow } } + // the password of this device. The wallets use it, and so do the keys of the wallets + // this device cosigns for, which can be here when no wallet is open. + function verifyPassword(password) { + return Daemon.currentWallet + ? Daemon.currentWallet.verifyPassword(password) + : Cosigner.verifyPassword(password) + } + function handleAuthRequired(qtobject, method, authMessage) { console.log('auth using method ' + method) @@ -857,8 +870,8 @@ ApplicationWindow } } - if (Daemon.currentWallet.verifyPassword('')) { - // wallet has no password + if (app.verifyPassword('')) { + // nothing here is protected by a password qtobject.authProceed() return } @@ -885,7 +898,7 @@ ApplicationWindow if (method === 'wallet' || method === 'wallet_password_only') { var dialog = app.passwordDialog.createObject(app, authMessage ? {'title': authMessage} : {}) dialog.passwordEntered.connect(function(password) { - if (Daemon.currentWallet.verifyPassword(password)) { + if (app.verifyPassword(password)) { dialog.close() qtobject.authProceed() } else { diff --git a/electrum/gui/qml/components/wizard/WCWalletName.qml b/electrum/gui/qml/components/wizard/WCWalletName.qml index 1020b03cd4e5..2b37a383ff8d 100644 --- a/electrum/gui/qml/components/wizard/WCWalletName.qml +++ b/electrum/gui/qml/components/wizard/WCWalletName.qml @@ -4,6 +4,8 @@ import QtQuick.Controls import org.electrum 1.0 +import "../controls" + WizardComponent { valid: Daemon.isValidWalletName(wallet_name.text) @@ -25,6 +27,16 @@ WizardComponent { text: Daemon.suggestWalletName() inputMethodHints: Qt.ImhNoPredictiveText } + + // this device can be set up as cosigner of another wallet, without having one + FlatButton { + Layout.fillWidth: true + Layout.topMargin: constants.paddingLarge + text: qsTr('Scan QR code') + icon.source: Qt.resolvedUrl('../../../icons/qrcode.png') + visible: !Cosigner.hasWallets() + onClicked: app.cosignerHandler.scanQrCode() + } } Component.onCompleted: { diff --git a/electrum/gui/qml/qeapp.py b/electrum/gui/qml/qeapp.py index 35b51f7f82d7..fbaf3c20c34c 100644 --- a/electrum/gui/qml/qeapp.py +++ b/electrum/gui/qml/qeapp.py @@ -26,6 +26,7 @@ from electrum.lnurl import SUPPORTED_LNURL_SCHEMES from .qeconfig import QEConfig +from .qecosigner import QECosigner from .qedaemon import QEDaemon from .qenetwork import QENetwork from .qewallet import QEWallet @@ -540,6 +541,7 @@ def __init__(self, args, *, config: 'SimpleConfig', daemon: 'Daemon', plugins: ' self.config = QEConfig(config) self.network = QENetwork(daemon.network) self.daemon = QEDaemon(daemon, self.plugins) + self.cosigner = QECosigner(config, parent=self) self.appController = QEAppController(self, self.plugins) self.maxAmount = QEAmount(is_max=True) self.biometrics = QEBiometrics(config=config, parent=self) @@ -547,6 +549,7 @@ def __init__(self, args, *, config: 'SimpleConfig', daemon: 'Daemon', plugins: ' self.context.setContextProperty('Config', self.config) self.context.setContextProperty('Network', self.network) self.context.setContextProperty('Daemon', self.daemon) + self.context.setContextProperty('Cosigner', self.cosigner) self.context.setContextProperty('FixedFont', self.fixedFont) self.context.setContextProperty('MAX', self.maxAmount) self.context.setContextProperty('QRIP', self.qr_ip_h) diff --git a/electrum/gui/qml/qecosigner.py b/electrum/gui/qml/qecosigner.py new file mode 100644 index 000000000000..5f3a92117f32 --- /dev/null +++ b/electrum/gui/qml/qecosigner.py @@ -0,0 +1,200 @@ +import threading +from typing import Optional, TYPE_CHECKING + +from PyQt6.QtCore import pyqtSignal, pyqtSlot, QModelIndex, QObject + +from electrum.cosigner import (Cosigner, add_cosigner, check_cosigners_password, find_cosigner_for_tx, + find_cosigner_id_for_tx, get_cosigner_ids) +from electrum.i18n import _ +from electrum.logging import get_logger +from electrum.network import Network, TxBroadcastError, BestEffortRequestFailed +from electrum.transaction import PartialTransaction, tx_from_any +from electrum.util import InvalidPassword + +from .auth import AuthMixin, auth_protect +from .qedaemon import QEDaemon + +if TYPE_CHECKING: + from electrum.simple_config import SimpleConfig + + +class QECosigner(AuthMixin, QObject): + """Signs for the wallets of the 'keystores' file, which this device does not have.""" + + _logger = get_logger(__name__) + + cosignerAdded = pyqtSignal() + setupFailed = pyqtSignal([str], arguments=['message']) + signFailed = pyqtSignal([str], arguments=['message']) + signSuccess = pyqtSignal([str], arguments=['txid']) + + def __init__(self, config: 'SimpleConfig', parent=None): + super().__init__(parent) + self._config = config + + @pyqtSlot(str, result=bool) + def isCosignerQr(self, data: str) -> bool: + """Whether that QR code holds a key for this device to sign with.""" + try: + Cosigner.from_qr_data(data) + except ValueError: + return False + return True + + @pyqtSlot(str, result=bool) + def canCosign(self, data: str) -> bool: + """Whether that QR code holds a transaction this device signs for. The keys are + indexed by the fingerprint they use in transactions, so this needs no password.""" + tx = self._parse_tx(data) + return tx is not None and find_cosigner_id_for_tx(self._config, tx) is not None + + @pyqtSlot(result=bool) + def canUseAppPassword(self) -> bool: + """Whether the password of this device is known, and can encrypt the key.""" + qedaemon = QEDaemon.instance + return bool(qedaemon.singlePasswordEnabled and qedaemon.singlePassword) + + @pyqtSlot(result=bool) + def hasWallets(self) -> bool: + return bool(QEDaemon.instance.availableWallets.rowCount(QModelIndex())) + + @pyqtSlot(result=bool) + def hasCosigners(self) -> bool: + """Whether this device already holds keys, which use the password of the app.""" + return bool(get_cosigner_ids(self._config)) + + @pyqtSlot(str, result=bool) + def verifyPassword(self, password: str) -> bool: + """Whether that password is the one of this device. The gui authenticates against + us when no wallet is open, as the keys of a cosigner can be here without any wallet. + """ + if single_password := QEDaemon.instance.singlePassword: + return password == single_password + try: + check_cosigners_password(self._config, password) + except InvalidPassword: + return False + return True + + @pyqtSlot(str, str) + def setupCosigner(self, data: str, password: str): + """Stores the key displayed by the QR code of the other device.""" + qedaemon = QEDaemon.instance + try: + cosigner = Cosigner.from_qr_data(data) + except ValueError as e: + self._logger.info(f'invalid cosigner QR code: {e}') + self.setupFailed.emit(_('This is not a cosigner QR code.')) + return + if self.canUseAppPassword(): + password = qedaemon.singlePassword + elif self.hasWallets(): + self.setupFailed.emit(' '.join([ + _('Electrum needs the password of this device in order to encrypt the cosigner key.'), + _('Please open one of your wallets first. If your wallets use different passwords, ' + 'change them so that they all use the same password.'), + ])) + return + elif not password: + self.setupFailed.emit(_('A password is required.')) + return + else: + # there is no wallet on this device. If it already holds keys, they use the + # password of the app, and the user has to type that one: a second password + # would leave the daemon unable to re-encrypt them all when it changes. + try: + check_cosigners_password(self._config, password) + except InvalidPassword: + self.setupFailed.emit(_('Invalid password')) + return + self._add_cosigner(cosigner, password) + + @auth_protect(method='wallet', message=_('Set up this device as cosigner?')) + def _add_cosigner(self, cosigner: Cosigner, password: str) -> None: + if not self.hasWallets(): + # there is no wallet on this device yet: this password becomes the password of the app + QEDaemon.instance.setSinglePassword(password) + add_cosigner(self._config, cosigner, password) + self.cosignerAdded.emit() + + @pyqtSlot(str, str, result='QVariantMap') + def loadPsbt(self, data: str, password: str) -> dict: + """Describes the transaction to be signed, for the confirmation dialog.""" + config = self._config + tx = self._parse_tx(data) + if tx is None: + return {'error': _('This is not a transaction QR code.')} + try: + cosigner = find_cosigner_for_tx(config, tx, password or QEDaemon.instance.singlePassword) + except InvalidPassword: + return {'error': _('Invalid password')} + if cosigner is None: + return {'error': _('This transaction belongs to no wallet that this device signs for.')} + outputs = [] + amount = 0 + warning = '' + for txout in tx.outputs(): + is_change = cosigner.is_wallet_output(txout) + if not is_change: + amount += txout.value + if cosigner.claims_our_key(txout): + warning = _('An output of this transaction falsely claims to belong to your wallet.') + outputs.append({ + 'address': txout.get_ui_address_str(), + 'value': config.format_amount_and_units(txout.value), + 'is_change': is_change, + }) + fee = tx.get_fee() + return { + 'outputs': outputs, + 'amount': config.format_amount_and_units(amount), + 'fee': config.format_amount_and_units(fee) if fee is not None else _('unknown'), + 'warning': warning, + } + + @pyqtSlot(str, str) + def signAndBroadcast(self, data: str, password: str): + self._sign_and_broadcast(data, password) + + @auth_protect(method='wallet', message=_('Sign and broadcast this transaction?')) + def _sign_and_broadcast(self, data: str, password: str) -> None: + def sign_task(): + try: + tx = self._parse_tx(data) + cosigner = find_cosigner_for_tx( + self._config, tx, password or QEDaemon.instance.singlePassword) + cosigner.sign_transaction(tx) + except InvalidPassword: + self.signFailed.emit(_('Invalid password')) + return + except Exception as e: + self._logger.exception('could not sign transaction') + self.signFailed.emit(repr(e)) + return + if not tx.is_complete(): + self.signFailed.emit(_('Could not sign transaction')) + return + self._broadcast(tx) + + threading.Thread(target=sign_task, daemon=True).start() + + def _parse_tx(self, data: str) -> Optional[PartialTransaction]: + try: + tx = tx_from_any(data) + except Exception: + return None + return tx if isinstance(tx, PartialTransaction) else None + + def _broadcast(self, tx: PartialTransaction) -> None: + network = Network.get_instance() + if not network: + self.signFailed.emit(_('You are offline.')) + return + try: + Network.run_from_another_thread(network.broadcast_transaction(tx)) + except TxBroadcastError as e: + self.signFailed.emit(e.get_message_for_gui()) + except BestEffortRequestFailed as e: + self.signFailed.emit(repr(e)) + else: + self.signSuccess.emit(tx.txid()) diff --git a/electrum/gui/qml/qedaemon.py b/electrum/gui/qml/qedaemon.py index b0779c100dc4..7177ed19a339 100644 --- a/electrum/gui/qml/qedaemon.py +++ b/electrum/gui/qml/qedaemon.py @@ -442,6 +442,15 @@ def singlePassword(self, password: str): self._password = password self.singlePasswordChanged.emit() + def setSinglePassword(self, password: str) -> None: + """Set the password of this device. Only for when there is no wallet yet, + so that wallets created later use that password too.""" + assert password + self.daemon.config.WALLET_SHOULD_USE_SINGLE_PASSWORD = True + self._use_single_password = True + self._password = password + self.singlePasswordChanged.emit() + @pyqtSlot(result=str) def suggestWalletName(self): # FIXME why not use util.get_new_wallet_name ? @@ -470,7 +479,13 @@ def setPassword(self, password): def _update_password_for_directory_and_unlock_wallets(self, *, old_password, new_password): # note: this assumes all wallet files are in a single directory. # change wallet passwords: - ret = self.daemon.update_password_for_directory(old_password=old_password, new_password=new_password) + try: + ret = self.daemon.update_password_for_directory(old_password=old_password, new_password=new_password) + except InvalidPassword: + # the keys of a cosigner cannot be read with that password, and nothing was + # changed. This must not keep the user from opening their wallets. + self._logger.warning('cosigner keys do not use the password of this device') + ret = False # If some wallets just had their password changed, they got "locked" by wallet.update_password(). # If the password is not unified yet, other loaded wallets might still be unlocked. # restore the invariant that all loaded wallets in qml must be unlocked: diff --git a/electrum/plugins/trustedcoin/qml.py b/electrum/plugins/trustedcoin/qml.py index 28088a21b004..ffc5a5e3b0c5 100644 --- a/electrum/plugins/trustedcoin/qml.py +++ b/electrum/plugins/trustedcoin/qml.py @@ -1,14 +1,13 @@ -from functools import partial from typing import TYPE_CHECKING -from PyQt6.QtCore import pyqtSignal, pyqtProperty, pyqtSlot +from PyQt6.QtCore import pyqtProperty from electrum.plugin import hook from electrum.gui.common_qt.plugins import PluginQObject from electrum.gui.qml.qedaemon import QEDaemon -from .trustedcoin import TrustedCoinPlugin, MOBILE_DISCLAIMER, parse_cosigner_qr_data +from .trustedcoin import TrustedCoinPlugin, MOBILE_DISCLAIMER if TYPE_CHECKING: from electrum.gui.qml import ElectrumQmlApplication @@ -16,24 +15,11 @@ class TrustedcoinPluginQObject(PluginQObject): - cosignerWalletCreated = pyqtSignal() - - @pyqtProperty(str) - def loader(self): - return 'main.qml' @pyqtProperty(str, constant=True) def disclaimer(self): return '\n\n'.join(MOBILE_DISCLAIMER) - @pyqtSlot(str, result=bool) - def isCosignerQr(self, data: str) -> bool: - try: - parse_cosigner_qr_data(data) - except ValueError: - return False - return True - class Plugin(TrustedCoinPlugin): def __init__(self, *args): @@ -43,12 +29,10 @@ def __init__(self, *args): @hook def init_qml(self, app: 'ElectrumQmlApplication'): self.logger.debug(f'init_qml hook called, gui={str(type(app))}') - wizard = QEDaemon.instance.newWalletWizard # important: TrustedcoinPluginQObject needs to be parented, as keeping a ref # in the plugin is not enough to avoid gc self.so = TrustedcoinPluginQObject(self, app) - self.extend_wizard(wizard) - wizard.createSuccess.connect(partial(self.on_wallet_created, wizard)) + self.extend_wizard(QEDaemon.instance.newWalletWizard) def extend_wizard(self, wizard: 'QENewWalletWizard'): super().extend_wizard(wizard) @@ -56,12 +40,6 @@ def extend_wizard(self, wizard: 'QENewWalletWizard'): 'trustedcoin_start': { 'gui': '../../../../plugins/trustedcoin/qml/Disclaimer', }, - 'trustedcoin_choose_seed': { - 'gui': '../../../../plugins/trustedcoin/qml/ChooseSeed', - }, - 'trustedcoin_scan_cosigner_qr': { - 'gui': '../../../../plugins/trustedcoin/qml/ScanCosignerQR', - }, # on mobile, restoring from seed disables two-factor authentication 'trustedcoin_have_seed': { 'gui': 'WCHaveSeed', @@ -77,8 +55,3 @@ def extend_wizard(self, wizard: 'QENewWalletWizard'): }, } wizard.navmap_merge(views) - - def on_wallet_created(self, wizard: 'QENewWalletWizard'): - wizard_data = wizard.get_wizard_data() - if 'trustedcoin_cosigner_qr' in wizard_data: - self.so.cosignerWalletCreated.emit() diff --git a/electrum/plugins/trustedcoin/qml/ChooseSeed.qml b/electrum/plugins/trustedcoin/qml/ChooseSeed.qml deleted file mode 100644 index 6f53c8558c49..000000000000 --- a/electrum/plugins/trustedcoin/qml/ChooseSeed.qml +++ /dev/null @@ -1,40 +0,0 @@ -import QtQuick 2.6 -import QtQuick.Layouts 1.0 -import QtQuick.Controls 2.1 - -import "../../../gui/qml/components/wizard" -import "../../../gui/qml/components/controls" - -WizardComponent { - valid: keystoregroup.checkedButton !== null - - function apply() { - wizard_data['keystore_type'] = keystoregroup.checkedButton.keystoretype - } - - ButtonGroup { - id: keystoregroup - } - - ColumnLayout { - width: parent.width - Label { - text: qsTr('How do you want to set up your 2FA wallet?') - Layout.preferredWidth: parent.width - wrapMode: Text.Wrap - } - ElRadioButton { - Layout.fillWidth: true - ButtonGroup.group: keystoregroup - property string keystoretype: 'cosigner_qr' - checked: true - text: qsTr('Scan the cosigner QR code displayed by Electrum desktop') - } - ElRadioButton { - Layout.fillWidth: true - ButtonGroup.group: keystoregroup - property string keystoretype: 'haveseed' - text: qsTr('Restore from my 2FA seed, with two-factor authentication disabled') - } - } -} diff --git a/electrum/plugins/trustedcoin/qml/Disclaimer.qml b/electrum/plugins/trustedcoin/qml/Disclaimer.qml index 5d2f90187003..e7a8fa3b3f31 100644 --- a/electrum/plugins/trustedcoin/qml/Disclaimer.qml +++ b/electrum/plugins/trustedcoin/qml/Disclaimer.qml @@ -11,6 +11,10 @@ WizardComponent { property QtObject plugin + function apply() { + wizard_data['keystore_type'] = 'haveseed' + } + ColumnLayout { width: parent.width diff --git a/electrum/plugins/trustedcoin/qml/ScanCosignerQR.qml b/electrum/plugins/trustedcoin/qml/ScanCosignerQR.qml deleted file mode 100644 index 043635c3e907..000000000000 --- a/electrum/plugins/trustedcoin/qml/ScanCosignerQR.qml +++ /dev/null @@ -1,87 +0,0 @@ -import QtQuick 2.6 -import QtQuick.Layouts 1.0 -import QtQuick.Controls 2.1 - -import org.electrum 1.0 - -import "../../../gui/qml/components/wizard" -import "../../../gui/qml/components/controls" - -WizardComponent { - id: root - securePage: true - - valid: false - - property QtObject plugin - property string _qrdata - - function apply() { - wizard_data['trustedcoin_cosigner_qr'] = _qrdata - } - - ColumnLayout { - width: parent.width - - Label { - Layout.fillWidth: true - wrapMode: Text.Wrap - text: [ - qsTr('On Electrum desktop, restore your 2FA seed, and choose to keep two-factor authentication with Electrum on your phone as cosigner.'), - qsTr('Then, scan the QR code displayed by the desktop wizard.') - ].join(' ') - } - - Button { - Layout.alignment: Qt.AlignHCenter - Layout.topMargin: constants.paddingLarge - icon.source: '../../../gui/icons/qrcode.png' - text: qsTr('Scan QR code') - onClicked: { - var dialog = app.scanDialog.createObject(app, { - hint: qsTr('Scan the cosigner QR code displayed by Electrum desktop') - }) - dialog.onFoundText.connect(function(data) { - dialog.close() - if (plugin.isCosignerQr(data)) { - _qrdata = data - valid = true - } else { - _qrdata = '' - valid = false - errorBox.text = qsTr('This is not a 2FA cosigner QR code.') - } - }) - dialog.open() - } - } - - InfoTextArea { - id: errorBox - Layout.fillWidth: true - Layout.topMargin: constants.paddingLarge - iconStyle: InfoTextArea.IconStyle.Error - visible: !valid && text - } - - Label { - Layout.fillWidth: true - Layout.topMargin: constants.paddingLarge - visible: valid - wrapMode: Text.Wrap - text: qsTr('QR code scanned. Electrum will create the cosigner wallet on this device.') - } - - Image { - Layout.alignment: Qt.AlignHCenter - source: '../../../gui/icons/confirmed.png' - visible: valid - Layout.preferredWidth: constants.iconSizeXLarge - Layout.preferredHeight: constants.iconSizeXLarge - } - } - - Component.onCompleted: { - plugin = AppController.plugin('trustedcoin') - } -} diff --git a/electrum/plugins/trustedcoin/qml/main.qml b/electrum/plugins/trustedcoin/qml/main.qml deleted file mode 100644 index 7dff00d81476..000000000000 --- a/electrum/plugins/trustedcoin/qml/main.qml +++ /dev/null @@ -1,16 +0,0 @@ -import QtQuick - -import org.electrum - -Item { - Connections { - target: AppController ? AppController.plugin('trustedcoin') : null - function onCosignerWalletCreated() { - var dialog = app.messageDialog.createObject(app, { - title: qsTr('Two-factor authentication'), - text: qsTr('This device is now a cosigner of your desktop wallet.') - }) - dialog.open() - } - } -} diff --git a/electrum/plugins/trustedcoin/qt.py b/electrum/plugins/trustedcoin/qt.py index 084a100f60d3..bed3af37c6e9 100644 --- a/electrum/plugins/trustedcoin/qt.py +++ b/electrum/plugins/trustedcoin/qt.py @@ -77,7 +77,7 @@ def show_incomplete_tx(self, window: 'ElectrumWindow', tx: 'PartialTransaction') if not isinstance(window.wallet, self.wallet_class): return False help_text = ' '.join([ - _('Scan this QR code with the Electrum app on your phone, then sign and broadcast the transaction there.'), + _('Scan this QR code with the Electrum app on your phone, in order to sign and broadcast the transaction.'), _('If your phone is not set up as cosigner yet, click on the two-factor authentication icon in the status bar.'), ]) try: @@ -96,7 +96,7 @@ def settings_dialog(self, window: 'ElectrumWindow'): _('This wallet is protected by two-factor authentication.'), ' '.join([ _('The TrustedCoin service has been discontinued: transactions are now co-signed by the Electrum app on your phone.'), - _('After you sign a transaction, scan the QR code displayed by Electrum with your phone, then sign and broadcast it there.'), + _('After you sign a transaction, scan the QR code displayed by Electrum with your phone, in order to broadcast it.'), ]), _('Do you want to set up your phone as cosigner of this wallet? You will need your 2FA seed.'), ]) @@ -150,7 +150,6 @@ def extend_wizard(self, wizard: 'QENewWalletWizard'): 'trustedcoin_start': { 'gui': WCDisclaimer, 'params': params, - 'next': 'trustedcoin_have_seed', }, 'trustedcoin_have_seed': { 'gui': WCHaveSeed, @@ -234,8 +233,8 @@ def __init__(self, parent, wizard): WalletWizardComponent.__init__(self, parent, wizard, title=_('Mobile cosigner')) self.layout().addWidget(WWLabel(' '.join([ - _('On your phone, open Electrum and create a new wallet.'), - _("Choose 'Wallet with two-factor authentication', then 'Scan cosigner QR code', and scan this QR code:"), + _('On your phone, open Electrum and scan this QR code.'), + _("If there is no wallet on your phone yet, use the 'Scan QR code' button of its wizard."), ]))) self.qr = QRCodeWidget('') self.layout().addWidget(self.qr) diff --git a/electrum/plugins/trustedcoin/trustedcoin.py b/electrum/plugins/trustedcoin/trustedcoin.py index dbd244234684..59b0ef36db2a 100644 --- a/electrum/plugins/trustedcoin/trustedcoin.py +++ b/electrum/plugins/trustedcoin/trustedcoin.py @@ -28,8 +28,8 @@ import electrum_ecc as ecc -from electrum import constants, keystore, bip32 -from electrum.bip32 import BIP32Node, xpub_type, is_xprv, is_xpub +from electrum import constants, cosigner, keystore, bip32 +from electrum.bip32 import BIP32Node, xpub_type from electrum.crypto import sha256 from electrum.mnemonic import Mnemonic, calc_seed_type, is_any_2fa_seed_type from electrum.wallet import Multisig_Wallet, Deterministic_Wallet @@ -73,26 +73,11 @@ def get_signing_xpub(xtype): ] -# The desktop wizard displays the second master private key in a QR code, -# along with the two other master public keys, for the mobile app to scan. -COSIGNER_QR_PREFIX = '2fa_cosigner:' - - def make_cosigner_qr_data(xprv2: str, xpub1: str, xpub3: str) -> str: - return COSIGNER_QR_PREFIX + ':'.join([xprv2, xpub1, xpub3]) - - -def parse_cosigner_qr_data(data: str) -> Tuple[str, str, str]: - """Returns (xprv2, xpub1, xpub3). Raises ValueError.""" - if not data.startswith(COSIGNER_QR_PREFIX): - raise ValueError('not a 2fa cosigner QR code') - keys = data[len(COSIGNER_QR_PREFIX):].split(':') - if len(keys) != 3: - raise ValueError('unexpected number of keys in 2fa cosigner QR code') - xprv2, xpub1, xpub3 = keys - if not (is_xprv(xprv2) and is_xpub(xpub1) and is_xpub(xpub3)): - raise ValueError('invalid keys in 2fa cosigner QR code') - return xprv2, xpub1, xpub3 + """The QR code that the desktop wizard displays for the mobile app to scan, which + sets it up as cosigner: the second master private key, and the two other public ones. + """ + return cosigner.Cosigner(xprv=xprv2, xpubs=[xpub1, xpub3], m=2).to_qr_data() class Wallet_2fa(Multisig_Wallet): @@ -206,11 +191,7 @@ def xkeys_from_seed(cls, seed, passphrase): def extend_wizard(self, wizard: 'NewWalletWizard'): views = { 'trustedcoin_start': { - 'next': 'trustedcoin_choose_seed', - }, - 'trustedcoin_choose_seed': { - 'next': lambda d: 'trustedcoin_have_seed' if d['keystore_type'] == 'haveseed' - else 'trustedcoin_scan_cosigner_qr', + 'next': 'trustedcoin_have_seed', }, 'trustedcoin_have_seed': { 'next': lambda d: 'trustedcoin_have_ext' if wizard.wants_ext(d) else 'trustedcoin_keep_disable', @@ -224,18 +205,12 @@ def extend_wizard(self, wizard: 'NewWalletWizard'): 'accept': lambda d: self.recovery_disable(d) if d['trustedcoin_keepordisable'] == 'disable' else None, 'last': lambda d: wizard.is_single_password() and d['trustedcoin_keepordisable'] == 'disable' }, - # desktop: show xprv2 to the mobile cosigner, keep xprv1 + # show xprv2 to the mobile cosigner, keep xprv1 'trustedcoin_show_cosigner_qr': { 'accept': self.on_accept_cosigner_qr, 'next': 'wallet_password', 'last': lambda d: wizard.is_single_password() }, - # mobile: scan xprv2 from the desktop wizard - 'trustedcoin_scan_cosigner_qr': { - 'accept': self.on_scan_cosigner_qr, - 'next': 'wallet_password', - 'last': lambda d: wizard.is_single_password() - }, } wizard.navmap_merge(views) @@ -249,11 +224,6 @@ def on_accept_cosigner_qr(self, wizard_data): xprv1, xpub1, xprv2, xpub2, xpub3 = self.create_keys(wizard_data) wizard_data.update({'x1': xprv1, 'x2': xpub2, 'x3': xpub3}) - def on_scan_cosigner_qr(self, wizard_data): - self.logger.debug('cosigner QR code scanned, creating keystores') - xprv2, xpub1, xpub3 = parse_cosigner_qr_data(wizard_data['trustedcoin_cosigner_qr']) - wizard_data.update({'x1': xpub1, 'x2': xprv2, 'x3': xpub3}) - def recovery_disable(self, wizard_data): self.logger.debug('2fa disabled, creating keystores') xprv1, xpub1, xprv2, xpub2, xpub3 = self.create_keys(wizard_data) diff --git a/electrum/wallet.py b/electrum/wallet.py index ff1459b0fda6..36653dbe9bd9 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -411,6 +411,7 @@ class Abstract_Wallet(ABC, Logger, EventListener): txin_type: str wallet_type: str + m = None # type: Optional[int] # number of signatures a multisig wallet requires lnworker: Optional['LNWallet'] network: Optional['Network'] @@ -2753,27 +2754,7 @@ def get_script_descriptor_for_address(self, address: str) -> Optional[Descriptor pubkeys = [ks.get_pubkey_provider(addr_index) for ks in self.get_keystores()] if not pubkeys: return None - if script_type == 'p2pk': - return descriptor.PKDescriptor(pubkey=pubkeys[0]) - elif script_type == 'p2pkh': - return descriptor.PKHDescriptor(pubkey=pubkeys[0]) - elif script_type == 'p2wpkh': - return descriptor.WPKHDescriptor(pubkey=pubkeys[0]) - elif script_type == 'p2wpkh-p2sh': - wpkh = descriptor.WPKHDescriptor(pubkey=pubkeys[0]) - return descriptor.SHDescriptor(subdescriptor=wpkh) - elif script_type == 'p2sh': - multi = descriptor.MultisigDescriptor(pubkeys=pubkeys, thresh=self.m, is_sorted=True) - return descriptor.SHDescriptor(subdescriptor=multi) - elif script_type == 'p2wsh': - multi = descriptor.MultisigDescriptor(pubkeys=pubkeys, thresh=self.m, is_sorted=True) - return descriptor.WSHDescriptor(subdescriptor=multi) - elif script_type == 'p2wsh-p2sh': - multi = descriptor.MultisigDescriptor(pubkeys=pubkeys, thresh=self.m, is_sorted=True) - wsh = descriptor.WSHDescriptor(subdescriptor=multi) - return descriptor.SHDescriptor(subdescriptor=wsh) - else: - raise NotImplementedError(f"unexpected {script_type=}") + return descriptor.from_legacy_electrum_script_type(script_type, pubkeys=pubkeys, m=self.m) def can_sign(self, tx: Transaction) -> bool: if not isinstance(tx, PartialTransaction): diff --git a/tests/test_cosigner.py b/tests/test_cosigner.py new file mode 100644 index 000000000000..40b706658b96 --- /dev/null +++ b/tests/test_cosigner.py @@ -0,0 +1,130 @@ +from typing import Tuple + +from electrum import keystore +from electrum.address_synchronizer import TX_HEIGHT_UNCONFIRMED +from electrum.bip32 import BIP32Node +from electrum.bitcoin import address_to_script +from electrum.cosigner import Cosigner, add_cosigner, check_cosigners_password +from electrum.fee_policy import FixedFeePolicy +from electrum.simple_config import SimpleConfig +from electrum.transaction import PartialTransaction, PartialTxOutput, Transaction, tx_from_any +from electrum.util import InvalidPassword + +from . import ElectrumTestCase +from .test_wallet_vertical import WalletIntegrityHelper + + +def _keys(name: str, xtype: str) -> Tuple[str, str]: + """A master key pair of that type, the same one for a given name.""" + node = BIP32Node.from_rootseed(name.encode('utf8'), xtype=xtype) + return node.to_xprv(), node.to_xpub() + + +class CosignerTestCase(ElectrumTestCase): + """This device signs for wallets it does not have, see electrum/cosigner.py.""" + + def setUp(self): + super().setUp() + self.config = SimpleConfig({'electrum_path': self.electrum_path}) + + def _make_tx(self, wallet) -> PartialTransaction: + """A transaction of that wallet, signed with the keys it has, as the cosigner + gets it: through a QR code, so it carries nothing but what a PSBT carries. + """ + spk = address_to_script(wallet.get_receiving_addresses()[0]) + funding_tx = Transaction( + '0200000001' + 32 * '11' + '0000000000ffffffff01' + + (100_000).to_bytes(8, 'little').hex() + bytes([len(spk)]).hex() + spk.hex() + '00000000') + wallet.adb.receive_tx_callback(funding_tx, tx_height=TX_HEIGHT_UNCONFIRMED) + outputs = [PartialTxOutput.from_address_and_value('bc1qs2svwhfz47qv9qju2waa6prxzv5f522fc4p06t', 50_000)] + tx = wallet.make_unsigned_transaction(outputs=outputs, fee_policy=FixedFeePolicy(1000)) + wallet.sign_transaction(tx, password=None) # does nothing if the wallet is watching-only + qr_data, __ = tx.to_qr_data() + return tx_from_any(qr_data) + + async def test_cosigns_for_a_multisig_wallet(self): + # a 2-of-3 wallet on another device, which holds the first key only + xprv1, xpub1 = _keys('key 1', 'p2wsh') + xprv2, xpub2 = _keys('key 2', 'p2wsh') + xprv3, xpub3 = _keys('key 3', 'p2wsh') + wallet = WalletIntegrityHelper.create_multisig_wallet( + [keystore.from_xprv(xprv1), keystore.from_xpub(xpub2), keystore.from_xpub(xpub3)], + '2of3', config=self.config) + tx = self._make_tx(wallet) + self.assertFalse(tx.is_complete()) + + cosigner = Cosigner(xprv=xprv2, xpubs=[xpub1, xpub3], m=2) + # the change of the transaction is recomputed from the master public keys + change = [txout for txout in tx.outputs() if cosigner.is_wallet_output(txout)] + self.assertEqual(1, len(change)) + self.assertTrue(wallet.is_mine(change[0].address)) + + cosigner.sign_transaction(tx) + self.assertTrue(tx.is_complete()) + + async def test_signs_for_a_single_sig_wallet(self): + # a watching-only wallet on another device: this one holds its key + xprv, xpub = _keys('the only key', 'p2wpkh') + wallet = WalletIntegrityHelper.create_standard_wallet( + keystore.from_xpub(xpub), config=self.config) + tx = self._make_tx(wallet) + self.assertFalse(tx.is_complete()) + + cosigner = Cosigner(xprv=xprv) + change = [txout for txout in tx.outputs() if cosigner.is_wallet_output(txout)] + self.assertEqual(1, len(change)) + self.assertTrue(wallet.is_mine(change[0].address)) + + cosigner.sign_transaction(tx) + self.assertTrue(tx.is_complete()) + + async def test_script_type_follows_the_master_keys(self): + for xtype, n, script_type in [ + ('standard', 1, 'p2pkh'), + ('p2wpkh', 1, 'p2wpkh'), + ('p2wpkh-p2sh', 1, 'p2wpkh-p2sh'), + ('standard', 3, 'p2sh'), + ('p2wsh', 3, 'p2wsh'), + ('p2wsh-p2sh', 3, 'p2wsh-p2sh'), + ]: + with self.subTest(msg=f'{xtype} {n} of {n}'): + keys = [_keys(f'key {i} {xtype}', xtype) for i in range(n)] + cosigner = Cosigner(xprv=keys[0][0], xpubs=[xpub for __, xpub in keys[1:]], m=n) + self.assertEqual(script_type, cosigner.script_type) + + async def test_qr_code_of_a_cosigner(self): + xprv, xpub = _keys('ours', 'p2wsh') + __, other = _keys('theirs', 'p2wsh') + cosigner = Cosigner(xprv=xprv, xpubs=[other], m=2) + self.assertEqual(f'cosigner:2:{xprv}:{other}', cosigner.to_qr_data()) + + scanned = Cosigner.from_qr_data(cosigner.to_qr_data()) + self.assertEqual(cosigner.to_dict(), scanned.to_dict()) + # a cosigner is indexed by the fingerprint its key uses in transactions + self.assertEqual(keystore.from_xpub(xpub).get_root_fingerprint(), scanned.cosigner_id) + + async def test_keys_that_use_two_passwords_cannot_all_be_read(self): + # why the app must not let the user choose a new password once it holds keys: + # they are read with a single password, which the daemon re-encrypts them with + xprv_one, __ = _keys('one', 'p2wpkh') + xprv_two, __ = _keys('two', 'p2wpkh') + add_cosigner(self.config, Cosigner(xprv=xprv_one), 'password one') + add_cosigner(self.config, Cosigner(xprv=xprv_two), 'password two') + for password in ['password one', 'password two']: + with self.assertRaises(InvalidPassword): + check_cosigners_password(self.config, password) + + async def test_invalid_qr_codes(self): + xprv, xpub = _keys('ours', 'p2wsh') + __, other = _keys('theirs', 'p2wsh') + __, single = _keys('theirs', 'p2wpkh') + for data in [ + xprv, # not a cosigner QR code at all + f'cosigner:2:{xpub}:{other}', # a public key cannot sign + f'cosigner:2:{xprv}:{other}:{other}', # the same key twice + f'cosigner:3:{xprv}:{other}', # more signatures than keys + f'cosigner:0:{xprv}', # no signature at all + f'cosigner:1:{xprv}:{single}', # keys of different types + ]: + with self.assertRaises(ValueError): + Cosigner.from_qr_data(data) diff --git a/tests/test_daemon.py b/tests/test_daemon.py index c6b2b2eb78e6..360d1498e7a5 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -10,13 +10,20 @@ from electrum.wallet import Abstract_Wallet from electrum.lnworker import LNWallet, LNPeerManager from electrum.lnwatcher import LNWatcher -from electrum import util +from electrum import util, cosigner +from electrum.cosigner import Cosigner +from electrum.util import InvalidPassword from electrum.utils.memory_leak import count_objects_in_memory from electrum import constants from . import ElectrumTestCase, as_testnet, restore_wallet_from_text__for_unittest +# a key this device could sign with, and the fingerprint it uses in transactions +COSIGNER_XPRV = "xprv9s21ZrQH143K3feHz3D5uMWDq118ZefvHNXxqg1Lsc222kCPNxix2skuH7TTnecemDdogJvkSqHDa3qqo8vkdxPQJwocYmg13X4FPnY5Y51" +COSIGNER_ID = "f95ca6f8" + + class DaemonTestCase(ElectrumTestCase): config: 'SimpleConfig' @@ -196,6 +203,39 @@ async def test_can_unify_large_folder_yet_to_be_unified(self): self.assertTrue(is_unified) self._run_post_unif_sanity_checks(paths, password="123456") + # cosigner keys ---> + + async def test_cosigner_keys_follow_the_directory_password(self): + # the keys of the wallets this device signs for are encrypted with that password + path = self._restore_wallet_from_text("9dk", password="123456", encrypt_file=True) + cosigner.add_cosigner(self.config, Cosigner(xprv=COSIGNER_XPRV), "123456") + is_unified = self.daemon.update_password_for_directory(old_password="123456", new_password="asdasd") + self.assertTrue(is_unified) + self._run_post_unif_sanity_checks([path], password="asdasd") + self.assertEqual( + COSIGNER_XPRV, cosigner.get_cosigner(self.config, COSIGNER_ID, "asdasd").xprv) + with self.assertRaises(InvalidPassword): + cosigner.get_cosigner(self.config, COSIGNER_ID, "123456") + + async def test_cosigner_keys_that_use_another_password_leave_the_wallets_alone(self): + # e.g. the keystores file was restored from a backup; the user must still be able + # to open their wallets, so nothing may be half-updated + path = self._restore_wallet_from_text("9dk", password="123456", encrypt_file=True) + with open(path, "rb") as f: + raw_before = f.read() + cosigner.add_cosigner(self.config, Cosigner(xprv=COSIGNER_XPRV), "999999") + with self.assertRaises(InvalidPassword): + self.daemon.update_password_for_directory(old_password="123456", new_password="asdasd") + with open(path, "rb") as f: + self.assertEqual(raw_before, f.read()) + self.assertEqual( + COSIGNER_XPRV, cosigner.get_cosigner(self.config, COSIGNER_ID, "999999").xprv) + + async def test_no_cosigner_key_is_written_for_a_device_that_cosigns_for_nothing(self): + self._restore_wallet_from_text("9dk", password="123456", encrypt_file=True) + self.daemon.update_password_for_directory(old_password="123456", new_password="asdasd") + self.assertFalse(os.path.exists(os.path.join(self.electrum_path, "keystores"))) + # misc ---> async def test_wallet_objects_are_properly_garbage_collected_after_check_pw_for_dir(self): diff --git a/tests/test_wizard.py b/tests/test_wizard.py index 5ae6872f583f..218908daa930 100644 --- a/tests/test_wizard.py +++ b/tests/test_wizard.py @@ -12,8 +12,12 @@ from electrum.address_synchronizer import TX_HEIGHT_UNCONFIRMED from electrum.bitcoin import address_to_script from electrum.fee_policy import FixedFeePolicy -from electrum.transaction import Transaction, PartialTxOutput, tx_from_any +from electrum.transaction import (Transaction, PartialTransaction, PartialTxInput, PartialTxOutput, + TxOutpoint, tx_from_any) +from electrum import cosigner +from electrum.cosigner import Cosigner from electrum.plugins.trustedcoin import trustedcoin +from electrum.util import InvalidPassword from electrum import util from electrum import slip39 from electrum.bip32 import KeyOriginInfo @@ -804,8 +808,6 @@ def _wizard_for_2fa_haveseed( d = v.wizard_data self.assertEqual('trustedcoin_start', v.view) - v = w.resolve_next(v.view, d) - self.assertEqual('trustedcoin_choose_seed', v.view) d.update({'keystore_type': 'haveseed'}) v = w.resolve_next(v.view, d) self.assertEqual('trustedcoin_have_seed', v.view) @@ -859,56 +861,87 @@ async def test_2fa_mobile_cosigner(self): xprv1, xpub1, xprv2, xpub2, xpub3 = w.plugins.get_plugin('trustedcoin').create_keys(v.wizard_data) cosigner_qr = trustedcoin.make_cosigner_qr_data(xprv2, xpub1, xpub3) self.assertEqual( - '2fa_cosigner:' + 'cosigner:2:' 'ZprvAkSth4YNtt4491XR5QabACZmFzdDV7XPN2KiYEaJsbMBtKJDEsji7cNDMEEusUbvqYtze57R9UvKsBn2g5LLAjhQaVSNTMKagZfQKSe8KBA:' 'Zpub6ySF6a5GjFcMJW1bx83wzKoke47zD6ouqGZ9zfBBne6htiwJtqPpvxfcWn9HsJF8wsKVzzMCunJA1Ux7Cm9AAtKY658yzheEV7usDVXa9E7:' 'Zpub6vZyhw1ShkEwNuzEqzZx6oLjntiSVTtdNZwVuKFPkYTN8o1nq2UK4e6HnucfhgLm3UVJ1ZWrnfmN8swnT7bYJ5e7sGUqHsTghP8Wc7MJ5ji', cosigner_qr) desktop_wallet = self._set_password_and_check_address(v=v, w=w, recv_addr=recv_addr, password='desktop') - # mobile: scan the cosigner QR code - w = self._wizard_for(name='mobile', wallet_type='2fa') - v = w._current - d = v.wizard_data - self.assertEqual('trustedcoin_start', v.view) - v = w.resolve_next(v.view, d) - self.assertEqual('trustedcoin_choose_seed', v.view) - d.update({'keystore_type': 'cosigner_qr'}) - v = w.resolve_next(v.view, d) - self.assertEqual('trustedcoin_scan_cosigner_qr', v.view) - d.update({'trustedcoin_cosigner_qr': cosigner_qr}) - v = w.resolve_next(v.view, d) - mobile_wallet = self._set_password_and_check_address(v=v, w=w, recv_addr=recv_addr, password='mobile') - - self.assertTrue(isinstance(mobile_wallet, trustedcoin.Wallet_2fa)) - self.assertTrue(mobile_wallet.keystores['x1'].is_watching_only()) - self.assertFalse(mobile_wallet.keystores['x2'].is_watching_only()) - self.assertFalse(mobile_wallet.can_sign_without_cosigner()) + # mobile: the key is stored in the keystores file, no wallet is created + cosigner.add_cosigner(self.config, Cosigner.from_qr_data(cosigner_qr), 'mobile') + + # this device also cosigns for another 2fa wallet + other_seed = 'universe topic remind silver february ranch shine worth innocent cattle enhance wise' + o_xprv1, o_xpub1, o_xprv2, o_xpub2 = trustedcoin.TrustedCoinPlugin.xkeys_from_seed(other_seed, '') + other = Cosigner.from_qr_data(trustedcoin.make_cosigner_qr_data( + o_xprv2, o_xpub1, trustedcoin.get_xpub3(o_xpub1, o_xpub2))) + cosigner.add_cosigner(self.config, other, 'mobile') + self.assertEqual(2, len(cosigner.get_cosigner_ids(self.config))) + # setting up the same wallet again does not add a second entry + cosigner.add_cosigner(self.config, Cosigner.from_qr_data(cosigner_qr), 'mobile') + self.assertEqual(2, len(cosigner.get_cosigner_ids(self.config))) + # reading the keystores file does not tell which wallets this device cosigns for + with open(os.path.join(self.electrum_path, "keystores")) as f: + stored = f.read() + for key in [xpub1, xpub2, xpub3, xprv2, o_xpub1, o_xpub2]: + self.assertNotIn(key, stored) + # they are indexed by the fingerprint their key uses in transactions + self.assertEqual( + sorted([keystore.from_xpub(xpub2).get_root_fingerprint(), other.cosigner_id]), + sorted(cosigner.get_cosigner_ids(self.config))) - with self.subTest(msg="desktop signs, mobile cosigns"): + with self.subTest(msg="desktop signs, mobile cosigns from its keystores file"): spk = address_to_script(recv_addr) funding_tx = Transaction( '0200000001' + 32 * '11' + '0000000000ffffffff01' + (100_000).to_bytes(8, 'little').hex() + bytes([len(spk)]).hex() + spk.hex() + '00000000') - for wallet in (desktop_wallet, mobile_wallet): - wallet.adb.receive_tx_callback(funding_tx, tx_height=TX_HEIGHT_UNCONFIRMED) + desktop_wallet.adb.receive_tx_callback(funding_tx, tx_height=TX_HEIGHT_UNCONFIRMED) outputs = [PartialTxOutput.from_address_and_value('bc1qs2svwhfz47qv9qju2waa6prxzv5f522fc4p06t', 50_000)] tx = desktop_wallet.make_unsigned_transaction(outputs=outputs, fee_policy=FixedFeePolicy(1000)) desktop_wallet.sign_transaction(tx, password='desktop') self.assertFalse(tx.is_complete()) + qr_data, __ = tx.to_qr_data() - tx = tx_from_any(qr_data) # scanned by the mobile app - mobile_wallet.sign_transaction(tx, password='mobile') + # the mobile app finds the key of that wallet in its keystores file + tx = tx_from_any(qr_data) + mobile = cosigner.find_cosigner_for_tx(self.config, tx, 'mobile') + self.assertEqual(xpub2, mobile.keystore.get_master_public_key()) + mobile.sign_transaction(tx) self.assertTrue(tx.is_complete()) - with self.subTest(msg="invalid cosigner QR codes"): - for data in [ - xprv2, - trustedcoin.make_cosigner_qr_data(xpub2, xpub1, xpub3), - cosigner_qr + ':' + xpub3, - ]: - with self.assertRaises(ValueError): - trustedcoin.parse_cosigner_qr_data(data) + with self.subTest(msg="the change of a transaction is not taken on trust"): + evil_tx = tx_from_any(qr_data) + ours = [o for o in evil_tx.outputs() if mobile.is_wallet_output(o)] + others = [o for o in evil_tx.outputs() if not mobile.is_wallet_output(o)] + self.assertEqual(1, len(ours)) + self.assertEqual(1, len(others)) + # whoever creates the transaction can claim one of our keys for an output of its own + others[0].bip32_paths = dict(ours[0].bip32_paths) + self.assertTrue(mobile.claims_our_key(others[0])) + # but that output does not pay to the script of the 2fa wallet + self.assertFalse(mobile.is_wallet_output(others[0])) + self.assertTrue(mobile.is_wallet_output(ours[0])) + + with self.subTest(msg="wrong password"): + with self.assertRaises(InvalidPassword): + cosigner.find_cosigner_for_tx(self.config, tx, 'wrong') + + with self.subTest(msg="transaction of an unrelated wallet"): + unrelated_tx = PartialTransaction.from_io( + [PartialTxInput(prevout=TxOutpoint(txid=bytes(32), out_idx=0))], + [PartialTxOutput.from_address_and_value(recv_addr, 10_000)]) + self.assertIsNone(cosigner.find_cosigner_for_tx(self.config, unrelated_tx, 'mobile')) + + with self.subTest(msg="the cosigner keys follow the password of the device"): + cosigner.update_cosigners_password(self.config, 'mobile', 'new password') + with self.assertRaises(InvalidPassword): + cosigner.find_cosigner_for_tx(self.config, tx, 'mobile') + mobile = cosigner.find_cosigner_for_tx(self.config, tx, 'new password') + self.assertEqual(xpub2, mobile.keystore.get_master_public_key()) + # the other wallet this device cosigns for is re-encrypted too + self.assertEqual( + o_xprv2, cosigner.get_cosigner(self.config, other.cosigner_id, 'new password').xprv) async def test_2fa_wallet_without_x3(self): # created offline, and never completed online with the TrustedCoin server From fb3ef2f2686659b9c2b3f2310f97df67fde7e889 Mon Sep 17 00:00:00 2001 From: ThomasV Date: Thu, 17 Sep 2026 18:18:02 +0200 Subject: [PATCH 3/3] cosigner: verify what a transaction claims before signing - for non-segwit inputs, fetch previous transaction from the network, and refuse to sign without it. Whether an input is segwit is decided by the script we recompute from the master public keys. - refuse non-default sighash flags: with SIGHASH_NONE or ANYONECANPAY our signature would not commit to the outputs, which could then be changed. - refuse to sign if the fee is unknown. The previous transactions are fetched with ignore_network_issues=False, so that a transaction is never displayed with amounts we could not verify. - warn about change that is sent far ahead of the addresses the wallet uses. --- electrum/cosigner.py | 40 ++++++- .../gui/qml/components/CosignerHandler.qml | 106 ++++++++++++++++-- electrum/gui/qml/qecosigner.py | 69 ++++++++++-- tests/test_cosigner.py | 26 ++++- tests/test_wizard.py | 85 +++++++++++++- 5 files changed, 299 insertions(+), 27 deletions(-) diff --git a/electrum/cosigner.py b/electrum/cosigner.py index 678eee738912..e71727563328 100644 --- a/electrum/cosigner.py +++ b/electrum/cosigner.py @@ -25,7 +25,7 @@ from . import descriptor, keystore from .bip32 import is_xprv, is_xpub, xpub_type from .crypto import pw_encode_with_version_and_mac, pw_decode_with_version_and_mac -from .transaction import PartialTransaction, PartialTxInput, PartialTxOutput +from .transaction import PartialTransaction, PartialTxInput, PartialTxOutput, Sighash from .util import os_chmod if TYPE_CHECKING: @@ -38,6 +38,11 @@ # QR code, for this device to scan. COSIGNER_QR_PREFIX = 'cosigner:' +# A wallet derives only a few addresses beyond the ones it has used, so it may never find +# the change of a transaction that was sent far ahead of them. Where its addresses end we +# cannot know, but normal use does not reach an index this high. +HIGH_DERIVATION_INDEX = 1000 + class Cosigner: """A key this device signs with, and the wallet that key belongs to. @@ -122,9 +127,18 @@ def is_wallet_output(self, txout: PartialTxOutput) -> bool: """Whether that output pays back to the wallet.""" return self.get_wallet_script(txout) is not None - def sign_transaction(self, tx: PartialTransaction) -> None: - """Signs the transaction with our key. Raises ValueError if it cannot be verified.""" - # add the scripts of the wallet, which a signer would otherwise get from its wallet + def is_high_derivation_index(self, txout: PartialTxOutput) -> bool: + """Whether that output of the wallet is so far ahead of the addresses it uses that + it may never be found, see HIGH_DERIVATION_INDEX. Only asked about wallet outputs, + whose derivation was verified against the script they pay to. + """ + __, der_suffix = self.keystore.find_my_pubkey_in_txinout(txout, only_der_suffix=True) + return der_suffix is not None and der_suffix[-1] >= HIGH_DERIVATION_INDEX + + def add_wallet_info_to_tx(self, tx: PartialTransaction) -> None: + """Adds the scripts of the wallet, which a signer would otherwise get from its + wallet. Raises ValueError if the transaction cannot be verified. + """ for txin in tx.inputs(): if not self.claims_our_key(txin): continue @@ -132,6 +146,24 @@ def sign_transaction(self, tx: PartialTransaction) -> None: if desc is None: raise ValueError('input does not spend from this wallet') txin.script_descriptor = desc + if not desc.is_segwit() and txin.utxo is None: + # the signature of a non-segwit input does not commit to its amount. Without + # the previous transaction we cannot know it, and the fee could be anything. + # note: we ask the script we recomputed, as txin.is_segwit() believes the + # witness field of the transaction, which is not ours. + raise ValueError('missing previous transaction of a non-segwit input') + if txin.sighash is not None and txin.sighash != Sighash.ALL: + # with SIGHASH_NONE or ANYONECANPAY, our signature would not commit to the + # outputs of the transaction, which could then be changed after we signed + raise ValueError(f'non-default sighash type: {txin.sighash}') + if tx.get_fee() is None: + # the amount of an input is unknown, so we cannot tell what the transaction pays + raise ValueError('unknown fee') + + def sign_transaction(self, tx: PartialTransaction) -> None: + """Signs the transaction with our key. Raises ValueError if the transaction + cannot be verified.""" + self.add_wallet_info_to_tx(tx) self.keystore.sign_transaction(tx, None) diff --git a/electrum/gui/qml/components/CosignerHandler.qml b/electrum/gui/qml/components/CosignerHandler.qml index 0b94baa8c6e5..70a5ddc58784 100644 --- a/electrum/gui/qml/components/CosignerHandler.qml +++ b/electrum/gui/qml/components/CosignerHandler.qml @@ -1,11 +1,19 @@ import QtQuick +import QtQuick.Layouts +import QtQuick.Controls import org.electrum +import "controls" + // Scanned QR codes that concern the wallets this device signs for without having them. Item { id: root + // the transaction being read, the dialog that says so, and what the dialog that + // confirms it needs. Kept until that dialog has it. + property var _pending + // called by the scan dialogs of the app. Returns true if the data was for us. function handleScannedData(data) { if (Cosigner.isCosignerQr(data)) { @@ -95,17 +103,25 @@ Item { } function signTransaction(data, password) { - var summary = Cosigner.loadPsbt(data, password ? password : '') - if (summary['error']) { - showError(summary['error']) - return - } - var dialog = cosignDialog.createObject(app, { - summary: summary, + // reading the transaction may have to wait for the network, so the summary + // of it comes back with onPsbtLoaded + _pending = { psbt: data, - password: password ? password : '' - }) - dialog.open() + password: password ? password : '', + busy: readingDialog.createObject(app) + } + _pending.busy.open() + Cosigner.loadPsbt(_pending.psbt, _pending.password) + } + + // what signTransaction put aside, if its dialog is not open yet + function _takePending() { + if (!_pending) + return null + _pending.busy.stop() + var pending = _pending + _pending = null + return pending } function showError(message) { @@ -132,7 +148,19 @@ Item { function onSetupFailed(message) { showError(message) } + function onPsbtLoaded(summary) { + var pending = _takePending() + if (!pending) // the dialog that confirms it is already open + return + var dialog = cosignDialog.createObject(app, { + summary: summary, + psbt: pending.psbt, + password: pending.password + }) + dialog.open() + } function onSignFailed(message) { + _takePending() showError(message) } function onSignSuccess(txid) { @@ -153,4 +181,62 @@ Item { onClosed: destroy() } } + + // shown while the previous transactions of a transaction are fetched + Component { + id: readingDialog + ElDialog { + id: dialog + + title: qsTr('Cosigner') + iconSource: Qt.resolvedUrl('../../icons/key.png') + allowClose: false + resizeWithKeyboard: false + needsSystemBarPadding: false + x: Math.floor((parent.width - implicitWidth) / 2) + y: Math.floor((parent.height - implicitHeight) / 2) + + // only show up if the network keeps us waiting + function open() { + showTimer.start() + } + + function stop() { + showTimer.stop() + if (visible) { + close() + } else { + // a dialog that was never shown does not get its onClosed callbacks + Qt.callLater(function() { dialog.destroy() }) + } + } + + ColumnLayout { + width: parent.width + + BusyIndicator { + Layout.alignment: Qt.AlignHCenter + running: true + } + + Label { + Layout.alignment: Qt.AlignHCenter + text: qsTr('Reading the transaction...') + } + + Item { + Layout.preferredHeight: 20 + } + } + + Timer { + id: showTimer + interval: 250 + repeat: false + onTriggered: dialog.visible = true + } + + onClosed: destroy() + } + } } diff --git a/electrum/gui/qml/qecosigner.py b/electrum/gui/qml/qecosigner.py index 5f3a92117f32..c81f9f98de55 100644 --- a/electrum/gui/qml/qecosigner.py +++ b/electrum/gui/qml/qecosigner.py @@ -7,7 +7,7 @@ find_cosigner_id_for_tx, get_cosigner_ids) from electrum.i18n import _ from electrum.logging import get_logger -from electrum.network import Network, TxBroadcastError, BestEffortRequestFailed +from electrum.network import Network, NetworkException, TxBroadcastError, BestEffortRequestFailed from electrum.transaction import PartialTransaction, tx_from_any from electrum.util import InvalidPassword @@ -25,6 +25,7 @@ class QECosigner(AuthMixin, QObject): cosignerAdded = pyqtSignal() setupFailed = pyqtSignal([str], arguments=['message']) + psbtLoaded = pyqtSignal(['QVariantMap'], arguments=['summary']) signFailed = pyqtSignal([str], arguments=['message']) signSuccess = pyqtSignal([str], arguments=['txid']) @@ -117,9 +118,36 @@ def _add_cosigner(self, cosigner: Cosigner, password: str) -> None: add_cosigner(self._config, cosigner, password) self.cosignerAdded.emit() - @pyqtSlot(str, str, result='QVariantMap') - def loadPsbt(self, data: str, password: str) -> dict: - """Describes the transaction to be signed, for the confirmation dialog.""" + def _add_info_to_tx(self, tx: PartialTransaction, cosigner: Cosigner) -> None: + """Completes the transaction with what the QR code could not carry, and checks + what it claims. Raises if it cannot be verified.""" + if tx.is_missing_info_from_network(): + # QR codes do not contain the previous transactions + Network.run_from_another_thread(tx.add_info_from_network( + Network.get_instance(), ignore_network_issues=False, timeout=10)) + cosigner.add_wallet_info_to_tx(tx) + + @pyqtSlot(str, str) + def loadPsbt(self, data: str, password: str): + """Describes the transaction to be signed, for the confirmation dialog. The + previous transactions are fetched from the network, so this does not run on the + gui thread: the summary comes back with psbtLoaded, a failure with signFailed. + """ + def load_task(): + try: + summary = self._load_psbt(data, password) + except Exception as e: + # the gui waits for one of our signals before it lets the user go on + self._logger.exception('could not read transaction') + summary = {'error': repr(e)} + if error := summary.get('error'): + self.signFailed.emit(error) + else: + self.psbtLoaded.emit(summary) + + threading.Thread(target=load_task, daemon=True).start() + + def _load_psbt(self, data: str, password: str) -> dict: config = self._config tx = self._parse_tx(data) if tx is None: @@ -130,26 +158,40 @@ def loadPsbt(self, data: str, password: str) -> dict: return {'error': _('Invalid password')} if cosigner is None: return {'error': _('This transaction belongs to no wallet that this device signs for.')} + try: + self._add_info_to_tx(tx, cosigner) + except NetworkException as e: + self._logger.info(f'could not fetch previous transactions: {e}') + return {'error': _('Could not fetch the previous transactions from the network.')} + except Exception as e: + self._logger.info(f'could not verify transaction: {e}') + return {'error': _('This transaction could not be verified.')} outputs = [] amount = 0 - warning = '' + claims_our_key = False + high_index = False for txout in tx.outputs(): is_change = cosigner.is_wallet_output(txout) - if not is_change: + if is_change: + high_index = high_index or cosigner.is_high_derivation_index(txout) + else: amount += txout.value - if cosigner.claims_our_key(txout): - warning = _('An output of this transaction falsely claims to belong to your wallet.') + claims_our_key = claims_our_key or cosigner.claims_our_key(txout) outputs.append({ 'address': txout.get_ui_address_str(), 'value': config.format_amount_and_units(txout.value), 'is_change': is_change, }) - fee = tx.get_fee() + warnings = [] + if claims_our_key: + warnings.append(_('An output of this transaction falsely claims to belong to your wallet.')) + if high_index: + warnings.append(_('The change of this transaction is sent to an address that your wallet may never find.')) return { 'outputs': outputs, 'amount': config.format_amount_and_units(amount), - 'fee': config.format_amount_and_units(fee) if fee is not None else _('unknown'), - 'warning': warning, + 'fee': config.format_amount_and_units(tx.get_fee()), + 'warning': '\n'.join(warnings), } @pyqtSlot(str, str) @@ -163,10 +205,15 @@ def sign_task(): tx = self._parse_tx(data) cosigner = find_cosigner_for_tx( self._config, tx, password or QEDaemon.instance.singlePassword) + self._add_info_to_tx(tx, cosigner) cosigner.sign_transaction(tx) except InvalidPassword: self.signFailed.emit(_('Invalid password')) return + except NetworkException as e: + self._logger.info(f'could not fetch previous transactions: {e}') + self.signFailed.emit(_('Could not fetch the previous transactions from the network.')) + return except Exception as e: self._logger.exception('could not sign transaction') self.signFailed.emit(repr(e)) diff --git a/tests/test_cosigner.py b/tests/test_cosigner.py index 40b706658b96..38471277200e 100644 --- a/tests/test_cosigner.py +++ b/tests/test_cosigner.py @@ -4,7 +4,8 @@ from electrum.address_synchronizer import TX_HEIGHT_UNCONFIRMED from electrum.bip32 import BIP32Node from electrum.bitcoin import address_to_script -from electrum.cosigner import Cosigner, add_cosigner, check_cosigners_password +from electrum.cosigner import (Cosigner, HIGH_DERIVATION_INDEX, add_cosigner, + check_cosigners_password) from electrum.fee_policy import FixedFeePolicy from electrum.simple_config import SimpleConfig from electrum.transaction import PartialTransaction, PartialTxOutput, Transaction, tx_from_any @@ -78,6 +79,29 @@ async def test_signs_for_a_single_sig_wallet(self): cosigner.sign_transaction(tx) self.assertTrue(tx.is_complete()) + async def test_change_sent_far_ahead_of_the_wallet_is_flagged(self): + # a wallet derives only a few addresses beyond the ones it has used, so change + # sent far ahead of them may never be found: the app warns about that + xprv, xpub = _keys('the only key', 'p2wpkh') + wallet = WalletIntegrityHelper.create_standard_wallet( + keystore.from_xpub(xpub), config=self.config) + tx = self._make_tx(wallet) + cosigner = Cosigner(xprv=xprv) + + change = [txout for txout in tx.outputs() if cosigner.is_wallet_output(txout)] + self.assertEqual(1, len(change)) + self.assertFalse(cosigner.is_high_derivation_index(change[0])) + + # the transaction can pay to the wallet and still be out of its reach + der_suffix = [1, HIGH_DERIVATION_INDEX] + far_ahead = PartialTxOutput( + scriptpubkey=cosigner.get_script_descriptor(der_suffix).expand().output_script, + value=change[0].value) + far_ahead.bip32_paths = { + cosigner.keystore.derive_pubkey(*der_suffix): (bytes.fromhex(cosigner.cosigner_id), der_suffix)} + self.assertTrue(cosigner.is_wallet_output(far_ahead)) + self.assertTrue(cosigner.is_high_derivation_index(far_ahead)) + async def test_script_type_follows_the_master_keys(self): for xtype, n, script_type in [ ('standard', 1, 'p2pkh'), diff --git a/tests/test_wizard.py b/tests/test_wizard.py index 218908daa930..b9d3390ca80f 100644 --- a/tests/test_wizard.py +++ b/tests/test_wizard.py @@ -13,7 +13,7 @@ from electrum.bitcoin import address_to_script from electrum.fee_policy import FixedFeePolicy from electrum.transaction import (Transaction, PartialTransaction, PartialTxInput, PartialTxOutput, - TxOutpoint, tx_from_any) + Sighash, TxOutpoint, TxOutput, tx_from_any) from electrum import cosigner from electrum.cosigner import Cosigner from electrum.plugins.trustedcoin import trustedcoin @@ -923,6 +923,29 @@ async def test_2fa_mobile_cosigner(self): self.assertFalse(mobile.is_wallet_output(others[0])) self.assertTrue(mobile.is_wallet_output(ours[0])) + with self.subTest(msg="a transaction whose fee is unknown is refused"): + # an input of unknown value, which the cosigner does not sign for + foreign_txin = PartialTxInput(prevout=TxOutpoint(txid=bytes(32), out_idx=0)) + evil_tx = PartialTransaction.from_io( + [tx_from_any(qr_data).inputs()[0], foreign_txin], + tx_from_any(qr_data).outputs()) + self.assertIsNone(evil_tx.get_fee()) + with self.assertRaises(ValueError): + mobile.sign_transaction(evil_tx) + + with self.subTest(msg="a non-default sighash type is refused"): + for sighash in [Sighash.NONE, Sighash.SINGLE, Sighash.ALL | Sighash.ANYONECANPAY]: + evil_tx = tx_from_any(qr_data) + evil_tx.inputs()[0].sighash = sighash + with self.assertRaises(ValueError): + mobile.sign_transaction(evil_tx) + # the default is accepted, whether it is spelled out or not + for sighash in [None, Sighash.ALL]: + signed = tx_from_any(qr_data) + signed.inputs()[0].sighash = sighash + mobile.sign_transaction(signed) + self.assertTrue(signed.is_complete()) + with self.subTest(msg="wrong password"): with self.assertRaises(InvalidPassword): cosigner.find_cosigner_for_tx(self.config, tx, 'wrong') @@ -943,6 +966,66 @@ async def test_2fa_mobile_cosigner(self): self.assertEqual( o_xprv2, cosigner.get_cosigner(self.config, other.cosigner_id, 'new password').xprv) + async def test_2fa_legacy_input_needs_prev_tx(self): + # the signature of a non-segwit input does not commit to its amount, so the + # cosigner needs the previous transaction in order to know the fee + seed = 'kiss live scene rude gate step hip quarter bunker oxygen motor glove' + xprv1, xpub1, xprv2, xpub2 = trustedcoin.TrustedCoinPlugin.xkeys_from_seed(seed, '') + xpub3 = trustedcoin.get_xpub3(xpub1, xpub2) + wallet = WalletIntegrityHelper.create_multisig_wallet( + [keystore.from_xprv(xprv1), keystore.from_xpub(xpub2), keystore.from_xpub(xpub3)], + '2of3', config=self.config) + self.assertEqual('p2sh', wallet.txin_type) + + recv_addr = wallet.get_receiving_addresses()[0] + spk = address_to_script(recv_addr) + funding_tx = Transaction( + '0200000001' + 32 * '11' + '0000000000ffffffff01' + + (100_000).to_bytes(8, 'little').hex() + bytes([len(spk)]).hex() + spk.hex() + '00000000') + wallet.adb.receive_tx_callback(funding_tx, tx_height=TX_HEIGHT_UNCONFIRMED) + outputs = [PartialTxOutput.from_address_and_value('bc1qs2svwhfz47qv9qju2waa6prxzv5f522fc4p06t', 50_000)] + tx = wallet.make_unsigned_transaction(outputs=outputs, fee_policy=FixedFeePolicy(1000)) + wallet.sign_transaction(tx, password=None) + cosigner.add_cosigner( + self.config, Cosigner.from_qr_data(trustedcoin.make_cosigner_qr_data(xprv2, xpub1, xpub3)), + 'mobile') + mobile = cosigner.find_cosigner_for_tx(self.config, tx, 'mobile') + + with self.subTest(msg="the previous transaction is fetched, then the cosigner signs"): + signed = tx_from_any(tx.to_qr_data()[0]) + # QR codes do not carry previous transactions + self.assertIsNone(signed.inputs()[0].utxo) + with self.assertRaises(ValueError): + mobile.sign_transaction(signed) + # the mobile app fetches it from the network before it signs + signed.inputs()[0].utxo = funding_tx + mobile.sign_transaction(signed) + self.assertTrue(signed.is_complete()) + + def unbacked_input() -> PartialTxInput: + # the amount is claimed by the transaction, but nothing backs it + txin = tx.inputs()[0] + evil_txin = PartialTxInput(prevout=txin.prevout) + evil_txin.witness_utxo = TxOutput(scriptpubkey=txin.scriptpubkey, value=1_000) + evil_txin.redeem_script = txin.redeem_script + evil_txin.bip32_paths = dict(txin.bip32_paths) + return evil_txin + + with self.subTest(msg="a claimed amount that nothing backs is refused"): + evil_tx = PartialTransaction.from_io([unbacked_input()], tx.outputs()) + self.assertIsNone(evil_tx.inputs()[0].utxo) + with self.assertRaises(ValueError): + mobile.sign_transaction(evil_tx) + + with self.subTest(msg="a witness field does not make a legacy input pass"): + evil_txin = unbacked_input() + evil_txin.witness = bytes.fromhex('0100') + # electrum believes that field, so it must not decide whether we sign + self.assertTrue(evil_txin.is_segwit()) + evil_tx = PartialTransaction.from_io([evil_txin], tx.outputs()) + with self.assertRaises(ValueError): + mobile.sign_transaction(evil_tx) + async def test_2fa_wallet_without_x3(self): # created offline, and never completed online with the TrustedCoin server recv_addr = "bc1qnf5qafvpx0afk47433j3tt30pqkxp5wa263m77wt0pvyqq67rmfs522m94"