From c0357c08eb7ac32793aaa3fa9d855d81bead64b8 Mon Sep 17 00:00:00 2001 From: SlugFiller <5435495+SlugFiller@users.noreply.github.com> Date: Tue, 12 Sep 2023 16:26:12 +0300 Subject: [PATCH] GPG advanced key management --- doc/README-GPG.md | 40 ++++- doc/README-Windows.md | 11 +- libagent/gpg/__init__.py | 267 ++++++++++++++++++++--------- libagent/gpg/agent.py | 81 ++++----- libagent/gpg/decode.py | 77 +++++++-- libagent/gpg/encode.py | 30 ++-- libagent/gpg/keyring.py | 16 +- libagent/gpg/protocol.py | 4 - libagent/gpg/tests/test_decode.py | 13 -- libagent/gpg/tests/test_keyring.py | 3 + libagent/server.py | 72 ++++---- libagent/ssh/__init__.py | 43 ++--- libagent/tests/test_server.py | 16 +- libagent/util.py | 80 +++++++++ libagent/win_server.py | 100 +++-------- 15 files changed, 529 insertions(+), 324 deletions(-) diff --git a/doc/README-GPG.md b/doc/README-GPG.md index 3542ec48..6d27be80 100644 --- a/doc/README-GPG.md +++ b/doc/README-GPG.md @@ -18,7 +18,8 @@ Thanks! Run ``` - $ (trezor|keepkey|ledger|jade|onlykey)-gpg init "Roman Zeyde " + $ (trezor|keepkey|ledger|jade|onlykey)-gpg init + $ (trezor|keepkey|ledger|jade|onlykey)-gpg add -d "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. @@ -137,13 +138,14 @@ $ gpg2 --export 'john@doe.bit' | gpg2 --list-packets | grep created | head -n1 After your main identity is created, you can add new user IDs using the regular GnuPG commands: ``` -$ trezor-gpg init "Foobar" -vv +$ trezor-gpg init +$ trezor-gpg add -d "Foobar" -vv $ export GNUPGHOME=${HOME}/.gnupg/trezor $ gpg2 -K ------------------------------------------ -sec nistp256/6275E7DA 2017-12-05 [SC] +sec nistp256/6275E7DA 1970-01-01 [SC] uid [ultimate] Foobar -ssb nistp256/35F58F26 2017-12-05 [E] +ssb nistp256/35F58F26 1970-01-01 [E] $ gpg2 --edit Foobar gpg> adduid @@ -159,10 +161,24 @@ gpg> save $ gpg2 -K ------------------------------------------ -sec nistp256/6275E7DA 2017-12-05 [SC] +sec nistp256/6275E7DA 1970-01-01 [SC] uid [ultimate] Xyzzy uid [ultimate] Foobar -ssb nistp256/35F58F26 2017-12-05 [E] +ssb nistp256/35F58F26 1970-01-01 [E] +``` + +This adds new user IDs to the same key. You can also add a new key using the `add` command: +``` +$ trezor-gpg add "Xyzzy" -vv +$ gpg2 -K +------------------------------------------ +sec nistp256/6275E7DA 1970-01-01 [SC] +uid [ultimate] Foobar +ssb nistp256/35F58F26 1970-01-01 [E] + +sec nistp256/BE61C208 1970-01-01 [SC] +uid [ultimate] Xyzzy +ssb nistp256/65088366 1970-01-01 [E] ``` ### Generate GnuPG subkeys @@ -173,7 +189,17 @@ pub rsa2048/90C4064B 2017-10-10 [SC] uid [ultimate] foobar sub rsa2048/4DD05FF0 2017-10-10 [E] -$ trezor-gpg init "foobar" --subkey +$ trezor-gpg add "foobar" --subkey +``` + +If you have already set the new folder as your default profile, and you want to add the subkey to an existing GnuPG from a previous (e.g. non-hardware) profile, you can specify the previous profile location using `--primary-homedir`: +``` +$ gpg2 -k foobar --homedir ~/.gnupg +pub rsa2048/90C4064B 2017-10-10 [SC] +uid [ultimate] foobar +sub rsa2048/4DD05FF0 2017-10-10 [E] + +$ trezor-gpg add "foobar" --subkey --primary-homedir ~/.gnupg ``` [![asciicast](https://asciinema.org/a/Ick5G724zrZRFsGY7ZUdFSnV1.png)](https://asciinema.org/a/Ick5G724zrZRFsGY7ZUdFSnV1) diff --git a/doc/README-Windows.md b/doc/README-Windows.md index 28d08414..8c6ada63 100644 --- a/doc/README-Windows.md +++ b/doc/README-Windows.md @@ -56,11 +56,19 @@ git clone https://github.com/romanz/trezor-agent.git Build and install the library: ``` +pip install ./trezor-agent +``` +If you want to be able to edit it without having to rebuild, use this command instead: +``` pip install -e trezor-agent ``` Build and install the agent of your choice: ``` +pip install ./trezor-agent/agents/ +``` +If you want to be able to edit it without having to rebuild, use this command instead: +``` pip install -e trezor-agent/agents/ ``` @@ -166,7 +174,8 @@ choco install gpg4win You must first create a signing identity: ``` --gpg init -e ed25519 "My Full Name " +-gpg init +-gpg add -d -e ed25519 "My Full Name " ``` You will be asked for confirmation on your device **twice**. diff --git a/libagent/gpg/__init__.py b/libagent/gpg/__init__.py index 6bad4f65..f85e5346 100644 --- a/libagent/gpg/__init__.py +++ b/libagent/gpg/__init__.py @@ -17,9 +17,10 @@ import stat import subprocess import sys +import threading try: - # TODO: Not supported on Windows. Use daemoniker instead? + # Not supported on Windows. Should be manually installed as a service instead. import daemon except ImportError: daemon = None @@ -27,7 +28,7 @@ import semver from .. import device, formats, server, util -from . import agent, client, encode, keyring, protocol +from . import agent, client, decode, encode, keyring, protocol log = logging.getLogger(__name__) @@ -39,52 +40,71 @@ def export_public_key(device_type, args): 'the timestamp of the GPG key manually).', args.time) c = client.Client(device=device_type()) identity = client.create_identity(user_id=args.user_id, - curve_name=args.ecdsa_curve) - verifying_key = c.pubkey(identity=identity, ecdh=False) - decryption_key = c.pubkey(identity=identity, ecdh=True) + curve_name=args.ecdsa_curve_name) signer_func = functools.partial(c.sign, identity=identity) fingerprints = [] + def gen_identity_and_key(user_id, curve_name, ecdh): + verify_identity = client.create_identity(user_id=user_id, curve_name=curve_name) + verifying_key = c.pubkey(identity=verify_identity, ecdh=ecdh) + return (verify_identity, verifying_key) + + result = None if args.subkey: # add as subkey - log.info('adding %s GPG subkey for "%s" to existing key', - args.ecdsa_curve, args.user_id) - # subkey for signing - signing_key = protocol.PublicKey( - curve_name=args.ecdsa_curve, created=args.time, - verifying_key=verifying_key, ecdh=False) - fingerprints.append(util.hexlify(signing_key.fingerprint())) - # subkey for encryption - encryption_key = protocol.PublicKey( - curve_name=formats.get_ecdh_curve_name(args.ecdsa_curve), - created=args.time, verifying_key=decryption_key, ecdh=True) - fingerprints.append(util.hexlify(encryption_key.fingerprint())) - primary_bytes = keyring.export_public_key(args.user_id) - result = encode.create_subkey(primary_bytes=primary_bytes, - subkey=signing_key, - signer_func=signer_func) - result = encode.create_subkey(primary_bytes=result, - subkey=encryption_key, - signer_func=signer_func) - else: # add as primary - log.info('creating new %s GPG primary key for "%s"', - args.ecdsa_curve, args.user_id) - # primary key for signing - primary = protocol.PublicKey( - curve_name=args.ecdsa_curve, created=args.time, - verifying_key=verifying_key, ecdh=False) - fingerprints.append(util.hexlify(primary.fingerprint())) - # subkey for encryption - subkey = protocol.PublicKey( - curve_name=formats.get_ecdh_curve_name(args.ecdsa_curve), - created=args.time, verifying_key=decryption_key, ecdh=True) - fingerprints.append(util.hexlify(subkey.fingerprint())) - - result = encode.create_primary(user_id=args.user_id, - pubkey=primary, - signer_func=signer_func) + if args.primary_homedir is None: + result = keyring.export_public_key(args.user_id) + else: + result = keyring.export_public_key(args.user_id, + env={'GNUPGHOME': args.primary_homedir}) + identity = decode.identity_for_key(result, gen_identity_and_key=gen_identity_and_key) + if identity is None: + if args.primary_homedir is None: + signer_func = keyring.create_agent_signer(next(decode.iter_keygrips(result)), + env=os.environ) + else: + signer_func = keyring.create_agent_signer(next(decode.iter_keygrips(result)), + env={'GNUPGHOME': args.primary_homedir}) + + if result is None or not args.no_sign: # Signing or certification key + if result is not None and args.subkey_path: + identity = client.create_identity(user_id=args.subkey_path, + curve_name=args.ecdsa_curve_name) + pubkey = protocol.PublicKey( + curve_name=args.ecdsa_curve_name, created=args.time, + verifying_key=c.pubkey(identity=identity, ecdh=False), ecdh=False) + fingerprints.append(util.hexlify(pubkey.fingerprint())) + if result is None: + result = encode.create_primary(user_id=args.user_id, + pubkey=pubkey, + signer_func=signer_func, + flags=1 if args.no_sign else 3) + else: + result = encode.create_subkey(primary_bytes=result, + subkey=pubkey, + subkey_path=args.subkey_path, + signer_func=signer_func, + flags=2) + + if args.encrypt != 'none': # Encryption key + if args.encrypt == 'communications': + flags = 4 + elif args.encrypt == 'storage': + flags = 8 + else: + flags = 12 + if args.subkey_path: + identity = client.create_identity(user_id=args.subkey_path, + curve_name=args.ecdsa_curve_name) + pubkey = protocol.PublicKey( + curve_name=args.ecdsa_curve_name, created=args.time, + verifying_key=c.pubkey(identity=identity, ecdh=True), ecdh=True) + fingerprints.append(util.hexlify(pubkey.fingerprint())) + assert result is not None result = encode.create_subkey(primary_bytes=result, - subkey=subkey, - signer_func=signer_func) + subkey=pubkey, + subkey_path=args.subkey_path, + signer_func=signer_func, + flags=flags) return (fingerprints, protocol.armor(result, 'PUBLIC KEY BLOCK')) @@ -144,9 +164,6 @@ def run_init(device_type, args): 'remove it manually if required', homedir) sys.exit(1) - # Prepare the key before making any changes - fingerprints, public_key_bytes = export_public_key(device_type, args) - os.makedirs(homedir, mode=0o700) agent_path = util.which('{}-gpg-agent'.format(device_name)) @@ -177,11 +194,12 @@ def run_init(device_type, args): # Prepare GPG configuration file with open(os.path.join(homedir, 'gpg.conf'), 'w') as f: + # Do not bother escaping or quoting config parameters. + # _gpgrt_argparse simply reads until EOL. f.write("""# Hardware-based GPG configuration -agent-program "{0}" +agent-program {0} personal-digest-preferences SHA512 -default-key {1} -""".format(util.escape_cmd_quotes(run_agent_script), fingerprints[0])) +""".format(run_agent_script)) # Prepare a helper script for setting up the new identity with open(os.path.join(homedir, 'env'), 'w') as f: @@ -198,6 +216,37 @@ def run_init(device_type, args): """.format(homedir)) os.chmod(f.name, 0o700) + +def run_add(device_type, args): + """Initialize hardware-based GnuPG identity.""" + util.setup_logging(verbosity=args.verbose) + log.warning('This GPG tool is still in EXPERIMENTAL mode, ' + 'so please note that the API and features may ' + 'change without backwards compatibility!') + + verify_gpg_version() + + # Add a new hardware-based identity to the GPG home directory + device_name = device_type.package_name().rsplit('-', 1)[0] + log.info('device name: %s', device_name) + homedir = args.homedir + if not homedir: + homedir = os.path.expanduser('~/.gnupg/{}'.format(device_name)) + + log.info('GPG home directory: %s', homedir) + + if not os.path.exists(homedir): + log.error('GPG home directory %s is missing, ' + 'use %s-gpg init first', homedir, device_name) + sys.exit(1) + + # Prepare the keys + fingerprints, public_key_bytes = export_public_key(device_type, args) + + if not fingerprints: + log.warning('No keys created') + sys.exit(1) + # Generate new GPG identity and import into GPG keyring verbosity = ('-' + ('v' * args.verbose)) if args.verbose else '--quiet' check_call(keyring.gpg_command(['--homedir', homedir, verbosity, @@ -209,9 +258,11 @@ def run_init(device_type, args): '--import-ownertrust']), input_bytes=(fingerprints[0] + ':6\n').encode()) - # Load agent and make sure it responds with the new identity - check_call(keyring.gpg_command(['--homedir', homedir, - '--list-secret-keys', args.user_id])) + if args.default: + # Make new key the default key + check_call([util.which('gpgconf'), '--homedir', homedir, + '--change-options', 'gpg'], + input_bytes=('default-key:0:"' + fingerprints[0]).encode()) def run_unlock(device_type, args): @@ -239,18 +290,16 @@ def run_agent(device_type): p = argparse.ArgumentParser() p.add_argument('--homedir', default=os.environ.get('GNUPGHOME')) p.add_argument('-v', '--verbose', default=0, action='count') - p.add_argument('--server', default=False, action='store_true', - help='Use stdin/stdout for communication with GPG.') if daemon: p.add_argument('--daemon', default=False, action='store_true', - help='Daemonize the agent.') + help='daemonize the agent') p.add_argument('--pin-entry-binary', type=str, default='pinentry', - help='Path to PIN entry UI helper.') + help='path to PIN entry UI helper') p.add_argument('--passphrase-entry-binary', type=str, default='pinentry', - help='Path to passphrase entry UI helper.') + help='path to passphrase entry UI helper') p.add_argument('--cache-expiry-seconds', type=float, default=float('inf'), - help='Expire passphrase from cache after this duration.') + help='expire passphrase from cache after this duration') args, _ = p.parse_known_args() @@ -261,6 +310,22 @@ def run_agent(device_type): run_agent_internal(args, device_type) +def handle_connection(conn, handler, close): + """Handle a single connection to the agent.""" + with contextlib.closing(conn): + try: + handler.handle(conn) + except agent.AgentStop: + log.info('stopping gpg-agent') + close.call() + return + except IOError as e: + log.info('connection closed: %s', e) + return + except Exception as e: # pylint: disable=broad-except + log.exception('handler failed: %s', e) + + def run_agent_internal(args, device_type): """Actually run the server.""" assert args.homedir @@ -273,29 +338,41 @@ def run_agent_internal(args, device_type): log.debug('pid: %d, parent pid: %d', os.getpid(), os.getppid()) try: env = {'GNUPGHOME': args.homedir, 'PATH': os.environ['PATH']} - pubkey_bytes = keyring.export_public_keys(env=env) device_type.ui = device.ui.UI(device_type=device_type, config=vars(args)) + device_mutex = threading.Lock() handler = agent.Handler(device=device_type(), - pubkey_bytes=pubkey_bytes) + device_mutex=device_mutex, + env=env) sock_server = _server_from_assuan_fd(os.environ) if sock_server is None: sock_server = _server_from_sock_path(env) with sock_server as sock: - for conn in agent.yield_connections(sock): - with contextlib.closing(conn): - try: - handler.handle(conn) - except agent.AgentStop: - log.info('stopping gpg-agent') - return - except IOError as e: - log.info('connection closed: %s', e) - return - except Exception as e: # pylint: disable=broad-except - log.exception('handler failed: %s', e) + close = util.DeferredMethod() + closed_event = threading.Event() + + def closed(): + closed_event.set() + + handle_conn = functools.partial(handle_connection, + handler=handler, + close=close) + kwargs = {'sock': sock, + 'handle_conn': handle_conn, + 'close': close, + 'closed': closed} + with server.spawn(server.server_thread, kwargs): + try: + with util.catchbreak(closed): + closed_event.wait() + finally: + log.debug('closing server') + close.call() + + except KeyboardInterrupt: + log.info('server stopped by SIGINT') except Exception as e: # pylint: disable=broad-except log.exception('gpg-agent failed: %s', e) @@ -318,25 +395,53 @@ def main(device_type): subparsers.required = True p = subparsers.add_parser('init', - help='initialize hardware-based GnuPG identity') - p.add_argument('user_id') - p.add_argument('-e', '--ecdsa-curve', default='nist256p1') - p.add_argument('-t', '--time', type=int, default=0) + help='initialize a hardware-based GnuPG home directory') p.add_argument('-v', '--verbose', default=0, action='count') - p.add_argument('-s', '--subkey', default=False, action='store_true') p.add_argument('--homedir', type=str, default=os.environ.get('GNUPGHOME'), - help='Customize GnuPG home directory for the new identity.') + help='GnuPG home directory to create') p.add_argument('--pin-entry-binary', type=str, default=argparse.SUPPRESS, - help='Path to PIN entry UI helper.') + help='path to PIN entry UI helper') p.add_argument('--passphrase-entry-binary', type=str, default=argparse.SUPPRESS, - help='Path to passphrase entry UI helper.') + help='path to passphrase entry UI helper') p.add_argument('--cache-expiry-seconds', type=float, default=argparse.SUPPRESS, - help='Expire passphrase from cache after this duration.') + help='expire passphrase from cache after this duration') p.set_defaults(func=run_init) + p = subparsers.add_parser('add', + help='add a hardware-based GnuPG identity or subkey to the profile') + p.add_argument('user_id') + p.add_argument('-e', '--ecdsa-curve-name', default='nist256p1', + choices=sorted(formats.SUPPORTED_CURVES), + help='specify curve name') + p.add_argument('-t', '--time', type=int, default=0, + help='set key creation time. This will modify the key\'s fingerprint, ' + 'but not the associated private key') + p.add_argument('-v', '--verbose', default=0, action='count') + p.add_argument('-d', '--default', default=False, action='store_true', + help='sets the newly created key as the default key for the profile') + p.add_argument('-s', '--subkey', default=False, action='store_true', + help='create a subkey instead of a primary key') + p.add_argument('--subkey-path', default=None, + help='custom derivation path for the subkey. If not specified, ' + 'the user id is used') + p.add_argument('--primary-homedir', default=None, + help='home directory in which the primary is stored, if creating a subkey. ' + 'Useful for keeping subkey and primary in separate profiles') + p.add_argument('--no-sign', default=False, action='store_true', + help='do not create a signing key. ' + 'If creating a primary key, it will be set to certify-only') + p.add_argument('--encrypt', default='any', choices=['none', 'any', 'communications', 'storage'], + help='select allowed encryption usage for the key. ' + 'If set to none, an encryption key will not be created') + + p.add_argument('--homedir', type=str, default=os.environ.get('GNUPGHOME'), + help='customize GnuPG home directory for the new identity') + + p.set_defaults(func=run_add) + p = subparsers.add_parser('unlock', help='unlock the hardware device') p.add_argument('-v', '--verbose', default=0, action='count') p.set_defaults(func=run_unlock) diff --git a/libagent/gpg/agent.py b/libagent/gpg/agent.py index 15c93643..05754bb8 100644 --- a/libagent/gpg/agent.py +++ b/libagent/gpg/agent.py @@ -8,19 +8,6 @@ log = logging.getLogger(__name__) -def yield_connections(sock): - """Run a server on the specified socket.""" - while True: - log.debug('waiting for connection on %s', sock.getsockname()) - try: - conn, _ = sock.accept() - except KeyboardInterrupt: - return - conn.settimeout(None) - log.debug('accepted connection on %s', sock.getsockname()) - yield conn - - def sig_encode(r, s): """Serialize ECDSA signature data into GPG S-expression.""" r = util.assuan_serialize(util.num2bytes(r, 32)) @@ -76,14 +63,15 @@ class Handler: def _get_options(self): return self.options - def __init__(self, device, pubkey_bytes): + def __init__(self, device, device_mutex, env): """C-tor.""" self.reset() self.options = [] + self.device_mutex = device_mutex device.ui.options_getter = self._get_options - self.client = client.Client(device=device) - # Cache public keys from GnuPG - self.pubkey_bytes = pubkey_bytes + with self.device_mutex: + self.client = client.Client(device=device) + self.env = env # "Clone" existing GPG version self.version = keyring.gpg_version() @@ -118,8 +106,9 @@ def handle_option(self, opt): def handle_get_passphrase(self, conn, _): """Allow simple GPG symmetric encryption (using a passphrase).""" - p1 = self.client.device.ui.get_passphrase('Symmetric encryption:') - p2 = self.client.device.ui.get_passphrase('Re-enter encryption:') + with self.device_mutex: + p1 = self.client.device.ui.get_passphrase('Symmetric encryption:') + p2 = self.client.device.ui.get_passphrase('Re-enter encryption:') if p1 == p2: result = b'D ' + util.assuan_serialize(p1.encode('ascii')) keyring.sendline(conn, result, confidential=True) @@ -151,23 +140,30 @@ def handle_scd(self, conn, args): raise AgentError(b'ERR 100696144 No such device ') keyring.sendline(conn, b'D ' + reply) - @util.memoize_method # global cache for key grips - def get_identity(self, keygrip): + def get_identity(self, keygrip, pubkey_bytes): """ Returns device.interface.Identity that matches specified keygrip. In case of missing keygrip, KeyError will be raised. """ - 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') - 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) + + def gen_identity_and_key(user_id, curve_name, ecdh): + verify_identity = client.create_identity(user_id=user_id, curve_name=curve_name) + verifying_key = self.client.pubkey(identity=verify_identity, ecdh=ecdh) + return (verify_identity, verifying_key) + + with self.device_mutex: + keygrip_bytes = binascii.unhexlify(keygrip) + pubkey_dict, user_ids = decode.load_by_keygrip( + pubkey_bytes=pubkey_bytes, keygrip=keygrip_bytes, + gen_identity_and_key=gen_identity_and_key) + # We assume the first user ID is used to generate TREZOR-based GPG keys. + user_id = user_ids[0]['value'].decode('utf-8') + 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) @@ -178,9 +174,11 @@ def get_identity(self, keygrip): def pksign(self, conn): """Sign a message digest using a private EC key.""" log.debug('signing %r digest (algo #%s)', self.digest, self.algo) - identity = self.get_identity(keygrip=self.keygrip) - r, s = self.client.sign(identity=identity, - digest=binascii.unhexlify(self.digest)) + identity = self.get_identity(keygrip=self.keygrip, + pubkey_bytes=keyring.export_public_keys(env=self.env)) + with self.device_mutex: + r, s = self.client.sign(identity=identity, + digest=binascii.unhexlify(self.digest)) result = sig_encode(r, s) log.debug('result: %r', result) keyring.sendline(conn, b'D ' + result) @@ -194,23 +192,26 @@ def pkdecrypt(self, conn): assert keyring.recvline(conn) == b'END' remote_pubkey = parse_ecdh(line) - identity = self.get_identity(keygrip=self.keygrip) - ec_point = self.client.ecdh(identity=identity, pubkey=remote_pubkey) + identity = self.get_identity(keygrip=self.keygrip, + pubkey_bytes=keyring.export_public_keys(env=self.env)) + with self.device_mutex: + ec_point = self.client.ecdh(identity=identity, pubkey=remote_pubkey) keyring.sendline(conn, b'D ' + _serialize_point(ec_point)) def have_key(self, conn, *keygrips): """Check if any keygrip corresponds to a TREZOR-based key.""" + pubkey_bytes = keyring.export_public_keys(env=self.env) if len(keygrips) == 1 and keygrips[0].startswith(b"--list="): # Support "fast-path" key listing: # https://dev.gnupg.org/rG40da61b89b62dcb77847dc79eb159e885f52f817 - keygrips = list(decode.iter_keygrips(pubkey_bytes=self.pubkey_bytes)) + keygrips = list(decode.iter_keygrips(pubkey_bytes=pubkey_bytes)) log.debug('keygrips: %r', keygrips) keyring.sendline(conn, b'D ' + util.assuan_serialize(b''.join(keygrips))) return for keygrip in keygrips: try: - self.get_identity(keygrip=keygrip) + self.get_identity(keygrip=keygrip, pubkey_bytes=pubkey_bytes) break except KeyError as e: log.warning('HAVEKEY(%s) failed: %s', keygrip, e) @@ -235,13 +236,15 @@ def handle(self, conn): args = tuple(parts[1:]) if command == b'BYE': + keyring.sendline(conn, b'OK closing connection') return elif command == b'KILLAGENT': - keyring.sendline(conn, b'OK') + keyring.sendline(conn, b'OK closing connection') raise AgentStop() if command not in self.handlers: log.error('unknown request: %r', line) + keyring.sendline(conn, b'ERR 67109139 Unknown IPC command ') continue handler = self.handlers[command] diff --git a/libagent/gpg/decode.py b/libagent/gpg/decode.py index 1d03b4b1..c19062a9 100644 --- a/libagent/gpg/decode.py +++ b/libagent/gpg/decode.py @@ -95,12 +95,6 @@ def _parse_embedded_signatures(subpackets): yield _parse_signature(util.Reader(stream)) -def has_custom_subpacket(signature_packet): - """Detect our custom public keys by matching subpacket data.""" - return any(protocol.CUSTOM_KEY_LABEL == subpacket[1:] - for subpacket in signature_packet['unhashed_subpackets']) - - def _parse_signature(stream): """See https://tools.ietf.org/html/rfc4880#section-5.2 for details.""" p = {'type': 'signature'} @@ -166,7 +160,8 @@ def _parse_pubkey(stream, packet_type='pubkey'): p['secret'] = leftover.read() parse_func, keygrip_func = SUPPORTED_CURVES[oid] - keygrip = keygrip_func(parse_func(mpi)) + p['verifying_key'] = parse_func(mpi) + keygrip = keygrip_func(p['verifying_key']) log.debug('keygrip: %s', util.hexlify(keygrip)) p['keygrip'] = keygrip @@ -293,13 +288,44 @@ def _parse_pubkey_packets(pubkey_bytes): return packets_per_pubkey -def load_by_keygrip(pubkey_bytes, keygrip): +def _check_for_subkey_path(pubkey_dict, piter, gen_identity_and_key): + if gen_identity_and_key is None: + # Only look for a custom subkey path if we can verify it. + return None + curve_name = protocol.get_curve_name_by_oid(pubkey_dict['curve_oid']) + ecdh = pubkey_dict['algo'] == protocol.ECDH_ALGO_ID + # Skip any packets between the subkey and the signature. + # Stop if we reached a different key + sig = next(piter, None) + while sig is not None and sig['tag'] not in [5, 6, 7, 14, 0x18]: + sig = next(piter, None) + if sig is None or sig['tag'] != 0x18: + return None + for subpacket in sig['unhashed_subpackets']: + data = bytearray(subpacket) + if data[0] != 100: + continue + # Custom packet. But that doesn't mean it's ours. + _, verifying_key = gen_identity_and_key(user_id=subpacket[:1].decode('utf-8'), + curve_name=curve_name, ecdh=ecdh) + if verifying_key != pubkey_dict['verifying_key']: + continue + return [_parse_user_id(util.Reader(io.BytesIO(subpacket[:1])))] + return None + + +def load_by_keygrip(pubkey_bytes, keygrip, gen_identity_and_key=None): """Return public key and first user ID 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: + piter = iter(packets) + p = next(piter, None) + while p is not None: if p.get('keygrip') == keygrip: + user_ids = _check_for_subkey_path(p, piter, gen_identity_and_key) + if user_ids is None: + user_ids = [p for p in packets if p['type'] == 'user_id'] return p, user_ids + p = next(piter, None) raise KeyError('{} keygrip not found'.format(util.hexlify(keygrip))) @@ -312,6 +338,33 @@ def iter_keygrips(pubkey_bytes): yield keygrip +def identity_for_key(pubkey_bytes, gen_identity_and_key): + """Returns the identity used to produce the associated primary key. + + This would normally be the user id. However, if a key generated from the user id does not match + the provided identity, ``None`` is returned. + """ + packets = parse_packets(io.BytesIO(pubkey_bytes)) + pubkey_dict = next(packets, None) + if pubkey_dict is None or pubkey_dict['type'] != 'pubkey' or 'verifying_key' not in pubkey_dict: + return None + user_id = next(packets, None) + while user_id is not None and user_id['type'] != 'user_id': + user_id = next(packets) + if user_id is None: + return None + try: + curve_name = protocol.get_curve_name_by_oid(pubkey_dict['curve_oid']) + except KeyError: + return None + ecdh = pubkey_dict['algo'] == protocol.ECDH_ALGO_ID + identity, verifying_key = gen_identity_and_key(user_id=user_id['value'].decode('utf-8'), + curve_name=curve_name, ecdh=ecdh) + if verifying_key != pubkey_dict['verifying_key']: + return None + return identity + + def load_signature(stream, original_data): """Load signature from stream, and compute GPG digest for verification.""" signature, = list(parse_packets((stream))) @@ -326,7 +379,7 @@ def remove_armor(armored_data): """Decode armored data into its binary form.""" stream = io.BytesIO(armored_data) lines = stream.readlines()[3:-1] - data = base64.b64decode(b''.join(lines)) - payload, checksum = data[:-3], data[-3:] + payload = base64.b64decode(b''.join(lines[:-1])) + checksum = base64.b64decode(lines[-1]) assert util.crc24(payload) == checksum return payload diff --git a/libagent/gpg/encode.py b/libagent/gpg/encode.py index 44c3d2e6..7e8595fb 100644 --- a/libagent/gpg/encode.py +++ b/libagent/gpg/encode.py @@ -3,12 +3,12 @@ import logging from .. import util -from . import decode, keyring, protocol +from . import decode, protocol log = logging.getLogger(__name__) -def create_primary(user_id, pubkey, signer_func, secret_bytes=b''): +def create_primary(user_id, pubkey, signer_func, flags, secret_bytes=b''): """Export new primary GPG public key, ready for "gpg2 --import".""" pubkey_packet = protocol.packet(tag=(5 if secret_bytes else 6), blob=pubkey.data() + secret_bytes) @@ -21,7 +21,7 @@ def create_primary(user_id, pubkey, signer_func, secret_bytes=b''): # https://tools.ietf.org/html/rfc4880#section-5.2.3.7 protocol.subpacket_byte(0x0B, 9), # preferred symmetric algo (AES-256) # https://tools.ietf.org/html/rfc4880#section-5.2.3.4 - protocol.subpacket_byte(0x1B, 1 | 2), # key flags (certify & sign) + protocol.subpacket_byte(0x1B, flags), # key flags # https://tools.ietf.org/html/rfc4880#section-5.2.3.21 protocol.subpacket_bytes(0x15, [8, 9, 10]), # preferred hash # https://tools.ietf.org/html/rfc4880#section-5.2.3.8 @@ -32,9 +32,7 @@ def create_primary(user_id, pubkey, signer_func, secret_bytes=b''): protocol.subpacket_byte(0x1E, 0x01), # advanced features (MDC) # https://tools.ietf.org/html/rfc4880#section-5.2.3.24 ] - unhashed_subpackets = [ - protocol.subpacket(16, pubkey.key_id()), # issuer key id - protocol.CUSTOM_SUBPACKET] + unhashed_subpackets = [protocol.subpacket(16, pubkey.key_id())] # issuer key id signature = protocol.make_signature( signer_func=signer_func, @@ -48,12 +46,13 @@ def create_primary(user_id, pubkey, signer_func, secret_bytes=b''): return pubkey_packet + user_id_packet + sign_packet -def create_subkey(primary_bytes, subkey, signer_func, secret_bytes=b''): +def create_subkey(primary_bytes, subkey, subkey_path, signer_func, flags, secret_bytes=b''): """Export new subkey to GPG primary key.""" + # pylint: disable=too-many-arguments subkey_packet = protocol.packet(tag=(7 if secret_bytes else 14), blob=subkey.data() + secret_bytes) - packets = list(decode.parse_packets(io.BytesIO(primary_bytes))) - primary, user_id, signature = packets[:3] + primary = next(decode.parse_packets(io.BytesIO(primary_bytes))) + assert primary['type'] == 'pubkey' data_to_sign = primary['_to_hash'] + subkey.data_to_hash() @@ -75,10 +74,6 @@ def create_subkey(primary_bytes, subkey, signer_func, secret_bytes=b''): # Subkey Binding Signature - # Key flags: https://tools.ietf.org/html/rfc4880#section-5.2.3.21 - # (certify & sign) (encrypt) - flags = (2) if (not subkey.ecdh) else (4 | 8) - hashed_subpackets = [ protocol.subpacket_time(subkey.created), # signature time protocol.subpacket_byte(0x1B, flags)] @@ -87,10 +82,11 @@ def create_subkey(primary_bytes, subkey, signer_func, secret_bytes=b''): unhashed_subpackets.append(protocol.subpacket(16, primary['key_id'])) if embedded_sig is not None: unhashed_subpackets.append(protocol.subpacket(32, embedded_sig)) - unhashed_subpackets.append(protocol.CUSTOM_SUBPACKET) - - if not decode.has_custom_subpacket(signature): - signer_func = keyring.create_agent_signer(user_id['value']) + if subkey_path: + # Packet ids 100-110 are free for use under risk of collision. + # This is good enough because we can verify the validity of the + # contents using the device itself. + unhashed_subpackets.append(protocol.subpacket(100, subkey_path.encode())) signature = protocol.make_signature( signer_func=signer_func, diff --git a/libagent/gpg/keyring.py b/libagent/gpg/keyring.py index 46dd00a6..12f70a35 100644 --- a/libagent/gpg/keyring.py +++ b/libagent/gpg/keyring.py @@ -39,8 +39,8 @@ def get_agent_sock_path(env=None, sp=subprocess): def connect_to_agent(env=None, sp=subprocess): """Connect to GPG agent's UNIX socket.""" sock_path = get_agent_sock_path(sp=sp, env=env) - # Make sure the original gpg-agent is running. - check_output(args=['gpg-connect-agent', '/bye'], sp=sp) + # This forces the gpg-agent configured for this environment to run. + check_output(gpg_command(['--list-secret-keys']), sp=sp, env=env) if sys.platform == 'win32': sock = win_server.Client(sock_path) else: @@ -228,13 +228,6 @@ def gpg_command(args, env=None): return [cmd] + args -def get_keygrip(user_id, sp=subprocess): - """Get a keygrip of the primary GPG key of the specified user.""" - args = gpg_command(['--list-keys', '--with-keygrip', user_id]) - output = check_output(args=args, sp=sp).decode('utf-8') - return re.findall(r'Keygrip = (\w+)', output)[0] - - def gpg_version(sp=subprocess): """Get a keygrip of the primary GPG key of the specified user.""" args = gpg_command(['--version']) @@ -264,10 +257,9 @@ def export_public_keys(env=None, sp=subprocess): return result -def create_agent_signer(user_id): +def create_agent_signer(keygrip, env): """Sign digest with existing GPG keys using gpg-agent tool.""" - sock = connect_to_agent(env=os.environ) - keygrip = get_keygrip(user_id) + sock = connect_to_agent(env=env) def sign(digest): """Sign the digest and return an ECDSA/RSA/DSA signature.""" diff --git a/libagent/gpg/protocol.py b/libagent/gpg/protocol.py index 32a99d03..5926b156 100644 --- a/libagent/gpg/protocol.py +++ b/libagent/gpg/protocol.py @@ -174,10 +174,6 @@ def keygrip_curve25519(vk): ECDH_ALGO_ID = 18 -CUSTOM_KEY_LABEL = b'TREZOR-GPG' # marks "our" pubkey -CUSTOM_SUBPACKET_ID = 26 # use "policy URL" subpacket -CUSTOM_SUBPACKET = subpacket(CUSTOM_SUBPACKET_ID, CUSTOM_KEY_LABEL) - def get_curve_name_by_oid(oid): """Return curve name matching specified OID, or raise KeyError.""" diff --git a/libagent/gpg/tests/test_decode.py b/libagent/gpg/tests/test_decode.py index 7cd240eb..fabc87c6 100644 --- a/libagent/gpg/tests/test_decode.py +++ b/libagent/gpg/tests/test_decode.py @@ -43,19 +43,6 @@ def test_gpg_files(public_key_path): # pylint: disable=redefined-outer-name assert list(decode.parse_packets(f)) -def test_has_custom_subpacket(): - sig = {'unhashed_subpackets': []} - assert not decode.has_custom_subpacket(sig) - - custom_markers = [ - protocol.CUSTOM_SUBPACKET, - protocol.subpacket(10, protocol.CUSTOM_KEY_LABEL), - ] - for marker in custom_markers: - sig = {'unhashed_subpackets': [marker]} - assert decode.has_custom_subpacket(sig) - - def test_load_by_keygrip_missing(): with pytest.raises(KeyError): decode.load_by_keygrip(pubkey_bytes=b'', keygrip=b'') diff --git a/libagent/gpg/tests/test_keyring.py b/libagent/gpg/tests/test_keyring.py index 605ba0c9..db05a97e 100644 --- a/libagent/gpg/tests/test_keyring.py +++ b/libagent/gpg/tests/test_keyring.py @@ -95,7 +95,10 @@ def test_get_agent_sock_path(): expected_prefix = b'/run/user/' expected_suffix = b'/gnupg/S.gpg-agent' expected_infix = b'0123456789' + expected_ifroot = b'/root/.gnupg/S.gpg-agent' # Use in case tox was executed as root value = keyring.get_agent_sock_path(sp=subprocess) + if value == expected_ifroot: + return assert value.startswith(expected_prefix) assert value.endswith(expected_suffix) value = value[len(expected_prefix):-len(expected_suffix)] diff --git a/libagent/server.py b/libagent/server.py index 43289ce7..a5bd9f9d 100644 --- a/libagent/server.py +++ b/libagent/server.py @@ -1,4 +1,4 @@ -"""UNIX-domain socket server for ssh-agent implementation.""" +"""UNIX-domain socket server and related utility functions.""" import contextlib import logging import os @@ -108,42 +108,50 @@ def handle_connection(conn, handler, mutex): log.warning('error: %s', e, exc_info=True) -def retry(func, exception_type, quit_event): - """ - Run the function, retrying when the specified exception_type occurs. - - Poll quit_event on each iteration, to be responsive to an external - exit request. - """ - while True: - if quit_event.is_set(): - raise StopIteration - try: - return func() - except exception_type: - pass - - -def server_thread(sock, handle_conn, quit_event): +def server_thread(sock, handle_conn, close, closed): """Run a server on the specified socket.""" log.debug('server thread started') - def accept_connection(): - conn, _ = sock.accept() - conn.settimeout(None) - return conn + mutex = threading.Lock() + conns = set() - while True: - log.debug('waiting for connection on %s', sock.getsockname()) + def connection_thread(conn): try: - conn = retry(accept_connection, socket.timeout, quit_event) - except StopIteration: - log.debug('server stopped') - break - # Handle connections from SSH concurrently. - threading.Thread(target=handle_conn, - kwargs={'conn': conn}).start() - log.debug('server thread stopped') + handle_conn(conn) + finally: + with mutex: + conns.discard(conn) + + def close_impl(): + with mutex: + for conn in conns: + if hasattr(conn, 'shutdown'): + conn.shutdown(socket.SHUT_RDWR) + conn.close() + if hasattr(sock, 'shutdown'): + sock.shutdown(socket.SHUT_RDWR) + sock.close() + + close.implement(close_impl) + + try: + + while True: + log.debug('waiting for connection on %s', sock.getsockname()) + try: + conn, _ = sock.accept() + except Exception: # pylint: disable=broad-except + log.debug('server stopped') + break + with mutex: + conns.add(conn) + # Handle connections concurrently. + threading.Thread(target=connection_thread, + kwargs={'conn': conn}).start() + + finally: + closed() + log.debug('server thread stopped') @contextlib.contextmanager diff --git a/libagent/ssh/__init__.py b/libagent/ssh/__init__.py index dee3ee24..acea41ac 100644 --- a/libagent/ssh/__init__.py +++ b/libagent/ssh/__init__.py @@ -17,7 +17,7 @@ import configargparse try: - # TODO: Not supported on Windows. Use daemoniker instead? + # Not supported on Windows. Should be manually installed as a service instead. import daemon except ImportError: daemon = None @@ -28,7 +28,6 @@ log = logging.getLogger(__name__) -UNIX_SOCKET_TIMEOUT = 0.1 SOCK_TYPE = 'Windows named pipe' if sys.platform == 'win32' else 'UNIX domain socket' SOCK_TYPE_PATH = 'Windows named pipe path' if sys.platform == 'win32' else 'UNIX socket path' FILE_PREFIX = 'file:' @@ -93,9 +92,6 @@ def create_agent_parser(device_type): p.add_argument('-e', '--ecdsa-curve-name', metavar='CURVE', default=formats.CURVE_NIST256, help='specify ECDSA curve name: ' + curve_names) - p.add_argument('--timeout', - default=UNIX_SOCKET_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, @@ -134,51 +130,48 @@ def create_agent_parser(device_type): @contextlib.contextmanager -def serve(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. - """ +def serve(handler, sock_path, closed): + """Start the ssh-agent server on a UNIX-domain socket.""" 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())} device_mutex = threading.Lock() with server.unix_domain_socket_server(sock_path) as sock: - sock.settimeout(timeout) - quit_event = threading.Event() + close = util.DeferredMethod() handle_conn = functools.partial(server.handle_connection, handler=handler, mutex=device_mutex) kwargs = {'sock': sock, 'handle_conn': handle_conn, - 'quit_event': quit_event} + 'close': close, + 'closed': closed} with server.spawn(server.server_thread, kwargs): try: yield environ finally: log.debug('closing server') - quit_event.set() + close.call() -def run_server(conn, command, sock_path, debug, timeout): +def run_server(conn, command, sock_path, debug): """Common code for run_agent and run_git below.""" ret = 0 try: + closed_event = threading.Event() + + def closed(): + closed_event.set() + handler = protocol.Handler(conn=conn, debug=debug) - with serve(handler=handler, sock_path=sock_path, - timeout=timeout) as env: + with serve(handler=handler, sock_path=sock_path, closed=closed) as env: if command: ret = server.run_process(command=command, environ=env) else: - try: - signal.pause() # wait for signal (e.g. SIGINT) - except AttributeError: - sys.stdin.read() # Windows doesn't support signal.pause + with util.catchbreak(closed): + closed_event.wait() except KeyboardInterrupt: - log.info('server stopped') + log.info('server stopped by SIGINT') return ret @@ -352,7 +345,7 @@ def main(device_type): 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) + debug=args.debug) else: for pk in conn.public_keys(): sys.stdout.write(pk) diff --git a/libagent/tests/test_server.py b/libagent/tests/test_server.py index 947160a8..682d5e0c 100644 --- a/libagent/tests/test_server.py +++ b/libagent/tests/test_server.py @@ -75,7 +75,8 @@ def test_handle(): def test_server_thread(): sock = FakeSocket() connections = [sock] - quit_event = threading.Event() + close = util.DeferredMethod() + closed_event = threading.Event() class FakeServer: def accept(self): @@ -86,14 +87,21 @@ def accept(self): def getsockname(self): return 'fake_server' + def close(self): + pass + def handle_conn(conn): assert conn is sock - quit_event.set() + close.call() + + def closed(): + closed_event.set() server.server_thread(sock=FakeServer(), handle_conn=handle_conn, - quit_event=quit_event) - quit_event.wait() + close=close, + closed=closed) + closed_event.wait() def test_spawn(): diff --git a/libagent/util.py b/libagent/util.py index 96ccad2f..01c172b2 100644 --- a/libagent/util.py +++ b/libagent/util.py @@ -1,11 +1,13 @@ """Various I/O and serialization utilities.""" import binascii import contextlib +import ctypes import functools import io import logging import struct import sys +import threading import time log = logging.getLogger(__name__) @@ -303,3 +305,81 @@ def set(self, value): """Set new value and reset the deadline for expiration.""" self.deadline = self.timer() + self.duration self.value = value + + +class DeferredMethod: + """Allows a thread to expose a method to the caller. + + Can be used to create reverse dependency. + """ + + def __init__(self): + """Should be initialized by the caller and passed to the thread.""" + self.implementation = None + self.event = threading.Event() + + def call(self, *args, **kwargs): + """Used by the caller to call the method.""" + self.event.wait() + return self.implementation(*args, **kwargs) + + def implement(self, implementation): + """Used by the thread to expose the method's implementation.""" + self.implementation = implementation + self.event.set() + + +@contextlib.contextmanager +def catchbreak(handler): + """Calls the handler if any break signal was detected. + + The handler is called in a different thread from the caller. + """ + if sys.platform == 'win32': + kernel32 = ctypes.WinDLL('kernel32', use_last_error=True) + + @ctypes.WINFUNCTYPE(ctypes.c_uint, ctypes.c_uint) + def ctrl_handler(_): + handler() + return True + + try: + kernel32.SetConsoleCtrlHandler(ctrl_handler, True) + yield + finally: + kernel32.SetConsoleCtrlHandler(ctrl_handler, False) + else: + sa_handler_functype = ctypes.CFUNCTYPE(None, ctypes.c_int) + + class sigset_t(ctypes.Structure): # pylint: disable=too-few-public-methods + _fields_ = [ + ("__val", ctypes.c_ulong * (1024 // (8 * ctypes.sizeof(ctypes.c_long)))) + ] + + class SIGACTION(ctypes.Structure): # pylint: disable=too-few-public-methods + _fields_ = [ + ("sa_handler", sa_handler_functype), + ("sa_mask", sigset_t), + ("sa_flags", ctypes.c_int), + ("sa_restorer", ctypes.c_void_p), + ] + + libc = ctypes.CDLL(None) + libc.sigaction.argtypes = [ctypes.c_int, + ctypes.POINTER(SIGACTION), + ctypes.POINTER(SIGACTION)] + libc.sigaction.restype = ctypes.c_int + + def signal_handler(_): + handler() + + try: + act = SIGACTION(sa_handler=sa_handler_functype(signal_handler)) + oldsigint = SIGACTION() + oldsigterm = SIGACTION() + libc.sigaction(2, ctypes.byref(act), ctypes.byref(oldsigint)) + libc.sigaction(15, ctypes.byref(act), ctypes.byref(oldsigterm)) + yield + finally: + libc.sigaction(2, oldsigint, None) + libc.sigaction(15, oldsigterm, None) diff --git a/libagent/win_server.py b/libagent/win_server.py index c0594029..64d9dcee 100644 --- a/libagent/win_server.py +++ b/libagent/win_server.py @@ -1,6 +1,4 @@ """Windows named pipe server simulating a UNIX socket.""" -import contextlib -import ctypes import io import os import socket @@ -13,31 +11,11 @@ from . import util -kernel32 = ctypes.WinDLL('kernel32', use_last_error=True) - PIPE_BUFFER_SIZE = 64 * 1024 CTRL_C_EVENT = 0 THREAD_SET_CONTEXT = 0x0010 -# Workaround for Ctrl+C not stopping IO on Windows -# See https://github.com/python/cpython/issues/85609 -@contextlib.contextmanager -def ctrl_cancel_async_io(file_handle): - """Listen for SIGINT and translate it to interrupting IO on the specified file handle.""" - @ctypes.WINFUNCTYPE(ctypes.c_uint, ctypes.c_uint) - def ctrl_handler(ctrl_event): - if ctrl_event == CTRL_C_EVENT: - kernel32.CancelIoEx(file_handle, None) - return False - - try: - kernel32.SetConsoleCtrlHandler(ctrl_handler, True) - yield - finally: - kernel32.SetConsoleCtrlHandler(ctrl_handler, False) - - # Based loosely on https://docs.microsoft.com/en-us/windows/win32/ipc/multithreaded-pipe-server class NamedPipe: """A Windows named pipe. @@ -143,8 +121,7 @@ def close(self): def connect(self): """Connect to a named pipe with the specified timeout.""" - with ctrl_cancel_async_io(self.handle): - waitHandle = win32event.WaitForSingleObject(self.overlapped.hEvent, self.timeout) + waitHandle = win32event.WaitForSingleObject(self.overlapped.hEvent, self.timeout) if waitHandle == win32event.WAIT_TIMEOUT: raise TimeoutError('Timed out waiting for client on pipe {0}'.format(self.name)) if not self.pending_io: @@ -171,8 +148,7 @@ def recv(self, size): if e.winerror == winerror.ERROR_NO_DATA: return None raise - with ctrl_cancel_async_io(self.handle): - win32event.WaitForSingleObject(self.overlapped.hEvent, self.timeout) + win32event.WaitForSingleObject(self.overlapped.hEvent, self.timeout) try: chunk_size = win32pipe.GetOverlappedResult(self.handle, self.overlapped, False) error_code = win32api.GetLastError() @@ -191,8 +167,7 @@ def send(self, data): winerror.ERROR_IO_PENDING, winerror.ERROR_MORE_DATA): raise IOError('WriteFile failed ({0})'.format(error_code)) - with ctrl_cancel_async_io(self.handle): - win32event.WaitForSingleObject(self.overlapped.hEvent, self.timeout) + win32event.WaitForSingleObject(self.overlapped.hEvent, self.timeout) written = win32pipe.GetOverlappedResult(self.handle, self.overlapped, False) error_code = win32api.GetLastError() if error_code != winerror.NO_ERROR: @@ -206,46 +181,6 @@ def sendall(self, data): data = data[written:] -class InterruptibleSocket: - """A wrapper for sockets which allows IO operations to be interrupted by SIGINT.""" - - def __init__(self, sock): - """Wraps the socket object ``sock``.""" - self.sock = sock - - def __del__(self): - """Close the wrapped socket. It should not outlive the wrapper.""" - self.close() - - def settimeout(self, timeout): - """Forward to underlying socket.""" - self.sock.settimeout(timeout) - - def recv(self, size): - """Forward to underlying socket, while monitoring for SIGINT.""" - try: - with ctrl_cancel_async_io(self.sock.fileno()): - return self.sock.recv(size) - except OSError as e: - if e.winerror == 10054: - # Convert socket close to end of file - return None - raise - - def sendall(self, reply): - """Forward to underlying socket, while monitoring for SIGINT.""" - with ctrl_cancel_async_io(self.sock.fileno()): - return self.sock.sendall(reply) - - def close(self): - """Forward to underlying socket.""" - return self.sock.close() - - def getsockname(self): - """Forward to underlying socket.""" - return self.sock.getsockname() - - class Server: """Listend on an emulated AF_UNIX socket on Windows. @@ -267,6 +202,7 @@ def __init__(self, pipe_name): self.pipe_name = pipe_name self.sock = None self.pipe = None + self.file = None if not isinstance(self.pipe_name, str): # GPG simulated socket via localhost socket self.key = os.urandom(16) @@ -276,12 +212,19 @@ def __init__(self, pipe_name): self.sock.listen(1) # Write key to file with open(self.pipe_name, 'wb') as f: - with ctrl_cancel_async_io(f.fileno()): + try: + self.file = f f.write(str(port).encode()) f.write(b'\n') f.write(self.key) + finally: + self.file = None def __del__(self): + """Close the underlying socket or pipe.""" + self.close() + + def close(self): """Close the underlying socket or pipe.""" if self.pipe is not None: self.pipe.close() @@ -289,6 +232,9 @@ def __del__(self): if self.sock is not None: self.sock.close() self.sock = None + if self.file is not None: + self.file.close() + self.file = None def settimeout(self, timeout): """Set the timeout in seconds.""" @@ -309,9 +255,7 @@ def accept(self): When a named pipe is used, the client's address is the same as the pipe name. """ if self.sock: - with ctrl_cancel_async_io(self.sock.fileno()): - sock, addr = self.sock.accept() - sock = InterruptibleSocket(sock) + sock, addr = self.sock.accept() sock.settimeout(self.timeout) if self.key != util.recv(sock, 16): sock.close() @@ -356,7 +300,8 @@ def __init__(self, pipe_name): if not isinstance(self.pipe_name, str): # Read key from file with open(self.pipe_name, 'rb') as f: - with ctrl_cancel_async_io(f.fileno()): + try: + self.file = f port = io.BytesIO() while True: c = f.read(1) @@ -370,7 +315,7 @@ def __init__(self, pipe_name): port = int(port.getvalue()) key_len = 0 key = io.BytesIO() - while key: + while key_len < 16: c = f.read(16-key_len) if not c: raise OSError('Could not read nonce for socket {0}'.format(pipe_name)) @@ -381,10 +326,11 @@ def __init__(self, pipe_name): c = f.read(1) if c: raise OSError('Corrupt socket {0}'.format(pipe_name)) + finally: + self.file = None # GPG simulated socket via localhost socket - sock = socket.socket() - sock.connect(('127.0.0.1', port)) - self.sock = InterruptibleSocket(sock) + self.sock = socket.socket() + self.sock.connect(('127.0.0.1', port)) self.sock.sendall(key) else: self.pipe = NamedPipe.open(pipe_name)