diff --git a/electrum/bip32.py b/electrum/bip32.py index 785253ad4459..f968b0f7f782 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/cosigner.py b/electrum/cosigner.py new file mode 100644 index 000000000000..e71727563328 --- /dev/null +++ b/electrum/cosigner.py @@ -0,0 +1,264 @@ +# 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, Sighash +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:' + +# 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. + + 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 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 + desc = self.get_wallet_script(txin) + 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) + + +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 9823bc365850..11fbf7b97e76 100644 --- a/electrum/daemon.py +++ b/electrum/daemon.py @@ -49,7 +49,8 @@ ) from .wallet import Wallet, Abstract_Wallet from .storage import WalletStorage -from .wallet_db import WalletDB, WalletUnfinished +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 @@ -554,8 +555,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) @@ -765,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/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/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..70a5ddc58784 --- /dev/null +++ b/electrum/gui/qml/components/CosignerHandler.qml @@ -0,0 +1,242 @@ +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)) { + 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) { + // 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 : '', + 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) { + 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 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) { + 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() + } + } + + // 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/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/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/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..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, { @@ -479,11 +481,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 +745,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/components/main.qml b/electrum/gui/qml/components/main.qml index 4bbcef5b82e3..a974494f7bf5 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/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/qecosigner.py b/electrum/gui/qml/qecosigner.py new file mode 100644 index 000000000000..c81f9f98de55 --- /dev/null +++ b/electrum/gui/qml/qecosigner.py @@ -0,0 +1,247 @@ +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, NetworkException, 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']) + psbtLoaded = pyqtSignal(['QVariantMap'], arguments=['summary']) + 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() + + 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: + 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.')} + 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 + claims_our_key = False + high_index = False + for txout in tx.outputs(): + is_change = cosigner.is_wallet_output(txout) + if is_change: + high_index = high_index or cosigner.is_high_derivation_index(txout) + else: + amount += txout.value + 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, + }) + 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(tx.get_fee()), + 'warning': '\n'.join(warnings), + } + + @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) + 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)) + 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/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 6175cc1b1027..3ce4e09d381d 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 @@ -425,8 +422,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 @@ -499,8 +494,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'] @@ -513,36 +506,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 ea9b478a34ec..020ccb11c724 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 d2916445ce4c..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.\nFor 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..ffc5a5e3b0c5 100644 --- a/electrum/plugins/trustedcoin/qml.py +++ b/electrum/plugins/trustedcoin/qml.py @@ -1,150 +1,57 @@ -from functools import partial -from typing import TYPE_CHECKING, Callable +from typing import TYPE_CHECKING + +from PyQt6.QtCore import pyqtProperty -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 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): + + @pyqtProperty(str, constant=True) + def disclaimer(self): + return '\n\n'.join(MOBILE_DISCLAIMER) 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.extend_wizard(wizard) - - # wizard support functions + self.so = TrustedcoinPluginQObject(self, app) + self.extend_wizard(QEDaemon.instance.newWalletWizard) - def extend_wizard(self, wizard: 'NewWalletWizard'): + def extend_wizard(self, wizard: 'QENewWalletWizard'): super().extend_wizard(wizard) views = { 'trustedcoin_start': { 'gui': '../../../../plugins/trustedcoin/qml/Disclaimer', }, - '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', - }, + # 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) diff --git a/electrum/plugins/trustedcoin/qml/ChooseSeed.qml b/electrum/plugins/trustedcoin/qml/ChooseSeed.qml deleted file mode 100644 index 8665dee27c94..000000000000 --- a/electrum/plugins/trustedcoin/qml/ChooseSeed.qml +++ /dev/null @@ -1,38 +0,0 @@ -import QtQuick 2.6 -import QtQuick.Layouts 1.0 -import QtQuick.Controls 2.1 - -import "../../../gui/qml/components/wizard" - -WizardComponent { - valid: keystoregroup.checkedButton !== null - - onAccept: { - wizard_data['keystore_type'] = keystoregroup.checkedButton.keystoretype - } - - ButtonGroup { - id: keystoregroup - } - - ColumnLayout { - width: parent.width - Label { - text: qsTr('Do you want to create a new seed, or restore a wallet using an existing seed?') - Layout.preferredWidth: parent.width - wrapMode: Text.Wrap - } - RadioButton { - ButtonGroup.group: keystoregroup - property string keystoretype: 'createseed' - checked: true - text: qsTr('Create a new seed') - } - RadioButton { - ButtonGroup.group: keystoregroup - property string keystoretype: 'haveseed' - text: qsTr('I already have a seed') - } - } -} - 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/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/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/qt.py b/electrum/plugins/trustedcoin/qt.py index 4fd049995355..bed3af37c6e9 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,221 +68,128 @@ 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(True) - 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, 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: + 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, in order to broadcast it.'), + ]), + _('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(True) - label.setTextFormat(Qt.TextFormat.RichText) - - 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(True) - 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, }, '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): @@ -335,18 +202,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) @@ -354,116 +225,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 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('') - 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(): @@ -471,173 +263,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..59b0ef36db2a 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, cosigner, keystore, bip32 +from electrum.bip32 import BIP32Node, xpub_type 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,30 @@ 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) - -server = TrustedCoinCosignerClient(user_agent="Electrum/" + version.ELECTRUM_VERSION) +def make_cosigner_qr_data(xprv2: str, xpub1: str, xpub3: str) -> str: + """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): @@ -253,137 +86,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 +128,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 +150,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,38 +187,11 @@ 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 = { 'trustedcoin_start': { - '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': 'trustedcoin_have_seed', }, 'trustedcoin_have_seed': { 'next': lambda d: 'trustedcoin_have_ext' if wizard.wants_ext(d) else 'trustedcoin_keep_disable', @@ -616,66 +200,31 @@ 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' - }, - 'trustedcoin_show_confirm_otp': { - 'accept': self.on_accept_otp_secret, + # 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() 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}} - - # 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 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) - 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_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}) 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 9c8a888b0b12..1bfbff50e699 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'] @@ -2007,7 +2008,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, @@ -2573,9 +2573,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 @@ -2755,27 +2752,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): @@ -3411,10 +3388,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 e3f289b31d5e..6b0873f25c3b 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 @@ -2094,10 +2088,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 abf99f5a03be..8554f4b149a1 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): @@ -723,7 +723,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: @@ -764,9 +766,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': @@ -818,17 +817,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_cosigner.py b/tests/test_cosigner.py new file mode 100644 index 000000000000..38471277200e --- /dev/null +++ b/tests/test_cosigner.py @@ -0,0 +1,154 @@ +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, 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 +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_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'), + ('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_wallet_vertical.py b/tests/test_wallet_vertical.py index 257ecaafd654..fc19a4bf5752 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 76c4ae3903e6..79c2ba9447ff 100644 --- a/tests/test_wizard.py +++ b/tests/test_wizard.py @@ -7,7 +7,17 @@ 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, PartialTransaction, PartialTxInput, PartialTxOutput, + Sighash, TxOutpoint, TxOutput, 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 @@ -792,29 +802,46 @@ 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) + + d.update({'keystore_type': 'haveseed'}) v = w.resolve_next(v.view, d) - self.assertEqual('trustedcoin_choose_seed', v.view) - d.update({'keystore_type': 'createseed'}) - 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(): @@ -826,83 +853,201 @@ 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') - 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) + 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( + '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: 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 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') + 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() + # 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="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="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') + + 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_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" + 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_2fa_haveseed_redirected_from_standard(self): w = self._wizard_for(wallet_type='standard') v = w._current @@ -924,9 +1069,7 @@ async def test_2fa_haveseed_redirected_from_standard(self): self.assertEqual('2fa', v.wizard_data['wallet_type']) 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) + self.assertEqual('trustedcoin_show_cosigner_qr', v.view) v = w.resolve_next(v.view, d) wallet = self._set_password_and_check_address(v=v, w=w, recv_addr="bc1qnf5qafvpx0afk47433j3tt30pqkxp5wa263m77wt0pvyqq67rmfs522m94") self.assertEqual('2fa', wallet.wallet_type)