From e66403cf8d83661dd02b579179b7fdd9f2ef47b4 Mon Sep 17 00:00:00 2001 From: Jarno Elonen Date: Tue, 27 Aug 2024 18:17:10 +0300 Subject: [PATCH] Reorganize commands to reduce clutter --- README.md | 16 +- hsm_secrets/hsm/__init__.py | 440 ++++++++++++++++--------------- hsm_secrets/piv/__init__.py | 53 ++-- hsm_secrets/utils.py | 4 +- hsm_secrets/x509/__init__.py | 40 ++- hsm_secrets/x509/cert_checker.py | 38 +-- run-tests.sh | 65 +++-- 7 files changed, 370 insertions(+), 286 deletions(-) diff --git a/README.md b/README.md index 27e1d7d..1103a86 100644 --- a/README.md +++ b/README.md @@ -139,12 +139,14 @@ You can always force a different authentication type, though: 2. Perform initial setup on an airgapped system. In summary: - Connect all HSMs and reset to factory defaults - - Distribute a common wrap key with `hsm-secrets hsm make-wrap-key` - - Create a Shamir's Shared Secret admin key with `hsm-secrets hsm admin-sharing-ceremony` + - Distribute a common wrap key with `hsm-secrets hsm backup make-key` + - Create a Shamir's Shared Secret admin key with `hsm-secrets hsm admin sharing-ceremony` - shares can be optionally password-protected - Add user YubiKeys with `hsm-secrets user add-yubikey` - - Generate keys and certificates with `hsm-secrets hsm compare --create` and `hsm-secrets x509 create --all` - - Clone master HSM to other devices using `hsm backup` and `hsm restore` + - Generate keys and certificates with `hsm-secrets hsm objects create-missing` + - Apply your logging settings from config to the device with `hsm log apply-settings` + - Check that everything's been created with `hsm compare` + - Clone master HSM to other devices using `hsm backup export` and `hsm backup import` See [Setup Workflow](doc/setup-workflow.md) for the full process. @@ -155,10 +157,10 @@ You can always force a different authentication type, though: - `hsm-secrets pass rotate` to rotate derived password(s) 4. Rarely, for admin changes, on an airgapped computer: - - Temporarily enable default admin key with `hsm-secrets hsm default-admin-enable` (asks key custodians for SSSS shared secret) + - Temporarily enable default admin key with `hsm-secrets hsm admin default-enable` (asks key custodians for SSSS shared secret) - Make changes on one of the devices (master) - - Re-clone HSMs with `hsm backup` and `hsm restore` - - Disable the default admin again with `hsm-secrets hsm default-admin-disable` + - Re-clone HSMs with `hsm backup export` and `hsm backup import` + - Disable the default admin again with `hsm-secrets hsm admin default-disable` YubiHSM2 devices are easy to reset, so you might want to do a test-run or two before an actual production deployment. diff --git a/hsm_secrets/hsm/__init__.py b/hsm_secrets/hsm/__init__.py index 72f4a5d..e4293f3 100644 --- a/hsm_secrets/hsm/__init__.py +++ b/hsm_secrets/hsm/__init__.py @@ -10,6 +10,7 @@ from hsm_secrets.log import _check_and_format_audit_conf_differences from hsm_secrets.utils import HSMAuthMethod, HsmSecretsCtx, cli_error, cli_info, cli_result, cli_ui_msg, cli_warn, confirm_and_delete_old_yubihsm_object_if_exists, open_hsm_session, open_hsm_session_with_password, pass_common_args, pretty_fmt_yubihsm_object, prompt_for_secret, pw_check_fromhex +from hsm_secrets.x509 import x509_create_certs import yubihsm.defs, yubihsm.exceptions, yubihsm.objects # type: ignore [import] from yubihsm.defs import OBJECT # type: ignore [import] @@ -51,21 +52,20 @@ def cmd_hsm(ctx: click.Context): These commands generally require a group of HSM custodians working together on an airgapped machine to perform security-sensitive operations on the YubiHSMs. - `list-objects` is an exception. It can be run by anyone alone. + `objects list` is an exception. It can be ran by anyone alone. HSM setup workflow: 0. Connect all devices. 1. Reset devices to factory defaults. 2. Set a common wrap key to all devices. - 3. Host a Secret Sharing Ceremony to add a super admin key. + 3. Host a admin Secret Sharing Ceremony. 4. Add user keys (Yubikey auth) to master device. - 5. Generate keys on master device with `compare --create`. - 6. Create certificates etc from the keys. - 7. Check that all configure objects are present on master (`compare`). - 8. Clone master device to other ones (backup + restore). - 9. Double check that all keys are present on all devices (`compare --alldevs`). - 10. Remove default admin key from all devices. + 5. Generate keys and certs on master device with `objects create`. + 6. Check that all configure objects are present on master (`compare`). + 7. Clone master device to other ones (backup + restore). + 8. Double check that all keys are present on all devices (`compare --alldevs`). + 9. Remove default admin key from all devices. Management workflow: @@ -76,9 +76,42 @@ def cmd_hsm(ctx: click.Context): """ ctx.ensure_object(dict) + + +@cmd_hsm.group('backup') +def cmd_hsm_backup(): + """Device backup/restore commands""" + pass + +@cmd_hsm.group('admin') +def cmd_hsm_admin(): + """Admin key management commands""" + pass + +@cmd_hsm.group('objects') +def cmd_hsm_objects(): + """YubiHSM object management commands""" + pass + +# --------------- + +@cmd_hsm.command('compare') +@click.option('--alldevs', is_flag=True, help="Compare all devices") +@pass_common_args +def compare_config(ctx: HsmSecretsCtx, alldevs: bool): + """Compare config with device contents + + Lists all objects by type (auth, wrap, etc.) in the configuration file, and then checks + that they exist in the YubiHSM(s). Shows which objects are missing and which are found. + + By default, only checks the master device, using the default admin key. + Override with the options as needed. + """ + return _compare_create_config(ctx, alldevs, create=False) + # --------------- -@cmd_hsm.command('list-objects') +@cmd_hsm_objects.command('list') @pass_common_args @click.option('--alldevs', is_flag=True, help="List objects on all devices") def list_objects(ctx: HsmSecretsCtx, alldevs: bool): @@ -92,9 +125,187 @@ def list_objects(ctx: HsmSecretsCtx, alldevs: bool): cli_result(pretty_fmt_yubihsm_object(o.get_info())) cli_result("") +@cmd_hsm_objects.command('delete') +@click.argument('obj_ids', nargs=-1, type=str, metavar=' ...', shell_complete=click_hsm_obj_auto_complete(None)) +@click.option('--alldevs', is_flag=True, help="Delete on all devices") +@click.option('--force', is_flag=True, help="Force deletion without confirmation (use with caution)") +@pass_common_args +def delete_object(ctx: HsmSecretsCtx, obj_ids: tuple, alldevs: bool, force: bool): + """Delete object(s) from the YubiHSM + + Deletes an object(s) with the given ID or label from the YubiHSM. + YubiHSM2 can have the same id for different types of objects, so this command + asks you to confirm the type of the object before deleting it. + + With `--force` ALL objects with the given ID will be deleted + without confirmation, regardless of their type. + """ + hsm_serials = ctx.conf.general.all_devices.keys() if alldevs else [ctx.hsm_serial] + for serial in hsm_serials: + with open_hsm_session(ctx, HSMAuthMethod.DEFAULT_ADMIN, serial) as ses: + not_found = set(obj_ids) + for id_or_label in obj_ids: + try: + id_int = ctx.conf.find_def_non_typed(id_or_label).id + except KeyError: + cli_warn(f"Object '{id_or_label}' not found in the configuration file. Assuming it's raw ID on the device.") + id_int = parse_keyid(id_or_label) + objects = ses.list_objects() + for o in objects: + if o.id == id_int: + not_found.remove(id_or_label) + if not force: + cli_ui_msg("Object found:") + cli_ui_msg(pretty_fmt_yubihsm_object(o.get_info())) + click.confirm("Delete this object?", default=False, abort=True, err=True) + o.delete() + cli_info("Object deleted.") + if not_found: + cli_error(f"Objects not found on device {serial}: {', '.join(not_found)}") + +@cmd_hsm_objects.command('create-missing') +@pass_common_args +def create_missing_keys(ctx: HsmSecretsCtx): + """Create missing keys and certs on the master YubiHSM + + Compares the configuration file with the objects in the YubiHSM and creates + any missing keys and certificates. + """ + _compare_create_config(ctx, alldevs=False, create=True) + x509_create_certs(ctx, all_certs=True, dry_run=False, cert_ids=(), skip_existing=True) + +# --------------- + +def _compare_create_config(ctx: HsmSecretsCtx, alldevs: bool, create: bool): + assert not (alldevs and create), "Cannot use --alldevs and --create together." + + assert isinstance(ctx.conf, HSMConfig) + config_items_per_type, config_to_hsm_type = find_all_config_items_per_type(ctx.conf) + + cli_info("") + cli_info("Reading objects from the YubiHSM(s)...") + hsm_serials = ctx.conf.general.all_devices.keys() if alldevs else [ctx.hsm_serial] + for serial in hsm_serials: + with open_hsm_session(ctx, HSMAuthMethod.DEFAULT_ADMIN, serial) as ses: + device_objs = list(ses.list_objects()) + cli_info("") + cli_result(f"--- YubiHSM device {serial} ---") + objects_accounted_for = {} + n_created, n_skipped = 0, 0 + + for t, items in config_items_per_type.items(): + items = sorted(items, key=lambda x: x.id) + cli_result(f"{t.__name__}") + for it in items: + obj: yubihsm.objects.YhsmObject|MockYhsmObject|None = None + for o in device_objs: + if o.id == it.id and (o.object_type == config_to_hsm_type[t].object_type): + obj = o + objects_accounted_for[o.id] = True + break + checkbox = "[x]" if obj else "[ ]" + cli_result(f" {checkbox} '{it.label}' (0x{it.id:04x})") + if create: + need_create = obj is None + if need_create: + from hsm_secrets.config import HSMAsymmetricKey, HSMSymmetricKey, HSMWrapKey, HSMOpaqueObject, HSMHmacKey, HSMAuthKey + warn_emoji = click.style("⚠️", fg='yellow') + unsupported_types = { + HSMWrapKey: f"{warn_emoji} Use `hsm backup make-key` to create", + HSMAuthKey: f"{warn_emoji} Use `hsm admin` and `user` commands to create", + HSMOpaqueObject: "Creating shortly..." if create else "", + } + + gear_emoji = click.style("⚙️", fg='cyan') + + if it.__class__ in unsupported_types: + msg = unsupported_types[it.__class__] + if msg: + cli_result(f" └-> {msg}") + n_skipped += 1 + + elif isinstance(it, HSMAsymmetricKey): + cli_result(f" └-> {gear_emoji} Creating...") + confirm_and_delete_old_yubihsm_object_if_exists(ses, it.id, OBJECT.ASYMMETRIC_KEY) + cli_info(f"Generating asymmetric key, type '{it.algorithm}'...") + if 'rsa' in it.algorithm.lower(): + cli_warn(" Note! RSA key generation is very slow. Please wait. The YubiHSM2 should blinking rapidly while it works.") + ses.asym_key_generate(it) + cli_info(f"Symmetric key ID '{hex(it.id)}' ({it.label}) stored in YubiHSM device {ses.get_serial()}") + n_created += 1 + + elif isinstance(it, HSMSymmetricKey): + cli_result(f" └-> {gear_emoji} Creating...") + confirm_and_delete_old_yubihsm_object_if_exists(ses, it.id, OBJECT.SYMMETRIC_KEY) + cli_info(f"Generating symmetric key, type '{it.algorithm}'...") + ses.sym_key_generate(it) + cli_info(f"Symmetric key ID '{hex(it.id)}' ({it.label}) generated in YubiHSM device {ses.get_serial()}") + + n_created += 1 + elif isinstance(it, HSMHmacKey): + cli_result(f" └-> {gear_emoji} Creating...") + confirm_and_delete_old_yubihsm_object_if_exists(ses, it.id, OBJECT.HMAC_KEY) + cli_info(f"Generating HMAC key, type '{it.algorithm}'...") + ses.hmac_key_generate(it) + cli_info(f"HMAC key ID '{hex(it.id)}' ({it.label}) stored in YubiHSM device {ses.get_serial()}") + n_created += 1 + else: + cli_result(click.style(f" └-> Unsupported object type: {it.__class__.__name__}. This is a bug. SKIPPING.", fg='red')) + n_skipped += 1 + + if len(objects_accounted_for) < len(device_objs): + cli_result("EXTRA OBJECTS (on the device but not in the config)") + for o in device_objs: + if o.id not in objects_accounted_for: + info = o.get_info() + cli_result(f" ??? '{str(info.label)}' (0x{o.id:04x}) <{o.object_type.name}>") + + if create: + cli_info("") + cli_info(f"KEY CREATION REPORT: Created {n_created} objects, skipped {n_skipped} objects.") + + + # Check logging / audit settings + cur_audit_settings, unknown_audit_commands = ses.get_audit_settings() + if unknown_audit_commands: + cli_result("UNKNOWN AUDIT LOG COMMANDS ON DEVICE (not necessarily a problem)") + for k, v in unknown_audit_commands.items(): + cli_result(f" ??? {k} = {v}") + + new_audit_setting = ctx.conf.admin.audit + ctx.conf.admin.audit.apply_defaults() + + audit_settings_diff = _check_and_format_audit_conf_differences(cur_audit_settings, new_audit_setting, raise_if_fixed_change=False) + if audit_settings_diff: + cli_result("MISMATCHING AUDIT LOG SETTINGS (device -> config)") + cli_result(audit_settings_diff) + cli_warn("Use 'log apply-settings' to change audit settings to match the configuration.") + + cli_info("") + +# --------------- + +@cmd_hsm.command('attest') +@pass_common_args +@click.argument('key_id', required=True, type=str, metavar='', shell_complete=click_hsm_obj_auto_complete(HSMAsymmetricKey)) +@click.option('--out', '-o', type=click.File('w', encoding='utf8'), help='Output file (default: stdout)', default=click.get_text_stream('stdout')) +def attest_key(ctx: HsmSecretsCtx, key_id: str, out: click.File): + """Attest an asymmetric key in the YubiHSM + + Create an a key attestation certificate, signed by the + Yubico attestation key, for the given key ID (in hex). + """ + from cryptography.hazmat.primitives.serialization import Encoding + id = ctx.conf.find_def(key_id, HSMAsymmetricKey).id + with open_hsm_session(ctx, HSMAuthMethod.DEFAULT_ADMIN, ctx.hsm_serial) as ses: + cert = ses.attest_asym_key(id) + pem = cert.public_bytes(Encoding.PEM).decode('UTF-8') + out.write(pem) # type: ignore + cli_info(f"Key 0x{id:04x} attestation certificate written to '{out.name}'") + # --------------- -@cmd_hsm.command('default-admin-enable') +@cmd_hsm_admin.command('default-enable') @pass_common_args @click.option('--use-backup-secret', is_flag=True, help="Use backup secret instead of shared secret") @click.option('--alldevs', is_flag=True, help="Add on all devices") @@ -145,9 +356,7 @@ def do_it(conf: HSMConfig, ses: HSMSession): raise click.ClickException("Failed to authenticate with the provided password.") -# --------------- - -@cmd_hsm.command('default-admin-disable') +@cmd_hsm_admin.command('default-disable') @pass_common_args @click.option('--alldevs', is_flag=True, help="Remove on all devices") @click.option('--force', is_flag=True, help="Force removal even if no other admin key exists") @@ -184,9 +393,8 @@ def default_admin_disable(ctx: HsmSecretsCtx, alldevs: bool, force: bool): click.pause("Press ENTER to continue.", err=True) raise e -# --------------- -@cmd_hsm.command('admin-sharing-ceremony') +@cmd_hsm_admin.command('sharing-ceremony') @click.option('--num-shares', '-n', type=int, required=True, help="Number of shares to generate") @click.option('--threshold', '-t', type=int, required=True, help="Number of shares required to reconstruct the key") @click.option('--with-backup', '-b', is_flag=True, default=False, help="Generate a backup key in addition to the shared key") @@ -227,16 +435,15 @@ def apply_password_fn(new_password: str): cli_info("OK. Shared admin key added successfully.") - # --------------- -@cmd_hsm.command('make-wrap-key') +@cmd_hsm_backup.command('make-key') @pass_common_args def make_wrap_key(ctx: HsmSecretsCtx): - """Generate a new wrap key for all YubiHSMs + """Add backup wrap key to all YubiHSMs Generate a new wrap key and set it to all configured YubiHSMs. - It is used to export/import keys securely between the devices. + It is used to export/import objects securely between the devices. This requires all the devices in config file to be connected and reachable. Note that the key is NOT printed out, only stored in the devices. @@ -263,195 +470,12 @@ def make_wrap_key(ctx: HsmSecretsCtx): del secret cli_info(f"OK. Common wrap key added to all devices (serials: {', '.join(hsm_serials)}).") -# --------------- - -@cmd_hsm.command('delete') -@click.argument('obj_ids', nargs=-1, type=str, metavar=' ...', shell_complete=click_hsm_obj_auto_complete(None)) -@click.option('--alldevs', is_flag=True, help="Delete on all devices") -@click.option('--force', is_flag=True, help="Force deletion without confirmation (use with caution)") -@pass_common_args -def delete_object(ctx: HsmSecretsCtx, obj_ids: tuple, alldevs: bool, force: bool): - """Delete object(s) from the YubiHSM - - Deletes an object(s) with the given ID or label from the YubiHSM. - YubiHSM2 can have the same id for different types of objects, so this command - asks you to confirm the type of the object before deleting it. - - With `--force` ALL objects with the given ID will be deleted - without confirmation, regardless of their type. - """ - hsm_serials = ctx.conf.general.all_devices.keys() if alldevs else [ctx.hsm_serial] - for serial in hsm_serials: - with open_hsm_session(ctx, HSMAuthMethod.DEFAULT_ADMIN, serial) as ses: - not_found = set(obj_ids) - for id_or_label in obj_ids: - try: - id_int = ctx.conf.find_def_non_typed(id_or_label).id - except KeyError: - cli_warn(f"Object '{id_or_label}' not found in the configuration file. Assuming it's raw ID on the device.") - id_int = parse_keyid(id_or_label) - objects = ses.list_objects() - for o in objects: - if o.id == id_int: - not_found.remove(id_or_label) - if not force: - cli_ui_msg("Object found:") - cli_ui_msg(pretty_fmt_yubihsm_object(o.get_info())) - click.confirm("Delete this object?", default=False, abort=True, err=True) - o.delete() - cli_info("Object deleted.") - if not_found: - cli_error(f"Objects not found on device {serial}: {', '.join(not_found)}") - -# --------------- - -@cmd_hsm.command('compare') -@click.option('--alldevs', is_flag=True, help="Compare all devices") -@click.option('--create', is_flag=True, help="Create missing keys in the YubiHSM") -@pass_common_args -def compare_config(ctx: HsmSecretsCtx, alldevs: bool, create: bool): - """Compare config with device contents - - Lists all objects by type (auth, wrap, etc.) in the configuration file, and then checks - that they exist in the YubiHSM(s). Shows which objects are missing and which are found. - - By default, only checks the master device, using the default admin key. - Override with the options as needed. - - If `--create` is given, missing keys will be created in the YubiHSM. - It only supports one device at a time, and requires the default admin key. - """ - if create and alldevs: - raise click.ClickException("The --create option only supports one device at a time, and requires the default admin key.") - - assert isinstance(ctx.conf, HSMConfig) - config_items_per_type, config_to_hsm_type = find_all_config_items_per_type(ctx.conf) - - cli_info("") - cli_info("Reading objects from the YubiHSM(s)...") - hsm_serials = ctx.conf.general.all_devices.keys() if alldevs else [ctx.hsm_serial] - for serial in hsm_serials: - with open_hsm_session(ctx, HSMAuthMethod.DEFAULT_ADMIN, serial) as ses: - device_objs = list(ses.list_objects()) - cli_info("") - cli_result(f"--- YubiHSM device {serial} ---") - objects_accounted_for = {} - n_created, n_skipped = 0, 0 - - for t, items in config_items_per_type.items(): - items = sorted(items, key=lambda x: x.id) - cli_result(f"{t.__name__}") - for it in items: - obj: yubihsm.objects.YhsmObject|MockYhsmObject|None = None - for o in device_objs: - if o.id == it.id and (o.object_type == config_to_hsm_type[t].object_type): - obj = o - objects_accounted_for[o.id] = True - break - checkbox = "[x]" if obj else "[ ]" - cli_result(f" {checkbox} '{it.label}' (0x{it.id:04x})") - if create: - need_create = obj is None - if need_create: - from hsm_secrets.config import HSMAsymmetricKey, HSMSymmetricKey, HSMWrapKey, HSMOpaqueObject, HSMHmacKey, HSMAuthKey - unsupported_types = (HSMWrapKey, HSMAuthKey, HSMOpaqueObject) - - gear_emoji = click.style("⚙️", fg='cyan') - - if isinstance(it, unsupported_types): - warn_emoji = click.style("⚠️", fg='yellow') - cli_result(f" └-> {warn_emoji} Cannot create '{it.__class__.__name__}' objects. Use other commands.") - n_skipped += 1 - - elif isinstance(it, HSMAsymmetricKey): - cli_result(f" └-> {gear_emoji} Creating...") - confirm_and_delete_old_yubihsm_object_if_exists(ses, it.id, OBJECT.ASYMMETRIC_KEY) - cli_info(f"Generating asymmetric key, type '{it.algorithm}'...") - if 'rsa' in it.algorithm.lower(): - cli_warn(" Note! RSA key generation is very slow. Please wait. The YubiHSM2 should blinking rapidly while it works.") - ses.asym_key_generate(it) - cli_info(f"Symmetric key ID '{hex(it.id)}' ({it.label}) stored in YubiHSM device {ses.get_serial()}") - n_created += 1 - - elif isinstance(it, HSMSymmetricKey): - cli_result(f" └-> {gear_emoji} Creating...") - confirm_and_delete_old_yubihsm_object_if_exists(ses, it.id, OBJECT.SYMMETRIC_KEY) - cli_info(f"Generating symmetric key, type '{it.algorithm}'...") - ses.sym_key_generate(it) - cli_info(f"Symmetric key ID '{hex(it.id)}' ({it.label}) generated in YubiHSM device {ses.get_serial()}") - - n_created += 1 - elif isinstance(it, HSMHmacKey): - cli_result(f" └-> {gear_emoji} Creating...") - confirm_and_delete_old_yubihsm_object_if_exists(ses, it.id, OBJECT.HMAC_KEY) - cli_info(f"Generating HMAC key, type '{it.algorithm}'...") - ses.hmac_key_generate(it) - cli_info(f"HMAC key ID '{hex(it.id)}' ({it.label}) stored in YubiHSM device {ses.get_serial()}") - n_created += 1 - else: - cli_result(click.style(f" └-> Unsupported object type: {it.__class__.__name__}. This is a bug. SKIPPING.", fg='red')) - n_skipped += 1 - - if len(objects_accounted_for) < len(device_objs): - cli_result("EXTRA OBJECTS (on the device but not in the config)") - for o in device_objs: - if o.id not in objects_accounted_for: - info = o.get_info() - cli_result(f" ??? '{str(info.label)}' (0x{o.id:04x}) <{o.object_type.name}>") - - if create: - cli_info("") - cli_info(f"KEY CREATION REPORT: Created {n_created} objects, skipped {n_skipped} objects. Run the command again without --create to verify status.") - - - # Check logging / audit settings - - cur_audit_settings, unknown_audit_commands = ses.get_audit_settings() - if unknown_audit_commands: - cli_result("UNKNOWN AUDIT LOG COMMANDS ON DEVICE (not necessarily a problem)") - for k, v in unknown_audit_commands.items(): - cli_result(f" ??? {k} = {v}") - - new_audit_setting = ctx.conf.admin.audit - ctx.conf.admin.audit.apply_defaults() - - audit_settings_diff = _check_and_format_audit_conf_differences(cur_audit_settings, new_audit_setting, raise_if_fixed_change=False) - if audit_settings_diff: - cli_result("MISMATCHING AUDIT LOG SETTINGS (device -> config)") - cli_result(audit_settings_diff) - if create: - cli_warn("Use 'hsm apply-audit-settings' to change audit settings to match the configuration.") - - cli_info("") - - -# --------------- - -@cmd_hsm.command('attest') -@pass_common_args -@click.argument('key_id', required=True, type=str, metavar='', shell_complete=click_hsm_obj_auto_complete(HSMAsymmetricKey)) -@click.option('--out', '-o', type=click.File('w', encoding='utf8'), help='Output file (default: stdout)', default=click.get_text_stream('stdout')) -def attest_key(ctx: HsmSecretsCtx, key_id: str, out: click.File): - """Attest an asymmetric key in the YubiHSM - - Create an a key attestation certificate, signed by the - Yubico attestation key, for the given key ID (in hex). - """ - from cryptography.hazmat.primitives.serialization import Encoding - id = ctx.conf.find_def(key_id, HSMAsymmetricKey).id - with open_hsm_session(ctx, HSMAuthMethod.DEFAULT_ADMIN, ctx.hsm_serial) as ses: - cert = ses.attest_asym_key(id) - pem = cert.public_bytes(Encoding.PEM).decode('UTF-8') - out.write(pem) # type: ignore - cli_info(f"Key 0x{id:04x} attestation certificate written to '{out.name}'") - -# --------------- -@cmd_hsm.command('backup') +@cmd_hsm_backup.command('export') @pass_common_args @click.option('--out', '-o', type=click.Path(exists=False, allow_dash=False), required=False, help='Output file', default=None) -def backup_hsm(ctx: HsmSecretsCtx, out: click.File|None): - """Make a .tar.gz backup of HSM +def backup_export(ctx: HsmSecretsCtx, out: click.File|None): + """Export backup .tar.gz of HSM Exports all objects under wrap from the YubiHSM and saves them to a .tar.gz file. The file can be used to restore the objects @@ -504,12 +528,12 @@ def backup_hsm(ctx: HsmSecretsCtx, out: click.File|None): cli_error(f"Skipped {skipped} objects due to errors or insufficient permissions.") -@cmd_hsm.command('restore') +@cmd_hsm_backup.command('import') @pass_common_args @click.argument('backup_file', type=click.Path(exists=True, allow_dash=False, dir_okay=False), required=True, metavar='') @click.option('--force', is_flag=True, help="Don't ask for confirmation before restoring") -def restore_hsm(ctx: HsmSecretsCtx, backup_file: str, force: bool): - """Restore a .tar.gz backup to HSM +def backup_import(ctx: HsmSecretsCtx, backup_file: str, force: bool): + """Restore backup .tar.gz to HSM Imports all objects from a .tar.gz backup file to the YubiHSM. The backup file must have been created with the `hsm backup` command, file names diff --git a/hsm_secrets/piv/__init__.py b/hsm_secrets/piv/__init__.py index 6ff88b3..af152f2 100644 --- a/hsm_secrets/piv/__init__.py +++ b/hsm_secrets/piv/__init__.py @@ -126,15 +126,17 @@ def import_to_yubikey_piv( @click.option('--template', '-t', required=False, help="Template label, default: first template") @click.option('--subject', '-s', required=False, help="Cert subject (DN), default: from config") @click.option('--validity', '-v', type=int, help="Validity period in days, default: from config") -@click.option('--key-type', '-k', type=click.Choice(['RSA2048', 'ECP256', 'ECP384']), default='RSA2048', help="Key type, default: same as CA") +@click.option('--key-type', '-k', type=click.Choice(['RSA2048', 'ECP256', 'ECP384']), help="Key type, default: same as CA") +@click.option('--csr', type=click.Path(exists=True, dir_okay=False, resolve_path=True), help="Path to existing CSR file") @click.option('--ca', '-c', required=False, help="CA ID (hex) or label, default: from config") @click.option('--out', '-o', required=False, type=click.Path(exists=False, dir_okay=False, resolve_path=True, allow_dash=False), help="Output filename stem, default: ./-piv[.key/.cer]") @click.option('--os-type', type=click.Choice(['windows', 'other']), default='windows', help="Target operating system") @click.option('--san', multiple=True, help="AdditionalSANs, e.g., 'DNS:example.com', 'IP:10.0.0.2', etc.") -def create_piv_cert(ctx: HsmSecretsCtx, user: str, template: str|None, subject: str, validity: int, key_type: str, ca: str, out: str, os_type: Literal["windows", "other"], san: List[str]): - """Create a PIV user certificate +def create_piv_cert(ctx: HsmSecretsCtx, user: str, template: str|None, subject: str, validity: int, key_type: Literal['RSA2048', 'ECP256', 'ECP384']|None, csr: str|None, ca: str, out: str, os_type: Literal["windows", "other"], san: List[str]): + """Create or sign a PIV user certificate - This command generates a new PIV user certificate and key pair, and signs it with a CA certificate. + If a CSR is provided, this command signs the CSR with a CA certificate. + Otherwise it generates a new key pair (key type required) and signs a certificate for it. Example SAN types: - RFC822:alice@example.com @@ -181,9 +183,18 @@ def create_piv_cert(ctx: HsmSecretsCtx, user: str, template: str|None, subject: if v: subject += f",{k}={v}" - # Generate key pair - key_type_enum = PivKeyType[key_type] - _public_key, private_key = _generate_piv_key_pair(key_type_enum) + # Handle CSR or key generation + if csr: + with open(csr, 'rb') as f: + csr_obj = x509.load_pem_x509_csr(f.read()) + private_key = None + elif key_type: + key_type_enum = PivKeyType[key_type] + _, private_key = _generate_piv_key_pair(key_type_enum) + csr_obj = None + else: + raise click.ClickException("Either --csr or --key-type must be provided") + # Add explicitly provided SANs x509_info.subject_alt_name = x509_info.subject_alt_name or x509_info.SubjectAltName() @@ -205,7 +216,7 @@ def create_piv_cert(ctx: HsmSecretsCtx, user: str, template: str|None, subject: x509_info.subject_alt_name.names.setdefault('rfc822', []).append(user) # Create X509CertBuilder - cert_builder = X509CertBuilder(ctx.conf, x509_info, private_key, dn_subject_override=subject) + cert_builder = X509CertBuilder(ctx.conf, x509_info, private_key or csr_obj, dn_subject_override=subject) # Sign the certificate with CA ca_id = ca or ctx.conf.piv.default_ca_id @@ -220,20 +231,22 @@ def create_piv_cert(ctx: HsmSecretsCtx, user: str, template: str|None, subject: PIVUserCertificateChecker(signed_cert, os_type).check_and_show_issues() - # Save files - key_file.write_bytes(private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption() - )) - - csr = cert_builder.generate_csr() - csr_file.write_bytes(csr.public_bytes(serialization.Encoding.PEM)) + # Save files + if private_key: + key_file.write_bytes(private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption() + )) + cli_info(f"Private key saved to: {key_file}") + + csr_obj = cert_builder.generate_csr() + csr_file.write_bytes(csr_obj.public_bytes(serialization.Encoding.PEM)) + cli_info(f"CSR saved to: {csr_file}") + elif csr: + cli_info(f"Using provided CSR: {csr}") _save_pem_certificate(signed_cert, cer_file.open('wb')) - - cli_info(f"Private key saved to: {key_file}") - cli_info(f"CSR saved to: {csr_file}") cli_info(f"Certificate saved to: {cer_file}") diff --git a/hsm_secrets/utils.py b/hsm_secrets/utils.py index f802688..040da60 100644 --- a/hsm_secrets/utils.py +++ b/hsm_secrets/utils.py @@ -424,13 +424,13 @@ def confirm_and_delete_old_yubihsm_object_if_exists(ses: HSMSession, obj_id: hsc :param serial: The serial number of the YubiHSM device :param hsm_key_obj: The object to check for :param abort: Whether to abort (raise) if the user does not want to delete the object - :return: True if the object doesn't exist or was deleted, False if the user chose not to delete it + :return: True if the object doesn't exist or was deleted, False if user chose not to delete it """ if info := ses.object_exists_raw(obj_id, object_type): cli_ui_msg(f"Object 0x{obj_id:04x} already exists on YubiHSM device:") cli_ui_msg(pretty_fmt_yubihsm_object(info)) cli_info("") - if click.confirm("Replace the existing key?", default=False, abort=abort, err=True): + if click.confirm("Replace the existing object?", default=False, abort=abort, err=True): ses.delete_object_raw(obj_id, object_type) else: return False diff --git a/hsm_secrets/x509/__init__.py b/hsm_secrets/x509/__init__.py index 972f1c4..6a7e72b 100644 --- a/hsm_secrets/x509/__init__.py +++ b/hsm_secrets/x509/__init__.py @@ -1,3 +1,4 @@ +from copy import deepcopy import click import datetime from pathlib import Path @@ -68,7 +69,7 @@ def create_cert_cmd(ctx: HsmSecretsCtx, all_certs: bool, dry_run: bool, certs: t """ if not all_certs and not certs: raise click.ClickException("Error: No certificates specified for creation.") - create_certs_impl(ctx, all_certs, dry_run, certs) + x509_create_certs(ctx, all_certs, dry_run, certs) # --------------- @@ -116,7 +117,7 @@ def get_cert_cmd(ctx: HsmSecretsCtx, all_certs: bool, outdir: str|None, bundle: # --------------- -def create_certs_impl(ctx: HsmSecretsCtx, all_certs: bool, dry_run: bool, cert_ids: tuple): +def x509_create_certs(ctx: HsmSecretsCtx, all_certs: bool, dry_run: bool, cert_ids: tuple, skip_existing: bool = False): """ Create certificates on a YubiHSM2, based on the configuration file and CLI arguments. Performs a topological sort of the certificates to ensure that any dependencies are created first. @@ -137,14 +138,21 @@ def _do_it(ses: HSMSession|None): creation_order = topological_sort_x509_cert_defs(selected_defs) id_to_cert_obj: dict[HSMKeyID, x509.Certificate] = {} + existing_cert_ids = set() + cert_issues: list[tuple[HSMOpaqueObject, list]] = [] # Create the certificates in topological order for cd in creation_order: + if skip_existing: + if ses and ses.object_exists(cd): + existing_cert_ids.add(cd.id) + continue + x509_info = merge_x509_info_with_defaults(scid_to_x509_def[cd.id].x509_info, ctx.conf) issuer = scid_to_opq_def[cd.sign_by] if cd.sign_by and cd.sign_by != cd.id else None signer = f"signed by: '{issuer.label}'" if issuer else 'self-signed' - cli_info(f"Creating 0x{cd.id:04x}: '{cd.label}' ({signer})") + cli_info(f"\nCreating 0x{cd.id:04x}: '{cd.label}' ({signer})") cli_info(indent(pretty_x509_info(x509_info), " ")) if not dry_run: @@ -157,13 +165,14 @@ def _do_it(ses: HSMSession|None): issuer_cert = id_to_cert_obj.get(cd.sign_by) if not issuer_cert: # Issuer cert was not created on this run, try to load it from the HSM - if not ses.object_exists(cd): - raise click.ClickException(f"ERROR: Certificate 0x{cd.sign_by:04x} not found in HSM. Create it first, to sign 0x{cd.id:04x}.") - issuer_cert = ses.get_certificate(cd) + issuer_def = scid_to_opq_def[cd.sign_by] + if not ses.object_exists(issuer_def): + raise click.ClickException(f"ERROR: Certificate 0x{cd.sign_by:04x} not found in HSM. Create it first to sign 0x{cd.id:04x}.") + issuer_cert = ses.get_certificate(issuer_def) sign_key_def = scid_to_x509_def[cd.sign_by].key if not ses.object_exists(sign_key_def): - raise click.ClickException(f"ERROR: Key 0x{sign_key_def.id:04x} not found in HSM. Create it first, to sign 0x{cd.id:04x}.") + raise click.ClickException(f"ERROR: Key 0x{sign_key_def.id:04x} not found in HSM. Create it first to sign 0x{cd.id:04x}.") issuer_key = ses.get_private_key(sign_key_def) # Create and sign the certificate @@ -174,20 +183,29 @@ def _do_it(ses: HSMSession|None): assert issuer_key id_to_cert_obj[cd.id] = builder.build_and_sign(issuer_cert, issuer_key) # NOTE: We'll assume all signed certs on HSM are CA -- fix this if storing leaf certs for some reason - X509IntermediateCACertificateChecker(id_to_cert_obj[cd.id]).check_and_show_issues() + issues = X509IntermediateCACertificateChecker(id_to_cert_obj[cd.id]).check_and_show_issues() + cert_issues.append((cd, issues)) else: id_to_cert_obj[cd.id] = builder.generate_and_self_sign() cli_info(f"Self-signed certificate created; assuming it's a root CA for checks...") - X509RootCACertificateChecker(id_to_cert_obj[cd.id]).check_and_show_issues() - + issues = X509RootCACertificateChecker(id_to_cert_obj[cd.id]).check_and_show_issues() + cert_issues.append((cd, issues)) # Put the certificates into the HSM for cd in creation_order: + if skip_existing and cd.id in existing_cert_ids: + continue if not dry_run: assert isinstance(ses, HSMSession) if confirm_and_delete_old_yubihsm_object_if_exists(ses, cd.id, yubihsm.defs.OBJECT.OPAQUE, abort=False): ses.put_certificate(cd, id_to_cert_obj[cd.id]) - cli_info(f"Certificate 0x{cd.id:04x} created and stored in YubiHSM (serial {ctx.hsm_serial}).") + cli_info(f"Certificate 0x{cd.id:04x} stored in YubiHSM {ctx.hsm_serial}.") + + # Show any issues found during certificate creation + for cd, issues in cert_issues: + if issues: + cli_warn(f"\n-- Check results for certificate 0x{cd.id:04x} ({cd.label}) --") + X509RootCACertificateChecker.show_issues(issues) if dry_run: cli_warn("DRY RUN. Would create the following certificates:") diff --git a/hsm_secrets/x509/cert_checker.py b/hsm_secrets/x509/cert_checker.py index 5455061..439e7bb 100644 --- a/hsm_secrets/x509/cert_checker.py +++ b/hsm_secrets/x509/cert_checker.py @@ -1,3 +1,4 @@ +from typing import Any, Callable from cryptography import x509 from cryptography.hazmat.primitives.asymmetric import rsa, ec, ed25519, ed448 from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID @@ -88,8 +89,6 @@ def _check_key_type_and_size(self): if isinstance(public_key, rsa.RSAPublicKey): if public_key.key_size < 2048: self._add_issue(f"RSA key size ({public_key.key_size}) is less than 2048 bits", IssueSeverity.ERROR) - elif public_key.key_size < 3072: - self._add_issue(f"RSA key size ({public_key.key_size}) is less than 3072 bits", IssueSeverity.NOTICE) elif isinstance(public_key, ec.EllipticCurvePublicKey): if public_key.curve.key_size < 256: self._add_issue(f"EC key size ({public_key.curve.key_size}) is less than 256 bits", IssueSeverity.ERROR) @@ -188,16 +187,17 @@ def _check_specific_subject_common_name_consistency(self, cn_value: str, san: x5 # To be implemented by subclasses pass - def show_issues(self): - notices = [message for severity, message in self.issues if severity == IssueSeverity.NOTICE] - warnings = [message for severity, message in self.issues if severity == IssueSeverity.WARNING] - errors = [message for severity, message in self.issues if severity == IssueSeverity.ERROR] + @staticmethod + def show_issues(issues: list[tuple[IssueSeverity, str]]): + notices = [message for severity, message in issues if severity == IssueSeverity.NOTICE] + warnings = [message for severity, message in issues if severity == IssueSeverity.WARNING] + errors = [message for severity, message in issues if severity == IssueSeverity.ERROR] if not (notices or warnings or errors): return - prn = cli_error if errors else (cli_warn if warnings else cli_info) - prn("\nDetected issues:") + prn: Any = cli_error if errors else (cli_warn if warnings else cli_info) + prn("Detected issues:") if notices: cli_info(" - ℹ️ Cert notices:") @@ -215,9 +215,10 @@ def show_issues(self): cli_error(f" - {msg}") - def check_and_show_issues(self): + def check_and_show_issues(self) -> list[tuple[IssueSeverity, str]]: self.check() - self.show_issues() + self.show_issues(self.issues) + return self.issues # ------ @@ -251,12 +252,13 @@ def _check_specific_key_usage(self, key_usage: x509.KeyUsage): self._add_issue("KeyUsage includes keyEncipherment, which is not typically needed for CA certificates", IssueSeverity.WARNING) def _check_name_constraints(self): - try: - nc = self.certificate.extensions.get_extension_for_class(x509.NameConstraints) - if not nc.critical: - self._add_issue("NameConstraints extension should be marked critical", IssueSeverity.WARNING) - except x509.ExtensionNotFound: - self._add_issue("NameConstraints extension not found", IssueSeverity.NOTICE) + #try: + # nc = self.certificate.extensions.get_extension_for_class(x509.NameConstraints) + # if not nc.critical: + # self._add_issue("NameConstraints extension should be marked critical", IssueSeverity.WARNING) + #except x509.ExtensionNotFound: + # self._add_issue("NameConstraints extension not found", IssueSeverity.NOTICE) + pass def _check_policy_extensions(self): try: @@ -283,8 +285,6 @@ def _check_key_type_and_size(self): if isinstance(public_key, rsa.RSAPublicKey): if public_key.key_size < 2048: self._add_issue(f"RSA key size ({public_key.key_size}) is less than 2048 bits", IssueSeverity.ERROR) - elif public_key.key_size < 3072: - self._add_issue(f"RSA key size ({public_key.key_size}) is less than 3072 bits", IssueSeverity.NOTICE) elif isinstance(public_key, ec.EllipticCurvePublicKey): if public_key.curve.key_size < 256: self._add_issue(f"EC key size ({public_key.curve.key_size}) is less than 256 bits", IssueSeverity.ERROR) @@ -354,4 +354,4 @@ def _check_path_length_constraint(self, path_length): if path_length is None: self._add_issue("No path length constraint set.", IssueSeverity.NOTICE) elif path_length > 0: - self._add_issue(f"Path length constraint is set to {path_length}. Ensure this aligns with your intended PKI structure.", IssueSeverity.NOTICE) + self._add_issue(f"Path length constraint is set to {path_length}. Ensure you need this intermediate CA to sign more intermediates.", IssueSeverity.NOTICE) diff --git a/run-tests.sh b/run-tests.sh index 19e6fe1..3b23561 100755 --- a/run-tests.sh +++ b/run-tests.sh @@ -52,12 +52,9 @@ assert_not_grep() { } setup() { - run_cmd -q hsm compare --create + local output=$(run_cmd -q hsm objects create-missing) assert_success - - local output=$(run_cmd x509 cert create -a) - assert_success - echo "$output" + #echo "$output" assert_not_grep "Cert errors" "$output" assert_not_grep "Cert warnings" "$output" @@ -76,14 +73,14 @@ setup() { EOF assert_success - run_cmd -q hsm make-wrap-key + run_cmd -q hsm backup make-key assert_success } # ------------------ test cases ------------------------- test_fresh_device() { - local count=$(run_cmd -q hsm list-objects | grep -c '^0x') + local count=$(run_cmd -q hsm objects list | grep -c '^0x') [ "$count" -eq 1 ] || { echo "Expected 1 object, but found $count"; return 1; } } @@ -93,7 +90,7 @@ test_create_all() { # Run simplified secret sharing command expect << EOF $EXPECT_PREAMBLE - spawn sh -c "$CMD hsm admin-sharing-ceremony --skip-ceremony -n 3 -t 2 2>&1" + spawn sh -c "$CMD hsm admin sharing-ceremony --skip-ceremony -n 3 -t 2 2>&1" expect { "airgapped" { sleep 0.1; send "y\r"; exp_continue } "admin password" { sleep 0.1; send "passw123\r"; exp_continue } @@ -110,7 +107,7 @@ EOF [ "$count" -eq 40 ] || { echo "Expected 40 objects, but found $count"; return 1; } # Remove default admin key - run_cmd hsm default-admin-disable + run_cmd hsm admin default-disable assert_success local count=$(run_cmd -q hsm compare | grep -c '\[x\]') assert_success @@ -143,21 +140,50 @@ test_tls_certificates() { [ -f $TEMPDIR/www-example-com.chain.pem ] || { echo "ERROR: Chain bundle not saved"; return 1; } } +test_piv_user_certificate_key_type() { + setup + + local output=$(run_cmd piv user-cert -u test.user@example.com --os-type windows --key-type RSA2048 --san "RFC822:test.user@example.com" --san "DIRECTORY:C=US,O=Organization,CN=test.user" --out $TEMPDIR/testuser-piv-key) + assert_success + echo "$output" + assert_not_grep "Cert errors" "$output" + assert_not_grep "Cert warnings" "$output" + + [ -f $TEMPDIR/testuser-piv-key.key.pem ] || { echo "ERROR: Key not saved"; return 1; } + [ -f $TEMPDIR/testuser-piv-key.csr.pem ] || { echo "ERROR: CSR not saved"; return 1; } + [ -f $TEMPDIR/testuser-piv-key.cer.pem ] || { echo "ERROR: Certificate not saved"; return 1; } -test_piv_user_certificate() { + local cert_output=$(openssl x509 -in $TEMPDIR/testuser-piv-key.cer.pem -text -noout) + assert_success + echo "$cert_output" + assert_grep "Subject:.*CN.*=.*test.user@example.com" "$cert_output" + assert_grep "X509v3 Subject Alternative Name:" "$cert_output" + assert_grep "test[.]user@example[.]com" "$cert_output" + assert_grep "Organization.*test[.]user" "$cert_output" + assert_grep "Key Usage: critical" "$cert_output" + assert_grep "Extended Key Usage" "$cert_output" + assert_grep "Smartcard" "$cert_output" + assert_grep "Client Authentication" "$cert_output" +} + +test_piv_user_certificate_csr() { setup - local output=$(run_cmd piv user-cert -u test.user@example.com --os-type windows --san "RFC822:test.user@example.com" --san "DIRECTORY:C=US,O=Organization,CN=test.user" --out $TEMPDIR/testuser-piv) + # Generate a CSR + openssl ecparam -genkey -name secp384r1 -out $TEMPDIR/testuser-csr.key.pem + openssl req -new -key $TEMPDIR/testuser-csr.key.pem -nodes -keyout $TEMPDIR/testuser-csr.key.pem -out $TEMPDIR/testuser-csr.csr.pem -subj "/CN=test.user@example.com" + + local output=$(run_cmd piv user-cert -u test.user@example.com --os-type windows --csr $TEMPDIR/testuser-csr.csr.pem --san "RFC822:test.user@example.com" --san "DIRECTORY:C=US,O=Organization,CN=test.user" --out $TEMPDIR/testuser-piv-csr) assert_success echo "$output" assert_not_grep "Cert errors" "$output" assert_not_grep "Cert warnings" "$output" - [ -f $TEMPDIR/testuser-piv.key.pem ] || { echo "ERROR: Key not saved"; return 1; } - [ -f $TEMPDIR/testuser-piv.csr.pem ] || { echo "ERROR: CSR not saved"; return 1; } - [ -f $TEMPDIR/testuser-piv.cer.pem ] || { echo "ERROR: Certificate not saved"; return 1; } + [ ! -f $TEMPDIR/testuser-piv-csr.key.pem ] || { echo "ERROR: Key should not be saved when using CSR"; return 1; } + [ ! -f $TEMPDIR/testuser-piv-csr.csr.pem ] || { echo "ERROR: CSR should not be saved when using existing CSR"; return 1; } + [ -f $TEMPDIR/testuser-piv-csr.cer.pem ] || { echo "ERROR: Certificate not saved"; return 1; } - local cert_output=$(openssl x509 -in $TEMPDIR/testuser-piv.cer.pem -text -noout) + local cert_output=$(openssl x509 -in $TEMPDIR/testuser-piv-csr.cer.pem -text -noout) assert_success echo "$cert_output" assert_grep "Subject:.*CN.*=.*test.user@example.com" "$cert_output" @@ -268,18 +294,18 @@ test_password_derivation() { test_wrapped_backup() { setup - run_cmd -q hsm backup --out $TEMPDIR/backup.tgz + run_cmd -q hsm backup export --out $TEMPDIR/backup.tgz assert_success tar tvfz $TEMPDIR/backup.tgz | grep -q 'ASYMMETRIC_KEY' || { echo "ERROR: No asymmetric keys found in backup"; return 1; } tar tvfz $TEMPDIR/backup.tgz | grep -q 'OPAQUE' || { echo "ERROR: No certificates found in backup"; return 1; } - run_cmd -q hsm delete --force 0x0210 + run_cmd -q hsm objects delete --force 0x0210 assert_success run_cmd -q hsm compare | grep -q '[ ].*ca-root-key-rsa' || { echo "ERROR: Key not deleted"; return 1; } assert_success - run_cmd -q hsm restore --force $TEMPDIR/backup.tgz + run_cmd -q hsm backup import --force $TEMPDIR/backup.tgz assert_success run_cmd -q hsm compare | grep -q '[x].*ca-root-key-rsa' || { echo "ERROR: Key not restored"; return 1; } assert_success @@ -423,7 +449,8 @@ run_test test_password_derivation run_test test_wrapped_backup run_test test_ssh_user_certificates run_test test_ssh_host_certificates -run_test test_piv_user_certificate +run_test test_piv_user_certificate_key_type +run_test test_piv_user_certificate_csr run_test test_piv_dc_certificate run_test test_logging_commands