1# Copyright (c) 2018 Yubico AB
2# All rights reserved.
3#
4#   Redistribution and use in source and binary forms, with or
5#   without modification, are permitted provided that the following
6#   conditions are met:
7#
8#    1. Redistributions of source code must retain the above copyright
9#       notice, this list of conditions and the following disclaimer.
10#    2. Redistributions in binary form must reproduce the above
11#       copyright notice, this list of conditions and the following
12#       disclaimer in the documentation and/or other materials provided
13#       with the distribution.
14#
15# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
16# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
17# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
18# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
19# COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
20# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
21# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
22# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
23# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
24# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
25# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
26# POSSIBILITY OF SUCH DAMAGE.
27
28from fido2.ctap import CtapError
29from fido2.ctap1 import ApduError
30from fido2.ctap2 import (
31    Ctap2,
32    ClientPin,
33    CredentialManagement,
34    FPBioEnrollment,
35    CaptureError,
36)
37from fido2.pcsc import CtapPcscDevice
38from yubikit.core.fido import FidoConnection
39from yubikit.core.smartcard import SW
40from time import sleep
41from .util import (
42    click_postpone_execution,
43    click_prompt,
44    click_force_option,
45    ykman_group,
46    prompt_timeout,
47)
48from .util import cli_fail
49from ..fido import is_in_fips_mode, fips_reset, fips_change_pin, fips_verify_pin
50from ..hid import list_ctap_devices
51from ..device import is_fips_version
52from ..pcsc import list_devices as list_ccid
53from smartcard.Exceptions import NoCardException, CardConnectionException
54from typing import Optional
55
56import click
57import logging
58
59logger = logging.getLogger(__name__)
60
61
62FIPS_PIN_MIN_LENGTH = 6
63PIN_MIN_LENGTH = 4
64
65
66@ykman_group(FidoConnection)
67@click.pass_context
68@click_postpone_execution
69def fido(ctx):
70    """
71    Manage the FIDO applications.
72
73    Examples:
74
75    \b
76      Reset the FIDO (FIDO2 and U2F) applications:
77      $ ykman fido reset
78
79    \b
80      Change the FIDO2 PIN from 123456 to 654321:
81      $ ykman fido access change-pin --pin 123456 --new-pin 654321
82
83    """
84    conn = ctx.obj["conn"]
85    try:
86        ctx.obj["ctap2"] = Ctap2(conn)
87    except (ValueError, CtapError) as e:
88        logger.info("FIDO device does not support CTAP2: %s", e)
89
90
91@fido.command()
92@click.pass_context
93def info(ctx):
94    """
95    Display general status of the FIDO2 application.
96    """
97    conn = ctx.obj["conn"]
98    ctap2 = ctx.obj.get("ctap2")
99
100    if is_fips_version(ctx.obj["info"].version):
101        click.echo("FIPS Approved Mode: " + ("Yes" if is_in_fips_mode(conn) else "No"))
102    elif ctap2:
103        client_pin = ClientPin(ctap2)  # N.B. All YubiKeys with CTAP2 support PIN.
104        if ctap2.info.options["clientPin"]:
105            if ctap2.info.force_pin_change:
106                click.echo(
107                    "NOTE: The FIDO PID is disabled and must be changed before it can "
108                    "be used!"
109                )
110            pin_retries, power_cycle = client_pin.get_pin_retries()
111            if pin_retries:
112                click.echo(f"PIN is set, with {pin_retries} attempt(s) remaining.")
113                if power_cycle:
114                    click.echo(
115                        "PIN is temporarily blocked. "
116                        "Remove and re-insert the YubiKey to unblock."
117                    )
118            else:
119                click.echo("PIN is set, but has been blocked.")
120        else:
121            click.echo("PIN is not set.")
122
123        bio_enroll = ctap2.info.options.get("bioEnroll")
124        if bio_enroll:
125            uv_retries, _ = client_pin.get_uv_retries()
126            if uv_retries:
127                click.echo(
128                    f"Fingerprints registered, with {uv_retries} attempt(s) "
129                    "remaining."
130                )
131            else:
132                click.echo(
133                    "Fingerprints registered, but blocked until PIN is verified."
134                )
135        elif bio_enroll is False:
136            click.echo("No fingerprints have been registered.")
137
138        always_uv = ctap2.info.options.get("alwaysUv")
139        if always_uv is not None:
140            click.echo(
141                "Always Require User Verification is turned "
142                + ("on." if always_uv else "off.")
143            )
144
145    else:
146        click.echo("PIN is not supported.")
147
148
149@fido.command("reset")
150@click_force_option
151@click.pass_context
152def reset(ctx, force):
153    """
154    Reset all FIDO applications.
155
156    This action will wipe all FIDO credentials, including FIDO U2F credentials,
157    on the YubiKey and remove the PIN code.
158
159    The reset must be triggered immediately after the YubiKey is
160    inserted, and requires a touch on the YubiKey.
161    """
162
163    conn = ctx.obj["conn"]
164
165    if isinstance(conn, CtapPcscDevice):  # NFC
166        readers = list_ccid(conn._name)
167        if not readers or readers[0].reader.name != conn._name:
168            logger.error(f"Multiple readers matched: {readers}")
169            cli_fail("Unable to isolate NFC reader.")
170        dev = readers[0]
171        logger.debug(f"use: {dev}")
172        is_fips = False
173
174        def prompt_re_insert():
175            click.echo(
176                "Remove and re-place your YubiKey on the NFC reader to perform the "
177                "reset..."
178            )
179
180            removed = False
181            while True:
182                sleep(0.5)
183                try:
184                    with dev.open_connection(FidoConnection):
185                        if removed:
186                            sleep(1.0)  # Wait for the device to settle
187                            break
188                except CardConnectionException:
189                    pass  # Expected, ignore
190                except NoCardException:
191                    removed = True
192            return dev.open_connection(FidoConnection)
193
194    else:  # USB
195        n_keys = len(list_ctap_devices())
196        if n_keys > 1:
197            cli_fail("Only one YubiKey can be connected to perform a reset.")
198        is_fips = is_fips_version(ctx.obj["info"].version)
199
200        ctap2 = ctx.obj.get("ctap2")
201        if not is_fips and not ctap2:
202            cli_fail("This YubiKey does not support FIDO reset.")
203
204        def prompt_re_insert():
205            click.echo("Remove and re-insert your YubiKey to perform the reset...")
206
207            removed = False
208            while True:
209                sleep(0.5)
210                keys = list_ctap_devices()
211                if not keys:
212                    removed = True
213                if removed and len(keys) == 1:
214                    return keys[0].open_connection(FidoConnection)
215
216    if not force:
217        if not click.confirm(
218            "WARNING! This will delete all FIDO credentials, including FIDO U2F "
219            "credentials, and restore factory settings. Proceed?",
220            err=True,
221        ):
222            ctx.abort()
223        if is_fips:
224            destroy_input = click_prompt(
225                "WARNING! This is a YubiKey FIPS device. This command will also "
226                "overwrite the U2F attestation key; this action cannot be undone and "
227                "this YubiKey will no longer be a FIPS compliant device.\n"
228                'To proceed, please enter the text "OVERWRITE"',
229                default="",
230                show_default=False,
231            )
232            if destroy_input != "OVERWRITE":
233                cli_fail("Reset aborted by user.")
234
235        conn = prompt_re_insert()
236
237    try:
238        with prompt_timeout():
239            if is_fips:
240                fips_reset(conn)
241            else:
242                Ctap2(conn).reset()
243    except CtapError as e:
244        logger.error("Reset failed", exc_info=e)
245        if e.code == CtapError.ERR.ACTION_TIMEOUT:
246            cli_fail(
247                "Reset failed. You need to touch your YubiKey to confirm the reset."
248            )
249        elif e.code in (CtapError.ERR.NOT_ALLOWED, CtapError.ERR.PIN_AUTH_BLOCKED):
250            cli_fail(
251                "Reset failed. Reset must be triggered within 5 seconds after the "
252                "YubiKey is inserted."
253            )
254        else:
255            cli_fail(f"Reset failed: {e.code.name}")
256    except ApduError as e:  # From fips_reset
257        logger.error("Reset failed", exc_info=e)
258        if e.code == SW.COMMAND_NOT_ALLOWED:
259            cli_fail(
260                "Reset failed. Reset must be triggered within 5 seconds after the "
261                "YubiKey is inserted."
262            )
263        else:
264            cli_fail("Reset failed.")
265    except Exception as e:
266        logger.error(e)
267        cli_fail("Reset failed.")
268
269
270def _fail_pin_error(ctx, e, other="%s"):
271    if e.code == CtapError.ERR.PIN_INVALID:
272        cli_fail("Wrong PIN.")
273    elif e.code == CtapError.ERR.PIN_AUTH_BLOCKED:
274        cli_fail(
275            "PIN authentication is currently blocked. "
276            "Remove and re-insert the YubiKey."
277        )
278    elif e.code == CtapError.ERR.PIN_BLOCKED:
279        cli_fail("PIN is blocked.")
280    else:
281        cli_fail(other % e.code)
282
283
284@fido.group("access")
285def access():
286    """
287    Manage the PIN for FIDO.
288    """
289
290
291@access.command("change-pin")
292@click.pass_context
293@click.option("-P", "--pin", help="Current PIN code.")
294@click.option("-n", "--new-pin", help="A new PIN.")
295@click.option(
296    "-u", "--u2f", is_flag=True, help="Set FIDO U2F PIN instead of FIDO2 PIN."
297)
298def change_pin(ctx, pin, new_pin, u2f):
299    """
300    Set or change the PIN code.
301
302    The FIDO2 PIN must be at least 4 characters long, and supports any type
303    of alphanumeric characters.
304
305    On YubiKey FIPS, a PIN can be set for FIDO U2F. That PIN must be at least
306    6 characters long.
307    """
308
309    is_fips = is_fips_version(ctx.obj["info"].version)
310
311    if is_fips and not u2f:
312        cli_fail("This is a YubiKey FIPS. To set the U2F PIN, pass the --u2f option.")
313
314    if u2f and not is_fips:
315        cli_fail(
316            "This is not a YubiKey FIPS, and therefore does not support a U2F PIN. "
317            "To set the FIDO2 PIN, remove the --u2f option."
318        )
319
320    if is_fips:
321        conn = ctx.obj["conn"]
322    else:
323        ctap2 = ctx.obj.get("ctap2")
324        if not ctap2:
325            cli_fail("PIN is not supported on this YubiKey.")
326        client_pin = ClientPin(ctap2)
327
328    def prompt_new_pin():
329        return click_prompt(
330            "Enter your new PIN",
331            hide_input=True,
332            confirmation_prompt=True,
333        )
334
335    def change_pin(pin, new_pin):
336        if pin is not None:
337            _fail_if_not_valid_pin(ctx, pin, is_fips)
338        try:
339            if is_fips:
340                try:
341                    # Failing this with empty current PIN does not cost a retry
342                    fips_change_pin(conn, pin or "", new_pin)
343                except ApduError as e:
344                    if e.code == SW.WRONG_LENGTH:
345                        pin = _prompt_current_pin()
346                        _fail_if_not_valid_pin(ctx, pin, is_fips)
347                        fips_change_pin(conn, pin, new_pin)
348                    else:
349                        raise
350
351            else:
352                client_pin.change_pin(pin, new_pin)
353
354        except CtapError as e:
355            logger.error("Failed to change PIN", exc_info=e)
356            if e.code == CtapError.ERR.PIN_POLICY_VIOLATION:
357                cli_fail("New PIN doesn't meet policy requirements.")
358            else:
359                _fail_pin_error(ctx, e, "Failed to change PIN: %s")
360
361        except ApduError as e:
362            logger.error("Failed to change PIN", exc_info=e)
363            if e.code == SW.VERIFY_FAIL_NO_RETRY:
364                cli_fail("Wrong PIN.")
365            elif e.code == SW.AUTH_METHOD_BLOCKED:
366                cli_fail("PIN is blocked.")
367            else:
368                cli_fail(f"Failed to change PIN: SW={e.code:04x}")
369
370    def set_pin(new_pin):
371        _fail_if_not_valid_pin(ctx, new_pin, is_fips)
372        try:
373            client_pin.set_pin(new_pin)
374        except CtapError as e:
375            logger.error("Failed to set PIN", exc_info=e)
376            if e.code == CtapError.ERR.PIN_POLICY_VIOLATION:
377                cli_fail("PIN is too long.")
378            else:
379                cli_fail(f"Failed to set PIN: {e.code}")
380
381    if not is_fips:
382        if ctap2.info.options.get("clientPin"):
383            if not pin:
384                pin = _prompt_current_pin()
385        else:
386            if pin:
387                cli_fail("There is no current PIN set. Use --new-pin to set one.")
388
389    if not new_pin:
390        new_pin = prompt_new_pin()
391
392    if is_fips:
393        _fail_if_not_valid_pin(ctx, new_pin, is_fips)
394        change_pin(pin, new_pin)
395    else:
396        if len(new_pin) < ctap2.info.min_pin_length:
397            cli_fail("New PIN is too short.")
398        if ctap2.info.options.get("clientPin"):
399            change_pin(pin, new_pin)
400        else:
401            set_pin(new_pin)
402
403
404def _require_pin(ctx, pin, feature="This feature"):
405    ctap2 = ctx.obj.get("ctap2")
406    if not ctap2:
407        cli_fail(f"{feature} is not supported on this YubiKey.")
408    if not ctap2.info.options.get("clientPin"):
409        cli_fail(f"{feature} requires having a PIN. Set a PIN first.")
410    if ctap2.info.force_pin_change:
411        cli_fail("The FIDO PIN is blocked. Change the PIN first.")
412    if pin is None:
413        pin = _prompt_current_pin(prompt="Enter your PIN")
414    return pin
415
416
417@access.command("verify-pin")
418@click.pass_context
419@click.option("-P", "--pin", help="Current PIN code.")
420def verify(ctx, pin):
421    """
422    Verify the FIDO PIN against a YubiKey.
423
424    For YubiKeys supporting FIDO2 this will reset the "retries" counter of the PIN.
425    For YubiKey FIPS this will unlock the session, allowing U2F registration.
426    """
427
428    ctap2 = ctx.obj.get("ctap2")
429    if ctap2:
430        pin = _require_pin(ctx, pin)
431        client_pin = ClientPin(ctap2)
432        try:
433            # Get a PIN token to verify the PIN.
434            client_pin.get_pin_token(
435                pin, ClientPin.PERMISSION.GET_ASSERTION, "ykman.example.com"
436            )
437        except CtapError as e:
438            logger.error("PIN verification failed", exc_info=e)
439            cli_fail(f"Error: {e}")
440    elif is_fips_version(ctx.obj["info"].version):
441        _fail_if_not_valid_pin(ctx, pin, True)
442        try:
443            fips_verify_pin(ctx.obj["conn"], pin)
444        except ApduError as e:
445            logger.error("PIN verification failed", exc_info=e)
446            if e.code == SW.VERIFY_FAIL_NO_RETRY:
447                cli_fail("Wrong PIN.")
448            elif e.code == SW.AUTH_METHOD_BLOCKED:
449                cli_fail("PIN is blocked.")
450            elif e.code == SW.COMMAND_NOT_ALLOWED:
451                cli_fail("PIN is not set.")
452            else:
453                cli_fail(f"PIN verification failed: {e.code.name}")
454    else:
455        cli_fail("This YubiKey does not support a FIDO PIN.")
456    click.echo("PIN verified.")
457
458
459def _prompt_current_pin(prompt="Enter your current PIN"):
460    return click_prompt(prompt, hide_input=True)
461
462
463def _fail_if_not_valid_pin(ctx, pin=None, is_fips=False):
464    min_length = FIPS_PIN_MIN_LENGTH if is_fips else PIN_MIN_LENGTH
465    if not pin or len(pin) < min_length:
466        ctx.fail(f"PIN must be over {min_length} characters long")
467
468
469def _gen_creds(credman):
470    data = credman.get_metadata()
471    if data.get(CredentialManagement.RESULT.EXISTING_CRED_COUNT) == 0:
472        return  # No credentials
473    for rp in credman.enumerate_rps():
474        for cred in credman.enumerate_creds(rp[CredentialManagement.RESULT.RP_ID_HASH]):
475            yield (
476                rp[CredentialManagement.RESULT.RP]["id"],
477                cred[CredentialManagement.RESULT.CREDENTIAL_ID],
478                cred[CredentialManagement.RESULT.USER]["id"],
479                cred[CredentialManagement.RESULT.USER]["name"],
480            )
481
482
483def _format_cred(rp_id, user_id, user_name):
484    return f"{rp_id} {user_id.hex()} {user_name}"
485
486
487@fido.group("credentials")
488def creds():
489    """
490    Manage discoverable (resident) credentials.
491
492    This command lets you manage credentials stored on your YubiKey.
493    Credential management is only available when a FIDO PIN is set on the YubiKey.
494
495    \b
496    Examples:
497
498    \b
499      List credentials (providing PIN via argument):
500      $ ykman fido credentials list --pin 123456
501
502    \b
503      Delete a credential by user name (PIN will be prompted for):
504      $ ykman fido credentials delete example_user
505    """
506
507
508def _init_credman(ctx, pin):
509    pin = _require_pin(ctx, pin, "Credential Management")
510
511    ctap2 = ctx.obj.get("ctap2")
512    client_pin = ClientPin(ctap2)
513    try:
514        token = client_pin.get_pin_token(pin, ClientPin.PERMISSION.CREDENTIAL_MGMT)
515    except CtapError as e:
516        logger.error("Ctap error", exc_info=e)
517        _fail_pin_error(ctx, e, "PIN error: %s")
518
519    return CredentialManagement(ctap2, client_pin.protocol, token)
520
521
522@creds.command("list")
523@click.pass_context
524@click.option("-P", "--pin", help="PIN code.")
525def creds_list(ctx, pin):
526    """
527    List credentials.
528    """
529    creds = _init_credman(ctx, pin)
530    for (rp_id, _, user_id, user_name) in _gen_creds(creds):
531        click.echo(_format_cred(rp_id, user_id, user_name))
532
533
534@creds.command("delete")
535@click.pass_context
536@click.argument("query")
537@click.option("-P", "--pin", help="PIN code.")
538@click.option("-f", "--force", is_flag=True, help="Confirm deletion without prompting")
539def creds_delete(ctx, query, pin, force):
540    """
541    Delete a credential.
542
543    \b
544    QUERY       A unique substring match of a credentials RP ID, user ID (hex) or name,
545                or credential ID.
546    """
547    credman = _init_credman(ctx, pin)
548
549    hits = [
550        (rp_id, cred_id, user_id, user_name)
551        for (rp_id, cred_id, user_id, user_name) in _gen_creds(credman)
552        if query.lower() in user_name.lower()
553        or query.lower() in rp_id.lower()
554        or user_id.hex().startswith(query.lower())
555        or query.lower() in _format_cred(rp_id, user_id, user_name)
556    ]
557    if len(hits) == 0:
558        cli_fail("No matches, nothing to be done.")
559    elif len(hits) == 1:
560        (rp_id, cred_id, user_id, user_name) = hits[0]
561        if force or click.confirm(
562            f"Delete credential {_format_cred(rp_id, user_id, user_name)}?"
563        ):
564            try:
565                credman.delete_cred(cred_id)
566            except CtapError as e:
567                logger.error("Failed to delete resident credential", exc_info=e)
568                cli_fail("Failed to delete resident credential.")
569    else:
570        cli_fail("Multiple matches, make the query more specific.")
571
572
573@fido.group("fingerprints")
574def bio():
575    """
576    Manage fingerprints.
577
578    Requires a YubiKey with fingerprint sensor.
579    Fingerprint management is only available when a FIDO PIN is set on the YubiKey.
580
581    \b
582    Examples:
583
584    \b
585      Register a new fingerprint (providing PIN via argument):
586      $ ykman fido fingerprints add "Left thumb" --pin 123456
587
588    \b
589      List already stored fingerprints (providing PIN via argument):
590      $ ykman fido fingerprints list --pin 123456
591
592    \b
593      Delete a stored fingerprint with ID "f691" (PIN will be prompted for):
594      $ ykman fido fingerprints delete f691
595
596    """
597
598
599def _init_bio(ctx, pin):
600    ctap2 = ctx.obj.get("ctap2")
601    if not ctap2 or "bioEnroll" not in ctap2.info.options:
602        cli_fail("Biometrics is not supported on this YubiKey.")
603    pin = _require_pin(ctx, pin, "Biometrics")
604
605    client_pin = ClientPin(ctap2)
606    try:
607        token = client_pin.get_pin_token(pin, ClientPin.PERMISSION.BIO_ENROLL)
608    except CtapError as e:
609        logger.error("Ctap error", exc_info=e)
610        _fail_pin_error(ctx, e, "PIN error: %s")
611
612    return FPBioEnrollment(ctap2, client_pin.protocol, token)
613
614
615def _format_fp(template_id, name):
616    return f"{template_id.hex()}{f' ({name})' if name else ''}"
617
618
619@bio.command("list")
620@click.pass_context
621@click.option("-P", "--pin", help="PIN code.")
622def bio_list(ctx, pin):
623    """
624    List registered fingerprint.
625
626    Lists fingerprints by ID and (if available) label.
627    """
628    bio = _init_bio(ctx, pin)
629
630    for t_id, name in bio.enumerate_enrollments().items():
631        click.echo(f"ID: {_format_fp(t_id, name)}")
632
633
634@bio.command("add")
635@click.pass_context
636@click.argument("name")
637@click.option("-P", "--pin", help="PIN code.")
638def bio_enroll(ctx, name, pin):
639    """
640    Add a new fingerprint.
641
642    \b
643    NAME        A short readable name for the fingerprint (eg. "Left thumb").
644    """
645    if len(name.encode()) > 15:
646        ctx.fail("Fingerprint name must be a maximum of 15 characters")
647    bio = _init_bio(ctx, pin)
648
649    enroller = bio.enroll()
650    template_id = None
651    while template_id is None:
652        click.echo("Place your finger against the sensor now...")
653        try:
654            template_id = enroller.capture()
655            remaining = enroller.remaining
656            if remaining:
657                click.echo(f"{remaining} more scans needed.")
658        except CaptureError as e:
659            logger.error(f"Capture error: {e.code}")
660            click.echo("Capture failed. Re-center your finger, and try again.")
661        except CtapError as e:
662            logger.error("Failed to add fingerprint template", exc_info=e)
663            if e.code == CtapError.ERR.FP_DATABASE_FULL:
664                cli_fail(
665                    "Fingerprint storage full. "
666                    "Remove some fingerprints before adding new ones."
667                )
668            elif e.code == CtapError.ERR.USER_ACTION_TIMEOUT:
669                cli_fail("Failed to add fingerprint due to user inactivity.")
670            cli_fail(f"Failed to add fingerprint: {e.code.name}")
671    click.echo("Capture complete.")
672    bio.set_name(template_id, name)
673
674
675@bio.command("rename")
676@click.pass_context
677@click.argument("template_id", metavar="ID")
678@click.argument("name")
679@click.option("-P", "--pin", help="PIN code.")
680def bio_rename(ctx, template_id, name, pin):
681    """
682    Set the label for a fingerprint.
683
684    \b
685    ID          The ID of the fingerprint to rename (as shown in "list").
686    NAME        A short readable name for the fingerprint (eg. "Left thumb").
687    """
688    if len(name.encode()) >= 16:
689        ctx.fail("Fingerprint name must be a maximum of 15 bytes")
690
691    bio = _init_bio(ctx, pin)
692    enrollments = bio.enumerate_enrollments()
693
694    key = bytes.fromhex(template_id)
695    if key not in enrollments:
696        cli_fail(f"No fingerprint matching ID={template_id}.")
697
698    bio.set_name(key, name)
699
700
701@bio.command("delete")
702@click.pass_context
703@click.argument("template_id", metavar="ID")
704@click.option("-P", "--pin", help="PIN code.")
705@click.option("-f", "--force", is_flag=True, help="Confirm deletion without prompting")
706def bio_delete(ctx, template_id, pin, force):
707    """
708    Delete a fingerprint.
709
710    Delete a fingerprint from the YubiKey by its ID, which can be seen by running the
711    "list" subcommand.
712    """
713    bio = _init_bio(ctx, pin)
714    enrollments = bio.enumerate_enrollments()
715
716    try:
717        key: Optional[bytes] = bytes.fromhex(template_id)
718    except ValueError:
719        key = None
720
721    if key not in enrollments:
722        # Match using template_id as NAME
723        matches = [k for k in enrollments if enrollments[k] == template_id]
724        if len(matches) == 0:
725            cli_fail(f"No fingerprint matching ID={template_id}")
726        elif len(matches) > 1:
727            cli_fail(
728                f"Multiple matches for NAME={template_id}. "
729                "Delete by template ID instead."
730            )
731        key = matches[0]
732
733    name = enrollments[key]
734    if force or click.confirm(f"Delete fingerprint {_format_fp(key, name)}?"):
735        try:
736            bio.remove_enrollment(key)
737        except CtapError as e:
738            logger.error("Failed to delete fingerprint template", exc_info=e)
739            cli_fail(f"Failed to delete fingerprint: {e.code.name}")
740