mirror of
https://github.com/cryptoadvance/specter-desktop.git
synced 2026-08-13 12:33:29 +02:00
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:
parent
ccfd335ade
commit
ba4699b80b
15 changed files with 1076 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
369
src/cryptoadvance/specter/devices/hwi/jade.py
Normal file
369
src/cryptoadvance/specter/devices/hwi/jade.py
Normal 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
|
||||
9
src/cryptoadvance/specter/devices/hwi/jadepy/README.md
Normal file
9
src/cryptoadvance/specter/devices/hwi/jadepy/README.md
Normal 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
|
||||
4
src/cryptoadvance/specter/devices/hwi/jadepy/__init__.py
Normal file
4
src/cryptoadvance/specter/devices/hwi/jadepy/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from .jade import JadeAPI
|
||||
from .jade_error import JadeError
|
||||
|
||||
__version__ = "0.0.1"
|
||||
567
src/cryptoadvance/specter/devices/hwi/jadepy/jade.py
Normal file
567
src/cryptoadvance/specter/devices/hwi/jadepy/jade.py
Normal 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
|
||||
19
src/cryptoadvance/specter/devices/hwi/jadepy/jade_error.py
Normal file
19
src/cryptoadvance/specter/devices/hwi/jadepy/jade_error.py
Normal 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)
|
||||
52
src/cryptoadvance/specter/devices/hwi/jadepy/jade_serial.py
Normal file
52
src/cryptoadvance/specter/devices/hwi/jadepy/jade_serial.py
Normal 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)
|
||||
29
src/cryptoadvance/specter/devices/jade.py
Normal file
29
src/cryptoadvance/specter/devices/jade.py
Normal 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)
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
14
src/cryptoadvance/specter/static/img/devices/jade_icon.svg
Normal file
14
src/cryptoadvance/specter/static/img/devices/jade_icon.svg
Normal 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
1
udev/55-usb-jade.rules
Normal 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"
|
||||
|
|
@ -21,4 +21,4 @@ $ sudo udevadm trigger
|
|||
$ sudo udevadm control --reload-rules
|
||||
$ sudo groupadd plugdev
|
||||
$ sudo usermod -aG plugdev `whoami`
|
||||
```
|
||||
```
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue