Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add FIDO config commands #592

Merged
merged 3 commits into from
Jan 23, 2024
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 121 additions & 20 deletions ykman/_cli/fido.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
CredentialManagement,
FPBioEnrollment,
CaptureError,
Config,
)
from fido2.pcsc import CtapPcscDevice
from yubikit.core.fido import FidoConnection
Expand All @@ -45,13 +46,14 @@
click_group,
prompt_timeout,
is_yk4_fips,
pretty_print,
)
from .util import CliFail
from ..fido import is_in_fips_mode, fips_reset, fips_change_pin, fips_verify_pin
from ..hid import list_ctap_devices
from ..pcsc import list_devices as list_ccid
from smartcard.Exceptions import NoCardException, CardConnectionException
from typing import Optional, Sequence, List
from typing import Optional, Sequence, List, Dict

import io
import csv as _csv
Expand Down Expand Up @@ -101,54 +103,59 @@ def info(ctx):
"""
conn = ctx.obj["conn"]
ctap2 = ctx.obj.get("ctap2")
info: Dict = {}
lines: List = [info]

if is_yk4_fips(ctx.obj["info"]):
click.echo("FIPS Approved Mode: " + ("Yes" if is_in_fips_mode(conn) else "No"))
info["FIPS Approved Mode"] = "Yes" if is_in_fips_mode(conn) else "No"
elif ctap2:
client_pin = ClientPin(ctap2) # N.B. All YubiKeys with CTAP2 support PIN.
if ctap2.info.options["clientPin"]:
if ctap2.info.force_pin_change:
click.echo(
lines.append(
"NOTE: The FIDO PIN is disabled and must be changed before it can "
"be used!"
)
pin_retries, power_cycle = client_pin.get_pin_retries()
if pin_retries:
click.echo(f"PIN is set, with {pin_retries} attempt(s) remaining.")
info["PIN"] = f"{pin_retries} attempt(s) remaining"
if power_cycle:
click.echo(
lines.append(
"PIN is temporarily blocked. "
"Remove and re-insert the YubiKey to unblock."
)
else:
click.echo("PIN is set, but has been blocked.")
info["PIN"] = "blocked"
else:
click.echo("PIN is not set.")
info["PIN"] = "not set"
info["Minimum PIN length"] = ctap2.info.min_pin_length

bio_enroll = ctap2.info.options.get("bioEnroll")
if bio_enroll:
uv_retries = client_pin.get_uv_retries()
if uv_retries:
click.echo(
f"Fingerprints registered, with {uv_retries} attempt(s) "
"remaining."
)
info["Fingerprints"] = f"registered, {uv_retries} attempt(s) remaining"
else:
click.echo(
"Fingerprints registered, but blocked until PIN is verified."
)
info["Fingerprints"] = "registered, blocked until PIN is verified"
elif bio_enroll is False:
click.echo("No fingerprints have been registered.")
info["Fingerprints"] = "not registered"

always_uv = ctap2.info.options.get("alwaysUv")
if always_uv is not None:
click.echo(
"Always Require User Verification is turned "
+ ("on." if always_uv else "off.")
)
info["Always Require UV"] = "on" if always_uv else "off"

remaining_creds = ctap2.info.remaining_disc_creds
if remaining_creds is not None:
info["Credential storage remaining"] = remaining_creds

ep = ctap2.info.options.get("ep")
if ep is not None:
info["Enterprise Attestation"] = "enabled" if ep else "disabled"

else:
click.echo("PIN is not supported.")
info["PIN"] = "not supported"

click.echo("\n".join(pretty_print(lines)))


@fido.command("reset")
Expand Down Expand Up @@ -460,6 +467,63 @@ def verify(ctx, pin):
click.echo("PIN verified.")


def _init_config(ctx, pin):
ctap2 = ctx.obj.get("ctap2")
if not Config.is_supported(ctap2.info):
raise CliFail("Authenticator Configuration is not supported on this YubiKey.")

pin = _require_pin(ctx, pin, "Authenticator Configuration")
client_pin = ClientPin(ctap2)
try:
token = client_pin.get_pin_token(pin, ClientPin.PERMISSION.AUTHENTICATOR_CFG)
except CtapError as e:
_fail_pin_error(ctx, e, "PIN error: %s")

return Config(ctap2, client_pin.protocol, token)


@access.command("force-change")
@click.pass_context
@click.option("-P", "--pin", help="PIN code")
def force_pin_change(ctx, pin):
"""
Force the PIN to be changed to a new value before use.
"""
options = ctx.obj.get("ctap2").info.options
if not options.get("setMinPINLength"):
raise CliFail("Force change PIN is not supported on this YubiKey.")

config = _init_config(ctx, pin)
config.set_min_pin_length(force_change_pin=True)


@access.command("set-min-length")
@click.pass_context
@click.option("-P", "--pin", help="PIN code")
@click.option("-R", "--rp-id", multiple=True, help="RP ID to allow")
@click.argument("length", type=click.IntRange(4))
def set_min_pin_length(ctx, pin, rp_id, length):
"""
Set the minimum length allowed for PIN.

Optionally use the --rp option to specify which RPs are allowed to request this
information.
"""
options = ctx.obj.get("ctap2").info.options
if not options.get("setMinPINLength"):
raise CliFail("Set minimum PIN length is not supported on this YubiKey.")

config = _init_config(ctx, pin)
if rp_id:
ctap2 = ctx.obj.get("ctap2")
cap = ctap2.info.max_rpids_for_min_pin
if len(rp_id) > cap:
raise CliFail(
f"Authenticator supports up to {cap} RP IDs ({len(rp_id)} given)."
)
config.set_min_pin_length(min_pin_length=length, rp_ids=rp_id)


def _prompt_current_pin(prompt="Enter your current PIN"):
return click_prompt(prompt, hide_input=True)

Expand Down Expand Up @@ -791,3 +855,40 @@ def bio_delete(ctx, template_id, pin, force):
logger.info("Fingerprint template deleted")
except CtapError as e:
raise CliFail(f"Failed to delete fingerprint: {e.code.name}")


@fido.group("config")
def config():
"""
Manage FIDO configuration.
"""


@config.command("toggle-always-uv")
@click.pass_context
@click.option("-P", "--pin", help="PIN code")
def toggle_always_uv(ctx, pin):
"""
Toggles the state of Always Require User Verification.
"""
options = ctx.obj.get("ctap2").info.options
if "alwaysUv" not in options:
raise CliFail("Always Require UV is not supported on this YubiKey.")

config = _init_config(ctx, pin)
config.toggle_always_uv()


@config.command("enable-ep-attestation")
@click.pass_context
@click.option("-P", "--pin", help="PIN code")
def enable_ep_attestation(ctx, pin):
"""
Enables Enterprise Attestation for Authenticators pre-configured to support it.
"""
options = ctx.obj.get("ctap2").info.options
if "ep" not in options:
raise CliFail("Enterprise Attestation is not supported on this YubiKey.")

config = _init_config(ctx, pin)
config.enable_enterprise_attestation()