From 498093f2f6b404903578277224a912e131b7313b Mon Sep 17 00:00:00 2001 From: Taylor Buchanan Date: Sat, 18 Sep 2021 14:00:20 -0500 Subject: [PATCH 01/35] Add Windows native SSH support --- libagent/device/ui.py | 6 +- libagent/ssh/__init__.py | 110 +++++++++++++++------- libagent/win_server.py | 191 +++++++++++++++++++++++++++++++++++++++ setup.py | 1 + tox.ini | 1 + 5 files changed, 275 insertions(+), 34 deletions(-) create mode 100644 libagent/win_server.py diff --git a/libagent/device/ui.py b/libagent/device/ui.py index 00486262..ebc88995 100644 --- a/libagent/device/ui.py +++ b/libagent/device/ui.py @@ -78,7 +78,8 @@ def button_request(self, _code=None): def create_default_options_getter(): """Return current TTY and DISPLAY settings for GnuPG pinentry.""" options = [] - if sys.stdin.isatty(): # short-circuit calling `tty` + # Windows reports that it has a TTY but throws FileNotFoundError + if sys.platform != 'win32' and sys.stdin.isatty(): # short-circuit calling `tty` try: ttyname = subprocess.check_output(args=['tty']).strip() options.append(b'ttyname=' + ttyname) @@ -88,7 +89,8 @@ def create_default_options_getter(): display = os.environ.get('DISPLAY') if display is not None: options.append('display={}'.format(display).encode('ascii')) - else: + # Windows likely doesn't support this anyway + elif sys.platform != 'win32': log.warning('DISPLAY not defined') log.info('using %s for pinentry options', options) diff --git a/libagent/ssh/__init__.py b/libagent/ssh/__init__.py index 614404d1..3f265abe 100644 --- a/libagent/ssh/__init__.py +++ b/libagent/ssh/__init__.py @@ -4,24 +4,32 @@ import io import logging import os +import random import re import signal +import string import subprocess import sys import tempfile import threading import configargparse -import daemon +try: + # TODO: Not supported on Windows. Use daemoniker instead? + import daemon +except ImportError: + daemon = None import pkg_resources -from .. import device, formats, server, util +from .. import device, formats, server, util, win_server from . import client, protocol log = logging.getLogger(__name__) UNIX_SOCKET_TIMEOUT = 0.1 - +WIN_PIPE_TIMEOUT = 0.1 +DEFAULT_TIMEOUT = WIN_PIPE_TIMEOUT if sys.platform == 'win32' else UNIX_SOCKET_TIMEOUT +SOCK_TYPE = 'Windows named pipe' if sys.platform == 'win32' else 'UNIX domain socket' def ssh_args(conn): """Create SSH command for connecting specified server.""" @@ -35,7 +43,7 @@ def ssh_args(conn): if 'user' in identity: args += ['-l', identity['user']] - args += ['-o', 'IdentityFile={}'.format(pubkey_tempfile.name)] + args += ['-o', 'IdentityFile={}'.format(pubkey_tempfile)] args += ['-o', 'IdentitiesOnly=true'] return args + [identity['host']] @@ -83,14 +91,14 @@ def create_agent_parser(device_type): default=formats.CURVE_NIST256, help='specify ECDSA curve name: ' + curve_names) p.add_argument('--timeout', - default=UNIX_SOCKET_TIMEOUT, type=float, + default=DEFAULT_TIMEOUT, type=float, help='timeout for accepting SSH client connections') p.add_argument('--debug', default=False, action='store_true', help='log SSH protocol messages for debugging.') p.add_argument('--log-file', type=str, help='Path to the log file (to be written by the agent).') p.add_argument('--sock-path', type=str, - help='Path to the UNIX domain socket of the agent.') + help='Path to the ' + SOCK_TYPE + ' of the agent.') p.add_argument('--pin-entry-binary', type=str, default='pinentry', help='Path to PIN entry UI helper.') @@ -100,17 +108,20 @@ def create_agent_parser(device_type): help='Expire passphrase from cache after this duration.') g = p.add_mutually_exclusive_group() - g.add_argument('-d', '--daemonize', default=False, action='store_true', - help='Daemonize the agent and print its UNIX socket path') + if daemon: + g.add_argument('-d', '--daemonize', default=False, action='store_true', + help='Daemonize the agent and print its ' + SOCK_TYPE) g.add_argument('-f', '--foreground', default=False, action='store_true', - help='Run agent in foreground with specified UNIX socket path') + help='Run agent in foreground with specified ' + SOCK_TYPE) g.add_argument('-s', '--shell', default=False, action='store_true', help=('run ${SHELL} as subprocess under SSH agent, allowing ' 'regular SSH-based tools to be used in the shell')) g.add_argument('-c', '--connect', default=False, action='store_true', help='connect to specified host via SSH') - g.add_argument('--mosh', default=False, action='store_true', - help='connect to specified host via using Mosh') + # Windows doesn't have native mosh + if sys.platform != 'win32': + g.add_argument('--mosh', default=False, action='store_true', + help='connect to specified host via using Mosh') p.add_argument('identity', type=_to_unicode, default=None, help='proto://[user@]host[:port][/path]') @@ -119,18 +130,48 @@ def create_agent_parser(device_type): return p +def get_ssh_env(sock_path): + ssh_version = subprocess.check_output(['ssh', '-V'], + stderr=subprocess.STDOUT) + log.debug('local SSH version: %r', ssh_version) + return {'SSH_AUTH_SOCK': sock_path, 'SSH_AGENT_PID': str(os.getpid())} + + +# Windows doesn't support AF_UNIX yet +# https://bugs.python.org/issue33408 +@contextlib.contextmanager +def serve_win(handler, sock_path, timeout=WIN_PIPE_TIMEOUT): + """ + Start the ssh-agent server on a Windows named pipe. + """ + environ = get_ssh_env(sock_path) + device_mutex = threading.Lock() + quit_event = threading.Event() + handle_conn = functools.partial(win_server.handle_connection, + handler=handler, + mutex=device_mutex, + quit_event=quit_event) + kwargs = dict(pipe_name=sock_path, + handle_conn=handle_conn, + quit_event=quit_event, + timeout=timeout) + with server.spawn(win_server.server_thread, kwargs): + try: + yield environ + finally: + log.debug('closing server') + quit_event.set() + + @contextlib.contextmanager -def serve(handler, sock_path, timeout=UNIX_SOCKET_TIMEOUT): +def serve_unix(handler, sock_path, timeout=UNIX_SOCKET_TIMEOUT): """ Start the ssh-agent server on a UNIX-domain socket. If no connection is made during the specified timeout, retry until the context is over. """ - ssh_version = subprocess.check_output(['ssh', '-V'], - stderr=subprocess.STDOUT) - log.debug('local SSH version: %r', ssh_version) - environ = {'SSH_AUTH_SOCK': sock_path, 'SSH_AGENT_PID': str(os.getpid())} + environ = get_ssh_env(sock_path) device_mutex = threading.Lock() with server.unix_domain_socket_server(sock_path) as sock: sock.settimeout(timeout) @@ -154,12 +195,15 @@ def run_server(conn, command, sock_path, debug, timeout): ret = 0 try: handler = protocol.Handler(conn=conn, debug=debug) - with serve(handler=handler, sock_path=sock_path, - timeout=timeout) as env: + serve_platform = serve_win if sys.platform == 'win32' else serve_unix + with serve_platform(handler=handler, sock_path=sock_path, timeout=timeout) as env: if command: ret = server.run_process(command=command, environ=env) else: - signal.pause() # wait for signal (e.g. SIGINT) + try: + signal.pause() # wait for signal (e.g. SIGINT) + except AttributeError: + sys.stdin.read() # Windows doesn't support signal.pause except KeyboardInterrupt: log.info('server stopped') return ret @@ -221,10 +265,9 @@ def public_keys_as_files(self): """Store public keys as temporary SSH identity files.""" if not self.public_keys_tempfiles: for pk in self.public_keys(): - f = tempfile.NamedTemporaryFile(prefix='trezor-ssh-pubkey-', mode='w') - f.write(pk) - f.flush() - self.public_keys_tempfiles.append(f) + with tempfile.NamedTemporaryFile(prefix='trezor-ssh-pubkey-', mode='w', delete=False, newline='') as f: + f.write(pk) + self.public_keys_tempfiles.append(f.name) return self.public_keys_tempfiles @@ -241,13 +284,16 @@ def _dummy_context(): def _get_sock_path(args): sock_path = args.sock_path - if not sock_path: - if args.foreground: - log.error('running in foreground mode requires specifying UNIX socket path') - sys.exit(1) - else: - sock_path = tempfile.mktemp(prefix='trezor-ssh-agent-') - return sock_path + if sock_path: + return sock_path + elif args.foreground: + log.error('running in foreground mode requires specifying ' + SOCK_TYPE) + sys.exit(1) + elif sys.platform == 'win32': + suffix = random.choices(string.ascii_letters, k=10) + return '\\\\.\pipe\\trezor-ssh-agent-' + ''.join(suffix) + else: + return tempfile.mktemp(prefix='trezor-ssh-agent-') @handle_connection_error @@ -286,7 +332,7 @@ def main(device_type): command = ['ssh'] + ssh_args(conn) + args.command elif args.mosh: command = ['mosh'] + mosh_args(conn) + args.command - elif args.daemonize: + elif daemon and args.daemonize: out = 'SSH_AUTH_SOCK={0}; export SSH_AUTH_SOCK;\n'.format(sock_path) sys.stdout.write(out) sys.stdout.flush() @@ -300,7 +346,7 @@ def main(device_type): command = os.environ['SHELL'] sys.stdin.close() - if command or args.daemonize or args.foreground: + if command or (daemon and args.daemonize) or args.foreground: with context: return run_server(conn=conn, command=command, sock_path=sock_path, debug=args.debug, timeout=args.timeout) diff --git a/libagent/win_server.py b/libagent/win_server.py new file mode 100644 index 00000000..2bccffdd --- /dev/null +++ b/libagent/win_server.py @@ -0,0 +1,191 @@ +"""Windows named pipe server for ssh-agent implementation.""" +import logging +import pywintypes +import struct +import threading +import win32api +import win32event +import win32pipe +import win32file +import winerror + +from . import util + +log = logging.getLogger(__name__) + +PIPE_BUFFER_SIZE = 64 * 1024 + +# Make MemoryView look like a buffer to reuse util.recv +class MvBuffer: + def __init__(self, mv): + self.mv = mv + def read(self, n): + return self.mv[0:n] + +# Based loosely on https://docs.microsoft.com/en-us/windows/win32/ipc/multithreaded-pipe-server +class NamedPipe: + __frame_size_size = struct.calcsize('>L') + + def __close(handle): + """Closes a named pipe handle.""" + if handle == win32file.INVALID_HANDLE_VALUE: + return + win32file.FlushFileBuffers(handle) + win32pipe.DisconnectNamedPipe(handle) + win32api.CloseHandle(handle) + + def open(name): + """Opens a named pipe server for receiving connections.""" + handle = win32pipe.CreateNamedPipe( + name, + win32pipe.PIPE_ACCESS_DUPLEX | win32file.FILE_FLAG_OVERLAPPED, + win32pipe.PIPE_TYPE_MESSAGE | win32pipe.PIPE_READMODE_MESSAGE | win32pipe.PIPE_WAIT, + win32pipe.PIPE_UNLIMITED_INSTANCES, + PIPE_BUFFER_SIZE, + PIPE_BUFFER_SIZE, + 0, + None) # Default security attributes + + if handle == win32file.INVALID_HANDLE_VALUE: + log.error("CreateNamedPipe failed (%d)", win32api.GetLastError()) + return None + + try: + pending_io = False + overlapped = win32file.OVERLAPPED() + overlapped.hEvent = win32event.CreateEvent(None, True, True, None) + error_code = win32pipe.ConnectNamedPipe(handle, overlapped) + if error_code == winerror.ERROR_IO_PENDING: + pending_io = True + elif error_code != winerror.ERROR_PIPE_CONNECTED or not win32event.SetEvent(overlapped.hEvent): + log.error('ConnectNamedPipe failed (%d)', error_code) + return None + log.debug('waiting for connection on %s', name) + return NamedPipe(name, handle, overlapped, pending_io) + except: + NamedPipe.__close(handle) + raise + + def __init__(self, name, handle, overlapped, pending_io): + self.name = name + self.handle = handle + self.overlapped = overlapped + self.pending_io = pending_io + + def close(self): + """Close the named pipe.""" + NamedPipe.__close(self.handle) + + def connect(self, timeout): + """Connect to an SSH client with the specified timeout.""" + waitHandle = win32event.WaitForSingleObject( + self.overlapped.hEvent, + timeout) + if waitHandle == win32event.WAIT_TIMEOUT: + return False + if not self.pending_io: + return True + win32pipe.GetOverlappedResult( + self.handle, + self.overlapped, + False) + error_code = win32api.GetLastError() + if error_code == winerror.NO_ERROR: + return True + log.error('GetOverlappedResult failed (%d)', error_code) + return False + + def read_frame(self, quit_event): + """Read the request frame from the SSH client.""" + request_size = None + remaining = None + buf = MvBuffer(win32file.AllocateReadBuffer(PIPE_BUFFER_SIZE)) + while True: + if quit_event.is_set(): + return None + error_code, _ = win32file.ReadFile(self.handle, buf.mv, self.overlapped) + if error_code not in (winerror.NO_ERROR, winerror.ERROR_IO_PENDING, winerror.ERROR_MORE_DATA): + log.error('ReadFile failed (%d)', error_code) + return None + win32event.WaitForSingleObject(self.overlapped.hEvent, win32event.INFINITE) + chunk_size = win32pipe.GetOverlappedResult(self.handle, self.overlapped, False) + error_code = win32api.GetLastError() + if error_code != winerror.NO_ERROR: + log.error('GetOverlappedResult failed (%d)', error_code) + return None + if request_size: + remaining -= chunk_size + else: + request_size, = util.recv(buf, '>L') + remaining = request_size - (chunk_size - NamedPipe.__frame_size_size) + if remaining <= 0: + break + return util.recv(buf, request_size) + + def send(self, reply): + """Send the specified reply to the SSH client.""" + error_code, _ = win32file.WriteFile(self.handle, reply) + if error_code == winerror.NO_ERROR: + return True + log.error('WriteFile failed (%d)', error_code) + return False + + +def handle_connection(pipe, handler, mutex, quit_event): + """ + Handle a single connection using the specified protocol handler in a loop. + + Since this function may be called concurrently from server_thread, + the specified mutex is used to synchronize the device handling. + """ + log.debug('welcome agent') + + try: + while True: + if quit_event.is_set(): + return + msg = pipe.read_frame(quit_event) + if not msg: + return + with mutex: + reply = handler.handle(msg=msg) + if not pipe.send(reply): + return + except pywintypes.error as e: + # Surface errors that aren't related to the client disconnecting + if e.args[0] == winerror.ERROR_BROKEN_PIPE: + log.debug('goodbye agent') + else: + raise + except Exception as e: # pylint: disable=broad-except + log.warning('error: %s', e, exc_info=True) + finally: + pipe.close() + + +def server_thread(pipe_name, handle_conn, quit_event, timeout): + """Run a Windows server on the specified pipe.""" + log.debug('server thread started') + + while True: + if quit_event.is_set(): + break + # A new pipe instance is necessary for each client + pipe = NamedPipe.open(pipe_name) + if not pipe: + break + try: + # Poll for a new client connection + while True: + if quit_event.is_set(): + break + if pipe.connect(timeout * 1000): + # Handle connections from SSH concurrently. + threading.Thread(target=handle_conn, + kwargs=dict(pipe=pipe)).start() + break + except: + pipe.close() + raise + + log.debug('server thread stopped') diff --git a/setup.py b/setup.py index 97b184a4..8f8b5cb4 100755 --- a/setup.py +++ b/setup.py @@ -27,6 +27,7 @@ 'pymsgbox>=1.0.6', 'semver>=2.2', 'unidecode>=0.4.20', + 'pypiwin32' ], platforms=['POSIX'], classifiers=[ diff --git a/tox.ini b/tox.ini index 3d6cc1a0..f4473213 100644 --- a/tox.ini +++ b/tox.ini @@ -14,6 +14,7 @@ deps= semver pydocstyle isort<5 + pypiwin32 commands= pycodestyle libagent isort --skip-glob .tox -c -rc libagent From 97416308edaa32fd95db62ba05fdb02639ab3cea Mon Sep 17 00:00:00 2001 From: Roman Zeyde Date: Tue, 25 Apr 2023 17:29:54 +0300 Subject: [PATCH 02/35] Remove unused imports and fix a small lint issue --- libagent/age/__init__.py | 6 +--- libagent/device/onlykey.py | 56 ++++++++++++++++++------------------ libagent/signify/__init__.py | 11 +------ 3 files changed, 30 insertions(+), 43 deletions(-) diff --git a/libagent/age/__init__.py b/libagent/age/__init__.py index 8637655a..95fd03ac 100644 --- a/libagent/age/__init__.py +++ b/libagent/age/__init__.py @@ -9,21 +9,17 @@ import argparse import base64 -import contextlib -import datetime import io import logging import os import sys -import traceback import bech32 import pkg_resources -import semver from cryptography.exceptions import InvalidTag from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305 -from .. import device, server, util +from .. import device, util from . import client log = logging.getLogger(__name__) diff --git a/libagent/device/onlykey.py b/libagent/device/onlykey.py index 710f0fed..c0b4b7e5 100644 --- a/libagent/device/onlykey.py +++ b/libagent/device/onlykey.py @@ -159,35 +159,35 @@ def pubkey(self, identity, ecdh=False): else: vk = ecdsa.VerifyingKey.from_string(ok_pubkey, curve=ecdsa.SECP256k1) return vk - else: - ok_pubkey = [] - while time.time() < t_end: - try: - ok_pub_part = self.ok.read_bytes(timeout_ms=100) - if len(ok_pub_part) == 64 and len(set(ok_pub_part[0:63])) != 1: - log.info('received part= %s', repr(ok_pub_part)) - ok_pubkey += ok_pub_part - # Todo know RSA type to know how many packets - except Exception as e: - raise interface.DeviceError(e) - log.info('received= %s', repr(ok_pubkey)) - if len(ok_pubkey) == 256: - # https://security.stackexchange.com/questions/42268/how-do-i-get-the-rsa-bit-length-with-the-pubkey-and-openssl - ok_pubkey = b'\x00\x00\x00\x07' + b'\x73\x73\x68\x2d\x72\x73\x61' + \ - b'\x00\x00\x00\x03' + b'\x01\x00\x01' + \ - b'\x00\x00\x01\x01' + b'\x00' + bytes(ok_pubkey) - # ok_pubkey = b'\x00\x00\x00\x07' + b'\x72\x73\x61\x2d\x73\x68\x61\x32\x2d\x32\x35\x - # 36' + b'\x00\x00\x00\x03' + b'\x01\x00\x01' + b'\x00\x00\x01\x01' + b'\x00' + byte - # s(ok_pubkey) - elif len(ok_pubkey) == 512: - ok_pubkey = b'\x00\x00\x00\x07' + b'\x73\x73\x68\x2d\x72\x73\x61' + \ - b'\x00\x00\x00\x03' + b'\x01\x00\x01' + \ - b'\x00\x00\x02\x01' + b'\x00' + bytes(ok_pubkey) - else: - raise interface.DeviceError("Error response length is not a valid public key") - log.info('pubkey len = %s', len(ok_pubkey)) - return ok_pubkey + ok_pubkey = [] + while time.time() < t_end: + try: + ok_pub_part = self.ok.read_bytes(timeout_ms=100) + if len(ok_pub_part) == 64 and len(set(ok_pub_part[0:63])) != 1: + log.info('received part= %s', repr(ok_pub_part)) + ok_pubkey += ok_pub_part + # Todo know RSA type to know how many packets + except Exception as e: + raise interface.DeviceError(e) + + log.info('received= %s', repr(ok_pubkey)) + if len(ok_pubkey) == 256: + # https://security.stackexchange.com/questions/42268/how-do-i-get-the-rsa-bit-length-with-the-pubkey-and-openssl + ok_pubkey = b'\x00\x00\x00\x07' + b'\x73\x73\x68\x2d\x72\x73\x61' + \ + b'\x00\x00\x00\x03' + b'\x01\x00\x01' + \ + b'\x00\x00\x01\x01' + b'\x00' + bytes(ok_pubkey) + # ok_pubkey = b'\x00\x00\x00\x07' + b'\x72\x73\x61\x2d\x73\x68\x61\x32\x2d\x32\x35\x + # 36' + b'\x00\x00\x00\x03' + b'\x01\x00\x01' + b'\x00\x00\x01\x01' + b'\x00' + byte + # s(ok_pubkey) + elif len(ok_pubkey) == 512: + ok_pubkey = b'\x00\x00\x00\x07' + b'\x73\x73\x68\x2d\x72\x73\x61' + \ + b'\x00\x00\x00\x03' + b'\x01\x00\x01' + \ + b'\x00\x00\x02\x01' + b'\x00' + bytes(ok_pubkey) + else: + raise interface.DeviceError("Error response length is not a valid public key") + log.info('pubkey len = %s', len(ok_pubkey)) + return ok_pubkey def sign(self, identity, blob): """Sign given blob and return the signature (as bytes).""" diff --git a/libagent/signify/__init__.py b/libagent/signify/__init__.py index aa0ab5d7..a846ee90 100644 --- a/libagent/signify/__init__.py +++ b/libagent/signify/__init__.py @@ -2,21 +2,12 @@ import argparse import binascii -import contextlib -import functools import hashlib import logging -import os -import re -import struct -import subprocess import sys import time -import pkg_resources -import semver - -from .. import formats, server, util +from .. import util from ..device import interface, ui log = logging.getLogger(__name__) From 1518b7bde0e0eeeb0cfdb951b06e4020d8e2be00 Mon Sep 17 00:00:00 2001 From: Roman Zeyde Date: Tue, 25 Apr 2023 17:59:42 +0300 Subject: [PATCH 03/35] Mark 'libagent' package as stable --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 9f61b5d0..0ee4eda9 100755 --- a/setup.py +++ b/setup.py @@ -35,7 +35,7 @@ platforms=['POSIX'], classifiers=[ 'Environment :: Console', - 'Development Status :: 4 - Beta', + 'Development Status :: 5 - Production/Stable', 'Intended Audience :: Developers', 'Intended Audience :: Information Technology', 'Intended Audience :: System Administrators', From bf7324ca89da8d56c7fd40f4402c7d711d21ef71 Mon Sep 17 00:00:00 2001 From: "Julian Smith, Main Street Ventures" Date: Thu, 11 May 2023 15:45:14 +1000 Subject: [PATCH 04/35] Update INSTALL.md Fix install step instruction --- doc/INSTALL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/INSTALL.md b/doc/INSTALL.md index 8c82cd43..276f3dda 100644 --- a/doc/INSTALL.md +++ b/doc/INSTALL.md @@ -100,7 +100,7 @@ Then, install the latest [keepkey_agent](https://pypi.python.org/pypi/keepkey_ag Or, on Mac using Homebrew: ``` - $ homebrew install keepkey-agent + $ brew install keepkey-agent ``` Or, directly from the latest source code: From 2b49eacc01b45caa5c7da379b54378d58e60bdd7 Mon Sep 17 00:00:00 2001 From: Roman Zeyde Date: Sat, 3 Jun 2023 16:40:00 +0300 Subject: [PATCH 05/35] Run CI also on PRs --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd699126..8202a251 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,6 @@ name: Build -on: [push] +on: [push, pull_request] jobs: build: From 473a565fc6a32d36b2f00c1e5ba2881416bdfb42 Mon Sep 17 00:00:00 2001 From: Senjuu Date: Mon, 31 Jul 2023 11:51:47 +0200 Subject: [PATCH 06/35] Add Support for ED25519 ssh-certificates --- libagent/formats.py | 41 ++++++++++++++--------- libagent/tests/test_formats.py | 61 ++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 16 deletions(-) diff --git a/libagent/formats.py b/libagent/formats.py index 28a911a1..c77d2124 100644 --- a/libagent/formats.py +++ b/libagent/formats.py @@ -21,14 +21,16 @@ ECDH_CURVE25519 = 'curve25519' # SSH key types +SSH_CERT_POSTFIX = b'-cert-v01@openssh.com' SSH_NIST256_DER_OCTET = b'\x04' SSH_NIST256_KEY_PREFIX = b'ecdsa-sha2-' SSH_NIST256_CURVE_NAME = b'nistp256' SSH_NIST256_KEY_TYPE = SSH_NIST256_KEY_PREFIX + SSH_NIST256_CURVE_NAME -SSH_NIST256_CERT_POSTFIX = b'-cert-v01@openssh.com' -SSH_NIST256_CERT_TYPE = SSH_NIST256_KEY_TYPE + SSH_NIST256_CERT_POSTFIX +SSH_NIST256_CERT_TYPE = SSH_NIST256_KEY_TYPE + SSH_CERT_POSTFIX SSH_ED25519_KEY_TYPE = b'ssh-ed25519' -SUPPORTED_KEY_TYPES = {SSH_NIST256_KEY_TYPE, SSH_NIST256_CERT_TYPE, SSH_ED25519_KEY_TYPE} +SSH_ED25519_CERT_TYPE = SSH_ED25519_KEY_TYPE + SSH_CERT_POSTFIX +SUPPORTED_KEY_TYPES = {SSH_NIST256_KEY_TYPE, SSH_NIST256_CERT_TYPE, + SSH_ED25519_KEY_TYPE, SSH_ED25519_CERT_TYPE} hashfunc = hashlib.sha256 @@ -43,6 +45,20 @@ def fingerprint(blob): return ':'.join('{:02x}'.format(c) for c in bytearray(digest)) +def __skip_certificate_fields(s): + _serial_number = util.recv(s, '>Q') + _type = util.recv(s, '>L') + _key_id = util.read_frame(s) + _valid_principals = util.read_frame(s) + _valid_after = util.recv(s, '>Q') + _valid_before = util.recv(s, '>Q') + _critical_options = util.read_frame(s) + _extensions = util.read_frame(s) + _reserved = util.read_frame(s) + _signature_key = util.read_frame(s) + _signature = util.read_frame(s) + + def parse_pubkey(blob): """ Parse SSH public key from given blob. @@ -69,18 +85,7 @@ def parse_pubkey(blob): point = util.read_frame(s) if key_type == SSH_NIST256_CERT_TYPE: - _serial_number = util.recv(s, '>Q') - _type = util.recv(s, '>L') - _key_id = util.read_frame(s) - _valid_principals = util.read_frame(s) - _valid_after = util.recv(s, '>Q') - _valid_before = util.recv(s, '>Q') - _critical_options = util.read_frame(s) - _extensions = util.read_frame(s) - _reserved = util.read_frame(s) - _signature_key = util.read_frame(s) - _signature = util.read_frame(s) - + __skip_certificate_fields(s) assert s.read() == b'' _type, point = point[:1], point[1:] assert _type == SSH_NIST256_DER_OCTET @@ -102,8 +107,12 @@ def ecdsa_verifier(sig, msg): result.update(point=coords, curve=CURVE_NIST256, verifier=ecdsa_verifier) - if key_type == SSH_ED25519_KEY_TYPE: + if key_type in (SSH_ED25519_KEY_TYPE, SSH_ED25519_CERT_TYPE): + if key_type == SSH_ED25519_CERT_TYPE: + _nonce = util.read_frame(s) pubkey = util.read_frame(s) + if key_type == SSH_ED25519_CERT_TYPE: + __skip_certificate_fields(s) assert s.read() == b'' def ed25519_verify(sig, msg): diff --git a/libagent/tests/test_formats.py b/libagent/tests/test_formats.py index e0a777d9..921cc05a 100644 --- a/libagent/tests/test_formats.py +++ b/libagent/tests/test_formats.py @@ -43,6 +43,57 @@ def test_fingerprint(): 'home\n' ) +_public_key_ed25519_cert = ( + 'ssh-ed25519-cert-v01@openssh.com ' + 'AAAAIHNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29' + 'tAAAAIK5TMdCnuxxy4rr0CTHLekAsnL4DAhFyksK5romkuw' + 'xgAAAAIFBdF2tjfSO8nLIi736is+f0erq28RTc7CkM11NZt' + 'TKRAAAAAAAAAAAAAAABAAAACXVuaXQtdGVzdAAAAA0AAAAJ' + 'dW5pdC10ZXN0AAAAAAAAAAD//////////wAAAAAAAACCAAA' + 'AFXBlcm1pdC1YMTEtZm9yd2FyZGluZwAAAAAAAAAXcGVybW' + 'l0LWFnZW50LWZvcndhcmRpbmcAAAAAAAAAFnBlcm1pdC1wb' + '3J0LWZvcndhcmRpbmcAAAAAAAAACnBlcm1pdC1wdHkAAAAA' + 'AAAADnBlcm1pdC11c2VyLXJjAAAAAAAAAAAAAABoAAAAE2V' + 'jZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBC' + 'HF5pUcZLVlTUBzos8ojyN34KrS7TnGAZINhRsCoNuRV4NFN' + 'IlEYpEvSwlumQuDx6B1y4Va+3pYzBbZInm6vwgAAABjAAAA' + 'E2VjZHNhLXNoYTItbmlzdHAyNTYAAABIAAAAICUMX1taTy6' + 'y+1Aa1m7kXHI/Qv7ZZIeNp7ndmCRLFCSuAAAAIBaX43k0Ye' + 'Bk8a5zp6FyFCBYVOtis/DUbGm07d7miPnE ' + 'hello\n' +) + +_public_key_ed25519_cert_BLOB = ( + b'\x00\x00\x00 ssh-ed25519-cert-v01@openssh.com' + b'\x00\x00\x00 \xaeS1\xd0\xa7\xbb\x1cr\xe2\xba' + b'\xf4\t1\xcbz@,\x9c\xbe\x03\x02\x11r\x92\xc2\xb9' + b'\xae\x89\xa4\xbb\x0c`\x00\x00\x00 P]\x17kc}#' + b'\xbc\x9c\xb2"\xef~\xa2\xb3\xe7\xf4z\xba\xb6\xf1' + b'\x14\xdc\xec)\x0c\xd7SY\xb52\x91\x00\x00\x00' + b'\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00' + b'\x00\tunit-test\x00\x00\x00\r\x00\x00\x00\tun' + b'it-test\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff' + b'\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00' + b'\x00\x00\x82\x00\x00\x00\x15permit-X11-forwar' + b'ding\x00\x00\x00\x00\x00\x00\x00\x17permit-ag' + b'ent-forwarding\x00\x00\x00\x00\x00\x00\x00\x16' + b'permit-port-forwarding\x00\x00\x00\x00\x00\x00' + b'\x00\npermit-pty\x00\x00\x00\x00\x00\x00\x00' + b'\x0epermit-user-rc\x00\x00\x00\x00\x00\x00\x00' + b'\x00\x00\x00\x00h\x00\x00\x00\x13ecdsa-sha2-n' + b'istp256\x00\x00\x00\x08nistp256\x00\x00\x00A' + b'\x04!\xc5\xe6\x95\x1cd\xb5eM@s\xa2\xcf(\x8f#w' + b'\xe0\xaa\xd2\xed9\xc6\x01\x92\r\x85\x1b\x02\xa0' + b'\xdb\x91W\x83E4\x89Db\x91/K\tn\x99\x0b\x83\xc7' + b'\xa0u\xcb\x85Z\xfbzX\xcc\x16\xd9"y\xba\xbf\x08' + b'\x00\x00\x00c\x00\x00\x00\x13ecdsa-sha2-nistp' + b'256\x00\x00\x00H\x00\x00\x00 %\x0c_[ZO.\xb2\xfb' + b'P\x1a\xd6n\xe4\\r?B\xfe\xd9d\x87\x8d\xa7\xb9' + b'\xdd\x98$K\x14$\xae\x00\x00\x00 \x16\x97\xe3y' + b'4a\xe0d\xf1\xaes\xa7\xa1r\x14 XT\xebb\xb3\xf0' + b'\xd4li\xb4\xed\xde\xe6\x88\xf9\xc4' +) + def test_parse_public_key(): key = formats.import_public_key(_public_key) @@ -86,6 +137,16 @@ def test_parse_ed25519(): assert p['type'] == b'ssh-ed25519' +def test_parse_ed25519_cert(): + p = formats.import_public_key(_public_key_ed25519_cert) + assert p['name'] == b'hello' + assert p['curve'] == 'ed25519' + + assert p['blob'] == _public_key_ed25519_cert_BLOB + assert p['fingerprint'] == '86:b6:17:3e:e1:5c:ba:e0:dc:86:80:b2:47:b4:ad:50' # nopep8 + assert p['type'] == b'ssh-ed25519-cert-v01@openssh.com' + + def test_export_ed25519(): pub = (b'\x00P]\x17kc}#\xbc\x9c\xb2"\xef~\xa2\xb3\xe7\xf4' b'z\xba\xb6\xf1\x14\xdc\xec)\x0c\xd7SY\xb52\x91') From 8cb323c5507b4cd757fae6483f05ea5fc7f7758a Mon Sep 17 00:00:00 2001 From: Roman Zeyde Date: Sat, 12 Aug 2023 11:27:30 +0300 Subject: [PATCH 07/35] Update docs to reference `trezor-agent` instead of `trezor_agent` (#342) --- agents/fake/setup.py | 2 +- doc/INSTALL.md | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/agents/fake/setup.py b/agents/fake/setup.py index 71c4ae1d..11f52d8f 100644 --- a/agents/fake/setup.py +++ b/agents/fake/setup.py @@ -7,7 +7,7 @@ setup( name='fake_device_agent', version='0.9.0', - description='Testing trezor_agent with a fake device - NOT SAFE!!!', + description='Testing SSH/GPG agent with a fake device - NOT SAFE!!!', author='Roman Zeyde', author_email='roman.zeyde@gmail.com', url='http://github.com/romanz/trezor-agent', diff --git a/doc/INSTALL.md b/doc/INSTALL.md index 276f3dda..86cbb1f0 100644 --- a/doc/INSTALL.md +++ b/doc/INSTALL.md @@ -63,7 +63,7 @@ gpg (GnuPG) 2.1.15 2. Make sure that your `udev` rules are configured [correctly](https://wiki.trezor.io/Udev_rules). -3. Then, install the latest [trezor_agent](https://pypi.python.org/pypi/trezor_agent) package: +3. Then, install the latest [trezor-agent](https://pypi.python.org/pypi/trezor-agent) package: ``` $ pip3 install Cython hidapi @@ -91,7 +91,7 @@ gpg (GnuPG) 2.1.15 * [KeepKey firmware releases](https://github.com/keepkey/keepkey-firmware/releases): `3.0.17+` 2. Make sure that your `udev` rules are configured [correctly](https://support.keepkey.com/support/solutions/articles/6000037796-keepkey-wallet-is-not-being-recognized-by-linux). -Then, install the latest [keepkey_agent](https://pypi.python.org/pypi/keepkey_agent) package: +Then, install the latest [keepkey-agent](https://pypi.python.org/pypi/keepkey-agent) package: ``` $ pip3 install keepkey_agent @@ -117,10 +117,10 @@ Then, install the latest [keepkey_agent](https://pypi.python.org/pypi/keepkey_ag * [Ledger Nano S firmware releases](https://github.com/LedgerHQ/blue-app-ssh-agent): `0.0.3+` (install [SSH/PGP Agent](https://www.ledgerwallet.com/images/apps/chrome-mngr-apps.png) app) 2. Make sure that your `udev` rules are configured [correctly](https://ledger.zendesk.com/hc/en-us/articles/115005165269-What-if-Ledger-Wallet-is-not-recognized-on-Linux-). -3. Then, install the latest [ledger_agent](https://pypi.python.org/pypi/ledger_agent) package: +3. Then, install the latest [ledger-agent](https://pypi.python.org/pypi/ledger-agent) package: ``` - $ pip3 install ledger_agent + $ pip3 install ledger-agent ``` Or, directly from the latest source code: From a247e877fc066a20b208917e9424c3a31ca79f4a Mon Sep 17 00:00:00 2001 From: Roman Zeyde Date: Sat, 12 Aug 2023 11:32:24 +0300 Subject: [PATCH 08/35] No need to install Cython & hidapi --- doc/INSTALL.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/doc/INSTALL.md b/doc/INSTALL.md index 86cbb1f0..7011e500 100644 --- a/doc/INSTALL.md +++ b/doc/INSTALL.md @@ -66,8 +66,7 @@ gpg (GnuPG) 2.1.15 3. Then, install the latest [trezor-agent](https://pypi.python.org/pypi/trezor-agent) package: ``` - $ pip3 install Cython hidapi - $ pip3 install trezor_agent + $ pip3 install trezor-agent ``` Or, directly from the latest source code: From 0acc6cd2efd46a59f22b81d442a37b1500bd2b7c Mon Sep 17 00:00:00 2001 From: Roman Zeyde Date: Sat, 19 Aug 2023 15:36:21 +0300 Subject: [PATCH 09/35] Update email in setup.py --- agents/fake/setup.py | 2 +- agents/keepkey/setup.py | 2 +- agents/ledger/setup.py | 2 +- agents/trezor/setup.py | 2 +- setup.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/agents/fake/setup.py b/agents/fake/setup.py index 11f52d8f..451e908c 100644 --- a/agents/fake/setup.py +++ b/agents/fake/setup.py @@ -9,7 +9,7 @@ version='0.9.0', description='Testing SSH/GPG agent with a fake device - NOT SAFE!!!', author='Roman Zeyde', - author_email='roman.zeyde@gmail.com', + author_email='dev@romanzey.de', url='http://github.com/romanz/trezor-agent', scripts=['fake_device_agent.py'], install_requires=[ diff --git a/agents/keepkey/setup.py b/agents/keepkey/setup.py index f796a679..3b32d1cb 100644 --- a/agents/keepkey/setup.py +++ b/agents/keepkey/setup.py @@ -6,7 +6,7 @@ version='0.9.0', description='Using KeepKey as hardware SSH/GPG agent', author='Roman Zeyde', - author_email='roman.zeyde@gmail.com', + author_email='dev@romanzey.de', url='http://github.com/romanz/trezor-agent', scripts=['keepkey_agent.py'], install_requires=[ diff --git a/agents/ledger/setup.py b/agents/ledger/setup.py index efb70937..e4248b2b 100644 --- a/agents/ledger/setup.py +++ b/agents/ledger/setup.py @@ -6,7 +6,7 @@ version='0.9.0', description='Using Ledger as hardware SSH/GPG agent', author='Roman Zeyde', - author_email='roman.zeyde@gmail.com', + author_email='dev@romanzey.de', url='http://github.com/romanz/trezor-agent', scripts=['ledger_agent.py'], install_requires=[ diff --git a/agents/trezor/setup.py b/agents/trezor/setup.py index 2921a5c1..1c8582a4 100644 --- a/agents/trezor/setup.py +++ b/agents/trezor/setup.py @@ -6,7 +6,7 @@ version='0.12.0', description='Using Trezor as hardware SSH/GPG agent', author='Roman Zeyde', - author_email='roman.zeyde@gmail.com', + author_email='dev@romanzey.de', url='http://github.com/romanz/trezor-agent', scripts=['trezor_agent.py'], install_requires=[ diff --git a/setup.py b/setup.py index 0ee4eda9..57f6e0c6 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ version='0.14.7', description='Using hardware wallets as SSH/GPG agent', author='Roman Zeyde', - author_email='roman.zeyde@gmail.com', + author_email='dev@romanzey.de', url='http://github.com/romanz/trezor-agent', packages=[ 'libagent', From 28cbb941f16e6a309ba10a4a23f56e49038ed67a Mon Sep 17 00:00:00 2001 From: Roman Zeyde Date: Sat, 19 Aug 2023 15:42:03 +0300 Subject: [PATCH 10/35] =?UTF-8?q?Bump=20version:=200.14.7=20=E2=86=92=200.?= =?UTF-8?q?14.8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 8f9abdc4..ba8117e3 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,6 +1,6 @@ [bumpversion] commit = True tag = True -current_version = 0.14.7 +current_version = 0.14.8 [bumpversion:file:setup.py] diff --git a/setup.py b/setup.py index 57f6e0c6..8b59fb89 100755 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ setup( name='libagent', - version='0.14.7', + version='0.14.8', description='Using hardware wallets as SSH/GPG agent', author='Roman Zeyde', author_email='dev@romanzey.de', From 6776971b5a5b9778be99d2f458740d19e061013f Mon Sep 17 00:00:00 2001 From: Roman Zeyde Date: Sat, 2 Sep 2023 13:03:22 +0300 Subject: [PATCH 11/35] Drop unneeded `contrib/` directory --- contrib/neopg-trezor | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100755 contrib/neopg-trezor diff --git a/contrib/neopg-trezor b/contrib/neopg-trezor deleted file mode 100755 index d65a0131..00000000 --- a/contrib/neopg-trezor +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env python3 -import os -import sys - -agent = 'trezor-gpg-agent' -binary = 'neopg' - -if sys.argv[1:2] == ['agent']: - os.execvp(agent, [agent, '-vv'] + sys.argv[2:]) -else: - # HACK: pass this script's path as argv[0], so it will be invoked again - # when NeoPG tries to run its own agent: - # https://github.com/das-labor/neopg/blob/1fe50460abe01febb118641e37aa50bc429a1786/src/neopg.cpp#L114 - # https://github.com/das-labor/neopg/blob/1fe50460abe01febb118641e37aa50bc429a1786/legacy/gnupg/common/asshelp.cpp#L217 - os.execvp(binary, [__file__, 'gpg2'] + sys.argv[1:]) From 23c6349c98967f39d9a5d8c803d2d1b6df4b2cc1 Mon Sep 17 00:00:00 2001 From: Roman Zeyde Date: Sat, 2 Sep 2023 14:34:08 +0300 Subject: [PATCH 12/35] Verify that 'identity-v1' state machine is used Following https://github.com/romanz/trezor-agent/issues/426. --- libagent/age/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libagent/age/__init__.py b/libagent/age/__init__.py index 95fd03ac..93a3cf8a 100644 --- a/libagent/age/__init__.py +++ b/libagent/age/__init__.py @@ -172,8 +172,10 @@ def main(device_type): try: if args.identity: run_pubkey(device_type=device_type, args=args) - elif args.age_plugin: + elif args.age_plugin == 'identity-v1': run_decrypt(device_type=device_type, args=args) + else: + log.error("Unsupported state machine: %r", args.age_plugin) except Exception as e: # pylint: disable=broad-except log.exception("age plugin failed: %s", e) From 37485e5a539e0eae6376839db66fce36e6f7fe37 Mon Sep 17 00:00:00 2001 From: doolio Date: Wed, 6 Sep 2023 13:14:41 +0200 Subject: [PATCH 13/35] Update README to include Blockstream Jade ...as a supported device --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6f9a828e..3dfe4eb3 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ See the following blog posts about this tool: - [TREZOR Firmware 1.4.0 — GPG decryption support](https://www.reddit.com/r/TREZOR/comments/50h8r9/new_trezor_firmware_fidou2f_and_initial_ethereum/d7420q7/) - [A Step by Step Guide to Securing your SSH Keys with the Ledger Nano S](https://thoughts.t37.net/a-step-by-step-guide-to-securing-your-ssh-keys-with-the-ledger-nano-s-92e58c64a005) -Currently [TREZOR One](https://trezor.io/), [TREZOR Model T](https://trezor.io/), [Keepkey](https://www.keepkey.com/), [Ledger Nano S](https://www.ledgerwallet.com/products/ledger-nano-s), and [OnlyKey](https://onlykey.io) are supported. +Currently [TREZOR One](https://trezor.io/), [TREZOR Model T](https://trezor.io/), [Keepkey](https://www.keepkey.com/), [Blockstream Jade](https://blockstream.com/jade/), [Ledger Nano S](https://www.ledgerwallet.com/products/ledger-nano-s), and [OnlyKey](https://onlykey.io) are supported. ## Components From 81de093ddb084712f3703360225a2d9cb440a28b Mon Sep 17 00:00:00 2001 From: doolio Date: Wed, 6 Sep 2023 13:26:14 +0200 Subject: [PATCH 14/35] Update description in setup.py --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 8b59fb89..34425c83 100755 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ setup( name='libagent', version='0.14.8', - description='Using hardware wallets as SSH/GPG agent', + description='Using hardware wallets as SSH/GPG/age agent', author='Roman Zeyde', author_email='dev@romanzey.de', url='http://github.com/romanz/trezor-agent', From 3c911e99a0394278104564092225d67c75e74b99 Mon Sep 17 00:00:00 2001 From: Roman Zeyde Date: Wed, 6 Sep 2023 19:41:26 +0300 Subject: [PATCH 15/35] Fix JADE link in `README.md` --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3dfe4eb3..053fe4c2 100644 --- a/README.md +++ b/README.md @@ -24,7 +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/age 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 +* [`jade_agent`](https://github.com/Blockstream/Jade/): 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 From de6dec340d1cb7c2804bd06b91fdc23d350f7007 Mon Sep 17 00:00:00 2001 From: SlugFiller <5435495+SlugFiller@users.noreply.github.com> Date: Fri, 15 Sep 2023 00:34:12 +0300 Subject: [PATCH 16/35] Add concurrency tag to CI --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8202a251..86f07aea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,10 @@ name: Build on: [push, pull_request] +concurrency: + group: ci-${{github.actor}}-${{github.head_ref || github.run_number}}-${{github.ref}} + cancel-in-progress: true + jobs: build: From a35d9ddde86a5d0361c29584e04f45e78b0047ad Mon Sep 17 00:00:00 2001 From: Roman Zeyde Date: Sat, 11 Nov 2023 20:54:53 +0200 Subject: [PATCH 17/35] Bump CI actions and test on Python 3.12 --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8202a251..b41c2d8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,12 +8,12 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] + python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies From 68e39c14216f466c8710bf65ef133c744f8f92da Mon Sep 17 00:00:00 2001 From: Branch Vincent Date: Wed, 24 Apr 2024 20:03:04 -0700 Subject: [PATCH 18/35] replace pkg_resources for python 3.12 --- libagent/age/__init__.py | 7 +++---- libagent/gpg/__init__.py | 7 +++---- libagent/ssh/__init__.py | 7 +++---- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/libagent/age/__init__.py b/libagent/age/__init__.py index dd2fbe66..e20cb3c4 100644 --- a/libagent/age/__init__.py +++ b/libagent/age/__init__.py @@ -13,9 +13,9 @@ import logging import os import sys +from importlib import metadata import bech32 -import pkg_resources from cryptography.exceptions import InvalidTag from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305 @@ -150,9 +150,8 @@ def main(device_type): p = argparse.ArgumentParser() agent_package = device_type.package_name() - resources_map = {r.key: r for r in pkg_resources.require(agent_package)} - resources = [resources_map[agent_package], resources_map['libagent']] - versions = '\n'.join('{}={}'.format(r.key, r.version) for r in resources) + resources = [metadata.distribution(agent_package), metadata.distribution('libagent')] + versions = '\n'.join('{}={}'.format(r.metadata['Name'], r.version) for r in resources) p.add_argument('--version', help='print the version info', action='version', version=versions) diff --git a/libagent/gpg/__init__.py b/libagent/gpg/__init__.py index 6bad4f65..4f1b166d 100644 --- a/libagent/gpg/__init__.py +++ b/libagent/gpg/__init__.py @@ -17,13 +17,13 @@ import stat import subprocess import sys +from importlib import metadata try: # TODO: Not supported on Windows. Use daemoniker instead? import daemon except ImportError: daemon = None -import pkg_resources import semver from .. import device, formats, server, util @@ -308,9 +308,8 @@ def main(device_type): parser = argparse.ArgumentParser(epilog=epilog) agent_package = device_type.package_name() - resources_map = {r.key: r for r in pkg_resources.require(agent_package)} - resources = [resources_map[agent_package], resources_map['libagent']] - versions = '\n'.join('{}={}'.format(r.key, r.version) for r in resources) + resources = [metadata.distribution(agent_package), metadata.distribution('libagent')] + versions = '\n'.join('{}={}'.format(r.metadata['Name'], r.version) for r in resources) parser.add_argument('--version', help='print the version info', action='version', version=versions) diff --git a/libagent/ssh/__init__.py b/libagent/ssh/__init__.py index dee3ee24..14f2656d 100644 --- a/libagent/ssh/__init__.py +++ b/libagent/ssh/__init__.py @@ -13,6 +13,7 @@ import sys import tempfile import threading +from importlib import metadata import configargparse @@ -21,7 +22,6 @@ import daemon except ImportError: daemon = None -import pkg_resources from .. import device, formats, server, util from . import client, protocol @@ -83,9 +83,8 @@ def create_agent_parser(device_type): p.add_argument('-v', '--verbose', default=0, action='count') agent_package = device_type.package_name() - resources_map = {r.key: r for r in pkg_resources.require(agent_package)} - resources = [resources_map[agent_package], resources_map['libagent']] - versions = '\n'.join('{}={}'.format(r.key, r.version) for r in resources) + resources = [metadata.distribution(agent_package), metadata.distribution('libagent')] + versions = '\n'.join('{}={}'.format(r.metadata['Name'], r.version) for r in resources) p.add_argument('--version', help='print the version info', action='version', version=versions) From b958b08f6ba4f4f21911da4f69e69d8ef3424e07 Mon Sep 17 00:00:00 2001 From: Branch Vincent Date: Wed, 24 Apr 2024 20:13:16 -0700 Subject: [PATCH 19/35] bump python to 3.8+ --- .github/workflows/ci.yml | 2 +- agents/fake/setup.py | 1 + agents/jade/setup.py | 1 + agents/keepkey/setup.py | 1 + agents/ledger/setup.py | 1 + agents/onlykey/setup.py | 1 + agents/trezor/setup.py | 1 + setup.py | 1 + 8 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3f2a532..f2c70af9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] steps: - uses: actions/checkout@v4 diff --git a/agents/fake/setup.py b/agents/fake/setup.py index 451e908c..e84a50b9 100644 --- a/agents/fake/setup.py +++ b/agents/fake/setup.py @@ -11,6 +11,7 @@ author='Roman Zeyde', author_email='dev@romanzey.de', url='http://github.com/romanz/trezor-agent', + python_requires='>=3.8', scripts=['fake_device_agent.py'], install_requires=[ 'libagent>=0.9.0', diff --git a/agents/jade/setup.py b/agents/jade/setup.py index 96903b68..7b4e80b4 100644 --- a/agents/jade/setup.py +++ b/agents/jade/setup.py @@ -8,6 +8,7 @@ author='Jamie C. Driver', author_email='jamie@blockstream.com', url='http://github.com/romanz/trezor-agent', + python_requires='>=3.8', scripts=['jade_agent.py'], install_requires=[ 'libagent>=0.14.5', diff --git a/agents/keepkey/setup.py b/agents/keepkey/setup.py index 3b32d1cb..f68aa838 100644 --- a/agents/keepkey/setup.py +++ b/agents/keepkey/setup.py @@ -8,6 +8,7 @@ author='Roman Zeyde', author_email='dev@romanzey.de', url='http://github.com/romanz/trezor-agent', + python_requires='>=3.8', scripts=['keepkey_agent.py'], install_requires=[ 'libagent>=0.9.0', diff --git a/agents/ledger/setup.py b/agents/ledger/setup.py index e4248b2b..b4bb08f6 100644 --- a/agents/ledger/setup.py +++ b/agents/ledger/setup.py @@ -8,6 +8,7 @@ author='Roman Zeyde', author_email='dev@romanzey.de', url='http://github.com/romanz/trezor-agent', + python_requires='>=3.8', scripts=['ledger_agent.py'], install_requires=[ 'libagent>=0.9.0', diff --git a/agents/onlykey/setup.py b/agents/onlykey/setup.py index 140fb1fb..c333c7bd 100644 --- a/agents/onlykey/setup.py +++ b/agents/onlykey/setup.py @@ -8,6 +8,7 @@ author='CryptoTrust', author_email='t@crp.to', url='http://github.com/trustcrypto/onlykey-agent', + python_requires='>=3.8', scripts=['onlykey_agent.py'], install_requires=[ 'libagent>=0.14.2', diff --git a/agents/trezor/setup.py b/agents/trezor/setup.py index 1c8582a4..28d21736 100644 --- a/agents/trezor/setup.py +++ b/agents/trezor/setup.py @@ -8,6 +8,7 @@ author='Roman Zeyde', author_email='dev@romanzey.de', url='http://github.com/romanz/trezor-agent', + python_requires='>=3.8', scripts=['trezor_agent.py'], install_requires=[ 'libagent>=0.14.0', diff --git a/setup.py b/setup.py index aefdcdc1..efe0b52b 100755 --- a/setup.py +++ b/setup.py @@ -8,6 +8,7 @@ author='Roman Zeyde', author_email='dev@romanzey.de', url='http://github.com/romanz/trezor-agent', + python_requires='>=3.8', packages=[ 'libagent', 'libagent.age', From f183758bbe39eda0c087d4c53cf17eb5a22609c1 Mon Sep 17 00:00:00 2001 From: Roman Zeyde Date: Thu, 5 Sep 2024 21:22:53 +0300 Subject: [PATCH 20/35] Sign tags via bumpversion --- .bumpversion.cfg | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index ba8117e3..0f73ab22 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -3,4 +3,6 @@ commit = True tag = True current_version = 0.14.8 +sign_tags = True + [bumpversion:file:setup.py] From 868975fb0cf2941bad51d283f64e1661ace4c8f4 Mon Sep 17 00:00:00 2001 From: Roman Zeyde Date: Thu, 5 Sep 2024 21:24:45 +0300 Subject: [PATCH 21/35] =?UTF-8?q?Bump=20version:=200.14.8=20=E2=86=92=200.?= =?UTF-8?q?15.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 3 +-- setup.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 0f73ab22..e5b12595 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,8 +1,7 @@ [bumpversion] commit = True tag = True -current_version = 0.14.8 - +current_version = 0.15.0 sign_tags = True [bumpversion:file:setup.py] diff --git a/setup.py b/setup.py index aefdcdc1..10586f2e 100755 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ setup( name='libagent', - version='0.14.8', + version='0.15.0', description='Using hardware wallets as SSH/GPG/age agent', author='Roman Zeyde', author_email='dev@romanzey.de', From 5e809c0c0afef2642d7b601a295a27f2d2522d8e Mon Sep 17 00:00:00 2001 From: Roman Zeyde Date: Thu, 5 Sep 2024 21:40:07 +0300 Subject: [PATCH 22/35] Remove releases section --- README.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/README.md b/README.md index 0bb3dd2a..f31ff16b 100644 --- a/README.md +++ b/README.md @@ -28,10 +28,6 @@ agents to interact with several different hardware devices: * [`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 - -The [/releases](/releases) page on Github contains the `libagent` -releases. - ## Documentation * **Installation** instructions are [here](doc/INSTALL.md) From e06f913faca0d981803608cb93e846cd065f6f33 Mon Sep 17 00:00:00 2001 From: Roman Zeyde Date: Fri, 8 Nov 2024 14:48:28 +0200 Subject: [PATCH 23/35] Test on Python 3.13 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3f2a532..5775ef36 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] + python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] steps: - uses: actions/checkout@v4 From 87f71174813300cea0f1fbd825d5e6fbc0b7b5d6 Mon Sep 17 00:00:00 2001 From: Roman Zeyde Date: Sat, 23 Nov 2024 13:13:16 +0200 Subject: [PATCH 24/35] Parse SSH identity with spaces --- libagent/formats.py | 2 +- libagent/tests/test_formats.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/libagent/formats.py b/libagent/formats.py index c77d2124..26f91401 100644 --- a/libagent/formats.py +++ b/libagent/formats.py @@ -225,7 +225,7 @@ def export_public_key(vk, label): def import_public_key(line): """Parse public key textual format, as saved at a .pub file.""" log.debug('loading SSH public key: %r', line) - file_type, base64blob, name = line.split() + file_type, base64blob, name = line.strip().split(maxsplit=2) blob = base64.b64decode(base64blob) result = parse_pubkey(blob) result['name'] = name.encode('utf-8') diff --git a/libagent/tests/test_formats.py b/libagent/tests/test_formats.py index 921cc05a..3a0c8a26 100644 --- a/libagent/tests/test_formats.py +++ b/libagent/tests/test_formats.py @@ -60,7 +60,7 @@ def test_fingerprint(): 'E2VjZHNhLXNoYTItbmlzdHAyNTYAAABIAAAAICUMX1taTy6' 'y+1Aa1m7kXHI/Qv7ZZIeNp7ndmCRLFCSuAAAAIBaX43k0Ye' 'Bk8a5zp6FyFCBYVOtis/DUbGm07d7miPnE ' - 'hello\n' + 'hello world\n' ) _public_key_ed25519_cert_BLOB = ( @@ -139,7 +139,7 @@ def test_parse_ed25519(): def test_parse_ed25519_cert(): p = formats.import_public_key(_public_key_ed25519_cert) - assert p['name'] == b'hello' + assert p['name'] == b'hello world' assert p['curve'] == 'ed25519' assert p['blob'] == _public_key_ed25519_cert_BLOB From f1fe7b516253d9ff3c3a0edc4d6b93a329d4b64e Mon Sep 17 00:00:00 2001 From: Roman Zeyde Date: Wed, 13 Nov 2024 17:12:21 +0200 Subject: [PATCH 25/35] Support SSH CA generation Fixes https://github.com/romanz/trezor-agent/issues/491. Usage example: ## generate TREZOR-based SSH CA public key $ trezor-agent -v 'SSH Certificate Authority' > /etc/ssh/trezor-ca.pub $ echo 'TrustedUserCAKeys /etc/ssh/trezor-ca.pub' | sudo tee -a /etc/ssh/sshd_config $ sudo systemctl restart ssh ## generate user-specific SSH key and certify it using trezor-agent $ ssh-keygen -t ed25519 -f user-key $ trezor-agent -v 'SSH Certificate Authority' -- \ ssh-keygen -Us trezor-ca.pub -V '+10m' -I user-id -n user user-key.pub ... Signed user key user-key-cert.pub: id "user-id" serial 0 for user valid from 2024-11-23T20:25:00 to 2024-11-23T20:36:27 ## use the certificate to login ssh -v user@localhost -o CertificateFile=user-key-cert.pub -i user-key ... debug1: Will attempt key: user-key-cert.pub ED25519-CERT SHA256:xdbgtQmUs5tUNf04f4Y3oQl5LGdBAMVjCH63R6EHH5Y explicit debug1: Will attempt key: user-key ED25519 SHA256:xdbgtQmUs5tUNf04f4Y3oQl5LGdBAMVjCH63R6EHH5Y explicit ... debug1: Offering public key: user-key-cert.pub ED25519-CERT SHA256:xdbgtQmUs5tUNf04f4Y3oQl5LGdBAMVjCH63R6EHH5Y explicit debug1: Server accepts key: user-key-cert.pub ED25519-CERT SHA256:xdbgtQmUs5tUNf04f4Y3oQl5LGdBAMVjCH63R6EHH5Y explicit Authenticated to localhost ([::1]:22) using "publickey". ... --- libagent/ssh/client.py | 70 +++++++++++++++++++++++++++++------------- 1 file changed, 49 insertions(+), 21 deletions(-) diff --git a/libagent/ssh/client.py b/libagent/ssh/client.py index aa3b47cc..e2b2684b 100644 --- a/libagent/ssh/client.py +++ b/libagent/ssh/client.py @@ -10,6 +10,11 @@ log = logging.getLogger(__name__) +SUPPORTED_CERT_TYPES = { + formats.SSH_ED25519_CERT_TYPE, + formats.SSH_NIST256_CERT_TYPE, +} + class Client: """Client wrapper for SSH authentication device.""" @@ -31,22 +36,17 @@ def export_public_keys(self, identities): def sign_ssh_challenge(self, blob, identity): """Sign given blob using a private key on the device.""" - log.debug('blob: %r', blob) + log.debug('blob (%d bytes): %r', len(blob), blob) msg = parse_ssh_blob(blob) + log.debug('parsed: %r', msg) + + identity_str = identity.to_string() if msg['sshsig']: log.info('please confirm "%s" signature for "%s" using %s...', - msg['namespace'], identity.to_string(), self.device) + msg['namespace'], identity_str, self.device) else: - log.debug('%s: user %r via %r (%r)', - msg['conn'], msg['user'], msg['auth'], msg['key_type']) - log.debug('nonce: %r', msg['nonce']) - fp = msg['public_key']['fingerprint'] - log.debug('fingerprint: %s', fp) - log.debug('hidden challenge size: %d bytes', len(blob)) - - log.info('please confirm user "%s" login to "%s" using %s...', - msg['user'].decode('ascii'), identity.to_string(), - self.device) + log.info('please confirm "%s" signature for "%s" using %s...', + msg['key_type'].decode('ascii'), identity_str, self.device) with self.device: return self.device.sign(blob=blob, identity=identity) @@ -66,17 +66,45 @@ def parse_ssh_blob(data): else: i = io.BytesIO(data) res['sshsig'] = False - res['nonce'] = util.read_frame(i) - i.read(1) # SSH2_MSG_USERAUTH_REQUEST == 50 (from ssh2.h, line 108) - res['user'] = util.read_frame(i) - res['conn'] = util.read_frame(i) - res['auth'] = util.read_frame(i) - i.read(1) # have_sig == 1 (from sshconnect2.c, line 1056) - res['key_type'] = util.read_frame(i) - public_key = util.read_frame(i) - res['public_key'] = formats.parse_pubkey(public_key) + first_frame = util.read_frame(i) + # https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.certkeys + is_cert = first_frame in SUPPORTED_CERT_TYPES + if is_cert: + # see `sshkey_certify_custom()` for details: + # https://github.com/openssh/openssh-portable/blob/master/sshkey.c + res['key_type'] = first_frame + res['nonce'] = util.read_frame(i) + if first_frame == formats.SSH_NIST256_CERT_TYPE: + res['curve'] = util.read_frame(i) + res['pubkey'] = util.read_frame(i) + res['serial_number'] = util.recv(i, '>Q') + res['type'] = util.recv(i, '>L') + res['key_id'] = util.read_frame(i) + res['valid_principals'] = tuple(_iter_parse_list(util.read_frame(i))) + res['valid_after'] = util.recv(i, '>Q') + res['valid_before'] = util.recv(i, '>Q') + res['critical_options'] = tuple(_iter_parse_list(util.read_frame(i))) + res['extensions'] = tuple(_iter_parse_list(util.read_frame(i))) + res['reserved'] = util.read_frame(i) + res['signature_key'] = util.read_frame(i) + else: + res['nonce'] = first_frame + i.read(1) # SSH2_MSG_USERAUTH_REQUEST == 50 (from ssh2.h, line 108) + res['user'] = util.read_frame(i) + res['conn'] = util.read_frame(i) + res['auth'] = util.read_frame(i) + i.read(1) # have_sig == 1 (from sshconnect2.c, line 1056) + res['key_type'] = util.read_frame(i) + public_key = util.read_frame(i) + res['public_key'] = formats.parse_pubkey(public_key) unparsed = i.read() if unparsed: log.warning('unparsed blob: %r', unparsed) return res + + +def _iter_parse_list(blob): + i = io.BytesIO(blob) + while i.tell() < len(blob): + yield util.read_frame(i) From e8e033fb0bd6e985ecf49a802f490dbd971a1676 Mon Sep 17 00:00:00 2001 From: Roman Zeyde Date: Wed, 25 Dec 2024 17:27:33 +0200 Subject: [PATCH 26/35] Dedup sending age response --- libagent/age/__init__.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/libagent/age/__init__.py b/libagent/age/__init__.py index e20cb3c4..4ba7f6bd 100644 --- a/libagent/age/__init__.py +++ b/libagent/age/__init__.py @@ -121,9 +121,7 @@ def run_decrypt(device_type, args): for file_index, stanzas in stanza_map.items(): _handle_single_file(file_index, stanzas, identities, c) - sys.stdout.buffer.write('-> done\n\n'.encode()) - sys.stdout.flush() - sys.stdout.close() + send('-> done\n\n') def _handle_single_file(file_index, stanzas, identities, c): @@ -131,20 +129,25 @@ def _handle_single_file(file_index, stanzas, identities, c): for peer_pubkey, encrypted in stanzas: for identity in identities: id_str = identity.to_string() - msg = base64_encode(f'Please confirm {id_str} decryption on {d} device...'.encode()) - sys.stdout.buffer.write(f'-> msg\n{msg}\n'.encode()) - sys.stdout.flush() + msg = f'Please confirm {id_str} decryption on {d} device...' + send(f'-> msg\n{base64_encode(msg.encode())}\n') key = c.ecdh(identity=identity, peer_pubkey=peer_pubkey) + result = decrypt(key=key, encrypted=encrypted) if not result: continue - sys.stdout.buffer.write(f'-> file-key {file_index}\n{base64_encode(result)}\n'.encode()) - sys.stdout.flush() + send(f'-> file-key {file_index}\n{base64_encode(result)}\n') return +def send(msg): + """Send a response back to `age` binary.""" + sys.stdout.buffer.write(msg.encode()) + sys.stdout.flush() + + def main(device_type): """Parse command-line arguments.""" p = argparse.ArgumentParser() From 82f46355ad4ae0a3d63b10bd10d1751a369eb836 Mon Sep 17 00:00:00 2001 From: Roman Zeyde Date: Sun, 23 Nov 2025 15:27:32 +0100 Subject: [PATCH 27/35] Parse SSH server host key as well https://github.com/openssh/openssh-portable/commit/266678e19eb0e86fdf865b431b6e172e7a95bf48 --- libagent/ssh/client.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/libagent/ssh/client.py b/libagent/ssh/client.py index e2b2684b..ab3675a2 100644 --- a/libagent/ssh/client.py +++ b/libagent/ssh/client.py @@ -97,6 +97,8 @@ def parse_ssh_blob(data): res['key_type'] = util.read_frame(i) public_key = util.read_frame(i) res['public_key'] = formats.parse_pubkey(public_key) + if res['auth'] == b'publickey-hostbound-v00@openssh.com': + res['server_host_key'] = formats.parse_pubkey(util.read_frame(i)) unparsed = i.read() if unparsed: From 60bed0f411595ea1b0bb122fafdd0852e10d1eec Mon Sep 17 00:00:00 2001 From: Roman Zeyde Date: Sat, 31 Jan 2026 15:46:48 +0100 Subject: [PATCH 28/35] Drop keepkey support Also, simplify invocation examples. --- README.md | 3 +-- agents/keepkey/keepkey_agent.py | 5 ---- agents/keepkey/setup.py | 39 ---------------------------- doc/DESIGN.md | 4 +-- doc/INSTALL.md | 34 +++---------------------- doc/README-GPG.md | 6 ++--- doc/README-PINENTRY.md | 2 +- doc/README-SSH.md | 18 ++++++------- libagent/device/keepkey.py | 45 --------------------------------- libagent/device/keepkey_defs.py | 24 ------------------ libagent/ssh/__init__.py | 2 +- 11 files changed, 18 insertions(+), 164 deletions(-) delete mode 100644 agents/keepkey/keepkey_agent.py delete mode 100644 agents/keepkey/setup.py delete mode 100644 libagent/device/keepkey.py delete mode 100644 libagent/device/keepkey_defs.py diff --git a/README.md b/README.md index f31ff16b..3515c259 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ See the following blog posts about this tool: - [TREZOR Firmware 1.4.0 — GPG decryption support](https://www.reddit.com/r/TREZOR/comments/50h8r9/new_trezor_firmware_fidou2f_and_initial_ethereum/d7420q7/) - [A Step by Step Guide to Securing your SSH Keys with the Ledger Nano S](https://thoughts.t37.net/a-step-by-step-guide-to-securing-your-ssh-keys-with-the-ledger-nano-s-92e58c64a005) -Currently [TREZOR One](https://trezor.io/), [TREZOR Model T](https://trezor.io/), [Keepkey](https://www.keepkey.com/), [Blockstream Jade](https://blockstream.com/jade/), [Ledger Nano S](https://www.ledgerwallet.com/products/ledger-nano-s), and [OnlyKey](https://onlykey.io) are supported. +Currently [TREZOR One](https://trezor.io/), [TREZOR Model T](https://trezor.io/), [Blockstream Jade](https://blockstream.com/jade/), [Ledger Nano S](https://www.ledgerwallet.com/products/ledger-nano-s), and [OnlyKey](https://onlykey.io) are supported. ## Components @@ -25,7 +25,6 @@ agents to interact with several different hardware devices: * [`trezor-agent`](https://pypi.org/project/trezor-agent/): Using Trezor as hardware-based SSH/PGP/age agent * [`ledger_agent`](https://pypi.org/project/ledger_agent/): Using Ledger as hardware-based SSH/PGP agent * [`jade_agent`](https://github.com/Blockstream/Jade/): 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 ## Documentation diff --git a/agents/keepkey/keepkey_agent.py b/agents/keepkey/keepkey_agent.py deleted file mode 100644 index 03d8ee5e..00000000 --- a/agents/keepkey/keepkey_agent.py +++ /dev/null @@ -1,5 +0,0 @@ -import libagent.gpg -import libagent.ssh -from libagent.device import keepkey - -ssh_agent = lambda: libagent.ssh.main(keepkey.KeepKey) diff --git a/agents/keepkey/setup.py b/agents/keepkey/setup.py deleted file mode 100644 index f68aa838..00000000 --- a/agents/keepkey/setup.py +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env python -from setuptools import setup - -setup( - name='keepkey_agent', - version='0.9.0', - description='Using KeepKey as hardware SSH/GPG agent', - author='Roman Zeyde', - author_email='dev@romanzey.de', - url='http://github.com/romanz/trezor-agent', - python_requires='>=3.8', - scripts=['keepkey_agent.py'], - install_requires=[ - 'libagent>=0.9.0', - 'keepkey>=0.7.3' - ], - 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': [ - 'keepkey-agent = keepkey_agent:ssh_agent', - ]}, -) diff --git a/doc/DESIGN.md b/doc/DESIGN.md index 2a627515..bc7d10ff 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, 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. +The various hardware wallets have the ability 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. @@ -38,8 +38,6 @@ The `trezor-agent` then instructs SSH to connect to the server. It will then eng GPG uses much the same approach as SSH, except in this case it relies on [SLIP-0017 : ECDH using deterministic hierarchy][3] for the mapping to an ECDH key and it maps these to the normal GPG child key infrastructure. -Note: Keepkey does not support en-/de-cryption at this time. - ### Index The canonicalisation process ([SLIP-0013][2] and [SLIP-0017][3]) of an email address or ssh address allows for the mixing in of an extra 'index' - a unsigned 32 bit number. This allows one to have multiple, different keys, for the same address. diff --git a/doc/INSTALL.md b/doc/INSTALL.md index 7011e500..9696e6a6 100644 --- a/doc/INSTALL.md +++ b/doc/INSTALL.md @@ -83,33 +83,7 @@ gpg (GnuPG) 2.1.15 $ brew install trezor-agent ``` -# 3. Install the KeepKey agent - -1. Make sure you are running the latest firmware version on your KeepKey: - - * [KeepKey firmware releases](https://github.com/keepkey/keepkey-firmware/releases): `3.0.17+` - -2. Make sure that your `udev` rules are configured [correctly](https://support.keepkey.com/support/solutions/articles/6000037796-keepkey-wallet-is-not-being-recognized-by-linux). -Then, install the latest [keepkey-agent](https://pypi.python.org/pypi/keepkey-agent) package: - - ``` - $ pip3 install keepkey_agent - ``` - - Or, on Mac using Homebrew: - - ``` - $ brew install keepkey-agent - ``` - - Or, directly from the latest source code: - - ``` - $ git clone https://github.com/romanz/trezor-agent - $ pip3 install --user -e trezor-agent/agents/keepkey - ``` - -# 4. Install the Ledger Nano S agent +# 3. Install the Ledger Nano S agent 1. Make sure you are running the latest firmware version on your Ledger Nano S: @@ -130,7 +104,7 @@ Then, install the latest [keepkey-agent](https://pypi.python.org/pypi/keepkey-ag $ pip3 install --user -e trezor-agent/agents/ledger ``` -# 5. Install the OnlyKey agent +# 4. Install the OnlyKey agent 1. Make sure you are running the latest firmware version on your OnlyKey: @@ -151,7 +125,7 @@ Then, install the latest [keepkey-agent](https://pypi.python.org/pypi/keepkey-ag $ pip3 install --user -e trezor-agent/agents/onlykey ``` -# 6. Install the Blockstream Jade agent +# 5. Install the Blockstream Jade agent 1. Make sure you are running the latest firmware version on your Blockstream Jade: @@ -175,7 +149,7 @@ Then, install the latest [keepkey-agent](https://pypi.python.org/pypi/keepkey-ag $ pip3 install --user -e trezor-agent/agents/jade ``` -# 7. Installation Troubleshooting +# 6. 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 3542ec48..89076caf 100644 --- a/doc/README-GPG.md +++ b/doc/README-GPG.md @@ -18,14 +18,14 @@ Thanks! Run ``` - $ (trezor|keepkey|ledger|jade|onlykey)-gpg init "Roman Zeyde " + $ trezor-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|jade|onlykey)` to your `.bashrc` or other environment file. +2. Add `export GNUPGHOME=~/.gnupg/trezor` 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,8 +203,6 @@ 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 `jade` or `onlykey` as required. - ###### `trezor-gpg-agent.service` ```` diff --git a/doc/README-PINENTRY.md b/doc/README-PINENTRY.md index 17aa5c88..31125e3c 100644 --- a/doc/README-PINENTRY.md +++ b/doc/README-PINENTRY.md @@ -45,7 +45,7 @@ to the `[Service]` section to tell the PIN entry program how to connect to the X If you haven't completed initialization yet, run: ``` -$ (trezor|keepkey|ledger)-gpg init --pin-entry-binary trezor-gpg-pinentry-tk "Roman Zeyde " +$ trezor-gpg init --pin-entry-binary trezor-gpg-pinentry-tk "Roman Zeyde " ``` to configure the PIN entry at the same time. diff --git a/doc/README-SSH.md b/doc/README-SSH.md index 5a84127f..cf9b4c17 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|jade|onlykey)-agent -h` for details on supported options and the configuration file format. +See `trezor-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). ## Usage -Use the `(trezor|keepkey|ledger|jade|onlykey)-agent` program to work with SSH. It has three main modes of operation: +Use the `trezor-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|jade|onlykey)-agent identity@myhost +trezor-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|jade|onlykey)-agent identity@myhost -- COMMAND --WITH --ARGUMENTS +$ trezor-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|jade|onlykey)-agent -e ed25519 bob@example.com -- rsync up/ bob@example.com:/home/bob + trezor-agent -e ed25519 bob@example.com -- rsync up/ bob@example.com:/home/bob ``` As a shortcut you can run ``` -$ (trezor|keepkey|ledger|jade|onlykey)-agent identity@myhost -s +$ trezor-agent identity@myhost -s ``` to start a shell with the proper environment. -##### 3. Connect to a server directly via `(trezor|keepkey|ledger|jade|onlykey)-agent` +##### 3. Connect to a server directly via `trezor-agent` If you just want to connect to a server this is the simplest way to do it: ``` -$ (trezor|keepkey|ledger|jade|onlykey)-agent user@remotehost -c +$ trezor-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. @@ -154,8 +154,6 @@ For more details, see the following great blog post: https://calebhearth.com/sig ##### 1. Create these files in `~/.config/systemd/user` -Replace `trezor` with `keepkey` or `ledger` or `jade` or `onlykey` as required. - ###### `trezor-ssh-agent.service` ```` diff --git a/libagent/device/keepkey.py b/libagent/device/keepkey.py deleted file mode 100644 index a8d265b6..00000000 --- a/libagent/device/keepkey.py +++ /dev/null @@ -1,45 +0,0 @@ -"""KeepKey-related code (see https://www.keepkey.com/).""" - -from .. import formats -from . import trezor - - -def _verify_support(identity, ecdh): - """Make sure the device supports given configuration.""" - protocol = identity.identity_dict['proto'] - if protocol not in {'ssh'}: - raise NotImplementedError( - 'Unsupported protocol: {}'.format(protocol)) - if ecdh: - raise NotImplementedError('No support for ECDH') - if identity.curve_name not in {formats.CURVE_NIST256}: - raise NotImplementedError( - 'Unsupported elliptic curve: {}'.format(identity.curve_name)) - - -class KeepKey(trezor.Trezor): - """Connection to KeepKey device.""" - - @classmethod - def package_name(cls): - """Python package name (at PyPI).""" - return 'keepkey-agent' - - @property - def _defs(self): - from . import keepkey_defs - return keepkey_defs - - required_version = '>=1.0.4' - - def _override_state_handler(self, _): - """No support for `state` handling on Keepkey.""" - - def pubkey(self, identity, ecdh=False): - """Return public key.""" - _verify_support(identity, ecdh) - return trezor.Trezor.pubkey(self, identity=identity, ecdh=ecdh) - - def ecdh(self, identity, pubkey): - """No support for ECDH in KeepKey firmware.""" - _verify_support(identity, ecdh=True) diff --git a/libagent/device/keepkey_defs.py b/libagent/device/keepkey_defs.py deleted file mode 100644 index 85149613..00000000 --- a/libagent/device/keepkey_defs.py +++ /dev/null @@ -1,24 +0,0 @@ -"""KeepKey-related definitions.""" - -# pylint: disable=unused-import,import-error - -from keepkeylib.client import CallException -from keepkeylib.client import KeepKeyClient as Client -from keepkeylib.client import PinException -from keepkeylib.messages_pb2 import PassphraseAck, PinMatrixAck -from keepkeylib.transport_hid import HidTransport -from keepkeylib.transport_webusb import WebUsbTransport -from keepkeylib.types_pb2 import IdentityType - -get_public_node = Client.get_public_node -sign_identity = Client.sign_identity -Client.state = None - - -def find_device(): - """Returns first WebUSB or HID transport.""" - for d in WebUsbTransport.enumerate(): - return WebUsbTransport(d) - - for d in HidTransport.enumerate(): - return HidTransport(d) diff --git a/libagent/ssh/__init__.py b/libagent/ssh/__init__.py index 14f2656d..064bc083 100644 --- a/libagent/ssh/__init__.py +++ b/libagent/ssh/__init__.py @@ -320,7 +320,7 @@ def main(device_type): identity.identity_dict['proto'] = 'ssh' log.info('identity #%d: %s', index, identity.to_string()) - # override default PIN/passphrase entry tools (relevant for TREZOR/Keepkey): + # override default PIN/passphrase entry tools (relevant for TREZOR): device_type.ui = device.ui.UI(device_type=device_type, config=vars(args)) conn = JustInTimeConnection( From 34ec4eee4ee5ea836e375eed949799c58f64ddb8 Mon Sep 17 00:00:00 2001 From: Roman Zeyde Date: Sat, 7 Feb 2026 10:02:18 +0100 Subject: [PATCH 29/35] Drop ledger support https://github.com/LedgerHQ/app-ssh-agent has beed deprecated in https://github.com/LedgerHQ/app-ssh-agent/pull/48. --- README.md | 4 +- agents/ledger/ledger_agent.py | 7 -- agents/ledger/setup.py | 41 -------- doc/INSTALL.md | 27 +----- doc/README-GPG.md | 2 +- doc/README-Windows.md | 2 +- libagent/device/ledger.py | 178 ---------------------------------- 7 files changed, 6 insertions(+), 255 deletions(-) delete mode 100644 agents/ledger/ledger_agent.py delete mode 100644 agents/ledger/setup.py delete mode 100644 libagent/device/ledger.py diff --git a/README.md b/README.md index 3515c259..2b03626d 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,8 @@ See the following blog posts about this tool: - [TREZOR Firmware 1.3.4 enables SSH login](https://medium.com/@satoshilabs/trezor-firmware-1-3-4-enables-ssh-login-86a622d7e609) - [TREZOR Firmware 1.3.6 — GPG Signing, SSH Login Updates and Advanced Transaction Features for Segwit](https://medium.com/@satoshilabs/trezor-firmware-1-3-6-20a7df6e692) - [TREZOR Firmware 1.4.0 — GPG decryption support](https://www.reddit.com/r/TREZOR/comments/50h8r9/new_trezor_firmware_fidou2f_and_initial_ethereum/d7420q7/) -- [A Step by Step Guide to Securing your SSH Keys with the Ledger Nano S](https://thoughts.t37.net/a-step-by-step-guide-to-securing-your-ssh-keys-with-the-ledger-nano-s-92e58c64a005) -Currently [TREZOR One](https://trezor.io/), [TREZOR Model T](https://trezor.io/), [Blockstream Jade](https://blockstream.com/jade/), [Ledger Nano S](https://www.ledgerwallet.com/products/ledger-nano-s), and [OnlyKey](https://onlykey.io) are supported. +Currently [TREZOR One](https://trezor.io/), [TREZOR Model T](https://trezor.io/), [Blockstream Jade](https://blockstream.com/jade/) and [OnlyKey](https://onlykey.io) are supported. ## Components @@ -23,7 +22,6 @@ 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/age agent -* [`ledger_agent`](https://pypi.org/project/ledger_agent/): Using Ledger as hardware-based SSH/PGP agent * [`jade_agent`](https://github.com/Blockstream/Jade/): Using Blockstream Jade 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/ledger/ledger_agent.py b/agents/ledger/ledger_agent.py deleted file mode 100644 index 8ba42169..00000000 --- a/agents/ledger/ledger_agent.py +++ /dev/null @@ -1,7 +0,0 @@ -import libagent.gpg -import libagent.ssh -from libagent.device.ledger import LedgerNanoS 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/ledger/setup.py b/agents/ledger/setup.py deleted file mode 100644 index b4bb08f6..00000000 --- a/agents/ledger/setup.py +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env python -from setuptools import setup - -setup( - name='ledger_agent', - version='0.9.0', - description='Using Ledger as hardware SSH/GPG agent', - author='Roman Zeyde', - author_email='dev@romanzey.de', - url='http://github.com/romanz/trezor-agent', - python_requires='>=3.8', - scripts=['ledger_agent.py'], - install_requires=[ - 'libagent>=0.9.0', - 'ledgerblue>=0.1.8' - ], - 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': [ - 'ledger-agent = ledger_agent:ssh_agent', - 'ledger-gpg = ledger_agent:gpg_tool', - 'ledger-gpg-agent = ledger_agent:gpg_agent', - ]}, -) diff --git a/doc/INSTALL.md b/doc/INSTALL.md index 9696e6a6..b59a81b0 100644 --- a/doc/INSTALL.md +++ b/doc/INSTALL.md @@ -83,28 +83,7 @@ gpg (GnuPG) 2.1.15 $ brew install trezor-agent ``` -# 3. Install the Ledger Nano S agent - -1. Make sure you are running the latest firmware version on your Ledger Nano S: - - * [Ledger Nano S firmware releases](https://github.com/LedgerHQ/blue-app-ssh-agent): `0.0.3+` (install [SSH/PGP Agent](https://www.ledgerwallet.com/images/apps/chrome-mngr-apps.png) app) - -2. Make sure that your `udev` rules are configured [correctly](https://ledger.zendesk.com/hc/en-us/articles/115005165269-What-if-Ledger-Wallet-is-not-recognized-on-Linux-). -3. Then, install the latest [ledger-agent](https://pypi.python.org/pypi/ledger-agent) package: - - ``` - $ pip3 install ledger-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/ledger - ``` - -# 4. Install the OnlyKey agent +# 3. Install the OnlyKey agent 1. Make sure you are running the latest firmware version on your OnlyKey: @@ -125,7 +104,7 @@ gpg (GnuPG) 2.1.15 $ pip3 install --user -e trezor-agent/agents/onlykey ``` -# 5. Install the Blockstream Jade agent +# 4. Install the Blockstream Jade agent 1. Make sure you are running the latest firmware version on your Blockstream Jade: @@ -149,7 +128,7 @@ gpg (GnuPG) 2.1.15 $ pip3 install --user -e trezor-agent/agents/jade ``` -# 6. Installation Troubleshooting +# 5. 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 89076caf..a8a518c5 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,jade}/gpg-agent.log` (can be [encrypted](https://keybase.io/romanz)) + * attach the GPG agent log from `~/.gnupg/trezor/gpg-agent.log` (can be [encrypted](https://keybase.io/romanz)) Thanks! diff --git a/doc/README-Windows.md b/doc/README-Windows.md index 28d08414..68b6e0c7 100644 --- a/doc/README-Windows.md +++ b/doc/README-Windows.md @@ -2,7 +2,7 @@ ## Preface -Since this library supports multiple hardware security devices, this document uses the term `` in commands to refer to the device of your choice. For example, if using Ledger Nano S, the command `-agent` becomes `ledger-agent`. +Since this library supports multiple hardware security devices, this document uses the term `` in commands to refer to the device of your choice. Installation and building has to be done with administrative privileges. Without these, the agent would only be installed for the current user, and could therefore not be used as a service. To run an administrative shell, hold the Windows key on the keyboard, and press R. In the input box that appears, type either "cmd" or "powershell" (Based on your preference. Both work), and then hold the Ctrl and Shift keys, and press Enter. A User Account Control dialog will pop up. Simply press "Yes". diff --git a/libagent/device/ledger.py b/libagent/device/ledger.py deleted file mode 100644 index df846a6f..00000000 --- a/libagent/device/ledger.py +++ /dev/null @@ -1,178 +0,0 @@ -"""Ledger-related code (see https://www.ledgerwallet.com/).""" - -import binascii -import logging -import struct - -from ledgerblue import comm # pylint: disable=import-error - -from .. import formats -from . import interface - -log = logging.getLogger(__name__) - - -def _expand_path(path): - """Convert BIP32 path into bytes.""" - return b''.join((struct.pack('>I', e) for e in path)) - - -def _convert_public_key(ecdsa_curve_name, result): - """Convert Ledger reply into PublicKey object.""" - if ecdsa_curve_name == 'nist256p1': - if (result[64] & 1) != 0: - result = bytearray([0x03]) + result[1:33] - else: - result = bytearray([0x02]) + result[1:33] - else: - result = result[1:] - keyX = bytearray(result[0:32]) - keyY = bytearray(result[32:][::-1]) - if (keyX[31] & 1) != 0: - keyY[31] |= 0x80 - result = b'\x00' + bytes(keyY) - return bytes(result) - - -class LedgerNanoS(interface.Device): - """Connection to Ledger Nano S device.""" - - LEDGER_APP_NAME = "SSH/PGP Agent" - ledger_app_version = None - ledger_app_supports_end_of_frame_byte = True - - def get_app_name_and_version(self, dongle): - """Retrieve currently running Ledger application name and its version string.""" - device_version_answer = dongle.exchange(binascii.unhexlify('B001000000')) - offset = 1 - app_name_length = struct.unpack_from("B", device_version_answer, offset)[0] - offset += 1 - app_name = device_version_answer[offset: offset + app_name_length] - offset += app_name_length - app_version_length = struct.unpack_from("B", device_version_answer, offset)[0] - offset += 1 - app_version = device_version_answer[offset: offset + app_version_length] - log.debug("running app %s, version %s", app_name, app_version) - return (app_name.decode(), app_version.decode()) - - @classmethod - def package_name(cls): - """Python package name (at PyPI).""" - return 'ledger-agent' - - def connect(self): - """Enumerate and connect to the first USB HID interface.""" - try: - dongle = comm.getDongle(debug=True) - (app_name, self.ledger_app_version) = self.get_app_name_and_version(dongle) - - version_parts = self.ledger_app_version.split(".") - if (version_parts[0] == "0" and version_parts[1] == "0" and int(version_parts[2]) <= 7): - self.ledger_app_supports_end_of_frame_byte = False - - if app_name != LedgerNanoS.LEDGER_APP_NAME: - # we could launch the app here if we are in the dashboard - raise interface.DeviceError(f'{self} is not running {LedgerNanoS.LEDGER_APP_NAME}') - - return dongle - except comm.CommException as e: - raise interface.DeviceError( - 'Error ({}) communicating with {}'.format(e, self)) - - def pubkey(self, identity, ecdh=False): - """Get PublicKey object for specified BIP32 address and elliptic curve.""" - curve_name = identity.get_curve_name(ecdh) - path = _expand_path(identity.get_bip32_address(ecdh)) - if curve_name == 'nist256p1': - p2 = '01' - else: - p2 = '02' - apdu = '800200' + p2 - apdu = binascii.unhexlify(apdu) - apdu += bytearray([len(path) + 1, len(path) // 4]) - apdu += path - log.debug('apdu: %r', apdu) - result = bytearray(self.conn.exchange(bytes(apdu))) - log.debug('result: %r', result) - return formats.decompress_pubkey( - pubkey=_convert_public_key(curve_name, result[1:]), - curve_name=identity.curve_name) - - def sign(self, identity, blob): - """Sign given blob and return the signature (as bytes).""" - # pylint: disable=too-many-locals,too-many-branches - path = _expand_path(identity.get_bip32_address(ecdh=False)) - offset = 0 - result = None - while offset != len(blob): - data = bytes() - if offset == 0: - data += bytearray([len(path) // 4]) + path - chunk_size = min(len(blob) - offset, 255 - len(data)) - data += blob[offset:offset + chunk_size] - - if identity.identity_dict['proto'] == 'ssh': - ins = '04' - else: - ins = '08' - - if identity.curve_name == 'nist256p1': - p2 = '81' if identity.identity_dict['proto'] == 'ssh' else '01' - else: - p2 = '82' if identity.identity_dict['proto'] == 'ssh' else '02' - - if offset + chunk_size == len(blob) and self.ledger_app_supports_end_of_frame_byte: - # mark that we are at the end of the frame - p1 = "80" if offset == 0 else "81" - else: - p1 = "00" if offset == 0 else "01" - - apdu = binascii.unhexlify('80' + ins + p1 + p2) + len(data).to_bytes(1, 'little') + data - - log.debug('apdu: %r', apdu) - try: - result = bytearray(self.conn.exchange(bytes(apdu))) - except comm.CommException as e: - raise interface.DeviceError( - 'Error ({}) communicating with {}'.format(e, self)) - - offset += chunk_size - - log.debug('result: %r', result) - if identity.curve_name == 'nist256p1': - offset = 3 - length = result[offset] - r = result[offset+1:offset+1+length] - if r[0] == 0: - r = r[1:] - offset = offset + 1 + length + 1 - length = result[offset] - s = result[offset+1:offset+1+length] - if s[0] == 0: - s = s[1:] - offset = offset + 1 + length - return bytes(r) + bytes(s) - else: - return bytes(result[:64]) - - def ecdh(self, identity, pubkey): - """Get shared session key using Elliptic Curve Diffie-Hellman.""" - path = _expand_path(identity.get_bip32_address(ecdh=True)) - if identity.curve_name == 'nist256p1': - p2 = '01' - else: - p2 = '02' - apdu = '800a00' + p2 - apdu = binascii.unhexlify(apdu) - apdu += bytearray([len(pubkey) + len(path) + 1]) - apdu += bytearray([len(path) // 4]) + path - apdu += pubkey - log.debug('apdu: %r', apdu) - try: - result = bytearray(self.conn.exchange(bytes(apdu))) - except comm.CommException as e: - raise interface.DeviceError( - 'Error ({}) communicating with {}'.format(e, self)) - log.debug('result: %r', result) - assert result[0] == 0x04 - return bytes(result) From 60e4a387517b583d0b70f0ada1b04f2b9706d193 Mon Sep 17 00:00:00 2001 From: nitramiz Date: Wed, 14 Jan 2026 16:50:24 +0000 Subject: [PATCH 30/35] libagent: Add USB IDs for Jade Plus --- libagent/device/jade.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/libagent/device/jade.py b/libagent/device/jade.py index 4660aacf..5ff3f1cf 100644 --- a/libagent/device/jade.py +++ b/libagent/device/jade.py @@ -22,7 +22,11 @@ class BlockstreamJade(interface.Device): """Connection to Blockstream Jade device.""" MIN_SUPPORTED_FW_VERSION = semver.VersionInfo(0, 1, 33) - DEVICE_IDS = [(0x10c4, 0xea60), (0x1a86, 0x55d4)] + DEVICE_IDS = [ + (0x10c4, 0xea60), # Jade 1.0 + (0x1a86, 0x55d4), # Jade 1.1 + (0x303a, 0x4001), # Jade 2 (Plus) + ] connection = None @classmethod From 05298ad5a377cf0a72f20bc3c4693cf4131ecf34 Mon Sep 17 00:00:00 2001 From: Roman Zeyde Date: Sat, 31 Jan 2026 11:53:52 +0100 Subject: [PATCH 31/35] Switch to trezorlib 0.20 Also support TS7. --- agents/trezor/setup.py | 6 +-- libagent/device/trezor.py | 97 ++++++++++++---------------------- libagent/device/trezor_defs.py | 30 ----------- libagent/device/ui.py | 9 ++++ setup.py | 2 +- 5 files changed, 48 insertions(+), 96 deletions(-) delete mode 100644 libagent/device/trezor_defs.py diff --git a/agents/trezor/setup.py b/agents/trezor/setup.py index 28d21736..02920da7 100644 --- a/agents/trezor/setup.py +++ b/agents/trezor/setup.py @@ -3,7 +3,7 @@ setup( name='trezor_agent', - version='0.12.0', + version='0.13.0', description='Using Trezor as hardware SSH/GPG agent', author='Roman Zeyde', author_email='dev@romanzey.de', @@ -11,8 +11,8 @@ python_requires='>=3.8', scripts=['trezor_agent.py'], install_requires=[ - 'libagent>=0.14.0', - 'trezor[hidapi]>=0.13' + 'libagent>=0.16.0', + 'trezor[hidapi]>=0.20' ], platforms=['POSIX'], classifiers=[ diff --git a/libagent/device/trezor.py b/libagent/device/trezor.py index 65978b39..e1afa236 100644 --- a/libagent/device/trezor.py +++ b/libagent/device/trezor.py @@ -1,9 +1,12 @@ """TREZOR-related code (see http://bitcointrezor.com/).""" -import binascii import logging -import semver +from trezorlib.btc import get_public_node +from trezorlib.client import get_default_client, get_default_session +from trezorlib.exceptions import TrezorFailure +from trezorlib.messages import IdentityType +from trezorlib.misc import get_ecdh_session_key, sign_identity from .. import formats from . import interface @@ -19,64 +22,34 @@ def package_name(cls): """Python package name (at PyPI).""" return 'trezor-agent' - @property - def _defs(self): - from . import trezor_defs - return trezor_defs - required_version = '>=1.4.0' ui = None # can be overridden by device's users - cached_session_id = None - - def _verify_version(self, connection): - f = connection.features - log.debug('connected to %s %s', self, f.device_id) - log.debug('label : %s', f.label) - log.debug('vendor : %s', f.vendor) - current_version = '{}.{}.{}'.format(f.major_version, - f.minor_version, - f.patch_version) - log.debug('version : %s', current_version) - log.debug('revision : %s', binascii.hexlify(f.revision)) - if not semver.match(current_version, self.required_version): - fmt = ('Please upgrade your {} firmware to {} version' - ' (current: {})') - raise ValueError(fmt.format(self, self.required_version, - current_version)) + _session = None # cache one session per agent process + + @property + def session(self): + """Return cached session, or connect and pair if needed.""" + if self.__class__._session is None: + assert self.ui is not None + client = get_default_client( + app_name="trezor-agent", + pin_callback=self.ui.get_pin, + code_entry_callback=self.ui.get_pairing_code, + ) + session = client.get_session(passphrase="") # TODO: support passphrase + log.info("%s @ fpr=%s", session, session.get_root_fingerprint().hex()) + self.__class__._session = session + + return self.__class__._session def connect(self): - """Enumerate and connect to the first available interface.""" - transport = self._defs.find_device() - if not transport: - raise interface.NotFoundError('{} not connected'.format(self)) - - log.debug('using transport: %s', transport) - for _ in range(5): # Retry a few times in case of PIN failures - connection = self._defs.Client(transport=transport, - ui=self.ui, - session_id=self.__class__.cached_session_id) - self._verify_version(connection) - - try: - # unlock PIN and passphrase - self._defs.get_address(connection, - "Testnet", - self._defs.PASSPHRASE_TEST_PATH) - return connection - except (self._defs.PinException, ValueError) as e: - log.error('Invalid PIN: %s, retrying...', e) - continue - except Exception as e: - log.exception('ping failed: %s', e) - connection.close() # so the next HID open() will succeed - raise - return None + """One session is cached.""" + return self def close(self): - """Close connection.""" - self.__class__.cached_session_id = self.conn.session_id - super().close() + """One session is cached.""" + pass def pubkey(self, identity, ecdh=False): """Return public key.""" @@ -84,8 +57,8 @@ def pubkey(self, identity, ecdh=False): log.debug('"%s" getting public key (%s) from %s', identity.to_string(), curve_name, self) addr = identity.get_bip32_address(ecdh=ecdh) - result = self._defs.get_public_node( - self.conn, + result = get_public_node( + self.session, n=addr, ecdsa_curve_name=curve_name) log.debug('result: %s', result) @@ -93,7 +66,7 @@ def pubkey(self, identity, ecdh=False): return formats.decompress_pubkey(pubkey=pubkey, curve_name=identity.curve_name) def _identity_proto(self, identity): - result = self._defs.IdentityType() + result = IdentityType() for name, value in identity.items(): setattr(result, name, value) return result @@ -109,8 +82,8 @@ def sign_with_pubkey(self, identity, blob): log.debug('"%s" signing %r (%s) on %s', identity.to_string(), blob, curve_name, self) try: - result = self._defs.sign_identity( - self.conn, + result = sign_identity( + self.session, identity=self._identity_proto(identity), challenge_hidden=blob, challenge_visual='', @@ -119,7 +92,7 @@ def sign_with_pubkey(self, identity, blob): assert len(result.signature) == 65 assert result.signature[:1] == b'\x00' return bytes(result.signature[1:]), bytes(result.public_key) - except self._defs.TrezorFailure as e: + except TrezorFailure as e: msg = '{} error: {}'.format(self, e) log.debug(msg, exc_info=True) raise interface.DeviceError(msg) @@ -135,8 +108,8 @@ def ecdh_with_pubkey(self, identity, pubkey): log.debug('"%s" shared session key (%s) for %r from %s', identity.to_string(), curve_name, pubkey, self) try: - result = self._defs.get_ecdh_session_key( - self.conn, + result = get_ecdh_session_key( + self.session, identity=self._identity_proto(identity), peer_public_key=pubkey, ecdsa_curve_name=curve_name) @@ -148,7 +121,7 @@ def ecdh_with_pubkey(self, identity, pubkey): self_pubkey = bytes(self_pubkey[1:]) return bytes(result.session_key), self_pubkey - except self._defs.TrezorFailure as e: + except TrezorFailure as e: msg = '{} error: {}'.format(self, e) log.debug(msg, exc_info=True) raise interface.DeviceError(msg) diff --git a/libagent/device/trezor_defs.py b/libagent/device/trezor_defs.py deleted file mode 100644 index b6a36da6..00000000 --- a/libagent/device/trezor_defs.py +++ /dev/null @@ -1,30 +0,0 @@ -"""TREZOR-related definitions.""" - -# pylint: disable=unused-import,import-error,no-name-in-module,no-member -import logging -import os - -import mnemonic -import semver -import trezorlib -from trezorlib.btc import get_address, get_public_node -from trezorlib.client import PASSPHRASE_TEST_PATH -from trezorlib.client import TrezorClient as Client -from trezorlib.exceptions import PinException, TrezorFailure -from trezorlib.messages import IdentityType -from trezorlib.misc import get_ecdh_session_key, sign_identity -from trezorlib.transport import get_transport - -log = logging.getLogger(__name__) - - -def find_device(): - """Selects a transport based on `TREZOR_PATH` environment variable. - - If unset, picks first connected device. - """ - try: - return get_transport(os.environ.get("TREZOR_PATH"), prefix_search=True) - except Exception as e: # pylint: disable=broad-except - log.debug("Failed to find a Trezor device: %s", e) - return None diff --git a/libagent/device/ui.py b/libagent/device/ui.py index 2cf0f130..9d5eb747 100644 --- a/libagent/device/ui.py +++ b/libagent/device/ui.py @@ -49,6 +49,15 @@ def get_pin(self, _code=None): binary=self.pin_entry_binary, options=self.options_getter()) + def get_pairing_code(self): + """Ask the user for pairing code.""" + return interact( + title='{} pairing'.format(self.device_name), + prompt='Pairing code:', + description='Enter 6-digit code show on {} screen'.format(self.device_name), + binary=self.pin_entry_binary, + options=self.options_getter()) + def get_passphrase(self, prompt='Passphrase:', available_on_device=False): """Ask the user for passphrase.""" passphrase = None diff --git a/setup.py b/setup.py index 4c21923a..40efa88d 100755 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ setup( name='libagent', - version='0.15.0', + version='0.16.0', description='Using hardware wallets as SSH/GPG/age agent', author='Roman Zeyde', author_email='dev@romanzey.de', From de6301e9c8d5459be070a472abf85c59998f8c32 Mon Sep 17 00:00:00 2001 From: Roman Zeyde Date: Sun, 1 Mar 2026 12:02:24 +0100 Subject: [PATCH 32/35] Lookup GnuPG user ID (instead of assuming it's the first one) --- libagent/gpg/agent.py | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/libagent/gpg/agent.py b/libagent/gpg/agent.py index 15c93643..2e04385c 100644 --- a/libagent/gpg/agent.py +++ b/libagent/gpg/agent.py @@ -161,19 +161,26 @@ def get_identity(self, keygrip): keygrip_bytes = binascii.unhexlify(keygrip) pubkey_dict, user_ids = decode.load_by_keygrip( pubkey_bytes=self.pubkey_bytes, keygrip=keygrip_bytes) - # We assume the first user ID is used to generate TREZOR-based GPG keys. - user_id = user_ids[0]['value'].decode('utf-8') + log.debug("pubkey_dict %s", pubkey_dict) + curve_name = protocol.get_curve_name_by_oid(pubkey_dict['curve_oid']) ecdh = pubkey_dict['algo'] == protocol.ECDH_ALGO_ID - identity = client.create_identity(user_id=user_id, curve_name=curve_name) - verifying_key = self.client.pubkey(identity=identity, ecdh=ecdh) - pubkey = protocol.PublicKey( - curve_name=curve_name, created=pubkey_dict['created'], - verifying_key=verifying_key, ecdh=ecdh) - assert pubkey.key_id() == pubkey_dict['key_id'] - assert pubkey.keygrip() == keygrip_bytes - return identity + # Lookup the first user ID that matches the provided keygrip + for user_id_dict in user_ids: + log.debug("user_id: %s", user_id_dict) + user_id = user_id_dict['value'].decode('utf-8') + + identity = client.create_identity(user_id=user_id, curve_name=curve_name) + verifying_key = self.client.pubkey(identity=identity, ecdh=ecdh) + pubkey = protocol.PublicKey( + curve_name=curve_name, created=pubkey_dict['created'], + verifying_key=verifying_key, ecdh=ecdh) + + if pubkey.keygrip() == keygrip_bytes and pubkey.key_id() == pubkey_dict['key_id']: + return identity + + raise KeyError(keygrip) def pksign(self, conn): """Sign a message digest using a private EC key.""" From ccfccbf9e58a581d8df785cb0f780487a6ca4aca Mon Sep 17 00:00:00 2001 From: Roman Zeyde Date: Sun, 1 Mar 2026 12:02:24 +0100 Subject: [PATCH 33/35] Fix `load_by_keygrip()` docstring --- libagent/gpg/decode.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libagent/gpg/decode.py b/libagent/gpg/decode.py index 1d03b4b1..3dfbaa50 100644 --- a/libagent/gpg/decode.py +++ b/libagent/gpg/decode.py @@ -294,7 +294,7 @@ def _parse_pubkey_packets(pubkey_bytes): def load_by_keygrip(pubkey_bytes, keygrip): - """Return public key and first user ID for specified keygrip.""" + """Return public key and user IDs for specified keygrip.""" for packets in _parse_pubkey_packets(pubkey_bytes): user_ids = [p for p in packets if p['type'] == 'user_id'] for p in packets: From 29fc6e43abeb8e6da587286c43ec1f8b24d25ec2 Mon Sep 17 00:00:00 2001 From: Roman Zeyde Date: Sun, 1 Mar 2026 15:36:50 +0100 Subject: [PATCH 34/35] Fix passphrase support on Trezor --- libagent/device/trezor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libagent/device/trezor.py b/libagent/device/trezor.py index e1afa236..a067c84c 100644 --- a/libagent/device/trezor.py +++ b/libagent/device/trezor.py @@ -3,7 +3,7 @@ import logging from trezorlib.btc import get_public_node -from trezorlib.client import get_default_client, get_default_session +from trezorlib.client import PassphraseSetting, get_default_client from trezorlib.exceptions import TrezorFailure from trezorlib.messages import IdentityType from trezorlib.misc import get_ecdh_session_key, sign_identity @@ -37,7 +37,7 @@ def session(self): pin_callback=self.ui.get_pin, code_entry_callback=self.ui.get_pairing_code, ) - session = client.get_session(passphrase="") # TODO: support passphrase + session = client.get_session(passphrase=PassphraseSetting.AUTO) log.info("%s @ fpr=%s", session, session.get_root_fingerprint().hex()) self.__class__._session = session From 22545a4134bac6a16f4b325adc21e2e3757d9a7c Mon Sep 17 00:00:00 2001 From: Roman Zeyde Date: Sun, 1 Mar 2026 16:30:38 +0100 Subject: [PATCH 35/35] Release libagent 0.16.1 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 40efa88d..a8add9f5 100755 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ setup( name='libagent', - version='0.16.0', + version='0.16.1', description='Using hardware wallets as SSH/GPG/age agent', author='Roman Zeyde', author_email='dev@romanzey.de',