Add Blockstream Jade support (#1234)

* Add Blockstream Jade support
* Add udev and move jade stuff
* Avoid showing elements core when not connected to node
This commit is contained in:
benk10 2021-06-15 18:40:43 -04:00 committed by GitHub
parent ccfd335ade
commit ba4699b80b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 1076 additions and 1 deletions

View file

@ -18,3 +18,4 @@ psutil==5.7.3
pyopenssl==20.0.1
flask_wtf==0.14.3
pgpy==0.5.3
cbor==1.0.0

View file

@ -12,6 +12,9 @@ bitbox02==5.3.0 \
--hash=sha256:797e6904d431f6d2ef711f169e7ce8fffc125cc8c5b3efb8187fd451f45635e1 \
--hash=sha256:fe0e8aeb9b32fd7d76bb3e9838895973a74dfd532a8fb8ac174a1a60214aee26
# via hwi
cbor==1.0.0 \
--hash=sha256:13225a262ddf5615cbd9fd55a76a0d53069d18b07d2e9f19c39e6acb8609bbb6
# via -r requirements.in
certifi==2019.9.11 \
--hash=sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50 \
--hash=sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef

View file

@ -6,6 +6,7 @@ from .bitbox02 import BitBox02
from .keepkey import Keepkey
from .specter import Specter
from .cobo import Cobo
from .jade import Jade
from .generic import GenericDevice
from .electrum import Electrum
from .bitcoin_core import BitcoinCore
@ -20,6 +21,7 @@ __all__ = [
ColdCard,
Keepkey,
Cobo,
Jade,
Electrum,
BitcoinCore,
ElementsCore,

View file

@ -0,0 +1,369 @@
"""
Blockstream Jade Devices
************************
"""
from .jadepy.jade import JadeAPI, JadeError
from serial.tools import list_ports
from typing import List, Union
from hwilib.descriptor import PubkeyProvider
from hwilib.hwwclient import HardwareWalletClient
from hwilib.errors import (
ActionCanceledError,
BadArgumentError,
DeviceConnectionError,
DeviceFailureError,
UnavailableActionError,
common_err_msgs,
handle_errors,
)
from hwilib.common import (
AddressType,
Chain,
)
from hwilib.key import ExtendedKey, parse_path
from hwilib.psbt import PSBT
from hwilib.tx import CTransaction
from hwilib._script import (
is_p2sh,
is_p2wpkh,
is_p2wsh,
is_witness,
)
import logging
import os
JADE_VENDOR_ID = 0x10C4
JADE_DEVICE_ID = 0xEA60
py_enumerate = (
enumerate # To use the enumerate built-in, since the name is overridden below
)
def jade_exception(f):
def func(*args, **kwargs):
try:
return f(*args, **kwargs)
except ValueError as e:
raise BadArgumentError(str(e))
except JadeError as e:
if e.code == -32000: # CBOR_RPC_USER_CANCELLED
raise ActionCanceledError("{} canceled by user".format(f.__name__))
elif e.code == -32602: # CBOR_RPC_BAD_PARAMETERS
raise BadArgumentError(e.message)
elif e.code == -32603: # CBOR_RPC_INTERNAL_ERROR
raise DeviceFailureError(e.message)
elif e.code == -32002: # CBOR_RPC_HW_LOCKED
raise DeviceConnectionError("Device is locked")
elif e.code == -32003: # CBOR_RPC_NETWORK_MISMATCH
raise DeviceConnectionError("Network/chain selection error")
elif e.code in [
-32600,
-32601,
-32001,
]: # CBOR_RPC_INVALID_REQUEST, CBOR_RPC_UNKNOWN_METHOD, CBOR_RPC_PROTOCOL_ERROR
raise DeviceConnectionError("Messaging/communiciation error")
else:
raise e
return func
# This class extends the HardwareWalletClient for Blockstream Jade specific things
class JadeClient(HardwareWalletClient):
NETWORKS = {Chain.MAIN: "mainnet", Chain.TEST: "testnet", Chain.REGTEST: "regtest"}
def _network(self):
return JadeClient.NETWORKS.get(self.chain, "mainnet")
ADDRTYPES = {
AddressType.LEGACY: "pkh(k)",
AddressType.WIT: "wpkh(k)",
AddressType.SH_WIT: "sh(wpkh(k))",
}
@staticmethod
def _convertAddrType(addrType):
return JadeClient.ADDRTYPES[addrType]
def __init__(self, path: str, password: str = "", expert: bool = False) -> None:
super(JadeClient, self).__init__(path, password, expert)
self.jade = JadeAPI.create_serial(path)
self.jade.connect()
# Push some host entropy into jade
self.jade.add_entropy(os.urandom(32))
# Do the PIN thing if required
# NOTE: uses standard 'requests' networking to connect to blind pinserver
try:
while not self.jade.auth_user(self._network()):
logging.debug("Incorrect PIN provided")
except:
try:
self.chain = Chain.TEST
while not self.jade.auth_user(self._network()):
logging.debug("Incorrect PIN provided")
except:
self.chain = Chain.REGTEST
while not self.jade.auth_user(self._network()):
logging.debug("Incorrect PIN provided")
# Retrieves the public key at the specified BIP 32 derivation path
@jade_exception
def get_pubkey_at_path(self, bip32_path: str) -> ExtendedKey:
path = parse_path(bip32_path)
xpub = self.jade.get_xpub(self._network(), path)
ext_key = ExtendedKey.deserialize(xpub)
return ext_key
# Walk the PSBT looking for inputs we can sign. Push any signatures into the
# 'partial_sigs' map in the input, and return the updated PSBT.
@jade_exception
def sign_tx(self, tx: PSBT) -> PSBT:
"""
Sign a transaction with the Blockstream Jade.
- Jade can only be used to sign single-key inputs at this time. It cannot sign multisig or arbitrary scripts.
"""
c_txn = CTransaction(tx.tx)
master_fp = self.get_master_fingerprint()
signing_pubkeys = [None] * len(tx.inputs)
# Signing input details
jade_inputs = []
for n_vin, (txin, psbtin) in py_enumerate(zip(c_txn.vin, tx.inputs)):
# Get bip32 path to use to sign, if required for this input
path = None
for pubkey, origin in psbtin.hd_keypaths.items():
if origin.fingerprint == master_fp and len(origin.path) > 0:
# Our input
if (
pubkey not in psbtin.partial_sigs
or not psbtin.partial_sigs[pubkey]
):
# hw to sign this input - it is not already signed
signing_pubkeys[n_vin] = pubkey
path = origin.path
# Get the tx and prevout/scriptcode
utxo = None
input_txn_bytes = None
if psbtin.witness_utxo:
utxo = psbtin.witness_utxo
if psbtin.non_witness_utxo:
if txin.prevout.hash != psbtin.non_witness_utxo.sha256:
raise BadArgumentError(
"Input {} has a non_witness_utxo with the wrong hash".format(
n_vin
)
)
utxo = psbtin.non_witness_utxo.vout[txin.prevout.n]
input_txn_bytes = psbtin.non_witness_utxo.serialize_without_witness()
scriptcode = utxo.scriptPubKey
if is_p2sh(scriptcode):
scriptcode = psbtin.redeem_script
witness_input, witness_version, witness_program = is_witness(scriptcode)
if witness_input:
if is_p2wsh(scriptcode):
scriptcode = psbtin.witness_script
elif is_p2wpkh(scriptcode):
scriptcode = b"\x76\xa9\x14" + witness_program + b"\x88\xac"
else:
scriptcode = None
# Build the input and add to the list
jade_inputs.append(
{
"is_witness": witness_input,
"input_tx": input_txn_bytes,
"script": scriptcode,
"path": path,
}
)
# Change output details
# This is optional, in that if we send it Jade validates the change output script
# and the user need not confirm that ouptut. If not passed the change output must
# be confirmed by the user on the hwwallet screen, like any other spend output.
change = [None] * len(tx.outputs)
for n_vout, (txout, psbtout) in py_enumerate(zip(c_txn.vout, tx.outputs)):
for pubkey, origin in psbtout.hd_keypaths.items():
# Considers 'our' outputs as change as far as Jade is concerned
# ie. can be auto-confirmed.
# Is this ok, or should check path also, assuming bip44-like ?
if origin.fingerprint == master_fp and len(origin.path) > 0:
addr_type = None
if txout.is_p2pkh():
addr_type = AddressType.LEGACY
elif txout.is_witness()[0] and not txout.is_p2wsh():
addr_type = AddressType.WIT
elif txout.is_p2sh():
addr_type = AddressType.SH_WIT # is it really though ?
if addr_type:
addr_type = self._convertAddrType(addr_type)
change[n_vout] = {"path": origin.path, "variant": addr_type}
# The txn itself
txn_bytes = c_txn.serialize_without_witness()
# Request Jade generate the signatures for our inputs.
# Change details are passed to be validated on the hw (user does not confirm)
signatures = self.jade.sign_tx(self._network(), txn_bytes, jade_inputs, change)
# Push sigs into PSBT structure as appropriate
for psbtin, pubkey, sig in zip(tx.inputs, signing_pubkeys, signatures):
if pubkey and sig:
psbtin.partial_sigs[pubkey] = sig
# Return the updated psbt
return tx
# Sign message, confirmed on device
@jade_exception
def sign_message(self, message: Union[str, bytes], bip32_path: str) -> str:
path = parse_path(bip32_path)
if isinstance(message, bytes) or isinstance(message, bytearray):
message = message.decode("utf-8")
signature = self.jade.sign_message(path, message)
return signature
# Display address of specified type on the device. Only supports single-key based addresses atm.
@jade_exception
def display_singlesig_address(self, bip32_path: str, addr_type: AddressType) -> str:
path = parse_path(bip32_path)
addr_type = self._convertAddrType(addr_type)
address = self.jade.get_receive_address(
self._network(), path, variant=addr_type
)
return address
def display_multisig_address(
self, threshold: int, pubkeys: List[PubkeyProvider], addr_type: AddressType
) -> str:
"""
The Blockstream Jade does not support multisig addresses.
:raises UnavailableActionError: Always, this function is unavailable
"""
raise UnavailableActionError(
"The Blockstream Jade does not support generic multisig P2SH address display"
)
# Setup a new device
def setup_device(self, label="", passphrase=""):
"""
The Blockstream Jade does not support setup via software.
:raises UnavailableActionError: Always, this function is unavailable
"""
raise UnavailableActionError(
"The Blockstream Jade does not support software setup"
)
# Wipe this device
def wipe_device(self):
"""
The Blockstream Jade does not support wiping via software.
:raises UnavailableActionError: Always, this function is unavailable
"""
raise UnavailableActionError(
"The Blockstream Jade does not support wiping via software"
)
# Restore device from mnemonic or xprv
def restore_device(self, label="", word_count=24):
"""
The Blockstream Jade does not support restoring via software.
:raises UnavailableActionError: Always, this function is unavailable
"""
raise UnavailableActionError(
"The Blockstream Jade does not support restoring via software"
)
# Begin backup process
def backup_device(self, label="", passphrase=""):
"""
The Blockstream Jade does not support backing up via software.
:raises UnavailableActionError: Always, this function is unavailable
"""
raise UnavailableActionError(
"The Blockstream Jade does not support creating a backup via software"
)
# Close the device
def close(self):
self.jade.disconnect()
# Prompt pin
def prompt_pin(self):
"""
The Blockstream Jade does not need a PIN sent from the host.
:raises UnavailableActionError: Always, this function is unavailable
"""
raise UnavailableActionError(
"The Blockstream Jade does not need a PIN sent from the host"
)
# Send pin
def send_pin(self, pin):
"""
The Blockstream Jade does not need a PIN sent from the host.
:raises UnavailableActionError: Always, this function is unavailable
"""
raise UnavailableActionError(
"The Blockstream Jade does not need a PIN sent from the host"
)
# Toggle passphrase
def toggle_passphrase(self):
"""
The Blockstream Jade does not support toggling passphrase from the host.
:raises UnavailableActionError: Always, this function is unavailable
"""
raise UnavailableActionError(
"The Blockstream Jade does not support toggling passphrase from the host"
)
def enumerate(password=""):
results = []
# Jade is not really an HID device, it shows as a serial/com port device.
# Scan com ports looking for the relevant vid and pid, and use 'path' to
# hold the path to the serial port device, eg. /dev/ttyUSB0
for devinfo in list_ports.comports():
if devinfo.vid == JADE_VENDOR_ID and devinfo.pid == JADE_DEVICE_ID:
d_data = {}
d_data["type"] = "jade"
d_data["path"] = devinfo.device
d_data["needs_pin_sent"] = False
d_data["needs_passphrase_sent"] = False
client = None
with handle_errors(common_err_msgs["enumerate"], d_data):
client = JadeClient(devinfo.device, password)
d_data["fingerprint"] = client.get_master_fingerprint().hex()
if client:
client.close()
results.append(d_data)
return results

View file

@ -0,0 +1,9 @@
# Python Jade Library
This is a slightly stripped down version of the official [Jade](https://github.com/Blockstream/Jade) python library.
This stripped down version was made at commit [dfdfc7e1d8b91227f4ea7555457640506b8c8aed](https://github.com/Blockstream/Jade/commit/dfdfc7e1d8b91227f4ea7555457640506b8c8aed).
## Changes
- Removed BLE module, reducing transitive dependencies

View file

@ -0,0 +1,4 @@
from .jade import JadeAPI
from .jade_error import JadeError
__version__ = "0.0.1"

View file

@ -0,0 +1,567 @@
import cbor
import json
import time
import logging
import collections
import traceback
import requests
import random
# JadeError
from .jade_error import JadeError
# Low-level comms backends
from .jade_serial import JadeSerialImpl
# Not used in HWI
# Removed to reduce transitive dependencies
# from .jade_ble import JadeBleImpl
# Default serial connection
DEFAULT_SERIAL_DEVICE = "/dev/ttyUSB0"
DEFAULT_BAUD_RATE = 115200
DEFAULT_SERIAL_TIMEOUT = 120
# Default BLE connection
DEFAULT_BLE_DEVICE_NAME = "Jade"
DEFAULT_BLE_SERIAL_NUMBER = None
DEFAULT_BLE_SCAN_TIMEOUT = 60
# 'jade' logger
logger = logging.getLogger("jade")
device_logger = logging.getLogger("jade-device")
#
# High-Level Jade Client API
# Builds on a JadeInterface to provide a meaningful API
#
# Either:
# a) use with JadeAPI.create_[serial|ble]() as jade:
# (recommended)
# or:
# b) use JadeAPI.create_[serial|ble], then call connect() before
# using, and disconnect() when finished
# (caveat cranium)
# or:
# c) use ctor to wrap existing JadeInterface instance
# (caveat cranium)
#
class JadeAPI:
def __init__(self, jade):
assert jade is not None
self.jade = jade
def __enter__(self):
self.connect()
return self
def __exit__(self, exc_type, exc, tb):
if exc_type:
logger.error("Exception causing JadeAPI context exit.")
logger.error(exc_type)
logger.error(exc)
traceback.print_tb(tb)
self.disconnect(exc_type is not None)
@staticmethod
def create_serial(device=None, baud=None, timeout=None):
impl = JadeInterface.create_serial(device, baud, timeout)
return JadeAPI(impl)
@staticmethod
def create_ble(device_name=None, serial_number=None, scan_timeout=None, loop=None):
impl = JadeInterface.create_ble(device_name, serial_number, scan_timeout, loop)
return JadeAPI(impl)
# Connect underlying interface
def connect(self):
self.jade.connect()
# Disconnect underlying interface
def disconnect(self, drain=False):
self.jade.disconnect(drain)
# Drain all output from the interface
def drain(self):
self.jade.drain()
# Simple http request function which can be used when a Jade response requires
# an external http call.
# The default implementation used in _jadeRpc() below.
@staticmethod
def _http_request(params):
logger.debug("_http_request: {}".format(params))
# Use the first non-onion url
url = [url for url in params["urls"] if ".onion" not in url][0]
if params["method"] == "GET":
assert "data" not in params, "Cannot pass body to requests.get"
f = requests.get(url)
elif params["method"] == "POST":
data = json.dumps(params["data"])
f = requests.post(url, data)
logger.debug("http_request received reply: {}".format(f.text))
if f.status_code != 200:
logger.error("http error {} : {}".format(f.status_code, f.text))
raise ValueError(f.status_code)
assert params["accept"] == "json"
f = f.json()
return {"body": f}
# Raise any returned error as an exception
@staticmethod
def _get_result_or_raise_error(reply):
if "error" in reply:
e = reply["error"]
raise JadeError(e.get("code"), e.get("message"), e.get("data"))
return reply["result"]
# Helper to call wrapper interface rpc invoker
def _jadeRpc(
self,
method,
params=None,
inputid=None,
http_request_fn=None,
long_timeout=False,
):
newid = inputid if inputid else str(random.randint(100000, 999999))
request = self.jade.build_request(newid, method, params)
reply = self.jade.make_rpc_call(request, long_timeout)
result = self._get_result_or_raise_error(reply)
# The Jade can respond with a request for interaction with a remote
# http server. This is used for interaction with the pinserver but the
# code below acts as a dumb proxy and simply makes the http request and
# forwards the response back to the Jade.
# Note: the function called to make the http-request can be passed in,
# or defaults to the simple _http_request() function above.
if isinstance(result, collections.Mapping) and "http_request" in result:
make_http_request = http_request_fn or self._http_request
http_request = result["http_request"]
http_response = make_http_request(http_request["params"])
return self._jadeRpc(
http_request["on-reply"],
http_response["body"],
http_request_fn=make_http_request,
long_timeout=long_timeout,
)
return result
# Get version information from the hw
def get_version_info(self):
return self._jadeRpc("get_version_info")
# Add client entropy to the hw rng
def add_entropy(self, entropy):
params = {"entropy": entropy}
return self._jadeRpc("add_entropy", params)
# OTA new firmware
def ota_update(self, fwcmp, fwlen, chunksize, cb):
compressed_size = len(fwcmp)
# Initiate OTA
params = {"fwsize": fwlen, "cmpsize": compressed_size}
result = self._jadeRpc("ota", params)
assert result is True
# Write binary chunks
written = 0
while written < compressed_size:
remaining = compressed_size - written
length = min(remaining, chunksize)
chunk = bytes(fwcmp[written : written + length])
result = self._jadeRpc("ota_data", chunk)
assert result is True
written += length
if cb:
cb(written, compressed_size)
# All binary data uploaded
return self._jadeRpc("ota_complete")
# Run (debug) healthcheck on the hw
def run_remote_selfcheck(self):
return self._jadeRpc("debug_selfcheck")
# Set the (debug) mnemonic
def set_mnemonic(self, mnemonic):
params = {"mnemonic": mnemonic}
return self._jadeRpc("debug_set_mnemonic", params)
# Set the (debug) seed
def set_seed(self, seed):
params = {"seed": seed}
return self._jadeRpc("debug_set_mnemonic", params)
# Trigger user authentication on the hw
# Involves pinserver handshake
def auth_user(self, network, http_request_fn=None):
params = {"network": network}
return self._jadeRpc(
"auth_user", params, http_request_fn=http_request_fn, long_timeout=True
)
# Get xpub given a path
def get_xpub(self, network, path):
params = {"network": network, "path": path}
return self._jadeRpc("get_xpub", params)
# Get receive-address for parameters
def get_receive_address(
self, *args, recovery_xpub=None, csv_blocks=0, variant=None
):
if variant is not None:
assert len(args) == 2
keys = ["network", "path", "variant"]
args += (variant,)
else:
assert len(args) == 4
keys = [
"network",
"subaccount",
"branch",
"pointer",
"recovery_xpub",
"csv_blocks",
]
args += (recovery_xpub, csv_blocks)
return self._jadeRpc("get_receive_address", dict(zip(keys, args)))
# Sign a message
def sign_message(self, path, message):
params = {"path": path, "message": message}
return self._jadeRpc("sign_message", params)
# Get a Liquid public blinding key for a given script
def get_blinding_key(self, script):
params = {"script": script}
return self._jadeRpc("get_blinding_key", params)
# Get the shared secret to unblind a tx, given the receiving script on
# our side and the pubkey of the sender (sometimes called "nonce" in
# Liquid)
def get_shared_nonce(self, script, their_pubkey):
params = {"script": script, "their_pubkey": their_pubkey}
return self._jadeRpc("get_shared_nonce", params)
# Get a "trusted" blinding factor to blind an output. Normally the blinding
# factors are generated and returned in the `get_commitments` call, but
# for the last output the VBF must be generated on the host side, so this
# call allows the host to get a valid ABF to compute the generator and
# then the "final" VBF. Nonetheless, this call is kept generic, and can
# also generate VBFs, thus the "type" parameter.
# `hash_prevouts` is computed as specified in BIP143 (double SHA of all
# the outpoints being spent as input. It's not checked right away since
# at this point Jade doesn't know anything about the tx we are referring
# to. It will be checked later during `sign_liquid_tx`.
# `output_index` is the output we are trying to blind.
# `type` can either be "ASSET" or "VALUE" to generate ABFs or VBFs.
def get_blinding_factor(self, hash_prevouts, output_index, type):
params = {
"hash_prevouts": hash_prevouts,
"output_index": output_index,
"type": type,
}
return self._jadeRpc("get_blinding_factor", params)
# Generate the blinding factors and commitments for a given output.
# Can optionally get a "custom" VBF, normally used for the last
# input where the VBF is not random, but generated accordingly to
# all the others.
# `hash_prevouts` and `output_index` have the same meaning as in
# the `get_blinding_factor` call.
# NOTE: the `asset_id` should be passed as it is normally displayed, so
# reversed compared to the "consensus" representation.
def get_commitments(self, asset_id, value, hash_prevouts, output_index, vbf=None):
params = {
"asset_id": asset_id,
"value": value,
"hash_prevouts": hash_prevouts,
"output_index": output_index,
}
if vbf is not None:
params["vbf"] = vbf
return self._jadeRpc("get_commitments", params)
# Sign a Liquid txn
def sign_liquid_tx(self, network, txn, inputs, commitments, change):
# Protocol:
# 1st message contains txn and number of inputs we are going to send.
# Reply ok if that corresponds to the expected number of inputs (n).
# Then we send one message per input - without expecting replies.
# Once all n input messages are sent, the hw then sends all n replies
# (as the user has a chance to confirm/cancel at this point).
# Then receive all n replies for the n signatures.
# NOTE: *NOT* a sequence of n blocking rpc calls.
base_id = 100 * random.randint(1000, 9999)
params = {
"network": network,
"txn": txn,
"num_inputs": len(inputs),
"trusted_commitments": commitments,
"change": change,
}
reply = self._jadeRpc("sign_liquid_tx", params, str(base_id))
assert reply
# Send all n inputs
requests = []
for (i, txinput) in enumerate(inputs, 1):
res_id = str(base_id + i)
request = self.jade.build_request(res_id, "tx_input", txinput)
self.jade.write_request(request)
requests.append(request)
time.sleep(0.1)
# Receive all n signatures
signatures = []
for request in requests:
reply = self.jade.read_response()
self.jade.validate_reply(request, reply)
signature = self._get_result_or_raise_error(reply)
signatures.append(signature)
assert len(signatures) == len(inputs)
return signatures
# Sign a txn
def sign_tx(self, network, txn, inputs, change):
# Protocol:
# 1st message contains txn and number of inputs we are going to send.
# Reply ok if that corresponds to the expected number of inputs (n).
# Then we send one message per input - without expecting replies.
# Once all n input messages are sent, the hw then sends all n replies
# (as the user has a chance to confirm/cancel at this point).
# Then receive all n replies for the n signatures.
# NOTE: *NOT* a sequence of n blocking rpc calls.
base_id = 100 * random.randint(1000, 9999)
params = {
"network": network,
"txn": txn,
"num_inputs": len(inputs),
"change": change,
}
reply = self._jadeRpc("sign_tx", params, str(base_id))
assert reply
# Send all n inputs
requests = []
for (i, txinput) in enumerate(inputs, 1):
res_id = str(base_id + i)
request = self.jade.build_request(res_id, "tx_input", txinput)
self.jade.write_request(request)
requests.append(request)
time.sleep(0.1)
# Receive all n signatures
signatures = []
for request in requests:
reply = self.jade.read_response()
self.jade.validate_reply(request, reply)
signature = self._get_result_or_raise_error(reply)
signatures.append(signature)
assert len(signatures) == len(inputs)
return signatures
#
# Mid-level interface to Jade
# Wraps either a serial or a ble connection
# Calls to send and receive bytes and cbor messages over the interface.
#
# Either:
# a) use wrapped with JadeAPI
# (recommended)
# or:
# b) use with JadeInterface.create_[serial|ble]() as jade:
# ...
# or:
# c) use JadeInterface.create_[serial|ble], then call connect() before
# using, and disconnect() when finished
# (caveat cranium)
# or:
# d) use ctor to wrap existing low-level implementation instance
# (caveat cranium)
#
class JadeInterface:
def __init__(self, impl):
assert impl is not None
self.impl = impl
def __enter__(self):
self.connect()
return self
def __exit__(self, exc_type, exc, tb):
if exc_type:
logger.error("Exception causing JadeInterface context exit.")
logger.error(exc_type)
logger.error(exc)
traceback.print_tb(tb)
self.disconnect(exc_type is not None)
@staticmethod
def create_serial(device=None, baud=None, timeout=None):
impl = JadeSerialImpl(
device or DEFAULT_SERIAL_DEVICE,
baud or DEFAULT_BAUD_RATE,
timeout or DEFAULT_SERIAL_TIMEOUT,
)
return JadeInterface(impl)
@staticmethod
def create_ble(device_name=None, serial_number=None, scan_timeout=None, loop=None):
impl = JadeBleImpl(
device_name or DEFAULT_BLE_DEVICE_NAME,
serial_number or DEFAULT_BLE_SERIAL_NUMBER,
scan_timeout or DEFAULT_BLE_SCAN_TIMEOUT,
loop=loop,
)
return JadeInterface(impl)
def connect(self):
self.impl.connect()
def disconnect(self, drain=False):
if drain:
self.drain()
self.impl.disconnect()
def drain(self):
logger.warn("Draining interface...")
drained = bytearray()
finished = False
while not finished:
byte_ = self.impl.read(1)
drained.extend(byte_)
finished = byte_ == b""
if finished or byte_ == b"\n" or len(drained) > 256:
try:
device_logger.warn(drained.decode("utf-8"))
except Exception as e:
# Dump the bytes raw and as hex if decoding as utf-8 failed
device_logger.warn("Raw:")
device_logger.warn(drained)
device_logger.warn("----")
device_logger.warn("Hex dump:")
device_logger.warn(drained.hex())
# Clear and loop to continue collecting
drained.clear()
@staticmethod
def build_request(input_id, method, params=None):
request = {"method": method, "id": input_id}
if params is not None:
request["params"] = params
return request
@staticmethod
def serialise_cbor_request(request):
dump = cbor.dumps(request)
len_dump = len(dump)
if "method" in request and "ota_data" in request["method"]:
msg = "Sending ota_data message {} as cbor of size {}".format(
request["id"], len_dump
)
logger.info(msg)
else:
logger.info("Sending: {} as cbor of size {}".format(request, len_dump))
return dump
def write(self, bytes_):
logger.debug("Sending: {} bytes".format(len(bytes_)))
wrote = self.impl.write(bytes_)
logger.debug("Sent: {} bytes".format(len(bytes_)))
return wrote
def write_request(self, request):
msg = self.serialise_cbor_request(request)
written = 0
while written < len(msg):
written += self.write(msg[written:])
def read(self, n):
logger.debug("Reading {} bytes...".format(n))
bytes_ = self.impl.read(n)
logger.debug("Received: {} bytes".format(len(bytes_)))
return bytes_
def read_cbor_message(self):
while True:
# 'self' is sufficiently 'file-like' to act as a load source.
# Throws EOFError on end of stream/timeout/lost-connection etc.
message = cbor.load(self)
# A message response (to a prior request)
if "id" in message:
logger.info("Received msg: {}".format(message))
return message
# A log message - handle as normal
if "log" in message:
response = message["log"].decode("utf-8")
log_methods = {
"E": device_logger.error,
"W": device_logger.warn,
"I": device_logger.info,
"D": device_logger.debug,
"V": device_logger.debug,
}
log_method = device_logger.error
if len(response) > 1 and response[1] == " ":
lvl = response[0]
log_method = log_methods.get(lvl, device_logger.error)
log_method(">> {}".format(response))
else:
# Unknown/unhandled/unexpected message
logger.error("Unhandled message received")
device_logger.error(message)
def read_response(self, long_timeout=False):
while True:
try:
return self.read_cbor_message()
except EOFError as e:
if not long_timeout:
raise
@staticmethod
def validate_reply(request, reply):
assert isinstance(reply, dict) and "id" in reply
assert ("result" in reply) != ("error" in reply)
assert reply["id"] == request["id"] or reply["id"] == "00" and "error" in reply
def make_rpc_call(self, request, long_timeout=False):
# Write outgoing request message
assert isinstance(request, dict)
assert "id" in request and len(request["id"]) > 0
assert "method" in request and len(request["method"]) > 0
assert len(request["id"]) < 16 and len(request["method"]) < 32
self.write_request(request)
# Read and validate incoming message
reply = self.read_response(long_timeout)
self.validate_reply(request, reply)
return reply

View file

@ -0,0 +1,19 @@
class JadeError(Exception):
def __init__(self, code, message, data):
self.code = code
self.message = message
self.data = data
def __repr__(self):
return (
"JadeError: "
+ str(self.code)
+ " - "
+ self.message
+ " (Data: "
+ repr(self.data)
+ ")"
)
def __str__(self):
return repr(self)

View file

@ -0,0 +1,52 @@
import serial
import logging
logger = logging.getLogger("jade.serial")
#
# Low-level Serial backend interface to Jade
# Calls to send and receive bytes over the interface.
# Intended for use via JadeInterface wrapper.
#
# Either:
# a) use via JadeInterface.create_serial() (see JadeInterface)
# (recommended)
# or:
# b) use JadeSerialImpl() directly, and call connect() before
# using, and disconnect() when finished,
# (caveat cranium)
#
class JadeSerialImpl:
def __init__(self, device, baud, timeout):
self.device = device
self.baud = baud
self.timeout = timeout
self.ser = None
def connect(self):
assert self.ser is None
logger.info("Connecting to {} at {}".format(self.device, self.baud))
self.ser = serial.Serial(
self.device, self.baud, timeout=self.timeout, write_timeout=self.timeout
)
assert self.ser is not None
self.ser.__enter__()
logger.info("Connected")
def disconnect(self):
if self.ser is not None:
self.ser.__exit__()
# Reset state
self.ser = None
def write(self, bytes_):
assert self.ser is not None
return self.ser.write(bytes_)
def read(self, n):
assert self.ser is not None
return self.ser.read(n)

View file

@ -0,0 +1,29 @@
from .hwi_device import HWIDevice
from .hwi.jade import JadeClient, enumerate
class Jade(HWIDevice):
device_type = "jade"
name = "Jade"
icon = "jade_icon.svg"
supports_hwi_toggle_passphrase = False
supports_hwi_multisig_display_address = False
@classmethod
def get_client(cls, *args, **kwargs):
return JadeClient(*args, **kwargs)
@classmethod
def enumerate(cls, *args, **kwargs):
return enumerate(*args, **kwargs)
def has_key_types(self, wallet_type, network="main"):
if wallet_type == "multisig":
return False
return super().has_key_types(wallet_type, network)
def no_key_found_reason(self, wallet_type, network="main"):
if wallet_type == "multisig":
return "Jade does not yet support multisig wallets."
return super().no_key_found_reason(wallet_type, network)

View file

@ -17,6 +17,7 @@ from flask import current_app as app
from .helpers import deep_update, hwi_get_config, save_hwi_bridge_config
from hwilib.devices.bitbox02 import Bitbox02Client
from .devices.hwi.specter_diy import SpecterClient
from .devices.hwi.jade import JadeClient
logger = logging.getLogger(__name__)
@ -417,6 +418,9 @@ class HWIBridge(JSONRPC):
if chain == "liquidv1":
chain = "main"
client.chain = Chain.argparse(chain)
if type(client) is JadeClient:
if chain == "signet":
client.chain = Chain.TEST
yield client
finally:
client.close()

View file

@ -104,6 +104,7 @@ class DeviceManager:
device_class
for device_class in device_classes
if device_class.device_type != "bitcoincore"
and device_class.device_type != "elementscore"
]
elif specter.is_liquid:
return [

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 2000 2000" style="enable-background:new 0 0 2000 2000;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
</style>
<g>
<polygon class="st0" points="961.2,1241 756.9,1037.4 357.7,1037.4 961.2,1639.2 "/>
<polygon class="st0" points="1036.3,1642.3 1639.9,1037.4 1241.4,1037.4 1036.3,1242.8 "/>
<polygon class="st0" points="1036.3,358 1036.3,756.2 1243.1,962.3 1642.3,962.3 "/>
<polygon class="st0" points="960.9,357.7 357.7,962.3 756.2,962.3 960.9,756.9 "/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 767 B

1
udev/55-usb-jade.rules Normal file
View file

@ -0,0 +1 @@
KERNEL=="ttyUSB*", SUBSYSTEMS=="usb", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", MODE="0660", GROUP="plugdev", TAG+="uaccess", TAG+="udev-acl", SYMLINK+="jade%n"

View file

@ -21,4 +21,4 @@ $ sudo udevadm trigger
$ sudo udevadm control --reload-rules
$ sudo groupadd plugdev
$ sudo usermod -aG plugdev `whoami`
```
```