# Copyright (c) 2015 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 from typing import Any import click from yubikit.core import TRANSPORT from yubikit.core.smartcard import SW, ApduError, SmartCardConnection from yubikit.management import CAPABILITY from yubikit.oath import ( HASH_ALGORITHM, OATH_TYPE, CredentialData, OathSession, _format_cred_id, parse_b32_key, ) from ..oath import calculate_steam, delete_broken_credential, is_hidden, is_steam from ..settings import AppData from .util import ( CliFail, EnumChoice, click_callback, click_force_option, click_group, click_parse_b32_key, click_postpone_execution, click_prompt, get_scp_params, is_yk4_fips, pretty_print, prompt_for_touch, prompt_timeout, ) logger = logging.getLogger(__name__) @click_group(connections=[SmartCardConnection]) @click.pass_context @click_postpone_execution def oath(ctx): """ Manage the OATH application. Examples: \b Generate codes for accounts starting with 'yubi': $ ykman oath accounts code yubi \b Add an account with the secret key f5up4ub3dw and the name yubico, which requires touch: $ ykman oath accounts add yubico f5up4ub3dw --touch \b Set a password for the OATH application: $ ykman oath access change """ dev = ctx.obj["device"] conn = dev.open_connection(SmartCardConnection) ctx.call_on_close(conn.close) scp_params = get_scp_params(ctx, CAPABILITY.OATH, conn) ctx.obj["session"] = OathSession(conn, scp_params) ctx.obj["oath_keys"] = AppData("oath_keys") info = ctx.obj["info"] is_fips = CAPABILITY.OATH in info.fips_capable ctx.obj["fips_unready"] = is_fips and CAPABILITY.OATH not in info.fips_approved ctx.obj["no_scp"] = is_fips and dev.transport == TRANSPORT.NFC and not scp_params @oath.command() @click.pass_context def info(ctx): """ Display general status of the OATH application. """ session = ctx.obj["session"] info = ctx.obj["info"] data: dict[str, Any] = {"OATH version": "%d.%d.%d" % session.version} lines: list[Any] = [data] if CAPABILITY.OATH in info.fips_capable: # This is a bit ugly as it makes assumptions about the structure of data data["FIPS approved"] = CAPABILITY.OATH in info.fips_approved elif is_yk4_fips(info): data["FIPS approved"] = session.locked data["Password protection"] = "enabled" if session.locked else "disabled" keys = ctx.obj["oath_keys"] if session.locked and session.device_id in keys: lines.append("The password for this YubiKey is remembered by ykman.") click.echo("\n".join(pretty_print(lines))) @oath.command() @click.pass_context @click_force_option def reset(ctx, force): """ Reset all OATH data. This action will delete all accounts and restore factory settings for the OATH application on the YubiKey. """ force or click.confirm( "WARNING! This will delete all stored OATH accounts and restore factory " "settings. Proceed?", abort=True, err=True, ) session = ctx.obj["session"] click.echo("Resetting OATH data...") old_id = session.device_id session.reset() keys = ctx.obj["oath_keys"] if old_id in keys: del keys[old_id] keys.write() logger.info("Deleted remembered access key") click.echo("Reset complete. All OATH accounts have been deleted from the YubiKey.") click_password_option = click.option( "-p", "--password", help="the password to unlock the YubiKey" ) click_remember_option = click.option( "-r", "--remember", is_flag=True, help="remember the password on this machine", ) def _validate(ctx, key, remember): session = ctx.obj["session"] keys = ctx.obj["oath_keys"] session.validate(key) if remember: keys.put_secret(session.device_id, key.hex()) keys.write() logger.info("Access key remembered") click.echo("Password remembered.") def _init_session(ctx, password, remember, prompt="Enter the password"): session = ctx.obj["session"] keys = ctx.obj["oath_keys"] device_id = session.device_id if session.locked: try: # Use password, if given as argument if password: logger.debug("Access key required, using provided password") key = session.derive_key(password) _validate(ctx, key, remember) return # Use stored key, if available if device_id in keys: logger.debug("Access key required, using remembered key") try: key = bytes.fromhex(keys.get_secret(device_id)) _validate(ctx, key, False) return except ApduError as e: # Delete wrong key and fall through to prompt if e.sw == SW.INCORRECT_PARAMETERS: logger.debug("Remembered key incorrect, deleting key") del keys[device_id] keys.write() except Exception as e: # Other error, fall though to prompt logger.warning("Error authenticating", exc_info=e) # Prompt for password password = click_prompt(prompt, hide_input=True) key = session.derive_key(password) _validate(ctx, key, remember) except ApduError: raise CliFail("Authentication to the YubiKey failed. Wrong password?") elif password: raise CliFail("Password provided, but no password is set.") def _fail_scp(ctx, e): if ctx.obj["no_scp"] and e.sw == SW.CONDITIONS_NOT_SATISFIED: raise CliFail("Unable to manage OATH over NFC without SCP") raise e @oath.group() def access(): """Manage password protection for OATH.""" @access.command() @click.pass_context @click_password_option @click.option( "-c", "--clear", is_flag=True, help="remove the current password", ) @click.option("-n", "--new-password", help="provide a new password as an argument") @click_remember_option def change(ctx, password, clear, new_password, remember): """ Change the password used to protect OATH accounts. Allows you to set or change a password that will be required to access the OATH accounts stored on the YubiKey. """ if clear: if new_password: raise CliFail("--clear cannot be combined with --new-password.") info = ctx.obj["info"] if CAPABILITY.OATH in info.fips_capable: raise CliFail("Removing the password is not allowed on YubiKey FIPS.") _init_session(ctx, password, False, prompt="Enter the current password") session = ctx.obj["session"] keys = ctx.obj["oath_keys"] device_id = session.device_id if clear: session.unset_key() if device_id in keys: del keys[device_id] keys.write() logger.info("Deleted remembered access key") click.echo("Password cleared from YubiKey.") else: if remember: try: keys.ensure_unlocked() except ValueError: raise CliFail( "Failed to remember password, the keyring is locked or unavailable." ) if not new_password: new_password = click_prompt( "Enter the new password", hide_input=True, confirmation_prompt=True ) key = session.derive_key(new_password) if remember: keys.put_secret(device_id, key.hex()) keys.write() click.echo("Password remembered.") elif device_id in keys: del keys[device_id] keys.write() try: session.set_key(key) click.echo("Password updated.") except ApduError as e: _fail_scp(ctx, e) @access.command() @click.pass_context @click_password_option def remember(ctx, password): """ Store the YubiKeys password on this computer to avoid having to enter it on each use. """ session = ctx.obj["session"] device_id = session.device_id keys = ctx.obj["oath_keys"] if not session.locked: if device_id in keys: del keys[session.device_id] keys.write() logger.info("Deleted remembered access key") click.echo("This YubiKey is not password protected.") else: try: keys.ensure_unlocked() except ValueError: raise CliFail( "Failed to remember password, the keyring is locked or unavailable." ) if not password: password = click_prompt("Enter the password", hide_input=True) key = session.derive_key(password) try: _validate(ctx, key, True) click.echo("Password remembered.") except Exception: raise CliFail("Authentication to the YubiKey failed. Wrong password?") def _clear_all_passwords(ctx, param, value): if not value or ctx.resilient_parsing: return keys = AppData("oath_keys") if keys: keys.clear() keys.write() click.echo("All passwords have been forgotten.") ctx.exit() @access.command() @click.pass_context @click.option( "-a", "--all", is_flag=True, is_eager=True, expose_value=False, callback=_clear_all_passwords, help="remove all stored passwords", ) def forget(ctx): """ Remove a stored password from this computer. """ session = ctx.obj["session"] device_id = session.device_id keys = ctx.obj["oath_keys"] if device_id in keys: del keys[session.device_id] keys.write() logger.info("Deleted remembered access key") click.echo("Password forgotten.") else: click.echo("No password stored for this YubiKey.") click_touch_option = click.option( "-t", "--touch", is_flag=True, help="require touch on YubiKey to generate code" ) click_show_hidden_option = click.option( "-H", "--show-hidden", is_flag=True, help="include hidden accounts" ) def _string_id(credential): return credential.id.decode("utf-8") def _error_multiple_hits(ctx, hits): click.echo("Error: Multiple matches, make the query more specific.", err=True) click.echo("", err=True) for cred in hits: click.echo(_string_id(cred), err=True) ctx.exit(1) def _search(creds, query, show_hidden): hits = [] for c in creds: cred_id = _string_id(c) if not show_hidden and is_hidden(c): continue if cred_id == query: return [c] if query.lower() in cred_id.lower(): hits.append(c) return hits @oath.group() def accounts(): """Manage and use OATH accounts.""" @accounts.command() @click.argument("name") @click.argument("secret", callback=click_parse_b32_key, required=False) @click.option( "-o", "--oath-type", type=EnumChoice(OATH_TYPE), default=OATH_TYPE.TOTP.name, help="time-based (TOTP) or counter-based (HOTP) account", show_default=True, ) @click.option( "-d", "--digits", type=click.Choice(["6", "7", "8"]), default="6", help="number of digits in generated code", show_default=True, ) @click.option( "-a", "--algorithm", type=EnumChoice(HASH_ALGORITHM), default=HASH_ALGORITHM.SHA1.name, show_default=True, help="algorithm to use for code generation", ) @click.option( "-c", "--counter", type=click.INT, default=0, help="initial counter value for HOTP accounts", ) @click.option("-i", "--issuer", help="issuer of the account (optional)") @click.option( "-P", "--period", help="number of seconds a TOTP code is valid", default=30, show_default=True, ) @click_touch_option @click_force_option @click_password_option @click_remember_option @click.pass_context def add( ctx, secret, name, issuer, period, oath_type, digits, touch, algorithm, counter, force, password, remember, ): """ Add a new account. This will add a new OATH account to the YubiKey. \b NAME human readable name of the account, such as a username or e-mail address SECRET base32-encoded secret/key value provided by the server """ if ctx.obj["fips_unready"]: raise CliFail( "YubiKey FIPS must be in FIPS approved mode prior to adding accounts" ) digits = int(digits) if not secret: while True: secret = click_prompt("Enter a secret key (base32)") try: secret = parse_b32_key(secret) break except Exception as e: click.echo(e) _init_session(ctx, password, remember) _add_cred( ctx, CredentialData( name, oath_type, algorithm, secret, digits, period, counter, issuer ), touch, force, ) @click_callback() def click_parse_uri(ctx, param, val): try: return CredentialData.parse_uri(val) except (ValueError, KeyError): raise click.BadParameter("URI seems to have the wrong format.") @accounts.command() @click.argument("data", callback=click_parse_uri, required=False, metavar="URI") @click_touch_option @click_force_option @click_password_option @click_remember_option @click.pass_context def uri(ctx, data, touch, force, password, remember): """ Add a new account from an otpauth:// URI. Use a URI to add a new account to the YubiKey. """ if ctx.obj["fips_unready"]: raise CliFail( "YubiKey FIPS must be in FIPS approved mode prior to adding accounts" ) if not data: while True: uri = click_prompt("Enter an OATH URI (otpauth://)") try: data = CredentialData.parse_uri(uri) break except Exception as e: click.echo(e) # Steam is a special case where we allow the otpauth # URI to contain a 'digits' value of '5'. if data.digits == 5 and is_steam(data): data.digits = 6 _init_session(ctx, password, remember) _add_cred(ctx, data, touch, force) def _add_cred(ctx, data, touch, force): session = ctx.obj["session"] version = session.version if not (0 < len(data.name) <= 64): raise CliFail("Name must be between 1 and 64 bytes.") if len(data.secret) < 2: raise CliFail("Secret must be at least 2 bytes.") if touch and not version >= (4, 2, 6): raise CliFail("Require touch is not supported on this YubiKey.") if data.counter and data.oath_type != OATH_TYPE.HOTP: raise CliFail("Counter only supported for HOTP accounts.") if data.hash_algorithm == HASH_ALGORITHM.SHA512 and ( not version >= (4, 3, 1) or is_yk4_fips(ctx.obj["info"]) ): raise CliFail("Algorithm SHA512 not supported on this YubiKey.") creds = session.list_credentials() cred_id = data.get_id() if not force and any(cred.id == cred_id for cred in creds): click.confirm( f"An account called {data.name} already exists on this YubiKey." " Do you want to overwrite it?", abort=True, err=True, ) firmware_overwrite_issue = (4, 0, 0) < version < (4, 3, 5) cred_is_subset = any( (cred.id.startswith(cred_id) and cred.id != cred_id) for cred in creds ) # YK4 has an issue with credential overwrite in firmware versions < 4.3.5 if firmware_overwrite_issue and cred_is_subset: raise CliFail("Choose a name that is not a subset of an existing account.") try: session.put_credential(data, touch) click.echo("OATH account added.") except ApduError as e: if e.sw == SW.NO_SPACE: raise CliFail("No space left on the YubiKey for OATH accounts.") elif e.sw == SW.COMMAND_ABORTED: # Some NEOs do not use the NO_SPACE error. raise CliFail("The command failed. Is there enough space on the YubiKey?") _fail_scp(ctx, e) @accounts.command("list") @click_show_hidden_option @click.pass_context @click.option("-o", "--oath-type", is_flag=True, help="display the OATH type") @click.option("-P", "--period", is_flag=True, help="display the period") @click_password_option @click_remember_option def list_creds(ctx, show_hidden, oath_type, period, password, remember): """ List all accounts. List all accounts stored on the YubiKey. """ _init_session(ctx, password, remember) session = ctx.obj["session"] creds = [ cred for cred in session.list_credentials() if show_hidden or not is_hidden(cred) ] creds.sort() for cred in creds: click.echo(_string_id(cred), nl=False) if oath_type: click.echo(f", {cred.oath_type.name}", nl=False) if period: click.echo(f", {cred.period}", nl=False) click.echo() @accounts.command() @click_show_hidden_option @click.pass_context @click.argument("query", required=False, default="") @click.option( "-s", "--single", is_flag=True, help="ensure only a single match, and output only the code", ) @click_password_option @click_remember_option def code(ctx, show_hidden, query, single, password, remember): """ Generate codes. Generate codes from OATH accounts stored on the YubiKey. Provide a query string to match one or more specific accounts. Accounts of type HOTP, or those that require touch, require a single match to be triggered. """ _init_session(ctx, password, remember) session = ctx.obj["session"] try: entries = session.calculate_all() except ApduError as e: if e.sw == SW.MEMORY_FAILURE: logger.warning("Corrupted data in OATH accounts, attempting to fix") if delete_broken_credential(session): entries = session.calculate_all() else: logger.error("Unable to fix memory failure") raise else: raise creds = _search(entries.keys(), query, show_hidden) if len(creds) == 1: cred = creds[0] code = entries[cred] if cred.touch_required: prompt_for_touch() try: if cred.oath_type == OATH_TYPE.HOTP: with prompt_timeout(): # HOTP might require touch, we don't know. # Assume yes after 500ms. code = session.calculate_code(cred) elif code is None: code = session.calculate_code(cred) except ApduError as e: if e.sw == SW.SECURITY_CONDITION_NOT_SATISFIED: raise CliFail("Touch account timed out!") entries[cred] = code elif single and len(creds) > 1: _error_multiple_hits(ctx, creds) elif single and len(creds) == 0: raise CliFail("No matching account found.") if single and creds: if is_steam(cred): click.echo(calculate_steam(session, cred)) else: click.echo(code.value) else: outputs = [] for cred in sorted(creds): code = entries[cred] if code: if is_steam(cred): code = calculate_steam(session, cred) else: code = code.value elif cred.touch_required: code = "[Requires Touch]" elif cred.oath_type == OATH_TYPE.HOTP: code = "[HOTP Account]" else: code = "" outputs.append((_string_id(cred), code)) longest_name = max(len(n) for (n, c) in outputs) if outputs else 0 longest_code = max(len(c) for (n, c) in outputs) if outputs else 0 format_str = "{:<%d} {:>%d}" % (longest_name, longest_code) for name, result in outputs: click.echo(format_str.format(name, result)) @accounts.command() @click.pass_context @click.argument("query") @click.argument("name") @click.option("-f", "--force", is_flag=True, help="confirm rename without prompting") @click_password_option @click_remember_option def rename(ctx, query, name, force, password, remember): """ Rename an account (requires YubiKey 5.3 or later). \b QUERY a query to match a single account (as shown in "list") NAME the name of the account (use ":" to specify issuer) """ _init_session(ctx, password, remember) session = ctx.obj["session"] creds = session.list_credentials() hits = _search(creds, query, True) if len(hits) == 0: click.echo("No matches, nothing to be done.") elif len(hits) == 1: cred = hits[0] if ":" in name: issuer, name = name.split(":", 1) else: issuer = None new_id = _format_cred_id(issuer, name, cred.oath_type, cred.period) if any(cred.id == new_id for cred in creds): raise CliFail( f"Another account with ID {new_id.decode()} " "already exists on this YubiKey." ) if force or ( click.confirm( f"Rename account: {_string_id(cred)} ?", default=False, err=True, ) ): session.rename_credential(cred.id, name, issuer) click.echo(f"Renamed {_string_id(cred)} to {new_id.decode()}.") else: click.echo("Rename aborted by user.") else: _error_multiple_hits(ctx, hits) @accounts.command() @click.pass_context @click.argument("query") @click.option("-f", "--force", is_flag=True, help="confirm deletion without prompting") @click_password_option @click_remember_option def delete(ctx, query, force, password, remember): """ Delete an account. Delete an account from the YubiKey. \b QUERY a query to match a single account (as shown in "list") """ _init_session(ctx, password, remember) session = ctx.obj["session"] creds = session.list_credentials() hits = _search(creds, query, True) if len(hits) == 0: click.echo("No matches, nothing to be done.") elif len(hits) == 1: cred = hits[0] if force or ( click.confirm( f"Delete account: {_string_id(cred)} ?", default=False, err=True, ) ): session.delete_credential(cred.id) click.echo(f"Deleted {_string_id(cred)}.") else: click.echo("Deletion aborted by user.") else: _error_multiple_hits(ctx, hits)