diff --git a/electrum/plugins/jade/jadepy/README.md b/electrum/plugins/jade/jadepy/README.md index c5251db50..7848c18b9 100644 --- a/electrum/plugins/jade/jadepy/README.md +++ b/electrum/plugins/jade/jadepy/README.md @@ -2,14 +2,10 @@ This is a slightly modified version of the official [Jade](https://github.com/Blockstream/Jade) python library. -This modified version was made from tag [0.1.32](https://github.com/Blockstream/Jade/releases/tag/0.1.32). +This modified version was made from tag [0.1.37](https://github.com/Blockstream/Jade/releases/tag/0.1.37). Intention is to fold these modifications back into Jade repo, for future api release. ## Changes - - Removed BLE module, reducing transitive dependencies -- Comment create_ble() functions -- More robust 'read_cbor_respose()' function - backported from jade master -- Tweak jade_serial.py to unset RTS line - backported from jade master -- _http_request() function removed, so cannot be used as unintentional fallback \ No newline at end of file +- _http_request() function removed, so cannot be used as unintentional fallback diff --git a/electrum/plugins/jade/jadepy/__init__.py b/electrum/plugins/jade/jadepy/__init__.py index 5079bd9b7..64e2ceb7e 100644 --- a/electrum/plugins/jade/jadepy/__init__.py +++ b/electrum/plugins/jade/jadepy/__init__.py @@ -1,4 +1,4 @@ from .jade import JadeAPI from .jade_error import JadeError -__version__ = "0.0.1" +__version__ = "0.2.0" diff --git a/electrum/plugins/jade/jadepy/jade.py b/electrum/plugins/jade/jadepy/jade.py index 495d934dc..4b31f06e3 100644 --- a/electrum/plugins/jade/jadepy/jade.py +++ b/electrum/plugins/jade/jadepy/jade.py @@ -9,7 +9,6 @@ import traceback import random import sys - # JadeError from .jade_error import JadeError @@ -17,9 +16,17 @@ from .jade_error import JadeError from .jade_serial import JadeSerialImpl from .jade_tcp import JadeTCPImpl -# Not used in electrum wallet -# Removed to reduce transitive dependencies -# from .jade_ble import JadeBleImpl +# 'jade' logger +logger = logging.getLogger('jade') +device_logger = logging.getLogger('jade-device') + +# BLE comms backend is optional +# It relies on the BLE dependencies being available +try: + from .jade_ble import JadeBleImpl +except ImportError as e: + logger.warn(e) + logger.warn('BLE scanning/connectivity will not be available') # Default serial connection @@ -32,14 +39,20 @@ 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') - -# Helper to map bytes-like types into hex-strings -# to make for prettier message-logging def _hexlify(data): + """ + Helper to map bytes-like types into hex-strings + to make for prettier message-logging. + + Parameters + ---------- + data : any + The object to hexlify. + - bytes or bytearrays have 'hex()' method invoked + - list and dicts (values) have this function mapped over them + - Otherwise the input is returned unchanged + """ if data is None: return None elif isinstance(data, bytes) or isinstance(data, bytearray): @@ -51,20 +64,32 @@ def _hexlify(data): else: return data - -# Simple http request function which can be used when a Jade response -# requires an external http call. -# The default implementation used in JadeAPI._jadeRpc() below. -# NOTE: Only available if the 'requests' dependency is available. - # NOTE: Removed entirely for electrum - so it is not used silently as a fallback. # (hard error preferred in that case) # Jade repo api will be improved to make enabling this function more explicit - # try: # import requests # # def _http_request(params): +# """ +# Simple http request function which can be used when a Jade response +# requires an external http call. +# The default implementation used in JadeAPI._jadeRpc() below. +# NOTE: Only available if the 'requests' dependency is available. +# +# Callers can supply their own implmentation of this call where it is required. +# +# Parameters +# ---------- +# data : dict +# A dictionary structure describing the http call to make +# +# Returns +# ------- +# dict +# with single key 'body', whose value is the json returned from the call +# +# """ # logger.debug('_http_request: {}'.format(params)) # # # Use the first non-onion url @@ -92,22 +117,23 @@ def _hexlify(data): # logger.warn('Default _http_requests() function will not be available') # -# -# 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: + """ + 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) + """ + def __init__(self, jade): assert jade is not None self.jade = jade @@ -126,39 +152,168 @@ class JadeAPI: @staticmethod def create_serial(device=None, baud=None, timeout=None): + """ + Create a JadeAPI object using the serial interface described. + + Parameters + ---------- + device : str, optional + The device identifier for the serial device. + Underlying implementation will default (to /dev/ttyUSB0) + + baud : int, optional + The communication baud rate. + Underlying implementation will default (to 115200) + + timeout : int, optional + The serial read timeout when awaiting messages. + Underlying implementation will default (to 120s) + + Returns + ------- + JadeAPI + API object configured to use given serial parameters. + NOTE: the api instance has not yet tried to contact the hw + - caller must call 'connect()' before trying to use the Jade. + """ 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) + @staticmethod + def create_ble(device_name=None, serial_number=None, + scan_timeout=None, loop=None): + """ + Create a JadeAPI object using the BLE interface described. + NOTE: raises JadeError if BLE dependencies not installed. + + Parameters + ---------- + device_name : str, optional + The device name of the desired BLE device. + Underlying implementation will default (to 'Jade') + + serial_number : int, optional + The serial number of the desired BLE device + - used to disambiguate multiple beacons with the same 'device name' + Underlying implementation will connect to the first beacon it scans + with the matching 'device name'. + + scan_timeout : int, optional + The timeout when scanning for devices which match the device name/serial number. + Underlying implementation will default (to 60s) + + loop : optional + The asynchio event loop to use, if required. + Underlying implementation will default (to asyncio.get_event_loop()) + + Returns + ------- + JadeAPI + API object configured to use given BLE parameters. + NOTE: the api instance has not yet tried to contact the hw + - caller must call 'connect()' before trying to use the Jade. + + Raises + ------ + JadeError if BLE backend not available (ie. BLE dependencies not installed) + """ + impl = JadeInterface.create_ble(device_name, serial_number, + scan_timeout, loop) + return JadeAPI(impl) - # Connect underlying interface def connect(self): + """ + Try to connect the underlying transport interface (eg. serial, ble, etc.) + Raises an exception on failure. + """ self.jade.connect() - # Disconnect underlying interface def disconnect(self, drain=False): + """ + Disconnect the underlying transport (eg. serial, ble, etc.) + + Parameters + ---------- + drain : bool, optional + When true log any/all remaining messages/data, otherwise silently discard. + NOTE: can prevent disconnection if data is arriving constantly. + Defaults to False. + """ self.jade.disconnect(drain) - # Drain all output from the interface def drain(self): + """ + Log any/all outstanding messages/data. + NOTE: can run indefinitely if data is arriving constantly. + """ self.jade.drain() - # Raise any returned error as an exception @staticmethod def _get_result_or_raise_error(reply): + """ + Raise any error message returned from a Jade rpc call as an exception. + + Parameters + ---------- + reply : dict + Dictionary representing a reply from a Jade rpc call. + + Returns + ------- + dict + Any nested 'result' structure, if the reply is not an error. + + Raises + ------ + JadeError + If the reply represented an error, including all details received. + """ 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): + """ + Helper to make a request/reply rpc call over the underlying transport interface. + NOTE: interface must be 'connected'. + + If the call returns an 'http_request' structure, this is handled here and the http + call is made, and the result is passed into the rpc method given in 'on reply', by + calling this function recursively. + + Parameters + ---------- + method : str + rpc method to invoke + + params : dict, optional + any parameters to pass to the rpc method + Defaults to None. + + inputid : str, optional + Any specific 'id' to use in the rpc message. + Defaults to a using a pseudo-random id generated in-situ. + + http_request_fn : function, optional + A function which accepts a dict (containing a description of the http request), makes + the described http call, and returns the body data in an element called 'body'. + Defaults to _http_request() above. + + long_timeout : bool, optional + Whether the rpc call should use an indefinitely long timeout, rather than that set on + construction. + (Useful if the call involves a non-trivial user interaction with the device.) + Defaults to False. + + Returns + ------- + dict + The reply from the rpc call. + NOTE: will return the last/final reply after a sequence of calls, where 'http_request' + was returned and remote data was fetched and passed into s subsequent call. + """ 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) @@ -185,29 +340,105 @@ class JadeAPI: return result - # Get version information from the hw def get_version_info(self): + """ + RPC call to fetch summary details pertaining to the hardware unit and running firmware. + + Returns + ------- + dict + Contains keys for various info describing the hw and running fw + """ return self._jadeRpc('get_version_info') - # Add client entropy to the hw rng def add_entropy(self, entropy): + """ + RPC call to add client entropy into the unit RNG entropy pool. + + Parameters + ---------- + entropy : bytes + Bytes to fold into the hw entropy pool. + + Returns + ------- + bool + True on success + """ params = {'entropy': entropy} return self._jadeRpc('add_entropy', params) - # OTA new firmware - def ota_update(self, fwcmp, fwlen, chunksize, cb): - + def set_epoch(self, epoch=None): + """ + RPC call to set the current time epoch value, required for TOTP use. + NOTE: The time is lost on each power-down and must be reset on restart/reconnect before + TOTP can be used. + + Parameters + ---------- + epoch : int, optional + Current epoch value, in seconds. Defaults to int(time.time()) value. + + Returns + ------- + bool + True on success + """ + params = {'epoch': epoch if epoch is not None else int(time.time())} + return self._jadeRpc('set_epoch', params) + + def ota_update(self, fwcmp, fwlen, chunksize, patchlen=None, cb=None): + """ + RPC call to attempt to update the unit's firmware. + + Parameters + ---------- + fwcmp : bytes + The compressed firmware image to upload to the Jade unit. Can be a full firmware or + and incremental diff to be applied to the currently running firmware image. + fwlen : int + The size of the new complete (uncompressed) firmware image (after any delta is applied). + chunksize : int + The size of the chunks used to upload the compressed firmware. Each chunk is uploaded + and ack'd by the hw unit. + The maximum supported chunk size is given in the version info data, under the key + 'JADE_OTA_MAX_CHUNK'. + patchlen: int, optional + If the compressed firmware bytes are an incremental diff to be applied to the running + firmware image, this is the size of that patch when uncompressed. + Defaults to None, implying the compressed data is a full firmware image upload. + (Compare with fwlen - the size of the final fw image.) + cb : function, optional + Callback function accepting two integers - the amount of compressed firmware sent thus + far, and the total length of the compressed firmware to send. + If passed, this function is invoked each time a fw chunk is successfully uploaded and + ack'd by the hw, to notify of upload progress. + Defaults to None, and nothing is called to report upload progress. + + Returns + ------- + bool + True if no errors were reported - on next restart the hw unit will attempt to boot the + new firmware. + """ + + # Compute the sha256 hash of the compressed file being uploaded cmphasher = hashlib.sha256() cmphasher.update(fwcmp) cmphash = cmphasher.digest() cmplen = len(fwcmp) # Initiate OTA + ota_method = 'ota' params = {'fwsize': fwlen, 'cmpsize': cmplen, 'cmphash': cmphash} - result = self._jadeRpc('ota', params) + if patchlen is not None: + ota_method = 'ota_delta' + params['patchsize'] = patchlen + + result = self._jadeRpc(ota_method, params) assert result is True # Write binary chunks @@ -226,23 +457,104 @@ class JadeAPI: # All binary data uploaded return self._jadeRpc('ota_complete') - # Run (debug) healthcheck on the hw def run_remote_selfcheck(self): + """ + RPC call to run in-built tests. + NOTE: Only available in a DEBUG build of the firmware. + + Returns + ------- + bool + True on success. + """ return self._jadeRpc('debug_selfcheck', long_timeout=True) - # Set the (debug) mnemonic + def clean_reset(self): + """ + RPC call to clean/reset memory and storage, as much as is practical. + NOTE: Only available in a DEBUG build of the firmware. + + Returns + ------- + bool + True on success. + """ + return self._jadeRpc('debug_clean_reset') + def set_mnemonic(self, mnemonic, passphrase=None, temporary_wallet=False): + """ + RPC call to set the wallet mnemonic (in RAM only - flash storage is untouched). + NOTE: Only available in a DEBUG build of the firmware. + + Parameters + ---------- + mnemonic : str + The wallet mnemonic to set. + + passphrase : str, optional + Any bip39 passphrase to apply. + Defaults to None. + + temporary_wallet : bool, optional + Whether to treat this wallet/mnemonic as an 'Emergency Restore' temporary wallet, as + opposed to one successfully loaded from the flash storage. + NOTE: in either case the wallet is only set in RAM, and flash storage is not affected. + Defaults to False. + + Returns + ------- + bool + True on success. + """ params = {'mnemonic': mnemonic, 'passphrase': passphrase, 'temporary_wallet': temporary_wallet} return self._jadeRpc('debug_set_mnemonic', params) - # Set the (debug) seed - def set_seed(self, seed, temporary_wallet=False): - params = {'seed': seed, 'temporary_wallet': temporary_wallet} + def set_seed(self, seed): + """ + RPC call to set the wallet seed. + NOTE: Only available in a DEBUG build of the firmware. + NOTE: Setting a seed always sets a 'temporary' wallet. + + Parameters + ---------- + seed : bytes + The wallet seed to set as a temporary wallet (cannot be persisted in flash). + + Returns + ------- + bool + True on success. + """ + params = {'seed': seed} return self._jadeRpc('debug_set_mnemonic', params) - # Override the pinserver details on the hww def set_pinserver(self, urlA=None, urlB=None, pubkey=None, cert=None): + """ + RPC call to explicitly set (override) the details of the blind pinserver used to + authenticate the PIN entered on the Jade unit. + This data is recorded in the hw flash, and returned to the caller when authenticating + (in auth_user(), below). + + Parameters + ---------- + urlA : str, optional + The primary url of the pinserver to use. + + urlB : str, optional + Any secondary url of the pinserver to use. + + pubkey : bytes, optional + The public key used to verify pinserver signed payloads. + + cert : bytes, optional + Any additional certificate required to verify the pinserver identity. + + Returns + ------- + bool + True on success. + """ params = {} if urlA is not None or urlB is not None: params['urlA'] = urlA @@ -253,39 +565,242 @@ class JadeAPI: params['certificate'] = cert return self._jadeRpc('update_pinserver', params) - # Reset the pinserver details on the hww to their defaults def reset_pinserver(self, reset_details, reset_certificate): + """ + RPC call to reset any formerly overidden pinserver details to their defauts. + + Parameters + ---------- + reset_details : bool, optional + If set, any overidden urls and pubkey are reset to their defaults. + + reset_certificate : bool, optional + If set, any additional certificate is reset (to None). + + Returns + ------- + bool + True on success. + """ params = {'reset_details': reset_details, 'reset_certificate': reset_certificate} return self._jadeRpc('update_pinserver', params) - # Trigger user authentication on the hw - # Involves pinserver handshake - def auth_user(self, network, http_request_fn=None): - params = {'network': network} + def auth_user(self, network, http_request_fn=None, epoch=None): + """ + RPC call to authenticate the user on the hw device, for using with the network provided. + + Parameters + ---------- + network : str + The name of the network intended for use - eg. 'mainnet', 'liquid', 'testnet' etc. + This is verified against the networks allowed on the hardware. + + http_request_fn : function, optional + Optional http-request function to pass http requests to the Jade pinserver. + Default behaviour is to use the '_http_request()' function which defers to the + 'requests' module. + If the 'reqests' module is not available, no default http-request function is created, + and one must be supplied here. + + epoch : int, optional + Current epoch value, in seconds. Defaults to int(time.time()) value. + + Returns + ------- + bool + True is returned immediately if the hw is already unlocked for use on the given network. + True if the PIN is entered and verified with the remote blind pinserver. + False if the PIN entered was incorrect. + """ + params = {'network': network, 'epoch': epoch if epoch is not None else int(time.time())} return self._jadeRpc('auth_user', params, http_request_fn=http_request_fn, long_timeout=True) - # Get xpub given a path + def register_otp(self, otp_name, otp_uri): + """ + RPC call to register a new OTP record on the hw device. + + Parameters + ---------- + otp_name : str + An identifying name for this OTP record + + otp_uri : str + The uri of this OTP record - must begin 'otpauth://' + + Returns + ------- + bool + True if the OTP uri was validated and persisted on the hw + """ + params = {'name': otp_name, 'uri': otp_uri} + return self._jadeRpc('register_otp', params) + + def get_otp_code(self, otp_name, value_override=None): + """ + RPC call to fetch a new OTP code from the hw device. + + Parameters + ---------- + otp_name : str + An identifying name for the OTP record to use + + value_override : int + An overriding HOTP counter or TOTP timestamp to use. + NOTE: Only available in a DEBUG build of the firmware. + + Returns + ------- + bool + True if the OTP uri was validated and persisted on the hw + """ + params = {'name': otp_name} + if value_override is not None: + params['override'] = value_override + return self._jadeRpc('get_otp_code', params) + def get_xpub(self, network, path): + """ + RPC call to fetch an xpub for the given bip32 path for the given network. + + Parameters + ---------- + network : str + Network to which the xpub applies - eg. 'mainnet', 'liquid', 'testnet', etc. + + path : [int] + bip32 path for which the xpub should be generated. + + Returns + ------- + str + base58 encoded xpub + """ params = {'network': network, 'path': path} return self._jadeRpc('get_xpub', params) - # Get registered multisig wallets def get_registered_multisigs(self): + """ + RPC call to fetch brief summaries of any multisig wallets registered to this signer. + + Returns + ------- + dict + Brief description of registered multisigs, keyed by registration name. + Each entry contains keys: + variant - str, script type, eg. 'sh(wsh(multi(k)))' + sorted - boolean, whether bip67 key sorting is applied + threshold - int, number of signers required,N + num_signers - total number of signatories, M + master_blinding_key - 32-bytes, any liquid master blinding key for this wallet + """ return self._jadeRpc('get_registered_multisigs') - # Register a multisig wallet - def register_multisig(self, network, multisig_name, variant, sorted_keys, threshold, signers): + def register_multisig(self, network, multisig_name, variant, sorted_keys, threshold, signers, + master_blinding_key=None): + """ + RPC call to register a new multisig wallet, which must contain the hw signer. + A registration name is provided - if it already exists that record is overwritten. + + Parameters + ---------- + network : string + Network to which the multisig should apply - eg. 'mainnet', 'liquid', 'testnet', etc. + + multisig_name : string + Name to use to identify this multisig wallet registration record. + If a registration record exists with the name given, that record is overwritten. + + variant : str + The script type - one of 'sh(multi(k))', 'wsh(multi(k))', 'sh(wsh(multi(k)))' + + sorted_keys : bool + Whether this is a 'sortedmulti()' wallet - ie. whether to apply bip67 sorting to the + pubkeys when generating redeem scripts. + + threshold : int + Number of signers required. + + signers : [dict] + Description of signers - should include keys: + - 'fingerprint' - 4 bytes, origin fingerprint + - 'derivation' - [int], bip32 path from origin to signer xpub provided + - 'xpub' - str, base58 xpub of signer - will be verified for hw unit signer + - 'path' - [int], any fixed path to always apply after the xpub - usually empty. + + master_blinding_key : 32-bytes, optional + The master blinding key to use for this multisig wallet on liquid. + Optional, defaults to None. + Logically mandatory when 'network' indicates a liquid network and the Jade is to be + used to generate confidential addresses, blinding keys, blinding nonces, asset blinding + factors or output commitments. + + Returns + ------- + bool + True on success, implying the mutisig wallet can now be used. + """ params = {'network': network, 'multisig_name': multisig_name, 'descriptor': {'variant': variant, 'sorted': sorted_keys, - 'threshold': threshold, 'signers': signers}} + 'threshold': threshold, 'signers': signers, + 'master_blinding_key': master_blinding_key}} return self._jadeRpc('register_multisig', params) - # Get receive-address for parameters def get_receive_address(self, *args, recovery_xpub=None, csv_blocks=0, - variant=None, multisig_name=None): + variant=None, multisig_name=None, confidential=None): + """ + RPC call to generate, show, and return an address for the given path. + The call has three forms. + + Parameters + ---------- + network: str + Network to which the address should apply - eg. 'mainnet', 'liquid', 'testnet', etc. + + Then either: + + 1. Blockstream Green (multisig shield) addresses + subaccount : int + Blockstream Green subaccount + + branch : int + Blockstream Green derivation branch + + pointer : int + Blockstream Green address pointer + + recovery_xpub : str, optional + xpub of recovery key for 2of3 subaccounts. Otherwise should be omitted. + Defaults to None (ie. not a 2of3 subaccount). + + csv_blocks : int, optional + Number of blocks to include in csv redeem script, if this is a csv-enabled account. + Otherwise should be omitted. + Defaults to 0 (ie. does not apply/not a csv-enabled account.) + + 2. Generic single-sig addresses + path: [int] + bip32 path for which the xpub should be generated. + + variant: str + The script type - one of 'pkh(k)', 'wpkh(k)', 'sh(wpkh(k))' + + 3. Generic multisig addresses + paths: [[int]] + bip32 path suffixes, one for each signer, applied as a suffix to the registered + signer path. Usually these path suffixes will all be identical. + + multisig_name : str + The name of the registered multisig wallet record used to generate the address. + + Returns + ------- + str + The address generated for the given parameters. + + """ if multisig_name is not None: assert len(args) == 2 keys = ['network', 'paths', 'multisig_name'] @@ -298,11 +813,43 @@ class JadeAPI: 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 + params = dict(zip(keys, args)) + if confidential is not None: + params['confidential'] = confidential + + return self._jadeRpc('get_receive_address', params) + def sign_message(self, path, message, use_ae_signatures=False, ae_host_commitment=None, ae_host_entropy=None): + """ + RPC call to format and sign the given message, using the given bip32 path. + Supports RFC6979 and anti-exfil signatures. + + Parameters + ---------- + path : [int] + bip32 path for which the signature should be generated. + + message : str + Message string to format and sign. + + ae_host_commitment : 32-bytes, optional + The host-commitment to use for Antil-Exfil signatures + + ae_host_entropy : 32-bytes, optional + The host-entropy to use for Antil-Exfil signatures + + Returns + ------- + 1. Legacy/RFC6979 signatures + str + base64-encoded signature + + 2. Anti-exfil signatures + (bytes, str) + signer-commitment, base64-encoded signature + """ if use_ae_signatures: # Anti-exfil protocol: # We send the signing request and receive the signer-commitment in @@ -318,65 +865,290 @@ class JadeAPI: params = {'path': path, 'message': message} return self._jadeRpc('sign_message', params) - # Get a Liquid master blinding key + def get_identity_pubkey(self, identity, curve, key_type, index=0): + """ + RPC call to fetch a pubkey for the given identity (slip13/slip17). + NOTE: this api returns an uncompressed public key + + Parameters + ---------- + identity : str + Identity string to format and sign. For example ssh://satoshi@bitcoin.org + + curve : str + Name of curve to use - currently only 'nist256p1' is supported + + key_type : str + Key derivation type - must be either 'slip-0013' for an identity pubkey, or 'slip-0017' + for an ecdh pubkey. + + index : int, optional + Index number (if require multiple keys/sigs per identity) + Defaults to 0 + + Returns + ------- + 65-bytes + Uncompressed public key for the given identity and index. + Consistent with 'sign_identity' or 'get_identity_shared_key', depending on the + 'key_type'. + + """ + params = {'identity': identity, 'curve': curve, 'type': key_type, 'index': index} + return self._jadeRpc('get_identity_pubkey', params) + + def get_identity_shared_key(self, identity, curve, their_pubkey, index=0): + """ + RPC call to fetch a SLIP-0017 shared ecdh key for the identity and counterparty public key. + NOTE: this api takes an uncompressed public key + + Parameters + ---------- + identity : str + Identity string to format and sign. For example ssh://satoshi@bitcoin.org + + curve : str + Name of curve to use - currently only 'nist256p1' is supported + + their_pubkey : 65-bytes + The counterparty's uncompressed public key + + index : int, optional + Index number (if require multiple keys/sigs per identity) + Defaults to 0 + + Returns + ------- + 32-bytes + The shared ecdh key for the given identity and cpty public key + Consistent with 'get_identity_pubkey' with 'key_type=slip-0017' + """ + params = {'identity': identity, 'curve': curve, 'index': index, + 'their_pubkey': their_pubkey} + return self._jadeRpc('get_identity_shared_key', params) + + def sign_identity(self, identity, curve, challenge, index=0): + """ + RPC call to authenticate the given identity through a challenge. + Supports RFC6979. + Returns the signature and the associated SLIP-0013 pubkey + NOTE: this api returns an uncompressed public key + + Parameters + ---------- + identity : str + Identity string to format and sign. For example ssh://satoshi@bitcoin.org + + curve : str + Name of curve to use - currently only 'nist256p1' is supported + + challenge : bytes + Challenge bytes to sign + + index : int, optional + Index number (if require multiple keys/sigs per identity) + Defaults to 0 + + Returns + ------- + dict + Contains keys: + pubkey - 65-bytes, the uncompressed SLIP-0013 public key, consistent with + 'get_identity_pubkey' with 'key_type=slip-0013' + signature - 65-bytes, RFC6979 deterministic signature, prefixed with 0x00 + """ + params = {'identity': identity, 'curve': curve, 'index': index, 'challenge': challenge} + return self._jadeRpc('sign_identity', params) + def get_master_blinding_key(self): + """ + RPC call to fetch the master (SLIP-077) blinding key for the hw signer. + NOTE: the master blinding key of any registered multisig wallets can be obtained from + the result of `get_registered_multisigs()`. + + Returns + ------- + 32-bytes + SLIP-077 master blinding key + """ return self._jadeRpc('get_master_blinding_key') - # Get a Liquid public blinding key for a given script - def get_blinding_key(self, script): - params = {'script': script} + def get_blinding_key(self, script, multisig_name=None): + """ + RPC call to fetch the public blinding key for the hw signer. + + Parameters + ---------- + script : bytes + The script for which the public blinding key is required. + + multisig_name : str, optional + The name of any registered multisig wallet for which to fetch the blinding key. + Defaults to None + + Returns + ------- + 33-bytes + Public blinding key for the passed script. + """ + params = {'script': script, 'multisig_name': multisig_name} 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). Optionally fetch our blinding pubkey also. - def get_shared_nonce(self, script, their_pubkey, include_pubkey=False): - params = {'script': script, 'their_pubkey': their_pubkey, 'include_pubkey': include_pubkey} + def get_shared_nonce(self, script, their_pubkey, include_pubkey=False, multisig_name=None): + """ + RPC call to get the shared secret to unblind a tx, given the receiving script and + the pubkey of the sender (sometimes called "blinding nonce" in Liquid). + Optionally fetch the hw signer's public blinding key also. + + Parameters + ---------- + script : bytes + The script for which the blinding nonce is required. + + their_pubkey : 33-bytes + The counterparty public key. + + include_pubkey : bool, optional + Whether to also return the wallet's public blinding key. + Defaults to False. + + multisig_name : str, optional + The name of any registered multisig wallet for which to fetch the blinding nonce. + Defaults to None + + Returns + ------- + 1. include_pubkey is False + 33-bytes + Public blinding nonce for the passed script and counterparty public key. + + 2. include_pubkey is True + dict + Contains keys: + shared_nonce - 32-bytes, public blinding nonce for the passed script as above. + blinding_key - 33-bytes, public blinding key for the passed script. + """ + params = {'script': script, 'their_pubkey': their_pubkey, + 'include_pubkey': include_pubkey, 'multisig_name': multisig_name} 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): + def get_blinding_factor(self, hash_prevouts, output_index, bftype, multisig_name=None): + """ + RPC call to get a deterministic "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, 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, hence the "bftype" + parameter. + + Parameters + ---------- + + hash_prevouts : 32-bytes + This value is computed as specified in bip143. + It is verified immediately since at this point Jade doesn't have the tx in question. + It will be checked later during `sign_liquid_tx()`. + + output_index : int + The index of the output we are trying to blind + + bftype : str + Can be eitehr "ASSET" or "VALUE", to generate abfs or vbfs. + + multisig_name : str, optional + The name of any registered multisig wallet for which to fetch the blinding factor. + Defaults to None + + Returns + ------- + 32-bytes + The requested blinding factor + """ params = {'hash_prevouts': hash_prevouts, 'output_index': output_index, - 'type': type} + 'type': bftype, + 'multisig_name': multisig_name} 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): + vbf=None, + multisig_name=None): + """ + RPC call to generate deterministic 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 + computed here, but generated on the host according to all the other values. + The commitments generated here should be passed back into `sign_liquid_tx()`. + + Parameters + ---------- + asset_id : 32-bytes + asset_id as usually displayed - ie. reversed compared to network/consensus order + + value : int + value in 'satoshi' or equivalent atomic integral unit + + hash_prevouts : 32-bytes + This value is computed as specified in bip143. + It is verified immediately since at this point Jade doesn't have the tx in question. + It will be checked later during `sign_liquid_tx()`. + + output_index : int + The index of the output we are trying to blind + + vbf : 32-bytes, optional + The vbf to use, in preference to deterministically generating one in this call. + + multisig_name : str, optional + The name of any registered multisig wallet for which to fetch the blinding factor. + Defaults to None + + Returns + ------- + dict + Containing the following the blinding factors and output commitments. + """ params = {'asset_id': asset_id, 'value': value, 'hash_prevouts': hash_prevouts, - 'output_index': output_index} - if vbf is not None: - params['vbf'] = vbf + 'output_index': output_index, + 'vbf': vbf, + 'multisig_name': multisig_name} return self._jadeRpc('get_commitments', params) - # Common code for sending btc- and liquid- tx-inputs and receiving the - # signatures. Handles standard EC and AE signing schemes. def _send_tx_inputs(self, base_id, inputs, use_ae_signatures): + """ + Helper call to send the tx inputs to Jade for signing. + Handles legacy RFC6979 signatures, as well as the Anti-Exfil protocol. + + Parameters + ---------- + base_id : int + The ids of the messages sent will be increments from this base id. + + inputs : [dict] + The tx inputs - see `sign_tx()` / `sign_liquid_tx()` for details. + + use_ae_signatures : bool + Whether to use the anti-exfil protocol to generate the signatures + + Returns + ------- + 1. if use_ae_signatures is False + [bytes] + An array of signatures corresponding to the array of inputs passed. + The signatures are in DER format with the sighash appended. + 'None' placeholder elements are used for inputs not requiring a signature. + + 2. if use_ae_signatures is True + [(32-bytes, bytes)] + An array of pairs of signer-commitments and signatures corresponding to the inputs. + The signatures are in DER format with the sighash appended. + (None, None) placeholder elements are used for inputs not requiring a signature. + """ if use_ae_signatures: # Anti-exfil protocol: # We send one message per input (which includes host-commitment *but @@ -439,8 +1211,72 @@ class JadeAPI: assert len(signatures) == len(inputs) return signatures - # Sign a Liquid txn - def sign_liquid_tx(self, network, txn, inputs, commitments, change, use_ae_signatures=False): + def sign_liquid_tx(self, network, txn, inputs, commitments, change, use_ae_signatures=False, + asset_info=None): + """ + RPC call to sign a liquid transaction. + + Parameters + ---------- + network : str + Network to which the address should apply - eg. 'liquid', 'liquid-testnet', etc. + + txn : bytes + The transaction to sign + + inputs : [dict] + The tx inputs. Should contain keys: + is_witness, bool - whether this is a segwit input + value_commitment, 33-bytes - The value commitment of ths input + + These are only required if signing this input: + script, bytes- the redeem script + path, [int] - the bip32 path to sign with + + These are only required for Anti-Exfil signatures: + ae_host_commitment, 32-bytes - The host-commitment for Anti-Exfil signatures + ae_host_entropy, 32-bytes - The host-entropy for Anti-Exfil signatures + + commitments : [dict] + An array sized for the number of outputs. + Unblinded outputs should have a 'null' placeholder element. + The commitments as retrieved from `get_commitments()`, with the addition of: + 'blinding_key', - the output's public blinding key + (as retrieved from `get_blinding_key()`) + + change : [dict] + An array sized for the number of outputs. + Outputs which are not change should have a 'null' placeholder element. + Change elements with data will be automatically verified by Jade, and not by the user. + Populated elements should contain sufficient data to generate the change address. + See `get_receive_address()` + + use_ae_signatures : bool, optional + Whether to use the anti-exfil protocol to generate the signatures. + Defaults to False. + + asset_info : [dict] + Any asset-registry data relevant to the assets being transacted, such that Jade can + display a meaningful name, issuer, ticker etc. rather than just asset-id. + At the very least must contain 'asset_id', 'contract' and 'issuance_prevout' items, + exactly as in the registry data. NOTE: asset_info for the network policy-asset is + not required. + Defaults to None. + + Returns + ------- + 1. if use_ae_signatures is False + [bytes] + An array of signatures corresponding to the array of inputs passed. + The signatures are in DER format with the sighash appended. + 'None' placeholder elements are used for inputs not requiring a signature. + + 2. if use_ae_signatures is True + [(32-bytes, bytes)] + An array of pairs of signer-commitments and signatures corresponding to the inputs. + The signatures are in DER format with the sighash appended. + (None, None) placeholder elements are used for inputs not requiring a signature. + """ # 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). base_id = 100 * random.randint(1000, 9999) @@ -449,7 +1285,8 @@ class JadeAPI: 'num_inputs': len(inputs), 'trusted_commitments': commitments, 'use_ae_signatures': use_ae_signatures, - 'change': change} + 'change': change, + 'asset_info': asset_info} reply = self._jadeRpc('sign_liquid_tx', params, str(base_id)) assert reply @@ -457,8 +1294,59 @@ class JadeAPI: # Send inputs and receive signatures return self._send_tx_inputs(base_id, inputs, use_ae_signatures) - # Sign a txn def sign_tx(self, network, txn, inputs, change, use_ae_signatures=False): + """ + RPC call to sign a btc transaction. + + Parameters + ---------- + network : str + Network to which the address should apply - eg. 'mainnet', 'testnet', etc. + + txn : bytes + The transaction to sign + + inputs : [dict] + The tx inputs. Should contain keys: + is_witness, bool - whether this is a segwit input + + These are only required if signing this input: + script, bytes- the redeem script + path, [int] - the bip32 path to sign with + + One of these is required: + input_tx, bytes - The prior transaction which created the utxo of this input + satoshi, int - The satoshi amount of this input - can be used in place of + 'input_tx' for a tx with a single segwit input + + These are only required for Anti-Exfil signatures: + ae_host_commitment, 32-bytes - The host-commitment for Anti-Exfil signatures + ae_host_entropy, 32-bytes - The host-entropy for Anti-Exfil signatures + + change : [dict] + An array sized for the number of outputs. + Outputs which are not change should have a 'null' placeholder element. + Change elements with data will be automatically verified by Jade, and not by the user. + Populated elements should contain sufficient data to generate the change address. + See `get_receive_address()` + + use_ae_signatures : bool + Whether to use the anti-exfil protocol to generate the signatures + + Returns + ------- + 1. if use_ae_signatures is False + [bytes] + An array of signatures corresponding to the array of inputs passed. + The signatures are in DER format with the sighash appended. + 'None' placeholder elements are used for inputs not requiring a signature. + + 2. if use_ae_signatures is True + [(32-bytes, bytes)] + An array of pairs of signer-commitments and signatures corresponding to the inputs. + The signatures are in DER format with the sighash appended. + (None, None) placeholder elements are used for inputs not requiring a signature. + """ # 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). base_id = 100 * random.randint(1000, 9999) @@ -475,26 +1363,27 @@ class JadeAPI: return self._send_tx_inputs(base_id, inputs, use_ae_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: + """ + 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) + """ + def __init__(self, impl): assert impl is not None self.impl = impl @@ -513,6 +1402,30 @@ class JadeInterface: @staticmethod def create_serial(device=None, baud=None, timeout=None): + """ + Create a JadeInterface object using the serial interface described. + + Parameters + ---------- + device : str, optional + The device identifier for the serial device. + Underlying implementation will default (to /dev/ttyUSB0) + + baud : int, optional + The communication baud rate. + Underlying implementation will default (to 115200) + + timeout : int, optional + The serial read timeout when awaiting messages. + Underlying implementation will default (to 120s) + + Returns + ------- + JadeInterface + Inerface object configured to use given serial parameters. + NOTE: the instance has not yet tried to contact the hw + - caller must call 'connect()' before trying to use the Jade. + """ if device and JadeTCPImpl.isSupportedDevice(device): impl = JadeTCPImpl(device) else: @@ -521,25 +1434,82 @@ class JadeInterface: 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) + @staticmethod + def create_ble(device_name=None, serial_number=None, + scan_timeout=None, loop=None): + """ + Create a JadeInterface object using the BLE interface described. + NOTE: raises JadeError if BLE dependencies not installed. + + Parameters + ---------- + device_name : str, optional + The device name of the desired BLE device. + Underlying implementation will default (to 'Jade') + + serial_number : int, optional + The serial number of the desired BLE device + - used to disambiguate multiple beacons with the same 'device name' + Underlying implementation will connect to the first beacon it scans + with the matching 'device name'. + + scan_timeout : int, optional + The timeout when scanning for devices which match the device name/serial number. + Underlying implementation will default (to 60s) + + loop : optional + The asynchio event loop to use, if required. + Underlying implementation will default (to asyncio.get_event_loop()) + + Returns + ------- + JadeInterface + Inerface object configured to use given BLE parameters. + NOTE: the instance has not yet tried to contact the hw + - caller must call 'connect()' before trying to use the Jade. + + Raises + ------ + JadeError if BLE backend not available (ie. BLE dependencies not installed) + """ + this_module = sys.modules[__name__] + if not hasattr(this_module, "JadeBleImpl"): + raise JadeError(1, "BLE support not installed", 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): + """ + Try to connect the underlying transport interface (eg. serial, ble, etc.) + Raises an exception on failure. + """ self.impl.connect() def disconnect(self, drain=False): + """ + Disconnect the underlying transport (eg. serial, ble, etc.) + + Parameters + ---------- + drain : bool, optional + When true log any/all remaining messages/data, otherwise silently discard. + NOTE: can prevent disconnection if data is arriving constantly. + Defaults to False. + """ if drain: self.drain() self.impl.disconnect() def drain(self): + """ + Log any/all outstanding messages/data. + NOTE: can run indefinitely if data is arriving constantly. + """ logger.warn("Draining interface...") drained = bytearray() finished = False @@ -565,6 +1535,26 @@ class JadeInterface: @staticmethod def build_request(input_id, method, params=None): + """ + Build a request dict from passed parameters + + Parameters + ---------- + input_id : str + The id of the request message to construct + + method : str + rpc method to invoke + + params : dict, optional + any parameters to pass to the rpc method + Defaults to None. + + Returns + ------- + dict + The request object as a dict + """ request = {"method": method, "id": input_id} if params is not None: request["params"] = params @@ -572,6 +1562,19 @@ class JadeInterface: @staticmethod def serialise_cbor_request(request): + """ + Method to format a request dict as a cbor message + + Parameters + ---------- + request : dict + The request dict + + Returns + ------- + bytes + The request formatted as cbor message bytes + """ dump = cbor.dumps(request) len_dump = len(dump) if 'method' in request and 'ota_data' in request['method']: @@ -582,24 +1585,64 @@ class JadeInterface: return dump def write(self, bytes_): + """ + Write bytes over the underlying interface + + Parameters + ---------- + bytes_ : bytes + The bytes to write + + Returns + ------- + int + The number of bytes written + """ 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): + """ + Write a request dict over the underlying interface, formatted as cbor. + + Parameters + ---------- + request : dict + The request dict to write + """ msg = self.serialise_cbor_request(request) written = 0 while written < len(msg): written += self.write(msg[written:]) def read(self, n): + """ + Try to read bytes from the underlying interface. + + Returns + ------- + bytes + The bytes received + """ 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): + """ + Try to read a single cbor (response) message from the underlying interface. + Respects the any read timeout. + If any 'log' messages are received, logs them locally at the nearest corresponding level + and awaits the next message. Returns when it receives what appears to be a reply message. + + Returns + ------- + dict + The message received, as a dict + """ while True: # 'self' is sufficiently 'file-like' to act as a load source. # Throws EOFError on end of stream/timeout/lost-connection etc. @@ -637,6 +1680,23 @@ class JadeInterface: device_logger.error(message) def read_response(self, long_timeout=False): + """ + Try to read a single cbor (response) message from the underlying interface. + If any 'log' messages are received, logs them locally at the nearest corresponding level + and awaits the next message. Returns when it receives what appears to be a reply message. + If `long_timeout` is false, any read-timeout is respected. If True, the call will block + indefinitely awaiting a response message. + + Parameters + ---------- + long_timeout : bool + Whether to wait indefinitely for the next (response) message. + + Returns + ------- + dict + The message received, as a dict + """ while True: try: return self.read_cbor_message() @@ -646,12 +1706,33 @@ class JadeInterface: @staticmethod def validate_reply(request, reply): + """ + Helper to minimally validate a reply message, in the context of a request. + Asserts if the reply does contain the expected minimal fields. + """ 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): + """ + Method to send a request over the underlying interface, and await a response. + The request is minimally validated before it is sent, and the response is simialrly + validated before being returned. + Any read-timeout is respected unless 'long_timeout' is passed, in which case the call + blocks indefinitely awaiting a response. + + Parameters + ---------- + long_timeout : bool + Whether to wait indefinitely for the response. + + Returns + ------- + dict + The (minimally validated) response message received, as a dict + """ # Write outgoing request message assert isinstance(request, dict) assert 'id' in request and len(request['id']) > 0