From ba4699b80b8da92749ab5049a147d2327e56eadd Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 15 Jun 2021 18:40:43 -0400 Subject: [PATCH] Add Blockstream Jade support (#1234) * Add Blockstream Jade support * Add udev and move jade stuff * Avoid showing elements core when not connected to node --- requirements.in | 1 + requirements.txt | 3 + src/cryptoadvance/specter/devices/__init__.py | 2 + src/cryptoadvance/specter/devices/hwi/jade.py | 369 ++++++++++++ .../specter/devices/hwi/jadepy/README.md | 9 + .../specter/devices/hwi/jadepy/__init__.py | 4 + .../specter/devices/hwi/jadepy/jade.py | 567 ++++++++++++++++++ .../specter/devices/hwi/jadepy/jade_error.py | 19 + .../specter/devices/hwi/jadepy/jade_serial.py | 52 ++ src/cryptoadvance/specter/devices/jade.py | 29 + src/cryptoadvance/specter/hwi_rpc.py | 4 + .../specter/managers/device_manager.py | 1 + .../specter/static/img/devices/jade_icon.svg | 14 + udev/55-usb-jade.rules | 1 + udev/README.md | 2 +- 15 files changed, 1076 insertions(+), 1 deletion(-) create mode 100644 src/cryptoadvance/specter/devices/hwi/jade.py create mode 100644 src/cryptoadvance/specter/devices/hwi/jadepy/README.md create mode 100644 src/cryptoadvance/specter/devices/hwi/jadepy/__init__.py create mode 100644 src/cryptoadvance/specter/devices/hwi/jadepy/jade.py create mode 100644 src/cryptoadvance/specter/devices/hwi/jadepy/jade_error.py create mode 100644 src/cryptoadvance/specter/devices/hwi/jadepy/jade_serial.py create mode 100644 src/cryptoadvance/specter/devices/jade.py create mode 100644 src/cryptoadvance/specter/static/img/devices/jade_icon.svg create mode 100644 udev/55-usb-jade.rules diff --git a/requirements.in b/requirements.in index c2d55a798..1a23c20c2 100644 --- a/requirements.in +++ b/requirements.in @@ -18,3 +18,4 @@ psutil==5.7.3 pyopenssl==20.0.1 flask_wtf==0.14.3 pgpy==0.5.3 +cbor==1.0.0 diff --git a/requirements.txt b/requirements.txt index 254efae26..aa364cf79 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,6 +12,9 @@ bitbox02==5.3.0 \ --hash=sha256:797e6904d431f6d2ef711f169e7ce8fffc125cc8c5b3efb8187fd451f45635e1 \ --hash=sha256:fe0e8aeb9b32fd7d76bb3e9838895973a74dfd532a8fb8ac174a1a60214aee26 # via hwi +cbor==1.0.0 \ + --hash=sha256:13225a262ddf5615cbd9fd55a76a0d53069d18b07d2e9f19c39e6acb8609bbb6 + # via -r requirements.in certifi==2019.9.11 \ --hash=sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50 \ --hash=sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef diff --git a/src/cryptoadvance/specter/devices/__init__.py b/src/cryptoadvance/specter/devices/__init__.py index 39af134c6..9ff43dbc0 100644 --- a/src/cryptoadvance/specter/devices/__init__.py +++ b/src/cryptoadvance/specter/devices/__init__.py @@ -6,6 +6,7 @@ from .bitbox02 import BitBox02 from .keepkey import Keepkey from .specter import Specter from .cobo import Cobo +from .jade import Jade from .generic import GenericDevice from .electrum import Electrum from .bitcoin_core import BitcoinCore @@ -20,6 +21,7 @@ __all__ = [ ColdCard, Keepkey, Cobo, + Jade, Electrum, BitcoinCore, ElementsCore, diff --git a/src/cryptoadvance/specter/devices/hwi/jade.py b/src/cryptoadvance/specter/devices/hwi/jade.py new file mode 100644 index 000000000..16b9d4fcc --- /dev/null +++ b/src/cryptoadvance/specter/devices/hwi/jade.py @@ -0,0 +1,369 @@ +""" +Blockstream Jade Devices +************************ +""" + +from .jadepy.jade import JadeAPI, JadeError +from serial.tools import list_ports + +from typing import List, Union +from hwilib.descriptor import PubkeyProvider +from hwilib.hwwclient import HardwareWalletClient +from hwilib.errors import ( + ActionCanceledError, + BadArgumentError, + DeviceConnectionError, + DeviceFailureError, + UnavailableActionError, + common_err_msgs, + handle_errors, +) +from hwilib.common import ( + AddressType, + Chain, +) +from hwilib.key import ExtendedKey, parse_path +from hwilib.psbt import PSBT +from hwilib.tx import CTransaction +from hwilib._script import ( + is_p2sh, + is_p2wpkh, + is_p2wsh, + is_witness, +) + +import logging +import os + +JADE_VENDOR_ID = 0x10C4 +JADE_DEVICE_ID = 0xEA60 + +py_enumerate = ( + enumerate # To use the enumerate built-in, since the name is overridden below +) + + +def jade_exception(f): + def func(*args, **kwargs): + try: + return f(*args, **kwargs) + except ValueError as e: + raise BadArgumentError(str(e)) + except JadeError as e: + if e.code == -32000: # CBOR_RPC_USER_CANCELLED + raise ActionCanceledError("{} canceled by user".format(f.__name__)) + elif e.code == -32602: # CBOR_RPC_BAD_PARAMETERS + raise BadArgumentError(e.message) + elif e.code == -32603: # CBOR_RPC_INTERNAL_ERROR + raise DeviceFailureError(e.message) + elif e.code == -32002: # CBOR_RPC_HW_LOCKED + raise DeviceConnectionError("Device is locked") + elif e.code == -32003: # CBOR_RPC_NETWORK_MISMATCH + raise DeviceConnectionError("Network/chain selection error") + elif e.code in [ + -32600, + -32601, + -32001, + ]: # CBOR_RPC_INVALID_REQUEST, CBOR_RPC_UNKNOWN_METHOD, CBOR_RPC_PROTOCOL_ERROR + raise DeviceConnectionError("Messaging/communiciation error") + else: + raise e + + return func + + +# This class extends the HardwareWalletClient for Blockstream Jade specific things +class JadeClient(HardwareWalletClient): + + NETWORKS = {Chain.MAIN: "mainnet", Chain.TEST: "testnet", Chain.REGTEST: "regtest"} + + def _network(self): + return JadeClient.NETWORKS.get(self.chain, "mainnet") + + ADDRTYPES = { + AddressType.LEGACY: "pkh(k)", + AddressType.WIT: "wpkh(k)", + AddressType.SH_WIT: "sh(wpkh(k))", + } + + @staticmethod + def _convertAddrType(addrType): + return JadeClient.ADDRTYPES[addrType] + + def __init__(self, path: str, password: str = "", expert: bool = False) -> None: + super(JadeClient, self).__init__(path, password, expert) + self.jade = JadeAPI.create_serial(path) + self.jade.connect() + + # Push some host entropy into jade + self.jade.add_entropy(os.urandom(32)) + + # Do the PIN thing if required + # NOTE: uses standard 'requests' networking to connect to blind pinserver + try: + while not self.jade.auth_user(self._network()): + logging.debug("Incorrect PIN provided") + except: + try: + self.chain = Chain.TEST + while not self.jade.auth_user(self._network()): + logging.debug("Incorrect PIN provided") + except: + self.chain = Chain.REGTEST + while not self.jade.auth_user(self._network()): + logging.debug("Incorrect PIN provided") + + # Retrieves the public key at the specified BIP 32 derivation path + @jade_exception + def get_pubkey_at_path(self, bip32_path: str) -> ExtendedKey: + path = parse_path(bip32_path) + xpub = self.jade.get_xpub(self._network(), path) + ext_key = ExtendedKey.deserialize(xpub) + return ext_key + + # Walk the PSBT looking for inputs we can sign. Push any signatures into the + # 'partial_sigs' map in the input, and return the updated PSBT. + @jade_exception + def sign_tx(self, tx: PSBT) -> PSBT: + """ + Sign a transaction with the Blockstream Jade. + + - Jade can only be used to sign single-key inputs at this time. It cannot sign multisig or arbitrary scripts. + """ + + c_txn = CTransaction(tx.tx) + master_fp = self.get_master_fingerprint() + signing_pubkeys = [None] * len(tx.inputs) + + # Signing input details + jade_inputs = [] + for n_vin, (txin, psbtin) in py_enumerate(zip(c_txn.vin, tx.inputs)): + # Get bip32 path to use to sign, if required for this input + path = None + for pubkey, origin in psbtin.hd_keypaths.items(): + if origin.fingerprint == master_fp and len(origin.path) > 0: + # Our input + if ( + pubkey not in psbtin.partial_sigs + or not psbtin.partial_sigs[pubkey] + ): + # hw to sign this input - it is not already signed + signing_pubkeys[n_vin] = pubkey + path = origin.path + + # Get the tx and prevout/scriptcode + utxo = None + input_txn_bytes = None + if psbtin.witness_utxo: + utxo = psbtin.witness_utxo + if psbtin.non_witness_utxo: + if txin.prevout.hash != psbtin.non_witness_utxo.sha256: + raise BadArgumentError( + "Input {} has a non_witness_utxo with the wrong hash".format( + n_vin + ) + ) + utxo = psbtin.non_witness_utxo.vout[txin.prevout.n] + input_txn_bytes = psbtin.non_witness_utxo.serialize_without_witness() + + scriptcode = utxo.scriptPubKey + + if is_p2sh(scriptcode): + scriptcode = psbtin.redeem_script + + witness_input, witness_version, witness_program = is_witness(scriptcode) + + if witness_input: + if is_p2wsh(scriptcode): + scriptcode = psbtin.witness_script + elif is_p2wpkh(scriptcode): + scriptcode = b"\x76\xa9\x14" + witness_program + b"\x88\xac" + else: + scriptcode = None + + # Build the input and add to the list + jade_inputs.append( + { + "is_witness": witness_input, + "input_tx": input_txn_bytes, + "script": scriptcode, + "path": path, + } + ) + + # Change output details + # This is optional, in that if we send it Jade validates the change output script + # and the user need not confirm that ouptut. If not passed the change output must + # be confirmed by the user on the hwwallet screen, like any other spend output. + change = [None] * len(tx.outputs) + for n_vout, (txout, psbtout) in py_enumerate(zip(c_txn.vout, tx.outputs)): + for pubkey, origin in psbtout.hd_keypaths.items(): + # Considers 'our' outputs as change as far as Jade is concerned + # ie. can be auto-confirmed. + # Is this ok, or should check path also, assuming bip44-like ? + if origin.fingerprint == master_fp and len(origin.path) > 0: + addr_type = None + if txout.is_p2pkh(): + addr_type = AddressType.LEGACY + elif txout.is_witness()[0] and not txout.is_p2wsh(): + addr_type = AddressType.WIT + elif txout.is_p2sh(): + addr_type = AddressType.SH_WIT # is it really though ? + + if addr_type: + addr_type = self._convertAddrType(addr_type) + change[n_vout] = {"path": origin.path, "variant": addr_type} + + # The txn itself + txn_bytes = c_txn.serialize_without_witness() + + # Request Jade generate the signatures for our inputs. + # Change details are passed to be validated on the hw (user does not confirm) + signatures = self.jade.sign_tx(self._network(), txn_bytes, jade_inputs, change) + + # Push sigs into PSBT structure as appropriate + for psbtin, pubkey, sig in zip(tx.inputs, signing_pubkeys, signatures): + if pubkey and sig: + psbtin.partial_sigs[pubkey] = sig + + # Return the updated psbt + return tx + + # Sign message, confirmed on device + @jade_exception + def sign_message(self, message: Union[str, bytes], bip32_path: str) -> str: + path = parse_path(bip32_path) + if isinstance(message, bytes) or isinstance(message, bytearray): + message = message.decode("utf-8") + signature = self.jade.sign_message(path, message) + return signature + + # Display address of specified type on the device. Only supports single-key based addresses atm. + @jade_exception + def display_singlesig_address(self, bip32_path: str, addr_type: AddressType) -> str: + path = parse_path(bip32_path) + addr_type = self._convertAddrType(addr_type) + address = self.jade.get_receive_address( + self._network(), path, variant=addr_type + ) + return address + + def display_multisig_address( + self, threshold: int, pubkeys: List[PubkeyProvider], addr_type: AddressType + ) -> str: + """ + The Blockstream Jade does not support multisig addresses. + + :raises UnavailableActionError: Always, this function is unavailable + """ + raise UnavailableActionError( + "The Blockstream Jade does not support generic multisig P2SH address display" + ) + + # Setup a new device + def setup_device(self, label="", passphrase=""): + """ + The Blockstream Jade does not support setup via software. + + :raises UnavailableActionError: Always, this function is unavailable + """ + raise UnavailableActionError( + "The Blockstream Jade does not support software setup" + ) + + # Wipe this device + def wipe_device(self): + """ + The Blockstream Jade does not support wiping via software. + + :raises UnavailableActionError: Always, this function is unavailable + """ + raise UnavailableActionError( + "The Blockstream Jade does not support wiping via software" + ) + + # Restore device from mnemonic or xprv + def restore_device(self, label="", word_count=24): + """ + The Blockstream Jade does not support restoring via software. + + :raises UnavailableActionError: Always, this function is unavailable + """ + raise UnavailableActionError( + "The Blockstream Jade does not support restoring via software" + ) + + # Begin backup process + def backup_device(self, label="", passphrase=""): + """ + The Blockstream Jade does not support backing up via software. + + :raises UnavailableActionError: Always, this function is unavailable + """ + raise UnavailableActionError( + "The Blockstream Jade does not support creating a backup via software" + ) + + # Close the device + def close(self): + self.jade.disconnect() + + # Prompt pin + def prompt_pin(self): + """ + The Blockstream Jade does not need a PIN sent from the host. + + :raises UnavailableActionError: Always, this function is unavailable + """ + raise UnavailableActionError( + "The Blockstream Jade does not need a PIN sent from the host" + ) + + # Send pin + def send_pin(self, pin): + """ + The Blockstream Jade does not need a PIN sent from the host. + + :raises UnavailableActionError: Always, this function is unavailable + """ + raise UnavailableActionError( + "The Blockstream Jade does not need a PIN sent from the host" + ) + + # Toggle passphrase + def toggle_passphrase(self): + """ + The Blockstream Jade does not support toggling passphrase from the host. + + :raises UnavailableActionError: Always, this function is unavailable + """ + raise UnavailableActionError( + "The Blockstream Jade does not support toggling passphrase from the host" + ) + + +def enumerate(password=""): + results = [] + + # Jade is not really an HID device, it shows as a serial/com port device. + # Scan com ports looking for the relevant vid and pid, and use 'path' to + # hold the path to the serial port device, eg. /dev/ttyUSB0 + for devinfo in list_ports.comports(): + if devinfo.vid == JADE_VENDOR_ID and devinfo.pid == JADE_DEVICE_ID: + d_data = {} + d_data["type"] = "jade" + d_data["path"] = devinfo.device + d_data["needs_pin_sent"] = False + d_data["needs_passphrase_sent"] = False + + client = None + with handle_errors(common_err_msgs["enumerate"], d_data): + client = JadeClient(devinfo.device, password) + d_data["fingerprint"] = client.get_master_fingerprint().hex() + + if client: + client.close() + + results.append(d_data) + + return results diff --git a/src/cryptoadvance/specter/devices/hwi/jadepy/README.md b/src/cryptoadvance/specter/devices/hwi/jadepy/README.md new file mode 100644 index 000000000..dfb5cf08e --- /dev/null +++ b/src/cryptoadvance/specter/devices/hwi/jadepy/README.md @@ -0,0 +1,9 @@ +# Python Jade Library + +This is a slightly stripped down version of the official [Jade](https://github.com/Blockstream/Jade) python library. + +This stripped down version was made at commit [dfdfc7e1d8b91227f4ea7555457640506b8c8aed](https://github.com/Blockstream/Jade/commit/dfdfc7e1d8b91227f4ea7555457640506b8c8aed). + +## Changes + +- Removed BLE module, reducing transitive dependencies diff --git a/src/cryptoadvance/specter/devices/hwi/jadepy/__init__.py b/src/cryptoadvance/specter/devices/hwi/jadepy/__init__.py new file mode 100644 index 000000000..5079bd9b7 --- /dev/null +++ b/src/cryptoadvance/specter/devices/hwi/jadepy/__init__.py @@ -0,0 +1,4 @@ +from .jade import JadeAPI +from .jade_error import JadeError + +__version__ = "0.0.1" diff --git a/src/cryptoadvance/specter/devices/hwi/jadepy/jade.py b/src/cryptoadvance/specter/devices/hwi/jadepy/jade.py new file mode 100644 index 000000000..d5077f7e4 --- /dev/null +++ b/src/cryptoadvance/specter/devices/hwi/jadepy/jade.py @@ -0,0 +1,567 @@ +import cbor +import json +import time +import logging +import collections +import traceback +import requests +import random + +# JadeError +from .jade_error import JadeError + +# Low-level comms backends +from .jade_serial import JadeSerialImpl + +# Not used in HWI +# Removed to reduce transitive dependencies +# from .jade_ble import JadeBleImpl + + +# Default serial connection +DEFAULT_SERIAL_DEVICE = "/dev/ttyUSB0" +DEFAULT_BAUD_RATE = 115200 +DEFAULT_SERIAL_TIMEOUT = 120 + +# Default BLE connection +DEFAULT_BLE_DEVICE_NAME = "Jade" +DEFAULT_BLE_SERIAL_NUMBER = None +DEFAULT_BLE_SCAN_TIMEOUT = 60 + +# 'jade' logger +logger = logging.getLogger("jade") +device_logger = logging.getLogger("jade-device") + + +# +# High-Level Jade Client API +# Builds on a JadeInterface to provide a meaningful API +# +# Either: +# a) use with JadeAPI.create_[serial|ble]() as jade: +# (recommended) +# or: +# b) use JadeAPI.create_[serial|ble], then call connect() before +# using, and disconnect() when finished +# (caveat cranium) +# or: +# c) use ctor to wrap existing JadeInterface instance +# (caveat cranium) +# +class JadeAPI: + def __init__(self, jade): + assert jade is not None + self.jade = jade + + def __enter__(self): + self.connect() + return self + + def __exit__(self, exc_type, exc, tb): + if exc_type: + logger.error("Exception causing JadeAPI context exit.") + logger.error(exc_type) + logger.error(exc) + traceback.print_tb(tb) + self.disconnect(exc_type is not None) + + @staticmethod + def create_serial(device=None, baud=None, timeout=None): + impl = JadeInterface.create_serial(device, baud, timeout) + return JadeAPI(impl) + + @staticmethod + def create_ble(device_name=None, serial_number=None, scan_timeout=None, loop=None): + impl = JadeInterface.create_ble(device_name, serial_number, scan_timeout, loop) + return JadeAPI(impl) + + # Connect underlying interface + def connect(self): + self.jade.connect() + + # Disconnect underlying interface + def disconnect(self, drain=False): + self.jade.disconnect(drain) + + # Drain all output from the interface + def drain(self): + self.jade.drain() + + # Simple http request function which can be used when a Jade response requires + # an external http call. + # The default implementation used in _jadeRpc() below. + @staticmethod + def _http_request(params): + logger.debug("_http_request: {}".format(params)) + + # Use the first non-onion url + url = [url for url in params["urls"] if ".onion" not in url][0] + if params["method"] == "GET": + assert "data" not in params, "Cannot pass body to requests.get" + f = requests.get(url) + elif params["method"] == "POST": + data = json.dumps(params["data"]) + f = requests.post(url, data) + + logger.debug("http_request received reply: {}".format(f.text)) + + if f.status_code != 200: + logger.error("http error {} : {}".format(f.status_code, f.text)) + raise ValueError(f.status_code) + + assert params["accept"] == "json" + f = f.json() + + return {"body": f} + + # Raise any returned error as an exception + @staticmethod + def _get_result_or_raise_error(reply): + if "error" in reply: + e = reply["error"] + raise JadeError(e.get("code"), e.get("message"), e.get("data")) + + return reply["result"] + + # Helper to call wrapper interface rpc invoker + def _jadeRpc( + self, + method, + params=None, + inputid=None, + http_request_fn=None, + long_timeout=False, + ): + newid = inputid if inputid else str(random.randint(100000, 999999)) + request = self.jade.build_request(newid, method, params) + reply = self.jade.make_rpc_call(request, long_timeout) + result = self._get_result_or_raise_error(reply) + + # The Jade can respond with a request for interaction with a remote + # http server. This is used for interaction with the pinserver but the + # code below acts as a dumb proxy and simply makes the http request and + # forwards the response back to the Jade. + # Note: the function called to make the http-request can be passed in, + # or defaults to the simple _http_request() function above. + if isinstance(result, collections.Mapping) and "http_request" in result: + make_http_request = http_request_fn or self._http_request + http_request = result["http_request"] + http_response = make_http_request(http_request["params"]) + return self._jadeRpc( + http_request["on-reply"], + http_response["body"], + http_request_fn=make_http_request, + long_timeout=long_timeout, + ) + + return result + + # Get version information from the hw + def get_version_info(self): + return self._jadeRpc("get_version_info") + + # Add client entropy to the hw rng + def add_entropy(self, entropy): + params = {"entropy": entropy} + return self._jadeRpc("add_entropy", params) + + # OTA new firmware + def ota_update(self, fwcmp, fwlen, chunksize, cb): + + compressed_size = len(fwcmp) + + # Initiate OTA + params = {"fwsize": fwlen, "cmpsize": compressed_size} + + result = self._jadeRpc("ota", params) + assert result is True + + # Write binary chunks + written = 0 + while written < compressed_size: + remaining = compressed_size - written + length = min(remaining, chunksize) + chunk = bytes(fwcmp[written : written + length]) + result = self._jadeRpc("ota_data", chunk) + assert result is True + written += length + + if cb: + cb(written, compressed_size) + + # All binary data uploaded + return self._jadeRpc("ota_complete") + + # Run (debug) healthcheck on the hw + def run_remote_selfcheck(self): + return self._jadeRpc("debug_selfcheck") + + # Set the (debug) mnemonic + def set_mnemonic(self, mnemonic): + params = {"mnemonic": mnemonic} + return self._jadeRpc("debug_set_mnemonic", params) + + # Set the (debug) seed + def set_seed(self, seed): + params = {"seed": seed} + return self._jadeRpc("debug_set_mnemonic", params) + + # Trigger user authentication on the hw + # Involves pinserver handshake + def auth_user(self, network, http_request_fn=None): + params = {"network": network} + return self._jadeRpc( + "auth_user", params, http_request_fn=http_request_fn, long_timeout=True + ) + + # Get xpub given a path + def get_xpub(self, network, path): + params = {"network": network, "path": path} + return self._jadeRpc("get_xpub", params) + + # Get receive-address for parameters + def get_receive_address( + self, *args, recovery_xpub=None, csv_blocks=0, variant=None + ): + if variant is not None: + assert len(args) == 2 + keys = ["network", "path", "variant"] + args += (variant,) + else: + assert len(args) == 4 + keys = [ + "network", + "subaccount", + "branch", + "pointer", + "recovery_xpub", + "csv_blocks", + ] + args += (recovery_xpub, csv_blocks) + return self._jadeRpc("get_receive_address", dict(zip(keys, args))) + + # Sign a message + def sign_message(self, path, message): + params = {"path": path, "message": message} + return self._jadeRpc("sign_message", params) + + # Get a Liquid public blinding key for a given script + def get_blinding_key(self, script): + params = {"script": script} + return self._jadeRpc("get_blinding_key", params) + + # Get the shared secret to unblind a tx, given the receiving script on + # our side and the pubkey of the sender (sometimes called "nonce" in + # Liquid) + def get_shared_nonce(self, script, their_pubkey): + params = {"script": script, "their_pubkey": their_pubkey} + return self._jadeRpc("get_shared_nonce", params) + + # Get a "trusted" blinding factor to blind an output. Normally the blinding + # factors are generated and returned in the `get_commitments` call, but + # for the last output the VBF must be generated on the host side, so this + # call allows the host to get a valid ABF to compute the generator and + # then the "final" VBF. Nonetheless, this call is kept generic, and can + # also generate VBFs, thus the "type" parameter. + # `hash_prevouts` is computed as specified in BIP143 (double SHA of all + # the outpoints being spent as input. It's not checked right away since + # at this point Jade doesn't know anything about the tx we are referring + # to. It will be checked later during `sign_liquid_tx`. + # `output_index` is the output we are trying to blind. + # `type` can either be "ASSET" or "VALUE" to generate ABFs or VBFs. + def get_blinding_factor(self, hash_prevouts, output_index, type): + params = { + "hash_prevouts": hash_prevouts, + "output_index": output_index, + "type": type, + } + return self._jadeRpc("get_blinding_factor", params) + + # Generate the blinding factors and commitments for a given output. + # Can optionally get a "custom" VBF, normally used for the last + # input where the VBF is not random, but generated accordingly to + # all the others. + # `hash_prevouts` and `output_index` have the same meaning as in + # the `get_blinding_factor` call. + # NOTE: the `asset_id` should be passed as it is normally displayed, so + # reversed compared to the "consensus" representation. + def get_commitments(self, asset_id, value, hash_prevouts, output_index, vbf=None): + params = { + "asset_id": asset_id, + "value": value, + "hash_prevouts": hash_prevouts, + "output_index": output_index, + } + if vbf is not None: + params["vbf"] = vbf + return self._jadeRpc("get_commitments", params) + + # Sign a Liquid txn + def sign_liquid_tx(self, network, txn, inputs, commitments, change): + # Protocol: + # 1st message contains txn and number of inputs we are going to send. + # Reply ok if that corresponds to the expected number of inputs (n). + # Then we send one message per input - without expecting replies. + # Once all n input messages are sent, the hw then sends all n replies + # (as the user has a chance to confirm/cancel at this point). + # Then receive all n replies for the n signatures. + # NOTE: *NOT* a sequence of n blocking rpc calls. + + base_id = 100 * random.randint(1000, 9999) + params = { + "network": network, + "txn": txn, + "num_inputs": len(inputs), + "trusted_commitments": commitments, + "change": change, + } + reply = self._jadeRpc("sign_liquid_tx", params, str(base_id)) + assert reply + + # Send all n inputs + requests = [] + for (i, txinput) in enumerate(inputs, 1): + res_id = str(base_id + i) + request = self.jade.build_request(res_id, "tx_input", txinput) + self.jade.write_request(request) + requests.append(request) + time.sleep(0.1) + + # Receive all n signatures + signatures = [] + for request in requests: + reply = self.jade.read_response() + self.jade.validate_reply(request, reply) + signature = self._get_result_or_raise_error(reply) + signatures.append(signature) + + assert len(signatures) == len(inputs) + return signatures + + # Sign a txn + def sign_tx(self, network, txn, inputs, change): + # Protocol: + # 1st message contains txn and number of inputs we are going to send. + # Reply ok if that corresponds to the expected number of inputs (n). + # Then we send one message per input - without expecting replies. + # Once all n input messages are sent, the hw then sends all n replies + # (as the user has a chance to confirm/cancel at this point). + # Then receive all n replies for the n signatures. + # NOTE: *NOT* a sequence of n blocking rpc calls. + + base_id = 100 * random.randint(1000, 9999) + params = { + "network": network, + "txn": txn, + "num_inputs": len(inputs), + "change": change, + } + reply = self._jadeRpc("sign_tx", params, str(base_id)) + assert reply + + # Send all n inputs + requests = [] + for (i, txinput) in enumerate(inputs, 1): + res_id = str(base_id + i) + request = self.jade.build_request(res_id, "tx_input", txinput) + self.jade.write_request(request) + requests.append(request) + time.sleep(0.1) + + # Receive all n signatures + signatures = [] + for request in requests: + reply = self.jade.read_response() + self.jade.validate_reply(request, reply) + signature = self._get_result_or_raise_error(reply) + signatures.append(signature) + + assert len(signatures) == len(inputs) + return signatures + + +# +# Mid-level interface to Jade +# Wraps either a serial or a ble connection +# Calls to send and receive bytes and cbor messages over the interface. +# +# Either: +# a) use wrapped with JadeAPI +# (recommended) +# or: +# b) use with JadeInterface.create_[serial|ble]() as jade: +# ... +# or: +# c) use JadeInterface.create_[serial|ble], then call connect() before +# using, and disconnect() when finished +# (caveat cranium) +# or: +# d) use ctor to wrap existing low-level implementation instance +# (caveat cranium) +# +class JadeInterface: + def __init__(self, impl): + assert impl is not None + self.impl = impl + + def __enter__(self): + self.connect() + return self + + def __exit__(self, exc_type, exc, tb): + if exc_type: + logger.error("Exception causing JadeInterface context exit.") + logger.error(exc_type) + logger.error(exc) + traceback.print_tb(tb) + self.disconnect(exc_type is not None) + + @staticmethod + def create_serial(device=None, baud=None, timeout=None): + impl = JadeSerialImpl( + device or DEFAULT_SERIAL_DEVICE, + baud or DEFAULT_BAUD_RATE, + timeout or DEFAULT_SERIAL_TIMEOUT, + ) + return JadeInterface(impl) + + @staticmethod + def create_ble(device_name=None, serial_number=None, scan_timeout=None, loop=None): + impl = JadeBleImpl( + device_name or DEFAULT_BLE_DEVICE_NAME, + serial_number or DEFAULT_BLE_SERIAL_NUMBER, + scan_timeout or DEFAULT_BLE_SCAN_TIMEOUT, + loop=loop, + ) + return JadeInterface(impl) + + def connect(self): + self.impl.connect() + + def disconnect(self, drain=False): + if drain: + self.drain() + + self.impl.disconnect() + + def drain(self): + logger.warn("Draining interface...") + drained = bytearray() + finished = False + + while not finished: + byte_ = self.impl.read(1) + drained.extend(byte_) + finished = byte_ == b"" + + if finished or byte_ == b"\n" or len(drained) > 256: + try: + device_logger.warn(drained.decode("utf-8")) + except Exception as e: + # Dump the bytes raw and as hex if decoding as utf-8 failed + device_logger.warn("Raw:") + device_logger.warn(drained) + device_logger.warn("----") + device_logger.warn("Hex dump:") + device_logger.warn(drained.hex()) + + # Clear and loop to continue collecting + drained.clear() + + @staticmethod + def build_request(input_id, method, params=None): + request = {"method": method, "id": input_id} + if params is not None: + request["params"] = params + return request + + @staticmethod + def serialise_cbor_request(request): + dump = cbor.dumps(request) + len_dump = len(dump) + if "method" in request and "ota_data" in request["method"]: + msg = "Sending ota_data message {} as cbor of size {}".format( + request["id"], len_dump + ) + logger.info(msg) + else: + logger.info("Sending: {} as cbor of size {}".format(request, len_dump)) + return dump + + def write(self, bytes_): + logger.debug("Sending: {} bytes".format(len(bytes_))) + wrote = self.impl.write(bytes_) + logger.debug("Sent: {} bytes".format(len(bytes_))) + return wrote + + def write_request(self, request): + msg = self.serialise_cbor_request(request) + written = 0 + while written < len(msg): + written += self.write(msg[written:]) + + def read(self, n): + logger.debug("Reading {} bytes...".format(n)) + bytes_ = self.impl.read(n) + logger.debug("Received: {} bytes".format(len(bytes_))) + return bytes_ + + def read_cbor_message(self): + while True: + # 'self' is sufficiently 'file-like' to act as a load source. + # Throws EOFError on end of stream/timeout/lost-connection etc. + message = cbor.load(self) + + # A message response (to a prior request) + if "id" in message: + logger.info("Received msg: {}".format(message)) + return message + + # A log message - handle as normal + if "log" in message: + response = message["log"].decode("utf-8") + log_methods = { + "E": device_logger.error, + "W": device_logger.warn, + "I": device_logger.info, + "D": device_logger.debug, + "V": device_logger.debug, + } + log_method = device_logger.error + if len(response) > 1 and response[1] == " ": + lvl = response[0] + log_method = log_methods.get(lvl, device_logger.error) + + log_method(">> {}".format(response)) + else: + # Unknown/unhandled/unexpected message + logger.error("Unhandled message received") + device_logger.error(message) + + def read_response(self, long_timeout=False): + while True: + try: + return self.read_cbor_message() + except EOFError as e: + if not long_timeout: + raise + + @staticmethod + def validate_reply(request, reply): + assert isinstance(reply, dict) and "id" in reply + assert ("result" in reply) != ("error" in reply) + assert reply["id"] == request["id"] or reply["id"] == "00" and "error" in reply + + def make_rpc_call(self, request, long_timeout=False): + # Write outgoing request message + assert isinstance(request, dict) + assert "id" in request and len(request["id"]) > 0 + assert "method" in request and len(request["method"]) > 0 + assert len(request["id"]) < 16 and len(request["method"]) < 32 + self.write_request(request) + + # Read and validate incoming message + reply = self.read_response(long_timeout) + self.validate_reply(request, reply) + + return reply diff --git a/src/cryptoadvance/specter/devices/hwi/jadepy/jade_error.py b/src/cryptoadvance/specter/devices/hwi/jadepy/jade_error.py new file mode 100644 index 000000000..d2d97ea4a --- /dev/null +++ b/src/cryptoadvance/specter/devices/hwi/jadepy/jade_error.py @@ -0,0 +1,19 @@ +class JadeError(Exception): + def __init__(self, code, message, data): + self.code = code + self.message = message + self.data = data + + def __repr__(self): + return ( + "JadeError: " + + str(self.code) + + " - " + + self.message + + " (Data: " + + repr(self.data) + + ")" + ) + + def __str__(self): + return repr(self) diff --git a/src/cryptoadvance/specter/devices/hwi/jadepy/jade_serial.py b/src/cryptoadvance/specter/devices/hwi/jadepy/jade_serial.py new file mode 100644 index 000000000..c241a3770 --- /dev/null +++ b/src/cryptoadvance/specter/devices/hwi/jadepy/jade_serial.py @@ -0,0 +1,52 @@ +import serial +import logging + + +logger = logging.getLogger("jade.serial") + + +# +# Low-level Serial backend interface to Jade +# Calls to send and receive bytes over the interface. +# Intended for use via JadeInterface wrapper. +# +# Either: +# a) use via JadeInterface.create_serial() (see JadeInterface) +# (recommended) +# or: +# b) use JadeSerialImpl() directly, and call connect() before +# using, and disconnect() when finished, +# (caveat cranium) +# +class JadeSerialImpl: + def __init__(self, device, baud, timeout): + self.device = device + self.baud = baud + self.timeout = timeout + self.ser = None + + def connect(self): + assert self.ser is None + + logger.info("Connecting to {} at {}".format(self.device, self.baud)) + self.ser = serial.Serial( + self.device, self.baud, timeout=self.timeout, write_timeout=self.timeout + ) + assert self.ser is not None + self.ser.__enter__() + logger.info("Connected") + + def disconnect(self): + if self.ser is not None: + self.ser.__exit__() + + # Reset state + self.ser = None + + def write(self, bytes_): + assert self.ser is not None + return self.ser.write(bytes_) + + def read(self, n): + assert self.ser is not None + return self.ser.read(n) diff --git a/src/cryptoadvance/specter/devices/jade.py b/src/cryptoadvance/specter/devices/jade.py new file mode 100644 index 000000000..126ed7414 --- /dev/null +++ b/src/cryptoadvance/specter/devices/jade.py @@ -0,0 +1,29 @@ +from .hwi_device import HWIDevice +from .hwi.jade import JadeClient, enumerate + + +class Jade(HWIDevice): + device_type = "jade" + name = "Jade" + icon = "jade_icon.svg" + + supports_hwi_toggle_passphrase = False + supports_hwi_multisig_display_address = False + + @classmethod + def get_client(cls, *args, **kwargs): + return JadeClient(*args, **kwargs) + + @classmethod + def enumerate(cls, *args, **kwargs): + return enumerate(*args, **kwargs) + + def has_key_types(self, wallet_type, network="main"): + if wallet_type == "multisig": + return False + return super().has_key_types(wallet_type, network) + + def no_key_found_reason(self, wallet_type, network="main"): + if wallet_type == "multisig": + return "Jade does not yet support multisig wallets." + return super().no_key_found_reason(wallet_type, network) diff --git a/src/cryptoadvance/specter/hwi_rpc.py b/src/cryptoadvance/specter/hwi_rpc.py index c205c18a9..8c9868e80 100644 --- a/src/cryptoadvance/specter/hwi_rpc.py +++ b/src/cryptoadvance/specter/hwi_rpc.py @@ -17,6 +17,7 @@ from flask import current_app as app from .helpers import deep_update, hwi_get_config, save_hwi_bridge_config from hwilib.devices.bitbox02 import Bitbox02Client from .devices.hwi.specter_diy import SpecterClient +from .devices.hwi.jade import JadeClient logger = logging.getLogger(__name__) @@ -417,6 +418,9 @@ class HWIBridge(JSONRPC): if chain == "liquidv1": chain = "main" client.chain = Chain.argparse(chain) + if type(client) is JadeClient: + if chain == "signet": + client.chain = Chain.TEST yield client finally: client.close() diff --git a/src/cryptoadvance/specter/managers/device_manager.py b/src/cryptoadvance/specter/managers/device_manager.py index 397423506..5e9d06b47 100644 --- a/src/cryptoadvance/specter/managers/device_manager.py +++ b/src/cryptoadvance/specter/managers/device_manager.py @@ -104,6 +104,7 @@ class DeviceManager: device_class for device_class in device_classes if device_class.device_type != "bitcoincore" + and device_class.device_type != "elementscore" ] elif specter.is_liquid: return [ diff --git a/src/cryptoadvance/specter/static/img/devices/jade_icon.svg b/src/cryptoadvance/specter/static/img/devices/jade_icon.svg new file mode 100644 index 000000000..97125d704 --- /dev/null +++ b/src/cryptoadvance/specter/static/img/devices/jade_icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/udev/55-usb-jade.rules b/udev/55-usb-jade.rules new file mode 100644 index 000000000..406f69e62 --- /dev/null +++ b/udev/55-usb-jade.rules @@ -0,0 +1 @@ +KERNEL=="ttyUSB*", SUBSYSTEMS=="usb", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", MODE="0660", GROUP="plugdev", TAG+="uaccess", TAG+="udev-acl", SYMLINK+="jade%n" diff --git a/udev/README.md b/udev/README.md index 34918d352..56b3b243f 100644 --- a/udev/README.md +++ b/udev/README.md @@ -21,4 +21,4 @@ $ sudo udevadm trigger $ sudo udevadm control --reload-rules $ sudo groupadd plugdev $ sudo usermod -aG plugdev `whoami` -``` \ No newline at end of file +```