# Copyright (c) 2023 Yubico AB # All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following # conditions are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following # disclaimer in the documentation and/or other materials provided # with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import logging import os import click from cryptography.hazmat.primitives import serialization from yubikit.core import TRANSPORT from yubikit.core.smartcard import SW, ApduError, SmartCardConnection from yubikit.hsmauth import ( ALGORITHM, CREDENTIAL_PASSWORD_LEN, MANAGEMENT_KEY_LEN, HsmAuthSession, InvalidPinError, ) from yubikit.management import CAPABILITY from ..hsmauth import ( generate_random_management_key, get_hsmauth_info, ) from ..util import InvalidPasswordError, parse_private_key from .util import ( CliFail, click_callback, click_force_option, click_format_option, click_group, click_postpone_execution, click_prompt, get_scp_params, log_or_echo, pretty_print, ) logger = logging.getLogger(__name__) def handle_error(e: Exception, default_exception_msg, target="Credential password"): if isinstance(e, InvalidPinError): attempts = e.attempts_remaining if attempts: raise CliFail(f"Wrong {target}, {attempts} attempts remaining.") else: raise CliFail(f"{target} is blocked.") elif isinstance(e, ApduError): if e.sw == SW.AUTH_METHOD_BLOCKED: raise CliFail("A credential with the provided label already exists.") elif e.sw == SW.NO_SPACE: raise CliFail("No space left on the YubiKey for YubiHSM Auth credentials.") elif e.sw == SW.FILE_NOT_FOUND: raise CliFail("Credential with the provided label was not found.") elif e.sw == SW.SECURITY_CONDITION_NOT_SATISFIED: raise CliFail("The device was not touched.") elif e.sw == SW.CONDITIONS_NOT_SATISFIED: raise CliFail(f"{target} does not meet complexity requirement.") elif isinstance(e, ValueError): raise # Re-raise, ValueErrors are handled elsewhere raise CliFail(default_exception_msg) def _parse_touch_required(touch_required: bool) -> str: if touch_required: return "On" else: return "Off" def _parse_algorithm(algorithm: ALGORITHM) -> str: if algorithm == ALGORITHM.AES128_YUBICO_AUTHENTICATION: return "Symmetric" else: return "Asymmetric" def _parse_hex(hex): try: return bytes.fromhex(hex) except Exception as e: raise ValueError(e) def _parse_key(key, key_len, key_type): key = _parse_hex(key) if len(key) != key_len: raise ValueError( f"must be exactly {key_len} bytes long ({key_len * 2} hexadecimal digits) " "long" ) return key def _parse_password(value, key_len, name): encoded = value.encode() if len(encoded) <= key_len: return encoded.ljust(key_len, b"\0") if len(encoded) == key_len * 2: return _parse_hex(value) raise ValueError(f"{name} must be at most 16 bytes") @click_callback() def click_parse_management_key(ctx, param, val): return _parse_key(val, MANAGEMENT_KEY_LEN, "Management key") @click_callback() def click_parse_management_password(ctx, param, val): return _parse_password(val, MANAGEMENT_KEY_LEN, "Management password") @click_callback() def click_parse_credential_password(ctx, param, val): return _parse_password(val, CREDENTIAL_PASSWORD_LEN, "Credential password") @click_callback() def click_parse_enc_key(ctx, param, val): return _parse_key(val, ALGORITHM.AES128_YUBICO_AUTHENTICATION.key_len, "ENC key") @click_callback() def click_parse_mac_key(ctx, param, val): return _parse_key(val, ALGORITHM.AES128_YUBICO_AUTHENTICATION.key_len, "MAC key") @click_callback() def click_parse_card_crypto(ctx, param, val): return _parse_hex(val) @click_callback() def click_parse_context(ctx, param, val): return _parse_hex(val) def _prompt_management_key(prompt="Enter management password", confirm=False): management_password = click_prompt( prompt, default="", hide_input=True, show_default=False, confirmation_prompt=confirm, ) return _parse_password( management_password, MANAGEMENT_KEY_LEN, "Management password" ) def _prompt_credential_password(prompt="Enter credential password", confirm=False): credential_password = click_prompt( prompt, hide_input=True, confirmation_prompt=confirm, ) return _parse_password( credential_password, CREDENTIAL_PASSWORD_LEN, "Credential password" ) def _prompt_symmetric_key(name): symmetric_key = click_prompt(f"Enter {name}") return _parse_key( symmetric_key, ALGORITHM.AES128_YUBICO_AUTHENTICATION.key_len, name ) def _fname(fobj): return getattr(fobj, "name", fobj) click_credential_password_option = click.option( "-c", "--credential-password", help="password to protect credential", callback=click_parse_credential_password, ) click_management_key_option = click.option( "-m", "--management-password", "--management-key", "management_key", help="the management password", callback=click_parse_management_password, ) click_touch_option = click.option( "-t", "--touch", is_flag=True, help="require touch on YubiKey to access credential" ) @click_group(connections=[SmartCardConnection]) @click.pass_context @click_postpone_execution def hsmauth(ctx): """ Manage the YubiHSM Auth application """ dev = ctx.obj["device"] conn = dev.open_connection(SmartCardConnection) ctx.call_on_close(conn.close) scp_params = get_scp_params(ctx, CAPABILITY.HSMAUTH, conn) ctx.obj["session"] = HsmAuthSession(conn, scp_params) try: if not scp_params and dev.transport == TRANSPORT.NFC: # Dummy command to test access ctx.obj["session"].get_management_key_retries() except ApduError as e: if e.sw == SW.CONDITIONS_NOT_SATISFIED: raise CliFail("Unable to manage HSMAuth over NFC without SCP.") raise info = ctx.obj["info"] ctx.obj["fips_unready"] = ( CAPABILITY.HSMAUTH in info.fips_capable and CAPABILITY.HSMAUTH not in info.fips_approved ) @hsmauth.command() @click.pass_context def info(ctx): """ Display general status of the YubiHSM Auth application. """ info = ctx.obj["info"] data = get_hsmauth_info(ctx.obj["session"]) if CAPABILITY.HSMAUTH in info.fips_capable: # This is a bit ugly as it makes assumptions about the structure of data data["FIPS approved"] = CAPABILITY.HSMAUTH in info.fips_approved click.echo("\n".join(pretty_print(data))) @hsmauth.command() @click.pass_context @click_force_option def reset(ctx, force): """ Reset all YubiHSM Auth data. This action will wipe all data and restore factory setting for the YubiHSM Auth application on the YubiKey. """ if not force: click.confirm( "WARNING! This will delete all stored YubiHSM Auth data and restore " "factory setting. Proceed?", abort=True, err=True, ) click.echo("Resetting YubiHSM Auth data...") ctx.obj["session"].reset() click.echo( "Reset complete. All YubiHSM Auth data has been cleared from the YubiKey." ) click.echo("Your YubiKey now has an empty Management password.") @hsmauth.group() def credentials(): """Manage YubiHSM Auth credentials.""" @credentials.command() @click.pass_context def list(ctx): """ List all credentials. List all credentials stored on the YubiKey. """ session = ctx.obj["session"] creds = session.list_credentials() if len(creds) == 0: click.echo("No items found") else: click.echo(f"Found {len(creds)} item(s)") max_size_label = max(len(cred.label) for cred in creds) max_size_type = ( 10 if any( c.algorithm == ALGORITHM.EC_P256_YUBICO_AUTHENTICATION for c in creds ) else 9 ) format_str = "{0: <{label_width}}\t{1: <{type_width}}\t{2}\t{3}" click.echo( format_str.format( "Label", "Type", "Touch", "Retries", label_width=max_size_label, type_width=max_size_type, ) ) for cred in creds: click.echo( format_str.format( cred.label, _parse_algorithm(cred.algorithm), _parse_touch_required(cred.touch_required), cred.counter, label_width=max_size_label, type_width=max_size_type, ) ) @credentials.command() @click.pass_context @click.argument("label") @click_credential_password_option @click_management_key_option @click_touch_option def generate(ctx, label, credential_password, management_key, touch): """Generate an asymmetric credential. This will generate an asymmetric YubiHSM Auth credential (private key) on the YubiKey. \b LABEL label for the YubiHSM Auth credential """ if ctx.obj["fips_unready"]: raise CliFail( "YubiKey FIPS must be in FIPS approved mode prior to adding credentials." ) if management_key is None: management_key = _prompt_management_key() if credential_password is None: credential_password = _prompt_credential_password() session = ctx.obj["session"] try: session.generate_credential_asymmetric( management_key, label, credential_password, touch ) click.echo("Asymmetric credential generated.") except Exception as e: handle_error( e, default_exception_msg="Failed to generate asymmetric credential." ) @credentials.command("import") @click.pass_context @click.argument("label") @click.argument("private-key", type=click.File("rb"), metavar="PRIVATE-KEY") @click.option("-p", "--password", help="password used to decrypt the private key") @click_credential_password_option @click_management_key_option @click_touch_option def import_credential( ctx, label, private_key, password, credential_password, management_key, touch ): """Import an asymmetric credential. This will import a private key as an asymmetric YubiHSM Auth credential to the YubiKey. \b LABEL label for the YubiHSM Auth credential PRIVATE-KEY file containing the private key (use '-' to use stdin) """ if ctx.obj["fips_unready"]: raise CliFail( "YubiKey FIPS must be in FIPS approved mode prior to adding credentials." ) if management_key is None: management_key = _prompt_management_key() if credential_password is None: credential_password = _prompt_credential_password() session = ctx.obj["session"] data = private_key.read() while True: if password is not None: password = password.encode() try: private_key = parse_private_key(data, password) except InvalidPasswordError: logger.debug("Error parsing key", exc_info=True) if password is None: password = click_prompt( "Enter password to decrypt key", default="", hide_input=True, show_default=False, ) continue else: password = None click.echo("Wrong password.") continue break try: session.put_credential_asymmetric( management_key, label, private_key, credential_password, touch, ) click.echo("Asymmetric credential imported.") except Exception as e: handle_error(e, default_exception_msg="Failed to import asymmetric credential.") @credentials.command() @click.pass_context @click.argument("label") @click.argument("public-key-output", type=click.File("wb"), metavar="PUBLIC-KEY") @click_format_option def export(ctx, label, public_key_output, format): """Export the public key corresponding to an asymmetric credential. This will export the long-term public key corresponding to the asymmetric YubiHSM Auth credential stored on the YubiKey. \b LABEL label for the YubiHSM Auth credential PUBLIC-KEY file to write the public key to (use '-' to use stdout) """ session = ctx.obj["session"] try: public_key = session.get_public_key(label) key_encoding = format public_key_encoded = public_key.public_bytes( encoding=key_encoding, format=serialization.PublicFormat.SubjectPublicKeyInfo, ) public_key_output.write(public_key_encoded) log_or_echo( f"Public key for {label} written to {_fname(public_key_output)}", logger, public_key_output, ) except ApduError as e: if e.sw == SW.AUTH_METHOD_BLOCKED: raise CliFail("The entry is not an asymmetric credential.") elif e.sw == SW.FILE_NOT_FOUND: raise CliFail("Credential not found.") else: raise CliFail("Unable to export public key.") @credentials.command() @click.pass_context @click.argument("label") @click.option("-E", "--enc-key", help="the ENC key", callback=click_parse_enc_key) @click.option("-M", "--mac-key", help="the MAC key", callback=click_parse_mac_key) @click.option( "-g", "--generate", is_flag=True, help="generate a random encryption and mac key" ) @click_credential_password_option @click_management_key_option @click_touch_option def symmetric( ctx, label, credential_password, management_key, enc_key, mac_key, generate, touch ): """Import a symmetric credential. This will import an encryption and mac key as a symmetric YubiHSM Auth credential on the YubiKey. \b LABEL label for the YubiHSM Auth credential """ if ctx.obj["fips_unready"]: raise CliFail( "YubiKey FIPS must be in FIPS approved mode prior to adding credentials." ) if management_key is None: management_key = _prompt_management_key() if credential_password is None: credential_password = _prompt_credential_password() if generate and (enc_key or mac_key): ctx.fail("--enc-key and --mac-key cannot be combined with --generate") if generate: enc_key = os.urandom(ALGORITHM.AES128_YUBICO_AUTHENTICATION.key_len) mac_key = os.urandom(ALGORITHM.AES128_YUBICO_AUTHENTICATION.key_len) click.echo("Generated ENC and MAC keys:") click.echo("\n".join(pretty_print({"ENC-KEY": enc_key, "MAC-KEY": mac_key}))) if not enc_key: enc_key = _prompt_symmetric_key("ENC key") if not mac_key: mac_key = _prompt_symmetric_key("MAC key") session = ctx.obj["session"] try: session.put_credential_symmetric( management_key, label, enc_key, mac_key, credential_password, touch, ) click.echo("Symmetric credential stored.") except Exception as e: handle_error(e, default_exception_msg="Failed to import symmetric credential.") @credentials.command() @click.pass_context @click.argument("label") @click.option( "-d", "--derivation-password", help="deriviation password for ENC and MAC keys" ) @click_credential_password_option @click_management_key_option @click_touch_option def derive(ctx, label, derivation_password, credential_password, management_key, touch): """Import a symmetric credential derived from a password. This will import a symmetric YubiHSM Auth credential by deriving ENC and MAC keys from a password. \b LABEL label for the YubiHSM Auth credential """ if ctx.obj["fips_unready"]: raise CliFail( "YubiKey FIPS must be in FIPS approved mode prior to adding credentials." ) if management_key is None: management_key = _prompt_management_key() if credential_password is None: credential_password = _prompt_credential_password() if derivation_password is None: derivation_password = click_prompt( "Enter derivation password", hide_input=True, confirmation_prompt=True, ) session = ctx.obj["session"] try: session.put_credential_derived( management_key, label, derivation_password, credential_password, touch ) click.echo("Derived symmetric credential stored.") except Exception as e: handle_error(e, default_exception_msg="Failed to import symmetric credential.") @credentials.command() @click.pass_context @click.argument("label") @click_management_key_option @click_force_option def delete(ctx, label, management_key, force): """ Delete a credential. This will delete a YubiHSM Auth credential from the YubiKey. \b LABEL a label to match a single credential (as shown in "list") """ if management_key is None: management_key = _prompt_management_key() if not force: click.confirm( f"Delete credential: {label} ?", abort=True, err=True, ) session = ctx.obj["session"] try: session.delete_credential(management_key, label) click.echo("Credential deleted.") except Exception as e: handle_error( e, default_exception_msg="Failed to delete credential.", ) @credentials.command() @click.pass_context @click.argument("label") @click.option( "-c", "--credential-password", help="current credential password", callback=click_parse_credential_password, ) @click_management_key_option @click.option( "-n", "--new-credential-password", help="a new credential password to set", callback=click_parse_credential_password, ) def change_password( ctx, label, credential_password, management_key, new_credential_password ): """ Change the password of a credential. This will change the password of a YubiHSM Auth credential stored on the YubiKey. Changing a password requires either the current password for the credential, or the management password (not both). \b LABEL a label to match a single credential (as shown in "list") """ session = ctx.obj["session"] if credential_password and management_key: ctx.fail("--credential-password and --management-password can't be combined") if management_key is None and credential_password is None: click.echo("Choose authentication method:") click.echo(" [1] Current credential password") click.echo(" [2] Management password") method = click_prompt( "Enter selection", type=click.Choice([1, 2]), default=1, show_choices=False, ) if method == 1: credential_password = _prompt_credential_password( "Enter current credential password" ) else: management_key = _prompt_management_key() use_management_key = management_key is not None if new_credential_password is None: new_credential_password = _prompt_credential_password( "Enter the new credential password", confirm=True ) try: if use_management_key: session.change_credential_password_admin( label, management_key, new_credential_password ) else: session.change_credential_password( label, credential_password, new_credential_password ) click.echo("Credential password changed.") except Exception as e: handle_error( e, default_exception_msg="Failed to change credential password.", target="Management password" if use_management_key else "Credential password", ) @hsmauth.group() def access(): """Manage Management Key for YubiHSM Auth""" @access.command(hidden=True) @click.pass_context @click.option( "-m", "--management-key", help="current management key", show_default=True, callback=click_parse_management_key, ) @click.option( "-n", "--new-management-key", help="a new management key to set", callback=click_parse_management_key, ) @click.option( "-g", "--generate", is_flag=True, help="generate a random management key (can't be used with --new-management-key)", ) def change_management_key(ctx, management_key, new_management_key, generate): """ Change the management key. Allows you to change the management key which is required to add and delete YubiHSM Auth credentials stored on the YubiKey. """ if management_key is None: management_key = _prompt_management_key( "Enter current management key [blank to use default key]" ) session = ctx.obj["session"] # Can't combine new key with generate. if new_management_key and generate: ctx.fail("Invalid options: --new-management-key conflicts with --generate") if new_management_key is None: if generate: if ctx.obj["info"].version >= (5, 8): raise CliFail( "Generating a random management key is not supported on this " "YubiKey." ) new_management_key = generate_random_management_key() click.echo(f"Generated management key: {new_management_key.hex()}") else: try: new_management_key = bytes.fromhex( click_prompt( "Enter the new management key", hide_input=True, confirmation_prompt=True, ) ) except Exception: ctx.fail("New management key has the wrong format.") if len(new_management_key) != MANAGEMENT_KEY_LEN: raise CliFail( "Management key has the wrong length (expected %d bytes)." % MANAGEMENT_KEY_LEN ) try: session.put_management_key(management_key, new_management_key) click.echo("Management key changed.") except Exception as e: handle_error( e, default_exception_msg="Failed to change management key.", target="Management key", ) @access.command() @click.pass_context @click.option( "-m", "--management-password", "management_key", help="current management password", show_default=True, callback=click_parse_management_password, ) @click.option( "-n", "--new-management-password", "new_management_key", help="a new management password to set", callback=click_parse_management_password, ) def change_management_password(ctx, management_key, new_management_key): """ Change the management key. Allows you to change the management key which is required to add and delete YubiHSM Auth credentials stored on the YubiKey. """ if management_key is None: management_key = _prompt_management_key( "Enter your current management password", ) if new_management_key is None: new_management_key = _prompt_management_key( "Enter a new management password", confirm=True ) session = ctx.obj["session"] try: session.put_management_key(management_key, new_management_key) click.echo("Management password changed.") except Exception as e: handle_error( e, default_exception_msg="Failed to change management password.", target="Management password", )