From 5e61bd8357330b6212466efc994798a2bfd93ed6 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Fri, 23 Jul 2021 18:53:52 +0200 Subject: [PATCH 1/7] Add shell completion for serial numbers In bash, the suggestions will simply be the available serial numbers. In more capable shells like zsh or fish, the suggestions will be annotated with the same device descriptions as shown by `ykman list`. --- pyproject.toml | 2 +- ykman/cli/__main__.py | 25 +++++++++++++++++++------ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3da0df1a..baaa1945 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ cryptography = "^2.1 || ^3.0" pyOpenSSL = {version = ">=0.15.1", optional = true} pyscard = "^1.9 || ^2.0" fido2 = ">=0.9, <1.0" -click = "^6.0 || ^7.0 || ^8.0" +click = "^8.0" pywin32 = {version = ">=223", platform = "win32"} [tool.poetry.dev-dependencies] diff --git a/ykman/cli/__main__.py b/ykman/cli/__main__.py index 90e82394..dd9e8d22 100644 --- a/ykman/cli/__main__.py +++ b/ykman/cli/__main__.py @@ -56,6 +56,7 @@ from .aliases import apply_aliases from .apdu import apdu import click +import click.shell_completion import ctypes import time import sys @@ -198,6 +199,14 @@ def _run_cmd_for_single(ctx, cmd, connections, reader_name=None): type=int, metavar="SERIAL", help="Specify which YubiKey to interact with by serial number.", + shell_complete=lambda ctx, param, incomplete: [ + click.shell_completion.CompletionItem( + str(dev_info.serial), + help=_describe_device(dev, dev_info), + ) + for dev, dev_info in list_all_devices() + if dev_info.serial and str(dev_info.serial).startswith(incomplete) + ], ) @click.option( "-r", @@ -330,13 +339,8 @@ def list_keys(ctx, serials, readers): if dev_info.serial: click.echo(dev_info.serial) else: - if dev.pid is None: # Devices from list_all_devices should always have PID. - raise AssertionError("PID is None") - name = get_name(dev_info, dev.pid.get_type()) - version = "%d.%d.%d" % dev_info.version if dev_info.version else "unknown" - mode = dev.pid.name.split("_", 1)[1].replace("_", "+") click.echo( - f"{name} ({version}) [{mode}]" + _describe_device(dev, dev_info) + (f" Serial: {dev_info.serial}" if dev_info.serial else "") ) pids.add(dev.pid) @@ -352,6 +356,15 @@ def list_keys(ctx, serials, readers): click.echo(f"{name} [{mode}] ") +def _describe_device(dev, dev_info): + if dev.pid is None: # Devices from list_all_devices should always have PID. + raise AssertionError("PID is None") + name = get_name(dev_info, dev.pid.get_type()) + version = "%d.%d.%d" % dev_info.version if dev_info.version else "unknown" + mode = dev.pid.name.split("_", 1)[1].replace("_", "+") + return f"{name} ({version}) [{mode}]" + + COMMANDS = (list_keys, info, otp, openpgp, oath, piv, fido, config, apdu) From b1a30d38da6bb82d288809a230436d4090585c20 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Fri, 23 Jul 2021 20:13:58 +0200 Subject: [PATCH 2/7] Add shell completion for CCID readers --- ykman/cli/__main__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ykman/cli/__main__.py b/ykman/cli/__main__.py index dd9e8d22..da741f20 100644 --- a/ykman/cli/__main__.py +++ b/ykman/cli/__main__.py @@ -214,6 +214,9 @@ def _run_cmd_for_single(ctx, cmd, connections, reader_name=None): help="Use an external smart card reader. Conflicts with --device and list.", metavar="NAME", default=None, + shell_complete=lambda ctx, param, incomplete: [ + reader.name for reader in list_readers() + ], ) @click.option( "-l", From 5aef4e0fdabef631acaadfd1d63c2e79a6654e04 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Tue, 17 Aug 2021 19:02:32 +0200 Subject: [PATCH 3/7] Require explicit user opt-in for --device completion --- ykman/cli/__main__.py | 50 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/ykman/cli/__main__.py b/ykman/cli/__main__.py index da741f20..03eaaf1e 100644 --- a/ykman/cli/__main__.py +++ b/ykman/cli/__main__.py @@ -45,6 +45,7 @@ ) from ..util import get_windows_version from ..diagnostics import get_diagnostics +from ..settings import AppData from .util import YkmanContextObject, ykman_group, cli_fail from .info import info from .otp import otp @@ -192,6 +193,37 @@ def _run_cmd_for_single(ctx, cmd, connections, reader_name=None): _disabled_interface(connections, cmd) +def _dangerous_completion(f): + def complete(ctx, param, incomplete): + state = AppData("completion") + + warned = state.get("disruptive_warned", []) + state["disruptive_warned"] = warned + enabled = state.get("disruptive_enabled", []) + state["disruptive_enabled"] = enabled + + if param.name in enabled: + return f(ctx, param, incomplete) + elif incomplete == "enable_disruptive": + enabled.append(param.name) + state.write() + return [f"# Shell completion enabled for --{param.name}."] + elif param.name in warned: + return [] + else: + warned.append(param.name) + state.write() + return [ + f"""# Shell completion for --{param.name} is slow and may disrupt""" + """ concurrent YubiKey operations.""" + """ To enable it, replace this completion text""" + """ with 'enable_disruptive' and then trigger completion.""" + """ This message will not be shown again.""" + ] + + return complete + + @ykman_group(context_settings=CLICK_CONTEXT_SETTINGS) @click.option( "-d", @@ -199,14 +231,16 @@ def _run_cmd_for_single(ctx, cmd, connections, reader_name=None): type=int, metavar="SERIAL", help="Specify which YubiKey to interact with by serial number.", - shell_complete=lambda ctx, param, incomplete: [ - click.shell_completion.CompletionItem( - str(dev_info.serial), - help=_describe_device(dev, dev_info), - ) - for dev, dev_info in list_all_devices() - if dev_info.serial and str(dev_info.serial).startswith(incomplete) - ], + shell_complete=_dangerous_completion( + lambda ctx, param, incomplete: [ + click.shell_completion.CompletionItem( + str(dev_info.serial), + help=_describe_device(dev, dev_info), + ) + for dev, dev_info in list_all_devices() + if dev_info.serial and str(dev_info.serial).startswith(incomplete) + ] + ), ) @click.option( "-r", From 3adac252fbf9a2b9134f6e3f08d3f99c86c44478 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Wed, 25 Aug 2021 16:19:37 +0200 Subject: [PATCH 4/7] Opt in to disruptive completions by environment variable --- ykman/cli/__main__.py | 42 ++++++++++-------------------------------- 1 file changed, 10 insertions(+), 32 deletions(-) diff --git a/ykman/cli/__main__.py b/ykman/cli/__main__.py index 03eaaf1e..11911bc8 100644 --- a/ykman/cli/__main__.py +++ b/ykman/cli/__main__.py @@ -45,7 +45,6 @@ ) from ..util import get_windows_version from ..diagnostics import get_diagnostics -from ..settings import AppData from .util import YkmanContextObject, ykman_group, cli_fail from .info import info from .otp import otp @@ -59,6 +58,7 @@ import click import click.shell_completion import ctypes +import os import time import sys import logging @@ -193,35 +193,11 @@ def _run_cmd_for_single(ctx, cmd, connections, reader_name=None): _disabled_interface(connections, cmd) -def _dangerous_completion(f): - def complete(ctx, param, incomplete): - state = AppData("completion") - - warned = state.get("disruptive_warned", []) - state["disruptive_warned"] = warned - enabled = state.get("disruptive_enabled", []) - state["disruptive_enabled"] = enabled - - if param.name in enabled: - return f(ctx, param, incomplete) - elif incomplete == "enable_disruptive": - enabled.append(param.name) - state.write() - return [f"# Shell completion enabled for --{param.name}."] - elif param.name in warned: - return [] - else: - warned.append(param.name) - state.write() - return [ - f"""# Shell completion for --{param.name} is slow and may disrupt""" - """ concurrent YubiKey operations.""" - """ To enable it, replace this completion text""" - """ with 'enable_disruptive' and then trigger completion.""" - """ This message will not be shown again.""" - ] - - return complete +def _experimental_completion(env_var_name, f): + if env_var_name in os.environ: + return f + else: + return lambda ctx, param, incomplete: [] @ykman_group(context_settings=CLICK_CONTEXT_SETTINGS) @@ -231,7 +207,9 @@ def complete(ctx, param, incomplete): type=int, metavar="SERIAL", help="Specify which YubiKey to interact with by serial number.", - shell_complete=_dangerous_completion( + shell_complete=_experimental_completion( + # Leading underscore for uniformity with _YKMAN_COMPLETE from Click + "_YKMAN_EXPERIMENTAL_COMPLETE_DEVICE", lambda ctx, param, incomplete: [ click.shell_completion.CompletionItem( str(dev_info.serial), @@ -239,7 +217,7 @@ def complete(ctx, param, incomplete): ) for dev, dev_info in list_all_devices() if dev_info.serial and str(dev_info.serial).startswith(incomplete) - ] + ], ), ) @click.option( From ad20005ed5d73eb24f7d17b70a45bdfff7e049fc Mon Sep 17 00:00:00 2001 From: Dain Nilsson Date: Mon, 15 Jan 2024 16:31:37 +0100 Subject: [PATCH 5/7] Cache seen serials for tab completion. --- ykman/_cli/__main__.py | 74 +++++++++++++++++++++++++----------------- 1 file changed, 44 insertions(+), 30 deletions(-) diff --git a/ykman/_cli/__main__.py b/ykman/_cli/__main__.py index 7be34a0b..934d4808 100644 --- a/ykman/_cli/__main__.py +++ b/ykman/_cli/__main__.py @@ -34,10 +34,11 @@ from .. import __version__ from ..pcsc import list_devices as list_ccid, list_readers -from ..device import scan_devices, list_all_devices +from ..device import scan_devices, list_all_devices as _list_all_devices from ..util import get_windows_version from ..logging import init_logging from ..diagnostics import get_diagnostics, sys_info +from ..settings import AppData from .util import YkmanContextObject, click_group, EnumChoice, CliFail, pretty_print from .info import info from .otp import otp @@ -54,7 +55,6 @@ import click import click.shell_completion import ctypes -import os import time import sys @@ -116,6 +116,20 @@ def require_reader(connection_types, reader): raise CliFail("Not a CCID command.") +def list_all_devices(*args, **kwargs): + devices = _list_all_devices(*args, **kwargs) + if devices: + history = AppData("history") + cache = history.setdefault("devices", {}) + for dev, dev_info in devices: + if dev_info.serial: + k = str(dev_info.serial) + cache[k] = cache.pop(k, None) or _describe_device(dev, dev_info, False) + [cache.pop(k) for k in list(cache.keys())[:-3]] + history.write() + return devices + + def require_device(connection_types, serial=None): # Find all connected devices devices, state = scan_devices() @@ -128,6 +142,7 @@ def require_device(connection_types, serial=None): except TimeoutError: raise CliFail("No YubiKey detected!") if n_devs > 1: + list_all_devices() # Update device cache raise CliFail( "Multiple YubiKeys detected. Use --device SERIAL to specify " "which one to use." @@ -153,12 +168,19 @@ def require_device(connection_types, serial=None): raise CliFail("Failed to connect to YubiKey.") return devs[0] else: - for _ in (0, 1): # If no match initially, wait a bit for state change. + for retry in ( + True, + False, + ): # If no match initially, wait a bit for state change. devs = list_all_devices(connection_types) - for dev, nfo in devs: - if nfo.serial == serial: - return dev, nfo - devices, state = _scan_changes(state) + for dev, dev_info in devs: + if dev_info.serial == serial: + return dev, dev_info + try: + if retry: + _, state = _scan_changes(state) + except TimeoutError: + break raise CliFail( f"Failed connecting to a YubiKey with serial: {serial}.\n" @@ -166,13 +188,6 @@ def require_device(connection_types, serial=None): ) -def _experimental_completion(env_var_name, f): - if env_var_name in os.environ: - return f - else: - return lambda ctx, param, incomplete: [] - - @click_group(context_settings=CLICK_CONTEXT_SETTINGS) @click.option( "-d", @@ -180,18 +195,14 @@ def _experimental_completion(env_var_name, f): type=int, metavar="SERIAL", help="specify which YubiKey to interact with by serial number", - shell_complete=_experimental_completion( - # Leading underscore for uniformity with _YKMAN_COMPLETE from Click - "_YKMAN_EXPERIMENTAL_COMPLETE_DEVICE", - lambda ctx, param, incomplete: [ - click.shell_completion.CompletionItem( - str(dev_info.serial), - help=_describe_device(dev, dev_info), - ) - for dev, dev_info in list_all_devices() - if dev_info.serial and str(dev_info.serial).startswith(incomplete) - ], - ), + shell_complete=lambda ctx, param, incomplete: [ + click.shell_completion.CompletionItem( + serial, + help=description, + ) + for serial, description in AppData("history").get("devices", {}).items() + if serial.startswith(incomplete) + ], ) @click.option( "-r", @@ -201,7 +212,7 @@ def _experimental_completion(env_var_name, f): metavar="NAME", default=None, shell_complete=lambda ctx, param, incomplete: [ - reader.name for reader in list_readers() + f'"{reader.name}"' for reader in list_readers() ], ) @click.option( @@ -347,13 +358,16 @@ def list_keys(ctx, serials, readers): click.echo(f"{name} [{mode}] ") -def _describe_device(dev, dev_info): +def _describe_device(dev, dev_info, include_mode=True): if dev.pid is None: # Devices from list_all_devices should always have PID. raise AssertionError("PID is None") name = get_name(dev_info, dev.pid.yubikey_type) version = dev_info.version or "unknown" - mode = dev.pid.name.split("_", 1)[1].replace("_", "+") - return f"{name} ({version}) [{mode}]" + description = f"{name} ({version})" + if include_mode: + mode = dev.pid.name.split("_", 1)[1].replace("_", "+") + description += f" [{mode}]" + return description COMMANDS = ( From d9c440f090d7600386959e518a7f6961e668d528 Mon Sep 17 00:00:00 2001 From: Dain Nilsson Date: Mon, 15 Jan 2024 19:30:27 +0100 Subject: [PATCH 6/7] Always cache at least as many devices as are currently found. --- ykman/_cli/__main__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ykman/_cli/__main__.py b/ykman/_cli/__main__.py index 934d4808..7ec89c04 100644 --- a/ykman/_cli/__main__.py +++ b/ykman/_cli/__main__.py @@ -118,14 +118,15 @@ def require_reader(connection_types, reader): def list_all_devices(*args, **kwargs): devices = _list_all_devices(*args, **kwargs) - if devices: + with_serial = [(dev, dev_info) for (dev, dev_info) in devices if dev_info.serial] + if with_serial: history = AppData("history") cache = history.setdefault("devices", {}) - for dev, dev_info in devices: + for dev, dev_info in with_serial: if dev_info.serial: k = str(dev_info.serial) cache[k] = cache.pop(k, None) or _describe_device(dev, dev_info, False) - [cache.pop(k) for k in list(cache.keys())[:-3]] + [cache.pop(k) for k in list(cache.keys())[: -max(3, len(with_serial))]] history.write() return devices From dfbe06781af221b789c412af07ea78d949666030 Mon Sep 17 00:00:00 2001 From: Dain Nilsson Date: Tue, 23 Jan 2024 11:12:56 +0100 Subject: [PATCH 7/7] Change number of devices cached. --- ykman/_cli/__main__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ykman/_cli/__main__.py b/ykman/_cli/__main__.py index 7ec89c04..f233f58e 100644 --- a/ykman/_cli/__main__.py +++ b/ykman/_cli/__main__.py @@ -126,7 +126,8 @@ def list_all_devices(*args, **kwargs): if dev_info.serial: k = str(dev_info.serial) cache[k] = cache.pop(k, None) or _describe_device(dev, dev_info, False) - [cache.pop(k) for k in list(cache.keys())[: -max(3, len(with_serial))]] + # 5, chosen by fair dice roll + [cache.pop(k) for k in list(cache.keys())[: -max(5, len(with_serial))]] history.write() return devices