diff --git a/contrib/requirements/requirements-hw.txt b/contrib/requirements/requirements-hw.txt index e8c7b2e9b..507da9734 100644 --- a/contrib/requirements/requirements-hw.txt +++ b/contrib/requirements/requirements-hw.txt @@ -2,5 +2,6 @@ Cython>=0.27 trezor[hidapi]>=0.9.0 keepkey btchip-python +ckcc-protocol websocket-client hidapi diff --git a/icons.qrc b/icons.qrc index 82cfba322..16242879e 100644 --- a/icons.qrc +++ b/icons.qrc @@ -49,6 +49,8 @@ icons/speaker.png icons/trezor_unpaired.png icons/trezor.png + icons/coldcard.png + icons/coldcard_unpaired.png icons/trustedcoin-status.png icons/trustedcoin-wizard.png icons/unconfirmed.png diff --git a/icons/coldcard.png b/icons/coldcard.png new file mode 100644 index 000000000..11e79a2bf Binary files /dev/null and b/icons/coldcard.png differ diff --git a/icons/coldcard_unpaired.png b/icons/coldcard_unpaired.png new file mode 100644 index 000000000..223318109 Binary files /dev/null and b/icons/coldcard_unpaired.png differ diff --git a/lib/transaction.py b/lib/transaction.py index 54ca59485..e23522d5c 100644 --- a/lib/transaction.py +++ b/lib/transaction.py @@ -1020,12 +1020,12 @@ class Transaction: else: return network_ser - def serialize_to_network(self, estimate_size=False, witness=True): + def serialize_to_network(self, estimate_size=False, witness=True, blank_scripts=False): nVersion = int_to_hex(self.version, 4) nLocktime = int_to_hex(self.locktime, 4) inputs = self.inputs() outputs = self.outputs() - txins = var_int(len(inputs)) + ''.join(self.serialize_input(txin, self.input_script(txin, estimate_size)) for txin in inputs) + txins = var_int(len(inputs)) + ''.join(self.serialize_input(txin, self.input_script(txin, estimate_size) if not blank_scripts else '') for txin in inputs) txouts = var_int(len(outputs)) + ''.join(self.serialize_output(o) for o in outputs) use_segwit_ser_for_estimate_size = estimate_size and self.is_segwit(guess_for_address=True) use_segwit_ser_for_actual_use = not estimate_size and \ diff --git a/plugins/coldcard/README.md b/plugins/coldcard/README.md new file mode 100644 index 000000000..04e62ce81 --- /dev/null +++ b/plugins/coldcard/README.md @@ -0,0 +1,25 @@ + +# Coldcard Hardware Wallet Plugin + +## Just the glue please + +- This code connects the public USB API and Electrum. Leverages all the good work that's been +done my the Electrum team to support hardware wallets. + + +### Ctags + +- I find this command useful (at top level) ... but I'm a VIM user. + + ctags -f .tags electrum `find . -name ENV -prune -o -name \*.py` + +### Working with latest ckcc-protocol + +- at top level, do this: + + pip install -e git+ssh://git@github.com/Coldcard/ckcc-protocol.git#egg=ckcc-protocol + +- but you'll need the https version of that, not ssh like I can. +- also a branch name would be good in there +- do `pip uninstall ckcc` first +- see diff --git a/plugins/coldcard/__init__.py b/plugins/coldcard/__init__.py new file mode 100644 index 000000000..e7aef3e72 --- /dev/null +++ b/plugins/coldcard/__init__.py @@ -0,0 +1,7 @@ +from electrum.i18n import _ + +fullname = 'Coldcard Wallet' +description = 'Provides support for the Coldcard hardware wallet from Coinkite' +requires = [('ckcc-protocol', 'github.com/Coldcard/ckcc-protocol')] +registers_keystore = ('hardware', 'coldcard', _("Coldcard wallet")) +available_for = ['qt', 'cmdline'] diff --git a/plugins/coldcard/cmdline.py b/plugins/coldcard/cmdline.py new file mode 100644 index 000000000..e70db7bdb --- /dev/null +++ b/plugins/coldcard/cmdline.py @@ -0,0 +1,47 @@ +from electrum.plugins import hook +from .coldcard import ColdcardPlugin +from electrum.util import print_msg, print_error, raw_input, print_stderr + +class ColdcardCmdLineHandler: + + def get_passphrase(self, msg, confirm): + raise NotImplementedError + + def get_pin(self, msg): + raise NotImplementedError + + def prompt_auth(self, msg): + raise NotImplementedError + + def yes_no_question(self, msg): + print_msg(msg) + return raw_input() in 'yY' + + def stop(self): + pass + + def show_message(self, msg, on_cancel=None): + print_stderr(msg) + + def show_error(self, msg, blocking=False): + print_error(msg) + + def update_status(self, b): + print_error('hw device status', b) + + def finished(self): + pass + +class Plugin(ColdcardPlugin): + handler = ColdcardCmdLineHandler() + + @hook + def init_keystore(self, keystore): + if not isinstance(keystore, self.keystore_class): + return + keystore.handler = self.handler + + def create_handler(self, window): + return self.handler + +# EOF diff --git a/plugins/coldcard/coldcard.py b/plugins/coldcard/coldcard.py new file mode 100644 index 000000000..5872c7251 --- /dev/null +++ b/plugins/coldcard/coldcard.py @@ -0,0 +1,561 @@ +# +# Coldcard Electrum plugin main code. +# +# +from struct import pack, unpack +import hashlib +import os, sys, time, io +import traceback + +from electrum import bitcoin +from electrum.bitcoin import TYPE_ADDRESS, int_to_hex +from electrum.i18n import _ +from electrum.plugins import BasePlugin, Device +from electrum.keystore import Hardware_KeyStore, xpubkey_to_pubkey +from electrum.transaction import Transaction +from electrum.wallet import Standard_Wallet +from electrum.crypto import hash_160 +from ..hw_wallet import HW_PluginBase +from ..hw_wallet.plugin import is_any_tx_output_on_change_branch +from electrum.util import print_error, is_verbose, bfh, bh2u, versiontuple +from electrum.base_wizard import ScriptTypeNotSupported + +try: + import hid + from ckcc.protocol import CCProtocolPacker, CCProtocolUnpacker + 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.client import ColdcardDevice, COINKITE_VID, CKCC_PID, CKCC_SIMULATOR_PATH + + requirements_ok = True +except ImportError: + requirements_ok = False + +class ElectrumColdcardDevice(ColdcardDevice): + # avoid use of pycoin for MiTM message signature test + def mitm_verify(self, sig, expect_xpub): + # verify a signature (65 bytes) over the session key, using the master bip32 node + # - customized to use specific EC library of Electrum. + from electrum.ecc import ECPubkey + + xtype, depth, parent_fingerprint, child_number, chain_code, K_or_k \ + = bitcoin.deserialize_xpub(expect_xpub) + + pubkey = ECPubkey(K_or_k) + try: + pubkey.verify_message_hash(sig[1:65], self.session_key) + return True + except: + return False + +def my_var_int(l): + # Bitcoin serialization of integers... directly into binary! + if l < 253: + return pack("B", l) + elif l < 0x10000: + return pack("' % (self.dev.master_fingerprint, + self.label()) + + @property + def expected_master(self, xp): + return self._expected_master + + @expected_master.setter + def expected_master(self, xp): + if self._expected_master is None: + # Do the MiTM test now that we know what master xpub should be + self.dev.check_mitm(expected_master=xp) + self._expected_master = xp + print_error('[coldcard]', "USB MiTM test passed") + else: + assert self._expected_master == xp, "XPUB changing?" + + def is_pairable(self): + # can't do anything w/ devices that aren't setup (but not normally reachable) + return bool(self.dev.master_xpub) + + def timeout(self, cutoff): + # nothing to do? + pass + + def close(self): + # close the HID device (so can be reused) + self.dev.close() + self.dev = None + + def is_initialized(self): + return bool(self.dev.master_xpub) + + def label(self): + # 'label' of this Coldcard. Warning: gets saved into wallet file, which might + # not be encrypted, so better for privacy if based on xpub/fingerprint rather than + # USB serial number. + if self.dev.is_simulator: + return 'Coldcard Simulator 0x%08x' % self.dev.master_fingerprint + + if not self.dev.master_fingerprint: + # failback; not expected + return 'Coldcard #' + self.dev.serial + + return 'Coldcard 0x%08x' % self.dev.master_fingerprint + + def has_usable_connection_with_device(self): + # Do end-to-end ping test + try: + self.ping_check() + return True + except: + return False + + def get_xpub(self, bip32_path, xtype): + # TODO: xtype? + return self.dev.send_recv(CCProtocolPacker.get_xpub(bip32_path), timeout=5000) + + def ping_check(self): + # check connection is working + assert self.dev.session_key, 'not encrypted?' + req = b'1234 Electrum Plugin 4321' # free up to 59 bytes + try: + echo = self.dev.send_recv(CCProtocolPacker.ping(req)) + assert echo == req + except: + raise RuntimeError("Communication trouble with Coldcard") + + def show_address(self, path, addr_fmt): + # prompt user w/ addres, also returns it immediately. + return self.dev.send_recv(CCProtocolPacker.show_address(path, addr_fmt), timeout=None) + + def sign_message_start(self, path, msg): + # this starts the UX experience. + self.dev.send_recv(CCProtocolPacker.sign_message(msg, path), timeout=None) + + def sign_message_poll(self): + # poll device... if user has approved, will get tuple: (addr, sig) else None + return self.dev.send_recv(CCProtocolPacker.get_signed_msg(), timeout=None) + + def sign_transaction_start(self, raw_psbt, finalize=True): + # Multiple steps to sign: + # - upload binary + # - start signing UX + # - wait for coldcard to complete process, or have it refused. + # - download resulting txn + assert 20 <= len(raw_psbt) < MAX_TXN_LEN, 'PSBT is too big' + dlen, chk = self.dev.upload_file(raw_psbt) + + resp = self.dev.send_recv(CCProtocolPacker.sign_transaction(dlen, chk, finalize=finalize), + timeout=None) + + if resp != None: + raise ValueError(resp) + + def sign_transaction_poll(self): + # poll device... if user has approved, will get tuple: (addr, sig) else None + return self.dev.send_recv(CCProtocolPacker.get_signed_txn(), timeout=None) + + def download_file(self, length, checksum, file_number=1): + # get a file + return self.dev.download_file(length, checksum, file_number=file_number) + + + +class Coldcard_KeyStore(Hardware_KeyStore): + hw_type = 'coldcard' + device = 'Coldcard' + + def __init__(self, d): + Hardware_KeyStore.__init__(self, d) + # Errors and other user interaction is done through the wallet's + # handler. The handler is per-window and preserved across + # device reconnects + self.force_watching_only = False + self.ux_busy = False + + # seems like only the derivation path and resulting xpub is stored in + # the wallet file... however, we need to know the master xpub to verify + # the mitm and also so we can put the right value into the subkey paths + # of PSBT files that might be generated offline + #self.ckcc_master_xpub = d.get('ckcc_master_xpub', '') + + def get_derivation(self): + return self.derivation + + def get_client(self): + rv = self.plugin.get_client(self) + if rv and self.ckcc_master_xpub: + rv.expected_master = self.ckcc_master_xpub + return rv + + def give_error(self, message, clear_client=False): + print_error(message) + if not self.ux_busy: + self.handler.show_error(message) + else: + self.ux_busy = False + if clear_client: + self.client = None + raise Exception(message) + + def wrap_busy(func): + # decorator: function takes over the UX on the device. + def wrapper(self, *args, **kwargs): + try: + self.ux_busy = True + return func(self, *args, **kwargs) + finally: + self.ux_busy = False + return wrapper + + def decrypt_message(self, pubkey, message, password): + raise RuntimeError(_('Encryption and decryption are currently not supported for {}').format(self.device)) + + @wrap_busy + def sign_message(self, sequence, message, password): + # Sign a message on device. Since we have big screen, of course we + # have to show the message unabiguously there first! + try: + msg = message.encode('ascii', errors='strict') + assert 1 <= len(msg) <= MSG_SIGNING_MAX_LENGTH + except (UnicodeError, AssertionError): + # there are other restrictions on message content, + # but let the device enforce and report those + self.handler.show_error('Only short (%d max) ASCII messages can be signed.' + % MSG_SIGNING_MAX_LENGTH) + return b'' + + client = self.get_client() + path = self.get_derivation() + ("/%d/%d" % sequence) + try: + cl = self.get_client() + try: + self.handler.show_message("Signing message (using %s)..." % path) + + cl.sign_message_start(path, msg) + + while 1: + # How to kill some time, without locking UI? + time.sleep(0.250) + + resp = cl.sign_message_poll() + if resp is not None: + break + + finally: + self.handler.finished() + + assert len(resp) == 2 + addr, raw_sig = resp + + # already encoded in Bitcoin fashion, binary. + assert 40 < len(raw_sig) <= 65 + + return raw_sig + + except (CCUserRefused, CCBusyError) as exc: + self.handler.show_error(str(exc)) + except CCProtoError as exc: + traceback.print_exc(file=sys.stderr) + self.handler.show_error('{}\n\n{}'.format( + _('Error showing address') + ':', str(exc))) + except Exception as e: + self.give_error(e, True) + + # give empty bytes for error cases; it seems to clear the old signature box + return b'' + + def build_psbt(self, tx, xfp=None, wallet=None): + # Render a PSBT file, for upload to Coldcard. + # + if xfp is None: + # we need to BIP32 fingerprint value: 4 bytes of ripemd(sha256(pubkey)) + kk = self.ckcc_master_xpub + print("kk = %r" % kk) + kk = bfh(self.get_pubkey_from_xpub(kk, [])) + print("pubk = %r" % kk) + assert len(kk) == 33 + + xfp, = unpack(' fingerprint LE32 + # a/b/c => ints + base_path = pack(' not multisig, must be bip32 + if type(wallet) is not Standard_Wallet: + 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) + +# EOF diff --git a/plugins/coldcard/qt.py b/plugins/coldcard/qt.py new file mode 100644 index 000000000..ebab43ef9 --- /dev/null +++ b/plugins/coldcard/qt.py @@ -0,0 +1,97 @@ +import time + +from electrum.i18n import _ +from electrum.plugins import hook +from electrum.wallet import Standard_Wallet +from electrum_gui.qt.util import * + +from .coldcard import ColdcardPlugin +from ..hw_wallet.qt import QtHandlerBase, QtPluginBase + + +class Plugin(ColdcardPlugin, QtPluginBase): + icon_unpaired = ":icons/coldcard_unpaired.png" + icon_paired = ":icons/coldcard.png" + + def create_handler(self, window): + return Coldcard_Handler(window) + + @hook + def receive_menu(self, menu, addrs, wallet): + if type(wallet) is not Standard_Wallet: + return + keystore = wallet.get_keystore() + 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) + + @hook + def transaction_dialog(self, dia): + # see gui/qt/transaction_dialog.py + + keystore = dia.wallet.get_keystore() + if type(keystore) != self.keystore_class: + # not a Coldcard wallet, hide feature + return + + # - add a new button, near "export" + btn = QPushButton(_("Save PSBT")) + btn.clicked.connect(lambda unused: self.export_psbt(dia)) + if dia.tx.is_complete(): + # but disable it for signed transactions (nothing to do if already signed) + btn.setDisabled(True) + + dia.sharing_buttons.append(btn) + + def export_psbt(self, dia): + # Called from hook in transaction dialog + tx = dia.tx + assert not tx.is_complete(), 'expect unsigned txn' + + # can only expect Coldcard wallets to work with these files (right now) + keystore = dia.wallet.get_keystore() + assert type(keystore) == self.keystore_class + + # convert to PSBT + raw_psbt = keystore.build_psbt(tx, wallet=dia.wallet) + + name = (dia.wallet.basename() + time.strftime('-%y%m%d-%H%M.psbt')).replace(' ', '-') + 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) + dia.show_message(_("Transaction exported successfully")) + dia.saved = True + + +class Coldcard_Handler(QtHandlerBase): + setup_signal = pyqtSignal() + #auth_signal = pyqtSignal(object) + + def __init__(self, win): + super(Coldcard_Handler, self).__init__(win, 'Coldcard') + self.setup_signal.connect(self.setup_dialog) + #self.auth_signal.connect(self.auth_dialog) + + + def message_dialog(self, msg): + self.clear_dialog() + self.dialog = dialog = WindowModalDialog(self.top_level_window(), _("Coldcard Status")) + l = QLabel(msg) + vbox = QVBoxLayout(dialog) + vbox.addWidget(l) + dialog.show() + + def get_setup(self): + self.done.clear() + self.setup_signal.emit() + self.done.wait() + return + + def setup_dialog(self): + self.show_error(_('Please initialization your Coldcard while disconnected.')) + return + +# EOF diff --git a/setup.py b/setup.py index ab79b00b0..383dfd44a 100755 --- a/setup.py +++ b/setup.py @@ -69,6 +69,7 @@ setup( 'electrum_plugins.revealer', 'electrum_plugins.trezor', 'electrum_plugins.digitalbitbox', + 'electrum_plugins.coldcard', 'electrum_plugins.trustedcoin', 'electrum_plugins.virtualkeyboard', ],