From 4793784604b07322a5add65196895af10eda33b9 Mon Sep 17 00:00:00 2001 From: onlykey Date: Fri, 23 Oct 2026 15:36:26 -0400 Subject: [PATCH] onlykey-agent 1.1.16: composite PQC GPG keys, derived-v2 identities For OnlyKey firmware v3.0.5 and python-onlykey 1.3.0. - Composite post-quantum GPG keys (libagent/device/onlykey_pqc.py): ML-KEM-768 + X25519 decryption and ML-DSA-65 + Ed25519 signing with keys in the PQC1-PQC4 slots, following draft-ietf-openpgp-pqc-10. - Derived identities v2: --skey/--dkey derived-v2 (or ECC32v2) selects the HKDF-based derivation (firmware v3.0.5+). The default is unchanged, so existing SSH and GPG keys stay the same. - The OnlyKey device module opens the HID handle once and always closes it on failure, and uses python-onlykey's shared challenge-code rule and response classifier (onlykey.protocol). - Packaging: onlykey-agent requires onlykey>=1.3.0 and lib-agent>=1.0.8, and Python 3.10+; PyPI project description added. --- agents/onlykey/MANIFEST.in | 1 + agents/onlykey/README.md | 70 ++++++++ agents/onlykey/setup.py | 31 +++- libagent/device/interface.py | 4 + libagent/device/onlykey.py | 191 +++++++++++++++------- libagent/device/onlykey_defs.py | 5 + libagent/device/onlykey_pqc.py | 153 ++++++++++++++++++ libagent/device/ui.py | 5 +- libagent/gpg/__init__.py | 116 +++++++++---- libagent/gpg/agent.py | 91 +++++++++-- libagent/gpg/keyring.py | 16 +- libagent/gpg/tests/test_agent.py | 63 ++++++++ libagent/gpg/tests/test_keyring.py | 39 +++++ libagent/server.py | 234 +++++++++++++++++++++++++++ libagent/ssh/__init__.py | 78 +++++++-- libagent/tests/test_onlykey_slots.py | 25 +++ libagent/tests/test_server.py | 84 ++++++++++ 17 files changed, 1084 insertions(+), 122 deletions(-) create mode 100644 agents/onlykey/MANIFEST.in create mode 100644 agents/onlykey/README.md create mode 100644 libagent/device/onlykey_pqc.py create mode 100644 libagent/tests/test_onlykey_slots.py diff --git a/agents/onlykey/MANIFEST.in b/agents/onlykey/MANIFEST.in new file mode 100644 index 00000000..bb3ec5f0 --- /dev/null +++ b/agents/onlykey/MANIFEST.in @@ -0,0 +1 @@ +include README.md diff --git a/agents/onlykey/README.md b/agents/onlykey/README.md new file mode 100644 index 00000000..318e25bd --- /dev/null +++ b/agents/onlykey/README.md @@ -0,0 +1,70 @@ +# onlykey-agent + +**Use your [OnlyKey](https://onlykey.io) as a hardware-based SSH and GPG agent.** + +`onlykey-agent` lets you perform SSH authentication and GPG operations (signing, +encryption, and decryption) using keys that are generated and stored on an +OnlyKey hardware security device. Your private keys are created on the device +and never leave it — they are never written to disk or exposed to your computer, +which protects them from malware and key-theft even on a compromised host. + +It is part of the [`lib-agent`](https://pypi.org/project/lib-agent/) family of +hardware agents (alongside `trezor-agent`, `ledger_agent`, `keepkey_agent`, and +`jade_agent`). + +## Features + +- **SSH agent** — authenticate to servers and Git hosts with a key held on your OnlyKey. +- **GPG agent** — sign emails, Git commits, and software releases, and decrypt messages. +- **age support** — encrypt and decrypt files with hardware-backed keys. +- **No keys on disk** — keys are generated and stored on the device and never reach the host. + +## Installation + +```bash +pip install onlykey-agent +``` + +This installs three console entry points: + +- `onlykey-agent` — SSH agent +- `onlykey-gpg` — GPG tool +- `onlykey-gpg-agent` — GPG agent + +## Usage + +Use your OnlyKey to log in over SSH: + +```bash +onlykey-agent user@example.com -c +``` + +Set up a GPG identity backed by your OnlyKey: + +```bash +onlykey-gpg init "Your Name " +``` + +Derived keys default to the original derivation, so existing SSH and GPG keys +are unchanged. `--skey derived-v2` / `--dkey derived-v2` selects the newer +HKDF-based derivation (firmware v3.1.0+), which gives different keys. + +Full SSH and GPG instructions, including common use cases, are available in the +[project documentation](https://github.com/trustcrypto/onlykey-agent). + +## Requirements + +- Python 3.10+ +- An [OnlyKey](https://onlykey.io) device (composite PQC GPG keys and `--skey/--dkey derived-v2` need firmware v3.1.0 or newer) +- [`lib-agent`](https://pypi.org/project/lib-agent/) and the + [`onlykey`](https://pypi.org/project/onlykey/) Python package (installed automatically) + +## Links + +- Homepage / source: https://github.com/trustcrypto/onlykey-agent +- OnlyKey: https://onlykey.io +- Documentation: https://docs.onlykey.io + +## License + +Released under the GNU Lesser General Public License v3 (LGPLv3). diff --git a/agents/onlykey/setup.py b/agents/onlykey/setup.py index 697b4d9b..25903068 100644 --- a/agents/onlykey/setup.py +++ b/agents/onlykey/setup.py @@ -1,18 +1,35 @@ #!/usr/bin/env python +import os + from setuptools import setup +here = os.path.abspath(os.path.dirname(__file__)) +try: + with open(os.path.join(here, 'README.md'), encoding='utf-8') as f: + long_description = f.read() +except IOError: + long_description = 'Using OnlyKey as hardware SSH/GPG agent' + setup( name='onlykey-agent', - version='1.1.13', + version='1.1.16', description='Using OnlyKey as hardware SSH/GPG agent', + long_description=long_description, + long_description_content_type='text/markdown', author='CryptoTrust', author_email='admin@crp.to', url='http://github.com/trustcrypto/onlykey-agent', + project_urls={ + 'Homepage': 'https://github.com/trustcrypto/onlykey-agent', + 'Documentation': 'https://docs.onlykey.io', + 'Source': 'https://github.com/trustcrypto/onlykey-agent', + }, scripts=['onlykey_agent.py'], install_requires=[ - 'lib-agent>=1.0.6', - 'onlykey>=1.2.8' + 'lib-agent>=1.0.8', + 'onlykey>=1.3.0' ], + python_requires='>=3.10', platforms=['POSIX'], classifiers=[ 'Environment :: Console', @@ -22,10 +39,10 @@ 'Intended Audience :: System Administrators', 'License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)', 'Operating System :: POSIX', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13', 'Topic :: Software Development :: Libraries :: Python Modules', 'Topic :: System :: Networking', 'Topic :: Communications', diff --git a/libagent/device/interface.py b/libagent/device/interface.py index a21aad77..00ea962b 100644 --- a/libagent/device/interface.py +++ b/libagent/device/interface.py @@ -109,6 +109,10 @@ def __init__(self): """C-tor.""" self.conn = None + @classmethod + def prepare(cls): + """Per-process setup, on the thread that will outlive the agent's worker threads.""" + def connect(self): """Connect to device, otherwise raise NotFoundError.""" raise NotImplementedError() diff --git a/libagent/device/onlykey.py b/libagent/device/onlykey.py index 63451426..405ab965 100644 --- a/libagent/device/onlykey.py +++ b/libagent/device/onlykey.py @@ -7,6 +7,7 @@ import logging import os import re +import sys import time from os import path @@ -37,21 +38,101 @@ def _defs(self): return onlykey_defs def connect(self): - """Enumerate and connect to the first USB HID interface.""" + """Enumerate and connect to the first USB HID interface. + + The HID handle is opened exactly once and always closed again on any + failure. The previous version constructed a fresh OnlyKey() - each of + which opens a USB HID handle - on every iteration of a retry loop and + never closed the earlier ones when the version string came back short, + so a client that hit that path (a running onlykey-gpg-agent in + particular) leaked open handles that the OS kept claimed, blocking + every other process - onlykey-cli, the OnlyKey App - from enumerating + the key until the agent was killed. + """ + self.device_name = 'OnlyKey' + self.ok = None t_end = time.time() + 2.5 while time.time() < t_end: try: - self.device_name = 'OnlyKey' self.ok = self._defs.OnlyKey() self.ok.set_time(time.time()) self.okversion = self.ok.read_string(timeout_ms=100) if len(self.okversion) > 8: self.okversion = self.okversion[8:] - if self.okversion[0] == 'v': - break + if self.okversion and self.okversion[0] == 'v': + self._probe_capabilities() + return + # Not the reply we wanted (device still booting / another + # client mid-exchange): drop this handle before retrying so we + # never hold more than one open at a time. + self._safe_close() + time.sleep(0.1) except Exception as exc: + self._safe_close() raise interface.NotFoundError( '{} not connected: "{}"'.format(self.device_name, exc)) from exc + # Timed out without a good version - do not leave a handle open for the + # caller to inherit; it thinks connect() succeeded otherwise. + self._safe_close() + raise interface.NotFoundError( + '{} not connected: no version response'.format(self.device_name)) + + def _probe_capabilities(self): + """Ask the firmware what it supports; None on firmware without the report.""" + self.caps = None + try: + self.caps = self.ok.getcapabilities() + except Exception as exc: # best effort - old firmware, busy device + log.debug('capabilities probe failed: %s', exc) + if self.caps: + log.info('firmware %s flags=%s', self.caps.get('version'), + [f.name for f in self.caps.get('flags', [])]) + + def _require_derived_v2(self, slot): + if self._derived_version(slot) != 2: + return + if self.caps: + supported = self._defs.CapabilityFlag.HKDF_V2_DERIVATION in self.caps.get('flags', []) + else: + m = re.match(r'v(\d+)\.(\d+)\.(\d+)', getattr(self, 'okversion', '') or '') + supported = not m or tuple(int(x) for x in m.groups()) >= (3, 0, 5) + if not supported: + raise interface.DeviceError( + 'derived-v2 needs OnlyKey firmware v3.1.0 or later (this OnlyKey has {})'.format( + getattr(self, 'okversion', None) or 'older firmware')) + + @classmethod + def prepare(cls): + if sys.platform == 'darwin': + # hidapi's IOHIDManager belongs to the thread that creates it; created on + # an agent worker thread, hid_exit() segfaults when Python exits. + import hid + hid.enumerate(0x1d50, 0x60fc) + + def _is_duo(self): + return bool(self.caps) and self._defs.CapabilityFlag.DUO in self.caps.get('flags', []) + + def _challenge(self, raw_message, identity): + """Print the 3-digit code the device wants in challenge-code mode.""" + b1, b2, b3 = self._defs.challenge_code(raw_message, duo=self._is_duo()) + print('Confirm on OnlyKey to authorize ' + identity.to_string() + + ': press any button, or enter {} {} {} if derivedkeymode/storedkeymode is 0' + .format(b1, b2, b3), file=sys.stderr, flush=True) + + def _raise_if_error(self, result): + """Raise DeviceError when a raw report is an Error/ERROR string from the device.""" + kind, text = self._defs.classify_response(result)[:2] + if kind == 'error': + raise interface.DeviceError(text) + + def _safe_close(self): + """Close and drop self.ok if it is open, swallowing any error.""" + if getattr(self, 'ok', None) is not None: + try: + self.ok.close() + except Exception: # pylint: disable=broad-except + pass + self.ok = None def set_skey(self, skey): """Set signing key to use.""" @@ -95,7 +176,26 @@ def get_key_by_keygrip(self, keygrip): raise KeyError('keygrip %s not found' % keygriplong) return None - DEFAULT_SLOT = 132 + DEFAULT_SLOT = 132 # derived v1 (released SHA256 KDF), a.k.a. ECC32 + DERIVED_V2_SLOT = 232 # derived v2 (HKDF): --skey/--dkey derived-v2 or ECC32v2 + + @classmethod + def _derived_version(cls, slot): + """1 for the released derived key (132), 2 for the HKDF one (232), else 0.""" + if slot == cls.DEFAULT_SLOT: + return 1 + if slot == cls.DERIVED_V2_SLOT: + return 2 + return 0 + + def _derived_code(self, slot, curve_name, op): + """Sign/decrypt code for a derived identity from the shared protocol table.""" + ver = 'v%d' % self._derived_version(slot) + table = self._defs.AGENT_DERIVATION[ver][op] + kt = self._defs.KeyType + curve = {'ed25519': kt.ED25519, 'nist256p1': kt.P256R1, + 'curve25519': kt.CURVE25519}.get(curve_name, kt.P256K1) + return table[curve] _SKEY_SLOT_RE = re.compile(r'--skey-slot=(\S+)') _DKEY_SLOT_RE = re.compile(r'--dkey-slot=(\S+)') @@ -109,13 +209,15 @@ def _parse_slot_value(value): if not value: return None try: + if value.lower() in ('derived-v2', 'ecc32v2'): + return 232 if value.startswith('ECC'): return int(value[3:]) + 100 if value.startswith('RSA'): return int(value[3:]) return int(value) except ValueError: - log.warning('Unrecognized key-slot value %r in run-agent.sh', value) + log.warning('Unrecognized key-slot value %r in run-agent.sh/.cmd', value) return None def get_sk_dk(self): @@ -129,8 +231,12 @@ def get_sk_dk(self): self.set_dkey(self.DEFAULT_SLOT) return - fpath = os.path.join(homedir, 'run-agent.sh') - log.debug('Path to run-agent.sh = %s', fpath) + # The slots are read back from the launcher `onlykey-gpg init` wrote, + # and on Windows that is run-agent.cmd (gpg cannot start a shell + # script there). Missing it would silently fall back to the default. + name = 'run-agent.cmd' if sys.platform == 'win32' else 'run-agent.sh' + fpath = os.path.join(homedir, name) + log.debug('Path to %s = %s', name, fpath) if not path.exists(fpath): self.set_skey(self.DEFAULT_SLOT) self.set_dkey(self.DEFAULT_SLOT) @@ -157,7 +263,7 @@ def sig_hash(self, sighash): def close(self): """Close connection.""" log.info('disconnected from %s', self.device_name) - self.ok.close() + self._safe_close() def pubkey(self, identity, ecdh=False): """Return public key.""" @@ -179,7 +285,11 @@ def pubkey(self, identity, ecdh=False): this_slot_id = self.skeyslot log.info('Key Slot =%s', this_slot_id) else: - this_slot_id = 132 + # derived identity: 132 = v1 (SHA256 KDF), 232 = v2 (HKDF); same request shape + this_slot_id = self.dkeyslot if ecdh else self.skeyslot + if not self._derived_version(this_slot_id): + this_slot_id = self.DEFAULT_SLOT + self._require_derived_v2(this_slot_id) log.info('Requesting public key from key slot =%s', this_slot_id) @@ -227,8 +337,7 @@ def pubkey(self, identity, ecdh=False): raise interface.DeviceError(e) log.info('received= %s', repr(ok_pubkey)) - if ok_pubkey[:5] == [69, 114, 114, 111, 114]: - raise interface.DeviceError("".join([chr(value) for value in ok_pubkey])) + self._raise_if_error(ok_pubkey) if len(set(ok_pubkey[34:63])) == 1: if curve_name in ('nist256p1', 'secp256k1'): raise interface.DeviceError("Public key curve does not match requested type") @@ -342,16 +451,10 @@ def sign(self, identity, blob): raw_message = blob else: raw_message = data - elif self.skeyslot == 132: - if curve_name == 'ed25519': - this_slot_id = 201 - log.info('Key type ed25519') - elif curve_name == 'nist256p1': - this_slot_id = 202 - log.info('Key type nistp256') - else: - this_slot_id = 203 - log.info('Key type secp256k1') + elif self._derived_version(self.skeyslot): + self._require_derived_v2(self.skeyslot) + this_slot_id = self._derived_code(self.skeyslot, curve_name, 'sign') + log.info('Derived v%d %s sign code %d', self._derived_version(self.skeyslot), curve_name, this_slot_id) # Send data and identity hash raw_message = blob + data elif curve_name != 'rsa': @@ -363,14 +466,8 @@ def sign(self, identity, blob): # Send just hash raw_message = data - h2 = hashlib.sha256() - h2.update(raw_message) - d = h2.digest() - assert len(d) == 32 - b1, b2, b3 = get_button(self, d[0]), get_button(self, d[15]), get_button(self, d[31]) log.info('Key Slot =%s', this_slot_id) - print('Enter the 3 digit challenge code on OnlyKey to authorize '+identity.to_string()) - print('{} {} {}'.format(b1, b2, b3)) + self._challenge(raw_message, identity) t_end = time.time() + 22 if 'rsa' not in curve_name: self.ok.send_large_message2(msg=self._defs.Message.OKSIGN, payload=raw_message, @@ -383,6 +480,7 @@ def sign(self, identity, blob): except Exception as e: raise interface.DeviceError(e) + self._raise_if_error(result) if len(result) >= 60: log.info('received= %s', repr(result)) while len(result) < 64: @@ -403,6 +501,7 @@ def sign(self, identity, blob): try: sig_part = self.ok.read_bytes(timeout_ms=100) if len(sig_part) == 64 and len(set(sig_part[0:63])) != 1: + self._raise_if_error(sig_part) log.info('received part= %s', repr(sig_part)) result += sig_part if len(result) == siglen: @@ -453,16 +552,10 @@ def ecdh(self, identity, pubkey): keygrip = identity.identity_dict['keygrip'] keygrip_slot_id = self.get_key_by_keygrip(keygrip) - if self.dkeyslot == 132: - if curve_name == 'curve25519': - this_slot_id = 204 - log.info('Key type curve25519') - elif curve_name == 'nist256p1': - this_slot_id = 202 - log.info('Key type nistp256') - else: - this_slot_id = 203 - log.info('Key type secp256k1') + if self._derived_version(self.dkeyslot): + self._require_derived_v2(self.dkeyslot) + this_slot_id = self._derived_code(self.dkeyslot, curve_name, 'decrypt') + log.info('Derived v%d %s decrypt code %d', self._derived_version(self.dkeyslot), curve_name, this_slot_id) raw_message = pubkey + data else: if keygrip_slot_id is not None: @@ -472,15 +565,9 @@ def ecdh(self, identity, pubkey): raw_message = pubkey log.info('Key Slot =%s', this_slot_id) log.info('data hash =%s', data) - h2 = hashlib.sha256() - h2.update(raw_message) - d = h2.digest() - assert len(d) == 32 - b1, b2, b3 = get_button(self, d[0]), get_button(self, d[15]), get_button(self, d[31]) self.ok.send_large_message2(msg=self._defs.Message.OKDECRYPT, payload=raw_message, slot_id=this_slot_id) - print('Enter the 3 digit challenge code on OnlyKey to authorize ' + identity.to_string()) - print('{} {} {}'.format(b1, b2, b3)) + self._challenge(raw_message, identity) t_end = time.time() + 22 if 'rsa' not in curve_name: while time.time() < t_end: @@ -490,6 +577,7 @@ def ecdh(self, identity, pubkey): break except Exception as e: raise interface.DeviceError(e) + self._raise_if_error(result) if len(set(result[34:63])) == 1: result = b'\x04' + bytes(result[0:32]) else: @@ -498,6 +586,7 @@ def ecdh(self, identity, pubkey): try: dec_part = self.ok.read_bytes(timeout_ms=100) if len(dec_part) == 64 and len(set(dec_part[0:63])) != 1: + self._raise_if_error(dec_part) log.info('received part= %s', repr(dec_part)) result += dec_part t_end = time.time() + 1 @@ -511,16 +600,10 @@ def ecdh(self, identity, pubkey): return bytes(result) -def get_button(self, byte): - """Return button number.""" - if str(self.okversion) == 'v0.2-beta.8c': - return byte % 5 + 1 - else: - return byte % 6 + 1 - - def convert_keyslot(self, s): # pylint: disable=unused-argument """Return key slot number.""" + if s.lower() in ('derived-v2', 'ecc32v2'): + return 232 if 'ECC' in s: if len(s) == 5: return int(s[3:5]) + 100 diff --git a/libagent/device/onlykey_defs.py b/libagent/device/onlykey_defs.py index c716a0fe..9749e04a 100644 --- a/libagent/device/onlykey_defs.py +++ b/libagent/device/onlykey_defs.py @@ -3,3 +3,8 @@ # pylint: disable=unused-import,import-error,no-name-in-module from onlykey import Message, OnlyKey +# Shared protocol logic (generated from libraries/onlykey/protocol/onlykey-protocol.json): +# the 3-digit challenge rule and the "is this reply an Error string?" classifier live +# there, not here. +from onlykey.protocol import (AGENT_DERIVATION, CapabilityFlag, KeyType, + challenge_code, classify_response) diff --git a/libagent/device/onlykey_pqc.py b/libagent/device/onlykey_pqc.py new file mode 100644 index 00000000..a18efa74 --- /dev/null +++ b/libagent/device/onlykey_pqc.py @@ -0,0 +1,153 @@ +""" +libagent/device/onlykey_pqc.py — composite post-quantum PGP for the OnlyKey backend. + +Bridges lib-agent's GPG sign/decrypt to the composite PQC keys loaded on the OnlyKey +(trustcrypto/libraries PR #31). One RSA slot (1-4) holds the composite key; the device +does each half on-device, the host does the OpenPGP composite framing (concat sig / +SHA3-256 key combine + AES key-unwrap), exactly like the openpgp.js `pqc` branch. + +Composite algorithms (draft-ietf-openpgp-pqc): + * pqc_mldsa_ed25519 (algo 30): sign = Ed25519 sig (64) || ML-DSA-65 sig (3309) + * pqc_mlkem_x25519 (algo 35): decrypt = X25519 + ML-KEM-768, combined per + draft-ietf-openpgp-pqc-10 section 4.2.1 - SHA3-256 over mlkemKeyShare, + ecdhKeyShare, ecdhCipherText, ecdhPublicKey, algId, "OpenPGPCompositeKDFv1" + and its length - then RFC 3394 AES-256 key-unwrap. + +Wire protocol (okpqc.cpp on the device): + * OKSIGN, slot, payload = [selector] + digest (selector 0=Ed25519, 1=ML-DSA) + * OKDECRYPT, slot, payload = 32-B X25519 point -> 32-B shared secret (ECC half) + * OKDECRYPT, slot, payload = 1088-B ML-KEM ct -> 32-B shared secret (PQC half) + +UNTESTED against hardware — by inspection; mirror-verified against openpgp.js kem.js. +GnuPG cannot consume these keys. It is on the LibrePGP track: v5 keys, its own +Kyber codepoint 8 (PUBKEY_ALGO_KYBER in common/openpgpdefs.h) and no ML-DSA at +all, and it rejects a v6 key packet outright. Checked against 2.4.8 and 2.5.21; +no released version supports RFC 9580 v6 keys. rpgp >= 0.20 with the `draft-pqc` +feature is the interoperable implementation. +""" +import hashlib +import struct + +# component selectors (match okpqc.h) +HALF_ECC = 0 +HALF_PQC = 1 + +# sizes +X25519_PT_LEN = 32 +MLKEM_CT_LEN = 1088 +SS_LEN = 32 +ED25519_SIG_LEN = 64 +MLDSA_SIG_LEN = 3309 + +# IANA-assigned (draft-ietf-openpgp-pqc-10). This is NOT just a tag: it is an +# input to the key combiner in composite_decrypt() below, so it is bound into +# the derived KEK. It was 105 - a PRIVATE/experimental codepoint - and every key +# or message produced under that value is unreadable here and everywhere else. +MLKEM_ALGO_ID = 35 # pqc_mlkem_x25519 +COMPOSITE_KDF_LABEL = b"OpenPGPCompositeKDFv1" + + +# --------------------------- device dual-ops --------------------------------- + +def _read_exact(ok, want, timeout_s=22): + import time + out = bytearray() + t_end = time.time() + timeout_s + while time.time() < t_end and len(out) < want: + try: + part = ok.read_bytes(timeout_ms=100) + except Exception: + continue + if part: + out.extend(part) + return bytes(out[:want]) + + +def device_sign_half(ok, slot, selector, digest): + """OKSIGN one half. Returns the raw signature (64 B Ed25519 / 3309 B ML-DSA).""" + ok.send_large_message2(msg=ok_msg(ok, 'OKSIGN'), slot_id=slot, + payload=bytes([selector]) + bytes(digest)) + want = ED25519_SIG_LEN if selector == HALF_ECC else MLDSA_SIG_LEN + return _read_exact(ok, want) + + +def device_decap_half(ok, slot, data): + """OKDECRYPT one half. `data` is the 32-B X25519 point or the 1088-B ML-KEM ct; + the device selects by size and returns the 32-B shared secret.""" + ok.send_large_message2(msg=ok_msg(ok, 'OKDECRYPT'), slot_id=slot, payload=bytes(data)) + return _read_exact(ok, SS_LEN) + + +def ok_msg(ok, name): + """Resolve the Message enum from whatever onlykey client the backend holds.""" + from onlykey.client import Message + return getattr(Message, name) + + +# --------------------------- composite sign ---------------------------------- + +def composite_sign(ok, slot, digest): + """pqc_mldsa_ed25519 signature = Ed25519(64) || ML-DSA-65(3309).""" + ecc = device_sign_half(ok, slot, HALF_ECC, digest) + pqc = device_sign_half(ok, slot, HALF_PQC, digest) + if len(ecc) != ED25519_SIG_LEN or len(pqc) != MLDSA_SIG_LEN: + raise ValueError("composite sign: bad half lengths %d/%d" % (len(ecc), len(pqc))) + return ecc + pqc + + +# --------------------------- composite decrypt ------------------------------- + +def _sha3_256(*parts): + h = hashlib.sha3_256() + for p in parts: + h.update(p) + return h.digest() + + +def _aes_key_unwrap(kek, wrapped): + """RFC 3394 AES key unwrap (AES-256).""" + from cryptography.hazmat.primitives.keywrap import aes_key_unwrap + return aes_key_unwrap(bytes(kek), bytes(wrapped)) + + +def composite_decrypt(ok, slot, ecc_ct, mlkem_ct, ecc_pub, mlkem_pub, wrapped_key, + algo_id=MLKEM_ALGO_ID): + """pqc_mlkem_x25519 decrypt -> session key. + + ecc_ct: 32-B X25519 ephemeral point; mlkem_ct: 1088-B ML-KEM ciphertext; + ecc_pub: 32-B recipient X25519 pubkey; mlkem_pub: 1184-B ML-KEM ek; wrapped_key: C. + Mirrors openpgp.js kem.js decrypt exactly. + """ + # 1) device does both halves + ecc_ss = device_decap_half(ok, slot, ecc_ct) # X25519(k, ephemeral) + mlkem_ss = device_decap_half(ok, slot, mlkem_ct) # ML-KEM decapsulate + + # 2) ECC key share IS the raw X25519 shared secret - draft-10 section + # 4.1.1.1, x25519Kem.Decaps(): "Set the output ecdhKeyShare to X". It is + # NOT hashed with the ciphertext and recipient key first, which is what + # this tree used to do. + ecc_key_share = ecc_ss + mlkem_key_share = mlkem_ss + + # 3) Key combiner - draft-10 section 4.2.1, verbatim: + # KEK = SHA3-256( mlkemKeyShare || ecdhKeyShare || ecdhCipherText || + # ecdhPublicKey || algId || domSep || len(domSep) ) + # where domSep is "OpenPGPCompositeKDFv1" and len(domSep) is one octet, + # decimal 21. + # + # NOT KMAC256, and neither the ML-KEM ciphertext nor its public key is + # an input. This tree previously used KMAC256 over data that included + # both, and a KEK built that way fails AES key unwrap against a + # conforming peer. + kek = _sha3_256( + mlkem_key_share, + ecc_key_share, + ecc_ct, + ecc_pub, + bytes([algo_id]), + COMPOSITE_KDF_LABEL, + bytes([len(COMPOSITE_KDF_LABEL)]), + ) + + # 4) AES-256 key unwrap -> session key + return _aes_key_unwrap(kek, wrapped_key) diff --git a/libagent/device/ui.py b/libagent/device/ui.py index 00486262..40b3febe 100644 --- a/libagent/device/ui.py +++ b/libagent/device/ui.py @@ -82,7 +82,10 @@ def create_default_options_getter(): try: ttyname = subprocess.check_output(args=['tty']).strip() options.append(b'ttyname=' + ttyname) - except subprocess.CalledProcessError as e: + except (subprocess.CalledProcessError, OSError) as e: + # OSError: no `tty` program at all. Windows has none outside a + # Git Bash or MSYS shell, and there isatty() is also true for NUL, + # the stdin a detached agent gets. log.warning('no TTY found: %s', e) display = os.environ.get('DISPLAY') diff --git a/libagent/gpg/__init__.py b/libagent/gpg/__init__.py index 53ab255a..b4dac728 100644 --- a/libagent/gpg/__init__.py +++ b/libagent/gpg/__init__.py @@ -21,7 +21,6 @@ import Crypto.Hash import Crypto.PublicKey import Crypto.Signature -import daemon import semver from Crypto.Hash import SHA256, SHA512 from Crypto.PublicKey import RSA @@ -118,6 +117,12 @@ def write_file(path, data): return f +def _windowless_python(): + """Windows: pythonw.exe next to this Python (no console window), else this Python.""" + pythonw = os.path.join(os.path.dirname(sys.executable), 'pythonw.exe') + return pythonw if os.path.isfile(pythonw) else sys.executable + + def run_init(device_type, args): """Initialize hardware-based GnuPG identity.""" util.setup_logging(verbosity=args.verbose) @@ -141,8 +146,11 @@ def run_init(device_type, args): 'remove it manually if required', homedir) sys.exit(1) - check_call(['mkdir', '-p', homedir]) - check_call(['chmod', '700', homedir]) + # os.makedirs/os.chmod, not the mkdir/chmod programs: Windows has neither + # (outside a Git Bash or MSYS shell). On Windows chmod only sets the + # read-only bit, which 0o700 leaves off. + os.makedirs(homedir) + os.chmod(homedir, 0o700) agent_path = util.which('{}-gpg-agent'.format(device_name)) @@ -151,29 +159,33 @@ def run_init(device_type, args): if args.import_pub is not None: with args.import_pub as f: device_type.import_pub(device_type, f.read()) - with open(os.path.join(homedir, 'run-agent.sh'), 'w') as f: - f.write(r"""#!/bin/sh - export PATH="{0}" - {1} \ - -vv \ - --skey-slot={skey} \ - --dkey-slot={dkey} \ - $* - """.format(os.environ['PATH'], agent_path, **vars(args))) + agent_flags = ['--skey-slot={skey}', '--dkey-slot={dkey}'] else: - with open(os.path.join(homedir, 'run-agent.sh'), 'w') as f: - f.write(r"""#!/bin/sh - export PATH="{0}" - {1} \ - -vv \ - --pin-entry-binary={pin_entry_binary} \ - --passphrase-entry-binary={passphrase_entry_binary} \ - --cache-expiry-seconds={cache_expiry_seconds} \ - $* - """.format(os.environ['PATH'], agent_path, **vars(args))) - - check_call(['chmod', '700', f.name]) - run_agent_script = f.name + agent_flags = ['--pin-entry-binary={pin_entry_binary}', + '--passphrase-entry-binary={passphrase_entry_binary}', + '--cache-expiry-seconds={cache_expiry_seconds}'] + agent_flags = ['-vv'] + [flag.format(**vars(args)) for flag in agent_flags] + + if sys.platform == 'win32': + # gpg on Windows starts agent-program with CreateProcess, which cannot + # run a shell script (error 193, "not a valid Win32 application"), so + # the launcher is a batch file doing what run-agent.sh does: set PATH, + # then start the agent with these flags and gpg's own arguments (%*). + # The agent runs under pythonw.exe (pip's .exe launchers are zip files + # Python can run): a console program started from gpg would open a + # console window, every time gpg starts its agent. + run_agent_script = os.path.join(homedir, 'run-agent.cmd') + with open(run_agent_script, 'w') as f: + f.write('@echo off\nset "PATH={0}"\n"{1}" "{2}" {3} %*\n'.format( + os.environ['PATH'], _windowless_python(), agent_path, + ' '.join(agent_flags))) + else: + run_agent_script = os.path.join(homedir, 'run-agent.sh') + with open(run_agent_script, 'w') as f: + f.write('#!/bin/sh\nexport PATH="{0}"\n{1} \\\n{2} \\\n$*\n'.format( + os.environ['PATH'], agent_path, + ' \\\n'.join(agent_flags))) + os.chmod(run_agent_script, 0o700) # Prepare GPG configuration file with open(os.path.join(homedir, 'gpg.conf'), 'w') as f: @@ -196,7 +208,7 @@ def run_init(device_type, args): ${{COMMAND}} fi """.format(homedir)) - check_call(['chmod', '700', f.name]) + os.chmod(f.name, 0o700) # Generate new GPG identity and import into GPG keyring pubkey = write_file(os.path.join(homedir, 'pubkey.asc'), @@ -237,6 +249,10 @@ def _server_from_assuan_fd(env): def _server_from_sock_path(env): sock_path = keyring.get_agent_sock_path(env=env) + if sys.platform == 'win32': + # GnuPG for Windows has no UNIX sockets: it reads libassuan's socket + # file (a loopback port and a nonce) at the same path. + return server.assuan_nonce_socket_server(sock_path) return server.unix_domain_socket_server(sock_path) @@ -250,10 +266,10 @@ def run_agent(device_type): if device_type.package_name() == 'onlykey-agent': p.add_argument('-sk', '--skey', type=str, metavar='SIGN_KEY', default='ECC32', - help='specify key to use for signing') + help='signing key: RSA1-4, ECC1-16, ECC32 (derived, default) or derived-v2 (HKDF)') p.add_argument('-dk', '--dkey', type=str, metavar='DECRYPT_KEY', default='ECC32', - help='specify key to use for decryption') + help='decryption key: RSA1-4, ECC1-16, ECC32 (derived, default) or derived-v2 (HKDF)') else: p.add_argument('--passphrase-entry-binary', type=str, default='pinentry', help='Path to passphrase entry UI helper.') @@ -264,7 +280,34 @@ def run_agent(device_type): args, _ = p.parse_known_args() - if args.daemon: + if args.daemon and sys.platform == 'win32': + # gpg starts agent-program with --daemon and waits for it to exit, then + # connects to the socket. python-daemon cannot run on Windows, so the + # agent starts a detached copy of itself without --daemon, and exits. + # The copy gets no handles from gpg: gpg would wait for them to close. + # It also leaves the job object of pip's .exe launcher, which kills + # every process in the job when the launcher exits. + script = sys.argv[0] + if not os.path.isfile(script) and os.path.isfile(script + '.exe'): + script += '.exe' # started through pip's launcher: argv[0] has no .exe + argv = [_windowless_python(), script] + [a for a in sys.argv[1:] if a != '--daemon'] + flags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP + try: + subprocess.Popen(argv, close_fds=True, stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + creationflags=flags | subprocess.CREATE_BREAKAWAY_FROM_JOB) + except OSError: + # A job that does not allow breakaway refuses the flag (access + # denied); the copy is then started inside that job. + subprocess.Popen(argv, close_fds=True, stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + creationflags=flags) + elif args.daemon: + # Imported here, not at the top: python-daemon needs the Unix-only + # `pwd` module, so a top-level import stopped every lib-agent command + # from starting on Windows (onlykey-agent, onlykey-gpg init) although + # only --daemon uses it. Daemonizing stays Unix-only. + import daemon # pylint: disable=import-outside-toplevel with daemon.DaemonContext(): run_agent_internal(args, device_type) else: @@ -303,6 +346,11 @@ def run_agent_internal(args, device_type): handler.handle(conn) except agent.AgentStop: log.info('stopping gpg-agent') + except ConnectionResetError as e: + # Windows reports gpg closing its connection as a reset + # (WinError 10054) where POSIX gives end-of-file: that + # client is gone, and the agent keeps serving the next. + log.debug('connection reset: %s', e) except IOError as e: log.info('connection closed: %s', e) return @@ -336,10 +384,10 @@ def main(device_type): p.add_argument('-e', '--ecdsa-curve', default='ed25519') p.add_argument('-sk', '--skey', type=str, metavar='SIGN_KEY', default='ECC32', - help='specify key to use for signing') + help='signing key: RSA1-4, ECC1-16, ECC32 (derived, default) or derived-v2 (HKDF)') p.add_argument('-dk', '--dkey', type=str, metavar='DECRYPT_KEY', default='ECC32', - help='specify key to use for decryption') + help='decryption key: RSA1-4, ECC1-16, ECC32 (derived, default) or derived-v2 (HKDF)') p.add_argument('-i', '--import-pub', type=argparse.FileType('r'), metavar='IMPORT_PUBLIC_KEY', default=None, @@ -380,4 +428,8 @@ def main(device_type): else: device_type.ui = device.ui.UI(device_type=device_type, config=vars(args)) - return args.func(device_type=device_type, args=args) + try: + return args.func(device_type=device_type, args=args) + except device.interface.DeviceError as e: + log.error('%s', e) + return 1 diff --git a/libagent/gpg/agent.py b/libagent/gpg/agent.py index 17c9aae8..668d2dbf 100644 --- a/libagent/gpg/agent.py +++ b/libagent/gpg/agent.py @@ -1,7 +1,11 @@ """GPG-agent utilities.""" import binascii +import hashlib import logging +from cryptography.hazmat.primitives.keywrap import (InvalidUnwrap, + aes_key_unwrap) + from .. import util from . import client, decode, keyring, protocol @@ -57,23 +61,67 @@ def _serialize_rsa(data): return b'(5:value' + util.assuan_serialize(prefix + data) + b')' -def parse_decrypt(line): - """Parse ECDH request and return remote public key.""" +def parse_ciphertext(line): + """Parse a PKDECRYPT ciphertext: its algorithm (ecdh, rsa or ecc) and parameters.""" prefix, line = line.split(b' ', 1) assert prefix == b'D' exp, leftover = keyring.parse(keyring.unescape(line)) - log.debug('ECDH/RSA s-exp: %r', exp) + log.debug('PKDECRYPT s-exp: %r', exp) assert not leftover label, exp = exp assert label == b'enc-val' - assert (exp[0] == b'ecdh' or exp[0] == b'rsa') - items = exp[1:] - if exp[0] == b'rsa': - log.debug('RSA parameters: %r', items) - return dict(items)[b'a'] - else: - log.debug('ECDH parameters: %r', items) - return dict(items)[b'e'] + # ecdh and rsa: GnuPG up to 2.4. ecc: GnuPG 2.5's PKDECRYPT --kem=PGP. + assert exp[0] in (b'ecdh', b'rsa', b'ecc') + return exp[0], dict(exp[1:]) + + +def parse_decrypt(line): + """Parse ECDH request and return remote public key.""" + algo, params = parse_ciphertext(line) + return params[b'a'] if algo == b'rsa' else params[b'e'] + + +# OpenPGP ids, as GnuPG 2.5 sends them in (c ...) and (h ...) (RFC 4880 9.2, 9.4). +_KEM_KEYWRAP_KEY_LEN = {7: 16, 8: 24, 9: 32} # AES-128, AES-192, AES-256 +_KEM_KDF_HASH = {8: hashlib.sha256, 9: hashlib.sha384, 10: hashlib.sha512} + + +def _ecdh_x(point): + """The shared secret RFC 6637 feeds to its KDF: the x-coordinate of the ECDH point.""" + if len(point) == 33 and point[0] in (0x04, 0x40): # Curve25519, with a prefix byte + return point[1:] + if len(point) == 65 and point[0] == 0x04: # uncompressed NIST/secp256k1 point + return point[1:33] + if len(point) == 64: # x || y + return point[:32] + if len(point) == 32: + return point + raise ValueError('unexpected ECDH result length {}'.format(len(point))) + + +def kem_pgp_session_key(params, shared_point): + """ + Finish GnuPG 2.5's PKDECRYPT --kem=PGP: the KDF and the key unwrap gpg did itself up to 2.4. + + RFC 6637 section 8: KEK = Hash(00 00 00 01 || Z || kdf-params), truncated to the + key-wrap key length; the session key is AES-key-unwrapped from s, whose first + byte is the length of the rest. + """ + if params.get(b't') is not None: + raise AgentError(b'ERR 67108924 Not supported ') # RFC 9580 X25519 keys + cipher, hash_algo = int(params[b'c']), int(params[b'h']) + if cipher not in _KEM_KEYWRAP_KEY_LEN or hash_algo not in _KEM_KDF_HASH: + raise AgentError(b'ERR 67108924 Not supported ') + wrapped = params[b's'] + if not wrapped or wrapped[0] != len(wrapped) - 1: + raise AgentError(b'ERR 67108943 Invalid data ') + digest = _KEM_KDF_HASH[hash_algo]( + b'\x00\x00\x00\x01' + _ecdh_x(shared_point) + params[b'kdf-params']).digest() + kek = digest[:_KEM_KEYWRAP_KEY_LEN[cipher]] + try: + return aes_key_unwrap(kek, wrapped[1:]) + except InvalidUnwrap as e: + raise AgentError(b'ERR 67109016 Decryption failed ') from e def _key_info(conn, args): @@ -125,7 +173,7 @@ def __init__(self, device, pubkey_bytes): b'SETKEY': lambda _, args: self.set_key(*args), b'SETHASH': lambda _, args: self.set_hash(*args), b'PKSIGN': lambda conn, _: self.pksign(conn), - b'PKDECRYPT': lambda conn, _: self.pkdecrypt(conn), + b'PKDECRYPT': self.pkdecrypt, b'HAVEKEY': lambda conn, args: self.have_key(conn, *args), b'KEYINFO': _key_info, b'SCD': self.handle_scd, @@ -245,16 +293,31 @@ def pksign(self, conn): log.debug('result: %r', result) keyring.sendline(conn, b'D ' + result) - def pkdecrypt(self, conn): + def pkdecrypt(self, conn, args=()): """Handle decryption using ECDH/RSA.""" + kem = None + for arg in args: + if arg.startswith(b'--kem'): + kem = arg.split(b'=', 1)[1] if b'=' in arg else b'PQC-PGP' + if kem not in (None, b'PGP'): + raise AgentError(b'ERR 67108924 Not supported ') + for msg in [b'S INQUIRE_MAXLEN 4096', b'INQUIRE CIPHERTEXT']: keyring.sendline(conn, msg) line = keyring.recvline(conn) assert keyring.recvline(conn) == b'END' - remote_pubkey = parse_decrypt(line) + algo, params = parse_ciphertext(line) + remote_pubkey = params[b'a'] if algo == b'rsa' else params[b'e'] identity = self.get_identity(keygrip=self.keygrip) + if kem == b'PGP': + # GnuPG 2.5 hands the KDF and the key unwrap to the agent and expects the + # session key back, without a PADDING status. + shared_point = self.client.ecdh(identity=identity, pubkey=remote_pubkey) + dvalue = _serialize_point(kem_pgp_session_key(params, shared_point)) + keyring.sendline(conn, b'D ' + dvalue) + return if identity.curve_name in ('rsa2048', 'rsa4096'): dvalue = _serialize_rsa(self.client.ecdh(identity=identity, pubkey=remote_pubkey)) else: diff --git a/libagent/gpg/keyring.py b/libagent/gpg/keyring.py index 2260b82d..f8dd54b4 100644 --- a/libagent/gpg/keyring.py +++ b/libagent/gpg/keyring.py @@ -8,6 +8,7 @@ import re import socket import subprocess +from urllib.parse import unquote, unquote_to_bytes from .. import util @@ -29,8 +30,11 @@ def get_agent_sock_path(env=None, sp=subprocess): """Parse gpgconf output to find out GPG agent UNIX socket path.""" args = [util.which('gpgconf'), '--list-dirs'] output = check_output(args=args, env=env, sp=sp) - lines = output.strip().split(b'\n') - dirs = dict(line.split(b':', 1) for line in lines) + # gpgconf percent-escapes its values (':' becomes '%3a', so a Windows path + # reads 'C%3a\...') and ends its lines with '\r\n' on Windows. + lines = output.strip().splitlines() + dirs = {name: unquote_to_bytes(value) + for name, value in (line.split(b':', 1) for line in lines)} log.debug('%s: %s', args, dirs) return dirs[b'agent-socket'] @@ -193,7 +197,10 @@ def get_gnupg_components(sp=subprocess): """Parse GnuPG components' paths.""" args = [util.which('gpgconf'), '--list-components'] output = check_output(args=args, sp=sp) - components = dict(re.findall('(.*):.*:(.*)', output.decode('utf-8'))) + # Same escaping as in get_agent_sock_path(): unescape the path and drop the + # '\r' of a Windows line ending, which '.*' would otherwise keep. + components = {name: unquote(path.rstrip('\r')) + for name, path in re.findall('(.*):.*:(.*)', output.decode('utf-8'))} log.debug('gpgconf --list-components: %s', components) return components @@ -225,7 +232,8 @@ def gpg_version(sp=subprocess): """Get a keygrip of the primary GPG key of the specified user.""" args = gpg_command(['--version']) output = check_output(args=args, sp=sp) - line = output.split(b'\n', maxsplit=1)[0] # b'gpg (GnuPG) 2.1.11' + # rstrip(): on Windows the line ends with '\r', which semver rejects. + line = output.split(b'\n', maxsplit=1)[0].rstrip() # b'gpg (GnuPG) 2.1.11' line = line.split(b' ')[-1] # b'2.1.11' line = line.split(b'-')[0] # remove trailing version parts return line.split(b'v')[-1] # remove 'v' prefix diff --git a/libagent/gpg/tests/test_agent.py b/libagent/gpg/tests/test_agent.py index 2f82522f..60c6d627 100644 --- a/libagent/gpg/tests/test_agent.py +++ b/libagent/gpg/tests/test_agent.py @@ -1,3 +1,7 @@ +import mock +import pytest + +from ... import util from .. import agent @@ -9,3 +13,62 @@ def test_sig_encode(): b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' b'\x00\x00\x00\x00")))') assert agent.sig_encode(12, 34) == SIG + + +# A real GnuPG 2.4 encryption to a cv25519 subkey (AES-256 session key, AES-128 key +# wrap, SHA-256 KDF); gpg --show-session-key gave 9:6A7D1A6B...F6AD287E. The X25519 +# shared secret was computed from the exported secret subkey. +KEM_E = bytes.fromhex('40b18a5df1abbad63d5fce3f0e71a9ba85657e066d94c711593530e4c11e2a5d04') +KEM_S = bytes.fromhex('30bd9e5a20b8d78ec028817ff1dc3bab57a83a144c0f142f219a09b75106c8c9' + '32d97491a348b566d45379870fc619ff2d') +KEM_KDF_PARAMS = bytes.fromhex('0a2b0601040197550105011203010807416e6f6e796d6f75732053656e646572' + '20202020f0e0d7e6ccb7330cb0a9714d3082cec72555966f') +KEM_SHARED = bytes.fromhex('1cfac96271fe26459c31ab98e865b5fde241ace9f858b0254c1f983a5750d018') +KEM_SESSION_KEY = bytes.fromhex('096a7d1a6bfb6429ef0dc70d1ad32721503373b0ea0550bdf49d6d9512f6ad287e' + '0e890505050505') + + +def _kem_params(override=None): + params = {b'c': b'7', b'h': b'8', b'e': KEM_E, b's': KEM_S, b'kdf-params': KEM_KDF_PARAMS} + params.update(override or {}) + return params + + +def test_kem_pgp_session_key_matches_gnupg(): + # The device returns the X25519 result with a 0x04 prefix byte. + assert agent.kem_pgp_session_key(_kem_params(), b'\x04' + KEM_SHARED) == KEM_SESSION_KEY + assert agent.kem_pgp_session_key(_kem_params(), KEM_SHARED) == KEM_SESSION_KEY + + +def test_kem_pgp_session_key_refuses_what_it_cannot_unwrap(): + with pytest.raises(agent.AgentError): + agent.kem_pgp_session_key(_kem_params(), b'\x04' + bytes(32)) # wrong shared secret + with pytest.raises(agent.AgentError): + agent.kem_pgp_session_key(_kem_params({b't': b'9580'}), KEM_SHARED) # RFC 9580 key + with pytest.raises(agent.AgentError): + agent.kem_pgp_session_key(_kem_params({b's': KEM_S[:-1]}), KEM_SHARED) # bad length + + +def test_pkdecrypt_kem_pgp_returns_the_session_key(): + """GnuPG 2.5 sends PKDECRYPT --kem=PGP with an (enc-val (ecc ...)) ciphertext.""" + sexp = (b'(7:enc-val(3:ecc(1:c1:7)(1:h1:8)(1:e33:' + KEM_E + b')(1:s49:' + KEM_S + b')' + b'(10:kdf-params' + str(len(KEM_KDF_PARAMS)).encode() + b':' + KEM_KDF_PARAMS + b')))') + lines = iter([b'D ' + util.assuan_serialize(sexp), b'END']) + sent = [] + handler = agent.Handler.__new__(agent.Handler) + handler.keygrip = b'00' * 20 + handler.get_identity = lambda keygrip: mock.Mock(curve_name='curve25519') + handler.client = mock.Mock() + handler.client.ecdh.return_value = b'\x04' + KEM_SHARED + with mock.patch.object(agent.keyring, 'recvline', lambda conn: next(lines)), \ + mock.patch.object(agent.keyring, 'sendline', lambda conn, msg: sent.append(msg)): + handler.pkdecrypt(None, (b'--kem=PGP',)) + handler.client.ecdh.assert_called_once_with(identity=mock.ANY, pubkey=KEM_E) + assert b'S PADDING 0' not in sent + assert sent[-1] == b'D ' + util.assuan_serialize(b'(5:value40:' + KEM_SESSION_KEY + b')') + + +def test_pkdecrypt_refuses_the_composite_kem(): + handler = agent.Handler.__new__(agent.Handler) + with pytest.raises(agent.AgentError): + handler.pkdecrypt(None, (b'--kem=PQC-PGP',)) diff --git a/libagent/gpg/tests/test_keyring.py b/libagent/gpg/tests/test_keyring.py index 6faa446f..5c0c4538 100644 --- a/libagent/gpg/tests/test_keyring.py +++ b/libagent/gpg/tests/test_keyring.py @@ -104,3 +104,42 @@ def test_get_agent_sock_path(): ''') expected = b'/run/user/1000/gnupg/S.gpg-agent' assert keyring.get_agent_sock_path(sp=sp) == expected + + +def test_get_agent_sock_path_windows(): + # Gpg4win's gpgconf: '%3a'-escaped drive letters, '\r\n' line endings. + sp = mock_subprocess(b'sysconfdir:C%3a\\ProgramData\\GNU\\etc\\gnupg\r\n' + b'bindir:C%3a\\Program Files\\GnuPG\\bin\r\n' + b'agent-socket:C%3a\\Users\\me\\AppData\\Local\\gnupg\\S.gpg-agent\r\n' + b'homedir:C%3a\\Users\\me\\AppData\\Roaming\\gnupg\r\n') + expected = b'C:\\Users\\me\\AppData\\Local\\gnupg\\S.gpg-agent' + assert keyring.get_agent_sock_path(sp=sp) == expected + + +def test_get_gnupg_components(): + sp = mock_subprocess(b'gpg:OpenPGP:/usr/bin/gpg\n' + b'gpg-agent:Private Keys:/usr/bin/gpg-agent\n') + components = keyring.get_gnupg_components(sp=sp) + assert components['gpg'] == '/usr/bin/gpg' + assert components['gpg-agent'] == '/usr/bin/gpg-agent' + + +def test_get_gnupg_components_windows(): + sp = mock_subprocess(b'gpg:OpenPGP:C%3a\\Program Files\\GnuPG\\bin\\gpg.exe\r\n' + b'gpg-agent:Private Keys:' + b'C%3a\\Program Files\\GnuPG\\bin\\gpg-agent.exe\r\n') + components = keyring.get_gnupg_components(sp=sp) + assert components['gpg'] == 'C:\\Program Files\\GnuPG\\bin\\gpg.exe' + assert components['gpg-agent'] == 'C:\\Program Files\\GnuPG\\bin\\gpg-agent.exe' + + +def test_gpg_version(): + with mock.patch.object(keyring, 'gpg_command', return_value=['gpg', '--version']): + sp = mock_subprocess(b'gpg (GnuPG) 2.4.9\nlibgcrypt 1.11.2\n') + assert keyring.gpg_version(sp=sp) == b'2.4.9' + + +def test_gpg_version_windows(): + with mock.patch.object(keyring, 'gpg_command', return_value=['gpg', '--version']): + sp = mock_subprocess(b'gpg (GnuPG) 2.5.24\r\nlibgcrypt 1.11.2\r\n') + assert keyring.gpg_version(sp=sp) == b'2.5.24' diff --git a/libagent/server.py b/libagent/server.py index 8e16c8dd..c97b1390 100644 --- a/libagent/server.py +++ b/libagent/server.py @@ -1,5 +1,6 @@ """UNIX-domain socket server for ssh-agent implementation.""" import contextlib +import hmac import logging import os import socket @@ -76,6 +77,239 @@ def unix_domain_socket_server_from_fd(fd): yield FDServer(fd) +ASSUAN_NONCE_LEN = 16 + + +class AssuanNonceServer: + """ + libassuan's socket emulation, as GnuPG for Windows uses it. + + The socket "file" holds a TCP port on 127.0.0.1, a newline, and a 16-byte + nonce. A client connects to the port and must send the nonce before + anything else; a connection that does not is dropped. The nonce is what + keeps other local users out, and the file's place in the user's own + profile is what keeps the nonce from them. + """ + + def __init__(self, sock, nonce): + """C-tor.""" + self.sock = sock + self.nonce = nonce + + def accept(self): + """Wait for a connection that starts with the nonce.""" + while True: + conn, addr = self.sock.accept() + conn.settimeout(10) # a silent client must not block the agent + received = b'' + try: + while len(received) < ASSUAN_NONCE_LEN: + chunk = conn.recv(ASSUAN_NONCE_LEN - len(received)) + if not chunk: + break + received += chunk + except OSError: + pass + if hmac.compare_digest(received, self.nonce): + return conn, addr + log.warning('dropping a connection without the socket nonce') + conn.close() + + def getsockname(self): + """Forward to underlying socket.""" + return self.sock.getsockname() + + +@contextlib.contextmanager +def assuan_nonce_socket_server(sock_path): + """ + Serve libassuan's Windows socket emulation at the specified path. + + Listen on a loopback port, write the port and a fresh nonce to sock_path, + and delete the file after the generated context is over. + """ + log.debug('serving on %s', sock_path) + remove_file(sock_path) + + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.bind(('127.0.0.1', 0)) + server.listen(1) + nonce = os.urandom(ASSUAN_NONCE_LEN) + with open(sock_path, 'wb') as f: + f.write('{}\n'.format(server.getsockname()[1]).encode('ascii') + nonce) + try: + yield AssuanNonceServer(server, nonce) + finally: + server.close() + remove_file(sock_path) + + +WINDOWS_PIPE_BUFSIZE = 65536 + + +class NamedPipeConnection: + """ + One client of a Windows named pipe, with the calls the agent makes on a socket. + + Overlapped I/O, because the pipe is opened overlapped (so accept() can time out). + """ + + def __init__(self, handle): + """C-tor.""" + self.handle = handle + + def recv(self, n): + """Read up to n bytes; b'' when the client has gone (the agent's EOF).""" + import _winapi # pylint: disable=import-outside-toplevel,import-error + try: + ov, err = _winapi.ReadFile(self.handle, n, overlapped=True) + except OSError as e: + if getattr(e, 'winerror', None) == _winapi.ERROR_BROKEN_PIPE: + return b'' + raise + try: + if err == _winapi.ERROR_IO_PENDING: + _winapi.WaitForMultipleObjects([ov.event], False, _winapi.INFINITE) + except BaseException: + ov.cancel() + raise + try: + nread, err = ov.GetOverlappedResult(True) + except OSError as e: + if getattr(e, 'winerror', None) == _winapi.ERROR_BROKEN_PIPE: + return b'' + raise + return bytes(ov.getbuffer()[:nread]) + + def sendall(self, data): + """Write all of data.""" + import _winapi # pylint: disable=import-outside-toplevel,import-error + ov, err = _winapi.WriteFile(self.handle, data, overlapped=True) + try: + if err == _winapi.ERROR_IO_PENDING: + _winapi.WaitForMultipleObjects([ov.event], False, _winapi.INFINITE) + except BaseException: + ov.cancel() + raise + nwritten, err = ov.GetOverlappedResult(True) + if nwritten != len(data): + raise OSError('short write to the agent pipe: {} of {}'.format(nwritten, len(data))) + + def settimeout(self, _): + """Not needed: the agent waits on a client for as long as it stays.""" + + def close(self): + """Close this client's pipe instance.""" + import _winapi # pylint: disable=import-outside-toplevel,import-error + if self.handle is not None: + _winapi.CloseHandle(self.handle) + self.handle = None + + +class NamedPipeServer: + """ + An ssh-agent listener on a Windows named pipe - what Windows' OpenSSH looks for. + + CPython on Windows has no socket.AF_UNIX, so the UNIX-domain server cannot + start there. Each accept() serves one pipe instance (byte mode) and makes the + next. Remote clients are rejected (PIPE_REJECT_REMOTE_CLIENTS), and the first + instance is created with FILE_FLAG_FIRST_PIPE_INSTANCE, so a name another + process already holds fails at start instead of letting it impersonate the + agent. The pipe has Windows' default security for a named pipe: only this + user, SYSTEM and Administrators may write to it. + """ + + PIPE_REJECT_REMOTE_CLIENTS = 0x00000008 + # Win32's byte-mode flags are 0, and _winapi does not name them; spelled out + # so the mode is visible: a byte stream, as ssh writes it, not messages. + PIPE_TYPE_BYTE = 0x00000000 + PIPE_READMODE_BYTE = 0x00000000 + + def __init__(self, name): + """C-tor.""" + self.name = name + self.timeout = None + self._pending = None # (handle, overlapped) waiting for a client + self._first = True + + def _listen(self): + import _winapi # pylint: disable=import-outside-toplevel,import-error + open_mode = _winapi.PIPE_ACCESS_DUPLEX | _winapi.FILE_FLAG_OVERLAPPED + if self._first: + open_mode |= _winapi.FILE_FLAG_FIRST_PIPE_INSTANCE + handle = _winapi.CreateNamedPipe( + self.name, open_mode, + self.PIPE_TYPE_BYTE | self.PIPE_READMODE_BYTE | _winapi.PIPE_WAIT + | self.PIPE_REJECT_REMOTE_CLIENTS, + _winapi.PIPE_UNLIMITED_INSTANCES, WINDOWS_PIPE_BUFSIZE, WINDOWS_PIPE_BUFSIZE, + _winapi.NMPWAIT_WAIT_FOREVER, _winapi.NULL) + self._first = False + try: + ov = _winapi.ConnectNamedPipe(handle, overlapped=True) + except OSError: + _winapi.CloseHandle(handle) + raise + self._pending = (handle, ov) + + def start(self): + """Create the first instance now, so a taken name fails before anything runs.""" + self._listen() + + def settimeout(self, timeout): + """How long accept() waits before raising socket.timeout (None: forever).""" + self.timeout = timeout + + def accept(self): + """Wait for a client on the pending instance; raise socket.timeout if none came.""" + import _winapi # pylint: disable=import-outside-toplevel,import-error + if self._pending is None: + self._listen() + handle, ov = self._pending + wait_ms = _winapi.INFINITE if self.timeout is None else int(self.timeout * 1000) + if _winapi.WaitForMultipleObjects([ov.event], False, wait_ms) == _winapi.WAIT_TIMEOUT: + raise socket.timeout() + self._pending = None + try: + ov.GetOverlappedResult(True) + except OSError as e: + # ERROR_NO_DATA: the client connected and left already - nothing to serve + _winapi.CloseHandle(handle) + if getattr(e, 'winerror', None) == _winapi.ERROR_NO_DATA: + raise socket.timeout() from e + raise + return NamedPipeConnection(handle), None + + def getsockname(self): + """The pipe's name.""" + return self.name + + def close(self): + """Stop listening.""" + import _winapi # pylint: disable=import-outside-toplevel,import-error + if self._pending is not None: + handle, ov = self._pending + self._pending = None + ov.cancel() + _winapi.CloseHandle(handle) + + +@contextlib.contextmanager +def named_pipe_server(pipe_name): + r""" + Serve the ssh-agent on a Windows named pipe (e.g. \\.\pipe\onlykey-agent-...). + + The Windows counterpart of unix_domain_socket_server(); the pipe goes away + when the generated context is over. + """ + log.debug('serving on %s', pipe_name) + pipe_server = NamedPipeServer(pipe_name) + pipe_server.start() + try: + yield pipe_server + finally: + pipe_server.close() + + def handle_connection(conn, handler, mutex): """ Handle a single connection using the specified protocol handler in a loop. diff --git a/libagent/ssh/__init__.py b/libagent/ssh/__init__.py index 2699b06e..d6a1dbf3 100644 --- a/libagent/ssh/__init__.py +++ b/libagent/ssh/__init__.py @@ -1,4 +1,5 @@ """SSH-agent implementation using hardware authentication devices.""" +import atexit import contextlib import functools import io @@ -10,9 +11,9 @@ import sys import tempfile import threading +import time import configargparse -import daemon from .. import device, formats, server, util from . import client, protocol @@ -21,6 +22,43 @@ UNIX_SOCKET_TIMEOUT = 0.1 +# CPython on Windows has no socket.AF_UNIX: the agent listens on a named pipe there, +# which is also where Windows' own OpenSSH looks for an agent (SSH_AUTH_SOCK = the pipe). +WINDOWS = sys.platform == 'win32' + + +def _agent_listener(sock_path): + """The agent's listening end: a named pipe on Windows, a UNIX-domain socket elsewhere.""" + if WINDOWS: + return server.named_pipe_server(sock_path) + return server.unix_domain_socket_server(sock_path) + + +def _ssh_program(): + """ + The ssh to run for --connect. + + On Windows, Windows' own OpenSSH: it reads a named-pipe SSH_AUTH_SOCK. An ssh + built for MSYS/Cygwin (Git Bash's /usr/bin/ssh is one) expects a UNIX-socket + path there instead, so finding 'ssh' first on PATH would pick one that cannot + reach this agent. + """ + if WINDOWS: + system_ssh = os.path.join(os.environ.get('SystemRoot', r'C:\Windows'), + 'System32', 'OpenSSH', 'ssh.exe') + if os.path.exists(system_ssh): + return system_ssh + return 'ssh' + + +def _wait_for_interrupt(): + """Serve until Ctrl+C: signal.pause() where it exists (not on Windows).""" + if hasattr(signal, 'pause'): + signal.pause() + else: + while True: + time.sleep(3600) + def ssh_args(conn): """Create SSH command for connecting specified server.""" @@ -82,7 +120,7 @@ def create_agent_parser(device_type): help='specify ECDSA/EDDSA curve name: ' + curve_names) p.add_argument('-sk', '--skey', type=str, metavar='SIGN_KEY', default='ECC32', - help='specify key to use for SSH signtature') + help='key for SSH signatures: ECC1-ECC16, ECC32 (derived, default) or derived-v2 (HKDF)') else: p.add_argument('-e', '--ecdsa-curve-name', metavar='CURVE', @@ -135,12 +173,12 @@ def serve(handler, sock_path, timeout=UNIX_SOCKET_TIMEOUT): If no connection is made during the specified timeout, retry until the context is over. """ - ssh_version = subprocess.check_output(['ssh', '-V'], + ssh_version = subprocess.check_output([_ssh_program(), '-V'], stderr=subprocess.STDOUT) log.debug('local SSH version: %r', ssh_version) environ = {'SSH_AUTH_SOCK': sock_path, 'SSH_AGENT_PID': str(os.getpid())} device_mutex = threading.Lock() - with server.unix_domain_socket_server(sock_path) as sock: + with _agent_listener(sock_path) as sock: sock.settimeout(timeout) quit_event = threading.Event() handle_conn = functools.partial(server.handle_connection, @@ -167,7 +205,7 @@ def run_server(conn, command, sock_path, debug, timeout): if command: ret = server.run_process(command=command, environ=env) else: - signal.pause() # wait for signal (e.g. SIGINT) + _wait_for_interrupt() # wait for a signal (e.g. SIGINT) except KeyboardInterrupt: log.info('server stopped') return ret @@ -182,6 +220,9 @@ def wrapper(*args, **kwargs): except device.interface.NotFoundError as e: log.error('Connection error (try unplugging and replugging your device): %s', e) return 1 + except device.interface.DeviceError as e: + log.error('%s', e) + return 1 return wrapper @@ -229,9 +270,19 @@ 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() + if WINDOWS: + # An open NamedTemporaryFile is delete-on-close on Windows, and no + # other process may open it - ssh could not read its IdentityFile. + # So the file is written and closed, and removed at exit. + f = tempfile.NamedTemporaryFile(prefix='trezor-ssh-pubkey-', mode='w', + delete=False) + f.write(pk) + f.close() + atexit.register(server.remove_file, f.name) + else: + f = tempfile.NamedTemporaryFile(prefix='trezor-ssh-pubkey-', mode='w') + f.write(pk) + f.flush() self.public_keys_tempfiles.append(f) return self.public_keys_tempfiles @@ -253,13 +304,15 @@ def _get_sock_path(args): if args.foreground: log.error('running in foreground mode requires specifying UNIX socket path') sys.exit(1) + elif WINDOWS: + sock_path = r'\\.\pipe\onlykey-agent-' + os.urandom(8).hex() else: sock_path = tempfile.mktemp(prefix='trezor-ssh-agent-') return sock_path @handle_connection_error -def main(device_type): +def main(device_type): # pylint: disable=too-many-locals """Run ssh-agent using given hardware client factory.""" args = create_agent_parser(device_type=device_type).parse_args() util.setup_logging(verbosity=args.verbose, filename=args.log_file) @@ -294,13 +347,17 @@ def main(device_type): command = args.command context = _dummy_context() if args.connect: - command = ['ssh'] + ssh_args(conn) + args.command + command = [_ssh_program()] + ssh_args(conn) + args.command elif args.mosh: command = ['mosh'] + mosh_args(conn) + args.command elif args.daemonize: out = 'SSH_AUTH_SOCK={0}; export SSH_AUTH_SOCK;\n'.format(sock_path) sys.stdout.write(out) sys.stdout.flush() + # Imported here, not at the top: python-daemon needs the Unix-only + # `pwd` module, and a top-level import stopped onlykey-agent from + # starting on Windows at all. Only --daemonize uses it. + import daemon # pylint: disable=import-outside-toplevel context = daemon.DaemonContext() log.info('running the agent as a daemon on %s', sock_path) elif args.foreground: @@ -313,6 +370,7 @@ def main(device_type): if command or args.daemonize or args.foreground: with context: + device_type.prepare() return run_server(conn=conn, command=command, sock_path=sock_path, debug=args.debug, timeout=args.timeout) else: diff --git a/libagent/tests/test_onlykey_slots.py b/libagent/tests/test_onlykey_slots.py new file mode 100644 index 00000000..98d89367 --- /dev/null +++ b/libagent/tests/test_onlykey_slots.py @@ -0,0 +1,25 @@ +import sys + +from ..device import onlykey + + +def _slots(tmpdir, monkeypatch, platform, launcher, text): + """The slots get_sk_dk() reads back from the launcher `onlykey-gpg init` wrote.""" + tmpdir.join(launcher).write(text) + monkeypatch.setattr(sys, 'platform', platform) + monkeypatch.setenv('AGENTHOMEDIR', str(tmpdir)) + dev = onlykey.OnlyKey.__new__(onlykey.OnlyKey) # no device needed + dev.get_sk_dk() + return dev.skeyslot, dev.dkeyslot + + +def test_slots_from_run_agent_sh(tmpdir, monkeypatch): + text = '#!/bin/sh\nexport PATH="/usr/bin"\nonlykey-gpg-agent \\\n-vv \\\n' \ + '--skey-slot=ECC1 \\\n--dkey-slot=ECC2 \\\n$*\n' + assert _slots(tmpdir, monkeypatch, 'linux', 'run-agent.sh', text) == (101, 102) + + +def test_slots_from_run_agent_cmd(tmpdir, monkeypatch): + text = ('@echo off\nset "PATH=C:\\Windows"\n"C:\\py\\pythonw.exe" ' + '"C:\\py\\Scripts\\onlykey-gpg-agent.exe" -vv --skey-slot=ECC1 --dkey-slot=ECC2 %*\n') + assert _slots(tmpdir, monkeypatch, 'win32', 'run-agent.cmd', text) == (101, 102) diff --git a/libagent/tests/test_server.py b/libagent/tests/test_server.py index 947160a8..87ad55c9 100644 --- a/libagent/tests/test_server.py +++ b/libagent/tests/test_server.py @@ -1,6 +1,7 @@ import io import os import socket +import sys import tempfile import threading @@ -133,3 +134,86 @@ def remove_raise(_): with pytest.raises(OSError): server.remove_file(path, remove=remove_raise, exists=lambda _: True) + + +def _assuan_connect(path, nonce=None): + """Connect as libassuan does on Windows: read the port, send the nonce.""" + with open(path, 'rb') as f: + port, file_nonce = f.read().split(b'\n', 1) + conn = socket.create_connection(('127.0.0.1', int(port))) + conn.sendall(file_nonce if nonce is None else nonce) + return conn + + +def test_assuan_nonce_socket_server(): + path = tempfile.mktemp() + with server.assuan_nonce_socket_server(path) as sock: + with open(path, 'rb') as f: + port, nonce = f.read().split(b'\n', 1) + assert int(port) == sock.getsockname()[1] + assert len(nonce) == server.ASSUAN_NONCE_LEN + + client = _assuan_connect(path) + conn, _ = sock.accept() + client.sendall(b'GETINFO version\n') + assert conn.recv(100) == b'GETINFO version\n' + conn.close() + client.close() + assert not os.path.exists(path) + + +def test_assuan_nonce_socket_server_drops_a_wrong_nonce(): + path = tempfile.mktemp() + with server.assuan_nonce_socket_server(path) as sock: + accepted = [] + thread = threading.Thread(target=lambda: accepted.append(sock.accept())) + thread.start() + intruder = _assuan_connect(path, nonce=b'\x00' * server.ASSUAN_NONCE_LEN) + assert intruder.recv(1) == b'' # dropped, never handed to the agent + intruder.close() + client = _assuan_connect(path) # the next, correct client is served + thread.join(timeout=10) + assert len(accepted) == 1 + accepted[0][0].close() + client.close() + + +windows_only = pytest.mark.skipif(not sys.platform.startswith('win'), + reason='named pipes are the Windows agent transport') + + +def _pipe_name(): + return r'\\.\pipe\libagent-test-' + os.urandom(6).hex() + + +@windows_only +def test_named_pipe_server_serves_a_client(): + name = _pipe_name() + with server.named_pipe_server(name) as sock: + assert sock.getsockname() == name + client = open(name, 'r+b', buffering=0) # what ssh does: CreateFile on SSH_AUTH_SOCK + conn, _ = sock.accept() + client.write(b'\x00\x00\x00\x01\x0b') # one ssh-agent frame + assert util.read_frame(conn) == b'\x0b' + util.send(conn, b'\x00\x00\x00\x01\x0c') + assert client.read(5) == b'\x00\x00\x00\x01\x0c' + client.close() + assert conn.recv(4) == b'' # the client left: the agent's EOF + conn.close() + + +@windows_only +def test_named_pipe_server_times_out_without_a_client(): + with server.named_pipe_server(_pipe_name()) as sock: + sock.settimeout(0.1) + with pytest.raises(socket.timeout): + sock.accept() + + +@windows_only +def test_named_pipe_server_refuses_a_name_already_taken(): + name = _pipe_name() + with server.named_pipe_server(name): + with pytest.raises(OSError): + with server.named_pipe_server(name): + pass