From 31d39f64b73e534787382dab4675c0274616627b Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Thu, 13 Jul 2023 16:58:37 +0200 Subject: [PATCH 01/17] HSMAUTH: Add lib for the YubiHSM Auth application This adds commands to communicate with the YubiHSM Auth application. --- yubikit/hsmauth.py | 501 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 501 insertions(+) create mode 100644 yubikit/hsmauth.py diff --git a/yubikit/hsmauth.py b/yubikit/hsmauth.py new file mode 100644 index 00000000..66a5fb89 --- /dev/null +++ b/yubikit/hsmauth.py @@ -0,0 +1,501 @@ +# 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. + +from .core import ( + int2bytes, + bytes2int, + require_version, + Version, + Tlv, +) +from .core.smartcard import ( + AID, + SmartCardConnection, + SmartCardProtocol, +) + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from cryptography.hazmat.primitives.asymmetric import ec + + +from functools import total_ordering +from enum import IntEnum, unique +from dataclasses import dataclass +from typing import Optional, List, Union, Tuple + +import logging + +logger = logging.getLogger(__name__) + + +# TLV tags for credential data +TAG_LABEL = 0x71 +TAG_LABEL_LIST = 0x72 +TAG_CREDENTIAL_PASSWORD = 0x73 +TAG_ALGORITHM = 0x74 +TAG_KEY_ENC = 0x75 +TAG_KEY_MAC = 0x76 +TAG_CONTEXT = 0x77 +TAG_RESPONSE = 0x78 +TAG_VERSION = 0x79 +TAG_TOUCH = 0x7A +TAG_MANAGEMENT_KEY = 0x7B +TAG_PUBLIC_KEY = 0x7C +TAG_PRIVATE_KEY = 0x7D + +# Instruction bytes for commands +INS_PUT = 0x01 +INS_DELETE = 0x02 +INS_CALCULATE = 0x03 +INS_GET_CHALLENGE = 0x04 +INS_LIST = 0x05 +INS_RESET = 0x06 +INS_GET_VERSION = 0x07 +INS_PUT_MANAGEMENT_KEY = 0x08 +INS_GET_MANAGEMENT_KEY_RETRIES = 0x09 +INS_GET_PUBLIC_KEY = 0x0A + +# Lengths for paramters +MANAGEMENT_KEY_LEN = 16 +CREDENTIAL_PASSWORD_LEN = 16 +MIN_LABEL_LEN = 1 +MAX_LABEL_LEN = 64 + +DEFAULT_MANAGEMENT_KEY = "00000000000000000000000000000000" +INITIAL_RETRY_COUNTER = 8 + + +@unique +class ALGORITHM(IntEnum): + AES128_YUBICO_AUTHENTICATION = 38 + EC_P256_YUBICO_AUTHENTICATION = 39 + + @property + def key_len(self): + if self.name.startswith("AES128"): + return 16 + elif self.name.startswith("EC_P256"): + return 32 + + @property + def pubkey_len(self): + if self.name.startswith("EC_P256"): + return 65 + + +def _parse_credential_password(credential_password: bytes) -> bytes: + if len(credential_password) > CREDENTIAL_PASSWORD_LEN: + raise ValueError( + "Credential password must be less than or equal to %d bytes long" + % CREDENTIAL_PASSWORD_LEN + ) + return credential_password.ljust(CREDENTIAL_PASSWORD_LEN, b"\0") + + +def _parse_label(label: str) -> bytes: + try: + parsed_label = label.encode() + except Exception: + raise ValueError(label) + + if len(parsed_label) < MIN_LABEL_LEN or len(parsed_label) > MAX_LABEL_LEN: + raise ValueError( + "Label must be between %d and %d bytes long" + % (MIN_LABEL_LEN, MAX_LABEL_LEN) + ) + return parsed_label + + +def _parse_select(response): + data = Tlv.unpack(TAG_VERSION, response) + return Version.from_bytes(data) + + +def _password_to_key(password: Union[str, bytes]) -> Tuple[bytes, bytes]: + """Derive encryption and MAC key from a password. + + :return: A tuple containing the encryption key, and MAC key. + """ + if isinstance(password, str): + pw_bytes = password.encode() + else: + pw_bytes = password + key = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=32, + salt=b"Yubico", + iterations=10000, + backend=default_backend(), + ).derive(pw_bytes) + key_enc, key_mac = key[:16], key[16:] + return key_enc, key_mac + + +@total_ordering +@dataclass(order=False, frozen=True) +class Credential: + label: str + algorithm: ALGORITHM + counter: int + touch_required: Optional[bool] + + def __lt__(self, other): + a = self.label.lower() + b = other.label.lower() + return a < b + + def __eq__(self, other): + return self.label == other.label + + def __hash__(self) -> int: + return hash(self.label) + + +@dataclass(frozen=True) +class SessionKeys: + key_senc: bytes + key_smac: bytes + key_srmac: bytes + card_crypto: Optional[bytes] + + @classmethod + def parse(cls, response) -> "SessionKeys": + key_senc = response[:16] + key_smac = response[16:32] + key_srmac = response[32:48] + card_crypto = None + if len(response) == 56: + card_crypto = response[48:] + + return cls( + key_senc=key_senc, + key_smac=key_smac, + key_srmac=key_srmac, + card_crypto=card_crypto, + ) + + +class HsmAuthSession: + def __init__(self, connection: SmartCardConnection) -> None: + self.protocol = SmartCardProtocol(connection) + self._version = _parse_select(self.protocol.select(AID.HSMAUTH)) + + @property + def version(self) -> Version: + return self._version + + def reset(self) -> None: + self.protocol.send_apdu(0, INS_RESET, 0xDE, 0xAD) + logger.info("YubiHSM Auth application data reset performed") + + def list_credentials(self) -> List[Credential]: + """List YubiHSM Auth credentials on YubiKey""" + + creds = [] + for tlv in Tlv.parse_list(self.protocol.send_apdu(0, INS_LIST, 0, 0)): + data = Tlv.unpack(TAG_LABEL_LIST, tlv) + algorithm = ALGORITHM(data[0]) + touch_required = bool(data[1]) + label_length = tlv.length - 3 + label = data[2 : 2 + label_length].decode() + counter = data[-1] + + creds.append(Credential(label, algorithm, counter, touch_required)) + return creds + + def _put_credential( + self, + management_key: bytes, + label: str, + key: bytes, + algorithm: ALGORITHM, + credential_password: bytes, + touch_required: bool = False, + ) -> Credential: + + if len(management_key) != MANAGEMENT_KEY_LEN: + raise ValueError( + "Management key must be %d bytes long" % MANAGEMENT_KEY_LEN + ) + + data = ( + Tlv(TAG_MANAGEMENT_KEY, management_key) + + Tlv(TAG_LABEL, _parse_label(label)) + + Tlv(TAG_ALGORITHM, int2bytes(algorithm)) + ) + + if algorithm == ALGORITHM.AES128_YUBICO_AUTHENTICATION: + data += Tlv(TAG_KEY_ENC, key[:16]) + Tlv(TAG_KEY_MAC, key[16:]) + elif algorithm == ALGORITHM.EC_P256_YUBICO_AUTHENTICATION: + data += Tlv(TAG_PRIVATE_KEY, key) + + data += Tlv( + TAG_CREDENTIAL_PASSWORD, _parse_credential_password(credential_password) + ) + + if touch_required: + data += Tlv(TAG_TOUCH, int2bytes(1)) + else: + data += Tlv(TAG_TOUCH, int2bytes(0)) + + logger.debug( + f"Importing YubiHSM Auth credential (label={label}, algo={algorithm}, " + f"touch_required={touch_required})" + ) + self.protocol.send_apdu(0, INS_PUT, 0, 0, data) + logger.info("Credential imported") + + return Credential(label, algorithm, INITIAL_RETRY_COUNTER, touch_required) + + def put_credential_symmetric( + self, + management_key: bytes, + label: str, + key_enc: bytes, + key_mac: bytes, + credential_password: bytes, + touch_required: bool = False, + ) -> Credential: + """Import a symmetric YubiHSM Auth credential""" + + aes128_key_len = ALGORITHM.AES128_YUBICO_AUTHENTICATION.key_len + if len(key_enc) != aes128_key_len or len(key_mac) != aes128_key_len: + raise ValueError( + "Encryption and MAC key must be %d bytes long", aes128_key_len + ) + + return self._put_credential( + management_key, + label, + key_enc + key_mac, + ALGORITHM.AES128_YUBICO_AUTHENTICATION, + credential_password, + touch_required, + ) + + def put_credential_derived( + self, + management_key: bytes, + label: str, + credential_password: bytes, + derivation_password: Union[str, bytes], + touch_required: bool = False, + ) -> Credential: + """Import a symmetric YubiHSM Auth credential derived from password""" + + key_enc, key_mac = _password_to_key(derivation_password) + + return self.put_credential_symmetric( + management_key, label, key_enc, key_mac, credential_password, touch_required + ) + + def put_credential_asymmetric( + self, + management_key: bytes, + label: str, + private_key: ec.EllipticCurvePrivateKeyWithSerialization, + credential_password: bytes, + touch_required: bool = False, + ) -> Credential: + """Import an asymmetric YubiHSM Auth credential""" + + require_version(self.version, (5, 6, 0)) + if not isinstance(private_key.curve, ec.SECP256R1): + raise ValueError("Unsupported curve") + + ln = ALGORITHM.EC_P256_YUBICO_AUTHENTICATION.key_len + numbers = private_key.private_numbers() + + return self._put_credential( + management_key, + label, + int2bytes(numbers.private_value, ln), + ALGORITHM.EC_P256_YUBICO_AUTHENTICATION, + credential_password, + touch_required, + ) + + def generate_credential_asymmetric( + self, + management_key: bytes, + label: str, + credential_password: bytes, + touch_required: bool = False, + ) -> Credential: + """Generate an asymmetric YubiHSM Auth credential + + Generates a private key on the YubiKey, whose corresponding + public key can be retrieved using `get_public_key` + """ + require_version(self.version, (5, 6, 0)) + return self._put_credential( + management_key, + label, + b"", # Emtpy byte will generate key + ALGORITHM.EC_P256_YUBICO_AUTHENTICATION, + credential_password, + touch_required, + ) + + def get_public_key(self, label: str) -> ec.EllipticCurvePublicKey: + """Get the public key for an asymmetric credential. + + This will return the long-term public key "PK-OCE" for an + asymmetric credential. + """ + require_version(self.version, (5, 6, 0)) + data = Tlv(TAG_LABEL, _parse_label(label)) + res = self.protocol.send_apdu(0, INS_GET_PUBLIC_KEY, 0, 0, data) + + return ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), res) + + def delete_credential(self, management_key: bytes, label: str) -> None: + """Delete a YubiHSM Auth credential""" + + if len(management_key) != MANAGEMENT_KEY_LEN: + raise ValueError( + "Management key must be %d bytes long" % MANAGEMENT_KEY_LEN + ) + + data = Tlv(TAG_MANAGEMENT_KEY, management_key) + Tlv( + TAG_LABEL, _parse_label(label) + ) + + self.protocol.send_apdu(0, INS_DELETE, 0, 0, data) + logger.info("Credential deleted") + + def put_management_key( + self, + management_key: bytes, + new_management_key: bytes, + ) -> None: + """Change YubiHSM Auth management key""" + + if ( + len(management_key) != MANAGEMENT_KEY_LEN + or len(new_management_key) != MANAGEMENT_KEY_LEN + ): + raise ValueError( + "Management key must be %d bytes long" % MANAGEMENT_KEY_LEN + ) + + data = Tlv(TAG_MANAGEMENT_KEY, management_key) + Tlv( + TAG_MANAGEMENT_KEY, new_management_key + ) + + self.protocol.send_apdu(0, INS_PUT_MANAGEMENT_KEY, 0, 0, data) + logger.info("New management key set") + + def get_management_key_retries(self) -> int: + """Get retries remaining for Management key""" + + res = self.protocol.send_apdu(0, INS_GET_MANAGEMENT_KEY_RETRIES, 0, 0) + return bytes2int(res) + + def _calculate_session_keys( + self, + label: str, + context: bytes, + credential_password: bytes, + card_crypto: Optional[bytes] = None, + public_key: Optional[bytes] = None, + ) -> bytes: + """Calculate session keys from YubiHSM Auth credential""" + + data = Tlv(TAG_LABEL, _parse_label(label)) + Tlv(TAG_CONTEXT, context) + + if public_key: + data += Tlv(TAG_PUBLIC_KEY, public_key) + + if card_crypto: + data += Tlv(TAG_RESPONSE, card_crypto) + + data += Tlv( + TAG_CREDENTIAL_PASSWORD, _parse_credential_password(credential_password) + ) + + res = self.protocol.send_apdu(0, INS_CALCULATE, 0, 0, data) + logger.info("Session keys calculated") + + return res + + def calculate_session_keys_symmetric( + self, + label: str, + context: bytes, + credential_password: bytes, + card_crypto: Optional[bytes] = None, + ) -> SessionKeys: + """Calculate session keys from symmetric YubiHSM Auth credential""" + + return SessionKeys.parse( + self._calculate_session_keys( + label=label, + context=context, + credential_password=credential_password, + card_crypto=card_crypto, + ) + ) + + def calculate_session_keys_asymmetric( + self, + label: str, + context: bytes, + public_key: ec.EllipticCurvePublicKeyWithSerialization, + credential_password: bytes, + card_crypto: bytes, + ) -> SessionKeys: + """Calculate session keys from asymmetric YubiHSM Auth credential""" + + require_version(self.version, (5, 6, 0)) + if not isinstance(public_key.curve, ec.SECP256R1): + raise ValueError("Unsupported curve") + + ln = ALGORITHM.EC_P256_YUBICO_AUTHENTICATION + numbers = public_key.public_numbers() + + return SessionKeys.parse( + self._calculate_session_keys( + label, + context, + card_crypto, + int2bytes((numbers.x + numbers.y), ln), + credential_password, + ) + ) + + def get_challenge(self, label: str) -> bytes: + """Get the Host Challenge. + + For symmetric credentials this is Host Challenge, a random + 8 byte value. For asymmetric credentials this is EPK-OCE. + """ + require_version(self.version, (5, 6, 0)) + data = Tlv(TAG_LABEL, _parse_label(label)) + return self.protocol.send_apdu(0, INS_GET_CHALLENGE, 0, 0, data) From 11bb1b0fdda6b5622e71b5ccc5b67f27e66c8abb Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Thu, 13 Jul 2023 17:07:19 +0200 Subject: [PATCH 02/17] HSMAUTH: Add cli commands for the YubiHSM Auth application --- ykman/_cli/__main__.py | 15 +- ykman/_cli/hsmauth.py | 616 +++++++++++++++++++++++++++++++++++++++++ ykman/hsmauth.py | 52 ++++ 3 files changed, 682 insertions(+), 1 deletion(-) create mode 100644 ykman/_cli/hsmauth.py create mode 100644 ykman/hsmauth.py diff --git a/ykman/_cli/__main__.py b/ykman/_cli/__main__.py index 3dadad28..cdec19b7 100644 --- a/ykman/_cli/__main__.py +++ b/ykman/_cli/__main__.py @@ -49,6 +49,7 @@ from .aliases import apply_aliases from .apdu import apdu from .script import run_script +from .hsmauth import hsmauth import click import ctypes @@ -327,7 +328,19 @@ def list_keys(ctx, serials, readers): click.echo(f"{name} [{mode}] ") -COMMANDS = (list_keys, info, otp, openpgp, oath, piv, fido, config, apdu, run_script) +COMMANDS = ( + list_keys, + info, + otp, + openpgp, + oath, + piv, + fido, + config, + apdu, + run_script, + hsmauth, +) for cmd in COMMANDS: diff --git a/ykman/_cli/hsmauth.py b/ykman/_cli/hsmauth.py new file mode 100644 index 00000000..01d44c99 --- /dev/null +++ b/ykman/_cli/hsmauth.py @@ -0,0 +1,616 @@ +# 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. + +from yubikit.core.smartcard import SmartCardConnection +from yubikit.hsmauth import ( + HsmAuthSession, + ALGORITHM, + MANAGEMENT_KEY_LEN, + CREDENTIAL_PASSWORD_LEN, + DEFAULT_MANAGEMENT_KEY, +) +from yubikit.core.smartcard import ApduError, SW + +from ..util import parse_private_key + +from ..hsmauth import ( + get_hsmauth_info, + generate_random_management_key, + parse_touch_required, +) +from .util import ( + CliFail, + click_force_option, + click_postpone_execution, + click_callback, + click_format_option, + click_prompt, + click_group, + pretty_print, +) + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives import serialization + +import click +import logging + +logger = logging.getLogger(__name__) + + +def _parse_key(key, key_len, key_type): + try: + key = bytes.fromhex(key) + except Exception: + ValueError(key) + + if len(key) != key_len: + raise ValueError( + f"{key_type} must be exactly {key_len} bytes long " + f"({key_len*2} hexadecimal digits) long" + ) + return key + + +def _parse_password(pwd, pwd_len, pwd_type): + try: + pwd = pwd.encode() + except Exception: + raise ValueError(pwd) + + if len(pwd) > pwd_len: + raise ValueError( + "%s must be less than or equal to %d bytes long" % (pwd_type, pwd_len) + ) + return pwd + + +def _parse_hex(hex): + try: + val = bytes.fromhex(hex) + return val + except Exception: + raise ValueError(hex) + + +@click_callback() +def click_parse_management_key(ctx, param, val): + return _parse_key(val, MANAGEMENT_KEY_LEN, "Management key") + + +@click_callback() +def click_parse_enc_key(ctx, param, val): + return _parse_key( + val, ALGORITHM.AES128_YUBICO_AUTHENTICATION.key_len, "Encryption 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_credential_password(ctx, param, val): + return _parse_password(val, CREDENTIAL_PASSWORD_LEN, "Credential password") + + +@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) + + +click_management_key_option = click.option( + "-m", + "--management-key", + help="the management key", + default=DEFAULT_MANAGEMENT_KEY, + show_default=True, + callback=click_parse_management_key, +) +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) + ctx.obj["session"] = HsmAuthSession(conn) + + +@hsmauth.command() +@click.pass_context +def info(ctx): + """ + Display general status of the PIV application. + """ + info = get_hsmauth_info(ctx.obj["session"]) + click.echo("\n".join(pretty_print(info))) + + +@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. + """ + + force or 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("Success! All YubiHSM Auth data have been cleared from the YubiKey.") + click.echo( + f"Your YubiKey now has the default Management Key ({DEFAULT_MANAGEMENT_KEY})." + ) + + +@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)") + + click.echo("Algo\tTouch\tRetries\tLabel") + + for cred in creds: + click.echo( + "{0}\t{1}\t{2}\t{3}".format( + cred.algorithm, + parse_touch_required(cred.touch_required), + cred.counter, + cred.label, + ) + ) + + +@credentials.command() +@click.pass_context +@click.argument("label") +@click.option("-E", "--enc-key", help="ENC key", callback=click_parse_enc_key) +@click.option("-M", "--mac-key", help="MAC key", callback=click_parse_mac_key) +@click.option("-p", "--private-key", type=click.File("rb")) +@click.option("-P", "--password", help="password used to decrypt the private key") +@click.option( + "-g", "--generate", is_flag=True, help="generate a private key on the YubiKey" +) +@click.option( + "-c", + "--credential-password", + help="password to protect credential", + callback=click_parse_credential_password, +) +@click.option( + "-d", + "--derivation-password", + help="deriviation password for ENC and MAC keys", +) +@click_management_key_option +@click_touch_option +def add( + ctx, + label, + enc_key, + mac_key, + private_key, + password, + generate, + credential_password, + derivation_password, + management_key, + touch, +): + """ + Add a new credential. + + This will add a new YubiHSM Auth credential to the YubiKey. + + \b + LABEL label for the YubiHSM Auth credential + """ + + if enc_key and mac_key and derivation_password: + ctx.fail( + "--enc-key and --mac-key cannot be combined with --derivation-password" + ) + + if enc_key and mac_key and private_key: + ctx.fail("--enc-key and --mac-key cannot be combined with --private-key") + + if derivation_password and private_key: + ctx.fail("--derivation-password cannot be combined with --private-key") + + if enc_key and not mac_key or mac_key and not enc_key: + ctx.fail("--enc-key and --mac-key need to be combined") + + session = ctx.obj["session"] + + if not credential_password: + credential_password = _parse_password( + click_prompt("Enter Credential password"), + CREDENTIAL_PASSWORD_LEN, + "Credential password", + ) + + try: + if enc_key and mac_key: + session.put_credential_symmetric( + management_key, label, enc_key, mac_key, credential_password, touch + ) + elif derivation_password: + session.put_credential_derived( + management_key, label, credential_password, derivation_password, touch + ) + elif private_key: + data = private_key.read() + private_key = parse_private_key(data, password) + if not isinstance( + private_key, ec.EllipticCurvePrivateKey + ) or not isinstance(private_key.curve, ec.SECP256R1): + raise CliFail( + "Private key must be an EC key " "with curve name secp256r1" + ) + session.put_credential_asymmetric( + management_key, + label, + private_key, + credential_password, + touch, + ) + elif generate: + session.generate_credential_asymmetric( + management_key, label, credential_password, touch + ) + + else: + ctx.fail( + "--enc-key and --mac-key, --derivaion-password " + "or --private-key required" + ) + except ApduError as e: + if e.sw == SW.AUTH_METHOD_BLOCKED: + raise CliFail('A credential with label "%s" already exists.' % label) + elif e.sw & 0xFFF0 == SW.VERIFY_FAIL_NO_RETRY: + raise CliFail("Wrong management key, %d retries left." % (e.sw & ~0xFFF0)) + elif e.sw == SW.NO_SPACE: + raise CliFail("No space left on the YubiKey for YubiHSM Auth credentials.") + else: + raise CliFail("Failed to add credential") + + +@credentials.command() +@click.pass_context +@click.argument("label") +@click.option("-o", "--output", type=click.File("wb"), help="file to output public key") +@click_format_option +def get_public_key(ctx, label, output, format): + """ + Get long-term public key for an asymmetric credential. + """ + + 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, + ) + if output: + output.write(public_key_encoded) + else: + click.echo(public_key_encoded, nl=False) + 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("Failed to get public key") + + +@credentials.command() +@click.pass_context +@click.argument("label") +def get_challenge(ctx, label): + """ + Get host challenge from credential. + + For symmetric credentials this is a random 8 byte value. For asymmetric + credentials this is EPK-OCE. + """ + + session = ctx.obj["session"] + + challenge = session.get_challenge(label).hex() + click.echo(f"Challenge: {challenge}") + + +@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") + """ + + force or click.confirm( + f"Delete credential: {label} ?", + abort=True, + err=True, + ) + + session = ctx.obj["session"] + + try: + session.delete_credential(management_key, label) + except ApduError as e: + if e.sw == SW.FILE_NOT_FOUND: + raise CliFail("Credential not found") + elif e.sw & 0xFFF0 == SW.VERIFY_FAIL_NO_RETRY: + raise CliFail("Wrong management key, %d retries left" % (e.sw & ~0xFFF0)) + else: + raise CliFail("Failed to delete credential.") + + +@credentials.command() +@click.pass_context +@click.argument("label") +@click.option( + "-c", + "--credential-password", + help="password to access credential", + callback=click_parse_credential_password, +) +@click.option( + "-C", "--context", help="the authentication context", callback=click_parse_context +) +@click.option( + "-p", + "--public-key", + help="the public key of the YubiHSM2 device", + type=click.File("rb"), +) +@click.option( + "-G", + "--card-cryptogram", + help="the card cryptogram", + callback=click_parse_card_crypto, +) +def calculate(ctx, label, credential_password, context, public_key, card_cryptogram): + """ + Calculate session credentials. + + This will create session credentials based on the "context" + and the credentials on the YubiKey. For symmetric credentials + the "context" will be the host + HSM challenge. For asymmetric + credentials the "context" will be EPK.OCE + EPK.SD. + """ + + if not credential_password: + credential_password = _parse_password( + click_prompt("Enter Credential password"), + CREDENTIAL_PASSWORD_LEN, + "Credential password", + ) + + try: + session = ctx.obj["session"] + if public_key: + data = public_key.read() + public_key = serialization.load_pem_public_key(data, default_backend()) + if not isinstance(public_key, ec.EllipticCurvePublicKey) or not isinstance( + public_key.curve, ec.SECP256R1 + ): + raise CliFail( + "Public key must be an EC key " "with curve name secp256r1" + ) + if not card_cryptogram or len(card_cryptogram) != 16: + raise CliFail("Card crypto must be 16 bytes long") + if not context: + context = _parse_hex(click.prompt("Enter context")) + if len(context) != 130: + raise CliFail("Context must be 130 bytes long (EPK.OCE + EPK.SD)") + + session_credentials = session.calculate_session_keys_asymmetric( + label, context, public_key, credential_password, card_cryptogram + ) + else: + if card_cryptogram and len(card_cryptogram) != 8: + raise CliFail("Card crypto must be 8 bytes long") + if not context: + context = _parse_hex(click.prompt("Enter context")) + if len(context) != 16: + raise CliFail("Context must be 130 bytes long (host + HSM challenge)") + + session_credentials = session.calculate_session_keys_symmetric( + label, + context, + credential_password, + card_cryptogram, + ) + + click.echo( + "\n".join( + pretty_print( + { + "S-ENC": session_credentials.key_senc, + "S-MAC": session_credentials.key_smac, + "S-RMAC": session_credentials.key_srmac, + } + ) + ) + ) + + except ApduError as e: + if e.sw == SW.SECURITY_CONDITION_NOT_SATISFIED: + raise CliFail("Touch required") + elif e.sw & 0xFFF0 == SW.VERIFY_FAIL_NO_RETRY: + raise CliFail( + "Wrong credential password, %d retries left" % (e.sw & ~0xFFF0) + ) + elif e.sw == SW.FILE_NOT_FOUND: + raise CliFail("Credential not found") + else: + raise CliFail("Failed to calculate session credentials.") + + +@hsmauth.group() +def access(): + """Manage Management Key for YubiHSM Auth""" + + +@access.command() +@click.pass_context +@click.option( + "-m", + "--management-key", + help="current management key", + default=DEFAULT_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(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. + """ + + 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 not new_management_key: + if generate: + 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) + except ApduError as e: + if e.sw & 0xFFF0 == SW.VERIFY_FAIL_NO_RETRY: + raise CliFail("Wrong management key, %d retries left." % (e.sw & ~0xFFF0)) + else: + raise CliFail("Failed to change management key.") + + +@access.command() +@click.pass_context +def retries(ctx): + """ + Get management key retries. + + This will retrieve the number of retiries left for the management key. + """ + + session = ctx.obj["session"] + + retries = session.get_management_key_retries() + click.echo(f"Retries left for Management Key: {retries}") diff --git a/ykman/hsmauth.py b/ykman/hsmauth.py new file mode 100644 index 00000000..3ae90151 --- /dev/null +++ b/ykman/hsmauth.py @@ -0,0 +1,52 @@ +# 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. + +from yubikit.hsmauth import HsmAuthSession, INITIAL_RETRY_COUNTER + +import os + + +def get_hsmauth_info(session: HsmAuthSession): + retries = session.get_management_key_retries() + info = { + "YubiHSM Auth version": session.version, + "Management key retries remaining": f"{retries}/{INITIAL_RETRY_COUNTER}", + } + + return info + + +def generate_random_management_key() -> bytes: + """Generate a new random management key.""" + return os.urandom(16) + + +def parse_touch_required(touch_required: bool) -> str: + if touch_required: + return "On" + else: + return "Off" From 7cb3ffff63ccfb598684d39ef173c5bfb6432753 Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Thu, 13 Jul 2023 17:08:41 +0200 Subject: [PATCH 03/17] HSMAUTH: Add tests for the YubiHSM Auth application --- tests/device/cli/test_hsmauth.py | 326 +++++++++++++++++++++++++++++++ tests/device/test_hsmauth.py | 253 ++++++++++++++++++++++++ tests/test_hsmauth.py | 68 +++++++ 3 files changed, 647 insertions(+) create mode 100644 tests/device/cli/test_hsmauth.py create mode 100644 tests/device/test_hsmauth.py create mode 100644 tests/test_hsmauth.py diff --git a/tests/device/cli/test_hsmauth.py b/tests/device/cli/test_hsmauth.py new file mode 100644 index 00000000..80b6b88b --- /dev/null +++ b/tests/device/cli/test_hsmauth.py @@ -0,0 +1,326 @@ +# -*- coding: utf-8 -*- +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec + +from yubikit.management import CAPABILITY +from .. import condition + +import pytest +import re +import os +import tempfile + +DEFAULT_MANAGEMENT_KEY = "00000000000000000000000000000000" +NON_DEFAULT_MANAGEMENT_KEY = "11111111111111111111111111111111" + + +def generate_pem_eccp256_keypair(): + pk = ec.generate_private_key(ec.SECP256R1(), default_backend()) + return ( + pk.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ), + pk.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ), + ) + + +@pytest.fixture() +def eccp256_keypair(): + tmp = tempfile.NamedTemporaryFile(delete=False) + private_key, public_key = generate_pem_eccp256_keypair() + tmp.write(private_key) + tmp.close() + yield tmp.name, public_key + os.remove(tmp.name) + + +@pytest.fixture +def eccp256_public_key(): + tmp = tempfile.NamedTemporaryFile(delete=False) + _, public_key = generate_pem_eccp256_keypair() + tmp.write(public_key) + tmp.close() + yield tmp.name + os.remove(tmp.name) + + +@pytest.fixture() +def tmp_file(): + tmp = tempfile.NamedTemporaryFile(delete=False) + yield tmp + os.remove(tmp.name) + + +@pytest.fixture(autouse=True) +@condition.capability(CAPABILITY.OATH) +@condition.min_version(5, 4, 3) +def preconditions(ykman_cli): + ykman_cli("hsmauth", "reset", "-f") + + +class TestOATH: + def test_hsmauth_info(self, ykman_cli): + output = ykman_cli("hsmauth", "info").output + assert "version:" in output + + def test_hsmauth_reset(self, ykman_cli): + output = ykman_cli("hsmauth", "reset", "-f").output + assert ( + "Success! All YubiHSM Auth data have been cleared from the YubiKey." + in output + ) + + +class TestCredentials: + def test_hsmauth_add_credential_symmetric(self, ykman_cli): + ykman_cli( + "hsmauth", + "credentials", + "add", + "test-name-sym", + "-c", + "123456", + "-d", + "password", + ) + creds = ykman_cli("hsmauth", "credentials", "list").output + assert "test-name" in creds + assert "38" in creds + + @condition.min_version(5, 6) + def test_hsmauth_add_credential_asymmetric(self, ykman_cli, eccp256_keypair): + private_key_file, public_key = eccp256_keypair + ykman_cli( + "hsmauth", + "credentials", + "add", + "test-name-asym", + "-c", + "123456", + "-p", + private_key_file, + ) + creds = ykman_cli("hsmauth", "credentials", "list").output + assert "test-name-asym" in creds + assert "39" in creds + + public_key_exported = ykman_cli( + "hsmauth", "credentials", "get-public-key", "test-name-asym" + ).stdout_bytes + assert public_key == public_key_exported + + def test_hsmauth_add_credential_prompt(self, ykman_cli): + ykman_cli( + "hsmauth", + "credentials", + "add", + "test-name-2", + "-d", + "password", + input="123456", + ) + creds = ykman_cli("hsmauth", "credentials", "list").output + assert "test-name-2" in creds + + def test_hsmauth_add_credential_touch_required(self, ykman_cli): + ykman_cli( + "hsmauth", + "credentials", + "add", + "test-name-3", + "-c", + "123456", + "-d", + "password", + "-t", + ) + creds = ykman_cli("hsmauth", "credentials", "list").output + assert "test-name-3" in creds + assert "On" in creds + + def test_hsmauth_add_credential_wrong_parameter_combo(self, ykman_cli): + key_enc = "090b47dbed595654901dee1cc655e420" + key_mac = "592fd483f759e29909a04c4505d2ce0a" + + # Providing derivation password, key_enc and key_mac together + # should fail + with pytest.raises(SystemExit): + ykman_cli( + "hsmauth", + "credentials", + "add", + "test-name-4", + "-c", + "123456", + "-d", + "password", + "-E", + key_enc, + "-M", + key_mac, + ) + + @condition.min_version(5, 6) + def test_get_public_key_to_file(self, ykman_cli, eccp256_keypair, tmp_file): + private_key_file, public_key = eccp256_keypair + ykman_cli( + "hsmauth", + "credentials", + "add", + "test-name-asym", + "-c", + "123456", + "-p", + private_key_file, + ) + + ykman_cli( + "hsmauth", + "credentials", + "get-public-key", + "test-name-asym", + "-o", + tmp_file.name, + ) + + public_key_from_file = tmp_file.read() + assert public_key_from_file == public_key + + @condition.min_version(5, 6) + def test_get_public_key_symmetric_credential(self, ykman_cli): + ykman_cli( + "hsmauth", + "credentials", + "add", + "test-name-sym", + "-c", + "123456", + "-d", + "password", + ) + + with pytest.raises(SystemExit): + ykman_cli("hsmauth", "credentials", "test-name-sym", "get-public-key") + + def test_hsmauth_delete(self, ykman_cli): + ykman_cli( + "hsmauth", + "credentials", + "add", + "delete-me", + "-c", + "123456", + "-d", + "password", + ) + ykman_cli("hsmauth", "credentials", "delete", "delete-me", "-f") + creds = ykman_cli("hsmauth", "credentials", "list").output + assert "delete-me" not in creds + + +class TestManagementKey: + def test_change_management_key_prompt(self, ykman_cli): + ykman_cli("hsmauth", "access", "change", input=NON_DEFAULT_MANAGEMENT_KEY) + + with pytest.raises(SystemExit): + # Should fail - wrong current key + ykman_cli( + "hsmauth", + "access", + "change", + "-m", + DEFAULT_MANAGEMENT_KEY, + "-n", + DEFAULT_MANAGEMENT_KEY, + ) + + # Should succeed + ykman_cli( + "hsmauth", + "access", + "change", + "-m", + NON_DEFAULT_MANAGEMENT_KEY, + "-n", + DEFAULT_MANAGEMENT_KEY, + ) + + def test_change_management_key_generate(self, ykman_cli): + output = ykman_cli("hsmauth", "access", "change", "-g").output + + assert re.match( + r"^Generated management key: [a-f0-9]{16}", output, re.MULTILINE + ) + + +class TestHostChallenge: + @condition.min_version(5, 6) + def test_get_host_challenge_symmetric(self, ykman_cli): + ykman_cli( + "hsmauth", + "credentials", + "add", + "test-name-sym", + "-c", + "123456", + "-d", + "password", + ) + + output = ykman_cli( + "hsmauth", "credentials", "get-challenge", "test-name-sym" + ).output + + print(output) + + assert re.match(r"^Challenge: [a-f0-9]{8}", output, re.MULTILINE) + + @condition.min_version(5, 6) + def test_get_host_challenge_asymmetric(self, ykman_cli): + ykman_cli( + "hsmauth", "credentials", "add", "test-name-asym", "-c", "123456", "-g" + ) + + output = ykman_cli( + "hsmauth", "credentials", "get-challenge", "test-name-asym" + ).output + + assert re.match(r"^Challenge: [a-f0-9]{65}", output, re.MULTILINE) + + +class TestSessionKeys: + def test_calculate_sessions_keys_symmetric(self, ykman_cli): + ykman_cli( + "hsmauth", + "credentials", + "add", + "test-name-sym", + "-c", + "123456", + "-d", + "password", + ) + + context = os.urandom(16).hex() + output = ykman_cli( + "hsmauth", + "credentials", + "calculate", + "test-name-sym", + "-c", + "123456", + "-C", + context, + ).output + + assert re.match( + r"^S-ENC: [a-f0-9]{32}\nS-MAC: [a-f0-9]{32}\nS-RMAC: [a-f0-9]{32}", + output, + re.MULTILINE, + ) diff --git a/tests/device/test_hsmauth.py b/tests/device/test_hsmauth.py new file mode 100644 index 00000000..aeed5734 --- /dev/null +++ b/tests/device/test_hsmauth.py @@ -0,0 +1,253 @@ +import pytest + +from yubikit.core.smartcard import ApduError +from yubikit.management import CAPABILITY +from yubikit.hsmauth import HsmAuthSession, Credential, INITIAL_RETRY_COUNTER + +from . import condition + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat + +import os + +DEFAULT_MANAGEMENT_KEY = bytes.fromhex("00000000000000000000000000000000") +NON_DEFAULT_MANAGEMENT_KEY = bytes.fromhex("11111111111111111111111111111111") + + +@pytest.fixture +@condition.capability(CAPABILITY.HSMAUTH) +@condition.min_version(5, 4, 3) +def session(ccid_connection): + hsmauth = HsmAuthSession(ccid_connection) + hsmauth.reset() + yield hsmauth + + +def import_key_derived( + session, + management_key, + credential_password=b"123456", + derivation_password=b"password", +) -> Credential: + credential = session.put_credential_derived( + management_key, + "Test PUT credential symmetric (derived)", + credential_password, + derivation_password, + ) + + return credential + + +def import_key_symmetric( + session, management_key, key_enc, key_mac, credential_password=b"123456" +) -> Credential: + credential = session.put_credential_symmetric( + management_key, + "Test PUT credential symmetric", + key_enc, + key_mac, + credential_password, + ) + + return credential + + +def import_key_asymmetric( + session, management_key, private_key, credential_password=b"12345" +) -> Credential: + credential = session.put_credential_asymmetric( + management_key, + "Test PUT credential asymmetric", + private_key, + credential_password, + ) + + return credential + + +def generate_key_asymmetric( + session, management_key, credential_password=b"12345" +) -> Credential: + credential = session.generate_credential_asymmetric( + management_key, + "Test GENERATE credential asymmetric", + credential_password, + ) + + return credential + + +class TestCredentialManagement: + def check_credential_in_list(self, session, credential: Credential): + credentials = session.list_credentials() + + assert credential in credentials + credential_retrieved = next(cred for cred in credentials if cred == credential) + assert credential_retrieved.label == credential.label + assert credential_retrieved.touch_required == credential.touch_required + assert credential_retrieved.algorithm == credential.algorithm + assert credential_retrieved.counter == INITIAL_RETRY_COUNTER + + def test_put_credential_symmetric_wrong_management_key(self, session): + with pytest.raises(ApduError): + import_key_derived(session, NON_DEFAULT_MANAGEMENT_KEY) + + def test_put_credential_symmetric_wrong_key_length(self, session): + with pytest.raises(ValueError): + import_key_symmetric( + session, DEFAULT_MANAGEMENT_KEY, os.urandom(24), os.urandom(24) + ) + + def test_put_credential_symmetric_exists(self, session): + import_key_derived(session, DEFAULT_MANAGEMENT_KEY) + with pytest.raises(ApduError): + import_key_derived(session, DEFAULT_MANAGEMENT_KEY) + + def test_put_credential_symmetric_works(self, session): + credential = import_key_derived(session, DEFAULT_MANAGEMENT_KEY) + + self.check_credential_in_list(session, credential) + + session.delete_credential(DEFAULT_MANAGEMENT_KEY, credential.label) + + @condition.min_version(5, 6) + def test_put_credential_asymmetric_unsupported_key(self, session): + private_key = ec.generate_private_key( + ec.SECP224R1, backend=default_backend() + ) # curve secp224r1 is not supported + + with pytest.raises(ValueError): + import_key_asymmetric(session, DEFAULT_MANAGEMENT_KEY, private_key) + + @condition.min_version(5, 6) + def test_put_credential_asymmetric_works(self, session): + private_key = ec.generate_private_key(ec.SECP256R1, backend=default_backend()) + credential = import_key_asymmetric(session, DEFAULT_MANAGEMENT_KEY, private_key) + + public_key = private_key.public_key() + assert public_key.public_bytes( + encoding=Encoding.PEM, format=PublicFormat.SubjectPublicKeyInfo + ) == session.get_public_key(credential.label).public_bytes( + encoding=Encoding.PEM, format=PublicFormat.SubjectPublicKeyInfo + ) + + self.check_credential_in_list(session, credential) + session.delete_credential(DEFAULT_MANAGEMENT_KEY, credential.label) + + @condition.min_version(5, 6) + def test_generate_credential_asymmetric_works(self, session): + credential = generate_key_asymmetric(session, DEFAULT_MANAGEMENT_KEY) + + self.check_credential_in_list(session, credential) + + public_key = session.get_public_key(credential.label) + + assert isinstance(public_key, ec.EllipticCurvePublicKey) + assert isinstance(public_key.curve, ec.SECP256R1) + + session.delete_credential(DEFAULT_MANAGEMENT_KEY, credential.label) + + @condition.min_version(5, 6) + def test_get_public_key_symmetric_credential(self, session): + credential = import_key_derived(session, DEFAULT_MANAGEMENT_KEY) + + with pytest.raises(ApduError): + session.get_public_key(credential.label) + + session.delete_credential(DEFAULT_MANAGEMENT_KEY, credential.label) + + def test_delete_credential_wrong_management_key(self, session): + credential = import_key_derived(session, DEFAULT_MANAGEMENT_KEY) + + with pytest.raises(ApduError): + session.delete_credential(NON_DEFAULT_MANAGEMENT_KEY, credential.label) + + def test_delete_credential_non_existing(self, session): + with pytest.raises(ApduError): + session.delete_credential(DEFAULT_MANAGEMENT_KEY, "Default key") + + def test_delete_credential_works(self, session): + credential = import_key_derived(session, DEFAULT_MANAGEMENT_KEY) + + session.delete_credential(DEFAULT_MANAGEMENT_KEY, credential.label) + credentials = session.list_credentials() + assert len(credentials) == 0 + + +class TestAccess: + def test_change_management_key(self, session): + session.put_management_key(DEFAULT_MANAGEMENT_KEY, NON_DEFAULT_MANAGEMENT_KEY) + + # Can't import key with old management key + with pytest.raises(ApduError): + import_key_derived(session, DEFAULT_MANAGEMENT_KEY) + + session.put_management_key(NON_DEFAULT_MANAGEMENT_KEY, DEFAULT_MANAGEMENT_KEY) + + def test_management_key_retries(self, session): + session.put_management_key(DEFAULT_MANAGEMENT_KEY, DEFAULT_MANAGEMENT_KEY) + initial_retries = session.get_management_key_retries() + assert initial_retries == 8 + + with pytest.raises(ApduError): + import_key_derived(session, NON_DEFAULT_MANAGEMENT_KEY) + + post_retries = session.get_management_key_retries() + assert post_retries == 7 + + +class TestSessionKeys: + def test_calculate_session_keys_symmetric(self, session): + credential_password = b"1234" + credential = import_key_derived( + session, + DEFAULT_MANAGEMENT_KEY, + credential_password=credential_password, + derivation_password=b"pwd", + ) + + # Example context and session keys + context = b"g\xfc\xf1\xfe\xb5\xf1\xd8\x83\xedv=\xbfI0\x90\xbb" + key_senc = b"\xb0o\x1a\xc9\x87\x91.\xbe\xdc\x1b\xf0\xe0*k]\x85" + key_smac = b"\xea\xd6\xc3\xa5\x96\xea\x86u\xbf1\xd3I\xab\xb5,t" + key_srmac = b"\xc2\xc6\x1e\x96\xab,X\xe9\x83z\xd0\xe7\xd0n\xe9\x0c" + + session_keys = session.calculate_session_keys_symmetric( + label=credential.label, + context=context, + credential_password=credential_password, + ) + + assert key_senc == session_keys.key_senc + assert key_smac == session_keys.key_smac + assert key_srmac == session_keys.key_srmac + + +class TestHostChallenge: + @condition.min_version(5, 6) + def test_get_challenge_symmetric(self, session): + credential = import_key_derived(session, DEFAULT_MANAGEMENT_KEY) + + challenge1 = session.get_challenge(credential.label) + challenge2 = session.get_challenge(credential.label) + assert len(challenge1) == 8 + assert len(challenge2) == 8 + assert challenge1 != challenge2 + + session.delete_credential(DEFAULT_MANAGEMENT_KEY, credential.label) + + @condition.min_version(5, 6) + def test_get_challenge_asymmetric(self, session): + credential = generate_key_asymmetric(session, DEFAULT_MANAGEMENT_KEY) + + challenge1 = session.get_challenge(credential.label) + challenge2 = session.get_challenge(credential.label) + + assert len(challenge1) == 65 + assert len(challenge2) == 65 + assert challenge1 != challenge2 + + session.delete_credential(DEFAULT_MANAGEMENT_KEY, credential.label) diff --git a/tests/test_hsmauth.py b/tests/test_hsmauth.py new file mode 100644 index 00000000..62e91687 --- /dev/null +++ b/tests/test_hsmauth.py @@ -0,0 +1,68 @@ +from ykman.hsmauth import generate_random_management_key + +from yubikit.hsmauth import ( + _parse_credential_password, + _parse_label, + _password_to_key, + CREDENTIAL_PASSWORD_LEN, + MAX_LABEL_LEN, +) +from binascii import a2b_hex + +import pytest + + +class TestHsmAuthFunctions: + def test_generate_random_management_key(self): + output1 = generate_random_management_key() + output2 = generate_random_management_key() + + assert isinstance(output1, bytes) + assert isinstance(output2, bytes) + assert 16 == len(output1) == len(output2) + + def test_parse_credential_password(self): + parsed_credential_password = _parse_credential_password(b"123456") + + assert ( + b"123456\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + == parsed_credential_password + ) + + def test_parse_credential_password_wrong_length(self): + with pytest.raises(ValueError): + _parse_credential_password(b"1" * (CREDENTIAL_PASSWORD_LEN + 1)) + + def test_parse_label(self): + parsed_label = _parse_label("Default key") + + assert isinstance(parsed_label, bytes) + + def test_parse_label_wrong_length(self): + with pytest.raises(ValueError): + _parse_label("1" * (MAX_LABEL_LEN + 1)) + + with pytest.raises(ValueError): + _parse_label("") + + def test_password_to_key(self): + assert ( + a2b_hex("090b47dbed595654901dee1cc655e420"), + a2b_hex("592fd483f759e29909a04c4505d2ce0a"), + ) == _password_to_key("password") + + assert ( + a2b_hex("090b47dbed595654901dee1cc655e420"), + a2b_hex("592fd483f759e29909a04c4505d2ce0a"), + ) == _password_to_key(b"password") + + def test__password_to_key_utf8(self): + assert ( + a2b_hex("f320972c667ba5cd4d35119a6b0271a1"), + a2b_hex("f10050ca688e5a6ce62b1ffb0f6f6869"), + ) == _password_to_key("κόσμε") + + assert ( + a2b_hex("f320972c667ba5cd4d35119a6b0271a1"), + a2b_hex("f10050ca688e5a6ce62b1ffb0f6f6869"), + ) == _password_to_key(a2b_hex("cebae1bdb9cf83cebcceb5")) From 28d6653ab37fce36308d94eef355da772b479022 Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Fri, 14 Jul 2023 13:40:03 +0200 Subject: [PATCH 04/17] HSMAUTH: Fix public key data format for calculate session keys cmd --- yubikit/hsmauth.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/yubikit/hsmauth.py b/yubikit/hsmauth.py index 66a5fb89..d8eb0142 100644 --- a/yubikit/hsmauth.py +++ b/yubikit/hsmauth.py @@ -48,6 +48,7 @@ from enum import IntEnum, unique from dataclasses import dataclass from typing import Optional, List, Union, Tuple +import struct import logging @@ -106,7 +107,7 @@ def key_len(self): @property def pubkey_len(self): if self.name.startswith("EC_P256"): - return 65 + return 64 def _parse_credential_password(credential_password: bytes) -> bytes: @@ -467,7 +468,7 @@ def calculate_session_keys_asymmetric( self, label: str, context: bytes, - public_key: ec.EllipticCurvePublicKeyWithSerialization, + public_key: ec.EllipticCurvePublicKey, credential_password: bytes, card_crypto: bytes, ) -> SessionKeys: @@ -477,16 +478,21 @@ def calculate_session_keys_asymmetric( if not isinstance(public_key.curve, ec.SECP256R1): raise ValueError("Unsupported curve") - ln = ALGORITHM.EC_P256_YUBICO_AUTHENTICATION numbers = public_key.public_numbers() + public_key_data = ( + struct.pack("!B", 4) + + int.to_bytes(numbers.x, public_key.key_size // 8, "big") + + int.to_bytes(numbers.y, public_key.key_size // 8, "big") + ) + return SessionKeys.parse( self._calculate_session_keys( - label, - context, - card_crypto, - int2bytes((numbers.x + numbers.y), ln), - credential_password, + label=label, + context=context, + credential_password=credential_password, + card_crypto=card_crypto, + public_key=public_key_data, ) ) From 47c801e2f4d4b64ac6931e39d962bdbee72c7553 Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Tue, 1 Aug 2023 11:30:51 +0200 Subject: [PATCH 05/17] HSMAUTH: remove card_crypto from SessionKeys class --- yubikit/hsmauth.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/yubikit/hsmauth.py b/yubikit/hsmauth.py index d8eb0142..8688d999 100644 --- a/yubikit/hsmauth.py +++ b/yubikit/hsmauth.py @@ -183,22 +183,17 @@ class SessionKeys: key_senc: bytes key_smac: bytes key_srmac: bytes - card_crypto: Optional[bytes] @classmethod def parse(cls, response) -> "SessionKeys": key_senc = response[:16] key_smac = response[16:32] key_srmac = response[32:48] - card_crypto = None - if len(response) == 56: - card_crypto = response[48:] return cls( key_senc=key_senc, key_smac=key_smac, key_srmac=key_srmac, - card_crypto=card_crypto, ) From 8517b0d2186b7b122d2bcc3a43104455f017262e Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Thu, 3 Aug 2023 10:23:50 +0200 Subject: [PATCH 06/17] HSMAUTH: change grouping and naming of cli commands --- ykman/_cli/hsmauth.py | 495 +++++++++++++++++++++++------------------- 1 file changed, 266 insertions(+), 229 deletions(-) diff --git a/ykman/_cli/hsmauth.py b/ykman/_cli/hsmauth.py index 01d44c99..e968af9e 100644 --- a/ykman/_cli/hsmauth.py +++ b/ykman/_cli/hsmauth.py @@ -28,6 +28,7 @@ from yubikit.core.smartcard import SmartCardConnection from yubikit.hsmauth import ( HsmAuthSession, + InvalidPinError, ALGORITHM, MANAGEMENT_KEY_LEN, CREDENTIAL_PASSWORD_LEN, @@ -35,7 +36,7 @@ ) from yubikit.core.smartcard import ApduError, SW -from ..util import parse_private_key +from ..util import parse_private_key, InvalidPasswordError from ..hsmauth import ( get_hsmauth_info, @@ -53,16 +54,34 @@ pretty_print, ) -from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives import serialization import click +import os import logging logger = logging.getLogger(__name__) +def handle_credential_error(e: Exception, default_exception_msg): + if isinstance(e, InvalidPinError): + attempts = e.attempts_remaining + if attempts: + raise CliFail(f"Wrong management key, {attempts} attempts remaining.") + else: + raise CliFail("Management key 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.") + raise CliFail(default_exception_msg) + + def _parse_key(key, key_len, key_type): try: key = bytes.fromhex(key) @@ -105,9 +124,7 @@ def click_parse_management_key(ctx, param, val): @click_callback() def click_parse_enc_key(ctx, param, val): - return _parse_key( - val, ALGORITHM.AES128_YUBICO_AUTHENTICATION.key_len, "Encryption key" - ) + return _parse_key(val, ALGORITHM.AES128_YUBICO_AUTHENTICATION.key_len, "ENC key") @click_callback() @@ -130,14 +147,52 @@ def click_parse_context(ctx, param, val): return _parse_hex(val) +def _prompt_management_key(prompt="Enter a management key [blank to use default key]"): + management_key = click_prompt( + prompt, default="", hide_input=True, show_default=False + ) + if management_key == "": + return DEFAULT_MANAGEMENT_KEY + + return _parse_key(management_key, MANAGEMENT_KEY_LEN, "Management key") + + +def _prompt_credential_password(prompt="Enter credential password"): + credential_password = click_prompt( + prompt, default="", hide_input=True, show_default=False + ) + + return _parse_password( + credential_password, CREDENTIAL_PASSWORD_LEN, "Credential password" + ) + + +def _prompt_symmetric_key(type): + symmetric_key = click_prompt(f"Enter {type}", default="", show_default=False) + + return _parse_key( + symmetric_key, ALGORITHM.AES128_YUBICO_AUTHENTICATION.key_len, "ENC key" + ) + + +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-key", help="the management key", - default=DEFAULT_MANAGEMENT_KEY, - show_default=True, callback=click_parse_management_key, ) + click_touch_option = click.option( "-t", "--touch", is_flag=True, help="require touch on YubiKey to access credential" ) @@ -191,7 +246,8 @@ def reset(ctx, force): click.echo("Success! All YubiHSM Auth data have been cleared from the YubiKey.") click.echo( - f"Your YubiKey now has the default Management Key ({DEFAULT_MANAGEMENT_KEY})." + "Your YubiKey now has the default Management Key" + f"({DEFAULT_MANAGEMENT_KEY.hex()})." ) @@ -232,125 +288,116 @@ def list(ctx): @credentials.command() @click.pass_context @click.argument("label") -@click.option("-E", "--enc-key", help="ENC key", callback=click_parse_enc_key) -@click.option("-M", "--mac-key", help="MAC key", callback=click_parse_mac_key) -@click.option("-p", "--private-key", type=click.File("rb")) -@click.option("-P", "--password", help="password used to decrypt the private key") -@click.option( - "-g", "--generate", is_flag=True, help="generate a private key on the YubiKey" -) -@click.option( - "-c", - "--credential-password", - help="password to protect credential", - callback=click_parse_credential_password, -) -@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 add( - ctx, - label, - enc_key, - mac_key, - private_key, - password, - generate, - credential_password, - derivation_password, - management_key, - touch, -): - """ - Add a new credential. +def generate(ctx, label, credential_password, management_key, touch): + """Generate an asymmetric credential. - This will add a new YubiHSM Auth credential to the YubiKey. + This will generate an asymmetric YubiHSM Auth credential + (private key) on the YubiKey. \b LABEL label for the YubiHSM Auth credential """ - if enc_key and mac_key and derivation_password: - ctx.fail( - "--enc-key and --mac-key cannot be combined with --derivation-password" + if not credential_password: + credential_password = _prompt_credential_password() + + if not management_key: + management_key = _prompt_management_key() + + session = ctx.obj["session"] + + try: + session.generate_credential_asymmetric( + management_key, label, credential_password, touch ) + except Exception as e: + handle_credential_error( + e, default_exception_msg="Failed to generate asymmetric credential." + ) + - if enc_key and mac_key and private_key: - ctx.fail("--enc-key and --mac-key cannot be combined with --private-key") +@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. - if derivation_password and private_key: - ctx.fail("--derivation-password cannot be combined with --private-key") + This will import a private key as an asymmetric YubiHSM Auth credential + to the YubiKey. - if enc_key and not mac_key or mac_key and not enc_key: - ctx.fail("--enc-key and --mac-key need to be combined") + \b + LABEL label for the YubiHSM Auth credential + PRIVATE-KEY file containing the private key (use '-' to use stdin) + """ + if not credential_password: + credential_password = _prompt_credential_password() + + if not management_key: + management_key = _prompt_management_key() session = ctx.obj["session"] - if not credential_password: - credential_password = _parse_password( - click_prompt("Enter Credential password"), - CREDENTIAL_PASSWORD_LEN, - "Credential password", - ) + data = private_key.read() - try: - if enc_key and mac_key: - session.put_credential_symmetric( - management_key, label, enc_key, mac_key, credential_password, touch - ) - elif derivation_password: - session.put_credential_derived( - management_key, label, credential_password, derivation_password, touch - ) - elif private_key: - data = private_key.read() + while True: + if password is not None: + password = password.encode() + try: private_key = parse_private_key(data, password) - if not isinstance( - private_key, ec.EllipticCurvePrivateKey - ) or not isinstance(private_key.curve, ec.SECP256R1): - raise CliFail( - "Private key must be an EC key " "with curve name secp256r1" + 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, ) - session.put_credential_asymmetric( - management_key, - label, - private_key, - credential_password, - touch, - ) - elif generate: - session.generate_credential_asymmetric( - management_key, label, credential_password, touch - ) + continue + else: + password = None + click.echo("Wrong password.") + continue + break - else: - ctx.fail( - "--enc-key and --mac-key, --derivaion-password " - "or --private-key required" - ) - except ApduError as e: - if e.sw == SW.AUTH_METHOD_BLOCKED: - raise CliFail('A credential with label "%s" already exists.' % label) - elif e.sw & 0xFFF0 == SW.VERIFY_FAIL_NO_RETRY: - raise CliFail("Wrong management key, %d retries left." % (e.sw & ~0xFFF0)) - elif e.sw == SW.NO_SPACE: - raise CliFail("No space left on the YubiKey for YubiHSM Auth credentials.") - else: - raise CliFail("Failed to add credential") + try: + session.put_credential_asymmetric( + management_key, + label, + private_key, + credential_password, + touch, + ) + except Exception as e: + handle_credential_error( + e, default_exception_msg="Failed to import asymmetric credential." + ) @credentials.command() @click.pass_context @click.argument("label") -@click.option("-o", "--output", type=click.File("wb"), help="file to output public key") +@click.argument("public-key-output", type=click.File("wb"), metavar="PUBLIC-KEY") @click_format_option -def get_public_key(ctx, label, output, format): - """ - Get long-term public key for an asymmetric credential. +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"] @@ -362,34 +409,121 @@ def get_public_key(ctx, label, output, format): encoding=key_encoding, format=serialization.PublicFormat.SubjectPublicKeyInfo, ) - if output: - output.write(public_key_encoded) - else: - click.echo(public_key_encoded, nl=False) + + public_key_output.write(public_key_encoded) + + logger.info(f"Public key for {label} written to {_fname(public_key_output)}") except ApduError as e: if e.sw == SW.AUTH_METHOD_BLOCKED: - raise CliFail("The entry is not an asymmetric credential") + raise CliFail("The entry is not an asymmetric credential.") elif e.sw == SW.FILE_NOT_FOUND: - raise CliFail("Credential not found") + raise CliFail("Credential not found.") else: - raise CliFail("Failed to get public key") + raise CliFail("Unable to export public key.") @credentials.command() @click.pass_context @click.argument("label") -def get_challenge(ctx, 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 """ - Get host challenge from credential. - For symmetric credentials this is a random 8 byte value. For asymmetric - credentials this is EPK-OCE. + if not credential_password: + credential_password = _prompt_credential_password() + + if not management_key: + management_key = _prompt_management_key() + + 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, + ) + + except Exception as e: + handle_credential_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 not credential_password: + credential_password = _prompt_credential_password() + + if not management_key: + management_key = _prompt_management_key() + + if not derivation_password: + derivation_password = click_prompt( + "Enter derivation password", default="", show_default=False + ) + session = ctx.obj["session"] - challenge = session.get_challenge(label).hex() - click.echo(f"Challenge: {challenge}") + try: + session.put_credential_derived( + management_key, label, credential_password, derivation_password, touch + ) + except Exception as e: + handle_credential_error( + e, default_exception_msg="Failed to import symmetric credential." + ) @credentials.command() @@ -407,6 +541,9 @@ def delete(ctx, label, management_key, force): LABEL a label to match a single credential (as shown in "list") """ + if not management_key: + management_key = _prompt_management_key() + force or click.confirm( f"Delete credential: {label} ?", abort=True, @@ -417,116 +554,12 @@ def delete(ctx, label, management_key, force): try: session.delete_credential(management_key, label) - except ApduError as e: - if e.sw == SW.FILE_NOT_FOUND: - raise CliFail("Credential not found") - elif e.sw & 0xFFF0 == SW.VERIFY_FAIL_NO_RETRY: - raise CliFail("Wrong management key, %d retries left" % (e.sw & ~0xFFF0)) - else: - raise CliFail("Failed to delete credential.") - - -@credentials.command() -@click.pass_context -@click.argument("label") -@click.option( - "-c", - "--credential-password", - help="password to access credential", - callback=click_parse_credential_password, -) -@click.option( - "-C", "--context", help="the authentication context", callback=click_parse_context -) -@click.option( - "-p", - "--public-key", - help="the public key of the YubiHSM2 device", - type=click.File("rb"), -) -@click.option( - "-G", - "--card-cryptogram", - help="the card cryptogram", - callback=click_parse_card_crypto, -) -def calculate(ctx, label, credential_password, context, public_key, card_cryptogram): - """ - Calculate session credentials. - - This will create session credentials based on the "context" - and the credentials on the YubiKey. For symmetric credentials - the "context" will be the host + HSM challenge. For asymmetric - credentials the "context" will be EPK.OCE + EPK.SD. - """ - - if not credential_password: - credential_password = _parse_password( - click_prompt("Enter Credential password"), - CREDENTIAL_PASSWORD_LEN, - "Credential password", + except Exception as e: + handle_credential_error( + e, + default_exception_msg="Failed to delete credential.", ) - try: - session = ctx.obj["session"] - if public_key: - data = public_key.read() - public_key = serialization.load_pem_public_key(data, default_backend()) - if not isinstance(public_key, ec.EllipticCurvePublicKey) or not isinstance( - public_key.curve, ec.SECP256R1 - ): - raise CliFail( - "Public key must be an EC key " "with curve name secp256r1" - ) - if not card_cryptogram or len(card_cryptogram) != 16: - raise CliFail("Card crypto must be 16 bytes long") - if not context: - context = _parse_hex(click.prompt("Enter context")) - if len(context) != 130: - raise CliFail("Context must be 130 bytes long (EPK.OCE + EPK.SD)") - - session_credentials = session.calculate_session_keys_asymmetric( - label, context, public_key, credential_password, card_cryptogram - ) - else: - if card_cryptogram and len(card_cryptogram) != 8: - raise CliFail("Card crypto must be 8 bytes long") - if not context: - context = _parse_hex(click.prompt("Enter context")) - if len(context) != 16: - raise CliFail("Context must be 130 bytes long (host + HSM challenge)") - - session_credentials = session.calculate_session_keys_symmetric( - label, - context, - credential_password, - card_cryptogram, - ) - - click.echo( - "\n".join( - pretty_print( - { - "S-ENC": session_credentials.key_senc, - "S-MAC": session_credentials.key_smac, - "S-RMAC": session_credentials.key_srmac, - } - ) - ) - ) - - except ApduError as e: - if e.sw == SW.SECURITY_CONDITION_NOT_SATISFIED: - raise CliFail("Touch required") - elif e.sw & 0xFFF0 == SW.VERIFY_FAIL_NO_RETRY: - raise CliFail( - "Wrong credential password, %d retries left" % (e.sw & ~0xFFF0) - ) - elif e.sw == SW.FILE_NOT_FOUND: - raise CliFail("Credential not found") - else: - raise CliFail("Failed to calculate session credentials.") - @hsmauth.group() def access(): @@ -564,6 +597,11 @@ def change(ctx, management_key, new_management_key, generate): YubiHSM Auth credentials stored on the YubiKey. """ + if not management_key: + 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. @@ -594,11 +632,10 @@ def change(ctx, management_key, new_management_key, generate): try: session.put_management_key(management_key, new_management_key) - except ApduError as e: - if e.sw & 0xFFF0 == SW.VERIFY_FAIL_NO_RETRY: - raise CliFail("Wrong management key, %d retries left." % (e.sw & ~0xFFF0)) - else: - raise CliFail("Failed to change management key.") + except Exception as e: + handle_credential_error( + e, default_exception_msg="Failed to change management key." + ) @access.command() From ae5facc23836e3ba662dec9c8f85a65a878bc03a Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Thu, 3 Aug 2023 10:25:47 +0200 Subject: [PATCH 07/17] HSMAUTH: update tests for YubiHSM Auth --- tests/device/cli/test_hsmauth.py | 227 +++++++++++++------------------ tests/device/test_hsmauth.py | 29 ++-- 2 files changed, 111 insertions(+), 145 deletions(-) diff --git a/tests/device/cli/test_hsmauth.py b/tests/device/cli/test_hsmauth.py index 80b6b88b..87bde60d 100644 --- a/tests/device/cli/test_hsmauth.py +++ b/tests/device/cli/test_hsmauth.py @@ -40,16 +40,6 @@ def eccp256_keypair(): os.remove(tmp.name) -@pytest.fixture -def eccp256_public_key(): - tmp = tempfile.NamedTemporaryFile(delete=False) - _, public_key = generate_pem_eccp256_keypair() - tmp.write(public_key) - tmp.close() - yield tmp.name - os.remove(tmp.name) - - @pytest.fixture() def tmp_file(): tmp = tempfile.NamedTemporaryFile(delete=False) @@ -78,114 +68,129 @@ def test_hsmauth_reset(self, ykman_cli): class TestCredentials: - def test_hsmauth_add_credential_symmetric(self, ykman_cli): + def test_import_credential_symmetric(self, ykman_cli): ykman_cli( "hsmauth", "credentials", - "add", + "symmetric", "test-name-sym", "-c", "123456", + "-E", + os.urandom(16).hex(), + "-M", + os.urandom(16).hex(), + "-m", + DEFAULT_MANAGEMENT_KEY, + ) + creds = ykman_cli("hsmauth", "credentials", "list").output + assert "test-name-sym" in creds + + def test_import_credential_symmetric_generate(self, ykman_cli): + output = ykman_cli( + "hsmauth", + "credentials", + "symmetric", + "test-name-sym-gen", + "-c", + "123456", + "-g", + "-m", + DEFAULT_MANAGEMENT_KEY, + ).output + + assert "Generated ENC and MAC keys" in output + + def test_import_credential_symmetric_derived(self, ykman_cli): + ykman_cli( + "hsmauth", + "credentials", + "derive", + "test-name-sym-derived", + "-c", + "123456", "-d", "password", ) creds = ykman_cli("hsmauth", "credentials", "list").output - assert "test-name" in creds - assert "38" in creds + assert "test-name-sym-derived" in creds @condition.min_version(5, 6) - def test_hsmauth_add_credential_asymmetric(self, ykman_cli, eccp256_keypair): - private_key_file, public_key = eccp256_keypair + def test_import_credential_asymmetric(self, ykman_cli): + pair = generate_pem_eccp256_keypair() ykman_cli( "hsmauth", "credentials", - "add", + "import", "test-name-asym", "-c", "123456", - "-p", - private_key_file, + "-m", + DEFAULT_MANAGEMENT_KEY, + "-", + input=pair[0], ) creds = ykman_cli("hsmauth", "credentials", "list").output assert "test-name-asym" in creds - assert "39" in creds public_key_exported = ykman_cli( - "hsmauth", "credentials", "get-public-key", "test-name-asym" + "hsmauth", "credentials", "export", "test-name-asym", "-" ).stdout_bytes - assert public_key == public_key_exported + assert pair[1] == public_key_exported - def test_hsmauth_add_credential_prompt(self, ykman_cli): + @condition.min_version(5, 6) + def test_generate_credential_asymmetric(self, ykman_cli): ykman_cli( "hsmauth", "credentials", - "add", - "test-name-2", - "-d", - "password", - input="123456", + "generate", + "test-name-asym-generated", + "-c", + "123456", + "-m", + DEFAULT_MANAGEMENT_KEY, ) + creds = ykman_cli("hsmauth", "credentials", "list").output - assert "test-name-2" in creds + assert "test-name-asym-generated" in creds - def test_hsmauth_add_credential_touch_required(self, ykman_cli): + def test_import_credential_touch_required(self, ykman_cli): ykman_cli( "hsmauth", "credentials", - "add", - "test-name-3", + "derive", + "test-name-touch", "-c", "123456", "-d", "password", "-t", ) + creds = ykman_cli("hsmauth", "credentials", "list").output - assert "test-name-3" in creds assert "On" in creds - - def test_hsmauth_add_credential_wrong_parameter_combo(self, ykman_cli): - key_enc = "090b47dbed595654901dee1cc655e420" - key_mac = "592fd483f759e29909a04c4505d2ce0a" - - # Providing derivation password, key_enc and key_mac together - # should fail - with pytest.raises(SystemExit): - ykman_cli( - "hsmauth", - "credentials", - "add", - "test-name-4", - "-c", - "123456", - "-d", - "password", - "-E", - key_enc, - "-M", - key_mac, - ) + assert "test-name-touch" in creds @condition.min_version(5, 6) - def test_get_public_key_to_file(self, ykman_cli, eccp256_keypair, tmp_file): + def test_export_public_key_to_file(self, ykman_cli, eccp256_keypair, tmp_file): private_key_file, public_key = eccp256_keypair ykman_cli( "hsmauth", "credentials", - "add", + "import", "test-name-asym", "-c", "123456", - "-p", + "-m", + DEFAULT_MANAGEMENT_KEY, private_key_file, ) ykman_cli( "hsmauth", "credentials", - "get-public-key", + "export", "test-name-asym", - "-o", tmp_file.name, ) @@ -193,40 +198,54 @@ def test_get_public_key_to_file(self, ykman_cli, eccp256_keypair, tmp_file): assert public_key_from_file == public_key @condition.min_version(5, 6) - def test_get_public_key_symmetric_credential(self, ykman_cli): + def test_export_public_key_symmetric_credential(self, ykman_cli): ykman_cli( "hsmauth", "credentials", - "add", + "derive", "test-name-sym", "-c", "123456", "-d", "password", + "-m", + DEFAULT_MANAGEMENT_KEY, ) with pytest.raises(SystemExit): - ykman_cli("hsmauth", "credentials", "test-name-sym", "get-public-key") + ykman_cli("hsmauth", "credentials", "export", "test-name-sym") - def test_hsmauth_delete(self, ykman_cli): + def test_delete_credential(self, ykman_cli): ykman_cli( "hsmauth", "credentials", - "add", + "derive", "delete-me", "-c", "123456", "-d", "password", + "-m", + DEFAULT_MANAGEMENT_KEY, ) + old_creds = ykman_cli("hsmauth", "credentials", "list").output + assert "delete-me" in old_creds ykman_cli("hsmauth", "credentials", "delete", "delete-me", "-f") - creds = ykman_cli("hsmauth", "credentials", "list").output - assert "delete-me" not in creds + new_creds = ykman_cli("hsmauth", "credentials", "list").output + assert "delete-me" not in new_creds class TestManagementKey: - def test_change_management_key_prompt(self, ykman_cli): - ykman_cli("hsmauth", "access", "change", input=NON_DEFAULT_MANAGEMENT_KEY) + def test_change_management_key(self, ykman_cli): + ykman_cli( + "hsmauth", + "access", + "change", + "-m", + DEFAULT_MANAGEMENT_KEY, + "-n", + NON_DEFAULT_MANAGEMENT_KEY, + ) with pytest.raises(SystemExit): # Should fail - wrong current key @@ -252,75 +271,17 @@ def test_change_management_key_prompt(self, ykman_cli): ) def test_change_management_key_generate(self, ykman_cli): - output = ykman_cli("hsmauth", "access", "change", "-g").output - - assert re.match( - r"^Generated management key: [a-f0-9]{16}", output, re.MULTILINE - ) - - -class TestHostChallenge: - @condition.min_version(5, 6) - def test_get_host_challenge_symmetric(self, ykman_cli): - ykman_cli( - "hsmauth", - "credentials", - "add", - "test-name-sym", - "-c", - "123456", - "-d", - "password", - ) - - output = ykman_cli( - "hsmauth", "credentials", "get-challenge", "test-name-sym" - ).output - - print(output) - - assert re.match(r"^Challenge: [a-f0-9]{8}", output, re.MULTILINE) - - @condition.min_version(5, 6) - def test_get_host_challenge_asymmetric(self, ykman_cli): - ykman_cli( - "hsmauth", "credentials", "add", "test-name-asym", "-c", "123456", "-g" - ) - output = ykman_cli( - "hsmauth", "credentials", "get-challenge", "test-name-asym" + "hsmauth", "access", "change", "-m", DEFAULT_MANAGEMENT_KEY, "-g" ).output - assert re.match(r"^Challenge: [a-f0-9]{65}", output, re.MULTILINE) - - -class TestSessionKeys: - def test_calculate_sessions_keys_symmetric(self, ykman_cli): - ykman_cli( - "hsmauth", - "credentials", - "add", - "test-name-sym", - "-c", - "123456", - "-d", - "password", + assert re.match( + r"^Generated management key: [a-f0-9]{16}", output, re.MULTILINE ) - context = os.urandom(16).hex() - output = ykman_cli( - "hsmauth", - "credentials", - "calculate", - "test-name-sym", - "-c", - "123456", - "-C", - context, - ).output + def test_get_management_key_retries(self, ykman_cli): + output = ykman_cli("hsmauth", "access", "retries").output assert re.match( - r"^S-ENC: [a-f0-9]{32}\nS-MAC: [a-f0-9]{32}\nS-RMAC: [a-f0-9]{32}", - output, - re.MULTILINE, + r"^Retries left for Management Key: [0-9]", output, re.MULTILINE ) diff --git a/tests/device/test_hsmauth.py b/tests/device/test_hsmauth.py index aeed5734..9bb7bedb 100644 --- a/tests/device/test_hsmauth.py +++ b/tests/device/test_hsmauth.py @@ -2,7 +2,12 @@ from yubikit.core.smartcard import ApduError from yubikit.management import CAPABILITY -from yubikit.hsmauth import HsmAuthSession, Credential, INITIAL_RETRY_COUNTER +from yubikit.hsmauth import ( + HsmAuthSession, + Credential, + INITIAL_RETRY_COUNTER, + InvalidPinError, +) from . import condition @@ -91,22 +96,22 @@ def check_credential_in_list(self, session, credential: Credential): assert credential_retrieved.algorithm == credential.algorithm assert credential_retrieved.counter == INITIAL_RETRY_COUNTER - def test_put_credential_symmetric_wrong_management_key(self, session): - with pytest.raises(ApduError): + def test_import_credential_symmetric_wrong_management_key(self, session): + with pytest.raises(InvalidPinError): import_key_derived(session, NON_DEFAULT_MANAGEMENT_KEY) - def test_put_credential_symmetric_wrong_key_length(self, session): + def test_import_credential_symmetric_wrong_key_length(self, session): with pytest.raises(ValueError): import_key_symmetric( session, DEFAULT_MANAGEMENT_KEY, os.urandom(24), os.urandom(24) ) - def test_put_credential_symmetric_exists(self, session): + def test_import_credential_symmetric_exists(self, session): import_key_derived(session, DEFAULT_MANAGEMENT_KEY) with pytest.raises(ApduError): import_key_derived(session, DEFAULT_MANAGEMENT_KEY) - def test_put_credential_symmetric_works(self, session): + def test_import_credential_symmetric_works(self, session): credential = import_key_derived(session, DEFAULT_MANAGEMENT_KEY) self.check_credential_in_list(session, credential) @@ -114,7 +119,7 @@ def test_put_credential_symmetric_works(self, session): session.delete_credential(DEFAULT_MANAGEMENT_KEY, credential.label) @condition.min_version(5, 6) - def test_put_credential_asymmetric_unsupported_key(self, session): + def test_import_credential_asymmetric_unsupported_key(self, session): private_key = ec.generate_private_key( ec.SECP224R1, backend=default_backend() ) # curve secp224r1 is not supported @@ -123,7 +128,7 @@ def test_put_credential_asymmetric_unsupported_key(self, session): import_key_asymmetric(session, DEFAULT_MANAGEMENT_KEY, private_key) @condition.min_version(5, 6) - def test_put_credential_asymmetric_works(self, session): + def test_import_credential_asymmetric_works(self, session): private_key = ec.generate_private_key(ec.SECP256R1, backend=default_backend()) credential = import_key_asymmetric(session, DEFAULT_MANAGEMENT_KEY, private_key) @@ -151,7 +156,7 @@ def test_generate_credential_asymmetric_works(self, session): session.delete_credential(DEFAULT_MANAGEMENT_KEY, credential.label) @condition.min_version(5, 6) - def test_get_public_key_symmetric_credential(self, session): + def test_export_public_key_symmetric_credential(self, session): credential = import_key_derived(session, DEFAULT_MANAGEMENT_KEY) with pytest.raises(ApduError): @@ -162,7 +167,7 @@ def test_get_public_key_symmetric_credential(self, session): def test_delete_credential_wrong_management_key(self, session): credential = import_key_derived(session, DEFAULT_MANAGEMENT_KEY) - with pytest.raises(ApduError): + with pytest.raises(InvalidPinError): session.delete_credential(NON_DEFAULT_MANAGEMENT_KEY, credential.label) def test_delete_credential_non_existing(self, session): @@ -182,7 +187,7 @@ def test_change_management_key(self, session): session.put_management_key(DEFAULT_MANAGEMENT_KEY, NON_DEFAULT_MANAGEMENT_KEY) # Can't import key with old management key - with pytest.raises(ApduError): + with pytest.raises(InvalidPinError): import_key_derived(session, DEFAULT_MANAGEMENT_KEY) session.put_management_key(NON_DEFAULT_MANAGEMENT_KEY, DEFAULT_MANAGEMENT_KEY) @@ -192,7 +197,7 @@ def test_management_key_retries(self, session): initial_retries = session.get_management_key_retries() assert initial_retries == 8 - with pytest.raises(ApduError): + with pytest.raises(InvalidPinError): import_key_derived(session, NON_DEFAULT_MANAGEMENT_KEY) post_retries = session.get_management_key_retries() From aeec9d2b8c786639568c3191fef38b893baaa061 Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Thu, 3 Aug 2023 10:27:15 +0200 Subject: [PATCH 08/17] HSMAUTH: use InvalidPinError in hsmauth --- yubikit/hsmauth.py | 70 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 56 insertions(+), 14 deletions(-) diff --git a/yubikit/hsmauth.py b/yubikit/hsmauth.py index 8688d999..2deab657 100644 --- a/yubikit/hsmauth.py +++ b/yubikit/hsmauth.py @@ -31,12 +31,9 @@ require_version, Version, Tlv, + InvalidPinError, ) -from .core.smartcard import ( - AID, - SmartCardConnection, - SmartCardProtocol, -) +from .core.smartcard import AID, SmartCardConnection, SmartCardProtocol, ApduError, SW from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes @@ -88,7 +85,10 @@ MIN_LABEL_LEN = 1 MAX_LABEL_LEN = 64 -DEFAULT_MANAGEMENT_KEY = "00000000000000000000000000000000" +DEFAULT_MANAGEMENT_KEY = ( + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" +) + INITIAL_RETRY_COUNTER = 8 @@ -158,6 +158,12 @@ def _password_to_key(password: Union[str, bytes]) -> Tuple[bytes, bytes]: return key_enc, key_mac +def _retries_from_sw(sw): + if sw & 0xFFF0 == SW.VERIFY_FAIL_NO_RETRY: + return sw & ~0xFFF0 + return None + + @total_ordering @dataclass(order=False, frozen=True) class Credential: @@ -264,8 +270,17 @@ def _put_credential( f"Importing YubiHSM Auth credential (label={label}, algo={algorithm}, " f"touch_required={touch_required})" ) - self.protocol.send_apdu(0, INS_PUT, 0, 0, data) - logger.info("Credential imported") + try: + self.protocol.send_apdu(0, INS_PUT, 0, 0, data) + logger.info("Credential imported") + except ApduError as e: + retries = _retries_from_sw(e.sw) + if retries is None: + raise + raise InvalidPinError( + attempts_remaining=retries, + message=f"Invalid management key, {retries} attempts remaining", + ) return Credential(label, algorithm, INITIAL_RETRY_COUNTER, touch_required) @@ -383,8 +398,17 @@ def delete_credential(self, management_key: bytes, label: str) -> None: TAG_LABEL, _parse_label(label) ) - self.protocol.send_apdu(0, INS_DELETE, 0, 0, data) - logger.info("Credential deleted") + try: + self.protocol.send_apdu(0, INS_DELETE, 0, 0, data) + logger.info("Credential deleted") + except ApduError as e: + retries = _retries_from_sw(e.sw) + if retries is None: + raise + raise InvalidPinError( + attempts_remaining=retries, + message=f"Invalid management key, {retries} attempts remaining", + ) def put_management_key( self, @@ -405,8 +429,17 @@ def put_management_key( TAG_MANAGEMENT_KEY, new_management_key ) - self.protocol.send_apdu(0, INS_PUT_MANAGEMENT_KEY, 0, 0, data) - logger.info("New management key set") + try: + self.protocol.send_apdu(0, INS_PUT_MANAGEMENT_KEY, 0, 0, data) + logger.info("New management key set") + except ApduError as e: + retries = _retries_from_sw(e.sw) + if retries is None: + raise + raise InvalidPinError( + attempts_remaining=retries, + message=f"Invalid management key, {retries} attempts remaining", + ) def get_management_key_retries(self) -> int: """Get retries remaining for Management key""" @@ -436,8 +469,17 @@ def _calculate_session_keys( TAG_CREDENTIAL_PASSWORD, _parse_credential_password(credential_password) ) - res = self.protocol.send_apdu(0, INS_CALCULATE, 0, 0, data) - logger.info("Session keys calculated") + try: + res = self.protocol.send_apdu(0, INS_CALCULATE, 0, 0, data) + logger.info("Session keys calculated") + except ApduError as e: + retries = _retries_from_sw(e.sw) + if retries is None: + raise + raise InvalidPinError( + attempts_remaining=retries, + message=f"Invalid credential password, {retries} attempts remaining", + ) return res From e6e475b7233c1ec2a5a3807d37967485d163cd56 Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Fri, 4 Aug 2023 15:57:51 +0200 Subject: [PATCH 09/17] HSMAUTH: change `SessionKeys` to NamedTuple --- yubikit/hsmauth.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/yubikit/hsmauth.py b/yubikit/hsmauth.py index 2deab657..0aea3fdd 100644 --- a/yubikit/hsmauth.py +++ b/yubikit/hsmauth.py @@ -44,7 +44,7 @@ from functools import total_ordering from enum import IntEnum, unique from dataclasses import dataclass -from typing import Optional, List, Union, Tuple +from typing import Optional, List, Union, Tuple, NamedTuple import struct import logging @@ -184,8 +184,7 @@ def __hash__(self) -> int: return hash(self.label) -@dataclass(frozen=True) -class SessionKeys: +class SessionKeys(NamedTuple): key_senc: bytes key_smac: bytes key_srmac: bytes From c4c47f0f71c2f88c584b2bc5c9f360602fdfcd2f Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Mon, 7 Aug 2023 16:13:07 +0200 Subject: [PATCH 10/17] HSMAUTH: change `_parse_credential_password` to accept str and bytes --- yubikit/hsmauth.py | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/yubikit/hsmauth.py b/yubikit/hsmauth.py index 0aea3fdd..d269e542 100644 --- a/yubikit/hsmauth.py +++ b/yubikit/hsmauth.py @@ -110,13 +110,17 @@ def pubkey_len(self): return 64 -def _parse_credential_password(credential_password: bytes) -> bytes: - if len(credential_password) > CREDENTIAL_PASSWORD_LEN: +def _parse_credential_password(credential_password: Union[bytes, str]) -> bytes: + if isinstance(credential_password, str): + pw = credential_password.encode().ljust(CREDENTIAL_PASSWORD_LEN, b"\0") + else: + pw = bytes(credential_password) + + if len(pw) != CREDENTIAL_PASSWORD_LEN: raise ValueError( - "Credential password must be less than or equal to %d bytes long" - % CREDENTIAL_PASSWORD_LEN + "Credential password must be %d bytes long" % CREDENTIAL_PASSWORD_LEN ) - return credential_password.ljust(CREDENTIAL_PASSWORD_LEN, b"\0") + return pw def _parse_label(label: str) -> bytes: @@ -236,7 +240,7 @@ def _put_credential( label: str, key: bytes, algorithm: ALGORITHM, - credential_password: bytes, + credential_password: Union[bytes, str], touch_required: bool = False, ) -> Credential: @@ -289,7 +293,7 @@ def put_credential_symmetric( label: str, key_enc: bytes, key_mac: bytes, - credential_password: bytes, + credential_password: Union[bytes, str], touch_required: bool = False, ) -> Credential: """Import a symmetric YubiHSM Auth credential""" @@ -313,8 +317,8 @@ def put_credential_derived( self, management_key: bytes, label: str, - credential_password: bytes, - derivation_password: Union[str, bytes], + credential_password: Union[bytes, str], + derivation_password: Union[bytes, str], touch_required: bool = False, ) -> Credential: """Import a symmetric YubiHSM Auth credential derived from password""" @@ -330,7 +334,7 @@ def put_credential_asymmetric( management_key: bytes, label: str, private_key: ec.EllipticCurvePrivateKeyWithSerialization, - credential_password: bytes, + credential_password: Union[bytes, str], touch_required: bool = False, ) -> Credential: """Import an asymmetric YubiHSM Auth credential""" @@ -355,7 +359,7 @@ def generate_credential_asymmetric( self, management_key: bytes, label: str, - credential_password: bytes, + credential_password: Union[bytes, str], touch_required: bool = False, ) -> Credential: """Generate an asymmetric YubiHSM Auth credential @@ -450,7 +454,7 @@ def _calculate_session_keys( self, label: str, context: bytes, - credential_password: bytes, + credential_password: Union[bytes, str], card_crypto: Optional[bytes] = None, public_key: Optional[bytes] = None, ) -> bytes: @@ -486,7 +490,7 @@ def calculate_session_keys_symmetric( self, label: str, context: bytes, - credential_password: bytes, + credential_password: Union[bytes, str], card_crypto: Optional[bytes] = None, ) -> SessionKeys: """Calculate session keys from symmetric YubiHSM Auth credential""" @@ -505,7 +509,7 @@ def calculate_session_keys_asymmetric( label: str, context: bytes, public_key: ec.EllipticCurvePublicKey, - credential_password: bytes, + credential_password: Union[bytes, str], card_crypto: bytes, ) -> SessionKeys: """Calculate session keys from asymmetric YubiHSM Auth credential""" From 44ed85ac9931978053e7cec53ebb18de79f9348f Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Mon, 7 Aug 2023 16:17:02 +0200 Subject: [PATCH 11/17] HSMAUTH: add hsmauth cli improvements --- ykman/_cli/hsmauth.py | 74 +++++++++++++++++++++++++------------------ ykman/hsmauth.py | 7 ---- 2 files changed, 43 insertions(+), 38 deletions(-) diff --git a/ykman/_cli/hsmauth.py b/ykman/_cli/hsmauth.py index e968af9e..a67221c2 100644 --- a/ykman/_cli/hsmauth.py +++ b/ykman/_cli/hsmauth.py @@ -41,7 +41,6 @@ from ..hsmauth import ( get_hsmauth_info, generate_random_management_key, - parse_touch_required, ) from .util import ( CliFail, @@ -82,6 +81,20 @@ def handle_credential_error(e: Exception, default_exception_msg): 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_key(key, key_len, key_type): try: key = bytes.fromhex(key) @@ -96,19 +109,6 @@ def _parse_key(key, key_len, key_type): return key -def _parse_password(pwd, pwd_len, pwd_type): - try: - pwd = pwd.encode() - except Exception: - raise ValueError(pwd) - - if len(pwd) > pwd_len: - raise ValueError( - "%s must be less than or equal to %d bytes long" % (pwd_type, pwd_len) - ) - return pwd - - def _parse_hex(hex): try: val = bytes.fromhex(hex) @@ -132,11 +132,6 @@ 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_credential_password(ctx, param, val): - return _parse_password(val, CREDENTIAL_PASSWORD_LEN, "Credential password") - - @click_callback() def click_parse_card_crypto(ctx, param, val): return _parse_hex(val) @@ -162,9 +157,7 @@ def _prompt_credential_password(prompt="Enter credential password"): prompt, default="", hide_input=True, show_default=False ) - return _parse_password( - credential_password, CREDENTIAL_PASSWORD_LEN, "Credential password" - ) + return credential_password def _prompt_symmetric_key(type): @@ -180,10 +173,7 @@ def _fname(fobj): click_credential_password_option = click.option( - "-c", - "--credential-password", - help="password to protect credential", - callback=click_parse_credential_password, + "-c", "--credential-password", help="password to protect credential" ) click_management_key_option = click.option( @@ -272,15 +262,37 @@ def list(ctx): else: click.echo(f"Found {len(creds)} item(s)") - click.echo("Algo\tTouch\tRetries\tLabel") + 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( - "{0}\t{1}\t{2}\t{3}".format( - cred.algorithm, - parse_touch_required(cred.touch_required), - cred.counter, + 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, ) ) diff --git a/ykman/hsmauth.py b/ykman/hsmauth.py index 3ae90151..69a49f2b 100644 --- a/ykman/hsmauth.py +++ b/ykman/hsmauth.py @@ -43,10 +43,3 @@ def get_hsmauth_info(session: HsmAuthSession): def generate_random_management_key() -> bytes: """Generate a new random management key.""" return os.urandom(16) - - -def parse_touch_required(touch_required: bool) -> str: - if touch_required: - return "On" - else: - return "Off" From 26fd65e6d64b7b206eb4515fb3adbddaefe8aeee Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Mon, 7 Aug 2023 16:21:57 +0200 Subject: [PATCH 12/17] HSMAUTH: change credential password format in tests --- tests/device/test_hsmauth.py | 10 +++++----- tests/test_hsmauth.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/device/test_hsmauth.py b/tests/device/test_hsmauth.py index 9bb7bedb..84d396f1 100644 --- a/tests/device/test_hsmauth.py +++ b/tests/device/test_hsmauth.py @@ -33,7 +33,7 @@ def session(ccid_connection): def import_key_derived( session, management_key, - credential_password=b"123456", + credential_password="123456", derivation_password=b"password", ) -> Credential: credential = session.put_credential_derived( @@ -47,7 +47,7 @@ def import_key_derived( def import_key_symmetric( - session, management_key, key_enc, key_mac, credential_password=b"123456" + session, management_key, key_enc, key_mac, credential_password="123456" ) -> Credential: credential = session.put_credential_symmetric( management_key, @@ -61,7 +61,7 @@ def import_key_symmetric( def import_key_asymmetric( - session, management_key, private_key, credential_password=b"12345" + session, management_key, private_key, credential_password="123456" ) -> Credential: credential = session.put_credential_asymmetric( management_key, @@ -74,7 +74,7 @@ def import_key_asymmetric( def generate_key_asymmetric( - session, management_key, credential_password=b"12345" + session, management_key, credential_password="123456" ) -> Credential: credential = session.generate_credential_asymmetric( management_key, @@ -206,7 +206,7 @@ def test_management_key_retries(self, session): class TestSessionKeys: def test_calculate_session_keys_symmetric(self, session): - credential_password = b"1234" + credential_password = "1234" credential = import_key_derived( session, DEFAULT_MANAGEMENT_KEY, diff --git a/tests/test_hsmauth.py b/tests/test_hsmauth.py index 62e91687..68a7c002 100644 --- a/tests/test_hsmauth.py +++ b/tests/test_hsmauth.py @@ -22,7 +22,7 @@ def test_generate_random_management_key(self): assert 16 == len(output1) == len(output2) def test_parse_credential_password(self): - parsed_credential_password = _parse_credential_password(b"123456") + parsed_credential_password = _parse_credential_password("123456") assert ( b"123456\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" From 1d5770dc28a7bf089275cb03f9c7b99f4a0451b4 Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Mon, 7 Aug 2023 16:24:38 +0200 Subject: [PATCH 13/17] HSMAUTH: remove `retries` command --- ykman/_cli/hsmauth.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/ykman/_cli/hsmauth.py b/ykman/_cli/hsmauth.py index a67221c2..306c4660 100644 --- a/ykman/_cli/hsmauth.py +++ b/ykman/_cli/hsmauth.py @@ -31,7 +31,6 @@ InvalidPinError, ALGORITHM, MANAGEMENT_KEY_LEN, - CREDENTIAL_PASSWORD_LEN, DEFAULT_MANAGEMENT_KEY, ) from yubikit.core.smartcard import ApduError, SW @@ -648,18 +647,3 @@ def change(ctx, management_key, new_management_key, generate): handle_credential_error( e, default_exception_msg="Failed to change management key." ) - - -@access.command() -@click.pass_context -def retries(ctx): - """ - Get management key retries. - - This will retrieve the number of retiries left for the management key. - """ - - session = ctx.obj["session"] - - retries = session.get_management_key_retries() - click.echo(f"Retries left for Management Key: {retries}") From 88191b7493653dd15c60a55e9c0fd8535d76d1cc Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Mon, 7 Aug 2023 17:00:05 +0200 Subject: [PATCH 14/17] HSMAUTH: change command name for change management key --- tests/device/cli/test_hsmauth.py | 20 +++++++++----------- ykman/_cli/hsmauth.py | 2 +- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/tests/device/cli/test_hsmauth.py b/tests/device/cli/test_hsmauth.py index 87bde60d..b73a9f11 100644 --- a/tests/device/cli/test_hsmauth.py +++ b/tests/device/cli/test_hsmauth.py @@ -240,7 +240,7 @@ def test_change_management_key(self, ykman_cli): ykman_cli( "hsmauth", "access", - "change", + "change-management-key", "-m", DEFAULT_MANAGEMENT_KEY, "-n", @@ -252,7 +252,7 @@ def test_change_management_key(self, ykman_cli): ykman_cli( "hsmauth", "access", - "change", + "change-management-key", "-m", DEFAULT_MANAGEMENT_KEY, "-n", @@ -263,7 +263,7 @@ def test_change_management_key(self, ykman_cli): ykman_cli( "hsmauth", "access", - "change", + "change-management-key", "-m", NON_DEFAULT_MANAGEMENT_KEY, "-n", @@ -272,16 +272,14 @@ def test_change_management_key(self, ykman_cli): def test_change_management_key_generate(self, ykman_cli): output = ykman_cli( - "hsmauth", "access", "change", "-m", DEFAULT_MANAGEMENT_KEY, "-g" + "hsmauth", + "access", + "change-management-key", + "-m", + DEFAULT_MANAGEMENT_KEY, + "-g", ).output assert re.match( r"^Generated management key: [a-f0-9]{16}", output, re.MULTILINE ) - - def test_get_management_key_retries(self, ykman_cli): - output = ykman_cli("hsmauth", "access", "retries").output - - assert re.match( - r"^Retries left for Management Key: [0-9]", output, re.MULTILINE - ) diff --git a/ykman/_cli/hsmauth.py b/ykman/_cli/hsmauth.py index 306c4660..42acae53 100644 --- a/ykman/_cli/hsmauth.py +++ b/ykman/_cli/hsmauth.py @@ -600,7 +600,7 @@ def access(): help="generate a random management key " "(can't be used with --new-management-key)", ) -def change(ctx, management_key, new_management_key, generate): +def change_management_key(ctx, management_key, new_management_key, generate): """ Change the management key. From 0b6bea9af720ef6f2680e0c1d85f0406132a4894 Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Mon, 7 Aug 2023 17:12:04 +0200 Subject: [PATCH 15/17] HSMAUTH: close tmp file in tests --- tests/device/cli/test_hsmauth.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/device/cli/test_hsmauth.py b/tests/device/cli/test_hsmauth.py index b73a9f11..d803b12d 100644 --- a/tests/device/cli/test_hsmauth.py +++ b/tests/device/cli/test_hsmauth.py @@ -44,6 +44,7 @@ def eccp256_keypair(): def tmp_file(): tmp = tempfile.NamedTemporaryFile(delete=False) yield tmp + tmp.close() os.remove(tmp.name) From 58ce91e881c4e7b13ec753af9d5b5f2314413d91 Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Wed, 9 Aug 2023 09:34:59 +0200 Subject: [PATCH 16/17] HSMAUTH: add and change type annotations --- yubikit/hsmauth.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/yubikit/hsmauth.py b/yubikit/hsmauth.py index d269e542..b90c2152 100644 --- a/yubikit/hsmauth.py +++ b/yubikit/hsmauth.py @@ -194,7 +194,7 @@ class SessionKeys(NamedTuple): key_srmac: bytes @classmethod - def parse(cls, response) -> "SessionKeys": + def parse(cls, response: bytes) -> "SessionKeys": key_senc = response[:16] key_smac = response[16:32] key_srmac = response[32:48] @@ -318,7 +318,7 @@ def put_credential_derived( management_key: bytes, label: str, credential_password: Union[bytes, str], - derivation_password: Union[bytes, str], + derivation_password: str, touch_required: bool = False, ) -> Credential: """Import a symmetric YubiHSM Auth credential derived from password""" From 3ef0b0c7dbd28c1901a78c27dd8e09a38ee8587f Mon Sep 17 00:00:00 2001 From: Elias Bonnici Date: Wed, 9 Aug 2023 09:58:14 +0200 Subject: [PATCH 17/17] HSMAUTH: change type annotation in `_password_to_key` --- tests/test_hsmauth.py | 15 ++++++--------- yubikit/hsmauth.py | 8 +++----- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/tests/test_hsmauth.py b/tests/test_hsmauth.py index 68a7c002..7a9e284f 100644 --- a/tests/test_hsmauth.py +++ b/tests/test_hsmauth.py @@ -51,18 +51,15 @@ def test_password_to_key(self): a2b_hex("592fd483f759e29909a04c4505d2ce0a"), ) == _password_to_key("password") - assert ( - a2b_hex("090b47dbed595654901dee1cc655e420"), - a2b_hex("592fd483f759e29909a04c4505d2ce0a"), - ) == _password_to_key(b"password") - def test__password_to_key_utf8(self): assert ( a2b_hex("f320972c667ba5cd4d35119a6b0271a1"), a2b_hex("f10050ca688e5a6ce62b1ffb0f6f6869"), ) == _password_to_key("κόσμε") - assert ( - a2b_hex("f320972c667ba5cd4d35119a6b0271a1"), - a2b_hex("f10050ca688e5a6ce62b1ffb0f6f6869"), - ) == _password_to_key(a2b_hex("cebae1bdb9cf83cebcceb5")) + def test_password_to_key_bytes_fails(self): + with pytest.raises(AttributeError): + _password_to_key(b"password") + + with pytest.raises(AttributeError): + _password_to_key(a2b_hex("cebae1bdb9cf83cebcceb5")) diff --git a/yubikit/hsmauth.py b/yubikit/hsmauth.py index b90c2152..9b567093 100644 --- a/yubikit/hsmauth.py +++ b/yubikit/hsmauth.py @@ -142,15 +142,13 @@ def _parse_select(response): return Version.from_bytes(data) -def _password_to_key(password: Union[str, bytes]) -> Tuple[bytes, bytes]: +def _password_to_key(password: str) -> Tuple[bytes, bytes]: """Derive encryption and MAC key from a password. :return: A tuple containing the encryption key, and MAC key. """ - if isinstance(password, str): - pw_bytes = password.encode() - else: - pw_bytes = password + pw_bytes = password.encode() + key = PBKDF2HMAC( algorithm=hashes.SHA256(), length=32,