diff --git a/hwilib/devices/jade.py b/hwilib/devices/jade.py index 6d471f6fc..bff69cd7c 100644 --- a/hwilib/devices/jade.py +++ b/hwilib/devices/jade.py @@ -5,6 +5,7 @@ from .jadepy import jade from .jadepy.jade import JadeAPI, JadeError +from .jadepy.jade_serial import JadeSerialImpl from serial.tools import list_ports @@ -58,7 +59,6 @@ # The test emulator port SIMULATOR_PATH = 'tcp:127.0.0.1:30121' -JADE_DEVICE_IDS = [(0x10c4, 0xea60), (0x1a86, 0x55d4), (0x0403, 0x6001), (0x1a86, 0x7523)] HAS_NETWORKING = hasattr(jade, '_http_request') py_enumerate = enumerate # To use the enumerate built-in, since the name is overridden below @@ -533,7 +533,7 @@ def _get_device_entry(device_model: str, device_path: str) -> Dict[str, Any]: # 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, devinfo.pid) in JADE_DEVICE_IDS: + if (devinfo.vid, devinfo.pid) in JadeSerialImpl.JADE_DEVICE_IDS: results.append(_get_device_entry('jade', devinfo.device)) # If we can connect to the simulator, add it too diff --git a/hwilib/devices/jadepy/README.md b/hwilib/devices/jadepy/README.md index 6ce232e0c..99fba6160 100644 --- a/hwilib/devices/jadepy/README.md +++ b/hwilib/devices/jadepy/README.md @@ -2,7 +2,7 @@ This is a slightly stripped down version of the official [Jade](https://github.com/Blockstream/Jade) python library. -This stripped down version was made from tag [0.1.38](https://github.com/Blockstream/Jade/releases/tag/0.1.38) +This stripped down version was made from tag [1.0.33](https://github.com/Blockstream/Jade/releases/tag/1.0.33) ## Changes diff --git a/hwilib/devices/jadepy/__init__.py b/hwilib/devices/jadepy/__init__.py index 64e2ceb7e..255b56026 100644 --- a/hwilib/devices/jadepy/__init__.py +++ b/hwilib/devices/jadepy/__init__.py @@ -1,4 +1,4 @@ from .jade import JadeAPI from .jade_error import JadeError -__version__ = "0.2.0" +__version__ = "1.0.33" diff --git a/hwilib/devices/jadepy/jade.py b/hwilib/devices/jadepy/jade.py index b21ee4c1e..2c8f4a3ce 100644 --- a/hwilib/devices/jadepy/jade.py +++ b/hwilib/devices/jadepy/jade.py @@ -7,6 +7,7 @@ import collections.abc import traceback import random +import socket import sys # JadeError @@ -65,7 +66,7 @@ def _http_request(params): 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. + Callers can supply their own implementation of this call where it is required. Parameters ---------- @@ -113,6 +114,32 @@ def http_call_fn(): return requests.post(url, data) logger.info('Default _http_requests() function will not be available') +def generate_dump(): + while True: + try: + with socket.create_connection(("localhost", 4444)) as s: + output = b"" + while b"Open On-Chip Debugger" not in output: + data = s.recv(1024) + if not data: + continue + output += data + + s.sendall(b"esp gcov dump\n") + + output = b"" + while b"Targets disconnected." not in output: + data = s.recv(1024) + if not data: + continue + output += data + s.sendall(b"resume\n") + time.sleep(1) + return + except ConnectionRefusedError: + pass + + class JadeAPI: """ High-Level Jade Client API @@ -421,7 +448,8 @@ def logout(self): """ return self._jadeRpc('logout') - def ota_update(self, fwcmp, fwlen, chunksize, fwhash=None, patchlen=None, cb=None): + def ota_update(self, fwcmp, fwlen, chunksize, fwhash=None, patchlen=None, cb=None, + extended_replies=False, gcov_dump=False): """ RPC call to attempt to update the unit's firmware. @@ -451,9 +479,15 @@ def ota_update(self, fwcmp, fwlen, chunksize, fwhash=None, patchlen=None, cb=Non 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 'extended_replies' was set, this callback is also passed the extended data included + in the replies, if not this is None. 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. + extended_replies: bool, optional + If set Jade may return addtional progress data with each data chunk uploaded, which is + then passed to any progress callback as above. If not no additional data is returned + or passed. Returns ------- @@ -472,7 +506,8 @@ def ota_update(self, fwcmp, fwlen, chunksize, fwhash=None, patchlen=None, cb=Non ota_method = 'ota' params = {'fwsize': fwlen, 'cmpsize': cmplen, - 'cmphash': cmphash} + 'cmphash': cmphash, + 'extended_replies': extended_replies} if fwhash is not None: params['fwhash'] = fwhash @@ -491,11 +526,16 @@ def ota_update(self, fwcmp, fwlen, chunksize, fwhash=None, patchlen=None, cb=Non length = min(remaining, chunksize) chunk = bytes(fwcmp[written:written + length]) result = self._jadeRpc('ota_data', chunk) - assert result is True written += length + have_extended_reply = isinstance(result, dict) + assert result is True or (extended_replies and have_extended_reply) + if (cb): - cb(written, cmplen) + cb(written, cmplen, result if have_extended_reply else None) + + if gcov_dump: + self.run_remote_gcov_dump() # All binary data uploaded return self._jadeRpc('ota_complete') @@ -513,6 +553,22 @@ def run_remote_selfcheck(self): """ return self._jadeRpc('debug_selfcheck', long_timeout=True) + def run_remote_gcov_dump(self): + """ + RPC call to run in-built gcov-dump. + NOTE: Only available in a DEBUG build of the firmware. + + Returns + ------- + bool + Always True. + """ + result = self._jadeRpc('debug_gcov_dump', long_timeout=True) + time.sleep(0.5) + generate_dump() + time.sleep(2) + return result + def capture_image_data(self, check_qr=False): """ RPC call to capture raw image data from the camera. @@ -625,7 +681,7 @@ def get_bip85_bip39_entropy(self, num_words, index, pubkey): The number of words the entropy is required to produce. index : int - The index to use in the bip32 path to calcuate the entropy. + The index to use in the bip32 path to calculate the entropy. pubkey: 33-bytes The host ephemeral pubkey to use to generate a shared ecdh secret to use as an AES key @@ -646,6 +702,38 @@ def get_bip85_bip39_entropy(self, num_words, index, pubkey): 'pubkey': pubkey} return self._jadeRpc('get_bip85_bip39_entropy', params) + def get_bip85_rsa_entropy(self, key_bits, index, pubkey): + """ + RPC call to fetch encrypted bip85-rsa entropy. + NOTE: Only available in a DEBUG build of the firmware. + + Parameters + ---------- + key_bits: int + The size of the RSA key. + + index : int + The index to use in the bip32 path to calculate the entropy. + + pubkey: 33-bytes + The host ephemeral pubkey to use to generate a shared ecdh secret to use as an AES key + to encrypt the returned entropy. + + Returns + ------- + dict + pubkey - 33-bytes, Jade's ephemeral pubkey used to generate a shared ecdh secret used as + an AES key to encrypt the returned entropy + encrypted - bytes, the requested bip85 rsa entropy, AES encrypted with the first key + derived from the ecdh shared secret, prefixed with the iv + hmac - 32-bytes, the hmac of the encrypted buffer, using the second key derived from the + ecdh shared secret + """ + params = {'key_bits': key_bits, + 'index': index, + 'pubkey': pubkey} + return self._jadeRpc('get_bip85_rsa_entropy', params) + 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 @@ -684,12 +772,12 @@ def set_pinserver(self, urlA=None, urlB=None, pubkey=None, cert=None): def reset_pinserver(self, reset_details, reset_certificate): """ - RPC call to reset any formerly overidden pinserver details to their defauts. + RPC call to reset any formerly overridden pinserver details to their defaults. Parameters ---------- reset_details : bool, optional - If set, any overidden urls and pubkey are reset to their defaults. + If set, any overridden urls and pubkey are reset to their defaults. reset_certificate : bool, optional If set, any additional certificate is reset (to None). @@ -815,6 +903,55 @@ def get_registered_multisigs(self): """ return self._jadeRpc('get_registered_multisigs') + def get_registered_multisig(self, multisig_name, as_file=False): + """ + RPC call to fetch details of a named multisig wallet registered to this signer. + NOTE: the multisig wallet must have been registered with firmware v1.0.23 or later + for the full signer details to be persisted and available. + + Parameters + ---------- + multisig_name : string + Name of multsig registration record to return. + + as_file : string, optional + If true the flat file format is returned, otherwise structured json is returned. + Defaults to false. + + Returns + ------- + dict + Description of registered multisig wallet identified by registration name. + Contains keys: + is_file is true: + multisig_file - str, the multisig file as produced by several wallet apps. + eg: + Name: MainWallet + Policy: 2 of 3 + Format: P2WSH + Derivation: m/48'/0'/0'/2' + + B237FE9D: xpub6E8C7BX4c7qfTsX7urnXggcAyFuhDmYLQhwRwZGLD9maUGWPinuc9k96ej... + 249192D2: xpub6EbXynW6xjYR3crcztum6KzSWqDJoAJQoovwamwVnLaCSHA6syXKPnJo6U... + 67F90FFC: xpub6EHuWWrYd8bp5FS1XAZsMPkmCqLSjpULmygWqAqWRCCjSWQwz6ntq5KnuQ... + + is_file is false: + multisig_name - str, name of multisig registration + variant - str, script type, eg. 'sh(wsh(multi(k)))' + sorted - boolean, whether bip67 key sorting is applied + threshold - int, number of signers required,N + master_blinding_key - 32-bytes, any liquid master blinding key for this wallet + signers - dict containing keys: + fingerprint - 4 bytes, origin fingerprint + derivation - [int], bip32 path from origin to signer xpub provided + xpub - str, base58 xpub of signer + path - [int], any fixed path to always apply after the xpub - usually empty. + + """ + params = {'multisig_name': multisig_name, + 'as_file': as_file} + return self._jadeRpc('get_registered_multisig', params) + def register_multisig(self, network, multisig_name, variant, sorted_keys, threshold, signers, master_blinding_key=None): """ @@ -892,6 +1029,42 @@ def register_multisig_file(self, multisig_file): params = {'multisig_file': multisig_file} return self._jadeRpc('register_multisig', params) + def get_registered_descriptors(self): + """ + RPC call to fetch brief summaries of any descriptor wallets registered to this signer. + + Returns + ------- + dict + Brief description of registered descriptor, keyed by registration name. + Each entry contains keys: + descriptor_len - int, length of descriptor output script + num_datavalues - int, total number of substitution placeholders passed with script + master_blinding_key - 32-bytes, any liquid master blinding key for this wallet + """ + return self._jadeRpc('get_registered_descriptors') + + def get_registered_descriptor(self, descriptor_name): + """ + RPC call to fetch details of a named descriptor wallet registered to this signer. + + Parameters + ---------- + descriptor_name : string + Name of descriptor registration record to return. + + Returns + ------- + dict + Description of registered descriptor wallet identified by registration name. + Contains keys: + descriptor_name - str, name of descritpor registration + descriptor - str, descriptor output script, may contain substitution placeholders + datavalues - dict containing placeholders for substitution into script + """ + params = {'descriptor_name': descriptor_name} + return self._jadeRpc('get_registered_descriptor', params) + def register_descriptor(self, network, descriptor_name, descriptor_script, datavalues=None): """ RPC call to register a new descriptor wallet, which must contain the hw signer. @@ -900,7 +1073,7 @@ def register_descriptor(self, network, descriptor_name, descriptor_script, datav Parameters ---------- network : string - Network to which the multisig should apply - eg. 'mainnet', 'liquid', 'testnet', etc. + Network to which the descriptor should apply - eg. 'mainnet', 'liquid', 'testnet', etc. descriptor_name : string Name to use to identify this descriptor wallet registration record. @@ -1067,6 +1240,70 @@ def sign_message_file(self, message_file): params = {'message_file': message_file} return self._jadeRpc('sign_message', params) + def get_bip85_pubkey(self, key_type, key_bits, index): + """ + RPC call to fetch a bip85-derived pubkey. + + Parameters + ---------- + key_type : string + The type of key to be derived. + At this time only 'RSA' is supported. + + key_bits : int + The number of bits in the desired key. Must be valid for the key type. + At this time must be 1024, 2048, 3096 or 4092 + + index : int + The index to use in the bip32 path to calculate the entropy to generate the key. + + Returns + ------- + string + PEM file of the public key derived. + """ + params = {'key_type': key_type, + 'key_bits': key_bits, + 'index': index} + return self._jadeRpc('get_bip85_pubkey', params) + + def sign_bip85_digests(self, key_type, key_bits, index, digests): + """ + RPC call to sign digests with a bip85-derived key. + + Parameters + ---------- + key_type : string + The type of key to be derived. + At this time only 'RSA' is supported. + + key_bits : int + The number of bits in the desired key. Must be valid for the key type. + At this time must be 1024, 2048, 3096 or 4092 + + index : int + The index to use in the bip32 path to calculate the entropy to generate the key. + + digests : [bytes] + An array of digests to sign. The maximum number of digests that can be signed in a + single call depends upon the key (and hence signature) size. + key_bits max digests + 1024 8 + 2048 8 + 3072 6 + 4096 4 + + Returns + ------- + [bytes] + Array of signatures, same size as input digests array + """ + params = {'key_type': key_type, + 'key_bits': key_bits, + 'index': index, + 'digests': digests} + return self._jadeRpc('sign_bip85_digests', params) + def get_identity_pubkey(self, identity, curve, key_type, index=0): """ RPC call to fetch a pubkey for the given identity (slip13/slip17). @@ -1162,18 +1399,57 @@ def sign_identity(self, identity, curve, challenge, index=0): params = {'identity': identity, 'curve': curve, 'index': index, 'challenge': challenge} return self._jadeRpc('sign_identity', params) - def get_master_blinding_key(self): + def sign_attestation(self, challenge): + """ + RPC call to sign passed challenge with embedded hw RSA-4096 key, such that the caller + can check the authenticity of the hardware unit. eg. whether it is a genuine + Blockstream production Jade unit. + Caller must have the public key of the external verifying authority they wish to validate + against (eg. Blockstream's Jade verification public key). + NOTE: only supported by ESP32S3-based hardware units. + + Parameters + ---------- + challenge : bytes + Challenge bytes to sign + + Returns + ------- + dict + Contains keys: + signature - 512-bytes, hardware RSA signature of the SHA256 hash of the passed + challenge bytes. + pubkey_pem - str, PEM export of RSA pubkey of the hardware unit, to verify the returned + RSA signature. + ext_signature - bytes, RSA signature of the verifying authority over the returned + pubkey_pem data. + (Caller can verify this signature with the public key of the verifying authority.) + """ + params = {'challenge': challenge} + return self._jadeRpc('sign_attestation', params) + + def get_master_blinding_key(self, only_if_silent=False): """ RPC call to fetch the master (SLIP-077) blinding key for the hw signer. + May block temporarily to request the user's permission to export. Passing 'only_if_silent' + causes the call to return the 'denied' error if it would normally ask the user. NOTE: the master blinding key of any registered multisig wallets can be obtained from the result of `get_registered_multisigs()`. + Parameters + ---------- + only_if_silent : boolean, optional + If True Jade will return the denied error if it would normally ask the user's permission + to export the master blinding key. Passing False (or letting default) may block while + asking the user to confirm the export on Jade. + Returns ------- 32-bytes SLIP-077 master blinding key """ - return self._jadeRpc('get_master_blinding_key') + params = {'only_if_silent': only_if_silent} + return self._jadeRpc('get_master_blinding_key', params) def get_blinding_key(self, script, multisig_name=None): """ @@ -1432,7 +1708,7 @@ def sign_liquid_tx(self, network, txn, inputs, commitments, change, use_ae_signa is_witness, bool - whether this is a segwit input script, bytes- the redeem script path, [int] - the bip32 path to sign with - value_commitment, 33-bytes - The value commitment of ths input + value_commitment, 33-bytes - The value commitment of the input This is optional if signing this input: sighash, int - The sighash to use, defaults to 0x01 (SIGHASH_ALL) @@ -1699,7 +1975,7 @@ def create_serial(device=None, baud=None, timeout=None): Returns ------- JadeInterface - Inerface object configured to use given serial parameters. + Interface 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. """ @@ -1741,7 +2017,7 @@ def create_ble(device_name=None, serial_number=None, Returns ------- JadeInterface - Inerface object configured to use given BLE parameters. + Interface 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. @@ -1995,7 +2271,7 @@ def validate_reply(request, 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 + The request is minimally validated before it is sent, and the response is similarly validated before being returned. Any read-timeout is respected unless 'long_timeout' is passed, in which case the call blocks indefinitely awaiting a response. diff --git a/hwilib/devices/jadepy/jade_serial.py b/hwilib/devices/jadepy/jade_serial.py index ac08119d0..4b17c59dc 100644 --- a/hwilib/devices/jadepy/jade_serial.py +++ b/hwilib/devices/jadepy/jade_serial.py @@ -2,6 +2,7 @@ import logging from serial.tools import list_ports +from .jade_error import JadeError logger = logging.getLogger(__name__) @@ -21,7 +22,9 @@ # class JadeSerialImpl: # Used when searching for devices that might be a Jade/compatible hw - JADE_DEVICE_IDS = [(0x10c4, 0xea60), (0x1a86, 0x55d4), (0x0403, 0x6001), (0x1a86, 0x7523)] + JADE_DEVICE_IDS = [ + (0x10c4, 0xea60), (0x1a86, 0x55d4), (0x0403, 0x6001), + (0x1a86, 0x7523), (0x303a, 0x4001), (0x303a, 0x1001)] @classmethod def _get_first_compatible_device(cls): @@ -51,7 +54,10 @@ def connect(self): assert self.ser is not None if not self.ser.is_open: - self.ser.open() + try: + self.ser.open() + except serial.serialutil.SerialException: + raise JadeError(1, "Unable to open port", self.device) # Ensure RTS and DTR are not set (as this can cause the hw to reboot) self.ser.setRTS(False) diff --git a/test/setup_environment.sh b/test/setup_environment.sh index d4f1463d7..cb4600fe8 100755 --- a/test/setup_environment.sh +++ b/test/setup_environment.sh @@ -331,11 +331,10 @@ if [[ -n ${build_jade} ]]; then # Clone the upstream if the directory does not exist # Then build the emulator if [ ! -d "qemu" ]; then - git clone --depth 1 --branch ${ESP_QEMU_BRANCH} --single-branch --recursive https://github.com/espressif/qemu.git ./qemu + git clone --quiet --depth 1 --branch ${ESP_QEMU_BRANCH} --single-branch --shallow-submodules https://github.com/espressif/qemu.git ./qemu cd qemu git checkout ${ESP_QEMU_COMMIT} - git submodule update --recursive --init ./configure \ --target-list=xtensa-softmmu \ --enable-gcrypt \ @@ -374,7 +373,7 @@ if [[ -n ${build_jade} ]]; then # Clone the upstream if the directory does not exist # Then build and install the tools if [ ! -d "esp-idf" ]; then - git clone --depth=1 --branch ${ESP_IDF_BRANCH} --single-branch --recursive https://github.com/espressif/esp-idf.git ./esp-idf + git clone --quiet --depth=1 --branch ${ESP_IDF_BRANCH} --single-branch --recursive --shallow-submodules https://github.com/espressif/esp-idf.git ./esp-idf cd esp-idf git checkout ${ESP_IDF_COMMIT}