From 471d0e03e7decdcb9399f289c996a3e1d9c6ba74 Mon Sep 17 00:00:00 2001 From: "Jamie C. Driver" Date: Tue, 8 Feb 2022 15:54:29 +0000 Subject: [PATCH] Add support for the Blockstream Jade hww Supports ssh and gpg, incl. ecdh/decryption. Initially only supports curve 'nist256p1'. --- README.md | 1 + agents/jade/jade_agent.py | 7 ++ agents/jade/setup.py | 45 +++++++++++++ doc/DESIGN.md | 2 +- doc/INSTALL.md | 26 ++++++- doc/README-GPG.md | 8 +-- doc/README-SSH.md | 18 ++--- libagent/device/jade.py | 138 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 230 insertions(+), 15 deletions(-) create mode 100644 agents/jade/jade_agent.py create mode 100644 agents/jade/setup.py create mode 100644 libagent/device/jade.py diff --git a/README.md b/README.md index 11790f9..fd08dce 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ agents to interact with several different hardware devices: * [`libagent`](https://pypi.org/project/libagent/): shared library * [`trezor-agent`](https://pypi.org/project/trezor-agent/): Using Trezor as hardware-based SSH/PGP agent * [`ledger_agent`](https://pypi.org/project/ledger_agent/): Using Ledger as hardware-based SSH/PGP agent +* [`jade_agent`](https://pypi.org/project/jade_agent/): Using Blockstream Jade as hardware-based SSH/PGP agent * [`keepkey_agent`](https://pypi.org/project/keepkey_agent/): Using KeepKey as hardware-based SSH/PGP agent * [`onlykey-agent`](https://pypi.org/project/onlykey-agent/): Using OnlyKey as hardware-based SSH/PGP agent diff --git a/agents/jade/jade_agent.py b/agents/jade/jade_agent.py new file mode 100644 index 0000000..ca8a97f --- /dev/null +++ b/agents/jade/jade_agent.py @@ -0,0 +1,7 @@ +import libagent.gpg +import libagent.ssh +from libagent.device.jade import BlockstreamJade as DeviceType + +ssh_agent = lambda: libagent.ssh.main(DeviceType) +gpg_tool = lambda: libagent.gpg.main(DeviceType) +gpg_agent = lambda: libagent.gpg.run_agent(DeviceType) diff --git a/agents/jade/setup.py b/agents/jade/setup.py new file mode 100644 index 0000000..319b55e --- /dev/null +++ b/agents/jade/setup.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python +from setuptools import setup + +setup( + name='jade_agent', + version='0.1.0', + description='Using Blockstream Jade as hardware SSH agent', + author='Jamie C. Driver', + author_email='jamie@blockstream.com', + url='http://github.com/romanz/trezor-agent', + scripts=['jade_agent.py'], + install_requires=[ + # FIXME: will need libagent version that includes jade support - 0.14.5 ?? + # FIXME: will need to put the tag version just as we are about to apply it ? + 'libagent>=0.14.4', + # Jade py api from github source, v0.1.33 + 'jadepy @ git+https://github.com/Blockstream/Jade.git@0.1.33#egg=jadepy[requests]' + ], + # Not sure why this doesn't work ... + # dependency_links=['https://github.com/Blockstream/Jade/tarball/0.1.33#egg=jadepy[requests]'], + platforms=['POSIX'], + classifiers=[ + 'Environment :: Console', + 'Development Status :: 4 - Beta', + 'Intended Audience :: Developers', + 'Intended Audience :: Information Technology', + 'Intended Audience :: System Administrators', + 'License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)', + 'Operating System :: POSIX', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Topic :: Software Development :: Libraries :: Python Modules', + 'Topic :: System :: Networking', + 'Topic :: Communications', + 'Topic :: Security', + 'Topic :: Utilities', + ], + entry_points={'console_scripts': [ + 'jade-agent = jade_agent:ssh_agent', + 'jade-gpg = jade_agent:gpg_tool', + 'jade-gpg-agent = jade_agent:gpg_agent', + ]}, +) diff --git a/doc/DESIGN.md b/doc/DESIGN.md index 1c06a72..dd671be 100644 --- a/doc/DESIGN.md +++ b/doc/DESIGN.md @@ -6,7 +6,7 @@ SSH and GPG do this by means of a simple interprocess communication protocol (us These two agents make the connection between the front end (e.g. a `gpg --sign` command, or an `ssh user@fqdn`). And then they wait for a request from the 'front end', and then do the actual asking for a password and subsequent using the private key to sign or decrypt something. -The various hardware wallets (Trezor, KeepKey and Ledger) each have the ability (as of Firmware 1.3.4) to use the NIST P-256 elliptic curve to sign, encrypt or decrypt. This curve can be used with S/MIME, GPG and SSH. +The various hardware wallets (Trezor, KeepKey, Ledger and Jade) each have the ability (as of Firmware 1.3.4) to use the NIST P-256 elliptic curve to sign, encrypt or decrypt. This curve can be used with S/MIME, GPG and SSH. So when you `ssh` to a machine - rather than consult the normal ssh-agent (which in turn will use your private SSH key in files such as `~/.ssh/id_rsa`) -- the trezor-agent will aks your hardware wallet to use its private key to sign the challenge. diff --git a/doc/INSTALL.md b/doc/INSTALL.md index ca7cf2e..8c82cd4 100644 --- a/doc/INSTALL.md +++ b/doc/INSTALL.md @@ -152,7 +152,31 @@ Then, install the latest [keepkey_agent](https://pypi.python.org/pypi/keepkey_ag $ pip3 install --user -e trezor-agent/agents/onlykey ``` -# 6. Installation Troubleshooting +# 6. Install the Blockstream Jade agent + +1. Make sure you are running the latest firmware version on your Blockstream Jade: + + * [Jade firmware releases](https://github.com/Blockstream/Jade/blob/master/CHANGELOG.md): `0.1.33+` + +2. Make sure that your `udev` rules are configured [correctly](https://github.com/bitcoin-core/HWI/blob/master/hwilib/udev/55-usb-jade.rules). + +3. If necessary, ensure the user is added to the [`dialout` group](https://help.blockstream.com/hc/en-us/articles/900005443223-My-Blockstream-Jade-is-not-recognized-by-my-computer) + +4. Then, install the latest [jade-agent](https://pypi.python.org/pypi/jade-agent) package: + + ``` + $ pip3 install jade-agent + ``` + + Or, directly from the latest source code: + + ``` + $ git clone https://github.com/romanz/trezor-agent + $ pip3 install --user -e trezor-agent + $ pip3 install --user -e trezor-agent/agents/jade + ``` + +# 7. Installation Troubleshooting If there is an import problem with the installed `protobuf` package, see [this issue](https://github.com/romanz/trezor-agent/issues/28) for fixing it. diff --git a/doc/README-GPG.md b/doc/README-GPG.md index a8db9b8..8a46955 100644 --- a/doc/README-GPG.md +++ b/doc/README-GPG.md @@ -5,7 +5,7 @@ and please let me [know](https://github.com/romanz/trezor-agent/issues/new) if s work well for you. If possible: * record the session (e.g. using [asciinema](https://asciinema.org)) - * attach the GPG agent log from `~/.gnupg/{trezor,ledger}/gpg-agent.log` (can be [encrypted](https://keybase.io/romanz)) + * attach the GPG agent log from `~/.gnupg/{trezor,ledger,jade}/gpg-agent.log` (can be [encrypted](https://keybase.io/romanz)) Thanks! @@ -18,14 +18,14 @@ Thanks! Run ``` - $ (trezor|keepkey|ledger|onlykey)-gpg init "Roman Zeyde " + $ (trezor|keepkey|ledger|jade|onlykey)-gpg init "Roman Zeyde " ``` Follow the instructions provided to complete the setup. Keep note of the timestamp value which you'll need if you want to regenerate the key later. If you'd like a Trezor-style PIN entry program, follow [these instructions](README-PINENTRY.md). -2. Add `export GNUPGHOME=~/.gnupg/(trezor|keepkey|ledger|onlykey)` to your `.bashrc` or other environment file. +2. Add `export GNUPGHOME=~/.gnupg/(trezor|keepkey|ledger|jade|onlykey)` to your `.bashrc` or other environment file. This `GNUPGHOME` contains your hardware keyring and agent settings. This agent software assumes all keys are backed by hardware devices so you can't use standard GPG keys in `GNUPGHOME` (if you do mix keys you'll receive an error when you attempt to use them). @@ -203,7 +203,7 @@ Follow [these instructions](enigmail.md) to set up Enigmail in Thunderbird. ##### 1. Create these files in `~/.config/systemd/user` -Replace `trezor` with `keepkey` or `ledger` or `onlykey` as required. +Replace `trezor` with `keepkey` or `ledger` or `jade` or `onlykey` as required. ###### `trezor-gpg-agent.service` diff --git a/doc/README-SSH.md b/doc/README-SSH.md index 6694073..28f306a 100644 --- a/doc/README-SSH.md +++ b/doc/README-SSH.md @@ -4,13 +4,13 @@ SSH requires no configuration, but you may put common command line options in `~/.ssh/agent.conf` to avoid repeating them in every invocation. -See `(trezor|keepkey|ledger|onlykey)-agent -h` for details on supported options and the configuration file format. +See `(trezor|keepkey|ledger|jade|onlykey)-agent -h` for details on supported options and the configuration file format. If you'd like a Trezor-style PIN entry program, follow [these instructions](README-PINENTRY.md). ## 2. Usage -Use the `(trezor|keepkey|ledger|onlykey)-agent` program to work with SSH. It has three main modes of operation: +Use the `(trezor|keepkey|ledger|jade|onlykey)-agent` program to work with SSH. It has three main modes of operation: ##### 1. Export public keys @@ -18,7 +18,7 @@ To get your public key so you can add it to `authorized_hosts` or allow ssh access to a service that supports it, run: ``` -(trezor|keepkey|ledger|onlykey)-agent identity@myhost +(trezor|keepkey|ledger|jade|onlykey)-agent identity@myhost ``` The identity (ex: `identity@myhost`) is used to derive the public key and is added as a comment to the exported key string. @@ -28,7 +28,7 @@ The identity (ex: `identity@myhost`) is used to derive the public key and is add Run ``` -$ (trezor|keepkey|ledger|onlykey)-agent identity@myhost -- COMMAND --WITH --ARGUMENTS +$ (trezor|keepkey|ledger|jade|onlykey)-agent identity@myhost -- COMMAND --WITH --ARGUMENTS ``` to start the agent in the background and execute the command with environment variables set up to use the SSH agent. The specified identity is used for all SSH connections. The agent will exit after the command completes. @@ -36,23 +36,23 @@ Note the `--` separator, which is used to separate `trezor-agent`'s arguments fr Example: ``` - (trezor|keepkey|ledger|onlykey)-agent -e ed25519 bob@example.com -- rsync up/ bob@example.com:/home/bob + (trezor|keepkey|ledger|jade|onlykey)-agent -e ed25519 bob@example.com -- rsync up/ bob@example.com:/home/bob ``` As a shortcut you can run ``` -$ (trezor|keepkey|ledger|onlykey)-agent identity@myhost -s +$ (trezor|keepkey|ledger|jade|onlykey)-agent identity@myhost -s ``` to start a shell with the proper environment. -##### 3. Connect to a server directly via `(trezor|keepkey|ledger|onlykey)-agent` +##### 3. Connect to a server directly via `(trezor|keepkey|ledger|jade|onlykey)-agent` If you just want to connect to a server this is the simplest way to do it: ``` -$ (trezor|keepkey|ledger|onlykey)-agent user@remotehost -c +$ (trezor|keepkey|ledger|jade|onlykey)-agent user@remotehost -c ``` The identity `user@remotehost` is used as both the destination user and host as well as for key derivation, so you must generate a separate key for each host you connect to. @@ -118,7 +118,7 @@ The same works for Mercurial (e.g. on [BitBucket](https://confluence.atlassian.c ##### 1. Create these files in `~/.config/systemd/user` -Replace `trezor` with `keepkey` or `ledger` or `onlykey` as required. +Replace `trezor` with `keepkey` or `ledger` or `jade` or `onlykey` as required. ###### `trezor-ssh-agent.service` diff --git a/libagent/device/jade.py b/libagent/device/jade.py new file mode 100644 index 0000000..8318a98 --- /dev/null +++ b/libagent/device/jade.py @@ -0,0 +1,138 @@ +"""Jade-related code (see https://www.keepkey.com/).""" + +import ecdsa +import logging +import semver +from serial.tools import list_ports + +from jadepy import JadeAPI + +from . import interface +from .. import formats +from .. import util + +log = logging.getLogger(__name__) + + +def _verify_support(identity, ecdh): + """Make sure the device supports given configuration.""" + if identity.get_curve_name(ecdh=ecdh) != formats.CURVE_NIST256: + raise NotImplementedError( + 'Unsupported elliptic curve: {}'.format(identity.curve_name)) + + +class BlockstreamJade(interface.Device): + """Connection to Blockstream Jade device.""" + MIN_SUPPORTED_FW_VERSION = semver.VersionInfo(0, 1, 33) + DEVICE_IDS = [(0x10c4, 0xea60), (0x1a86, 0x55d4)] + connection = None + + @classmethod + def package_name(cls): + """Python package name (at PyPI).""" + return 'jade-agent' + + def connect(self): + # Return the existing connection if we have one + if BlockstreamJade.connection is not None: + return BlockstreamJade.connection + + # Jade is a serial (over usb) device, it shows as a serial/com port device. + # Scan com ports looking for the relevant vid and pid, and connect to the + # first matching device. Then call 'auth_user' - this usually requires network + # access in order to unlock the device with a PIN and the remote blind pinserver. + devices = [] + for devinfo in list_ports.comports(): + device_product_key = (devinfo.vid, devinfo.pid) + if device_product_key in self.DEVICE_IDS: + try: + jade = JadeAPI.create_serial(devinfo.device) + + # Monkey-patch a no-op 'close()' method to suppress logged errors + jade.close = lambda: log.debug("Close called") + + # Connect and fetch version info + jade.connect() + verinfo = jade.get_version_info() + + # Check minimum supported firmware version (ignore candidate/build parts) + fwversion = semver.VersionInfo.parse(verinfo['JADE_VERSION']) + if self.MIN_SUPPORTED_FW_VERSION > fwversion.finalize_version(): + msg = ('Outdated {} firmware for device. Please update using' + ' a Blockstream Green companion app') + raise ValueError(msg.format(fwversion)) + + # Authenticate the user (unlock with pin) + # NOTE: usually requires network access unless already unlocked + # (or temporary 'Emergency Restore' wallet is already in use). + network = 'testnet' if verinfo.get('JADE_NETWORKS') == 'TEST' else 'mainnet' + while not jade.auth_user(network): + log.warning("PIN incorrect, please try again") + + # Cache the connection to jade + BlockstreamJade.connection = jade + return jade + except Exception as e: + raise interface.NotFoundError( + '{} not connected: "{}"'.format(self, e)) + + @staticmethod + def get_identity_string(identity): + return interface.identity_to_string(identity.identity_dict) + + @staticmethod + def load_uncompressed_pubkey(pubkey, curve_name): + assert curve_name == formats.CURVE_NIST256 + assert len(pubkey) == 65 and pubkey[0] == 0x04 + curve = ecdsa.NIST256p + point = ecdsa.ellipticcurve.Point(curve.curve, + util.bytes2num(pubkey[1:33]), + util.bytes2num(pubkey[33:65])) + return ecdsa.VerifyingKey.from_public_point(point, curve=curve, + hashfunc=formats.hashfunc) + + def pubkey(self, identity, ecdh=False): + """Get PublicKey object for specified BIP32 address and elliptic curve.""" + _verify_support(identity, ecdh) + identity_string = self.get_identity_string(identity) + curve_name = identity.get_curve_name(ecdh=ecdh) + key_type = 'slip-0017' if ecdh else 'slip-0013' + + log.debug('"%s" getting %s public key (%s) from %s', + identity_string, key_type, curve_name, self) + result = self.conn.get_identity_pubkey(identity_string, curve_name, key_type) + log.debug('result: %s', result) + + assert len(result) == 33 or len(result) == 65 + convert_pubkey = formats.decompress_pubkey if len(result) == 33 else self.load_uncompressed_pubkey + return convert_pubkey(pubkey=result, curve_name=curve_name) + + def sign(self, identity, blob): + """Sign given blob and return the signature (as bytes).""" + _verify_support(identity, ecdh=False) + identity_string = self.get_identity_string(identity) + curve_name = identity.get_curve_name(ecdh=False) + + log.debug('"%s" signing %r (%s) on %s', + identity_string, blob, curve_name, self) + result = self.conn.sign_identity(identity_string, curve_name, blob) + log.debug('result: %s', result) + + signature = result['signature'] + assert len(signature) == 64 or (len(signature) == 65 and signature[0] == 0x00) + if len(signature) == 65: + signature = signature[1:] + return signature + + def ecdh(self, identity, pubkey): + """Get shared session key using Elliptic Curve Diffie-Hellman.""" + _verify_support(identity, ecdh=True) + identity_string = self.get_identity_string(identity) + curve_name = identity.get_curve_name(ecdh=True) + + log.debug('"%s" shared session key (%s) for %r from %s', + identity_string, curve_name, pubkey, self) + result = self.conn.get_identity_shared_key(identity_string, curve_name, pubkey) + log.debug('result: %s', result) + + return result