Skip to content

Commit

Permalink
Merge #559: Jade support sorted multi
Browse files Browse the repository at this point in the history
230bdd8 tests: Update jade emulator build flags to reduce jade test times (Jamie C. Driver)
ce861ea jade: have Jade skip automatic change validation when in 'expert' mode (Jamie C. Driver)
d104efb jade: Support 'sortedmulti' with Jade, and add a firmware version check (Jamie C. Driver)
2fbe50b jade: Log when Jade may not be able to identify multisig change output (Jamie C. Driver)
b6fbc54 jade: Add device-ids for new Blockstream Jade serial chips (Jamie C. Driver)

Pull request description:

  Latest jade firmware supports sortedmulti(), so this PR is to enable that in HWI, and added a minimum fw version check.
  Also, the latest batch of Jade hw uses a different serial/usb chip, so added the vendor/product ids to recognise those.
  Also, tweak the qemu emulator 'configure' flags to make the jade simulator build smaller and also run faster - this should halve the time the jade tests take to run.

ACKs for top commit:
  achow101:
    ACK 230bdd8

Tree-SHA512: 3ceb5e6abd453c1db094ad5e542cbf7b65a9f82c7920ef2b04a26ca19f0b73f055c3b059f226d93865eab94034b37f0a4ca6885c996511613c6d432ec2b0295c
  • Loading branch information
achow101 committed Feb 1, 2022
2 parents 985ec97 + 230bdd8 commit 3d9ebd3
Show file tree
Hide file tree
Showing 7 changed files with 182 additions and 105 deletions.
180 changes: 97 additions & 83 deletions hwilib/devices/jade.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,13 @@
)

import logging
import semver
import os

# The test emulator port
SIMULATOR_PATH = 'tcp:127.0.0.1:2222'

JADE_DEVICE_IDS = [(0x10c4, 0xea60)]
JADE_DEVICE_IDS = [(0x10c4, 0xea60), (0x1a86, 0x55d4)]
HAS_NETWORKING = hasattr(jade, '_http_request')

py_enumerate = enumerate # To use the enumerate built-in, since the name is overridden below
Expand Down Expand Up @@ -91,6 +92,7 @@ def func(*args: Any, **kwargs: Any) -> Any:

# This class extends the HardwareWalletClient for Blockstream Jade specific things
class JadeClient(HardwareWalletClient):
MIN_SUPPORTED_FW_VERSION = semver.VersionInfo(0, 1, 32)

NETWORKS = {Chain.MAIN: 'mainnet',
Chain.TEST: 'testnet',
Expand All @@ -110,16 +112,16 @@ def _network(self) -> str:
AddressType.WIT: 'wsh(multi(k))',
AddressType.SH_WIT: 'sh(wsh(multi(k)))'}

@staticmethod
def _convertAddrType(addrType: AddressType, multisig: bool) -> str:
return JadeClient.MULTI_ADDRTYPES[addrType] if multisig else JadeClient.ADDRTYPES[addrType]
@classmethod
def _convertAddrType(cls, addrType: AddressType, multisig: bool) -> str:
return cls.MULTI_ADDRTYPES[addrType] if multisig else cls.ADDRTYPES[addrType]

# Derive a deterministic name for a multisig registration record
# Derive a deterministic name for a multisig registration record (ignoring bip67 key sorting)
@staticmethod
def _get_multisig_name(type: str, threshold: int, signers: List[Tuple[bytes, Sequence[int]]]) -> str:
# Concatenate script-type, threshold, and all signers fingerprints and derivation paths
# Concatenate script-type, threshold, and all signers fingerprints and derivation paths (sorted)
summary = type + '|' + str(threshold) + '|'
for fingerprint, path in signers:
for fingerprint, path in sorted(signers):
summary += fingerprint.hex() + '|' + str(path) + '|'

