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