From cc5c0532e52fbe282e862e20c250cc88ed435cad Mon Sep 17 00:00:00 2001 From: "Peter D. Gray" Date: Fri, 14 Jun 2019 13:04:32 -0400 Subject: [PATCH] Working towards multisig --- electrum/plugins/coldcard/coldcard.py | 363 ++++++++++++-------------- electrum/plugins/coldcard/qt.py | 148 +++++++++-- 2 files changed, 295 insertions(+), 216 deletions(-) diff --git a/electrum/plugins/coldcard/coldcard.py b/electrum/plugins/coldcard/coldcard.py index f79324d3a..4de5b6470 100644 --- a/electrum/plugins/coldcard/coldcard.py +++ b/electrum/plugins/coldcard/coldcard.py @@ -8,18 +8,21 @@ import traceback from electrum.bip32 import BIP32Node, InvalidMasterKeyVersionBytes from electrum.i18n import _ -from electrum.plugin import Device +from electrum.plugin import Device, hook from electrum.keystore import Hardware_KeyStore, xpubkey_to_pubkey, Xpub -from electrum.transaction import Transaction -from electrum.wallet import Standard_Wallet +from electrum.transaction import Transaction, multisig_script, parse_redeemScript_multisig +from electrum.wallet import Standard_Wallet, Multisig_Wallet, Wallet from electrum.crypto import hash_160 from electrum.util import bfh, bh2u, versiontuple, UserFacingException from electrum.base_wizard import ScriptTypeNotSupported from electrum.logging import get_logger +from electrum.bitcoin import DecodeBase58Check from ..hw_wallet import HW_PluginBase -from ..hw_wallet.plugin import LibraryFoundButUnusable +from ..hw_wallet.plugin import LibraryFoundButUnusable, only_hook_if_libraries_available +from .basic_psbt import BasicPSBT +from .build_psbt import build_psbt, xfp2str, unpacked_xfp_path _logger = get_logger(__name__) @@ -30,10 +33,10 @@ try: from ckcc.protocol import CCProtoError, CCUserRefused, CCBusyError from ckcc.constants import (MAX_MSG_LEN, MAX_BLK_LEN, MSG_SIGNING_MAX_LENGTH, MAX_TXN_LEN, AF_CLASSIC, AF_P2SH, AF_P2WPKH, AF_P2WSH, AF_P2WPKH_P2SH, AF_P2WSH_P2SH) - from ckcc.constants import ( - PSBT_GLOBAL_UNSIGNED_TX, PSBT_IN_NON_WITNESS_UTXO, PSBT_IN_WITNESS_UTXO, - PSBT_IN_SIGHASH_TYPE, PSBT_IN_REDEEM_SCRIPT, PSBT_IN_WITNESS_SCRIPT, - PSBT_IN_BIP32_DERIVATION, PSBT_OUT_BIP32_DERIVATION, PSBT_OUT_REDEEM_SCRIPT) + #from ckcc.constants import ( + #PSBT_GLOBAL_UNSIGNED_TX, PSBT_GLOBAL_XPUB, PSBT_IN_NON_WITNESS_UTXO, PSBT_IN_WITNESS_UTXO, + #PSBT_IN_SIGHASH_TYPE, PSBT_IN_REDEEM_SCRIPT, PSBT_IN_WITNESS_SCRIPT, + #PSBT_IN_BIP32_DERIVATION, PSBT_OUT_BIP32_DERIVATION, PSBT_OUT_REDEEM_SCRIPT) from ckcc.client import ColdcardDevice, COINKITE_VID, CKCC_PID, CKCC_SIMULATOR_PATH @@ -60,32 +63,6 @@ except ImportError: CKCC_SIMULATED_PID = CKCC_PID ^ 0x55aa -def my_var_int(l): - # Bitcoin serialization of integers... directly into binary! - if l < 253: - return pack("B", l) - elif l < 0x10000: - return pack("I', xfp)).decode('ascii').upper() - class CKCCClient: # Challenge: I haven't found anywhere that defines a base class for this 'client', # nor an API (interface) to be met. Winging it. Gets called from lib/plugins.py mostly? @@ -114,13 +91,17 @@ class CKCCClient: return '' % (xfp2str(self.dev.master_fingerprint), self.label()) - def verify_connection(self, expected_xfp, expected_xpub): + def verify_connection(self, expected_xfp, expected_xpub=None): + print("verify") ex = (expected_xfp, expected_xpub) if self._expected_device == ex: # all is as expected return + if expected_xpub is None: + expected_xpub = self.dev.master_xpub + if ( (self._expected_device is not None) or (self.dev.master_fingerprint != expected_xfp) or (self.dev.master_xpub != expected_xpub)): @@ -138,7 +119,11 @@ class CKCCClient: self._expected_device = ex + if not getattr(self, 'ckcc_xpub', None): + self.ckcc_xpub = expected_xpub + _logger.info("Successfully verified against MiTM") + print("verify OK") def is_pairable(self): # can't do anything w/ devices that aren't setup (this code not normally reachable) @@ -216,9 +201,13 @@ class CKCCClient: raise RuntimeError("Communication trouble with Coldcard") def show_address(self, path, addr_fmt): - # prompt user w/ addres, also returns it immediately. + # prompt user w/ address, also returns it immediately. return self.dev.send_recv(CCProtocolPacker.show_address(path, addr_fmt), timeout=None) + def show_p2sh_address(self, *args, **kws): + # prompt user w/ p2sh address, also returns it immediately. + return self.dev.send_recv(CCProtocolPacker.show_p2sh_address(*args, **kws), timeout=None) + def get_version(self): # gives list of strings return self.dev.send_recv(CCProtocolPacker.version(), timeout=1000).split('\n') @@ -268,6 +257,9 @@ class Coldcard_KeyStore(Hardware_KeyStore): self.force_watching_only = False self.ux_busy = False + # for multisig I need to know what wallet this keystore is part of + self.my_wallet = None + # Seems like only the derivation path and resulting **derived** xpub is stored in # the wallet file... however, we need to know at least the fingerprint of the master # xpub to verify against MiTM, and also so we can put the right value into the subkey paths @@ -275,15 +267,16 @@ class Coldcard_KeyStore(Hardware_KeyStore): # - save the fingerprint of the master xpub, as "xfp" # - it's a LE32 int, but hex BE32 is more natural way to view it # - device reports these value during encryption setup process + # - full xpub value now optional lab = d['label'] if hasattr(lab, 'xfp'): # initial setup self.ckcc_xfp = lab.xfp - self.ckcc_xpub = lab.xpub + self.ckcc_xpub = getattr(lab, 'xpub', None) else: # wallet load: fatal if missing, we need them! self.ckcc_xfp = d['ckcc_xfp'] - self.ckcc_xpub = d['ckcc_xpub'] + self.ckcc_xpub = d.get('ckcc_xpub', None) def dump(self): # our additions to the stored data about keystore -- only during creation? @@ -300,6 +293,7 @@ class Coldcard_KeyStore(Hardware_KeyStore): def get_client(self): # called when user tries to do something like view address, sign somthing. # - not called during probing/setup + # - will fail if indicated device can't produce the xpub (at derivation) expected rv = self.plugin.get_client(self) if rv: rv.verify_connection(self.ckcc_xfp, self.ckcc_xpub) @@ -383,144 +377,8 @@ class Coldcard_KeyStore(Hardware_KeyStore): # give empty bytes for error cases; it seems to clear the old signature box return b'' - def build_psbt(self, tx: Transaction, wallet=None, xfp=None): - # Render a PSBT file, for upload to Coldcard. - # - if xfp is None: - # need fingerprint of MASTER xpub, not the derived key - xfp = self.ckcc_xfp - - inputs = tx.inputs() - - if 'prev_tx' not in inputs[0]: - # fetch info about inputs, if needed? - # - needed during export PSBT flow, not normal online signing - assert wallet, 'need wallet reference' - wallet.add_hw_info(tx) - - # wallet.add_hw_info installs this attr - assert tx.output_info is not None, 'need data about outputs' - - # Build map of pubkey needed as derivation from master, in PSBT binary format - # 1) binary version of the common subpath for all keys - # m/ => fingerprint LE32 - # a/b/c => ints - base_path = pack('= 0, "unknown pubkey?" + tx.add_signature_to_txin(inp_idx, pk_pos, sig) + count += 1 + + assert count, "unable to add any partial sigs" + + # reset / update objs + tx.raw = tx.serialize() + tx.raw_psbt = None + + return count @staticmethod def _encode_txin_type(txin_type): @@ -599,11 +489,30 @@ class Coldcard_KeyStore(Hardware_KeyStore): self.logger.exception('') self.handler.show_error(exc) + @wrap_busy + def show_p2sh_address(self, M, script, xfp_paths, txin_type): + client = self.get_client() + addr_fmt = self._encode_txin_type(txin_type) + try: + try: + self.handler.show_message(_("Showing address ...")) + dev_addr = client.show_p2sh_address(M, xfp_paths, script, addr_fmt=addr_fmt) + # we could double check address here + finally: + self.handler.finished() + except CCProtoError as exc: + self.logger.exception('Error showing address') + self.handler.show_error('{}\n\n{}'.format( + _('Error showing address') + ':', str(exc))) + except BaseException as exc: + self.logger.exception('') + self.handler.show_error(exc) + class ColdcardPlugin(HW_PluginBase): keystore_class = Coldcard_KeyStore - minimum_library = (0, 7, 2) + minimum_library = (0, 7, 7) client = None DEVICE_IDS = [ @@ -611,8 +520,7 @@ class ColdcardPlugin(HW_PluginBase): (COINKITE_VID, CKCC_SIMULATED_PID) ] - #SUPPORTED_XTYPES = ('standard', 'p2wpkh-p2sh', 'p2wpkh', 'p2wsh-p2sh', 'p2wsh') - SUPPORTED_XTYPES = ('standard', 'p2wpkh', 'p2wpkh-p2sh') + SUPPORTED_XTYPES = ('standard', 'p2wpkh-p2sh', 'p2wpkh', 'p2wsh-p2sh', 'p2wsh') def __init__(self, parent, config, name): HW_PluginBase.__init__(self, parent, config, name) @@ -688,31 +596,102 @@ class ColdcardPlugin(HW_PluginBase): return xpub def get_client(self, keystore, force_pair=True): - # All client interaction should not be in the main GUI thread + # Acquire a connection to the hardware device (via USB) devmgr = self.device_manager() handler = keystore.handler with devmgr.hid_lock: client = devmgr.client_for_keystore(self, handler, keystore, force_pair) - # returns the client for a given keystore. can use xpub - #if client: - # client.used() + if client is not None: client.ping_check() + return client + @staticmethod + def export_ms_wallet(wallet, fp, name): + # Build the text file Coldcard needs to understand the multisig wallet + # it is participating in. All involved Coldcards can share same file. + + print('# Exported from Electrum', file=fp) + print(f'Name: {name:.20s}', file=fp) + print(f'Policy: {wallet.m} of {wallet.n}', file=fp) + + xpubs = [] + derivs = set() + for xp, ks in zip(wallet.get_master_public_keys(), wallet.get_keystores()): + xfp = getattr(ks, 'ckcc_xfp', None) + if xfp is None: + xfp = xfp_from_xpub(ks.get_master_public_key()) + + dd = getattr(ks, 'derivation', 'm') + + xpubs.append( (xfp2str(xfp), xp, dd) ) + derivs.add(dd) + + # Derivation doesn't matter too much to the Coldcard, since it + # uses key path data from PSBT or USB request as needed. However, + # if there is a clear value, provide it. + if len(derivs) == 1: + print("Derivation: " + derivs.pop(), file=fp) + + print('', file=fp) + + assert len(xpubs) == wallet.n + for xfp, xp, dd in xpubs: + if derivs: + # show as a comment if unclear + print(f'# derivation: {dd}', file=fp) + + print(f'{xfp}: {xp}\n', file=fp) + def show_address(self, wallet, address, keystore=None): if keystore is None: keystore = wallet.get_keystore() if not self.show_address_helper(wallet, address, keystore): return + txin_type = wallet.get_txin_type(address) + # Standard_Wallet => not multisig, must be bip32 - if type(wallet) is not Standard_Wallet: + if type(wallet) is Standard_Wallet: + sequence = wallet.get_address_index(address) + keystore.show_address(sequence, txin_type) + elif type(wallet) is Multisig_Wallet: + # More involved for P2SH/P2WSH addresses: need M, and all public keys, and their + # derivation paths. Must construct script, and track fingerprints+paths for + # all those keys + + pubkeys = wallet.get_public_keys(address) + + xfps = [] + for xp, ks in zip(wallet.get_master_public_keys(), wallet.get_keystores()): + path = "%s/%d/%d" % (getattr(ks, 'derivation', 'm'), + *wallet.get_address_index(address)) + + # need master XFP for each co-signers; if it's a coldcard, easy. + xfp = getattr(ks, 'ckcc_xfp', None) + if xfp is None: + xfp = xfp_from_xpub(ks.get_master_public_key()) + + xfps.append(unpacked_xfp_path(xfp, path)) + + # put into BIP45 (sorted) order + pkx = list(sorted(zip(pubkeys, xfps))) + + script = bfh(multisig_script([pk for pk,xfp in pkx], wallet.m)) + + keystore.show_p2sh_address(wallet.m, script, [xfp for pk,xfp in pkx], txin_type) + + else: keystore.handler.show_error(_('This function is only available for standard wallets when using {}.').format(self.device)) return - sequence = wallet.get_address_index(address) - txin_type = wallet.get_txin_type(address) - keystore.show_address(sequence, txin_type) + # overloads HW_PluginBase.load_wallet, which we need + def XXX_load_wallet(self, wallet, window): + # Hook runs when a specific wallet is openned okay; also maybe a new one? + # - capture wallet containing keystore + for ks in wallet.get_keystores(): + if type(ks) == Coldcard_KeyStore: + ks.my_wallet = wallet # EOF diff --git a/electrum/plugins/coldcard/qt.py b/electrum/plugins/coldcard/qt.py index e8fbf2cc9..57578a442 100644 --- a/electrum/plugins/coldcard/qt.py +++ b/electrum/plugins/coldcard/qt.py @@ -3,16 +3,22 @@ from functools import partial from PyQt5.QtCore import Qt, pyqtSignal from PyQt5.QtWidgets import QPushButton, QLabel, QVBoxLayout, QWidget, QGridLayout +from PyQt5.QtWidgets import QFileDialog from electrum.i18n import _ from electrum.plugin import hook -from electrum.wallet import Standard_Wallet -from electrum.gui.qt.util import WindowModalDialog, CloseButton, get_parent_main_window +from electrum.wallet import Standard_Wallet, Multisig_Wallet +from electrum.gui.qt.util import WindowModalDialog, CloseButton, get_parent_main_window, Buttons from .coldcard import ColdcardPlugin, xfp2str from ..hw_wallet.qt import QtHandlerBase, QtPluginBase from ..hw_wallet.plugin import only_hook_if_libraries_available +from binascii import a2b_hex +from base64 import b64encode, b64decode + +from .basic_psbt import BasicPSBT +from .build_psbt import build_psbt class Plugin(ColdcardPlugin, QtPluginBase): icon_unpaired = "coldcard_unpaired.png" @@ -24,13 +30,54 @@ class Plugin(ColdcardPlugin, QtPluginBase): @only_hook_if_libraries_available @hook def receive_menu(self, menu, addrs, wallet): - if type(wallet) is not Standard_Wallet: - return - keystore = wallet.get_keystore() + # Context menu on each address in the Addresses Tab, right click... + + if type(wallet) is Standard_Wallet: + keystore = wallet.get_keystore() + else: + # find if any devices are connected and ready to go, use first of those. + for ks in wallet.get_keystores(): + if ks.has_usable_connection_with_device(): + keystore = ks + break + else: + # don't hook into menu + return + if type(keystore) == self.keystore_class and len(addrs) == 1: def show_address(): - keystore.thread.add(partial(self.show_address, wallet, addrs[0])) - menu.addAction(_("Show on Coldcard"), show_address) + keystore.thread.add(partial(self.show_address, wallet, addrs[0], keystore=keystore)) + menu.addAction(_("Show on Coldcard ({})").format(keystore.label), show_address) + + @only_hook_if_libraries_available + @hook + def wallet_info_buttons(self, main_window, dialog): + # user is about to see the "Wallet Information" dialog + # - add a button if multisig wallet, and a Coldcard is a cosigner. + wallet = main_window.wallet + + if type(wallet) is not Multisig_Wallet: + return + + if not any(type(ks) == self.keystore_class for ks in wallet.get_keystores()): + # doesn't involve a Coldcard wallet, hide feature + return + + btn = QPushButton(_("Export for Coldcard")) + btn.clicked.connect(lambda unused: self.export_multisig_setup(main_window, wallet)) + + return Buttons(btn, CloseButton(dialog)) + + def export_multisig_setup(self, main_window, wallet): + + basename = wallet.basename().rsplit('.', 1)[0] # trim .json + name = f'{basename}-cc-export.txt'.replace(' ', '-') + fileName = main_window.getSaveFileName(_("Select where to save the setup file"), + name, "*.txt") + if fileName: + with open(fileName, "wt") as f: + ColdcardPlugin.export_ms_wallet(wallet, f, basename) + main_window.show_message(_("Wallet setup file exported successfully")) @only_hook_if_libraries_available @hook @@ -65,22 +112,61 @@ class Plugin(ColdcardPlugin, QtPluginBase): assert type(keystore) == self.keystore_class # convert to PSBT - raw_psbt = keystore.build_psbt(tx, wallet=dia.wallet) + build_psbt(tx, dia.wallet) - name = (dia.wallet.basename() + time.strftime('-%y%m%d-%H%M.psbt')).replace(' ', '-') + name = (dia.wallet.basename() + time.strftime('-%y%m%d-%H%M.psbt'))\ + .replace(' ', '-').replace('.json', '') fileName = dia.main_window.getSaveFileName(_("Select where to save the PSBT file"), name, "*.psbt") if fileName: with open(fileName, "wb+") as f: - f.write(raw_psbt) + f.write(tx.raw_psbt) dia.show_message(_("Transaction exported successfully")) dia.saved = True def show_settings_dialog(self, window, keystore): # When they click on the icon for CC we come here. - device_id = self.choose_device(window, keystore) - if device_id: - CKCCSettingsDialog(window, self, keystore, device_id).exec_() + # - doesn't matter if device not connected, continue + CKCCSettingsDialog(window, self, keystore).exec_() + + @hook + def init_menubar_tools(self, main_window, tools_menu): + # add some PSBT-related tools to the Tool menu. + tools_menu.addSeparator() + tools_menu.addAction(_("&Combine PSBT Files"), lambda: self.psbt_combiner(main_window)) + + def psbt_combiner(self, window): + title = _("Select the signed PSBT files to combine and transmit") + directory = '' + fnames, __ = QFileDialog.getOpenFileNames(window, title, directory, "PSBT Files (*.psbt)") + + psbts = [] + for fn in fnames: + try: + with open(fn, "rb") as f: + raw = f.read() + + psbt = BasicPSBT() + psbt.parse(raw, fn) + + psbts.append(psbt) + except (AssertionError, ValueError, IOError, os.error) as reason: + window.show_critical(_("Electrum was unable to open your PSBT file") + "\n" + str(reason), title=_("Unable to read file")) + return + + # Consistency checks + try: + assert len(psbts) >= 2, _("Need 2 or more PSBT to be able to combine them.") + first = psbts[0] + for p in psbts: + assert (p.txn == first.txn), \ + f"All must relate to the same original transaction, check file: {p.filename}" + for idx, inp in enumerate(p.inputs): + assert inp.part_sigs, f"No partial signatures found in file: {p.filename}" + + except AssertionError as exc: + window.show_critical(str(exc), title=_("Unable to combine PSBT files")) + return class Coldcard_Handler(QtHandlerBase): @@ -112,21 +198,25 @@ class Coldcard_Handler(QtHandlerBase): return class CKCCSettingsDialog(WindowModalDialog): - '''This dialog doesn't require a device be paired with a wallet. - We want users to be able to wipe a device even if they've forgotten - their PIN.''' - def __init__(self, window, plugin, keystore, device_id): + def __init__(self, window, plugin, keystore): title = _("{} Settings").format(plugin.device) super(CKCCSettingsDialog, self).__init__(window, title) self.setMaximumWidth(540) + # Note: Coldcard may **not** be connected at present time. Keep working! + devmgr = plugin.device_manager() - config = devmgr.config - handler = keystore.handler + #config = devmgr.config + #handler = keystore.handler self.thread = thread = keystore.thread + self.keystore = keystore def connect_and_doit(): + # Attempt connection to device, or raise. + device_id = plugin.choose_device(window, keystore) + if not device_id: + raise RuntimeError("Device not connected") client = devmgr.client_by_id(device_id) if not client: raise RuntimeError("Device not connected") @@ -148,13 +238,14 @@ class CKCCSettingsDialog(WindowModalDialog): y = 3 rows = [ + ('xfp', _("Master Fingerprint")), + ('serial', _("USB Serial")), ('fw_version', _("Firmware Version")), ('fw_built', _("Build Date")), ('bl_version', _("Bootloader")), - ('xfp', _("Master Fingerprint")), - ('serial', _("USB Serial")), ] for row_num, (member_name, label) in enumerate(rows): + # XXX we know xfp already, even if not connected widget = QLabel('000000000000') widget.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard) @@ -164,7 +255,7 @@ class CKCCSettingsDialog(WindowModalDialog): y += 1 body_layout.addLayout(grid) - upg_btn = QPushButton('Upgrade') + upg_btn = QPushButton(_('Upgrade')) #upg_btn.setDefault(False) def _start_upgrade(): thread.add(connect_and_doit, on_success=self.start_upgrade) @@ -177,10 +268,19 @@ class CKCCSettingsDialog(WindowModalDialog): dialog_vbox = QVBoxLayout(self) dialog_vbox.addWidget(body) - # Fetch values and show them - thread.add(connect_and_doit, on_success=self.show_values) + # Fetch firmware/versions values and show them. + thread.add(connect_and_doit, on_success=self.show_values, on_error=self.show_placeholders) + + def show_placeholders(self, unclear_arg): + # device missing, so hide lots of detail. + self.xfp.setText('%s' % xfp2str(self.keystore.ckcc_xfp)) + self.serial.setText('(not connected)') + self.fw_version.setText('') + self.fw_built.setText('') + self.bl_version.setText('') def show_values(self, client): + dev = client.dev self.xfp.setText('%s' % xfp2str(dev.master_fingerprint))