# Hash it, get the first 6-bytes as hex, prepend with 'hwi'
Expand All @@ -134,6 +136,11 @@ def __init__(self, path: str, password: str = '', expert: bool = False, timeout:
verinfo = self.jade.get_version_info()
uninitialized = verinfo['JADE_STATE'] not in ['READY', 'TEMP']

# Check minimum supported firmware version (ignore candidate/build parts)
fw_version = semver.parse_version_info(verinfo['JADE_VERSION'])
if self.MIN_SUPPORTED_FW_VERSION > fw_version.finalize_version():
raise DeviceNotReadyError(f'Jade fw version: {fw_version} - minimum required version: {self.MIN_SUPPORTED_FW_VERSION}. '
'Please update using a Blockstream Green companion app')
if path == SIMULATOR_PATH:
if uninitialized:
# Connected to simulator but it appears to have no wallet set
Expand Down Expand Up @@ -271,77 +278,78 @@ def _split_at_last_hardened_element(path: Sequence[int]) -> Tuple[Sequence[int],
# be confirmed by the user on the hwwallet screen, like any other spend output.
change: List[Optional[Dict[str, Any]]] = [None] * len(tx.outputs)

# If signing multisig inputs, get registered multisigs details in case we
# see any multisig outputs which may be change which we can auto-validate.
# ie. filter speculative 'signing multisigs' to ones actually registered on the hw
candidate_multisigs = {}
if signing_multisigs:
registered_multisigs = self.jade.get_registered_multisigs()
signing_multisigs = {k: v for k, v in signing_multisigs.items()
if k in registered_multisigs
and registered_multisigs[k]['variant'] == v[0]
and registered_multisigs[k]['threshold'] == v[1]
and registered_multisigs[k]['num_signers'] == len(v[2])}

# Look at every output...
for n_vout, (txout, psbtout) in py_enumerate(zip(c_txn.vout, tx.outputs)):
num_signers = len(psbtout.hd_keypaths)

if num_signers == 1 and signing_singlesigs:
# Single-sig output - since we signed singlesig inputs this could be our change
for pubkey, origin in psbtout.hd_keypaths.items():
# Considers 'our' outputs as potential change as far as Jade is concerned
# ie. can be verified and auto-confirmed.
# Is this ok, or should check path also, assuming bip44-like ?
if origin.fingerprint == master_fp and len(origin.path) > 0:
change_addr_type = None
if txout.is_p2pkh():
change_addr_type = AddressType.LEGACY
elif txout.is_witness()[0] and not txout.is_p2wsh():
change_addr_type = AddressType.WIT # ie. p2wpkh
elif txout.is_p2sh() and is_witness(psbtout.redeem_script)[0]:
change_addr_type = AddressType.SH_WIT
else:
continue

script_variant = self._convertAddrType(change_addr_type, multisig=False)
change[n_vout] = {'path': origin.path, 'variant': script_variant}

elif num_signers > 1 and signing_multisigs:
# Multisig output - since we signed multisig inputs this could be our change
candidate_multisigs = {k: v for k, v in signing_multisigs.items() if len(v[2]) == num_signers}
if not candidate_multisigs:
continue

for pubkey, origin in psbtout.hd_keypaths.items():
if origin.fingerprint == master_fp and len(origin.path) > 0:
change_addr_type = None
if txout.is_p2sh() and not is_witness(psbtout.redeem_script)[0]:
change_addr_type = AddressType.LEGACY
scriptcode = psbtout.redeem_script
elif txout.is_p2wsh() and not txout.is_p2sh():
change_addr_type = AddressType.WIT
scriptcode = psbtout.witness_script
elif txout.is_p2sh() and is_witness(psbtout.redeem_script)[0]:
change_addr_type = AddressType.SH_WIT
scriptcode = psbtout.witness_script
else:
continue

parsed = parse_multisig(scriptcode)
if parsed:
script_variant = self._convertAddrType(change_addr_type, multisig=True)
threshold = parsed[0]

pubkeys = parsed[1]
hd_keypath_origins = [psbtout.hd_keypaths[pubkey] for pubkey in pubkeys]

signers, paths = _parse_signers(hd_keypath_origins)
multisig_name = self._get_multisig_name(script_variant, threshold, signers)

matched_multisig = candidate_multisigs.get(multisig_name) == (script_variant, threshold, signers)
if matched_multisig:
change[n_vout] = {'paths': paths, 'multisig_name': multisig_name}
# Skip automatic change validation in expert mode - user checks *every* output on hw
if not self.expert:
# If signing multisig inputs, get registered multisigs details in case we
# see any multisig outputs which may be change which we can auto-validate.
# ie. filter speculative 'signing multisigs' to ones actually registered on the hw
if signing_multisigs:
registered_multisigs = self.jade.get_registered_multisigs()
signing_multisigs = {k: v for k, v in signing_multisigs.items()
if k in registered_multisigs
and registered_multisigs[k]['variant'] == v[0]
and registered_multisigs[k]['threshold'] == v[1]
and registered_multisigs[k]['num_signers'] == len(v[2])}

# Look at every output...
for n_vout, (txout, psbtout) in py_enumerate(zip(c_txn.vout, tx.outputs)):
num_signers = len(psbtout.hd_keypaths)

if num_signers == 1 and signing_singlesigs:
# Single-sig output - since we signed singlesig inputs this could be our change
for pubkey, origin in psbtout.hd_keypaths.items():
# Considers 'our' outputs as potential change as far as Jade is concerned
# ie. can be verified and auto-confirmed.
# Is this ok, or should check path also, assuming bip44-like ?
if origin.fingerprint == master_fp and len(origin.path) > 0:
change_addr_type = None
if txout.is_p2pkh():
change_addr_type = AddressType.LEGACY
elif txout.is_witness()[0] and not txout.is_p2wsh():
change_addr_type = AddressType.WIT # ie. p2wpkh
elif txout.is_p2sh() and is_witness(psbtout.redeem_script)[0]:
change_addr_type = AddressType.SH_WIT
else:
continue

script_variant = self._convertAddrType(change_addr_type, multisig=False)
change[n_vout] = {'path': origin.path, 'variant': script_variant}

elif num_signers > 1 and signing_multisigs:
# Multisig output - since we signed multisig inputs this could be our change
candidate_multisigs = {k: v for k, v in signing_multisigs.items() if len(v[2]) == num_signers}
if not candidate_multisigs:
continue

for pubkey, origin in psbtout.hd_keypaths.items():
if origin.fingerprint == master_fp and len(origin.path) > 0:
change_addr_type = None
if txout.is_p2sh() and not is_witness(psbtout.redeem_script)[0]:
change_addr_type = AddressType.LEGACY
scriptcode = psbtout.redeem_script
elif txout.is_p2wsh() and not txout.is_p2sh():
change_addr_type = AddressType.WIT
scriptcode = psbtout.witness_script
elif txout.is_p2sh() and is_witness(psbtout.redeem_script)[0]:
change_addr_type = AddressType.SH_WIT
scriptcode = psbtout.witness_script
else:
continue

parsed = parse_multisig(scriptcode)
if parsed:
script_variant = self._convertAddrType(change_addr_type, multisig=True)
threshold = parsed[0]

pubkeys = parsed[1]
hd_keypath_origins = [psbtout.hd_keypaths[pubkey] for pubkey in pubkeys]

signers, paths = _parse_signers(hd_keypath_origins)
multisig_name = self._get_multisig_name(script_variant, threshold, signers)
matched_multisig = candidate_multisigs.get(multisig_name)

if matched_multisig and matched_multisig[0] == script_variant and matched_multisig[1] == threshold and sorted(matched_multisig[2]) == sorted(signers):
change[n_vout] = {'paths': paths, 'multisig_name': multisig_name}

# The txn itself
txn_bytes = c_txn.serialize_without_witness()
Expand Down Expand Up @@ -384,15 +392,17 @@ def display_multisig_address(self, addr_type: AddressType, multisig: MultisigDes
signer_origins = []
signers = []
paths = []
if multisig.is_sorted:
raise BadArgumentError('Blockstream Jade can not generate addresses for sorted multisigs')
for pubkey in multisig.pubkeys:
if pubkey.extkey is None:
raise BadArgumentError('Blockstream Jade can only generate addresses for multisigs with full extended keys')
if pubkey.origin is None:
raise BadArgumentError('Blockstream Jade can only generate addresses for multisigs with key origin information')
if pubkey.deriv_path is None:
raise BadArgumentError('Blockstream Jade can only generate addresses for multisigs with key origin derivation path information')
raise BadArgumentError('Blockstream Jade can only generate addresses for multisigs with key derivation paths')

if pubkey.origin.path and not is_hardened(pubkey.origin.path[-1]):
logging.warning(f'Final element of origin path {pubkey.origin.path} unhardened')
logging.warning('Blockstream Jade may not be able to identify change sent back to this descriptor')

# Tuple to derive deterministic name for the registrtion
signer_origins.append((pubkey.origin.fingerprint, pubkey.origin.path))
Expand All @@ -407,13 +417,17 @@ def display_multisig_address(self, addr_type: AddressType, multisig: MultisigDes
path = pubkey.deriv_path[1:] if pubkey.deriv_path[0] == '/' else pubkey.deriv_path
paths.append(parse_path(path))

# Get a deterministic name for this multisig wallet
if multisig.is_sorted and paths[:-1] != paths[1:]:
logging.warning('Sorted multisig with different derivations per signer')
logging.warning('Blockstream Jade may not be able to validate change sent back to this descriptor')

# Get a deterministic name for this multisig wallet (ignoring bip67 key sorting)
script_variant = self._convertAddrType(addr_type, multisig=True)
multisig_name = self._get_multisig_name(script_variant, multisig.thresh, signer_origins)

# Need to ensure this multisig wallet is registered first
# (Note: 're-registering' is a no-op)
self.jade.register_multisig(self._network(), multisig_name, script_variant, multisig.thresh, signers)
self.jade.register_multisig(self._network(), multisig_name, script_variant, multisig.is_sorted, multisig.thresh, signers)
address = self.jade.get_receive_address(self._network(), paths, multisig_name=multisig_name)

return str(address)
Expand Down
2 changes: 1 addition & 1 deletion hwilib/devices/jadepy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 commit [5d6c1ff2bb134261ccb7f939c3cea5f051945ab8](https://github.com/Blockstream/Jade/commit/5d6c1ff2bb134261ccb7f939c3cea5f051945ab8)
This stripped down version was made from tag [0.1.32](https://github.com/Blockstream/Jade/releases/tag/0.1.32)

## Changes

Expand Down
30 changes: 23 additions & 7 deletions hwilib/devices/jadepy/jade.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,21 @@
device_logger = logging.getLogger('jade-device')


# Helper to map bytes-like types into hex-strings
# to make for prettier message-logging
def _hexlify(data):
if data is None:
return None
elif isinstance(data, bytes) or isinstance(data, bytearray):
return data.hex()
elif isinstance(data, list):
return [_hexlify(item) for item in data]
elif isinstance(data, dict):
return {k: _hexlify(v) for k, v in data.items()}
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.
Expand Down Expand Up @@ -257,9 +272,10 @@ def get_registered_multisigs(self):
return self._jadeRpc('get_registered_multisigs')

# Register a multisig wallet
def register_multisig(self, network, multisig_name, variant, threshold, signers):
def register_multisig(self, network, multisig_name, variant, sorted_keys, threshold, signers):
params = {'network': network, 'multisig_name': multisig_name,
'descriptor': {'variant': variant, 'threshold': threshold, 'signers': signers}}
'descriptor': {'variant': variant, 'sorted': sorted_keys,
'threshold': threshold, 'signers': signers}}
return self._jadeRpc('register_multisig', params)

# Get receive-address for parameters
Expand Down Expand Up @@ -308,9 +324,9 @@ def get_blinding_key(self, script):

# Get the shared secret to unblind a tx, given the receiving script on
# our side and the pubkey of the sender (sometimes called "nonce" in
# Liquid)
def get_shared_nonce(self, script, their_pubkey):
params = {'script': script, 'their_pubkey': their_pubkey}
# 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}
return self._jadeRpc('get_shared_nonce', params)

# Get a "trusted" blinding factor to blind an output. Normally the blinding
Expand Down Expand Up @@ -557,7 +573,7 @@ def serialise_cbor_request(request):
msg = 'Sending ota_data message {} as cbor of size {}'.format(request['id'], len_dump)
logger.info(msg)
else:
logger.info('Sending: {} as cbor of size {}'.format(request, len_dump))
logger.info('Sending: {} as cbor of size {}'.format(_hexlify(request), len_dump))
return dump

def write(self, bytes_):
Expand Down Expand Up @@ -586,7 +602,7 @@ def read_cbor_message(self):

# A message response (to a prior request)
if 'id' in message:
logger.info("Received msg: {}".format(message))
logger.info("Received msg: {}".format(_hexlify(message)))
return message

# A log message - handle as normal
Expand Down
1 change: 1 addition & 0 deletions hwilib/udev/55-usb-jade.rules
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
KERNEL=="ttyUSB*", SUBSYSTEMS=="usb", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", MODE="0660", GROUP="plugdev", TAG+="uaccess", TAG+="udev-acl", SYMLINK+="jade%n"
KERNEL=="ttyACM*", SUBSYSTEMS=="usb", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="55d4", MODE="0660", GROUP="plugdev", TAG+="uaccess", TAG+="udev-acl", SYMLINK+="jade%n"
3 changes: 3 additions & 0 deletions mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ ignore_missing_imports = True
[mypy-pyaes]
ignore_missing_imports = True

[mypy-semver]
ignore_missing_imports = True

[mypy-usb1]
ignore_missing_imports = True

Expand Down

0 comments on commit 3d9ebd3

Please sign in to comment.