From 21f60258e06813b98a96d3046a4364e126932c0a Mon Sep 17 00:00:00 2001 From: "Jamie C. Driver" Date: Fri, 6 Dec 2024 09:00:52 +0000 Subject: [PATCH 1/2] jade setup_environment: tweak qemu clone following issue with upstream repo Do not clone submodules recursively as an unused upstream module has disappeared, and the qemu build handles fetching submodules as needed. Also add flag to clone submodules without their entire history. --- test/setup_environment.sh | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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} From f4f7fe53ac4d91f2dbbdadb2bbbba2926b472fa0 Mon Sep 17 00:00:00 2001 From: "Jamie C. Driver" Date: Tue, 3 Sep 2024 16:09:50 +0100 Subject: [PATCH 2/2] jade: update Jade api to 1.0.33 This extends the serial api to recognise recently supported hardware. --- hwilib/devices/jade.py | 4 +- hwilib/devices/jadepy/README.md | 2 +- hwilib/devices/jadepy/__init__.py | 2 +- hwilib/devices/jadepy/jade.py | 306 +++++++++++++++++++++++++-- hwilib/devices/jadepy/jade_serial.py | 10 +- 5 files changed, 303 insertions(+), 21 deletions(-) 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)