From 338b092f1047e927a233b33c80231cb36a3ae291 Mon Sep 17 00:00:00 2001 From: Dain Nilsson Date: Fri, 1 Mar 2024 12:03:30 +0100 Subject: [PATCH] Squashed commits --- examples/piv_certificate.py | 4 +- tests/device/cli/conftest.py | 34 +- tests/device/cli/piv/conftest.py | 44 ++ .../cli/piv/test_generate_cert_and_csr.py | 162 +++---- tests/device/cli/piv/test_key_management.py | 170 +++---- tests/device/cli/piv/test_management_key.py | 67 ++- tests/device/cli/piv/test_misc.py | 11 +- tests/device/cli/piv/test_pin_puk.py | 57 ++- .../device/cli/piv/test_read_write_object.py | 17 +- tests/device/cli/piv/util.py | 4 +- tests/device/cli/test_hsmauth.py | 115 +++-- tests/device/cli/test_oath.py | 214 ++++----- tests/device/cli/test_openpgp.py | 40 +- tests/device/cli/test_otp.py | 10 + tests/device/test_hsmauth.py | 132 +++--- tests/device/test_oath.py | 4 +- tests/device/test_openpgp.py | 125 ++++-- tests/device/test_otp.py | 7 + tests/device/test_piv.py | 325 ++++++++------ tests/test_piv.py | 31 +- ykman/_cli/__main__.py | 215 ++++++++- ykman/_cli/apdu.py | 11 + ykman/_cli/fido.py | 43 +- ykman/_cli/hsmauth.py | 227 +++++++--- ykman/_cli/info.py | 8 +- ykman/_cli/oath.py | 63 ++- ykman/_cli/openpgp.py | 15 +- ykman/_cli/piv.py | 101 +++-- ykman/_cli/securedomain.py | 385 ++++++++++++++++ ykman/_cli/util.py | 137 +++++- ykman/piv.py | 33 +- ykman/util.py | 21 +- yubikit/core/__init__.py | 3 + yubikit/core/smartcard.py | 237 ---------- yubikit/core/smartcard/__init__.py | 420 ++++++++++++++++++ yubikit/core/smartcard/scp.py | 371 ++++++++++++++++ yubikit/hsmauth.py | 37 +- yubikit/management.py | 74 ++- yubikit/oath.py | 12 +- yubikit/openpgp.py | 11 +- yubikit/piv.py | 143 +++++- yubikit/securedomain.py | 371 ++++++++++++++++ yubikit/support.py | 2 + yubikit/yubiotp.py | 7 + 44 files changed, 3416 insertions(+), 1104 deletions(-) create mode 100644 ykman/_cli/securedomain.py delete mode 100644 yubikit/core/smartcard.py create mode 100644 yubikit/core/smartcard/__init__.py create mode 100644 yubikit/core/smartcard/scp.py create mode 100644 yubikit/securedomain.py diff --git a/examples/piv_certificate.py b/examples/piv_certificate.py index 7fc6f943..453e4031 100644 --- a/examples/piv_certificate.py +++ b/examples/piv_certificate.py @@ -26,7 +26,6 @@ PivSession, SLOT, KEY_TYPE, - MANAGEMENT_KEY_TYPE, DEFAULT_MANAGEMENT_KEY, ) from ykman.piv import sign_certificate_builder @@ -57,7 +56,8 @@ key = click.prompt( "Enter management key", default=DEFAULT_MANAGEMENT_KEY.hex(), hide_input=True ) -piv.authenticate(MANAGEMENT_KEY_TYPE.TDES, bytes.fromhex(key)) + +piv.authenticate(bytes.fromhex(key)) # Generate a private key on the YubiKey print(f"Generating {key_type.name} key in slot {slot:X}...") diff --git a/tests/device/cli/conftest.py b/tests/device/cli/conftest.py index 21cb5e2d..854920d4 100644 --- a/tests/device/cli/conftest.py +++ b/tests/device/cli/conftest.py @@ -1,34 +1,28 @@ from yubikit.core import TRANSPORT -from ykman._cli.__main__ import cli, _DefaultFormatter +from ykman._cli.__main__ import cli from ykman._cli.aliases import apply_aliases from ykman._cli.util import CliFail from click.testing import CliRunner from functools import partial -import logging import pytest -@pytest.fixture(scope="module") -def ykman_cli(device, info): +@pytest.fixture() +def ykman_cli(capsys, device, info): + def _ykman_cli(*argv, **kwargs): + argv = apply_aliases(["ykman"] + [str(a) for a in argv]) + runner = CliRunner(mix_stderr=False) + with capsys.disabled(): + result = runner.invoke(cli, argv[1:], obj={}, **kwargs) + if result.exit_code != 0: + if isinstance(result.exception, CliFail): + raise SystemExit() + raise result.exception + return result + if device.transport == TRANSPORT.NFC: return partial(_ykman_cli, "--reader", device.reader.name) elif info.serial is not None: return partial(_ykman_cli, "--device", info.serial) else: return _ykman_cli - - -def _ykman_cli(*argv, **kwargs): - handler = logging.StreamHandler() - handler.setLevel(logging.WARNING) - handler.setFormatter(_DefaultFormatter()) - logging.getLogger().addHandler(handler) - - argv = apply_aliases(["ykman"] + [str(a) for a in argv]) - runner = CliRunner(mix_stderr=False) - result = runner.invoke(cli, argv[1:], obj={}, **kwargs) - if result.exit_code != 0: - if isinstance(result.exception, CliFail): - raise SystemExit() - raise result.exception - return result diff --git a/tests/device/cli/piv/conftest.py b/tests/device/cli/piv/conftest.py index 2af43ba1..31163dc6 100644 --- a/tests/device/cli/piv/conftest.py +++ b/tests/device/cli/piv/conftest.py @@ -1,5 +1,7 @@ from yubikit.management import CAPABILITY from ... import condition +from .util import DEFAULT_PIN, DEFAULT_PUK, DEFAULT_MANAGEMENT_KEY +from typing import NamedTuple import pytest @@ -7,3 +9,45 @@ @condition.capability(CAPABILITY.PIV) def ensure_piv(ykman_cli): ykman_cli("piv", "reset", "-f") + + +class Keys(NamedTuple): + pin: str + puk: str + mgmt: str + + +@pytest.fixture +def default_keys(): + yield Keys(DEFAULT_PIN, DEFAULT_PUK, DEFAULT_MANAGEMENT_KEY) + + +@pytest.fixture +def keys(ykman_cli, info, default_keys): + if CAPABILITY.PIV in info.fips_capable: + new_keys = Keys( + "12345679", + "12345670", + "010203040506070801020304050607080102030405060709", + ) + + ykman_cli( + "piv", "access", "change-pin", "-P", default_keys.pin, "-n", new_keys.pin + ) + ykman_cli( + "piv", "access", "change-puk", "-p", default_keys.puk, "-n", new_keys.puk + ) + ykman_cli( + "piv", + "access", + "change-management-key", + "-m", + default_keys.mgmt, + "-n", + new_keys.mgmt, + "-f", + ) + + yield new_keys + else: + yield default_keys diff --git a/tests/device/cli/piv/test_generate_cert_and_csr.py b/tests/device/cli/piv/test_generate_cert_and_csr.py index 76a1ba8c..f2eb7269 100644 --- a/tests/device/cli/piv/test_generate_cert_and_csr.py +++ b/tests/device/cli/piv/test_generate_cert_and_csr.py @@ -3,7 +3,7 @@ from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import ec, rsa, padding -from .util import DEFAULT_PIN, DEFAULT_MANAGEMENT_KEY, NON_DEFAULT_MANAGEMENT_KEY +from .util import NON_DEFAULT_MANAGEMENT_KEY from ... import condition import pytest @@ -33,20 +33,20 @@ def not_roca(version): class TestNonDefaultMgmKey: @pytest.fixture(autouse=True) - def set_mgmt_key(self, ykman_cli): + def set_mgmt_key(self, ykman_cli, keys): ykman_cli( "piv", "access", "change-management-key", "-P", - DEFAULT_PIN, + keys.pin, "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, "-n", NON_DEFAULT_MANAGEMENT_KEY, ) - def _test_generate_self_signed(self, ykman_cli, slot, algo): + def _test_generate_self_signed(self, ykman_cli, keys, slot, algo): pubkey_output = ykman_cli( "piv", "keys", @@ -68,7 +68,7 @@ def _test_generate_self_signed(self, ykman_cli, slot, algo): "-s", "subject-" + algo, "-P", - DEFAULT_PIN, + keys.pin, "-", input=pubkey_output, ) @@ -82,37 +82,37 @@ def _test_generate_self_signed(self, ykman_cli, slot, algo): @condition.yk4_fips(False) @condition.check(not_roca) - def test_generate_self_signed_slot_9a_rsa1024(self, ykman_cli): - self._test_generate_self_signed(ykman_cli, "9a", "RSA1024") + def test_generate_self_signed_slot_9a_rsa2048(self, ykman_cli, keys): + self._test_generate_self_signed(ykman_cli, keys, "9a", "RSA2048") - def test_generate_self_signed_slot_9a_eccp256(self, ykman_cli): - self._test_generate_self_signed(ykman_cli, "9a", "ECCP256") + def test_generate_self_signed_slot_9a_eccp256(self, ykman_cli, keys): + self._test_generate_self_signed(ykman_cli, keys, "9a", "ECCP256") @condition.yk4_fips(False) @condition.check(not_roca) - def test_generate_self_signed_slot_9c_rsa1024(self, ykman_cli): - self._test_generate_self_signed(ykman_cli, "9c", "RSA1024") + def test_generate_self_signed_slot_9c_rsa2048(self, ykman_cli, keys): + self._test_generate_self_signed(ykman_cli, keys, "9c", "RSA2048") - def test_generate_self_signed_slot_9c_eccp256(self, ykman_cli): - self._test_generate_self_signed(ykman_cli, "9c", "ECCP256") + def test_generate_self_signed_slot_9c_eccp256(self, ykman_cli, keys): + self._test_generate_self_signed(ykman_cli, keys, "9c", "ECCP256") @condition.yk4_fips(False) @condition.check(not_roca) - def test_generate_self_signed_slot_9d_rsa1024(self, ykman_cli): - self._test_generate_self_signed(ykman_cli, "9d", "RSA1024") + def test_generate_self_signed_slot_9d_rsa2048(self, ykman_cli, keys): + self._test_generate_self_signed(ykman_cli, keys, "9d", "RSA2048") - def test_generate_self_signed_slot_9d_eccp256(self, ykman_cli): - self._test_generate_self_signed(ykman_cli, "9d", "ECCP256") + def test_generate_self_signed_slot_9d_eccp256(self, ykman_cli, keys): + self._test_generate_self_signed(ykman_cli, keys, "9d", "ECCP256") @condition.yk4_fips(False) @condition.check(not_roca) - def test_generate_self_signed_slot_9e_rsa1024(self, ykman_cli): - self._test_generate_self_signed(ykman_cli, "9e", "RSA1024") + def test_generate_self_signed_slot_9e_rsa2048(self, ykman_cli, keys): + self._test_generate_self_signed(ykman_cli, keys, "9e", "RSA2048") - def test_generate_self_signed_slot_9e_eccp256(self, ykman_cli): - self._test_generate_self_signed(ykman_cli, "9e", "ECCP256") + def test_generate_self_signed_slot_9e_eccp256(self, ykman_cli, keys): + self._test_generate_self_signed(ykman_cli, keys, "9e", "ECCP256") - def _test_generate_csr(self, ykman_cli, slot, algo): + def _test_generate_csr(self, ykman_cli, keys, slot, algo): subject_input = "subject-" + algo pubkey_output = ykman_cli( "piv", @@ -131,7 +131,7 @@ def _test_generate_csr(self, ykman_cli, slot, algo): "request", slot, "-P", - DEFAULT_PIN, + keys.pin, "-", "-", "-s", @@ -147,54 +147,54 @@ def _test_generate_csr(self, ykman_cli, slot, algo): @condition.yk4_fips(False) @condition.check(not_roca) - def test_generate_csr_slot_9a_rsa1024(self, ykman_cli): - self._test_generate_csr(ykman_cli, "9a", "RSA1024") + def test_generate_csr_slot_9a_rsa2048(self, ykman_cli, keys): + self._test_generate_csr(ykman_cli, keys, "9a", "RSA2048") - def test_generate_csr_slot_9a_eccp256(self, ykman_cli): - self._test_generate_csr(ykman_cli, "9a", "ECCP256") + def test_generate_csr_slot_9a_eccp256(self, ykman_cli, keys): + self._test_generate_csr(ykman_cli, keys, "9a", "ECCP256") @condition.yk4_fips(False) @condition.check(not_roca) - def test_generate_csr_slot_9c_rsa1024(self, ykman_cli): - self._test_generate_csr(ykman_cli, "9c", "RSA1024") + def test_generate_csr_slot_9c_rsa2048(self, ykman_cli, keys): + self._test_generate_csr(ykman_cli, keys, "9c", "RSA2048") - def test_generate_csr_slot_9c_eccp256(self, ykman_cli): - self._test_generate_csr(ykman_cli, "9c", "ECCP256") + def test_generate_csr_slot_9c_eccp256(self, ykman_cli, keys): + self._test_generate_csr(ykman_cli, keys, "9c", "ECCP256") @condition.yk4_fips(False) @condition.check(not_roca) - def test_generate_csr_slot_9d_rsa1024(self, ykman_cli): - self._test_generate_csr(ykman_cli, "9d", "RSA1024") + def test_generate_csr_slot_9d_rsa2048(self, ykman_cli, keys): + self._test_generate_csr(ykman_cli, keys, "9d", "RSA2048") - def test_generate_csr_slot_9d_eccp256(self, ykman_cli): - self._test_generate_csr(ykman_cli, "9d", "ECCP256") + def test_generate_csr_slot_9d_eccp256(self, ykman_cli, keys): + self._test_generate_csr(ykman_cli, keys, "9d", "ECCP256") @condition.yk4_fips(False) @condition.check(not_roca) - def test_generate_csr_slot_9e_rsa1024(self, ykman_cli): - self._test_generate_csr(ykman_cli, "9e", "RSA1024") + def test_generate_csr_slot_9e_rsa2048(self, ykman_cli, keys): + self._test_generate_csr(ykman_cli, keys, "9e", "RSA2048") - def test_generate_csr_slot_9e_eccp256(self, ykman_cli): - self._test_generate_csr(ykman_cli, "9e", "ECCP256") + def test_generate_csr_slot_9e_eccp256(self, ykman_cli, keys): + self._test_generate_csr(ykman_cli, keys, "9e", "ECCP256") class TestProtectedMgmKey: @pytest.fixture(autouse=True) - def protect_mgmt_key(self, ykman_cli): + def protect_mgmt_key(self, ykman_cli, keys): ykman_cli( "piv", "access", "change-management-key", "-p", "-P", - DEFAULT_PIN, + keys.pin, "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, ) - def _test_generate_self_signed(self, ykman_cli, slot, algo): + def _test_generate_self_signed(self, ykman_cli, keys, slot, algo): pubkey_output = ykman_cli( - "piv", "keys", "generate", slot, "-a", algo, "-P", DEFAULT_PIN, "-" + "piv", "keys", "generate", slot, "-a", algo, "-P", keys.pin, "-" ).output ykman_cli( "piv", @@ -202,7 +202,7 @@ def _test_generate_self_signed(self, ykman_cli, slot, algo): "generate", slot, "-P", - DEFAULT_PIN, + keys.pin, "-s", "subject-" + algo, "-", @@ -218,40 +218,40 @@ def _test_generate_self_signed(self, ykman_cli, slot, algo): @condition.yk4_fips(False) @condition.check(not_roca) - def test_generate_self_signed_slot_9a_rsa1024(self, ykman_cli): - self._test_generate_self_signed(ykman_cli, "9a", "RSA1024") + def test_generate_self_signed_slot_9a_rsa2048(self, ykman_cli, keys): + self._test_generate_self_signed(ykman_cli, keys, "9a", "RSA2048") - def test_generate_self_signed_slot_9a_eccp256(self, ykman_cli): - self._test_generate_self_signed(ykman_cli, "9a", "ECCP256") + def test_generate_self_signed_slot_9a_eccp256(self, ykman_cli, keys): + self._test_generate_self_signed(ykman_cli, keys, "9a", "ECCP256") @condition.yk4_fips(False) @condition.check(not_roca) - def test_generate_self_signed_slot_9c_rsa1024(self, ykman_cli): - self._test_generate_self_signed(ykman_cli, "9c", "RSA1024") + def test_generate_self_signed_slot_9c_rsa2048(self, ykman_cli, keys): + self._test_generate_self_signed(ykman_cli, keys, "9c", "RSA2048") - def test_generate_self_signed_slot_9c_eccp256(self, ykman_cli): - self._test_generate_self_signed(ykman_cli, "9c", "ECCP256") + def test_generate_self_signed_slot_9c_eccp256(self, ykman_cli, keys): + self._test_generate_self_signed(ykman_cli, keys, "9c", "ECCP256") @condition.yk4_fips(False) @condition.check(not_roca) - def test_generate_self_signed_slot_9d_rsa1024(self, ykman_cli): - self._test_generate_self_signed(ykman_cli, "9d", "RSA1024") + def test_generate_self_signed_slot_9d_rsa2048(self, ykman_cli, keys): + self._test_generate_self_signed(ykman_cli, keys, "9d", "RSA2048") - def test_generate_self_signed_slot_9d_eccp256(self, ykman_cli): - self._test_generate_self_signed(ykman_cli, "9d", "ECCP256") + def test_generate_self_signed_slot_9d_eccp256(self, ykman_cli, keys): + self._test_generate_self_signed(ykman_cli, keys, "9d", "ECCP256") @condition.yk4_fips(False) @condition.check(not_roca) - def test_generate_self_signed_slot_9e_rsa1024(self, ykman_cli): - self._test_generate_self_signed(ykman_cli, "9e", "RSA1024") + def test_generate_self_signed_slot_9e_rsa2048(self, ykman_cli, keys): + self._test_generate_self_signed(ykman_cli, keys, "9e", "RSA2048") - def test_generate_self_signed_slot_9e_eccp256(self, ykman_cli): - self._test_generate_self_signed(ykman_cli, "9e", "ECCP256") + def test_generate_self_signed_slot_9e_eccp256(self, ykman_cli, keys): + self._test_generate_self_signed(ykman_cli, keys, "9e", "ECCP256") - def _test_generate_csr(self, ykman_cli, slot, algo): + def _test_generate_csr(self, ykman_cli, keys, slot, algo): subject_input = "subject-" + algo pubkey_output = ykman_cli( - "piv", "keys", "generate", slot, "-a", algo, "-P", DEFAULT_PIN, "-" + "piv", "keys", "generate", slot, "-a", algo, "-P", keys.pin, "-" ).output csr_output = ykman_cli( "piv", @@ -259,7 +259,7 @@ def _test_generate_csr(self, ykman_cli, slot, algo): "request", slot, "-P", - DEFAULT_PIN, + keys.pin, "-", "-", "-s", @@ -275,32 +275,32 @@ def _test_generate_csr(self, ykman_cli, slot, algo): @condition.yk4_fips(False) @condition.check(not_roca) - def test_generate_csr_slot_9a_rsa1024(self, ykman_cli): - self._test_generate_csr(ykman_cli, "9a", "RSA1024") + def test_generate_csr_slot_9a_rsa2048(self, ykman_cli, keys): + self._test_generate_csr(ykman_cli, keys, "9a", "RSA2048") - def test_generate_csr_slot_9a_eccp256(self, ykman_cli): - self._test_generate_csr(ykman_cli, "9a", "ECCP256") + def test_generate_csr_slot_9a_eccp256(self, ykman_cli, keys): + self._test_generate_csr(ykman_cli, keys, "9a", "ECCP256") @condition.yk4_fips(False) @condition.check(not_roca) - def test_generate_csr_slot_9c_rsa1024(self, ykman_cli): - self._test_generate_csr(ykman_cli, "9c", "RSA1024") + def test_generate_csr_slot_9c_rsa2048(self, ykman_cli, keys): + self._test_generate_csr(ykman_cli, keys, "9c", "RSA2048") - def test_generate_csr_slot_9c_eccp256(self, ykman_cli): - self._test_generate_csr(ykman_cli, "9c", "ECCP256") + def test_generate_csr_slot_9c_eccp256(self, ykman_cli, keys): + self._test_generate_csr(ykman_cli, keys, "9c", "ECCP256") @condition.yk4_fips(False) @condition.check(not_roca) - def test_generate_csr_slot_9d_rsa1024(self, ykman_cli): - self._test_generate_csr(ykman_cli, "9d", "RSA1024") + def test_generate_csr_slot_9d_rsa2048(self, ykman_cli, keys): + self._test_generate_csr(ykman_cli, keys, "9d", "RSA2048") - def test_generate_csr_slot_9d_eccp256(self, ykman_cli): - self._test_generate_csr(ykman_cli, "9d", "ECCP256") + def test_generate_csr_slot_9d_eccp256(self, ykman_cli, keys): + self._test_generate_csr(ykman_cli, keys, "9d", "ECCP256") @condition.yk4_fips(False) @condition.check(not_roca) - def test_generate_csr_slot_9e_rsa1024(self, ykman_cli): - self._test_generate_csr(ykman_cli, "9e", "RSA1024") + def test_generate_csr_slot_9e_rsa2048(self, ykman_cli, keys): + self._test_generate_csr(ykman_cli, keys, "9e", "RSA2048") - def test_generate_csr_slot_9e_eccp256(self, ykman_cli): - self._test_generate_csr(ykman_cli, "9e", "ECCP256") + def test_generate_csr_slot_9e_eccp256(self, ykman_cli, keys): + self._test_generate_csr(ykman_cli, keys, "9e", "ECCP256") diff --git a/tests/device/cli/piv/test_key_management.py b/tests/device/cli/piv/test_key_management.py index c2eac6b4..128e501c 100644 --- a/tests/device/cli/piv/test_key_management.py +++ b/tests/device/cli/piv/test_key_management.py @@ -3,7 +3,7 @@ from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ec from yubikit.core import NotSupportedError -from .util import DEFAULT_PIN, DEFAULT_MANAGEMENT_KEY +from yubikit.management import CAPABILITY from ... import condition import tempfile import os @@ -45,7 +45,7 @@ def tmp_file(): class TestKeyExport: @condition.min_version(5, 3) - def test_from_metadata(self, ykman_cli): + def test_from_metadata(self, ykman_cli, keys): pair = generate_pem_eccp256_keypair() ykman_cli( @@ -54,7 +54,7 @@ def test_from_metadata(self, ykman_cli): "import", "9a", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, "-", input=pair[0], ) @@ -62,7 +62,7 @@ def test_from_metadata(self, ykman_cli): assert exported == pair[1] @condition.min_version(4, 3) - def test_from_metadata_or_attestation(self, ykman_cli): + def test_from_metadata_or_attestation(self, ykman_cli, keys): der = ykman_cli( "piv", "keys", @@ -73,7 +73,7 @@ def test_from_metadata_or_attestation(self, ykman_cli): "-F", "der", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, "-", ).stdout_bytes exported = ykman_cli( @@ -81,7 +81,7 @@ def test_from_metadata_or_attestation(self, ykman_cli): ).stdout_bytes assert der == exported - def test_from_metadata_or_cert(self, ykman_cli): + def test_from_metadata_or_cert(self, ykman_cli, keys): private_key_pem, public_key_pem = generate_pem_eccp256_keypair() ykman_cli( "piv", @@ -89,7 +89,7 @@ def test_from_metadata_or_cert(self, ykman_cli): "import", "9a", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, "-", input=private_key_pem, ) @@ -100,9 +100,9 @@ def test_from_metadata_or_cert(self, ykman_cli): "9a", "-", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, "-P", - DEFAULT_PIN, + keys.pin, "-s", "test", input=public_key_pem, @@ -113,7 +113,7 @@ def test_from_metadata_or_cert(self, ykman_cli): assert public_key_pem == exported @condition.max_version(5, 2, 9) - def test_from_cert_verify(self, ykman_cli): + def test_from_cert_verify(self, ykman_cli, keys): private_key_pem, public_key_pem = generate_pem_eccp256_keypair() ykman_cli( "piv", @@ -121,7 +121,7 @@ def test_from_cert_verify(self, ykman_cli): "import", "9a", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, "-", input=private_key_pem, ) @@ -132,17 +132,17 @@ def test_from_cert_verify(self, ykman_cli): "9a", "-", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, "-P", - DEFAULT_PIN, + keys.pin, "-s", "test", input=public_key_pem, ) - ykman_cli("piv", "keys", "export", "9a", "--verify", "-P", DEFAULT_PIN, "-") + ykman_cli("piv", "keys", "export", "9a", "--verify", "-P", keys.pin, "-") @condition.max_version(5, 2, 9) - def test_from_cert_verify_fails(self, ykman_cli): + def test_from_cert_verify_fails(self, ykman_cli, keys): private_key_pem = generate_pem_eccp256_keypair()[0] public_key_pem = generate_pem_eccp256_keypair()[1] ykman_cli( @@ -151,7 +151,7 @@ def test_from_cert_verify_fails(self, ykman_cli): "import", "9a", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, "-", input=private_key_pem, ) @@ -162,35 +162,34 @@ def test_from_cert_verify_fails(self, ykman_cli): "9a", "-", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, "-P", - DEFAULT_PIN, + keys.pin, "-s", "test", input=public_key_pem, ) with pytest.raises(SystemExit): - ykman_cli("piv", "keys", "export", "9a", "--verify", "-P", DEFAULT_PIN, "-") + ykman_cli("piv", "keys", "export", "9a", "--verify", "-P", keys.pin, "-") class TestKeyManagement: @condition.check(not_roca) - def test_generate_key_default(self, ykman_cli): - output = ykman_cli( - "piv", "keys", "generate", "9a", "-m", DEFAULT_MANAGEMENT_KEY, "-" - ).output + def test_generate_key_default(self, ykman_cli, keys): + output = ykman_cli("piv", "keys", "generate", "9a", "-m", keys.mgmt, "-").output assert "BEGIN PUBLIC KEY" in output @condition.check(roca) - def test_generate_key_default_cve201715361(self, ykman_cli): + def test_generate_key_default_cve201715361(self, ykman_cli, keys): with pytest.raises(NotSupportedError): - ykman_cli( - "piv", "keys", "generate", "9a", "-m", DEFAULT_MANAGEMENT_KEY, "-" - ) + ykman_cli("piv", "keys", "generate", "9a", "-m", keys.mgmt, "-") @condition.check(not_roca) @condition.yk4_fips(False) - def test_generate_key_rsa1024(self, ykman_cli): + def test_generate_key_rsa1024(self, ykman_cli, info, keys): + if CAPABILITY.PIV in info.fips_capable: + pytest.skip("RSA1024 not available on FIPS") + output = ykman_cli( "piv", "keys", @@ -199,13 +198,13 @@ def test_generate_key_rsa1024(self, ykman_cli): "-a", "RSA1024", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, "-", ).output assert "BEGIN PUBLIC KEY" in output @condition.check(not_roca) - def test_generate_key_rsa2048(self, ykman_cli): + def test_generate_key_rsa2048(self, ykman_cli, keys): output = ykman_cli( "piv", "keys", @@ -214,14 +213,14 @@ def test_generate_key_rsa2048(self, ykman_cli): "-a", "RSA2048", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, "-", ).output assert "BEGIN PUBLIC KEY" in output @condition.yk4_fips(False) @condition.check(roca) - def test_generate_key_rsa1024_cve201715361(self, ykman_cli): + def test_generate_key_rsa1024_cve201715361(self, ykman_cli, keys): with pytest.raises(NotSupportedError): ykman_cli( "piv", @@ -231,12 +230,12 @@ def test_generate_key_rsa1024_cve201715361(self, ykman_cli): "-a", "RSA1024", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, "-", ) @condition.check(roca) - def test_generate_key_rsa2048_cve201715361(self, ykman_cli): + def test_generate_key_rsa2048_cve201715361(self, ykman_cli, keys): with pytest.raises(NotSupportedError): ykman_cli( "piv", @@ -246,11 +245,11 @@ def test_generate_key_rsa2048_cve201715361(self, ykman_cli): "-a", "RSA2048", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, "-", ) - def test_generate_key_eccp256(self, ykman_cli): + def test_generate_key_eccp256(self, ykman_cli, keys): output = ykman_cli( "piv", "keys", @@ -259,25 +258,25 @@ def test_generate_key_eccp256(self, ykman_cli): "-a", "ECCP256", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, "-", ).output assert "BEGIN PUBLIC KEY" in output - def test_import_key_eccp256(self, ykman_cli): + def test_import_key_eccp256(self, ykman_cli, keys): ykman_cli( "piv", "keys", "import", "9a", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, "-", input=generate_pem_eccp256_keypair()[0], ) @condition.min_version(4) - def test_generate_key_eccp384(self, ykman_cli): + def test_generate_key_eccp384(self, ykman_cli, keys): output = ykman_cli( "piv", "keys", @@ -286,13 +285,13 @@ def test_generate_key_eccp384(self, ykman_cli): "-a", "ECCP384", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, "-", ).output assert "BEGIN PUBLIC KEY" in output @condition.min_version(4) - def test_generate_key_pin_policy_always(self, ykman_cli): + def test_generate_key_pin_policy_always(self, ykman_cli, keys): output = ykman_cli( "piv", "keys", @@ -301,7 +300,7 @@ def test_generate_key_pin_policy_always(self, ykman_cli): "--pin-policy", "ALWAYS", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, "-a", "ECCP256", "-", @@ -309,7 +308,7 @@ def test_generate_key_pin_policy_always(self, ykman_cli): assert "BEGIN PUBLIC KEY" in output @condition.min_version(4) - def test_import_key_pin_policy_always(self, ykman_cli): + def test_import_key_pin_policy_always(self, ykman_cli, keys): for pin_policy in ["ALWAYS", "always"]: ykman_cli( "piv", @@ -319,13 +318,13 @@ def test_import_key_pin_policy_always(self, ykman_cli): "--pin-policy", pin_policy, "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, "-", input=generate_pem_eccp256_keypair()[0], ) @condition.min_version(4) - def test_generate_key_touch_policy_always(self, ykman_cli): + def test_generate_key_touch_policy_always(self, ykman_cli, keys): output = ykman_cli( "piv", "keys", @@ -334,7 +333,7 @@ def test_generate_key_touch_policy_always(self, ykman_cli): "--touch-policy", "ALWAYS", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, "-a", "ECCP256", "-", @@ -342,7 +341,7 @@ def test_generate_key_touch_policy_always(self, ykman_cli): assert "BEGIN PUBLIC KEY" in output @condition.min_version(4) - def test_import_key_touch_policy_always(self, ykman_cli): + def test_import_key_touch_policy_always(self, ykman_cli, keys): for touch_policy in ["ALWAYS", "always"]: ykman_cli( "piv", @@ -352,13 +351,13 @@ def test_import_key_touch_policy_always(self, ykman_cli): "--touch-policy", touch_policy, "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, "-", input=generate_pem_eccp256_keypair()[0], ) @condition.min_version(4, 3) - def test_attest_key(self, ykman_cli): + def test_attest_key(self, ykman_cli, keys): ykman_cli( "piv", "keys", @@ -367,13 +366,13 @@ def test_attest_key(self, ykman_cli): "-a", "ECCP256", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, "-", ) output = ykman_cli("piv", "keys", "attest", "9a", "-").output assert "BEGIN CERTIFICATE" in output - def _test_generate_csr(self, ykman_cli, tmp_file, algo): + def _test_generate_csr(self, ykman_cli, keys, tmp_file, algo): ykman_cli( "piv", "keys", @@ -382,7 +381,7 @@ def _test_generate_csr(self, ykman_cli, tmp_file, algo): "-a", algo, "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, tmp_file, ) output = ykman_cli( @@ -394,7 +393,7 @@ def _test_generate_csr(self, ykman_cli, tmp_file, algo): "-s", "test-subject", "-P", - DEFAULT_PIN, + keys.pin, "-", ).output csr = x509.load_pem_x509_csr(output.encode(), default_backend()) @@ -402,13 +401,18 @@ def _test_generate_csr(self, ykman_cli, tmp_file, algo): @condition.yk4_fips(False) @condition.check(not_roca) - def test_generate_csr_rsa1024(self, ykman_cli, tmp_file): - self._test_generate_csr(ykman_cli, tmp_file, "RSA1024") + def test_generate_csr_rsa1024(self, ykman_cli, keys, info, tmp_file): + if CAPABILITY.PIV in info.fips_capable: + pytest.skip("RSA1024 not available on FIPS") + + self._test_generate_csr(ykman_cli, keys, tmp_file, "RSA1024") - def test_generate_csr_eccp256(self, ykman_cli, tmp_file): - self._test_generate_csr(ykman_cli, tmp_file, "ECCP256") + def test_generate_csr_eccp256(self, ykman_cli, keys, tmp_file): + self._test_generate_csr(ykman_cli, keys, tmp_file, "ECCP256") - def test_import_verify_correct_cert_succeeds_with_pin(self, ykman_cli, tmp_file): + def test_import_verify_correct_cert_succeeds_with_pin( + self, ykman_cli, keys, tmp_file + ): # Set up a key in the slot and create a certificate for it public_key_pem = ykman_cli( "piv", @@ -418,7 +422,7 @@ def test_import_verify_correct_cert_succeeds_with_pin(self, ykman_cli, tmp_file) "-a", "ECCP256", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, "-", ).output @@ -429,9 +433,9 @@ def test_import_verify_correct_cert_succeeds_with_pin(self, ykman_cli, tmp_file) "9a", "-", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, "-P", - DEFAULT_PIN, + keys.pin, "-s", "test", input=public_key_pem, @@ -448,7 +452,7 @@ def test_import_verify_correct_cert_succeeds_with_pin(self, ykman_cli, tmp_file) "9a", tmp_file, "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, ) ykman_cli( @@ -459,9 +463,9 @@ def test_import_verify_correct_cert_succeeds_with_pin(self, ykman_cli, tmp_file) "9a", tmp_file, "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, "-P", - DEFAULT_PIN, + keys.pin, ) ykman_cli( "piv", @@ -471,11 +475,11 @@ def test_import_verify_correct_cert_succeeds_with_pin(self, ykman_cli, tmp_file) "9a", tmp_file, "-m", - DEFAULT_MANAGEMENT_KEY, - input=DEFAULT_PIN, + keys.mgmt, + input=keys.pin, ) - def test_import_verify_wrong_cert_fails(self, ykman_cli): + def test_import_verify_wrong_cert_fails(self, ykman_cli, keys): # Set up a key in the slot and create a certificate for it public_key_pem = ykman_cli( "piv", @@ -485,7 +489,7 @@ def test_import_verify_wrong_cert_fails(self, ykman_cli): "-a", "ECCP256", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, "-", ).output @@ -496,9 +500,9 @@ def test_import_verify_wrong_cert_fails(self, ykman_cli): "9a", "-", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, "-P", - DEFAULT_PIN, + keys.pin, "-s", "test", input=public_key_pem, @@ -515,7 +519,7 @@ def test_import_verify_wrong_cert_fails(self, ykman_cli): "-a", "ECCP256", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, "-", input=public_key_pem, ) @@ -529,13 +533,13 @@ def test_import_verify_wrong_cert_fails(self, ykman_cli): "9a", "-", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, "-P", - DEFAULT_PIN, + keys.pin, input=cert_pem, ) - def test_import_no_verify_wrong_cert_succeeds(self, ykman_cli): + def test_import_no_verify_wrong_cert_succeeds(self, ykman_cli, keys): # Set up a key in the slot and create a certificate for it public_key_pem = ykman_cli( "piv", @@ -545,7 +549,7 @@ def test_import_no_verify_wrong_cert_succeeds(self, ykman_cli): "-a", "ECCP256", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, "-", ).output @@ -556,9 +560,9 @@ def test_import_no_verify_wrong_cert_succeeds(self, ykman_cli): "9a", "-", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, "-P", - DEFAULT_PIN, + keys.pin, "-s", "test", input=public_key_pem, @@ -575,7 +579,7 @@ def test_import_no_verify_wrong_cert_succeeds(self, ykman_cli): "-a", "ECCP256", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, "-", input=public_key_pem, ) @@ -589,9 +593,9 @@ def test_import_no_verify_wrong_cert_succeeds(self, ykman_cli): "9a", "-", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, "-P", - DEFAULT_PIN, + keys.pin, input=cert_pem, ) @@ -602,9 +606,9 @@ def test_import_no_verify_wrong_cert_succeeds(self, ykman_cli): "9a", "-", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, "-P", - DEFAULT_PIN, + keys.pin, input=cert_pem, ) diff --git a/tests/device/cli/piv/test_management_key.py b/tests/device/cli/piv/test_management_key.py index 64b19b77..caab1f7a 100644 --- a/tests/device/cli/piv/test_management_key.py +++ b/tests/device/cli/piv/test_management_key.py @@ -1,37 +1,32 @@ -from .util import ( - old_new_new, - DEFAULT_PIN, - DEFAULT_MANAGEMENT_KEY, - NON_DEFAULT_MANAGEMENT_KEY, -) +from .util import old_new_new, NON_DEFAULT_MANAGEMENT_KEY import re import pytest class TestManagementKey: - def test_change_management_key_force_fails_without_generate(self, ykman_cli): + def test_change_management_key_force_fails_without_generate(self, ykman_cli, keys): with pytest.raises(SystemExit): ykman_cli( "piv", "access", "change-management-key", "-P", - DEFAULT_PIN, + keys.pin, "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, "-f", ) - def test_change_management_key_protect_random(self, ykman_cli): + def test_change_management_key_protect_random(self, ykman_cli, keys): ykman_cli( "piv", "access", "change-management-key", "-p", "-P", - DEFAULT_PIN, + keys.pin, "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, ) output = ykman_cli("piv", "info").output assert "Management key is stored on the YubiKey, protected by PIN" in output @@ -44,23 +39,23 @@ def test_change_management_key_protect_random(self, ykman_cli): "change-management-key", "-p", "-P", - DEFAULT_PIN, + keys.pin, "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, ) # Should succeed - PIN as key - ykman_cli("piv", "access", "change-management-key", "-p", "-P", DEFAULT_PIN) + ykman_cli("piv", "access", "change-management-key", "-p", "-P", keys.pin) - def test_change_management_key_protect_prompt(self, ykman_cli): + def test_change_management_key_protect_prompt(self, ykman_cli, keys): ykman_cli( "piv", "access", "change-management-key", "-p", "-P", - DEFAULT_PIN, - input=DEFAULT_MANAGEMENT_KEY, + keys.pin, + input=keys.mgmt, ) output = ykman_cli("piv", "info").output assert "Management key is stored on the YubiKey, protected by PIN" in output @@ -73,17 +68,17 @@ def test_change_management_key_protect_prompt(self, ykman_cli): "change-management-key", "-p", "-P", - DEFAULT_PIN, + keys.pin, "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, ) # Should succeed - PIN as key - ykman_cli("piv", "access", "change-management-key", "-p", "-P", DEFAULT_PIN) + ykman_cli("piv", "access", "change-management-key", "-p", "-P", keys.pin) - def test_change_management_key_no_protect_generate(self, ykman_cli): + def test_change_management_key_no_protect_generate(self, ykman_cli, keys): output = ykman_cli( - "piv", "access", "change-management-key", "-m", DEFAULT_MANAGEMENT_KEY, "-g" + "piv", "access", "change-management-key", "-m", keys.mgmt, "-g" ).output assert re.match( @@ -93,13 +88,13 @@ def test_change_management_key_no_protect_generate(self, ykman_cli): output = ykman_cli("piv", "info").output assert "Management key is stored on the YubiKey" not in output - def test_change_management_key_no_protect_arg(self, ykman_cli): + def test_change_management_key_no_protect_arg(self, ykman_cli, keys): output = ykman_cli( "piv", "access", "change-management-key", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, "-n", NON_DEFAULT_MANAGEMENT_KEY, ).output @@ -113,7 +108,7 @@ def test_change_management_key_no_protect_arg(self, ykman_cli): "access", "change-management-key", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, "-n", NON_DEFAULT_MANAGEMENT_KEY, ) @@ -125,28 +120,28 @@ def test_change_management_key_no_protect_arg(self, ykman_cli): "-m", NON_DEFAULT_MANAGEMENT_KEY, "-n", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, ).output assert "" == output - def test_change_management_key_no_protect_arg_bad_length(self, ykman_cli): + def test_change_management_key_no_protect_arg_bad_length(self, ykman_cli, keys): with pytest.raises(SystemExit): ykman_cli( "piv", "access", "change-management-key", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, "-n", "10020304050607080102030405060708", ) - def test_change_management_key_no_protect_prompt(self, ykman_cli): + def test_change_management_key_no_protect_prompt(self, ykman_cli, keys): output = ykman_cli( "piv", "access", "change-management-key", - input=old_new_new(DEFAULT_MANAGEMENT_KEY, NON_DEFAULT_MANAGEMENT_KEY), + input=old_new_new(keys.mgmt, NON_DEFAULT_MANAGEMENT_KEY), ).output assert "Generated" not in output output = ykman_cli("piv", "info").output @@ -157,25 +152,27 @@ def test_change_management_key_no_protect_prompt(self, ykman_cli): "piv", "access", "change-management-key", - input=old_new_new(DEFAULT_MANAGEMENT_KEY, NON_DEFAULT_MANAGEMENT_KEY), + input=old_new_new(keys.mgmt, NON_DEFAULT_MANAGEMENT_KEY), ) ykman_cli( "piv", "access", "change-management-key", - input=old_new_new(NON_DEFAULT_MANAGEMENT_KEY, DEFAULT_MANAGEMENT_KEY), + input=old_new_new(NON_DEFAULT_MANAGEMENT_KEY, keys.mgmt), ) assert "Generated" not in output - def test_change_management_key_new_key_conflicts_with_generate(self, ykman_cli): + def test_change_management_key_new_key_conflicts_with_generate( + self, ykman_cli, keys + ): with pytest.raises(SystemExit): ykman_cli( "piv", "access", "change-management-key", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, "-n", NON_DEFAULT_MANAGEMENT_KEY, "-g", diff --git a/tests/device/cli/piv/test_misc.py b/tests/device/cli/piv/test_misc.py index 1587cf02..8ab44910 100644 --- a/tests/device/cli/piv/test_misc.py +++ b/tests/device/cli/piv/test_misc.py @@ -2,9 +2,6 @@ import pytest -DEFAULT_MANAGEMENT_KEY = "010203040506070801020304050607080102030405060708" - - class TestMisc: def setUp(self, ykman_cli): ykman_cli("piv", "reset", "-f") @@ -17,7 +14,7 @@ def test_reset(self, ykman_cli): output = ykman_cli("piv", "reset", "-f").output assert "Success!" in output - def test_export_invalid_certificate_fails(self, ykman_cli): + def test_export_invalid_certificate_fails(self, ykman_cli, keys): ykman_cli( "piv", "objects", @@ -25,7 +22,7 @@ def test_export_invalid_certificate_fails(self, ykman_cli): hex(OBJECT_ID.AUTHENTICATION), "-", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, input="This is not a cert", ) @@ -34,7 +31,7 @@ def test_export_invalid_certificate_fails(self, ykman_cli): "piv", "certificates", "export", hex(OBJECT_ID.AUTHENTICATION), "-" ) - def test_info_with_invalid_certificate_does_not_crash(self, ykman_cli): + def test_info_with_invalid_certificate_does_not_crash(self, ykman_cli, keys): ykman_cli( "piv", "objects", @@ -42,7 +39,7 @@ def test_info_with_invalid_certificate_does_not_crash(self, ykman_cli): hex(OBJECT_ID.AUTHENTICATION), "-", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, input="This is not a cert", ) ykman_cli("piv", "info") diff --git a/tests/device/cli/piv/test_pin_puk.py b/tests/device/cli/piv/test_pin_puk.py index fe006622..a61712db 100644 --- a/tests/device/cli/piv/test_pin_puk.py +++ b/tests/device/cli/piv/test_pin_puk.py @@ -1,73 +1,67 @@ from .util import ( old_new_new, - DEFAULT_PIN, NON_DEFAULT_PIN, - DEFAULT_PUK, NON_DEFAULT_PUK, - DEFAULT_MANAGEMENT_KEY, ) from ykman.piv import OBJECT_ID_PIVMAN_DATA, PivmanData +from yubikit.management import CAPABILITY import pytest import re class TestPin: - def test_change_pin(self, ykman_cli): - ykman_cli( - "piv", "access", "change-pin", "-P", DEFAULT_PIN, "-n", NON_DEFAULT_PIN - ) - ykman_cli( - "piv", "access", "change-pin", "-P", NON_DEFAULT_PIN, "-n", DEFAULT_PIN - ) + def test_change_pin(self, ykman_cli, keys): + ykman_cli("piv", "access", "change-pin", "-P", keys.pin, "-n", NON_DEFAULT_PIN) + ykman_cli("piv", "access", "change-pin", "-P", NON_DEFAULT_PIN, "-n", keys.pin) - def test_change_pin_prompt(self, ykman_cli): + def test_change_pin_prompt(self, ykman_cli, keys): ykman_cli( "piv", "access", "change-pin", - input=old_new_new(DEFAULT_PIN, NON_DEFAULT_PIN), + input=old_new_new(keys.pin, NON_DEFAULT_PIN), ) ykman_cli( "piv", "access", "change-pin", - input=old_new_new(NON_DEFAULT_PIN, DEFAULT_PIN), + input=old_new_new(NON_DEFAULT_PIN, keys.pin), ) class TestPuk: - def test_change_puk(self, ykman_cli): + def test_change_puk(self, ykman_cli, keys): o1 = ykman_cli( - "piv", "access", "change-puk", "-p", DEFAULT_PUK, "-n", NON_DEFAULT_PUK + "piv", "access", "change-puk", "-p", keys.puk, "-n", NON_DEFAULT_PUK ).output assert "New PUK set." in o1 o2 = ykman_cli( - "piv", "access", "change-puk", "-p", NON_DEFAULT_PUK, "-n", DEFAULT_PUK + "piv", "access", "change-puk", "-p", NON_DEFAULT_PUK, "-n", keys.puk ).output assert "New PUK set." in o2 with pytest.raises(SystemExit): ykman_cli( - "piv", "access", "change-puk", "-p", NON_DEFAULT_PUK, "-n", DEFAULT_PUK + "piv", "access", "change-puk", "-p", NON_DEFAULT_PUK, "-n", keys.puk ) - def test_change_puk_prompt(self, ykman_cli): + def test_change_puk_prompt(self, ykman_cli, keys): ykman_cli( "piv", "access", "change-puk", - input=old_new_new(DEFAULT_PUK, NON_DEFAULT_PUK), + input=old_new_new(keys.puk, NON_DEFAULT_PUK), ) ykman_cli( "piv", "access", "change-puk", - input=old_new_new(NON_DEFAULT_PUK, DEFAULT_PUK), + input=old_new_new(NON_DEFAULT_PUK, keys.puk), ) - def test_unblock_pin(self, ykman_cli): + def test_unblock_pin(self, ykman_cli, keys): for _ in range(3): with pytest.raises(SystemExit): ykman_cli( @@ -77,7 +71,7 @@ def test_unblock_pin(self, ykman_cli): "-P", NON_DEFAULT_PIN, "-n", - DEFAULT_PIN, + keys.pin, ) o = ykman_cli("piv", "info").output @@ -85,11 +79,11 @@ def test_unblock_pin(self, ykman_cli): with pytest.raises(SystemExit): ykman_cli( - "piv", "access", "change-pin", "-p", DEFAULT_PIN, "-n", NON_DEFAULT_PIN + "piv", "access", "change-pin", "-p", keys.pin, "-n", NON_DEFAULT_PIN ) o = ykman_cli( - "piv", "access", "unblock-pin", "-p", DEFAULT_PUK, "-n", DEFAULT_PIN + "piv", "access", "unblock-pin", "-p", keys.puk, "-n", keys.pin ).output assert "PIN unblocked" in o o = ykman_cli("piv", "info").output @@ -97,14 +91,14 @@ def test_unblock_pin(self, ykman_cli): class TestSetRetries: - def test_set_retries(self, ykman_cli, version): + def test_set_retries(self, ykman_cli, default_keys, version): ykman_cli( "piv", "access", "set-retries", "5", "6", - input=f"{DEFAULT_MANAGEMENT_KEY}\n{DEFAULT_PIN}\ny\n", + input=f"{default_keys.mgmt}\n{default_keys.pin}\ny\n", ) o = ykman_cli("piv", "info").output @@ -112,7 +106,10 @@ def test_set_retries(self, ykman_cli, version): if version >= (5, 3): assert re.search(r"PUK tries remaining:\s+6/6", o) - def test_set_retries_clears_puk_blocked(self, ykman_cli): + def test_set_retries_clears_puk_blocked(self, ykman_cli, keys, info): + if CAPABILITY.PIV in info.fips_capable: + pytest.skip("YubiKey FIPS") + for _ in range(3): with pytest.raises(SystemExit): ykman_cli( @@ -122,7 +119,7 @@ def test_set_retries_clears_puk_blocked(self, ykman_cli): "-p", NON_DEFAULT_PUK, "-n", - DEFAULT_PUK, + keys.puk, ) pivman = PivmanData() @@ -135,7 +132,7 @@ def test_set_retries_clears_puk_blocked(self, ykman_cli): hex(OBJECT_ID_PIVMAN_DATA), "-", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, input=pivman.get_bytes(), ) @@ -148,7 +145,7 @@ def test_set_retries_clears_puk_blocked(self, ykman_cli): "set-retries", "3", "3", - input=f"{DEFAULT_MANAGEMENT_KEY}\n{DEFAULT_PIN}\ny\n", + input=f"{keys.mgmt}\n{keys.pin}\ny\n", ) o = ykman_cli("piv", "info").output diff --git a/tests/device/cli/piv/test_read_write_object.py b/tests/device/cli/piv/test_read_write_object.py index 340a32b5..f52d8e3e 100644 --- a/tests/device/cli/piv/test_read_write_object.py +++ b/tests/device/cli/piv/test_read_write_object.py @@ -7,11 +7,8 @@ import pytest -DEFAULT_MANAGEMENT_KEY = "010203040506070801020304050607080102030405060708" - - class TestReadWriteObject: - def test_write_read_preserves_ansi_escapes(self, ykman_cli): + def test_write_read_preserves_ansi_escapes(self, ykman_cli, keys): red = b"\x00\x1b[31m" blue = b"\x00\x1b[34m" reset = b"\x00\x1b[0m" @@ -31,7 +28,7 @@ def test_write_read_preserves_ansi_escapes(self, ykman_cli): "objects", "import", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, "0x5f0001", "-", input=data, @@ -41,7 +38,7 @@ def test_write_read_preserves_ansi_escapes(self, ykman_cli): ).stdout_bytes assert data == output_data - def test_read_write_read_is_noop(self, ykman_cli): + def test_read_write_read_is_noop(self, ykman_cli, keys): data = os.urandom(32) ykman_cli( @@ -51,7 +48,7 @@ def test_read_write_read_is_noop(self, ykman_cli): hex(OBJECT_ID.AUTHENTICATION), "-", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, input=data, ) @@ -67,7 +64,7 @@ def test_read_write_read_is_noop(self, ykman_cli): hex(OBJECT_ID.AUTHENTICATION), "-", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, input=output1, ) @@ -76,7 +73,7 @@ def test_read_write_read_is_noop(self, ykman_cli): ).stdout_bytes assert output2 == data - def test_read_write_certificate_as_object(self, ykman_cli): + def test_read_write_certificate_as_object(self, ykman_cli, keys): with pytest.raises(SystemExit): ykman_cli("piv", "objects", "export", hex(OBJECT_ID.AUTHENTICATION), "-") @@ -92,7 +89,7 @@ def test_read_write_certificate_as_object(self, ykman_cli): hex(OBJECT_ID.AUTHENTICATION), "-", "-m", - DEFAULT_MANAGEMENT_KEY, + keys.mgmt, input=input_tlv, ) diff --git a/tests/device/cli/piv/util.py b/tests/device/cli/piv/util.py index 5d9d8cf8..54dfd3fc 100644 --- a/tests/device/cli/piv/util.py +++ b/tests/device/cli/piv/util.py @@ -1,7 +1,7 @@ DEFAULT_PIN = "123456" -NON_DEFAULT_PIN = "654321" +NON_DEFAULT_PIN = "12341235" DEFAULT_PUK = "12345678" -NON_DEFAULT_PUK = "87654321" +NON_DEFAULT_PUK = "12341236" DEFAULT_MANAGEMENT_KEY = "010203040506070801020304050607080102030405060708" NON_DEFAULT_MANAGEMENT_KEY = "010103040506070801020304050607080102030405060708" diff --git a/tests/device/cli/test_hsmauth.py b/tests/device/cli/test_hsmauth.py index 7887fd1a..f4b67aef 100644 --- a/tests/device/cli/test_hsmauth.py +++ b/tests/device/cli/test_hsmauth.py @@ -22,7 +22,28 @@ import struct DEFAULT_MANAGEMENT_KEY = "00000000000000000000000000000000" -NON_DEFAULT_MANAGEMENT_KEY = "11111111111111111111111111111111" +NON_DEFAULT_MANAGEMENT_KEY = "11111111111111111111111111111112" + + +# Test both password and key +@pytest.fixture(params=[DEFAULT_MANAGEMENT_KEY, "p4ssw0rd123"]) +def management_key(request, ykman_cli, info): + key = request.param + if key == DEFAULT_MANAGEMENT_KEY and CAPABILITY.HSMAUTH in info.fips_capable: + key = "00000000000000000000000000000001" + + if key != DEFAULT_MANAGEMENT_KEY: + ykman_cli( + "hsmauth", + "access", + "change-management-password", + "-m", + "", + "-n", + key, + ) + + yield key def generate_pem_eccp256_keypair(): @@ -97,59 +118,62 @@ def verify_credential_password(self, ykman_cli, credential_password, label): apdu = calculate_session_keys_apdu(label, context, credential_password) # Try to calculate session keys using credential password - ykman_cli("apdu", "-a", "hsmauth", apdu) + # TODO: Use SCP if needed + ykman_cli("--scp", "scp11b", "apdu", "-a", "hsmauth", apdu) - def test_import_credential_symmetric(self, ykman_cli): + def test_import_credential_symmetric(self, ykman_cli, management_key): ykman_cli( "hsmauth", "credentials", "symmetric", "test-name-sym", "-c", - "123456", + "12345679", "-E", os.urandom(16).hex(), "-M", os.urandom(16).hex(), "-m", - DEFAULT_MANAGEMENT_KEY, + management_key, ) - self.verify_credential_password(ykman_cli, "123456", "test-name-sym") + self.verify_credential_password(ykman_cli, "12345679", "test-name-sym") creds = ykman_cli("hsmauth", "credentials", "list").output assert "test-name-sym" in creds - def test_import_credential_symmetric_generate(self, ykman_cli): + def test_import_credential_symmetric_generate(self, ykman_cli, management_key): output = ykman_cli( "hsmauth", "credentials", "symmetric", "test-name-sym-gen", "-c", - "123456", + "12345679", "-g", "-m", - DEFAULT_MANAGEMENT_KEY, + management_key, ).output - self.verify_credential_password(ykman_cli, "123456", "test-name-sym-gen") + self.verify_credential_password(ykman_cli, "12345679", "test-name-sym-gen") assert "Generated ENC and MAC keys" in output - def test_import_credential_symmetric_derived(self, ykman_cli): + def test_import_credential_symmetric_derived(self, ykman_cli, management_key): ykman_cli( "hsmauth", "credentials", "derive", "test-name-sym-derived", "-c", - "123456", + "12345679", "-d", "password", + "-m", + management_key, ) - self.verify_credential_password(ykman_cli, "123456", "test-name-sym-derived") + self.verify_credential_password(ykman_cli, "12345679", "test-name-sym-derived") creds = ykman_cli("hsmauth", "credentials", "list").output assert "test-name-sym-derived" in creds @condition.min_version(5, 6) - def test_import_credential_asymmetric(self, ykman_cli): + def test_import_credential_asymmetric(self, ykman_cli, management_key): pair = generate_pem_eccp256_keypair() ykman_cli( "hsmauth", @@ -157,9 +181,9 @@ def test_import_credential_asymmetric(self, ykman_cli): "import", "test-name-asym", "-c", - "123456", + "12345679", "-m", - DEFAULT_MANAGEMENT_KEY, + management_key, "-", input=pair[0], ) @@ -172,32 +196,34 @@ def test_import_credential_asymmetric(self, ykman_cli): assert pair[1] == public_key_exported @condition.min_version(5, 6) - def test_generate_credential_asymmetric(self, ykman_cli): + def test_generate_credential_asymmetric(self, ykman_cli, management_key): ykman_cli( "hsmauth", "credentials", "generate", "test-name-asym-generated", "-c", - "123456", + "12345679", "-m", - DEFAULT_MANAGEMENT_KEY, + management_key, ) creds = ykman_cli("hsmauth", "credentials", "list").output assert "test-name-asym-generated" in creds - def test_import_credential_touch_required(self, ykman_cli): + def test_import_credential_touch_required(self, ykman_cli, management_key): ykman_cli( "hsmauth", "credentials", "derive", "test-name-touch", "-c", - "123456", + "12345679", "-d", "password", "-t", + "-m", + management_key, ) creds = ykman_cli("hsmauth", "credentials", "list").output @@ -205,7 +231,9 @@ def test_import_credential_touch_required(self, ykman_cli): assert "test-name-touch" in creds @condition.min_version(5, 6) - def test_export_public_key_to_file(self, ykman_cli, eccp256_keypair, tmp_file): + def test_export_public_key_to_file( + self, ykman_cli, management_key, eccp256_keypair, tmp_file + ): private_key_file, public_key = eccp256_keypair ykman_cli( "hsmauth", @@ -213,9 +241,9 @@ def test_export_public_key_to_file(self, ykman_cli, eccp256_keypair, tmp_file): "import", "test-name-asym", "-c", - "123456", + "12345679", "-m", - DEFAULT_MANAGEMENT_KEY, + management_key, private_key_file, ) @@ -231,51 +259,53 @@ def test_export_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_export_public_key_symmetric_credential(self, ykman_cli): + def test_export_public_key_symmetric_credential(self, ykman_cli, management_key): ykman_cli( "hsmauth", "credentials", "derive", "test-name-sym", "-c", - "123456", + "12345679", "-d", "password", "-m", - DEFAULT_MANAGEMENT_KEY, + management_key, ) with pytest.raises(SystemExit): ykman_cli("hsmauth", "credentials", "export", "test-name-sym") - def test_delete_credential(self, ykman_cli): + def test_delete_credential(self, ykman_cli, management_key): ykman_cli( "hsmauth", "credentials", "derive", "delete-me", "-c", - "123456", + "12345679", "-d", "password", "-m", - DEFAULT_MANAGEMENT_KEY, + management_key, ) old_creds = ykman_cli("hsmauth", "credentials", "list").output assert "delete-me" in old_creds - ykman_cli("hsmauth", "credentials", "delete", "delete-me", "-f") + ykman_cli( + "hsmauth", "credentials", "delete", "delete-me", "-f", "-m", management_key + ) new_creds = ykman_cli("hsmauth", "credentials", "list").output assert "delete-me" not in new_creds class TestManagementKey: - def test_change_management_key(self, ykman_cli): + def test_change_management_key(self, ykman_cli, management_key): ykman_cli( "hsmauth", "access", - "change-management-key", + "change-management-password", "-m", - DEFAULT_MANAGEMENT_KEY, + management_key, "-n", NON_DEFAULT_MANAGEMENT_KEY, ) @@ -285,31 +315,34 @@ def test_change_management_key(self, ykman_cli): ykman_cli( "hsmauth", "access", - "change-management-key", + "change-management-password", "-m", - DEFAULT_MANAGEMENT_KEY, + management_key, "-n", - DEFAULT_MANAGEMENT_KEY, + management_key, ) # Should succeed ykman_cli( "hsmauth", "access", - "change-management-key", + "change-management-password", "-m", NON_DEFAULT_MANAGEMENT_KEY, "-n", - DEFAULT_MANAGEMENT_KEY, + management_key, ) - def test_change_management_key_generate(self, ykman_cli): + def test_change_management_key_generate(self, ykman_cli, management_key): + if len(management_key) != 32: + pytest.skip("string management key") + output = ykman_cli( "hsmauth", "access", "change-management-key", "-m", - DEFAULT_MANAGEMENT_KEY, + management_key, "-g", ).output diff --git a/tests/device/cli/test_oath.py b/tests/device/cli/test_oath.py index dc5fbc1b..1dfc4451 100644 --- a/tests/device/cli/test_oath.py +++ b/tests/device/cli/test_oath.py @@ -39,6 +39,28 @@ def preconditions(ykman_cli): ykman_cli("oath", "reset", "-f") +@pytest.fixture() +def password(info): + if CAPABILITY.OATH in info.fips_capable: + yield PASSWORD + else: + yield None + + +@pytest.fixture() +def accounts_cli(ykman_cli, password): + if password: + ykman_cli("oath", "access", "change", "-n", password) + + def fn(*args, **kwargs): + argv = ["oath", "accounts", *args] + if password: + argv += ["-p", password] + return ykman_cli(*argv, **kwargs) + + yield fn + + class TestOATH: def test_oath_info(self, ykman_cli): output = ykman_cli("oath", "info").output @@ -49,65 +71,63 @@ def test_info_does_not_indicate_fips_mode_for_non_fips_key(self, ykman_cli): info = ykman_cli("oath", "info").output assert "FIPS:" not in info - def test_oath_add_credential(self, ykman_cli): - ykman_cli("oath", "accounts", "add", "test-name", "abba") - creds = ykman_cli("oath", "accounts", "list").output + def test_oath_add_credential(self, accounts_cli, password): + accounts_cli("add", "test-name", "abba") + creds = accounts_cli("list").output assert "test-name" in creds - def test_oath_add_credential_prompt(self, ykman_cli): - ykman_cli("oath", "accounts", "add", "test-name-2", input="abba") - creds = ykman_cli("oath", "accounts", "list").output + def test_oath_add_credential_prompt(self, accounts_cli): + accounts_cli("add", "test-name-2", input="abba") + creds = accounts_cli("list").output assert "test-name-2" in creds - def test_oath_add_credential_with_space(self, ykman_cli): - ykman_cli("oath", "accounts", "add", "test-name-space", "ab ba") - creds = ykman_cli("oath", "accounts", "list").output + def test_oath_add_credential_with_space(self, accounts_cli): + accounts_cli("add", "test-name-space", "ab ba") + creds = accounts_cli("list").output assert "test-name-space" in creds - def test_oath_hidden_cred(self, ykman_cli): - ykman_cli("oath", "accounts", "add", "_hidden:name", "abba") - creds = ykman_cli("oath", "accounts", "code").output + def test_oath_hidden_cred(self, accounts_cli): + accounts_cli("add", "_hidden:name", "abba") + creds = accounts_cli("code").output assert "_hidden:name" not in creds - creds = ykman_cli("oath", "accounts", "code", "-H").output + creds = accounts_cli("code", "-H").output assert "_hidden:name" in creds - def test_oath_add_uri_hotp(self, ykman_cli): - ykman_cli("oath", "accounts", "uri", URI_HOTP_EXAMPLE) - creds = ykman_cli("oath", "accounts", "list").output + def test_oath_add_uri_hotp(self, accounts_cli): + accounts_cli("uri", URI_HOTP_EXAMPLE) + creds = accounts_cli("list").output assert "Example:demo" in creds - def test_oath_add_uri_totp(self, ykman_cli): - ykman_cli("oath", "accounts", "uri", URI_TOTP_EXAMPLE) - creds = ykman_cli("oath", "accounts", "list").output + def test_oath_add_uri_totp(self, accounts_cli): + accounts_cli("uri", URI_TOTP_EXAMPLE) + creds = accounts_cli("list").output assert "john.doe" in creds - def test_oath_add_uri_totp_extra_parameter(self, ykman_cli): - ykman_cli("oath", "accounts", "uri", URI_TOTP_EXAMPLE_EXTRA_PARAMETER) - creds = ykman_cli("oath", "accounts", "list").output + def test_oath_add_uri_totp_extra_parameter(self, accounts_cli): + accounts_cli("uri", URI_TOTP_EXAMPLE_EXTRA_PARAMETER) + creds = accounts_cli("list").output assert "john.doe.extra" in creds - def test_oath_add_uri_totp_prompt(self, ykman_cli): - ykman_cli("oath", "accounts", "uri", input=URI_TOTP_EXAMPLE_B) - creds = ykman_cli("oath", "accounts", "list").output + def test_oath_add_uri_totp_prompt(self, accounts_cli): + accounts_cli("uri", input=URI_TOTP_EXAMPLE_B) + creds = accounts_cli("list").output assert "john.doe" in creds - def test_oath_code(self, ykman_cli): - ykman_cli("oath", "accounts", "add", "test-name2", "abba") - creds = ykman_cli("oath", "accounts", "code").output + def test_oath_code(self, accounts_cli): + accounts_cli("add", "test-name2", "abba") + creds = accounts_cli("code").output assert "test-name2" in creds - def test_oath_code_query_single(self, ykman_cli): - ykman_cli("oath", "accounts", "add", "query-me", "abba") - creds = ykman_cli("oath", "accounts", "code", "query-me").output + def test_oath_code_query_single(self, accounts_cli): + accounts_cli("add", "query-me", "abba") + creds = accounts_cli("code", "query-me").output assert "query-me" in creds - def test_oath_code_query_multiple(self, ykman_cli): - ykman_cli("oath", "accounts", "add", "foo", "abba") - ykman_cli("oath", "accounts", "add", "query-me", "abba") - ykman_cli("oath", "accounts", "add", "bar", "abba") - lines = ( - ykman_cli("oath", "accounts", "code", "query").output.strip().splitlines() - ) + def test_oath_code_query_multiple(self, accounts_cli): + accounts_cli("add", "foo", "abba") + accounts_cli("add", "query-me", "abba") + accounts_cli("add", "bar", "abba") + lines = accounts_cli("code", "query").output.strip().splitlines() assert len(lines) == 1 assert "query-me" in lines[0] @@ -115,10 +135,8 @@ def test_oath_reset(self, ykman_cli): output = ykman_cli("oath", "reset", "-f").output assert "Success! All OATH accounts have been deleted from the YubiKey" in output - def test_oath_hotp_vectors_6(self, ykman_cli): - ykman_cli( - "oath", - "accounts", + def test_oath_hotp_vectors_6(self, accounts_cli): + accounts_cli( "add", "-o", "HOTP", @@ -126,13 +144,11 @@ def test_oath_hotp_vectors_6(self, ykman_cli): b32encode(b"12345678901234567890").decode(), ) for code in ["755224", "287082", "359152", "969429", "338314"]: - words = ykman_cli("oath", "accounts", "code", "testvector").output.split() + words = accounts_cli("code", "testvector").output.split() assert code in words - def test_oath_hotp_vectors_8(self, ykman_cli): - ykman_cli( - "oath", - "accounts", + def test_oath_hotp_vectors_8(self, accounts_cli): + accounts_cli( "add", "-o", "HOTP", @@ -142,44 +158,42 @@ def test_oath_hotp_vectors_8(self, ykman_cli): b32encode(b"12345678901234567890").decode(), ) for code in ["84755224", "94287082", "37359152", "26969429", "40338314"]: - words = ykman_cli("oath", "accounts", "code", "testvector8").output.split() + words = accounts_cli("code", "testvector8").output.split() assert code in words - def test_oath_hotp_code(self, ykman_cli): - ykman_cli("oath", "accounts", "add", "-o", "HOTP", "hotp-cred", "abba") - words = ykman_cli("oath", "accounts", "code", "hotp-cred").output.split() + def test_oath_hotp_code(self, accounts_cli): + accounts_cli("add", "-o", "HOTP", "hotp-cred", "abba") + words = accounts_cli("code", "hotp-cred").output.split() assert "659165" in words - def test_oath_hotp_code_single(self, ykman_cli): - ykman_cli("oath", "accounts", "add", "-o", "HOTP", "hotp-cred", "abba") - words = ykman_cli( - "oath", "accounts", "code", "hotp-cred", "--single" - ).output.split() + def test_oath_hotp_code_single(self, accounts_cli): + accounts_cli("add", "-o", "HOTP", "hotp-cred", "abba") + words = accounts_cli("code", "hotp-cred", "--single").output.split() assert "659165" in words - def test_oath_totp_steam_code(self, ykman_cli): - ykman_cli("oath", "accounts", "add", "Steam:steam-cred", "abba") - cred = ykman_cli("oath", "accounts", "code", "steam-cred").output.strip() + def test_oath_totp_steam_code(self, accounts_cli): + accounts_cli("add", "Steam:steam-cred", "abba") + cred = accounts_cli("code", "steam-cred").output.strip() code = cred.split()[-1] assert 5 == len(code), f"cred wrong length: {code!r}" assert all( c in STEAM_CHAR_TABLE for c in code ), f"{code!r} contains non-steam characters" - def test_oath_totp_steam_code_single(self, ykman_cli): - ykman_cli("oath", "accounts", "add", "Steam:steam-cred", "abba") - code = ykman_cli("oath", "accounts", "code", "-s", "steam-cred").output.strip() + def test_oath_totp_steam_code_single(self, accounts_cli): + accounts_cli("add", "Steam:steam-cred", "abba") + code = accounts_cli("code", "-s", "steam-cred").output.strip() assert 5 == len(code), f"cred wrong length: {code!r}" assert all( c in STEAM_CHAR_TABLE for c in code ), f"{code!r} contains non-steam characters" - def test_oath_code_output_no_touch(self, ykman_cli): - ykman_cli("oath", "accounts", "add", "TOTP:normal", "aaaa") - ykman_cli("oath", "accounts", "add", "Steam:normal", "aaba") - ykman_cli("oath", "accounts", "add", "-o", "HOTP", "HOTP:normal", "abaa") + def test_oath_code_output_no_touch(self, accounts_cli): + accounts_cli("add", "TOTP:normal", "aaaa") + accounts_cli("add", "Steam:normal", "aaba") + accounts_cli("add", "-o", "HOTP", "HOTP:normal", "abaa") - lines = ykman_cli("oath", "accounts", "code").output.strip().splitlines() + lines = accounts_cli("code").output.strip().splitlines() entries = {line.split()[0]: line for line in lines} assert "HOTP Account" in entries["HOTP:normal"] @@ -194,14 +208,14 @@ def test_oath_code_output_no_touch(self, ykman_cli): int(code) @condition.min_version(4) - def test_oath_code_output(self, ykman_cli): - ykman_cli("oath", "accounts", "add", "TOTP:normal", "aaaa") - ykman_cli("oath", "accounts", "add", "--touch", "TOTP:touch", "aaab") - ykman_cli("oath", "accounts", "add", "Steam:normal", "aaba") - ykman_cli("oath", "accounts", "add", "--touch", "Steam:touch", "aabb") - ykman_cli("oath", "accounts", "add", "-o", "HOTP", "HOTP:normal", "abaa") - - lines = ykman_cli("oath", "accounts", "code").output.strip().splitlines() + def test_oath_code_output(self, accounts_cli): + accounts_cli("add", "TOTP:normal", "aaaa") + accounts_cli("add", "--touch", "TOTP:touch", "aaab") + accounts_cli("add", "Steam:normal", "aaba") + accounts_cli("add", "--touch", "Steam:touch", "aabb") + accounts_cli("add", "-o", "HOTP", "HOTP:normal", "abaa") + + lines = accounts_cli("code").output.strip().splitlines() entries = {line.split()[0]: line for line in lines} assert "Requires Touch" in entries["TOTP:touch"] assert "Requires Touch" in entries["Steam:touch"] @@ -218,50 +232,48 @@ def test_oath_code_output(self, ykman_cli): int(code) @condition.min_version(4) - def test_oath_totp_steam_touch_not_in_code_output(self, ykman_cli): - ykman_cli("oath", "accounts", "add", "--touch", "Steam:steam-cred", "abba") - ykman_cli("oath", "accounts", "add", "TOTP:totp-cred", "abba") - lines = ykman_cli("oath", "accounts", "code").output.strip().splitlines() + def test_oath_totp_steam_touch_not_in_code_output(self, accounts_cli): + accounts_cli("add", "--touch", "Steam:steam-cred", "abba") + accounts_cli("add", "TOTP:totp-cred", "abba") + lines = accounts_cli("code").output.strip().splitlines() assert "Requires Touch" in lines[0] - def test_oath_delete(self, ykman_cli): - ykman_cli("oath", "accounts", "add", "delete-me", "abba") - ykman_cli("oath", "accounts", "delete", "delete-me", "-f") - assert "delete-me", ykman_cli("oath", "accounts" not in "list") + def test_oath_delete(self, accounts_cli): + accounts_cli("add", "delete-me", "abba") + accounts_cli("delete", "delete-me", "-f") + assert "delete-me" not in accounts_cli("list").output - def test_oath_unicode(self, ykman_cli): - ykman_cli("oath", "accounts", "add", "😃", "abba") - ykman_cli("oath", "accounts", "code") - ykman_cli("oath", "accounts", "list") - ykman_cli("oath", "accounts", "delete", "😃", "-f") + def test_oath_unicode(self, accounts_cli): + accounts_cli("add", "😃", "abba") + accounts_cli("code") + accounts_cli("list") + accounts_cli("delete", "😃", "-f") @condition.yk4_fips(False) @condition.min_version(4, 3, 1) - def test_oath_sha512(self, ykman_cli): - ykman_cli("oath", "accounts", "add", "abba", "abba", "--algorithm", "SHA512") - ykman_cli("oath", "accounts", "delete", "abba", "-f") + def test_oath_sha512(self, accounts_cli): + accounts_cli("add", "abba", "abba", "--algorithm", "SHA512") + accounts_cli("delete", "abba", "-f") # NEO credential capacity may vary based on configuration @condition.min_version(4) - def test_add_max_creds(self, ykman_cli, version): + def test_add_max_creds(self, accounts_cli, version): n_creds = 32 if version < (5, 7, 0) else 64 for i in range(n_creds): - ykman_cli("oath", "accounts", "add", "test" + str(i), "abba") - output = ykman_cli("oath", "accounts", "list").output + accounts_cli("add", "test" + str(i), "abba") + output = accounts_cli("list").output lines = output.strip().split("\n") assert len(lines) == i + 1 with pytest.raises(SystemExit): - ykman_cli("oath", "accounts", "add", "testx", "abba") + accounts_cli("add", "testx", "abba") @condition.min_version(5, 3, 1) - def test_rename(self, ykman_cli): - ykman_cli("oath", "accounts", "uri", URI_TOTP_EXAMPLE) - ykman_cli( - "oath", "accounts", "rename", "john.doe", "Example:user@example.com", "-f" - ) + def test_rename(self, accounts_cli): + accounts_cli("uri", URI_TOTP_EXAMPLE) + accounts_cli("rename", "john.doe", "Example:user@example.com", "-f") - creds = ykman_cli("oath", "accounts", "list").output + creds = accounts_cli("list").output assert "john.doe" not in creds assert "Example:user@example.com" in creds diff --git a/tests/device/cli/test_openpgp.py b/tests/device/cli/test_openpgp.py index 93dfcd3b..e59ce4d3 100644 --- a/tests/device/cli/test_openpgp.py +++ b/tests/device/cli/test_openpgp.py @@ -3,9 +3,11 @@ import pytest DEFAULT_PIN = "123456" -NON_DEFAULT_PIN = "654321" +NON_DEFAULT_PIN = "12345679" +NON_DEFAULT_PIN_2 = "12345670" DEFAULT_ADMIN_PIN = "12345678" -NON_DEFAULT_ADMIN_PIN = "87654321" +NON_DEFAULT_ADMIN_PIN = "12345670" +NON_DEFAULT_ADMIN_PIN_2 = "12345679" def old_new_new(old, new): @@ -34,7 +36,13 @@ def test_change_pin(self, ykman_cli): "openpgp", "access", "change-pin", "-P", DEFAULT_PIN, "-n", NON_DEFAULT_PIN ) ykman_cli( - "openpgp", "access", "change-pin", "-P", NON_DEFAULT_PIN, "-n", DEFAULT_PIN + "openpgp", + "access", + "change-pin", + "-P", + NON_DEFAULT_PIN, + "-n", + NON_DEFAULT_PIN_2, ) def test_change_pin_prompt(self, ykman_cli): @@ -48,7 +56,7 @@ def test_change_pin_prompt(self, ykman_cli): "openpgp", "access", "change-pin", - input=old_new_new(NON_DEFAULT_PIN, DEFAULT_PIN), + input=old_new_new(NON_DEFAULT_PIN, NON_DEFAULT_PIN_2), ) @@ -70,7 +78,7 @@ def test_change_admin_pin(self, ykman_cli): "-a", NON_DEFAULT_ADMIN_PIN, "-n", - DEFAULT_ADMIN_PIN, + NON_DEFAULT_ADMIN_PIN_2, ) def test_change_pin_prompt(self, ykman_cli): @@ -84,18 +92,24 @@ def test_change_pin_prompt(self, ykman_cli): "openpgp", "access", "change-admin-pin", - input=old_new_new(NON_DEFAULT_ADMIN_PIN, DEFAULT_ADMIN_PIN), + input=old_new_new(NON_DEFAULT_ADMIN_PIN, NON_DEFAULT_ADMIN_PIN_2), ) class TestResetPin: def ensure_pin_changed(self, ykman_cli): ykman_cli( - "openpgp", "access", "change-pin", "-P", NON_DEFAULT_PIN, "-n", DEFAULT_PIN + "openpgp", + "access", + "change-pin", + "-P", + NON_DEFAULT_PIN, + "-n", + NON_DEFAULT_PIN_2, ) def test_set_and_use_reset_code(self, ykman_cli): - reset_code = "12345678" + reset_code = "00112233" ykman_cli( "openpgp", @@ -120,7 +134,7 @@ def test_set_and_use_reset_code(self, ykman_cli): self.ensure_pin_changed(ykman_cli) def test_set_and_use_reset_code_prompt(self, ykman_cli): - reset_code = "87654321" + reset_code = "11223344" ykman_cli( "openpgp", @@ -137,7 +151,13 @@ def test_set_and_use_reset_code_prompt(self, ykman_cli): ) ykman_cli( - "openpgp", "access", "change-pin", "-P", NON_DEFAULT_PIN, "-n", DEFAULT_PIN + "openpgp", + "access", + "change-pin", + "-P", + NON_DEFAULT_PIN, + "-n", + NON_DEFAULT_PIN_2, ) def test_unblock_pin_with_admin_pin(self, ykman_cli): diff --git a/tests/device/cli/test_otp.py b/tests/device/cli/test_otp.py index d37b552e..57e1cb7d 100644 --- a/tests/device/cli/test_otp.py +++ b/tests/device/cli/test_otp.py @@ -35,6 +35,11 @@ import pytest +def no_pin_complexity(info): + """PIN complexity enabled""" + return not info.pin_complexity + + @pytest.fixture(autouse=True) @condition.capability(CAPABILITY.OTP) def ensure_otp(): @@ -47,6 +52,7 @@ def test_ykman_otp_info(self, ykman_cli): assert "Slot 1:" in info assert "Slot 2:" in info + @condition.check(no_pin_complexity) def test_ykman_swap_slots(self, ykman_cli): info = ykman_cli("otp", "info").output if "programmed" not in info: @@ -65,6 +71,7 @@ def test_ykman_otp_info_does_not_indicate_fips_mode_for_non_fips_key( class TestReclaimTimeout: + @condition.check(no_pin_complexity) def test_update_after_reclaim(self, ykman_cli): info = ykman_cli("otp", "info").output if "programmed" not in info: @@ -78,6 +85,7 @@ def test_update_after_reclaim(self, ykman_cli): class TestSlotStaticPassword: @pytest.fixture(autouse=True) + @condition.check(no_pin_complexity) def delete_slot(self, ykman_cli): try: ykman_cli("otp", "delete", "2", "-f") @@ -145,6 +153,7 @@ def test_overwrite_prompt(self, ykman_cli): class TestSlotProgramming: @pytest.fixture(autouse=True) + @condition.check(no_pin_complexity) def delete_slot(self, ykman_cli): try: ykman_cli("otp", "delete", "2", "-f") @@ -567,6 +576,7 @@ def _check_slot_2_does_not_have_access_code(self, ykman_cli): class TestSlotCalculate: @pytest.fixture(autouse=True) + @condition.check(no_pin_complexity) def delete_slot(self, ykman_cli): try: ykman_cli("otp", "delete", "2", "-f") diff --git a/tests/device/test_hsmauth.py b/tests/device/test_hsmauth.py index fb4758bd..9a20f11b 100644 --- a/tests/device/test_hsmauth.py +++ b/tests/device/test_hsmauth.py @@ -18,7 +18,7 @@ import os DEFAULT_MANAGEMENT_KEY = bytes.fromhex("00000000000000000000000000000000") -NON_DEFAULT_MANAGEMENT_KEY = bytes.fromhex("11111111111111111111111111111111") +NON_DEFAULT_MANAGEMENT_KEY = bytes.fromhex("11111111111111111111111111111112") @pytest.fixture @@ -30,11 +30,22 @@ def session(ccid_connection): yield hsmauth +@pytest.fixture +def management_key(session, info): + if CAPABILITY.HSMAUTH in info.fips_capable: + key = bytes.fromhex("00000000000000000000000000000001") + session.put_management_key(DEFAULT_MANAGEMENT_KEY, key) + + yield key + else: + yield DEFAULT_MANAGEMENT_KEY + + def import_key_derived( session, management_key, - credential_password="123456", - derivation_password="password", + credential_password="12345679", + derivation_password="p4ssw0rd", ) -> Credential: credential = session.put_credential_derived( management_key, @@ -47,7 +58,7 @@ def import_key_derived( def import_key_symmetric( - session, management_key, key_enc, key_mac, credential_password="123456" + session, management_key, key_enc, key_mac, credential_password="12345679" ) -> Credential: credential = session.put_credential_symmetric( management_key, @@ -61,7 +72,7 @@ def import_key_symmetric( def import_key_asymmetric( - session, management_key, private_key, credential_password="123456" + session, management_key, private_key, credential_password="12345679" ) -> Credential: credential = session.put_credential_asymmetric( management_key, @@ -74,7 +85,7 @@ def import_key_asymmetric( def generate_key_asymmetric( - session, management_key, credential_password="123456" + session, management_key, credential_password="12345679" ) -> Credential: credential = session.generate_credential_asymmetric( management_key, @@ -101,49 +112,63 @@ def verify_credential_password( ): context = b"g\xfc\xf1\xfe\xb5\xf1\xd8\x83\xedv=\xbfI0\x90\xbb" - # Try to calculate session keys using credential password + # Try to calculate session keys using wrong credential password + with pytest.raises(InvalidPinError): + session.calculate_session_keys_symmetric( + label=credential.label, + context=context, + credential_password="wrongvalue", + ) + + # Try to calculate session keys using correct credential password session.calculate_session_keys_symmetric( label=credential.label, context=context, credential_password=credential_password, ) - def test_import_credential_symmetric_wrong_management_key(self, session): + def test_import_credential_symmetric_wrong_management_key( + self, session, management_key + ): with pytest.raises(InvalidPinError): import_key_derived(session, NON_DEFAULT_MANAGEMENT_KEY) - def test_import_credential_symmetric_wrong_key_length(self, session): + def test_import_credential_symmetric_wrong_key_length( + self, session, management_key + ): with pytest.raises(ValueError): import_key_symmetric( - session, DEFAULT_MANAGEMENT_KEY, os.urandom(24), os.urandom(24) + session, management_key, os.urandom(24), os.urandom(24) ) - def test_import_credential_symmetric_exists(self, session): - import_key_derived(session, DEFAULT_MANAGEMENT_KEY) + def test_import_credential_symmetric_exists(self, session, management_key): + import_key_derived(session, management_key) with pytest.raises(ApduError): - import_key_derived(session, DEFAULT_MANAGEMENT_KEY) + import_key_derived(session, management_key) - def test_import_credential_symmetric_works(self, session): - credential = import_key_derived(session, DEFAULT_MANAGEMENT_KEY, "1234") + def test_import_credential_symmetric_works(self, session, management_key): + credential = import_key_derived(session, management_key, "12345679") - self.verify_credential_password(session, "1234", credential) + self.verify_credential_password(session, "12345679", credential) self.check_credential_in_list(session, credential) - session.delete_credential(DEFAULT_MANAGEMENT_KEY, credential.label) + session.delete_credential(management_key, credential.label) @condition.min_version(5, 6) - def test_import_credential_asymmetric_unsupported_key(self, session): + def test_import_credential_asymmetric_unsupported_key( + self, session, management_key + ): 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) + import_key_asymmetric(session, management_key, private_key) @condition.min_version(5, 6) - def test_import_credential_asymmetric_works(self, session): + def test_import_credential_asymmetric_works(self, session, management_key): private_key = ec.generate_private_key(ec.SECP256R1(), backend=default_backend()) - credential = import_key_asymmetric(session, DEFAULT_MANAGEMENT_KEY, private_key) + credential = import_key_asymmetric(session, management_key, private_key) public_key = private_key.public_key() assert public_key.public_bytes( @@ -153,11 +178,11 @@ def test_import_credential_asymmetric_works(self, session): ) self.check_credential_in_list(session, credential) - session.delete_credential(DEFAULT_MANAGEMENT_KEY, credential.label) + session.delete_credential(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) + def test_generate_credential_asymmetric_works(self, session, management_key): + credential = generate_key_asymmetric(session, management_key) self.check_credential_in_list(session, credential) @@ -166,47 +191,47 @@ def test_generate_credential_asymmetric_works(self, session): assert isinstance(public_key, ec.EllipticCurvePublicKey) assert isinstance(public_key.curve, ec.SECP256R1) - session.delete_credential(DEFAULT_MANAGEMENT_KEY, credential.label) + session.delete_credential(management_key, credential.label) @condition.min_version(5, 6) - def test_export_public_key_symmetric_credential(self, session): - credential = import_key_derived(session, DEFAULT_MANAGEMENT_KEY) + def test_export_public_key_symmetric_credential(self, session, management_key): + credential = import_key_derived(session, management_key) with pytest.raises(ApduError): session.get_public_key(credential.label) - session.delete_credential(DEFAULT_MANAGEMENT_KEY, credential.label) + session.delete_credential(management_key, credential.label) - def test_delete_credential_wrong_management_key(self, session): - credential = import_key_derived(session, DEFAULT_MANAGEMENT_KEY) + def test_delete_credential_wrong_management_key(self, session, management_key): + credential = import_key_derived(session, management_key) with pytest.raises(InvalidPinError): session.delete_credential(NON_DEFAULT_MANAGEMENT_KEY, credential.label) - def test_delete_credential_non_existing(self, session): + def test_delete_credential_non_existing(self, session, management_key): with pytest.raises(ApduError): - session.delete_credential(DEFAULT_MANAGEMENT_KEY, "Default key") + session.delete_credential(management_key, "Default key") - def test_delete_credential_works(self, session): - credential = import_key_derived(session, DEFAULT_MANAGEMENT_KEY) + def test_delete_credential_works(self, session, management_key): + credential = import_key_derived(session, management_key) - session.delete_credential(DEFAULT_MANAGEMENT_KEY, credential.label) + session.delete_credential(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) + def test_change_management_key(self, session, management_key): + session.put_management_key(management_key, NON_DEFAULT_MANAGEMENT_KEY) # Can't import key with old management key with pytest.raises(InvalidPinError): - import_key_derived(session, DEFAULT_MANAGEMENT_KEY) + import_key_derived(session, management_key) - session.put_management_key(NON_DEFAULT_MANAGEMENT_KEY, DEFAULT_MANAGEMENT_KEY) + session.put_management_key(NON_DEFAULT_MANAGEMENT_KEY, management_key) - def test_management_key_retries(self, session): - session.put_management_key(DEFAULT_MANAGEMENT_KEY, DEFAULT_MANAGEMENT_KEY) + def test_management_key_retries(self, session, management_key): + session.put_management_key(management_key, management_key) initial_retries = session.get_management_key_retries() assert initial_retries == 8 @@ -218,11 +243,11 @@ def test_management_key_retries(self, session): class TestSessionKeys: - def test_calculate_session_keys_symmetric(self, session): - credential_password = "1234" + def test_calculate_session_keys_symmetric(self, session, management_key): + credential_password = "a password" credential = import_key_derived( session, - DEFAULT_MANAGEMENT_KEY, + management_key, credential_password=credential_password, derivation_password="pwd", ) @@ -246,8 +271,8 @@ def test_calculate_session_keys_symmetric(self, session): class TestHostChallenge: @condition.min_version(5, 6) - def test_get_challenge_symmetric(self, session): - credential = import_key_derived(session, DEFAULT_MANAGEMENT_KEY) + def test_get_challenge_symmetric(self, session, management_key): + credential = import_key_derived(session, management_key) challenge1 = session.get_challenge(credential.label) challenge2 = session.get_challenge(credential.label) @@ -255,17 +280,20 @@ def test_get_challenge_symmetric(self, session): assert len(challenge2) == 8 assert challenge1 != challenge2 - session.delete_credential(DEFAULT_MANAGEMENT_KEY, credential.label) + session.delete_credential(management_key, credential.label) @condition.min_version(5, 6) - def test_get_challenge_asymmetric(self, session): - credential = generate_key_asymmetric(session, DEFAULT_MANAGEMENT_KEY) + def test_get_challenge_asymmetric(self, session, management_key): + credential_password = "12345679" + credential = generate_key_asymmetric( + session, management_key, credential_password + ) - challenge1 = session.get_challenge(credential.label) - challenge2 = session.get_challenge(credential.label) + challenge1 = session.get_challenge(credential.label, credential_password) + challenge2 = session.get_challenge(credential.label, credential_password) assert len(challenge1) == 65 assert len(challenge2) == 65 assert challenge1 != challenge2 - session.delete_credential(DEFAULT_MANAGEMENT_KEY, credential.label) + session.delete_credential(management_key, credential.label) diff --git a/tests/device/test_oath.py b/tests/device/test_oath.py index ade4ca24..7994d11b 100644 --- a/tests/device/test_oath.py +++ b/tests/device/test_oath.py @@ -16,9 +16,11 @@ @pytest.fixture @condition.capability(CAPABILITY.OATH) -def session(ccid_connection): +def session(ccid_connection, info): oath = OathSession(ccid_connection) oath.reset() + if CAPABILITY.OATH in info.fips_capable: + oath.set_key(KEY) yield oath diff --git a/tests/device/test_openpgp.py b/tests/device/test_openpgp.py index ac8de929..0142c492 100644 --- a/tests/device/test_openpgp.py +++ b/tests/device/test_openpgp.py @@ -10,8 +10,9 @@ KdfNone, ) from yubikit.management import CAPABILITY -from yubikit.core.smartcard import ApduError +from yubikit.core.smartcard import ApduError, AID from . import condition +from typing import NamedTuple import pytest import time @@ -19,9 +20,9 @@ E = 65537 DEFAULT_PIN = "123456" -NON_DEFAULT_PIN = "654321" +NON_DEFAULT_PIN = "12345670" DEFAULT_ADMIN_PIN = "12345678" -NON_DEFAULT_ADMIN_PIN = "87654321" +NON_DEFAULT_ADMIN_PIN = "12345670" @pytest.fixture @@ -32,11 +33,48 @@ def session(ccid_connection): return pgp +class Keys(NamedTuple): + pin: str + admin: str + + +def reset_state(session): + session.protocol.connection.connection.disconnect() + session.protocol.connection.connection.connect() + session.protocol.select(AID.OPENPGP) + + def not_roca(version): """ROCA affected""" return not ((4, 2, 0) <= version < (4, 3, 5)) +def fips_capable(info): + """Not FIPS capable""" + return CAPABILITY.OPENPGP in info.fips_capable + + +def not_fips_capable(info): + """FIPS capable""" + return not fips_capable(info) + + +@pytest.fixture +def keys(session, info): + if fips_capable(info): + new_keys = Keys( + "12345679", + "12345679", + ) + session.change_pin(DEFAULT_PIN, new_keys.pin) + session.change_admin(DEFAULT_ADMIN_PIN, new_keys.admin) + reset_state(session) + + yield new_keys + else: + yield Keys(DEFAULT_PIN, DEFAULT_ADMIN_PIN) + + def test_import_requires_admin(session): priv = rsa.generate_private_key(E, RSA_SIZE.RSA2048, default_backend()) with pytest.raises(ApduError): @@ -51,83 +89,90 @@ def test_generate_requires_admin(session): @condition.min_version(5, 2) @pytest.mark.parametrize("oid", [x for x in OID if "25519" not in x.name]) -def test_import_sign_ecdsa(session, oid): +def test_import_sign_ecdsa(session, info, keys, oid): + if fips_capable(info) and oid == OID.SECP256K1: + pytest.skip("FIPS capable") + priv = ec.generate_private_key(getattr(ec, oid.name)()) - session.verify_admin(DEFAULT_ADMIN_PIN) + session.verify_admin(keys.admin) session.put_key(KEY_REF.SIG, priv) message = b"Hello world" - session.verify_pin(DEFAULT_PIN) + session.verify_pin(keys.pin) sig = session.sign(message, hashes.SHA256()) priv.public_key().verify(sig, message, ec.ECDSA(hashes.SHA256())) @condition.min_version(5, 2) -def test_import_sign_eddsa(session): +def test_import_sign_eddsa(session, keys): priv = ed25519.Ed25519PrivateKey.generate() - session.verify_admin(DEFAULT_ADMIN_PIN) + session.verify_admin(keys.admin) session.put_key(KEY_REF.SIG, priv) message = b"Hello world" - session.verify_pin(DEFAULT_PIN) + session.verify_pin(keys.pin) sig = session.sign(message, hashes.SHA256()) priv.public_key().verify(sig, message) @condition.min_version(5, 2) @pytest.mark.parametrize("oid", [x for x in OID if "25519" not in x.name]) -def test_import_ecdh(session, oid): +def test_import_ecdh(session, info, keys, oid): + if fips_capable(info) and oid == OID.SECP256K1: + pytest.skip("FIPS capable") priv = ec.generate_private_key(getattr(ec, oid.name)()) - session.verify_admin(DEFAULT_ADMIN_PIN) + session.verify_admin(keys.admin) session.put_key(KEY_REF.DEC, priv) e_priv = ec.generate_private_key(getattr(ec, oid.name)()) shared1 = e_priv.exchange(ec.ECDH(), priv.public_key()) - session.verify_pin(DEFAULT_PIN, extended=True) + session.verify_pin(keys.pin, extended=True) shared2 = session.decrypt(e_priv.public_key()) assert shared1 == shared2 +@condition.check(not_fips_capable) @condition.min_version(5, 2) -def test_import_ecdh_x25519(session): +def test_import_ecdh_x25519(session, keys): priv = x25519.X25519PrivateKey.generate() - session.verify_admin(DEFAULT_ADMIN_PIN) + session.verify_admin(keys.admin) session.put_key(KEY_REF.DEC, priv) e_priv = x25519.X25519PrivateKey.generate() shared1 = e_priv.exchange(priv.public_key()) - session.verify_pin(DEFAULT_PIN, extended=True) + session.verify_pin(keys.pin, extended=True) shared2 = session.decrypt(e_priv.public_key()) assert shared1 == shared2 @pytest.mark.parametrize("key_size", [2048, 3072, 4096]) -def test_import_sign_rsa(session, key_size, info): +def test_import_sign_rsa(session, keys, key_size, info): if key_size != 2048: if info.version[0] < 4: pytest.skip(f"RSA {key_size} requires YuibKey 4 or later") elif info.version[0] == 4 and info.is_fips: pytest.skip(f"RSA {key_size} not supported on YubiKey 4 FIPS") priv = rsa.generate_private_key(E, key_size, default_backend()) - session.verify_admin(DEFAULT_ADMIN_PIN) + session.verify_admin(keys.admin) session.put_key(KEY_REF.SIG, priv) if 0 < info.version[0] < 5: # Keys don't work without a generation time (or fingerprint) session.set_generation_time(KEY_REF.SIG, int(time.time())) message = b"Hello world" - session.verify_pin(DEFAULT_PIN) + session.verify_pin(keys.pin) sig = session.sign(message, hashes.SHA256()) priv.public_key().verify(sig, message, padding.PKCS1v15(), hashes.SHA256()) +@condition.check(not_fips_capable) @pytest.mark.parametrize("key_size", [2048, 3072, 4096]) -def test_import_decrypt_rsa(session, key_size, info): +def test_import_decrypt_rsa(session, keys, key_size, info): if key_size != 2048: if info.version[0] < 4: pytest.skip(f"RSA {key_size} requires YuibKey 4 or later") elif info.version[0] == 4 and info.is_fips: pytest.skip(f"RSA {key_size} not supported on YubiKey 4 FIPS") priv = rsa.generate_private_key(E, key_size, default_backend()) - session.verify_admin(DEFAULT_ADMIN_PIN) + session.verify_admin(keys.admin) session.put_key(KEY_REF.DEC, priv) if info.version[0] < 5: # Keys don't work without a generation time (or fingerprint) @@ -135,7 +180,7 @@ def test_import_decrypt_rsa(session, key_size, info): message = b"Hello world" cipher = priv.public_key().encrypt(message, padding.PKCS1v15()) - session.verify_pin(DEFAULT_PIN, extended=True) + session.verify_pin(keys.pin, extended=True) plain = session.decrypt(cipher) assert message == plain @@ -143,13 +188,13 @@ def test_import_decrypt_rsa(session, key_size, info): @condition.check(not_roca) @pytest.mark.parametrize("key_size", [2048, 3072, 4096]) -def test_generate_rsa(session, key_size, info): +def test_generate_rsa(session, keys, key_size, info): if key_size != 2048: if info.version[0] < 4: pytest.skip(f"RSA {key_size} requires YuibKey 4 or later") elif info.version[0] == 4 and info.is_fips: pytest.skip(f"RSA {key_size} not supported on YubiKey 4 FIPS") - session.verify_admin(DEFAULT_ADMIN_PIN) + session.verify_admin(keys.admin) pub = session.generate_rsa_key(KEY_REF.SIG, RSA_SIZE(key_size)) if info.version[0] < 5: # Keys don't work without a generation time (or fingerprint) @@ -158,51 +203,55 @@ def test_generate_rsa(session, key_size, info): assert pub.key_size == key_size message = b"Hello world" - session.verify_pin(DEFAULT_PIN) + session.verify_pin(keys.pin) sig = session.sign(message, hashes.SHA256()) pub.verify(sig, message, padding.PKCS1v15(), hashes.SHA256()) @condition.min_version(5, 2) @pytest.mark.parametrize("oid", [x for x in OID if "25519" not in x.name]) -def test_generate_ecdsa(session, oid): - session.verify_admin(DEFAULT_ADMIN_PIN) +def test_generate_ecdsa(session, info, keys, oid): + if fips_capable(info) and oid == OID.SECP256K1: + pytest.skip("FIPS capable") + + session.verify_admin(keys.admin) pub = session.generate_ec_key(KEY_REF.SIG, oid) message = b"Hello world" - session.verify_pin(DEFAULT_PIN) + session.verify_pin(keys.pin) sig = session.sign(message, hashes.SHA256()) pub.verify(sig, message, ec.ECDSA(hashes.SHA256())) @condition.min_version(5, 2) -def test_generate_ed25519(session): - session.verify_admin(DEFAULT_ADMIN_PIN) +def test_generate_ed25519(session, keys): + session.verify_admin(keys.admin) pub = session.generate_ec_key(KEY_REF.SIG, OID.Ed25519) message = b"Hello world" - session.verify_pin(DEFAULT_PIN) + session.verify_pin(keys.pin) sig = session.sign(message, hashes.SHA256()) pub.verify(sig, message) @condition.min_version(5, 2) -def test_generate_x25519(session): - session.verify_admin(DEFAULT_ADMIN_PIN) +@condition.check(not_fips_capable) +def test_generate_x25519(session, keys): + session.verify_admin(keys.admin) pub = session.generate_ec_key(KEY_REF.DEC, OID.X25519) e_priv = x25519.X25519PrivateKey.generate() shared1 = e_priv.exchange(pub) - session.verify_pin(DEFAULT_PIN, extended=True) + session.verify_pin(keys.pin, extended=True) shared2 = session.decrypt(e_priv.public_key()) assert shared1 == shared2 @condition.min_version(5, 2) -def test_kdf(session): +def test_kdf(session, keys): with pytest.raises(ApduError): session.set_kdf(KdfIterSaltedS2k.create()) - session.change_admin(DEFAULT_ADMIN_PIN, NON_DEFAULT_ADMIN_PIN) + session.change_admin(keys.admin, NON_DEFAULT_ADMIN_PIN) session.verify_admin(NON_DEFAULT_ADMIN_PIN) session.set_kdf(KdfIterSaltedS2k.create()) session.verify_admin(DEFAULT_ADMIN_PIN) @@ -218,14 +267,14 @@ def test_kdf(session): @condition.min_version(5, 2) -def test_attestation(session): +def test_attestation(session, keys): if not session.get_key_information()[KEY_REF.ATT]: pytest.skip("No attestation key") - session.verify_admin(DEFAULT_ADMIN_PIN) + session.verify_admin(keys.admin) pub = session.generate_ec_key(KEY_REF.SIG, OID.SECP256R1) - session.verify_pin(DEFAULT_PIN) + session.verify_pin(keys.pin) cert = session.attest_key(KEY_REF.SIG) assert cert.public_key() == pub diff --git a/tests/device/test_otp.py b/tests/device/test_otp.py index 0b6fe5ab..467bc1c3 100644 --- a/tests/device/test_otp.py +++ b/tests/device/test_otp.py @@ -25,6 +25,11 @@ def conn_type(request, version, transport): return conn_type +def no_pin_complexity(info): + """PIN complexity enabled""" + return not info.pin_complexity + + @pytest.fixture() @condition.capability(CAPABILITY.OTP) def session(conn_type, info, device): @@ -75,6 +80,7 @@ def call(): class TestProgrammingState: @pytest.fixture(autouse=True) @condition.min_version(2, 1) + @condition.check(no_pin_complexity) def clear_slots(self, session, read_config): state = read_config() for slot in (SLOT.ONE, SLOT.TWO): @@ -137,6 +143,7 @@ def test_slot_touch_triggered(self, session, read_config, slot): class TestChallengeResponse: @pytest.fixture(autouse=True) @condition.check(not_usb_ccid) + @condition.check(no_pin_complexity) def clear_slot2(self, session, read_config): state = read_config() if state.is_configured(SLOT.TWO): diff --git a/tests/device/test_piv.py b/tests/device/test_piv.py index d9138c4b..c6944dc5 100644 --- a/tests/device/test_piv.py +++ b/tests/device/test_piv.py @@ -21,7 +21,7 @@ OBJECT_ID, MANAGEMENT_KEY_TYPE, InvalidPinError, - check_key_support, + _do_check_key_support, ) from ykman.piv import ( check_key, @@ -34,12 +34,13 @@ from ykman.util import parse_certificates, parse_private_key from ..util import open_file from . import condition +from typing import NamedTuple DEFAULT_PIN = "123456" -NON_DEFAULT_PIN = "654321" +NON_DEFAULT_PIN = "12341235" DEFAULT_PUK = "12345678" -NON_DEFAULT_PUK = "87654321" +NON_DEFAULT_PUK = "12341236" DEFAULT_MANAGEMENT_KEY = bytes.fromhex( "010203040506070801020304050607080102030405060708" ) @@ -72,11 +73,34 @@ def session(ccid_connection): reset_state(piv) -def mgm_key_type(session): - try: - return session.get_management_key_metadata().key_type - except NotSupportedError: - return MANAGEMENT_KEY_TYPE.TDES +class Keys(NamedTuple): + pin: str + puk: str + mgmt: bytes + + +@pytest.fixture +def default_keys(): + yield Keys(DEFAULT_PIN, DEFAULT_PUK, DEFAULT_MANAGEMENT_KEY) + + +@pytest.fixture +def keys(session, info, default_keys): + if info.pin_complexity: + new_keys = Keys( + "12345679" if CAPABILITY.PIV in info.fips_capable else "123458", + "12345670", + bytes.fromhex("010203040506070801020304050607080102030405060709"), + ) + session.change_pin(default_keys.pin, new_keys.pin) + session.change_puk(default_keys.puk, new_keys.puk) + session.authenticate(default_keys.mgmt) + session.set_management_key(session.management_key_type, new_keys.mgmt) + reset_state(session) + + yield new_keys + else: + yield default_keys def not_roca(version): @@ -90,21 +114,22 @@ def reset_state(session): def assert_mgm_key_is(session, key): - session.authenticate(mgm_key_type(session), key) + session.authenticate(key) def assert_mgm_key_is_not(session, key): with pytest.raises(ApduError): - session.authenticate(mgm_key_type(session), key) + session.authenticate(key) def generate_key( session, + keys, slot=SLOT.AUTHENTICATION, key_type=KEY_TYPE.ECCP256, pin_policy=PIN_POLICY.DEFAULT, ): - session.authenticate(mgm_key_type(session), DEFAULT_MANAGEMENT_KEY) + session.authenticate(keys.mgmt) key = session.generate_key(slot, key_type, pin_policy=pin_policy) reset_state(session) return key @@ -125,12 +150,13 @@ def generate_sw_key(key_type): def import_key( session, + keys, slot=SLOT.AUTHENTICATION, key_type=KEY_TYPE.ECCP256, pin_policy=PIN_POLICY.DEFAULT, ): private_key = generate_sw_key(key_type) - session.authenticate(mgm_key_type(session), DEFAULT_MANAGEMENT_KEY) + session.authenticate(keys.mgmt) session.put_key(slot, private_key, pin_policy) reset_state(session) return private_key.public_key() @@ -151,15 +177,14 @@ def verify_cert_signature(cert, public_key=None): public_key.verify(*args) -def skip_unsupported_key_type(key_type, info): - if key_type == KEY_TYPE.RSA1024 and info.is_fips and info.version[0] == 4: - pytest.skip("RSA1024 not available on YubiKey FIPS") +def skip_unsupported_key_type(key_type, info, pin_policy=PIN_POLICY.DEFAULT): try: - check_key_support( + _do_check_key_support( info.version, key_type, - PIN_POLICY.DEFAULT, + pin_policy, TOUCH_POLICY.DEFAULT, + fips_restrictions=CAPABILITY.PIV in info.fips_capable, ) except NotSupportedError as e: pytest.skip(f"{e}") @@ -171,14 +196,14 @@ class TestCertificateSignatures: "hash_algorithm", (hashes.SHA256, hashes.SHA384, hashes.SHA512) ) def test_generate_self_signed_certificate( - self, info, session, key_type, hash_algorithm + self, info, session, key_type, hash_algorithm, keys ): skip_unsupported_key_type(key_type, info) slot = SLOT.SIGNATURE - public_key = import_key(session, slot, key_type) - session.authenticate(mgm_key_type(session), DEFAULT_MANAGEMENT_KEY) - session.verify_pin(DEFAULT_PIN) + public_key = import_key(session, keys, slot, key_type) + session.authenticate(keys.mgmt) + session.verify_pin(keys.pin) cert = generate_self_signed_certificate( session, slot, public_key, "CN=alice", NOW, NOW, hash_algorithm ) @@ -195,66 +220,66 @@ class TestDecrypt: "key_type", [KEY_TYPE.RSA1024, KEY_TYPE.RSA2048, KEY_TYPE.RSA3072, KEY_TYPE.RSA4096], ) - def test_import_decrypt(self, session, info, key_type): + def test_import_decrypt(self, session, info, key_type, keys): skip_unsupported_key_type(key_type, info) - public_key = import_key(session, SLOT.KEY_MANAGEMENT, key_type=key_type) + public_key = import_key(session, keys, SLOT.KEY_MANAGEMENT, key_type=key_type) pt = os.urandom(32) ct = public_key.encrypt(pt, padding.PKCS1v15()) - session.verify_pin(DEFAULT_PIN) + session.verify_pin(keys.pin) pt2 = session.decrypt(SLOT.KEY_MANAGEMENT, ct, padding.PKCS1v15()) assert pt == pt2 class TestKeyAgreement: @pytest.mark.parametrize("key_type", ECDH_KEY_TYPES) - def test_generate_ecdh(self, session, info, key_type): + def test_generate_ecdh(self, session, info, key_type, keys): skip_unsupported_key_type(key_type, info) e_priv = generate_sw_key(key_type) - public_key = generate_key(session, SLOT.KEY_MANAGEMENT, key_type=key_type) + public_key = generate_key(session, keys, SLOT.KEY_MANAGEMENT, key_type=key_type) if key_type == KEY_TYPE.X25519: args = (public_key,) else: args = (ec.ECDH(), public_key) shared1 = e_priv.exchange(*args) - session.verify_pin(DEFAULT_PIN) + session.verify_pin(keys.pin) shared2 = session.calculate_secret(SLOT.KEY_MANAGEMENT, e_priv.public_key()) assert shared1 == shared2 @pytest.mark.parametrize("key_type", ECDH_KEY_TYPES) - def test_import_ecdh(self, session, info, key_type): + def test_import_ecdh(self, session, info, key_type, keys): skip_unsupported_key_type(key_type, info) e_priv = generate_sw_key(key_type) - public_key = import_key(session, SLOT.KEY_MANAGEMENT, key_type=key_type) + public_key = import_key(session, keys, SLOT.KEY_MANAGEMENT, key_type=key_type) if key_type == KEY_TYPE.X25519: args = (public_key,) else: args = (ec.ECDH(), public_key) shared1 = e_priv.exchange(*args) - session.verify_pin(DEFAULT_PIN) + session.verify_pin(keys.pin) shared2 = session.calculate_secret(SLOT.KEY_MANAGEMENT, e_priv.public_key()) assert shared1 == shared2 class TestKeyManagement: - def test_delete_certificate_requires_authentication(self, session): - generate_key(session, SLOT.AUTHENTICATION) + def test_delete_certificate_requires_authentication(self, session, keys): + generate_key(session, keys, SLOT.AUTHENTICATION) with pytest.raises(ApduError): session.delete_certificate(SLOT.AUTHENTICATION) - session.authenticate(mgm_key_type(session), DEFAULT_MANAGEMENT_KEY) + session.authenticate(keys.mgmt) session.delete_certificate(SLOT.AUTHENTICATION) - def test_generate_csr_works(self, session): - public_key = generate_key(session, SLOT.AUTHENTICATION) + def test_generate_csr_works(self, session, keys): + public_key = generate_key(session, keys, SLOT.AUTHENTICATION) - session.verify_pin(DEFAULT_PIN) + session.verify_pin(keys.pin) csr = generate_csr(session, SLOT.AUTHENTICATION, public_key, "CN=alice") assert csr.public_key().public_numbers() == public_key.public_numbers() @@ -263,25 +288,25 @@ def test_generate_csr_works(self, session): == "alice" ) - def test_generate_self_signed_certificate_requires_pin(self, session): - session.verify_pin(DEFAULT_PIN) - public_key = generate_key(session, SLOT.AUTHENTICATION) + def test_generate_self_signed_certificate_requires_pin(self, session, keys): + session.verify_pin(keys.pin) + public_key = generate_key(session, keys, SLOT.AUTHENTICATION) with pytest.raises(ApduError): generate_self_signed_certificate( session, SLOT.AUTHENTICATION, public_key, "CN=alice", NOW, NOW ) - session.verify_pin(DEFAULT_PIN) + session.verify_pin(keys.pin) generate_self_signed_certificate( session, SLOT.AUTHENTICATION, public_key, "CN=alice", NOW, NOW ) @pytest.mark.parametrize("slot", (SLOT.SIGNATURE, SLOT.AUTHENTICATION)) - def test_generate_self_signed_certificate(self, session, slot): - public_key = generate_key(session, slot) - session.authenticate(mgm_key_type(session), DEFAULT_MANAGEMENT_KEY) - session.verify_pin(DEFAULT_PIN) + def test_generate_self_signed_certificate(self, session, slot, keys): + public_key = generate_key(session, keys, slot) + session.authenticate(keys.mgmt) + session.verify_pin(keys.pin) cert = generate_self_signed_certificate( session, slot, public_key, "CN=alice", NOW, NOW ) @@ -292,28 +317,28 @@ def test_generate_self_signed_certificate(self, session, slot): == "alice" ) - def test_generate_key_requires_authentication(self, session): + def test_generate_key_requires_authentication(self, session, keys): with pytest.raises(ApduError): session.generate_key( SLOT.AUTHENTICATION, KEY_TYPE.ECCP256, touch_policy=TOUCH_POLICY.DEFAULT ) - session.authenticate(mgm_key_type(session), DEFAULT_MANAGEMENT_KEY) + session.authenticate(keys.mgmt) session.generate_key(SLOT.AUTHENTICATION, KEY_TYPE.ECCP256) - def test_put_certificate_requires_authentication(self, session): + def test_put_certificate_requires_authentication(self, session, keys): cert = get_test_cert() with pytest.raises(ApduError): session.put_certificate(SLOT.AUTHENTICATION, cert) - session.authenticate(mgm_key_type(session), DEFAULT_MANAGEMENT_KEY) + session.authenticate(keys.mgmt) session.put_certificate(SLOT.AUTHENTICATION, cert) - def _test_put_key_pairing(self, session, alg1, alg2): + def _test_put_key_pairing(self, session, keys, alg1, alg2): # Set up a key in the slot and create a certificate for it - public_key = generate_key(session, SLOT.AUTHENTICATION, key_type=alg1) - session.authenticate(mgm_key_type(session), DEFAULT_MANAGEMENT_KEY) - session.verify_pin(DEFAULT_PIN) + public_key = generate_key(session, keys, SLOT.AUTHENTICATION, key_type=alg1) + session.authenticate(keys.mgmt) + session.verify_pin(keys.pin) cert = generate_self_signed_certificate( session, SLOT.AUTHENTICATION, public_key, "CN=test", NOW, NOW ) @@ -326,47 +351,49 @@ def _test_put_key_pairing(self, session, alg1, alg2): session.delete_certificate(SLOT.AUTHENTICATION) # Overwrite the key with one of the same type - generate_key(session, SLOT.AUTHENTICATION, key_type=alg1) - session.verify_pin(DEFAULT_PIN) + generate_key(session, keys, SLOT.AUTHENTICATION, key_type=alg1) + session.verify_pin(keys.pin) assert not check_key(session, SLOT.AUTHENTICATION, cert.public_key()) # Overwrite the key with one of a different type - generate_key(session, SLOT.AUTHENTICATION, key_type=alg2) - session.verify_pin(DEFAULT_PIN) + generate_key(session, keys, SLOT.AUTHENTICATION, key_type=alg2) + session.verify_pin(keys.pin) assert not check_key(session, SLOT.AUTHENTICATION, cert.public_key()) @condition.check(not_roca) @condition.yk4_fips(False) - def test_put_certificate_verifies_key_pairing_rsa1024(self, session): - self._test_put_key_pairing(session, KEY_TYPE.RSA1024, KEY_TYPE.ECCP256) + def test_put_certificate_verifies_key_pairing_rsa1024(self, session, keys, info): + if CAPABILITY.PIV in info.fips_capable: + pytest.skip("RSA1024 not available on YubiKey FIPS") + self._test_put_key_pairing(session, keys, KEY_TYPE.RSA1024, KEY_TYPE.ECCP256) @condition.check(not_roca) - def test_put_certificate_verifies_key_pairing_rsa2048(self, session): - self._test_put_key_pairing(session, KEY_TYPE.RSA2048, KEY_TYPE.ECCP256) + def test_put_certificate_verifies_key_pairing_rsa2048(self, session, keys): + self._test_put_key_pairing(session, keys, KEY_TYPE.RSA2048, KEY_TYPE.ECCP256) @condition.check(not_roca) - def test_put_certificate_verifies_key_pairing_eccp256_a(self, session): - self._test_put_key_pairing(session, KEY_TYPE.ECCP256, KEY_TYPE.RSA2048) + def test_put_certificate_verifies_key_pairing_eccp256_a(self, session, keys): + self._test_put_key_pairing(session, keys, KEY_TYPE.ECCP256, KEY_TYPE.RSA2048) @condition.min_version(4) - def test_put_certificate_verifies_key_pairing_eccp256_b(self, session): - self._test_put_key_pairing(session, KEY_TYPE.ECCP256, KEY_TYPE.ECCP384) + def test_put_certificate_verifies_key_pairing_eccp256_b(self, session, keys): + self._test_put_key_pairing(session, keys, KEY_TYPE.ECCP256, KEY_TYPE.ECCP384) @condition.min_version(4) - def test_put_certificate_verifies_key_pairing_eccp384(self, session): - self._test_put_key_pairing(session, KEY_TYPE.ECCP384, KEY_TYPE.ECCP256) + def test_put_certificate_verifies_key_pairing_eccp384(self, session, keys): + self._test_put_key_pairing(session, keys, KEY_TYPE.ECCP384, KEY_TYPE.ECCP256) - def test_put_key_requires_authentication(self, session): + def test_put_key_requires_authentication(self, session, keys): private_key = get_test_key() with pytest.raises(ApduError): session.put_key(SLOT.AUTHENTICATION, private_key) - session.authenticate(mgm_key_type(session), DEFAULT_MANAGEMENT_KEY) + session.authenticate(keys.mgmt) session.put_key(SLOT.AUTHENTICATION, private_key) - def test_get_certificate_does_not_require_authentication(self, session): + def test_get_certificate_does_not_require_authentication(self, session, keys): cert = get_test_cert() - session.authenticate(mgm_key_type(session), DEFAULT_MANAGEMENT_KEY) + session.authenticate(keys.mgmt) session.put_certificate(SLOT.AUTHENTICATION, cert) reset_state(session) @@ -374,8 +401,8 @@ def test_get_certificate_does_not_require_authentication(self, session): class TestCompressedCertificate: - def test_put_and_read_compressed_certificate(self, session): - session.authenticate(mgm_key_type(session), DEFAULT_MANAGEMENT_KEY) + def test_put_and_read_compressed_certificate(self, session, keys): + session.authenticate(keys.mgmt) cert = get_test_cert() session.put_certificate(SLOT.AUTHENTICATION, cert) session.put_certificate(SLOT.SIGNATURE, cert, compress=True) @@ -395,20 +422,20 @@ class TestManagementKeyReadOnly: calls needed. """ - def test_authenticate_twice_does_not_throw(self, session): - session.authenticate(mgm_key_type(session), DEFAULT_MANAGEMENT_KEY) - session.authenticate(mgm_key_type(session), DEFAULT_MANAGEMENT_KEY) + def test_authenticate_twice_does_not_throw(self, session, keys): + session.authenticate(keys.mgmt) + session.authenticate(keys.mgmt) - def test_reset_resets_has_stored_key_flag(self, session): + def test_reset_resets_has_stored_key_flag(self, session, keys): pivman = get_pivman_data(session) assert not pivman.has_stored_key - session.verify_pin(DEFAULT_PIN) - session.authenticate(mgm_key_type(session), DEFAULT_MANAGEMENT_KEY) + session.verify_pin(keys.pin) + session.authenticate(keys.mgmt) pivman_set_mgm_key( session, NON_DEFAULT_MANAGEMENT_KEY, - mgm_key_type(session), + session.management_key_type, store_on_device=True, ) @@ -422,31 +449,33 @@ def test_reset_resets_has_stored_key_flag(self, session): assert not pivman.has_stored_key # Should this really fail? - def disabled_test_reset_while_verified_throws_nice_ValueError(self, session): - session.verify_pin(DEFAULT_PIN) + def disabled_test_reset_while_verified_throws_nice_ValueError(self, session, keys): + session.verify_pin(keys.pin) with pytest.raises(ValueError) as cm: session.reset() assert "Cannot read remaining tries from status word: 9000" in str(cm.exception) - def test_set_mgm_key_does_not_change_key_if_not_authenticated(self, session): + def test_set_mgm_key_does_not_change_key_if_not_authenticated(self, session, keys): with pytest.raises(ApduError): session.set_management_key( - mgm_key_type(session), NON_DEFAULT_MANAGEMENT_KEY + session.management_key_type, NON_DEFAULT_MANAGEMENT_KEY ) - assert_mgm_key_is(session, DEFAULT_MANAGEMENT_KEY) + assert_mgm_key_is(session, keys.mgmt) @condition.min_version(3, 5) - def test_set_stored_mgm_key_does_not_destroy_key_if_pin_not_verified(self, session): - session.authenticate(mgm_key_type(session), DEFAULT_MANAGEMENT_KEY) + def test_set_stored_mgm_key_does_not_destroy_key_if_pin_not_verified( + self, session, keys + ): + session.authenticate(keys.mgmt) with pytest.raises(ApduError): pivman_set_mgm_key( session, NON_DEFAULT_MANAGEMENT_KEY, - mgm_key_type(session), + session.management_key_type, store_on_device=True, ) - assert_mgm_key_is(session, DEFAULT_MANAGEMENT_KEY) + assert_mgm_key_is(session, keys.mgmt) class TestManagementKeyReadWrite: @@ -455,24 +484,26 @@ class TestManagementKeyReadWrite: key. """ - def test_set_mgm_key_changes_mgm_key(self, session): - session.authenticate(mgm_key_type(session), DEFAULT_MANAGEMENT_KEY) - session.set_management_key(mgm_key_type(session), NON_DEFAULT_MANAGEMENT_KEY) + def test_set_mgm_key_changes_mgm_key(self, session, keys): + session.authenticate(keys.mgmt) + session.set_management_key( + session.management_key_type, NON_DEFAULT_MANAGEMENT_KEY + ) - assert_mgm_key_is_not(session, DEFAULT_MANAGEMENT_KEY) + assert_mgm_key_is_not(session, keys.mgmt) assert_mgm_key_is(session, NON_DEFAULT_MANAGEMENT_KEY) - def test_set_stored_mgm_key_succeeds_if_pin_is_verified(self, session): - session.verify_pin(DEFAULT_PIN) - session.authenticate(mgm_key_type(session), DEFAULT_MANAGEMENT_KEY) + def test_set_stored_mgm_key_succeeds_if_pin_is_verified(self, session, keys): + session.verify_pin(keys.pin) + session.authenticate(keys.mgmt) pivman_set_mgm_key( session, NON_DEFAULT_MANAGEMENT_KEY, - mgm_key_type(session), + session.management_key_type, store_on_device=True, ) - assert_mgm_key_is_not(session, DEFAULT_MANAGEMENT_KEY) + assert_mgm_key_is_not(session, keys.mgmt) assert_mgm_key_is(session, NON_DEFAULT_MANAGEMENT_KEY) pivman_prot = get_pivman_protected_data(session) @@ -488,43 +519,46 @@ def sign(session, slot, key_type, message): class TestOperations: @condition.min_version(4) - def test_sign_with_pin_policy_always_requires_pin_every_time(self, session): - generate_key(session, pin_policy=PIN_POLICY.ALWAYS) + def test_sign_with_pin_policy_always_requires_pin_every_time(self, session, keys): + generate_key(session, keys, pin_policy=PIN_POLICY.ALWAYS) with pytest.raises(ApduError): sign(session, SLOT.AUTHENTICATION, KEY_TYPE.ECCP256, b"foo") - session.verify_pin(DEFAULT_PIN) + session.verify_pin(keys.pin) sig = sign(session, SLOT.AUTHENTICATION, KEY_TYPE.ECCP256, b"foo") assert sig with pytest.raises(ApduError): sign(session, SLOT.AUTHENTICATION, KEY_TYPE.ECCP256, b"foo") - session.verify_pin(DEFAULT_PIN) + session.verify_pin(keys.pin) sig = sign(session, SLOT.AUTHENTICATION, KEY_TYPE.ECCP256, b"foo") assert sig @condition.yk4_fips(False) + @condition.check(lambda info: CAPABILITY.PIV not in info.fips_capable) @condition.min_version(4) - def test_sign_with_pin_policy_never_does_not_require_pin(self, session): - generate_key(session, pin_policy=PIN_POLICY.NEVER) + def test_sign_with_pin_policy_never_does_not_require_pin(self, session, keys): + generate_key(session, keys, pin_policy=PIN_POLICY.NEVER) sig = sign(session, SLOT.AUTHENTICATION, KEY_TYPE.ECCP256, b"foo") assert sig @condition.yk4_fips(True) - def test_pin_policy_never_blocked_on_fips(self, session): + def test_pin_policy_never_blocked_on_fips(self, session, keys): with pytest.raises(NotSupportedError): - generate_key(session, pin_policy=PIN_POLICY.NEVER) + generate_key(session, keys, pin_policy=PIN_POLICY.NEVER) @condition.min_version(4) - def test_sign_with_pin_policy_once_requires_pin_once_per_session(self, session): - generate_key(session, pin_policy=PIN_POLICY.ONCE) + def test_sign_with_pin_policy_once_requires_pin_once_per_session( + self, session, keys + ): + generate_key(session, keys, pin_policy=PIN_POLICY.ONCE) with pytest.raises(ApduError): sign(session, SLOT.AUTHENTICATION, KEY_TYPE.ECCP256, b"foo") - session.verify_pin(DEFAULT_PIN) + session.verify_pin(keys.pin) sig = sign(session, SLOT.AUTHENTICATION, KEY_TYPE.ECCP256, b"foo") assert sig @@ -536,19 +570,19 @@ def test_sign_with_pin_policy_once_requires_pin_once_per_session(self, session): with pytest.raises(ApduError): sign(session, SLOT.AUTHENTICATION, KEY_TYPE.ECCP256, b"foo") - session.verify_pin(DEFAULT_PIN) + session.verify_pin(keys.pin) sig = sign(session, SLOT.AUTHENTICATION, KEY_TYPE.ECCP256, b"foo") assert sig sig = sign(session, SLOT.AUTHENTICATION, KEY_TYPE.ECCP256, b"foo") assert sig - def test_signature_can_be_verified_by_public_key(self, session): - public_key = generate_key(session) + def test_signature_can_be_verified_by_public_key(self, session, keys): + public_key = generate_key(session, keys) signed_data = bytes(random.randint(0, 255) for i in range(32)) - session.verify_pin(DEFAULT_PIN) + session.verify_pin(keys.pin) sig = sign(session, SLOT.AUTHENTICATION, KEY_TYPE.ECCP256, signed_data) assert sig @@ -564,62 +598,64 @@ def block_pin(session): class TestUnblockPin: - def test_unblock_pin_requires_no_previous_authentication(self, session): - session.unblock_pin(DEFAULT_PUK, NON_DEFAULT_PIN) + def test_unblock_pin_requires_no_previous_authentication(self, session, keys): + session.unblock_pin(keys.puk, NON_DEFAULT_PIN) def test_unblock_pin_with_wrong_puk_throws_InvalidPinError(self, session): with pytest.raises(InvalidPinError): session.unblock_pin(NON_DEFAULT_PUK, NON_DEFAULT_PIN) - def test_unblock_pin_resets_pin_and_retries(self, session): - session.reset() - reset_state(session) - + def test_unblock_pin_resets_pin_and_retries(self, session, keys): block_pin(session) with pytest.raises(InvalidPinError): - session.verify_pin(DEFAULT_PIN) + session.verify_pin(keys.pin) - session.unblock_pin(DEFAULT_PUK, NON_DEFAULT_PIN) + session.unblock_pin(keys.puk, NON_DEFAULT_PIN) assert session.get_pin_attempts() == 3 session.verify_pin(NON_DEFAULT_PIN) - def test_set_pin_retries_requires_pin_and_mgm_key(self, session, version): + def test_set_pin_retries_requires_pin_and_mgm_key( + self, session, version, default_keys + ): + keys = default_keys + # Fails with no authentication with pytest.raises(ApduError): session.set_pin_attempts(4, 4) # Fails with only PIN - session.verify_pin(DEFAULT_PIN) + session.verify_pin(keys.pin) with pytest.raises(ApduError): session.set_pin_attempts(4, 4) reset_state(session) - session.authenticate(mgm_key_type(session), DEFAULT_MANAGEMENT_KEY) + session.authenticate(keys.mgmt) # Fails with only management key (requirement added in 0.1.3) if version >= (0, 1, 3): with pytest.raises(ApduError): session.set_pin_attempts(4, 4) # Succeeds with both PIN and management key - session.verify_pin(DEFAULT_PIN) + session.verify_pin(keys.pin) session.set_pin_attempts(4, 4) - def test_set_pin_retries_sets_pin_and_puk_tries(self, session): + def test_set_pin_retries_sets_pin_and_puk_tries(self, session, default_keys): + keys = default_keys pin_tries = 9 puk_tries = 7 - session.verify_pin(DEFAULT_PIN) - session.authenticate(mgm_key_type(session), DEFAULT_MANAGEMENT_KEY) + session.verify_pin(keys.pin) + session.authenticate(keys.mgmt) session.set_pin_attempts(pin_tries, puk_tries) reset_state(session) assert session.get_pin_attempts() == pin_tries with pytest.raises(InvalidPinError) as ctx: - session.change_puk(NON_DEFAULT_PUK, DEFAULT_PUK) + session.change_puk(NON_DEFAULT_PUK, keys.puk) assert ctx.value.attempts_remaining == puk_tries - 1 @@ -635,17 +671,17 @@ def test_pin_metadata(self, session): assert data.total_attempts == 3 assert data.attempts_remaining == 3 - def test_management_key_metadata(self, session, version): + def test_management_key_metadata(self, session, info): data = session.get_management_key_metadata() default_type = data.key_type - if version < (5, 7, 0): + if info.version < (5, 7, 0): assert data.key_type == MANAGEMENT_KEY_TYPE.TDES else: assert data.key_type == MANAGEMENT_KEY_TYPE.AES192 assert data.default_value is True assert data.touch_policy is TOUCH_POLICY.NEVER - session.authenticate(mgm_key_type(session), DEFAULT_MANAGEMENT_KEY) + session.authenticate(DEFAULT_MANAGEMENT_KEY) session.set_management_key( MANAGEMENT_KEY_TYPE.AES192, NON_DEFAULT_MANAGEMENT_KEY ) @@ -658,16 +694,19 @@ def test_management_key_metadata(self, session, version): data = session.get_management_key_metadata() assert data.default_value is True - session.set_management_key(MANAGEMENT_KEY_TYPE.TDES, NON_DEFAULT_MANAGEMENT_KEY) - data = session.get_management_key_metadata() - assert data.default_value is False + if CAPABILITY.PIV not in info.fips_capable: + session.set_management_key( + MANAGEMENT_KEY_TYPE.TDES, NON_DEFAULT_MANAGEMENT_KEY + ) + data = session.get_management_key_metadata() + assert data.default_value is False @pytest.mark.parametrize("key_type", list(KEY_TYPE)) - def test_slot_metadata_generate(self, session, info, key_type): + def test_slot_metadata_generate(self, session, info, keys, key_type): skip_unsupported_key_type(key_type, info) slot = SLOT.SIGNATURE - key = generate_key(session, slot, key_type) + key = generate_key(session, keys, slot, key_type) data = session.get_slot_metadata(slot) assert data.key_type == key_type @@ -700,8 +739,10 @@ def test_slot_metadata_generate(self, session, info, key_type): (SLOT.CARD_AUTH, PIN_POLICY.NEVER), ], ) - def test_slot_metadata_put(self, session, key, slot, pin_policy): - session.authenticate(mgm_key_type(session), DEFAULT_MANAGEMENT_KEY) + def test_slot_metadata_put(self, session, info, keys, key, slot, pin_policy): + key_type = KEY_TYPE.from_public_key(key.public_key()) + skip_unsupported_key_type(key_type, info, pin_policy) + session.authenticate(keys.mgmt) session.put_key(slot, key) data = session.get_slot_metadata(slot) @@ -724,9 +765,9 @@ class TestMoveAndDelete: def preconditions(self): pass - def test_move_key(self, session): + def test_move_key(self, session, keys): key = ec.generate_private_key(ec.SECP256R1(), default_backend()) - session.authenticate(mgm_key_type(session), DEFAULT_MANAGEMENT_KEY) + session.authenticate(keys.mgmt) session.put_key(SLOT.AUTHENTICATION, key) data_a = session.get_slot_metadata(SLOT.AUTHENTICATION) @@ -737,9 +778,9 @@ def test_move_key(self, session): with pytest.raises(ApduError): session.get_slot_metadata(SLOT.AUTHENTICATION) - def test_delete_key(self, session): + def test_delete_key(self, session, keys): key = ec.generate_private_key(ec.SECP256R1(), default_backend()) - session.authenticate(mgm_key_type(session), DEFAULT_MANAGEMENT_KEY) + session.authenticate(keys.mgmt) session.put_key(SLOT.AUTHENTICATION, key) session.get_slot_metadata(SLOT.AUTHENTICATION) diff --git a/tests/test_piv.py b/tests/test_piv.py index 283ba416..461612b8 100644 --- a/tests/test_piv.py +++ b/tests/test_piv.py @@ -6,7 +6,7 @@ MANAGEMENT_KEY_TYPE, PIN_POLICY, TOUCH_POLICY, - check_key_support, + _do_check_key_support, ) import pytest @@ -46,7 +46,7 @@ def test_generate_random_management_key(self): def test_supported_algorithms(self): with pytest.raises(NotSupportedError): - check_key_support( + _do_check_key_support( Version(3, 1, 1), KEY_TYPE.ECCP384, PIN_POLICY.DEFAULT, @@ -54,26 +54,45 @@ def test_supported_algorithms(self): ) with pytest.raises(NotSupportedError): - check_key_support( + _do_check_key_support( Version(4, 4, 1), KEY_TYPE.RSA1024, PIN_POLICY.DEFAULT, TOUCH_POLICY.DEFAULT, ) + for key_type in (KEY_TYPE.RSA1024, KEY_TYPE.X25519): + with pytest.raises(NotSupportedError): + _do_check_key_support( + Version(5, 7, 0), + key_type, + PIN_POLICY.DEFAULT, + TOUCH_POLICY.DEFAULT, + fips_restrictions=True, + ) + + with pytest.raises(NotSupportedError): + _do_check_key_support( + Version(5, 7, 0), + KEY_TYPE.RSA2048, + PIN_POLICY.NEVER, + TOUCH_POLICY.DEFAULT, + fips_restrictions=True, + ) + for key_type in (KEY_TYPE.RSA1024, KEY_TYPE.RSA2048): with pytest.raises(NotSupportedError): - check_key_support( + _do_check_key_support( Version(4, 3, 4), key_type, PIN_POLICY.DEFAULT, TOUCH_POLICY.DEFAULT ) for key_type in (KEY_TYPE.ED25519, KEY_TYPE.X25519): with pytest.raises(NotSupportedError): - check_key_support( + _do_check_key_support( Version(5, 6, 0), key_type, PIN_POLICY.DEFAULT, TOUCH_POLICY.DEFAULT ) for key_type in KEY_TYPE: - check_key_support( + _do_check_key_support( Version(5, 7, 0), key_type, PIN_POLICY.DEFAULT, TOUCH_POLICY.DEFAULT ) diff --git a/ykman/_cli/__main__.py b/ykman/_cli/__main__.py index d89f40db..c19776fd 100644 --- a/ykman/_cli/__main__.py +++ b/ykman/_cli/__main__.py @@ -29,17 +29,38 @@ from yubikit.core.otp import OtpConnection from yubikit.core.fido import FidoConnection from yubikit.core.smartcard import SmartCardConnection +from yubikit.core.smartcard.scp import ( + Scp03KeyParams, + StaticKeys, + ScpKid, + KeyRef, +) from yubikit.support import get_name, read_info from yubikit.logging import LOG_LEVEL from .. import __version__ from ..pcsc import list_devices as list_ccid, list_readers from ..device import scan_devices, list_all_devices as _list_all_devices -from ..util import get_windows_version +from ..util import ( + get_windows_version, + parse_private_key, + parse_certificates, + InvalidPasswordError, +) from ..logging import init_logging from ..diagnostics import get_diagnostics, sys_info from ..settings import AppData -from .util import YkmanContextObject, click_group, EnumChoice, CliFail, pretty_print +from .util import ( + YkmanContextObject, + click_group, + EnumChoice, + HexIntParamType, + CliFail, + pretty_print, + click_prompt, + find_scp11_params, + organize_scp11_certificates, +) from .info import info from .otp import otp from .openpgp import openpgp @@ -51,12 +72,16 @@ from .apdu import apdu from .script import run_script from .hsmauth import hsmauth +from .securedomain import securedomain, click_parse_scp_ref, ScpKidParamType +from cryptography.exceptions import InvalidSignature +from dataclasses import replace import click import click.shell_completion import ctypes import time import sys +import re import logging @@ -217,6 +242,50 @@ def require_device(connection_types, serial=None): f'"{reader.name}"' for reader in list_readers() ], ) +@click.option( + "-t", + "--scp-ca", + type=click.File("rb"), + help="specify the CA to use to verify the SCP11 card key (CA-KLCC)", +) +@click.option( + "-c", + "--scp-sd", + metavar="KID KVN", + type=(ScpKidParamType(), HexIntParamType()), + default=(0, 0), + callback=click_parse_scp_ref, + hidden="--full-help" not in sys.argv, + help="specify which key the YubiKey is using to authenticate", +) +@click.option( + "-o", + "--scp-oce", + metavar="KID KVN", + type=HexIntParamType(), + nargs=2, + default=(0, 0), + hidden="--full-help" not in sys.argv, + help="specify which key the OCE is using to authenticate", +) +@click.option( + "-s", + "--scp", + "scp_cred", + metavar="CRED", + multiple=True, + help="specify private key and certificate chain for secure messaging, " + "can be used multiple times to provide key and certificates in multiple " + "files (private key, certificates in leaf-last order), OR SCP03 keys in hex " + "(K-ENC K-MAC [K-DEK])", +) +@click.option( + "-p", + "--scp-password", + "scp_cred_password", + metavar="PASSWORD", + help="specify a password required to access the --scp file, if needed", +) @click.option( "-l", "--log-level", @@ -255,7 +324,18 @@ def require_device(connection_types, serial=None): help="show --help output, including hidden commands", ) @click.pass_context -def cli(ctx, device, log_level, log_file, reader): +def cli( + ctx, + device, + scp_ca, + scp_sd, + scp_oce, + scp_cred, + scp_cred_password, + log_level, + log_file, + reader, +): """ Configure your YubiKey via the command line. @@ -280,6 +360,8 @@ def cli(ctx, device, log_level, log_file, reader): if reader and device: ctx.fail("--reader and --device options can't be combined.") + use_scp = bool(scp_sd or scp_cred or scp_ca) + subcmd = next(c for c in COMMANDS if c.name == ctx.invoked_subcommand) # Commands that don't directly act on a key if subcmd in (list_keys,): @@ -287,6 +369,8 @@ def cli(ctx, device, log_level, log_file, reader): ctx.fail("--device can't be used with this command.") if reader: ctx.fail("--reader can't be used with this command.") + if use_scp: + ctx.fail("SCP can't be used with this command.") return # Commands which need a YubiKey to act on @@ -294,16 +378,14 @@ def cli(ctx, device, log_level, log_file, reader): subcmd, "connections", [SmartCardConnection, FidoConnection, OtpConnection] ) if connections: + if connections == [FidoConnection] and WIN_CTAP_RESTRICTED: + # FIDO-only command on Windows without Admin won't work. + raise CliFail("FIDO access on Windows requires running as Administrator.") def resolve(): - if connections == [FidoConnection] and WIN_CTAP_RESTRICTED: - # FIDO-only command on Windows without Admin won't work. - raise CliFail( - "FIDO access on Windows requires running as Administrator." - ) - items = getattr(resolve, "items", None) if not items: + # We might be connecting over NFC, and thus may require SCP11 if reader is not None: items = require_reader(connections, reader) else: @@ -315,6 +397,120 @@ def resolve(): ctx.obj.add_resolver("pid", lambda: resolve()[0].pid) ctx.obj.add_resolver("info", lambda: resolve()[1]) + if use_scp: + if SmartCardConnection not in connections: + raise CliFail("SCP can only be used with CCID commands") + + scp_kid, scp_kvn = scp_sd + if scp_kid: + try: + scp_kid = ScpKid(scp_kid) + except ValueError: + raise CliFail(f"Invalid KID for card certificate: {scp_kid}") + + if scp_ca: + ca = scp_ca.read() + else: + ca = None + + re_hex_keys = re.compile(r"^[0-9a-fA-F]{32}$") + if all(re_hex_keys.match(k) for k in scp_cred) and 2 <= len(scp_cred) <= 3: + scp03_keys = StaticKeys(*(bytes.fromhex(k) for k in scp_cred)) + scp11_creds = None + else: + f = click.File("rb") + scp11_creds = [f.convert(fn, None, ctx).read() for fn in scp_cred] + scp03_keys = None + + if not scp_kid: + if scp03_keys: + scp_kid = ScpKid.SCP03 + elif not scp11_creds: + scp_kid = ScpKid.SCP11b + + if scp03_keys and scp_kid != ScpKid.SCP03: + raise CliFail("--scp with SCP03 keys can only be used with SCP03") + + if scp_kid == ScpKid.SCP03: + if scp_ca: + raise CliFail("--scp-ca can only be used with SCP11") + + def params_f(_): + return Scp03KeyParams( + ref=KeyRef(ScpKid.SCP03, scp_kvn), + keys=scp03_keys or StaticKeys.default(), + ) + + elif scp11_creds: + # SCP11 a/c + if scp_kid not in (ScpKid.SCP11a, ScpKid.SCP11c, None): + raise CliFail("--scp with file(s) can only be used with SCP11 a/c") + + first = scp11_creds.pop(0) + password = scp_cred_password.encode() if scp_cred_password else None + + while True: + try: + sk_oce_ecka = parse_private_key(first, password) + break + except InvalidPasswordError: + if scp_cred_password: + raise CliFail("Wrong password to decrypt private key") + logger.debug("Error parsing key", exc_info=True) + password = click_prompt( + "Enter password to decrypt SCP11 key", + default="", + hide_input=True, + show_default=False, + ).encode() + + if scp11_creds: + certificates = [] + for c in scp11_creds: + certificates.extend(parse_certificates(c, None)) + else: + certificates = parse_certificates(first, password) + # If the bundle contains the CA we strip it out + _, inter, leaf = organize_scp11_certificates(certificates) + # Send the KA-KLOC and OCE certificates + certificates = list(inter) + [leaf] + + def params_f(conn): + if not scp_kid: + # TODO: Find key based on CA + # Check for SCP11a key, then SCP11c + try: + params = find_scp11_params(conn, ScpKid.SCP11a, scp_kvn, ca) + except (ValueError, InvalidSignature) as e: + try: + params = find_scp11_params( + conn, ScpKid.SCP11c, scp_kvn, ca + ) + except (ValueError, InvalidSignature): + raise e + else: + params = find_scp11_params(conn, scp_kid, scp_kvn, ca) + return replace( + params, + oce_ref=KeyRef(*scp_oce), + sk_oce_ecka=sk_oce_ecka, + certificates=certificates, + ) + + else: + # SCP11b + if scp_kid not in (ScpKid.SCP11b, None): + raise CliFail(f"{scp_kid.name} requires --scp") + if any(scp_oce): + raise CliFail("SCP11b cannot be used with --scp-oce") + + def params_f(conn): + return find_scp11_params(conn, ScpKid.SCP11b, scp_kvn, ca) + + connections = [SmartCardConnection] + + ctx.obj.add_resolver("scp", lambda: params_f) + @cli.command("list") @click.option( @@ -384,6 +580,7 @@ def _describe_device(dev, dev_info, include_mode=True): apdu, run_script, hsmauth, + securedomain, ) diff --git a/ykman/_cli/apdu.py b/ykman/_cli/apdu.py index 83d3f206..b7ef0752 100644 --- a/ykman/_cli/apdu.py +++ b/ykman/_cli/apdu.py @@ -139,16 +139,27 @@ def apdu(ctx, no_pretty, app, apdu, send_apdu): ctx.fail("No commands provided.") dev = ctx.obj["device"] + scp_resolve = ctx.obj.get("scp") + with dev.open_connection(SmartCardConnection) as conn: protocol = SmartCardProtocol(conn) is_first = True + if scp_resolve: + params = scp_resolve(conn) + else: + params = None + if app: is_first = False click.echo("SELECT AID: " + _hex(app)) resp = protocol.select(app) _print_response(resp, SW.OK, no_pretty) + if params: + click.echo("INITIALIZE SCP") + protocol.init_scp(params) + if send_apdu: # Compatibility mode (full APDUs) for apdu in send_apdu: if not is_first: diff --git a/ykman/_cli/fido.py b/ykman/_cli/fido.py index 5e37ec3b..d1b66122 100755 --- a/ykman/_cli/fido.py +++ b/ykman/_cli/fido.py @@ -98,14 +98,18 @@ def info(ctx): """ Display general status of the FIDO2 application. """ - conn = ctx.obj["conn"] + info = ctx.obj["info"] ctap2 = ctx.obj.get("ctap2") - info: Dict = {} - lines: List = [info] - if is_yk4_fips(ctx.obj["info"]): - info["FIPS Approved Mode"] = "Yes" if is_in_fips_mode(conn) else "No" - elif ctap2: + data: Dict = {} + lines: List = [data] + + if CAPABILITY.FIDO2 in info.fips_capable: + data["FIPS approved"] = CAPABILITY.FIDO2 in info.fips_approved + elif is_yk4_fips(info): + data["FIPS approved"] = is_in_fips_mode(ctx.obj["conn"]) + + if ctap2: client_pin = ClientPin(ctap2) # N.B. All YubiKeys with CTAP2 support PIN. if ctap2.info.options["clientPin"]: if ctap2.info.force_pin_change: @@ -115,42 +119,41 @@ def info(ctx): ) pin_retries, power_cycle = client_pin.get_pin_retries() if pin_retries: - info["PIN"] = f"{pin_retries} attempt(s) remaining" + data["PIN"] = f"{pin_retries} attempt(s) remaining" if power_cycle: lines.append( "PIN is temporarily blocked. " "Remove and re-insert the YubiKey to unblock." ) else: - info["PIN"] = "blocked" + data["PIN"] = "Blocked" else: - info["PIN"] = "not set" - info["Minimum PIN length"] = ctap2.info.min_pin_length + data["PIN"] = "Not set" + data["Minimum PIN length"] = ctap2.info.min_pin_length bio_enroll = ctap2.info.options.get("bioEnroll") if bio_enroll: uv_retries = client_pin.get_uv_retries() if uv_retries: - info["Fingerprints"] = f"registered, {uv_retries} attempt(s) remaining" + data["Fingerprints"] = f"Registered, {uv_retries} attempt(s) remaining" else: - info["Fingerprints"] = "registered, blocked until PIN is verified" + data["Fingerprints"] = "Registered, blocked until PIN is verified" elif bio_enroll is False: - info["Fingerprints"] = "not registered" + data["Fingerprints"] = "Not registered" always_uv = ctap2.info.options.get("alwaysUv") if always_uv is not None: - info["Always Require UV"] = "on" if always_uv else "off" + data["Always Require UV"] = "On" if always_uv else "Off" remaining_creds = ctap2.info.remaining_disc_creds if remaining_creds is not None: - info["Credential storage remaining"] = remaining_creds + data["Credential storage remaining"] = remaining_creds ep = ctap2.info.options.get("ep") if ep is not None: - info["Enterprise Attestation"] = "enabled" if ep else "disabled" - + data["Enterprise Attestation"] = "Enabled" if ep else "Disabled" else: - info["PIN"] = "not supported" + data["PIN"] = "Not supported" click.echo("\n".join(pretty_print(lines))) @@ -891,6 +894,10 @@ def toggle_always_uv(ctx, pin): if not options or "alwaysUv" not in options: raise CliFail("Always Require UV is not supported on this YubiKey.") + info = ctx.obj["info"] + if CAPABILITY.FIDO2 in info.fips_capable: + raise CliFail("Always Require UV can not be disabled on this YubiKey.") + config = _init_config(ctx, pin) config.toggle_always_uv() diff --git a/ykman/_cli/hsmauth.py b/ykman/_cli/hsmauth.py index 2239323e..c54f60d8 100644 --- a/ykman/_cli/hsmauth.py +++ b/ykman/_cli/hsmauth.py @@ -31,8 +31,9 @@ InvalidPinError, ALGORITHM, MANAGEMENT_KEY_LEN, - DEFAULT_MANAGEMENT_KEY, + CREDENTIAL_PASSWORD_LEN, ) +from yubikit.management import CAPABILITY from yubikit.core.smartcard import ApduError, SW from ..util import parse_private_key, InvalidPasswordError @@ -50,6 +51,7 @@ click_prompt, click_group, pretty_print, + get_scp_params, ) from cryptography.hazmat.primitives import serialization @@ -61,7 +63,9 @@ logger = logging.getLogger(__name__) -def handle_credential_error(e: Exception, default_exception_msg): +def handle_credential_error( + e: Exception, default_exception_msg, target="Credential password" +): if isinstance(e, InvalidPinError): attempts = e.attempts_remaining if attempts: @@ -78,7 +82,7 @@ def handle_credential_error(e: Exception, default_exception_msg): elif e.sw == SW.SECURITY_CONDITION_NOT_SATISFIED: raise CliFail("The device was not touched.") elif e.sw == SW.CONDITIONS_NOT_SATISFIED: - raise CliFail("Credential password does not meet complexity requirement.") + raise CliFail(f"{target} does not meet complexity requirement.") raise CliFail(default_exception_msg) @@ -96,26 +100,31 @@ def _parse_algorithm(algorithm: ALGORITHM) -> str: return "Asymmetric" -def _parse_key(key, key_len, key_type): +def _parse_hex(hex): try: - key = bytes.fromhex(key) - except Exception: - ValueError(key) + return bytes.fromhex(hex) + except Exception as e: + raise ValueError(e) + + +def _parse_key(key, key_len, key_type): + key = _parse_hex(key) if len(key) != key_len: raise ValueError( - f"{key_type} must be exactly {key_len} bytes long " - f"({key_len * 2} hexadecimal digits) long" + f"must be exactly {key_len} bytes long ({key_len * 2} hexadecimal digits) " + "long" ) return key -def _parse_hex(hex): - try: - val = bytes.fromhex(hex) - return val - except Exception: - raise ValueError(hex) +def _parse_password(value, key_len, name): + encoded = value.encode() + if len(encoded) <= key_len: + return encoded.ljust(key_len, b"\0") + if len(encoded) == key_len * 2: + return _parse_hex(value) + raise ValueError(f"{name} must be at most 16 bytes") @click_callback() @@ -123,6 +132,16 @@ def click_parse_management_key(ctx, param, val): return _parse_key(val, MANAGEMENT_KEY_LEN, "Management key") +@click_callback() +def click_parse_management_password(ctx, param, val): + return _parse_password(val, MANAGEMENT_KEY_LEN, "Management password") + + +@click_callback() +def click_parse_credential_password(ctx, param, val): + return _parse_password(val, CREDENTIAL_PASSWORD_LEN, "Credential password") + + @click_callback() def click_parse_enc_key(ctx, param, val): return _parse_key(val, ALGORITHM.AES128_YUBICO_AUTHENTICATION.key_len, "ENC key") @@ -143,29 +162,36 @@ 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 +def _prompt_management_key(prompt="Enter management password", confirm=False): + management_password = click_prompt( + prompt, + default="", + hide_input=True, + show_default=False, + confirmation_prompt=confirm, + ) + return _parse_password( + management_password, MANAGEMENT_KEY_LEN, "Management password" ) - 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 + prompt, + hide_input=True, + confirmation_prompt=True, ) - return credential_password + 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) +def _prompt_symmetric_key(name): + symmetric_key = click_prompt(f"Enter {name}") return _parse_key( - symmetric_key, ALGORITHM.AES128_YUBICO_AUTHENTICATION.key_len, "ENC key" + symmetric_key, ALGORITHM.AES128_YUBICO_AUTHENTICATION.key_len, name ) @@ -174,16 +200,22 @@ def _fname(fobj): click_credential_password_option = click.option( - "-c", "--credential-password", help="password to protect credential" + "-c", + "--credential-password", + help="password to protect credential", + callback=click_parse_credential_password, ) click_management_key_option = click.option( "-m", + "--management-password", "--management-key", - help="the management key", - callback=click_parse_management_key, + "management_key", + help="the management password", + callback=click_parse_management_password, ) + click_touch_option = click.option( "-t", "--touch", is_flag=True, help="require touch on YubiKey to access credential" ) @@ -195,13 +227,19 @@ def _fname(fobj): 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) + + scp_params = get_scp_params(ctx, CAPABILITY.HSMAUTH, conn) + + ctx.obj["session"] = HsmAuthSession(conn, scp_params) + info = ctx.obj["info"] + ctx.obj["fips_unready"] = ( + CAPABILITY.HSMAUTH in info.fips_capable + and CAPABILITY.HSMAUTH not in info.fips_approved + ) @hsmauth.command() @@ -210,8 +248,13 @@ def info(ctx): """ Display general status of the YubiHSM Auth application. """ - info = get_hsmauth_info(ctx.obj["session"]) - click.echo("\n".join(pretty_print(info))) + info = ctx.obj["info"] + data = get_hsmauth_info(ctx.obj["session"]) + if CAPABILITY.HSMAUTH in info.fips_capable: + # This is a bit ugly as it makes assumptions about the structure of data + data["FIPS approved"] = CAPABILITY.HSMAUTH in info.fips_approved + + click.echo("\n".join(pretty_print(data))) @hsmauth.command() @@ -236,10 +279,7 @@ def reset(ctx, force): ctx.obj["session"].reset() click.echo("Success! All YubiHSM Auth data have been cleared from the YubiKey.") - click.echo( - "Your YubiKey now has the default Management Key" - f"({DEFAULT_MANAGEMENT_KEY.hex()})." - ) + click.echo("Your YubiKey now has an empty Management password.") @hsmauth.group() @@ -314,12 +354,17 @@ def generate(ctx, label, credential_password, management_key, touch): LABEL label for the YubiHSM Auth credential """ - if not credential_password: - credential_password = _prompt_credential_password() + if ctx.obj["fips_unready"]: + raise CliFail( + "YubiKey FIPS must be in FIPS approved mode prior to adding credentials" + ) - if not management_key: + if management_key is None: management_key = _prompt_management_key() + if credential_password is None: + credential_password = _prompt_credential_password() + session = ctx.obj["session"] try: @@ -352,12 +397,17 @@ def import_credential( 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 ctx.obj["fips_unready"]: + raise CliFail( + "YubiKey FIPS must be in FIPS approved mode prior to adding credentials" + ) - if not management_key: + if management_key is None: management_key = _prompt_management_key() + if credential_password is None: + credential_password = _prompt_credential_password() + session = ctx.obj["session"] data = private_key.read() @@ -458,12 +508,17 @@ def symmetric( LABEL label for the YubiHSM Auth credential """ - if not credential_password: - credential_password = _prompt_credential_password() + if ctx.obj["fips_unready"]: + raise CliFail( + "YubiKey FIPS must be in FIPS approved mode prior to adding credentials" + ) - if not management_key: + if management_key is None: management_key = _prompt_management_key() + if credential_password is None: + credential_password = _prompt_credential_password() + if generate and (enc_key or mac_key): ctx.fail("--enc-key and --mac-key cannot be combined with --generate") @@ -516,15 +571,22 @@ def derive(ctx, label, derivation_password, credential_password, management_key, LABEL label for the YubiHSM Auth credential """ - if not credential_password: - credential_password = _prompt_credential_password() + if ctx.obj["fips_unready"]: + raise CliFail( + "YubiKey FIPS must be in FIPS approved mode prior to adding credentials" + ) - if not management_key: + if management_key is None: management_key = _prompt_management_key() - if not derivation_password: + if credential_password is None: + credential_password = _prompt_credential_password() + + if derivation_password is None: derivation_password = click_prompt( - "Enter derivation password", default="", show_default=False + "Enter derivation password", + hide_input=True, + confirmation_prompt=True, ) session = ctx.obj["session"] @@ -554,7 +616,7 @@ def delete(ctx, label, management_key, force): LABEL a label to match a single credential (as shown in "list") """ - if not management_key: + if management_key is None: management_key = _prompt_management_key() force or click.confirm( @@ -579,13 +641,12 @@ def access(): """Manage Management Key for YubiHSM Auth""" -@access.command() +@access.command(hidden=True) @click.pass_context @click.option( "-m", "--management-key", help="current management key", - default=DEFAULT_MANAGEMENT_KEY, show_default=True, callback=click_parse_management_key, ) @@ -610,7 +671,7 @@ def change_management_key(ctx, management_key, new_management_key, generate): YubiHSM Auth credentials stored on the YubiKey. """ - if not management_key: + if management_key is None: management_key = _prompt_management_key( "Enter current management key [blank to use default key]" ) @@ -621,7 +682,7 @@ def change_management_key(ctx, management_key, new_management_key, generate): if new_management_key and generate: ctx.fail("Invalid options: --new-management-key conflicts with --generate") - if not new_management_key: + if new_management_key is None: if generate: new_management_key = generate_random_management_key() click.echo(f"Generated management key: {new_management_key.hex()}") @@ -647,5 +708,53 @@ def change_management_key(ctx, management_key, new_management_key, generate): session.put_management_key(management_key, new_management_key) except Exception as e: handle_credential_error( - e, default_exception_msg="Failed to change management key." + e, + default_exception_msg="Failed to change management key.", + target="Management key", + ) + + +@access.command() +@click.pass_context +@click.option( + "-m", + "--management-password", + "management_key", + help="current management password", + show_default=True, + callback=click_parse_management_password, +) +@click.option( + "-n", + "--new-management-password", + "new_management_key", + help="a new management password to set", + callback=click_parse_management_password, +) +def change_management_password(ctx, management_key, new_management_key): + """ + Change the management key. + + Allows you to change the management key which is required to add and delete + YubiHSM Auth credentials stored on the YubiKey. + """ + + if management_key is None: + management_key = _prompt_management_key( + "Enter your current management password", + ) + + if new_management_key is None: + new_management_key = _prompt_management_key( + "Enter a new management password", confirm=True + ) + + session = ctx.obj["session"] + try: + session.put_management_key(management_key, new_management_key) + except Exception as e: + handle_credential_error( + e, + default_exception_msg="Failed to change management password.", + target="Management password", ) diff --git a/ykman/_cli/info.py b/ykman/_cli/info.py index a5767e83..59a62bcd 100644 --- a/ykman/_cli/info.py +++ b/ykman/_cli/info.py @@ -34,7 +34,7 @@ from yubikit.oath import OathSession from yubikit.support import get_name -from .util import CliFail, is_yk4_fips, click_command +from .util import CliFail, is_yk4_fips, click_command, pretty_print from ..otp import is_in_fips_mode as otp_in_fips_mode from ..oath import is_in_fips_mode as oath_in_fips_mode from ..fido import is_in_fips_mode as ctap_in_fips_mode @@ -196,6 +196,12 @@ def info(ctx, check_fips): info.supported_capabilities, info.config.enabled_capabilities ) + if info.fips_capable: + click.echo() + click.echo("FIPS approved applications") + data = {c.display_name: c in info.fips_approved for c in info.fips_capable} + click.echo("\n".join(pretty_print(data))) + if check_fips: click.echo() if is_yk4_fips(info): diff --git a/ykman/_cli/oath.py b/ykman/_cli/oath.py index 4a1ada8e..c4f8b3d8 100644 --- a/ykman/_cli/oath.py +++ b/ykman/_cli/oath.py @@ -25,8 +25,6 @@ # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -import click -import logging from .util import ( CliFail, click_force_option, @@ -39,6 +37,8 @@ prompt_timeout, EnumChoice, is_yk4_fips, + pretty_print, + get_scp_params, ) from yubikit.core.smartcard import ApduError, SW, SmartCardConnection from yubikit.oath import ( @@ -49,9 +49,13 @@ parse_b32_key, _format_cred_id, ) +from yubikit.management import CAPABILITY from ..oath import is_steam, calculate_steam, is_hidden, delete_broken_credential from ..settings import AppData +from typing import Dict, List, Any +import click +import logging logger = logging.getLogger(__name__) @@ -82,8 +86,16 @@ def oath(ctx): dev = ctx.obj["device"] conn = dev.open_connection(SmartCardConnection) ctx.call_on_close(conn.close) - ctx.obj["session"] = OathSession(conn) + + scp_params = get_scp_params(ctx, CAPABILITY.OATH, conn) + + ctx.obj["session"] = OathSession(conn, scp_params) ctx.obj["oath_keys"] = AppData("oath_keys") + info = ctx.obj["info"] + ctx.obj["fips_unready"] = ( + CAPABILITY.OATH in info.fips_capable + and CAPABILITY.OATH not in info.fips_approved + ) @oath.command() @@ -93,16 +105,22 @@ def info(ctx): Display general status of the OATH application. """ session = ctx.obj["session"] - version = session.version - click.echo(f"OATH version: {version[0]}.{version[1]}.{version[2]}") - click.echo("Password protection: " + ("enabled" if session.locked else "disabled")) + info = ctx.obj["info"] + data: Dict[str, Any] = {"OATH version": "%d.%d.%d" % session.version} + lines: List[Any] = [data] + if CAPABILITY.OATH in info.fips_capable: + # This is a bit ugly as it makes assumptions about the structure of data + data["FIPS approved"] = CAPABILITY.OATH in info.fips_approved + elif is_yk4_fips(info): + data["FIPS approved"] = session.locked + + data["Password protection"] = "enabled" if session.locked else "disabled" keys = ctx.obj["oath_keys"] if session.locked and session.device_id in keys: - click.echo("The password for this YubiKey is remembered by ykman.") + lines.append("The password for this YubiKey is remembered by ykman.") - if is_yk4_fips(ctx.obj["info"]): - click.echo(f"FIPS Approved Mode: {'Yes' if session.locked else 'No'}") + click.echo("\n".join(pretty_print(lines))) @oath.command() @@ -226,8 +244,13 @@ def change(ctx, password, clear, new_password, remember): Allows you to set or change a password that will be required to access the OATH accounts stored on the YubiKey. """ - if clear and new_password: - ctx.fail("--clear cannot be combined with --new-password.") + if clear: + if new_password: + raise CliFail("--clear cannot be combined with --new-password.") + + info = ctx.obj["info"] + if CAPABILITY.OATH in info.fips_capable: + raise CliFail("Removing the password is not allowed on YubiKey FIPS.") _init_session(ctx, password, False, prompt="Enter the current password") @@ -453,6 +476,11 @@ def add( SECRET base32-encoded secret/key value provided by the server """ + if ctx.obj["fips_unready"]: + raise CliFail( + "YubiKey FIPS must be in FIPS approved mode prior to adding accounts" + ) + digits = int(digits) if not secret: @@ -480,7 +508,7 @@ def add( def click_parse_uri(ctx, param, val): try: return CredentialData.parse_uri(val) - except ValueError: + except (ValueError, KeyError): raise click.BadParameter("URI seems to have the wrong format.") @@ -498,6 +526,11 @@ def uri(ctx, data, touch, force, password, remember): Use a URI to add a new account to the YubiKey. """ + if ctx.obj["fips_unready"]: + raise CliFail( + "YubiKey FIPS must be in FIPS approved mode prior to adding accounts" + ) + if not data: while True: uri = click_prompt("Enter an OATH URI (otpauth://)") @@ -521,16 +554,16 @@ def _add_cred(ctx, data, touch, force): version = session.version if not (0 < len(data.name) <= 64): - ctx.fail("Name must be between 1 and 64 bytes.") + raise CliFail("Name must be between 1 and 64 bytes.") if len(data.secret) < 2: - ctx.fail("Secret must be at least 2 bytes.") + raise CliFail("Secret must be at least 2 bytes.") if touch and version < (4, 2, 6): raise CliFail("Require touch is not supported on this YubiKey.") if data.counter and data.oath_type != OATH_TYPE.HOTP: - ctx.fail("Counter only supported for HOTP accounts.") + raise CliFail("Counter only supported for HOTP accounts.") if data.hash_algorithm == HASH_ALGORITHM.SHA512 and ( version < (4, 3, 1) or is_yk4_fips(ctx.obj["info"]) diff --git a/ykman/_cli/openpgp.py b/ykman/_cli/openpgp.py index 08fdfa62..dd13c4bf 100644 --- a/ykman/_cli/openpgp.py +++ b/ykman/_cli/openpgp.py @@ -27,6 +27,7 @@ from yubikit.core.smartcard import ApduError, SW, SmartCardConnection from yubikit.openpgp import OpenPgpSession, UIF, PIN_POLICY, KEY_REF as _KEY_REF +from yubikit.management import CAPABILITY from ..util import parse_certificates, parse_private_key from ..openpgp import get_openpgp_info from .util import ( @@ -38,6 +39,7 @@ click_group, EnumChoice, pretty_print, + get_scp_params, ) from enum import IntEnum import logging @@ -81,7 +83,10 @@ def openpgp(ctx): dev = ctx.obj["device"] conn = dev.open_connection(SmartCardConnection) ctx.call_on_close(conn.close) - ctx.obj["session"] = OpenPgpSession(conn) + + scp_params = get_scp_params(ctx, CAPABILITY.OPENPGP, conn) + + ctx.obj["session"] = OpenPgpSession(conn, scp_params) @openpgp.command() @@ -90,8 +95,12 @@ def info(ctx): """ Display general status of the OpenPGP application. """ - session = ctx.obj["session"] - click.echo("\n".join(pretty_print(get_openpgp_info(session)))) + info = ctx.obj["info"] + data = get_openpgp_info(ctx.obj["session"]) + if CAPABILITY.OPENPGP in info.fips_capable: + # This is a bit ugly as it makes assumptions about the structure of data + data["FIPS approved"] = CAPABILITY.OPENPGP in info.fips_approved + click.echo("\n".join(pretty_print(data))) @openpgp.command() diff --git a/ykman/_cli/piv.py b/ykman/_cli/piv.py index bb9c1a89..a6f5d3bf 100644 --- a/ykman/_cli/piv.py +++ b/ykman/_cli/piv.py @@ -73,6 +73,7 @@ prompt_timeout, EnumChoice, pretty_print, + get_scp_params, ) from cryptography.hazmat.primitives import serialization, hashes from cryptography.hazmat.backends import default_backend @@ -196,9 +197,16 @@ def piv(ctx): dev = ctx.obj["device"] conn = dev.open_connection(SmartCardConnection) ctx.call_on_close(conn.close) - session = PivSession(conn) + + scp_params = get_scp_params(ctx, CAPABILITY.PIV, conn) + session = PivSession(conn, scp_params) + + info = ctx.obj["info"] ctx.obj["session"] = session ctx.obj["pivman_data"] = get_pivman_data(session) + ctx.obj["fips_unready"] = ( + CAPABILITY.PIV in info.fips_capable and CAPABILITY.PIV not in info.fips_approved + ) @piv.command() @@ -207,8 +215,12 @@ def info(ctx): """ Display general status of the PIV application. """ - info = get_piv_info(ctx.obj["session"]) - click.echo("\n".join(pretty_print(info))) + info = ctx.obj["info"] + data = get_piv_info(ctx.obj["session"]) + if CAPABILITY.PIV in info.fips_capable: + # This is a bit ugly as it makes assumptions about the structure of data + data[0]["FIPS approved"] = CAPABILITY.PIV in info.fips_approved + click.echo("\n".join(pretty_print(data))) @piv.command() @@ -264,6 +276,16 @@ def set_pin_retries(ctx, management_key, pin, pin_retries, puk_retries, force): NOTE: This will reset the PIN and PUK to their factory defaults. """ session = ctx.obj["session"] + info = ctx.obj["info"] + if CAPABILITY.PIV in info.fips_capable: + if not ( + session.get_pin_metadata().default_value + and session.get_puk_metadata().default_value + ): + raise CliFail( + "Retry attempts must be set before PIN/PUK have been changed." + ) + _ensure_authenticated( ctx, pin, management_key, require_pin_and_key=True, no_prompt=force ) @@ -404,8 +426,6 @@ def change_puk(ctx, puk, new_puk): "--algorithm", help="management key algorithm", type=EnumChoice(MANAGEMENT_KEY_TYPE), - default=MANAGEMENT_KEY_TYPE.TDES.name, - show_default=True, ) @click.option( "-p", @@ -442,7 +462,16 @@ def change_management_key( A random key may be generated and stored on the YubiKey, protected by PIN. """ session = ctx.obj["session"] - pivman = ctx.obj["pivman_data"] + + if not algorithm: + try: + algorithm = session.get_management_key_metadata().key_type + except NotSupportedError: + algorithm = MANAGEMENT_KEY_TYPE.TDES + + info = ctx.obj["info"] + if CAPABILITY.PIV in info.fips_capable and algorithm in (MANAGEMENT_KEY_TYPE.TDES,): + raise CliFail(f"{algorithm.name} not supported on YubiKey FIPS.") pin_verified = _ensure_authenticated( ctx, @@ -455,13 +484,14 @@ def change_management_key( # Can't combine new key with generate. if new_management_key and generate: - ctx.fail("Invalid options: --new-management-key conflicts with --generate") + raise CliFail("Invalid options: --new-management-key conflicts with --generate") # Touch not supported on NEO. if touch and session.version < (4, 0, 0): raise CliFail("Require touch not supported on this YubiKey.") # If an old stored key needs to be cleared, the PIN is needed. + pivman = ctx.obj["pivman_data"] if not pin_verified and pivman.has_stored_key: if pin: _verify_pin(ctx, session, pivman, pin, no_prompt=force) @@ -479,7 +509,7 @@ def change_management_key( if not protect: click.echo(f"Generated management key: {new_management_key.hex()}") elif force: - ctx.fail( + raise CliFail( "New management key not given. Remove the --force " "flag, or set the --generate flag or the " "--new-management-key option." @@ -494,7 +524,7 @@ def change_management_key( ) ) except Exception: - ctx.fail("New management key has the wrong format.") + raise CliFail("New management key has the wrong format.") if len(new_management_key) != algorithm.key_len: raise CliFail( @@ -589,6 +619,12 @@ def generate_key( PUBLIC-KEY file containing the generated public key (use '-' to use stdout) """ + if ctx.obj["fips_unready"]: + raise CliFail( + "YubiKey FIPS must be in FIPS approved mode prior to key generation" + ) + _check_key_support_fips(ctx, algorithm, pin_policy) + session = ctx.obj["session"] _ensure_authenticated(ctx, pin, management_key) @@ -628,6 +664,10 @@ def import_key( SLOT PIV slot of the private key PRIVATE-KEY file containing the private key (use '-' to use stdin) """ + + if ctx.obj["fips_unready"]: + raise CliFail("YubiKey FIPS must be in FIPS approved mode prior to key import") + session = ctx.obj["session"] data = private_key.read() @@ -653,6 +693,10 @@ def import_key( continue break + _check_key_support_fips( + ctx, KEY_TYPE.from_public_key(private_key.public_key()), pin_policy + ) + _ensure_authenticated(ctx, pin, management_key) session.put_key(slot, private_key, pin_policy, touch_policy) @@ -1162,6 +1206,15 @@ def read_object(ctx, pin, object_id, output): session = ctx.obj["session"] pivman = ctx.obj["pivman_data"] + if ctx.obj["fips_unready"] and object_id in ( + OBJECT_ID.PRINTED, + OBJECT_ID.FINGERPRINTS, + OBJECT_ID.FACIAL, + OBJECT_ID.IRIS, + ): + raise CliFail( + "YubiKey FIPS must be in FIPS approved mode to export this object." + ) def do_read_object(retry=True): try: @@ -1234,7 +1287,7 @@ def generate_object(ctx, pin, management_key, object_id): elif OBJECT_ID.CAPABILITY == object_id: session.put_object(OBJECT_ID.CAPABILITY, generate_ccc()) else: - ctx.fail("Unsupported object ID for generate.") + raise CliFail("Unsupported object ID for generate.") def _prompt_management_key(prompt="Enter a management key [blank to use default key]"): @@ -1287,18 +1340,12 @@ def _verify_pin(ctx, session, pivman, pin, no_prompt=False): session.verify_pin(pin) if pivman.has_derived_key: with prompt_timeout(): - session.authenticate( - MANAGEMENT_KEY_TYPE.TDES, derive_management_key(pin, pivman.salt) - ) + session.authenticate(derive_management_key(pin, pivman.salt)) session.verify_pin(pin) # Ensure verify was the last thing we did elif pivman.has_stored_key: pivman_prot = get_pivman_protected_data(session) - try: - key_type = session.get_management_key_metadata().key_type - except NotSupportedError: - key_type = MANAGEMENT_KEY_TYPE.TDES with prompt_timeout(): - session.authenticate(key_type, pivman_prot.key) + session.authenticate(pivman_prot.key) session.verify_pin(pin) # Ensure verify was the last thing we did except InvalidPinError as e: attempts = e.attempts_remaining @@ -1326,19 +1373,23 @@ def _verify_pin_if_needed(ctx, session, func, pin=None, no_prompt=False): def _authenticate(ctx, session, management_key, mgm_key_prompt, no_prompt=False): if not management_key: if no_prompt: - ctx.fail("Management key required.") + raise CliFail("Management key required.") else: if mgm_key_prompt is None: management_key = _prompt_management_key() else: management_key = _prompt_management_key(mgm_key_prompt) try: - try: - key_type = session.get_management_key_metadata().key_type - except NotSupportedError: - key_type = MANAGEMENT_KEY_TYPE.TDES - with prompt_timeout(): - session.authenticate(key_type, management_key) + session.authenticate(management_key) except Exception: raise CliFail("Authentication with management key failed.") + + +def _check_key_support_fips(ctx, key_type, pin_policy): + info = ctx.obj["info"] + if CAPABILITY.PIV in info.fips_capable: + if key_type in (KEY_TYPE.RSA1024, KEY_TYPE.X25519): + raise CliFail(f"Key type {key_type.name} not supported on YubiKey FIPS") + if pin_policy in (PIN_POLICY.NEVER,): + raise CliFail(f"PIN policy {pin_policy.name} not supported on YubiKey FIPS") diff --git a/ykman/_cli/securedomain.py b/ykman/_cli/securedomain.py new file mode 100644 index 00000000..d588de34 --- /dev/null +++ b/ykman/_cli/securedomain.py @@ -0,0 +1,385 @@ +# Copyright (c) 2024 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, ApduError, SW +from yubikit.core.smartcard.scp import ( + ScpKid, + KeyRef, + Scp03KeyParams, + Scp11KeyParams, + StaticKeys, +) +from yubikit.management import CAPABILITY +from yubikit.securedomain import SecureDomainSession + +from ..util import ( + parse_private_key, + parse_certificates, + InvalidPasswordError, +) +from .util import ( + CliFail, + click_group, + click_force_option, + click_postpone_execution, + click_callback, + click_prompt, + HexIntParamType, + pretty_print, + get_scp_params, + organize_scp11_certificates, +) +from cryptography import x509 +from cryptography.hazmat.primitives import serialization +from typing import Dict, List, Any + +import click +import logging +import sys + + +logger = logging.getLogger(__name__) + + +@click_group( + "sd", connections=[SmartCardConnection], hidden="--full-help" not in sys.argv +) +@click.pass_context +@click_postpone_execution +def securedomain(ctx): + """ + Manage the Secure Domain application, which holds keys for SCP. + """ + + dev = ctx.obj["device"] + conn = dev.open_connection(SmartCardConnection) + ctx.call_on_close(conn.close) + + session = SecureDomainSession(conn) + scp_params = get_scp_params(ctx, CAPABILITY(-1), conn) + + if scp_params: + session.authenticate(scp_params) + + ctx.obj["authenticated"] = ( + isinstance(scp_params, Scp03KeyParams) + or isinstance(scp_params, Scp11KeyParams) + and scp_params.ref.kid in (ScpKid.SCP11a, ScpKid.SCP11c) + ) + + ctx.obj["session"] = session + + +@securedomain.command() +@click.pass_context +def info(ctx): + """ + List keys in the Secure Domain of the YubiKey. + """ + sd = ctx.obj["session"] + data: List[Any] = [] + cas = sd.get_supported_ca_identifiers() + for ref in sd.get_key_information().keys(): + if ref.kid < 0x10: # SCP03 + data.append(f"{ref}") + else: # SCP11 + inner: Dict[str, Any] = {} + if ref in cas: + inner["CA Key Identifier"] = ":".join(f"{b:02X}" for b in cas[ref]) + try: + inner["Certificate chain"] = [ + c.subject.rfc4514_string() for c in sd.get_certificate_bundle(ref) + ] + except ApduError: + pass + data.append({ref: inner}) + + click.echo("\n".join(pretty_print(data))) + + +@securedomain.command() +@click.pass_context +@click_force_option +def reset(ctx, force): + """ + Reset all Secure Domain data. + + This action will wipe all keys and restore factory settings for + the Secure Domain on the YubiKey. + """ + force or click.confirm( + "WARNING! This will delete all stored Secure Domain data and restore factory " + "settings. Proceed?", + abort=True, + err=True, + ) + + click.echo("Resetting Secure Domain data...") + ctx.obj["session"].reset() + + click.echo("Success! Secure Domain data has been cleared from the YubiKey.") + click.echo("Your YubiKey now has the default SCP key set") + + +@securedomain.group() +def keys(): + """Manage SCP keys.""" + + +def _require_auth(ctx): + if not ctx.obj["authenticated"]: + raise CliFail( + "This command requires authentication, " + "invoke ykman with --scp03-keys or --scp11-cred." + ) + + +def _fname(fobj): + return getattr(fobj, "name", fobj) + + +@click_callback() +def click_parse_scp_ref(ctx, param, val): + try: + return KeyRef(*val) + except AttributeError: + raise ValueError(val) + + +class ScpKidParamType(HexIntParamType): + name = "kid" + + def convert(self, value, param, ctx): + if isinstance(value, int): + return value + try: + name = value.upper()[:-1] + value[-1].lower() + return ScpKid[name] + except KeyError: + try: + if value.lower().startswith("0x"): + return int(value[2:], 16) + if ":" in value: + return int(value.replace(":", ""), 16) + return int(value) + except ValueError: + self.fail(f"{value!r} is not a valid integer", param, ctx) + + +click_key_argument = click.argument( + "key", + metavar="KID KVN", + type=(ScpKidParamType(), HexIntParamType()), + callback=click_parse_scp_ref, +) + + +@keys.command("generate") +@click.pass_context +@click_key_argument +@click.argument("public-key-output", type=click.File("wb"), metavar="PUBLIC-KEY") +def generate_key(ctx, key, public_key_output): + """ + Generate an asymmetric key pair. + + The private key is generated on the YubiKey, and written to one of the slots. + + \b + KID KVN key reference for the new key + PUBLIC-KEY file containing the generated public key (use '-' to use stdout) + """ + + _require_auth(ctx) + valid = (ScpKid.SCP11a, ScpKid.SCP11b, ScpKid.SCP11c) + if key.kid not in valid: + values_str = ", ".join(f"0x{v:x} ({v.name})" for v in valid) + raise CliFail(f"KID must be one of {values_str}") + + session = ctx.obj["session"] + + public_key = session.generate_ec_key(key) + + key_encoding = serialization.Encoding.PEM + public_key_output.write( + public_key.public_bytes( + encoding=key_encoding, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + ) + logger.info( + f"Private key generated for {key}, public key written to " + f"{_fname(public_key_output)}" + ) + + +@keys.command("import") +@click.pass_context +@click_key_argument +@click.argument("input", metavar="INPUT") +@click.option("-p", "--password", help="password used to decrypt the file (if needed)") +def import_key(ctx, key, input, password): + """ + Import a key or certificate. + + KID 0x01 expects the input to be a ":"-separated triple of K-ENC:K-MAC:K-DEK. + + KID 0x11, 0x13, and 0x15 expect the input to be a file containing a private key and + (optionally) a certificate chain. + + KID 0x10, 0x20-0x2F expect the file to contain a CA-KLOC certificate. + + \b + KID KVN key reference for the new key + INPUT SCP03 keyset, or input file (use '-' to use stdin) + """ + + _require_auth(ctx) + session = ctx.obj["session"] + + if key.kid == ScpKid.SCP03: + session.put_key(key, StaticKeys(*[bytes.fromhex(k) for k in input.split(":")])) + return + + file = click.File("rb").convert(input, None, ctx) + data = file.read() + if key.kid in (ScpKid.SCP11a, ScpKid.SCP11b, ScpKid.SCP11c): + # Expect a private key + while True: + if password is not None: + password = password.encode() + try: + target = parse_private_key(data, password) + break + except InvalidPasswordError: + logger.debug("Error parsing file", exc_info=True) + if password is None: + password = click_prompt( + "Enter password to decrypt file", + default="", + hide_input=True, + show_default=False, + ) + else: + password = None + click.echo("Wrong password.") + + ca, bundle, leaf = organize_scp11_certificates( + parse_certificates(data, password) + ) + if leaf: + bundle = list(bundle) + [leaf] + + elif key.kid in (0x10, *range(0x20, 0x30)): # Public CA key + ca, inter, leaf = organize_scp11_certificates(parse_certificates(data, None)) + if not ca: + raise CliFail("Input does not contain a valid CA-KLOC certificate") + target = ca.public_key() + bundle = None + + else: + raise CliFail(f"Invalid value for KID={key.kid:x}") + + session.put_key(key, target) + + # If we have a bundle of intermediate certificates, store them + if bundle: + session.store_certificate_bundle(key, bundle) + + # If the CA has a Subject Key Identifer we should store it + if ca: + ski = ca.extensions.get_extension_for_class(x509.SubjectKeyIdentifier) + session.store_ca_issuer(key, ski.value.digest) + + +@keys.command() +@click.pass_context +@click_key_argument +@click.argument("certificates-output", type=click.File("wb"), metavar="OUTPUT") +def export(ctx, key, certificates_output): + """ + Export certificate chain for a key. + + \b + KID KVN key reference to output certificate chain for + OUTPUT file to write the certificate chain to (use '-' to use stdout) + """ + session = ctx.obj["session"] + pems = [ + cert.public_bytes(encoding=serialization.Encoding.PEM) + for cert in reversed(session.get_certificate_bundle(key)) + ] + if pems: + certificates_output.write(b"".join(pems)) + logger.info( + f"Certificate chain for {key} written to {_fname(certificates_output)}" + ) + else: + raise CliFail(f"No certificate chain stored for {key}") + + +@keys.command("delete") +@click.pass_context +@click_key_argument +def delete_key(ctx, key): + """ + Delete a key or keyset. + + Deletes the key or keyset with the given KID and KVN. Set either KID or KVN to 0 to + use it as a wildcard and delete all keys matching the specific KID or KVN + + \b + KID KVN key reference for the key to delete + """ + _require_auth(ctx) + session = ctx.obj["session"] + try: + session.delete_key(key.kid, key.kvn) + except ApduError as e: + if e.sw == SW.REFERENCE_DATA_NOT_FOUND: + raise CliFail(f"No key stored in {key}.") + raise + + +@keys.command("set-allowlist") +@click.pass_context +@click_key_argument +@click.argument("serials", nargs=-1, type=HexIntParamType()) +def set_allowlist(ctx, key, serials): + """ + Set an allowlist of certificate serial numbers for a key. + + Each certificate in the chain used when authenticating an SCP11a/c session will be + checked and rejected if their serial number is not in this allowlist. + + \b + KID KVN key reference to set the allowlist for + SERIALS serial numbers of certificates to allow (space separated) + """ + _require_auth(ctx) + session = ctx.obj["session"] + + session.set_allowlist(key, serials) diff --git a/ykman/_cli/util.py b/ykman/_cli/util.py index e7aef850..931f1cd7 100644 --- a/ykman/_cli/util.py +++ b/ykman/_cli/util.py @@ -25,18 +25,24 @@ # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -import functools -import click -import sys -from yubikit.management import DeviceInfo +from ..util import parse_certificates +from yubikit.core import TRANSPORT +from yubikit.core.smartcard import SmartCardConnection, ApduError +from yubikit.core.smartcard.scp import ScpKid, KeyRef, ScpKeyParams, Scp11KeyParams +from yubikit.management import DeviceInfo, CAPABILITY from yubikit.oath import parse_b32_key +from yubikit.securedomain import SecureDomainSession from collections import OrderedDict from collections.abc import MutableMapping from cryptography.hazmat.primitives import serialization +from cryptography import x509 from contextlib import contextmanager from threading import Timer from enum import Enum -from typing import List +from typing import Optional, Sequence, Tuple, List +import functools +import click +import sys import logging logger = logging.getLogger(__name__) @@ -135,6 +141,22 @@ def convert(self, value, param, ctx): return self.choices_enum[name] +class HexIntParamType(click.ParamType): + name = "integer" + + def convert(self, value, param, ctx): + if isinstance(value, int): + return value + try: + if value.lower().startswith("0x"): + return int(value[2:], 16) + if ":" in value: + return int(value.replace(":", ""), 16) + return int(value) + except ValueError: + self.fail(f"{value!r} is not a valid integer", param, ctx) + + def click_callback(invoke_on_missing=False): def wrap(f): @functools.wraps(f) @@ -268,13 +290,13 @@ def __init__(self, message, status=1): self.status = status -def pretty_print(value, level: int = 0) -> List[str]: +def pretty_print(value, level: int = 0) -> Sequence[str]: """Pretty-prints structured data, as that returned by get_diagnostics. Returns a list of strings which can be printed as lines. """ indent = " " * level - lines = [] + lines: List[str] = [] if isinstance(value, list): for v in value: lines.extend(pretty_print(v, level)) @@ -308,3 +330,104 @@ def pretty_print(value, level: int = 0) -> List[str]: def is_yk4_fips(info: DeviceInfo) -> bool: return info.version[0] == 4 and info.is_fips + + +def find_scp11_params( + connection: SmartCardConnection, kid: int, kvn: int, ca: Optional[bytes] = None +) -> Scp11KeyParams: + scp = SecureDomainSession(connection) + if not kvn: + if ca: + # Find by CA + for ref, ca_check in scp.get_supported_ca_identifiers(klcc=True).items(): + if ca_check == ca: + if not kid or ref.kid == kid: + kid, kvn = ref + break + # Find any matching KID + for ref in scp.get_key_information().keys(): + if ref.kid == kid: + kvn = ref.kvn + break + else: + raise ValueError(f"No SCP key found matching KID=0x{kid:x}") + try: + chain = scp.get_certificate_bundle(KeyRef(kid, kvn)) + if ca: + logger.debug("Validating KLCC CA using supplied file") + parent = parse_certificates(ca, None)[0] + for cert in chain: + # Requires cryptography >= 40 + cert.verify_directly_issued_by(parent) + parent = cert + logger.info("KLCC CA validated") + else: + logger.info("No CA supplied, skipping KLCC CA validation") + + pub_key = chain[-1].public_key() + return Scp11KeyParams(KeyRef(kid, kvn), pub_key) + except ApduError: + raise ValueError( + f"Unable to get SCP key paramaters (KID=0x{kid:x}, KVN=ox{kvn:x})" + ) + + +def get_scp_params( + ctx: click.Context, capability: CAPABILITY, connection: SmartCardConnection +) -> Optional[ScpKeyParams]: + # Explicit SCP + resolve = ctx.obj.get("scp") + if resolve: + return resolve(connection) + + # Automatic SCP11b if needed + info = ctx.obj["info"] + if connection.transport == TRANSPORT.NFC and capability in info.fips_capable: + logger.debug("Attempt to find SCP11b key") + try: + params = find_scp11_params(connection, ScpKid.SCP11b, 0) + logger.info("SCP11b key found, using for FIPS capable applications") + return params + except ValueError: + logger.debug("No SCP11b key found, not using SCP") + + return None + + +def organize_scp11_certificates( + certificates: Sequence[x509.Certificate], +) -> Tuple[ + Optional[x509.Certificate], Sequence[x509.Certificate], Optional[x509.Certificate] +]: + if not certificates: + return None, [], None + + # Order leaf-last + ordered, certificates = [certificates[0]], list(certificates[1:]) + while certificates: + for c in certificates: + if c.subject == ordered[0].issuer: + certificates.remove(c) + ordered.insert(0, c) + break + if ordered[-1].subject == c.issuer: + certificates.remove(c) + ordered.append(c) + break + else: + raise ValueError("Incomplete chain of certificates") + + ca, leaf = None, None + + # Check if root is self-signed: + peek = ordered[0] + if peek.issuer == peek.subject: + ca = ordered.pop(0) + + # Check if leaf has keyAgreement policy: + if ordered: + kue = ordered[-1].extensions.get_extension_for_oid(x509.ExtensionOID.KEY_USAGE) + if kue.value.key_agreement: + leaf = ordered.pop() + + return ca, ordered, leaf diff --git a/ykman/piv.py b/ykman/piv.py index f5911ae2..a0df6dcb 100644 --- a/ykman/piv.py +++ b/ykman/piv.py @@ -38,6 +38,7 @@ TAG_LRC, SlotMetadata, ) +from .util import display_serial from cryptography import x509 from cryptography.exceptions import InvalidSignature @@ -231,7 +232,7 @@ def get_bytes(self) -> bytes: data += Tlv(0x82, self.salt) if self.pin_timestamp is not None: data += Tlv(0x83, struct.pack(">I", self.pin_timestamp)) - return Tlv(0x80, data) + return Tlv(0x80, data) if data else b"" class PivmanProtectedData: @@ -243,7 +244,7 @@ def get_bytes(self) -> bytes: data = b"" if self.key is not None: data += Tlv(0x89, self.key) - return Tlv(0x88, data) + return Tlv(0x88, data) if data else b"" def get_pivman_data(session: PivSession) -> PivmanData: @@ -296,6 +297,7 @@ def pivman_set_mgm_key( :param store_on_device: If set, the management key is stored on device. """ pivman = get_pivman_data(session) + pivman_old_bytes = pivman.get_bytes() pivman_prot = None if store_on_device or (not store_on_device and pivman.has_stored_key): @@ -318,8 +320,10 @@ def pivman_set_mgm_key( # Set flag for stored or not stored key. pivman.mgm_key_protected = store_on_device - # Update readable pivman data - session.put_object(OBJECT_ID_PIVMAN_DATA, pivman.get_bytes()) + # Update readable pivman data, if changed + pivman_bytes = pivman.get_bytes() + if pivman_old_bytes != pivman_bytes: + session.put_object(OBJECT_ID_PIVMAN_DATA, pivman_bytes) if pivman_prot is not None: if store_on_device: @@ -354,7 +358,6 @@ def pivman_change_pin(session: PivSession, old_pin: str, new_pin: str) -> None: if pivman.has_derived_key: logger.debug("Has derived management key, update for new PIN") session.authenticate( - MANAGEMENT_KEY_TYPE.TDES, derive_management_key(old_pin, cast(bytes, pivman.salt)), ) session.verify_pin(new_pin) @@ -595,18 +598,8 @@ def get_piv_info(session: PivSession): cert = certs.get(slot, None) if cert: try: - # Try to read out full DN, fallback to only CN. - # Support for DN was added in crytography 2.5 subject_dn = cert.subject.rfc4514_string() issuer_dn = cert.issuer.rfc4514_string() - print_dn = True - except AttributeError: - print_dn = False - logger.debug("Failed to read DN, falling back to only CNs") - cn = cert.subject.get_attributes_for_oid(x509.NameOID.COMMON_NAME) - subject_cn = cn[0].value if cn else "None" - cn = cert.issuer.get_attributes_for_oid(x509.NameOID.COMMON_NAME) - issuer_cn = cn[0].value if cn else "None" except ValueError as e: # Malformed certificates may throw ValueError logger.debug("Failed parsing certificate", exc_info=True) @@ -638,13 +631,9 @@ def get_piv_info(session: PivSession): # Print out everything cert_data["Public key type"] = key_algo - if print_dn: - cert_data["Subject DN"] = subject_dn - cert_data["Issuer DN"] = issuer_dn - else: - cert_data["Subject CN"] = subject_cn - cert_data["Issuer CN"] = issuer_cn - cert_data["Serial"] = serial + cert_data["Subject DN"] = subject_dn + cert_data["Issuer DN"] = issuer_dn + cert_data["Serial"] = display_serial(serial) cert_data["Fingerprint"] = fingerprint if not_before: cert_data["Not before"] = not_before.isoformat() diff --git a/ykman/util.py b/ykman/util.py index e4872874..385d2f85 100644 --- a/ykman/util.py +++ b/ykman/util.py @@ -25,7 +25,7 @@ # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -from yubikit.core import Tlv +from yubikit.core import Tlv, int2bytes from cryptography.hazmat.primitives.serialization import pkcs12 from cryptography.hazmat.primitives import serialization from cryptography.hazmat.backends import default_backend @@ -145,16 +145,8 @@ def get_leaf_certificates(certs): :param certs: The list of cryptography x509 certificate objects. """ - issuers = [ - cert.issuer.get_attributes_for_oid(x509.NameOID.COMMON_NAME) for cert in certs - ] - leafs = [ - cert - for cert in certs - if ( - cert.subject.get_attributes_for_oid(x509.NameOID.COMMON_NAME) not in issuers - ) - ] + issuers = [cert.issuer for cert in certs] + leafs = [cert for cert in certs if cert.subject not in issuers] return leafs @@ -176,6 +168,13 @@ def is_pkcs12(data): return False +def display_serial(serial: int) -> str: + """Displays an x509 certificate serial number in a readable format.""" + if serial >= 0x10000000000000000: + return ":".join(f"{b:02x}" for b in int2bytes(serial, 20)) + return f"{serial} ({hex(serial)})" + + class OSVERSIONINFOW(ctypes.Structure): _fields_ = [ ("dwOSVersionInfoSize", ctypes.c_ulong), diff --git a/yubikit/core/__init__.py b/yubikit/core/__init__.py index 473af30d..a2117933 100644 --- a/yubikit/core/__init__.py +++ b/yubikit/core/__init__.py @@ -56,6 +56,9 @@ class Version(NamedTuple): def __str__(self): return "%d.%d.%d" % self + def __bool__(self): + return any(self) + @classmethod def from_bytes(cls, data: bytes) -> "Version": return cls(*data) diff --git a/yubikit/core/smartcard.py b/yubikit/core/smartcard.py deleted file mode 100644 index 7df4ee77..00000000 --- a/yubikit/core/smartcard.py +++ /dev/null @@ -1,237 +0,0 @@ -# Copyright (c) 2020 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 . import ( - Version, - TRANSPORT, - USB_INTERFACE, - Connection, - CommandError, - ApplicationNotAvailableError, -) -from time import time -from enum import Enum, IntEnum, unique -from typing import Tuple -import abc -import struct -import logging - -logger = logging.getLogger(__name__) - - -class ApduError(CommandError): - """Thrown when an APDU response has the wrong SW code""" - - def __init__(self, data: bytes, sw: int): - self.data = data - self.sw = sw - - def __str__(self): - return f"APDU error: SW=0x{self.sw:04x}" - - -@unique -class ApduFormat(str, Enum): - """APDU encoding format""" - - SHORT = "short" - EXTENDED = "extended" - - -@unique -class AID(bytes, Enum): - """YubiKey Application smart card AID values.""" - - OTP = bytes.fromhex("a0000005272001") - MANAGEMENT = bytes.fromhex("a000000527471117") - OPENPGP = bytes.fromhex("d27600012401") - OATH = bytes.fromhex("a0000005272101") - PIV = bytes.fromhex("a000000308") - FIDO = bytes.fromhex("a0000006472f0001") - HSMAUTH = bytes.fromhex("a000000527210701") - - -@unique -class SW(IntEnum): - NO_INPUT_DATA = 0x6285 - VERIFY_FAIL_NO_RETRY = 0x63C0 - MEMORY_FAILURE = 0x6581 - WRONG_LENGTH = 0x6700 - SECURITY_CONDITION_NOT_SATISFIED = 0x6982 - AUTH_METHOD_BLOCKED = 0x6983 - DATA_INVALID = 0x6984 - CONDITIONS_NOT_SATISFIED = 0x6985 - COMMAND_NOT_ALLOWED = 0x6986 - INCORRECT_PARAMETERS = 0x6A80 - FUNCTION_NOT_SUPPORTED = 0x6A81 - FILE_NOT_FOUND = 0x6A82 - NO_SPACE = 0x6A84 - REFERENCE_DATA_NOT_FOUND = 0x6A88 - APPLET_SELECT_FAILED = 0x6999 - WRONG_PARAMETERS_P1P2 = 0x6B00 - INVALID_INSTRUCTION = 0x6D00 - COMMAND_ABORTED = 0x6F00 - OK = 0x9000 - - -class SmartCardConnection(Connection, metaclass=abc.ABCMeta): - usb_interface = USB_INTERFACE.CCID - - @property - @abc.abstractmethod - def transport(self) -> TRANSPORT: - """Get the transport type of the connection (USB or NFC)""" - - @abc.abstractmethod - def send_and_receive(self, apdu: bytes) -> Tuple[bytes, int]: - """Sends a command APDU and returns the response""" - - -INS_SELECT = 0xA4 -P1_SELECT = 0x04 -P2_SELECT = 0x00 - -INS_SEND_REMAINING = 0xC0 -SW1_HAS_MORE_DATA = 0x61 - -SHORT_APDU_MAX_CHUNK = 0xFF - - -def _encode_short_apdu(cla, ins, p1, p2, data, le=0): - buf = struct.pack(">BBBBB", cla, ins, p1, p2, len(data)) + data - if le: - buf += struct.pack(">B", le) - return buf - - -def _encode_extended_apdu(cla, ins, p1, p2, data, le=0): - buf = struct.pack(">BBBBBH", cla, ins, p1, p2, 0, len(data)) + data - if le: - buf += struct.pack(">H", le) - return buf - - -class SmartCardProtocol: - """An implementation of the Smart Card protocol.""" - - def __init__( - self, - smartcard_connection: SmartCardConnection, - ins_send_remaining: int = INS_SEND_REMAINING, - ): - self.apdu_format = ApduFormat.SHORT - self.connection = smartcard_connection - self._ins_send_remaining = ins_send_remaining - self._touch_workaround = False - self._last_long_resp = 0.0 - - def close(self) -> None: - self.connection.close() - - def enable_touch_workaround(self, version: Version) -> None: - self._touch_workaround = self.connection.transport == TRANSPORT.USB and ( - (4, 2, 0) <= version <= (4, 2, 6) - ) - logger.debug(f"Touch workaround enabled={self._touch_workaround}") - - def select(self, aid: bytes) -> bytes: - """Perform a SELECT instruction. - - :param aid: The YubiKey application AID value. - """ - try: - return self.send_apdu(0, INS_SELECT, P1_SELECT, P2_SELECT, aid) - except ApduError as e: - if e.sw in ( - SW.FILE_NOT_FOUND, - SW.APPLET_SELECT_FAILED, - SW.INVALID_INSTRUCTION, - SW.WRONG_PARAMETERS_P1P2, - ): - raise ApplicationNotAvailableError() - raise - - def send_apdu( - self, cla: int, ins: int, p1: int, p2: int, data: bytes = b"", le: int = 0 - ) -> bytes: - """Send APDU message. - - :param cla: The instruction class. - :param ins: The instruction code. - :param p1: The instruction parameter. - :param p2: The instruction parameter. - :param data: The command data in bytes. - :param le: The maximum number of bytes in the data - field of the response. - """ - if ( - self._touch_workaround - and self._last_long_resp > 0 - and time() - self._last_long_resp < 2 - ): - logger.debug("Sending dummy APDU as touch workaround") - self.connection.send_and_receive( - _encode_short_apdu(0, 0, 0, 0, b"") - ) # Dummy APDU, returns error - self._last_long_resp = 0 - - if self.apdu_format is ApduFormat.SHORT: - while len(data) > SHORT_APDU_MAX_CHUNK: - chunk, data = data[:SHORT_APDU_MAX_CHUNK], data[SHORT_APDU_MAX_CHUNK:] - response, sw = self.connection.send_and_receive( - _encode_short_apdu(0x10 | cla, ins, p1, p2, chunk, le) - ) - if sw != SW.OK: - raise ApduError(response, sw) - response, sw = self.connection.send_and_receive( - _encode_short_apdu(cla, ins, p1, p2, data, le) - ) - get_data = _encode_short_apdu(0, self._ins_send_remaining, 0, 0, b"") - elif self.apdu_format is ApduFormat.EXTENDED: - response, sw = self.connection.send_and_receive( - _encode_extended_apdu(cla, ins, p1, p2, data, le) - ) - get_data = _encode_extended_apdu(0, self._ins_send_remaining, 0, 0, b"") - else: - raise TypeError("Invalid ApduFormat set") - - # Read chained response - buf = b"" - while sw >> 8 == SW1_HAS_MORE_DATA: - buf += response - response, sw = self.connection.send_and_receive(get_data) - - if sw != SW.OK: - raise ApduError(response, sw) - buf += response - - if self._touch_workaround and len(buf) > 54: - self._last_long_resp = time() - else: - self._last_long_resp = 0 - - return buf diff --git a/yubikit/core/smartcard/__init__.py b/yubikit/core/smartcard/__init__.py new file mode 100644 index 00000000..ed7a0172 --- /dev/null +++ b/yubikit/core/smartcard/__init__.py @@ -0,0 +1,420 @@ +# Copyright (c) 2020 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 .. import ( + Version, + TRANSPORT, + USB_INTERFACE, + Connection, + NotSupportedError, + ApplicationNotAvailableError, + CommandError, + BadResponseError, +) +from .scp import ( + ScpState, + ScpKeyParams, + Scp03KeyParams, + Scp11KeyParams, + INS_EXTERNAL_AUTHENTICATE, +) +from enum import Enum, IntEnum, unique +from time import time +from typing import Tuple +import abc +import struct +import logging +import warnings + + +__all__ = ["ApduError", "ApduFormat", "SW", "AID"] + +logger = logging.getLogger(__name__) + + +class SmartCardConnection(Connection, metaclass=abc.ABCMeta): + usb_interface = USB_INTERFACE.CCID + + @property + @abc.abstractmethod + def transport(self) -> TRANSPORT: + """Get the transport type of the connection (USB or NFC)""" + + @abc.abstractmethod + def send_and_receive(self, apdu: bytes) -> Tuple[bytes, int]: + """Sends a command APDU and returns the response""" + + +class ApduError(CommandError): + """Thrown when an APDU response has the wrong SW code""" + + def __init__(self, data: bytes, sw: int): + self.data = data + self.sw = sw + + def __str__(self): + return f"APDU error: SW=0x{self.sw:04x}" + + +@unique +class ApduFormat(str, Enum): + """APDU encoding format""" + + SHORT = "short" + EXTENDED = "extended" + + +@unique +class AID(bytes, Enum): + """YubiKey Application smart card AID values.""" + + OTP = bytes.fromhex("a0000005272001") + MANAGEMENT = bytes.fromhex("a000000527471117") + OPENPGP = bytes.fromhex("d27600012401") + OATH = bytes.fromhex("a0000005272101") + PIV = bytes.fromhex("a000000308") + FIDO = bytes.fromhex("a0000006472f0001") + HSMAUTH = bytes.fromhex("a000000527210701") + SECURE_DOMAIN = bytes.fromhex("a000000151000000") + + +@unique +class SW(IntEnum): + NO_INPUT_DATA = 0x6285 + VERIFY_FAIL_NO_RETRY = 0x63C0 + MEMORY_FAILURE = 0x6581 + WRONG_LENGTH = 0x6700 + SECURITY_CONDITION_NOT_SATISFIED = 0x6982 + AUTH_METHOD_BLOCKED = 0x6983 + DATA_INVALID = 0x6984 + CONDITIONS_NOT_SATISFIED = 0x6985 + COMMAND_NOT_ALLOWED = 0x6986 + INCORRECT_PARAMETERS = 0x6A80 + FUNCTION_NOT_SUPPORTED = 0x6A81 + FILE_NOT_FOUND = 0x6A82 + NO_SPACE = 0x6A84 + REFERENCE_DATA_NOT_FOUND = 0x6A88 + APPLET_SELECT_FAILED = 0x6999 + WRONG_PARAMETERS_P1P2 = 0x6B00 + INVALID_INSTRUCTION = 0x6D00 + CLASS_NOT_SUPPORTED = 0x6E00 + COMMAND_ABORTED = 0x6F00 + OK = 0x9000 + + +INS_SELECT = 0xA4 +P1_SELECT = 0x04 +P2_SELECT = 0x00 + +INS_SEND_REMAINING = 0xC0 + + +class ApduProcessor(abc.ABC): + @abc.abstractmethod + def send_apdu( + self, + cla: int, + ins: int, + p1: int, + p2: int, + data: bytes, + le: int, + ) -> Tuple[bytes, int]: + ... + + +class ApduFormatProcessor(ApduProcessor): + def __init__(self, connection: SmartCardConnection): + self.connection = connection + + def send_apdu(self, cla, ins, p1, p2, data, le): + apdu = self.format_apdu(cla, ins, p1, p2, data, le) + return self.connection.send_and_receive(apdu) + + @abc.abstractmethod + def format_apdu( + self, cla: int, ins: int, p1: int, p2: int, data: bytes, le: int + ) -> bytes: + ... + + +SHORT_APDU_MAX_CHUNK = 0xFF + + +class ShortApduProcessor(ApduFormatProcessor): + def format_apdu(self, cla, ins, p1, p2, data, le): + buf = struct.pack(">BBBBB", cla, ins, p1, p2, len(data)) + data + if le: + buf += struct.pack(">B", le) + return buf + + def send_apdu(self, cla, ins, p1, p2, data, le): + while len(data) > SHORT_APDU_MAX_CHUNK: + chunk, data = ( + data[:SHORT_APDU_MAX_CHUNK], + data[SHORT_APDU_MAX_CHUNK:], + ) + apdu = self.format_apdu(0x10 | cla, ins, p1, p2, chunk, le) + response, sw = self.connection.send_and_receive(apdu) + if sw != SW.OK: + return response, sw + return super().send_apdu(cla, ins, p1, p2, data, le) + + +class ExtendedApduProcessor(ApduFormatProcessor): + def format_apdu(self, cla, ins, p1, p2, data, le): + buf = struct.pack(">BBBBBH", cla, ins, p1, p2, 0, len(data)) + data + if le: + buf += struct.pack(">H", le) + return buf + + +SW1_HAS_MORE_DATA = 0x61 + + +class ChainedResponseProcessor(ApduProcessor): + def __init__( + self, + connection: SmartCardConnection, + extended_apdus: bool = True, + ins_send_remaining: int = INS_SEND_REMAINING, + ): + self.connection = connection + self.processor = ( + ExtendedApduProcessor(connection) + if extended_apdus + else ShortApduProcessor(connection) + ) + self._get_data = self.processor.format_apdu(0, ins_send_remaining, 0, 0, b"", 0) + + def send_apdu(self, cla, ins, p1, p2, data, le): + response, sw = self.processor.send_apdu(cla, ins, p1, p2, data, le) + + # Read chained response + buf = b"" + while sw >> 8 == SW1_HAS_MORE_DATA: + buf += response + response, sw = self.connection.send_and_receive(self._get_data) + + buf += response + return buf, sw + + +class TouchWorkaroundProcessor(ChainedResponseProcessor): + def __init__( + self, + connection: SmartCardConnection, + ins_send_remaining: int = INS_SEND_REMAINING, + ): + super().__init__(connection, ins_send_remaining=ins_send_remaining) + self._last_long_resp = 0.0 + + def send_apdu(self, cla, ins, p1, p2, data, le): + if self._last_long_resp > 0 and time() - self._last_long_resp < 2: + logger.debug("Sending dummy APDU as touch workaround") + # Dummy APDU, returns error + super().send_apdu(0, 0, 0, 0, b"", 0) + self._last_long_resp = 0 + + resp, sw = super().send_apdu(cla, ins, p1, p2, data, le) + + if len(resp) > 54: + self._last_long_resp = time() + else: + self._last_long_resp = 0 + + return resp, sw + + +class ScpProcessor(ChainedResponseProcessor): + def __init__( + self, + connection: SmartCardConnection, + scp_state: ScpState, + ins_send_remaining: int = INS_SEND_REMAINING, + ): + super().__init__(connection, ins_send_remaining=ins_send_remaining) + self._state = scp_state + + def send_apdu(self, cla, ins, p1, p2, data, le, encrypt: bool = True): + cla |= 0x04 + + if encrypt: + data = self._state.encrypt(data) + + # Calculate and add MAC to data + apdu = self.processor.format_apdu(cla, ins, p1, p2, data + b"\0" * 8, le) + mac = self._state.mac(apdu[:-8]) + data = data + mac + + resp, sw = super().send_apdu(cla, ins, p1, p2, data, le) + + # Un-MAC and decrypt, if needed + if resp: + resp = self._state.unmac(resp, sw) + if resp: + resp = self._state.decrypt(resp) + + return resp, sw + + +class SmartCardProtocol: + """An implementation of the Smart Card protocol.""" + + def __init__( + self, + smartcard_connection: SmartCardConnection, + ins_send_remaining: int = INS_SEND_REMAINING, + ): + self.connection = smartcard_connection + self._apdu_format = ApduFormat.SHORT + self._ins_send_remaining = ins_send_remaining + self._processor = ChainedResponseProcessor( + self.connection, False, ins_send_remaining + ) + + @property + def apdu_format(self) -> ApduFormat: + warnings.warn( + "Deprecated: do not read apdu_format.", + DeprecationWarning, + ) + + return self._apdu_format + + @apdu_format.setter + def apdu_format(self, value) -> None: + if value == self._apdu_format: + return + if value != ApduFormat.EXTENDED: + raise ValueError(f"Cannot change to {value}") + self._processor = ChainedResponseProcessor( + self.connection, ins_send_remaining=self._ins_send_remaining + ) + self._apdu_format = value + + def close(self) -> None: + self.connection.close() + + def enable_touch_workaround(self, version: Version) -> None: + if self.connection.transport == TRANSPORT.USB and ( + (4, 2, 0) <= version <= (4, 2, 6) + ): + self._processor = TouchWorkaroundProcessor( + self.connection, self._ins_send_remaining + ) + logger.debug("Touch workaround enabled") + + def send_apdu( + self, + cla: int, + ins: int, + p1: int, + p2: int, + data: bytes = b"", + le: int = 0, + ) -> bytes: + """Send APDU message. + + :param cla: The instruction class. + :param ins: The instruction code. + :param p1: The instruction parameter. + :param p2: The instruction parameter. + :param data: The command data in bytes. + :param le: The maximum number of bytes in the data + field of the response. + """ + resp, sw = self._processor.send_apdu(cla, ins, p1, p2, data, le) + + if sw != SW.OK: + raise ApduError(resp, sw) + + return resp + + def select(self, aid: bytes) -> bytes: + """Perform a SELECT instruction. + + :param aid: The YubiKey application AID value. + """ + logger.debug(f"Selecting AID: {aid.hex()}") + self._processor = ChainedResponseProcessor( + self.connection, + self._apdu_format == ApduFormat.EXTENDED, + self._ins_send_remaining, + ) + + try: + return self.send_apdu(0, INS_SELECT, P1_SELECT, P2_SELECT, aid) + except ApduError as e: + if e.sw in ( + SW.FILE_NOT_FOUND, + SW.APPLET_SELECT_FAILED, + SW.INVALID_INSTRUCTION, + SW.WRONG_PARAMETERS_P1P2, + ): + raise ApplicationNotAvailableError() + raise + + def init_scp(self, key_params: ScpKeyParams) -> None: + try: + if isinstance(key_params, Scp03KeyParams): + self._scp03_init(key_params) + elif isinstance(key_params, Scp11KeyParams): + self._scp11_init(key_params) + else: + raise ValueError("Unsupported ScpKeyParams") + except ApduError as e: + if e.sw == SW.CLASS_NOT_SUPPORTED: + raise NotSupportedError( + "This YubiKey does not support secure messaging" + ) + if e.sw == SW.REFERENCE_DATA_NOT_FOUND: + raise ValueError("Incorrect SCP parameters") + raise + except BadResponseError: + raise ValueError("Incorrect SCP parameters") + + def _scp03_init(self, key_params: Scp03KeyParams) -> None: + logger.debug("Initializing SCP03") + scp, host_cryptogram = ScpState.scp03_init(self.send_apdu, key_params) + processor = ScpProcessor(self.connection, scp, self._ins_send_remaining) + + # Send EXTERNAL AUTHENTICATE + # P1 = C-DECRYPTION, R-ENCRYPTION, C-MAC, and R-MAC + processor.send_apdu( + 0x84, INS_EXTERNAL_AUTHENTICATE, 0x33, 0, host_cryptogram, 0, encrypt=False + ) + self._processor = processor + self._apdu_format = ApduFormat.EXTENDED + logger.info("SCP03 initialized") + + def _scp11_init(self, key_params: Scp11KeyParams) -> None: + logger.debug("Initializing SCP11") + scp = ScpState.scp11_init(self.send_apdu, key_params) + self._processor = ScpProcessor(self.connection, scp, self._ins_send_remaining) + self._apdu_format = ApduFormat.EXTENDED + logger.info("SCP11 initialized") diff --git a/yubikit/core/smartcard/scp.py b/yubikit/core/smartcard/scp.py new file mode 100644 index 00000000..86adab7f --- /dev/null +++ b/yubikit/core/smartcard/scp.py @@ -0,0 +1,371 @@ +# 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 .. import Tlv, BadResponseError +from cryptography import x509 +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import cmac, hashes, serialization, constant_time +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.kdf.x963kdf import X963KDF +from dataclasses import dataclass, field +from enum import IntEnum, unique +from typing import NamedTuple, Tuple, Optional, Callable, Sequence, Union + +import os +import abc +import struct +import logging + +logger = logging.getLogger(__name__) + + +INS_INITIALIZE_UPDATE = 0x50 +INS_EXTERNAL_AUTHENTICATE = 0x82 +INS_INTERNAL_AUTHENTICATE = 0x88 +INS_PERFORM_SECURITY_OPERATION = 0x2A + +_DEFAULT_KEY = b"\x40\x41\x42\x43\x44\x45\x46\x47\x48\x49\x4a\x4b\x4c\x4d\x4e\x4f" + +_KEY_ENC = 0x04 +_KEY_MAC = 0x06 +_KEY_RMAC = 0x07 +_CARD_CRYPTOGRAM = 0x00 +_HOST_CRYPTOGRAM = 0x01 + + +def _derive(key: bytes, t: int, context: bytes, L: int = 0x80) -> bytes: + # this only supports aes128 + if L != 0x80 and L != 0x40: + raise ValueError("L must be 0x40 or 0x80") + + i = b"\0" * 11 + struct.pack("!BBHB", t, 0, L, 1) + context + + c = cmac.CMAC(algorithms.AES(key), backend=default_backend()) + c.update(i) + return c.finalize()[: L // 8] + + +def _calculate_mac(key: bytes, chain: bytes, message: bytes) -> Tuple[bytes, bytes]: + c = cmac.CMAC(algorithms.AES(key), backend=default_backend()) + c.update(chain) + c.update(message) + chain = c.finalize() + return chain, chain[:8] + + +def _init_cipher(key: bytes, counter: int, response=False) -> Cipher: + encryptor = Cipher( + algorithms.AES(key), modes.ECB(), backend=default_backend() # nosec ECB + ).encryptor() + iv_data = (b"\x80" if response else b"\x00") + int.to_bytes(counter, 15, "big") + iv = encryptor.update(iv_data) + encryptor.finalize() + return Cipher( + algorithms.AES(key), + modes.CBC(iv), + backend=default_backend(), + ) + + +class SessionKeys(NamedTuple): + """SCP Session Keys.""" + + key_senc: bytes + key_smac: bytes + key_srmac: bytes + key_dek: Optional[bytes] = None + + +class StaticKeys(NamedTuple): + """SCP03 Static Keys.""" + + key_enc: bytes + key_mac: bytes + key_dek: Optional[bytes] = None + + @classmethod + def default(cls) -> "StaticKeys": + return cls(_DEFAULT_KEY, _DEFAULT_KEY, _DEFAULT_KEY) + + def derive(self, context: bytes) -> SessionKeys: + return SessionKeys( + _derive(self.key_enc, _KEY_ENC, context), + _derive(self.key_mac, _KEY_MAC, context), + _derive(self.key_mac, _KEY_RMAC, context), + self.key_dek, + ) + + +@unique +class ScpKid(IntEnum): + SCP03 = 0x1 + SCP11a = 0x11 + SCP11b = 0x13 + SCP11c = 0x15 + + +class KeyRef(bytes): + @property + def kid(self) -> int: + return self[0] + + @property + def kvn(self) -> int: + return self[1] + + def __new__(cls, kid_or_data: Union[int, bytes], kvn: Optional[int] = None): + """This allows creation by passing either binary data, or kid and kvn.""" + if isinstance(kid_or_data, int): # kid and kvn + if kvn is None: + raise ValueError("Missing kvn") + data = bytes([kid_or_data, kvn]) + else: # Binary id and version + if kvn is not None: + raise ValueError("kvn can only be provided if kid_or_data is a kid") + data = kid_or_data + + # mypy thinks this is wrong + return super(KeyRef, cls).__new__(cls, data) # type: ignore + + def __init__(self, kid_or_data: Union[int, bytes], kvn: Optional[int] = None): + if len(self) != 2: + raise ValueError("Incorrect length") + + def __repr__(self): + return f"KeyRef(kid=0x{self.kid:02x}, kvn=0x{self.kvn:02x})" + + def __str__(self): + return repr(self) + + +@dataclass(frozen=True) +class ScpKeyParams(abc.ABC): + ref: KeyRef + + +@dataclass(frozen=True) +class Scp03KeyParams(ScpKeyParams): + ref: KeyRef = KeyRef(ScpKid.SCP03, 0) + keys: StaticKeys = StaticKeys.default() + + +@dataclass(frozen=True) +class Scp11KeyParams(ScpKeyParams): + pk_sd_ecka: ec.EllipticCurvePublicKey + # For SCP11 a/c we need an OCE key, with its trust chain + oce_ref: Optional[KeyRef] = None + sk_oce_ecka: Optional[ec.EllipticCurvePrivateKey] = None + # Certificate chain for sk_oce_ecka, leaf-last order + certificates: Sequence[x509.Certificate] = field(default_factory=list) + + +SendApdu = Callable[[int, int, int, int, bytes], bytes] + + +class ScpState: + def __init__( + self, + session_keys: SessionKeys, + mac_chain: bytes = b"\0" * 16, + enc_counter: int = 1, + ): + self._keys = session_keys + self._mac_chain = mac_chain + self._enc_counter = enc_counter + + def encrypt(self, data: bytes) -> bytes: + # Pad the data + msg = data + padlen = 15 - len(msg) % 16 + msg += b"\x80" + msg = msg.ljust(len(msg) + padlen, b"\0") + + # Encrypt + cipher = _init_cipher(self._keys.key_senc, self._enc_counter) + encryptor = cipher.encryptor() + encrypted = encryptor.update(msg) + encryptor.finalize() + self._enc_counter += 1 + return encrypted + + def mac(self, data: bytes) -> bytes: + next_mac_chain, mac = _calculate_mac(self._keys.key_smac, self._mac_chain, data) + self._mac_chain = next_mac_chain + return mac + + def unmac(self, data: bytes, sw: int) -> bytes: + msg, mac = data[:-8], data[-8:] + rmac = _calculate_mac( + self._keys.key_srmac, self._mac_chain, msg + struct.pack("!H", sw) + )[1] + if not constant_time.bytes_eq(mac, rmac): + raise BadResponseError("Wrong MAC") + return msg + + def decrypt(self, encrypted: bytes) -> bytes: + # Decrypt + cipher = _init_cipher(self._keys.key_senc, self._enc_counter - 1, True) + decryptor = cipher.decryptor() + decrypted = decryptor.update(encrypted) + decryptor.finalize() + + # Unpad + unpadded = decrypted.rstrip(b"\x00") + if unpadded[-1] != 0x80: + raise BadResponseError("Wrong padding") + unpadded = unpadded[:-1] + + return unpadded + + @classmethod + def scp03_init( + cls, + send_apdu: SendApdu, + key_params: Scp03KeyParams, + *, + host_challenge: Optional[bytes] = None, + ) -> Tuple["ScpState", bytes]: + logger.debug("Initializing SCP03 handshake") + host_challenge = host_challenge or os.urandom(8) + resp = send_apdu( + 0x80, INS_INITIALIZE_UPDATE, key_params.ref.kvn, 0x00, host_challenge + ) + + diversification_data = resp[:10] # noqa: unused + key_info = resp[10:13] # noqa: unused + card_challenge = resp[13:21] + card_cryptogram = resp[21:29] + + context = host_challenge + card_challenge + session_keys = key_params.keys.derive(context) + + gen_card_crypto = _derive( + session_keys.key_smac, _CARD_CRYPTOGRAM, context, 0x40 + ) + if not constant_time.bytes_eq(gen_card_crypto, card_cryptogram): + # This means wrong keys + raise BadResponseError("Wrong SCP03 key set") + + host_cryptogram = _derive( + session_keys.key_smac, _HOST_CRYPTOGRAM, context, 0x40 + ) + + return cls(session_keys), host_cryptogram + + @classmethod + def scp11_init( + cls, + send_apdu: SendApdu, + key_params: Scp11KeyParams, + ) -> "ScpState": + kid = ScpKid(key_params.ref.kid) + logger.debug(f"Initializing {kid.name} handshake") + + # GPC v2.3 Amendment F (SCP11) v1.4 §7.1.1 + if kid == ScpKid.SCP11a: + params = 0b01 + elif kid == ScpKid.SCP11b: + params = 0b00 + elif kid == ScpKid.SCP11c: + params = 0b11 + else: + raise ValueError("Invalid SCP KID") + + if kid in (ScpKid.SCP11a, ScpKid.SCP11c): + # GPC v2.3 Amendment F (SCP11) v1.4 §7.5 + assert key_params.sk_oce_ecka # nosec + n = len(key_params.certificates) - 1 + assert n >= 0 # nosec + oce_ref = key_params.oce_ref or KeyRef(0, 0) + logger.debug("Sending certificate chain") + for i, cert in enumerate(key_params.certificates): + p2 = oce_ref.kid | (0x80 if i < n else 0) + data = cert.public_bytes(serialization.Encoding.DER) + logger.debug(f"Sending cert: {cert.subject}") + send_apdu(0x80, INS_PERFORM_SECURITY_OPERATION, oce_ref.kvn, p2, data) + + key_usage = bytes( + [0x3C] + ) # AUTHENTICATED | C_MAC | C_DECRYPTION | R_MAC | R_ENCRYPTION + key_type = bytes([0x88]) # AES + key_len = bytes([16]) # 128-bit + + # Host ephemeral key + esk_oce_ecka = ec.generate_private_key(key_params.pk_sd_ecka.curve) + epk_oce_ecka = esk_oce_ecka.public_key() + + # GPC v2.3 Amendment F (SCP11) v1.4 §7.6.2.3 + data = Tlv( + 0xA6, + Tlv(0x90, bytes([0x11, params])) + + Tlv(0x95, key_usage) + + Tlv(0x80, key_type) + + Tlv(0x81, key_len), + ) + Tlv( + 0x5F49, + epk_oce_ecka.public_bytes( + serialization.Encoding.X962, + serialization.PublicFormat.UncompressedPoint, + ), + ) + + # Static host key (SCP11a/c), or ephemeral key again (SCP11b) + sk_oce_ecka = key_params.sk_oce_ecka or esk_oce_ecka + + logger.debug("Performing key agreement") + ins = ( + INS_INTERNAL_AUTHENTICATE + if key_params.ref.kid == ScpKid.SCP11b + else INS_EXTERNAL_AUTHENTICATE + ) + resp = send_apdu(0x80, ins, key_params.ref.kvn, key_params.ref.kid, data) + + epk_sd_ecka_tlv, resp = Tlv.parse_from(resp) + epk_sd_ecka = Tlv.unpack(0x5F49, epk_sd_ecka_tlv) + receipt = Tlv.unpack(0x86, resp) + + # GPC v2.3 Amendment F (SCP11) v1.3 §3.1.2 Key Derivation + key_agreement_data = data + epk_sd_ecka_tlv + sharedinfo = key_usage + key_type + key_len + keys = X963KDF(hashes.SHA256(), 5 * key_len[0], sharedinfo).derive( + esk_oce_ecka.exchange( + ec.ECDH(), + ec.EllipticCurvePublicKey.from_encoded_point( + sk_oce_ecka.curve, epk_sd_ecka + ), + ) + + sk_oce_ecka.exchange(ec.ECDH(), key_params.pk_sd_ecka) + ) + + # 5 keys were derived, one for verification of receipt + ln = key_len[0] + keys = [keys[i : i + ln] for i in range(0, ln * 5, ln)] + c = cmac.CMAC(algorithms.AES(keys.pop(0))) + c.update(key_agreement_data) + c.verify(receipt) + # The 4 remaining keys are session keys + session_keys = SessionKeys(*keys) + + return cls(session_keys, receipt) diff --git a/yubikit/hsmauth.py b/yubikit/hsmauth.py index 8a98af11..b10963bf 100644 --- a/yubikit/hsmauth.py +++ b/yubikit/hsmauth.py @@ -33,7 +33,14 @@ Tlv, InvalidPinError, ) -from .core.smartcard import AID, SmartCardConnection, SmartCardProtocol, ApduError, SW +from .core.smartcard import ( + AID, + SmartCardConnection, + SmartCardProtocol, + ApduError, + SW, + ScpKeyParams, +) from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes @@ -213,10 +220,17 @@ def parse(cls, response: bytes) -> "SessionKeys": class HsmAuthSession: """A session with the YubiHSM Auth application.""" - def __init__(self, connection: SmartCardConnection) -> None: + def __init__( + self, + connection: SmartCardConnection, + scp_key_params: Optional[ScpKeyParams] = None, + ) -> None: self.protocol = SmartCardProtocol(connection) self._version = _parse_select(self.protocol.select(AID.HSMAUTH)) + if scp_key_params: + self.protocol.init_scp(scp_key_params) + @property def version(self) -> Version: """The YubiHSM Auth application version.""" @@ -599,14 +613,25 @@ def calculate_session_keys_asymmetric( ) ) - def get_challenge(self, label: str) -> bytes: + def get_challenge( + self, label: str, credential_password: Union[bytes, str, None] = None + ) -> bytes: """Get the Host Challenge. - For symmetric credentials this is Host Challenge, a random - 8 byte value. For asymmetric credentials this is EPK-OCE. + For symmetric credentials this is Host Challenge, a random 8 byte value. + For asymmetric credentials this is EPK-OCE. :param label: The label of the credential. + :param credential_password: The password used to protect access to the + credential, needed for asymmetric credentials. """ require_version(self.version, (5, 6, 0)) - data = Tlv(TAG_LABEL, _parse_label(label)) + + data: bytes = Tlv(TAG_LABEL, _parse_label(label)) + + if credential_password is not None and self.version >= (5, 7, 1): + data += Tlv( + TAG_CREDENTIAL_PASSWORD, _parse_credential_password(credential_password) + ) + return self.protocol.send_apdu(0, INS_GET_CHALLENGE, 0, 0, data) diff --git a/yubikit/management.py b/yubikit/management.py index 5ba57ce5..2d54ef7c 100644 --- a/yubikit/management.py +++ b/yubikit/management.py @@ -45,7 +45,7 @@ CommandRejectedError, ) from .core.fido import FidoConnection -from .core.smartcard import AID, SmartCardConnection, SmartCardProtocol +from .core.smartcard import AID, SmartCardConnection, SmartCardProtocol, ScpKeyParams from fido2.hid import CAPABILITY as CTAP_CAPABILITY from enum import IntEnum, IntFlag, unique @@ -74,6 +74,32 @@ def __str__(self): name = "|".join(c.name or str(c) for c in CAPABILITY if c in self) return f"{name}: {hex(self)}" + @classmethod + def _from_fips(cls, fips: int) -> "CAPABILITY": + c = CAPABILITY(0) + if fips & (1 << 0): + c |= CAPABILITY.FIDO2 + if fips & (1 << 1): + c |= CAPABILITY.PIV + if fips & (1 << 2): + c |= CAPABILITY.OPENPGP + if fips & (1 << 3): + c |= CAPABILITY.OATH + if fips & (1 << 4): + c |= CAPABILITY.HSMAUTH + return c + + @classmethod + def _from_aid(cls, aid: AID) -> "CAPABILITY": + # TODO: match on prefix? + try: + return getattr(CAPABILITY, aid.name) + except AttributeError: + pass + if aid == AID.FIDO: + return CAPABILITY.FIDO2 + raise ValueError("Unhandled AID") + @property def display_name(self) -> str: if self == CAPABILITY.OTP: @@ -174,9 +200,13 @@ class DEVICE_FLAG(IntFlag): TAG_FREE_FORM = 0x11 TAG_HID_INIT_DELAY = 0x12 TAG_PART_NUMBER = 0x13 +TAG_FIPS_CAPABLE = 0x14 +TAG_FIPS_APPROVED = 0x15 TAG_PIN_COMPLEXITY = 0x16 TAG_NFC_RESTRICTED = 0x17 TAG_RESET_BLOCKED = 0x18 +TAG_FPS_VERSION = 0x20 +TAG_STM_VERSION = 0x21 @dataclass @@ -214,8 +244,8 @@ def get_bytes( buf += Tlv(TAG_DEVICE_FLAGS, int2bytes(self.device_flags)) if new_lock_code: buf += Tlv(TAG_CONFIG_LOCK, new_lock_code) - if self.nfc_restricted is not None: - buf += Tlv(TAG_NFC_RESTRICTED, b"\1" if self.nfc_restricted else b"\0") + if self.nfc_restricted: + buf += Tlv(TAG_NFC_RESTRICTED, b"\1") if len(buf) > 0xFF: raise NotSupportedError("DeviceConfiguration too large") return int2bytes(len(buf)) + buf @@ -233,8 +263,13 @@ class DeviceInfo: is_locked: bool is_fips: bool = False is_sky: bool = False + part_number: Optional[str] = None + fips_capable: CAPABILITY = CAPABILITY(0) + fips_approved: CAPABILITY = CAPABILITY(0) pin_complexity: bool = False reset_blocked: CAPABILITY = CAPABILITY(0) + fps_version: Optional[Version] = None + stm_version: Optional[Version] = None def has_transport(self, transport: TRANSPORT) -> bool: return transport in self.supported_capabilities @@ -277,8 +312,20 @@ def parse_tlvs( supported[TRANSPORT.NFC] = CAPABILITY(bytes2int(data[TAG_NFC_SUPPORTED])) enabled[TRANSPORT.NFC] = CAPABILITY(bytes2int(data[TAG_NFC_ENABLED])) nfc_restricted = data.get(TAG_NFC_RESTRICTED, b"\0") == b"\1" + try: + part_number = data.get(TAG_PART_NUMBER, b"").decode() or None + except UnicodeDecodeError: + part_number = None + fips_capable = CAPABILITY._from_fips( + bytes2int(data.get(TAG_FIPS_CAPABLE, b"\0")) + ) + fips_approved = CAPABILITY._from_fips( + bytes2int(data.get(TAG_FIPS_APPROVED, b"\0")) + ) pin_complexity = data.get(TAG_PIN_COMPLEXITY, b"\0") == b"\1" reset_blocked = CAPABILITY(bytes2int(data.get(TAG_RESET_BLOCKED, b"\0"))) + fps_version = Version.from_bytes(data.get(TAG_FPS_VERSION, b"\0\0\0")) + stm_version = Version.from_bytes(data.get(TAG_STM_VERSION, b"\0\0\0")) return cls( DeviceConfig(enabled, auto_eject_to, chal_resp_to, flags, nfc_restricted), @@ -289,8 +336,13 @@ def parse_tlvs( locked, fips, sky, + part_number, + fips_capable, + fips_approved, pin_complexity, reset_blocked, + fps_version or None, + stm_version or None, ) @@ -396,10 +448,14 @@ def write_config(self, config): class _ManagementSmartCardBackend(_Backend): - def __init__(self, smartcard_connection): + def __init__(self, smartcard_connection, scp_key_params): self.protocol = SmartCardProtocol(smartcard_connection) try: select_bytes = self.protocol.select(AID.MANAGEMENT) + + if scp_key_params: + self.protocol.init_scp(scp_key_params) + if select_bytes[-2:] == b"\x90\x00": # YubiKey Edge incorrectly appends SW twice. select_bytes = select_bytes[:-2] @@ -469,13 +525,19 @@ def write_config(self, config): class ManagementSession: def __init__( - self, connection: Union[OtpConnection, SmartCardConnection, FidoConnection] + self, + connection: Union[OtpConnection, SmartCardConnection, FidoConnection], + scp_key_params: Optional[ScpKeyParams] = None, ): if isinstance(connection, OtpConnection): + if scp_key_params: + raise ValueError("SCP can only be used with SmartCardConnection") self.backend: _Backend = _ManagementOtpBackend(connection) elif isinstance(connection, SmartCardConnection): - self.backend = _ManagementSmartCardBackend(connection) + self.backend = _ManagementSmartCardBackend(connection, scp_key_params) elif isinstance(connection, FidoConnection): + if scp_key_params: + raise ValueError("SCP can only be used with SmartCardConnection") self.backend = _ManagementCtapBackend(connection) else: raise TypeError("Unsupported connection type") diff --git a/yubikit/oath.py b/yubikit/oath.py index a8a65e2e..77ce5fbc 100644 --- a/yubikit/oath.py +++ b/yubikit/oath.py @@ -6,7 +6,7 @@ Tlv, BadResponseError, ) -from .core.smartcard import AID, SmartCardConnection, SmartCardProtocol +from .core.smartcard import AID, SmartCardConnection, SmartCardProtocol, ScpKeyParams from urllib.parse import unquote, urlparse, parse_qs from functools import total_ordering @@ -262,11 +262,19 @@ def _format_code(credential, timestamp, truncated): class OathSession: """A session with the OATH application.""" - def __init__(self, connection: SmartCardConnection): + def __init__( + self, + connection: SmartCardConnection, + scp_key_params: Optional[ScpKeyParams] = None, + ): self.protocol = SmartCardProtocol(connection, INS_SEND_REMAINING) self._version, self._salt, self._challenge = _parse_select( self.protocol.select(AID.OATH) ) + + if scp_key_params: + self.protocol.init_scp(scp_key_params) + self._has_key = self._challenge is not None self._device_id = _get_device_id(self._salt) self.protocol.enable_touch_workaround(self._version) diff --git a/yubikit/openpgp.py b/yubikit/openpgp.py index 8ab08227..5011d9dd 100644 --- a/yubikit/openpgp.py +++ b/yubikit/openpgp.py @@ -41,6 +41,7 @@ ApduError, AID, SW, + ScpKeyParams, ) from cryptography import x509 @@ -992,7 +993,11 @@ def _pad_message(attributes, message, hash_algorithm): class OpenPgpSession: """A session with the OpenPGP application.""" - def __init__(self, connection: SmartCardConnection): + def __init__( + self, + connection: SmartCardConnection, + scp_key_params: Optional[ScpKeyParams] = None, + ): self.protocol = SmartCardProtocol(connection) try: self.protocol.select(AID.OPENPGP) @@ -1004,6 +1009,10 @@ def __init__(self, connection: SmartCardConnection): self.protocol.select(AID.OPENPGP) else: raise + + if scp_key_params: + self.protocol.init_scp(scp_key_params) + self._version = self._read_version() self.protocol.enable_touch_workaround(self.version) diff --git a/yubikit/piv.py b/yubikit/piv.py index 32cd0414..984662a7 100755 --- a/yubikit/piv.py +++ b/yubikit/piv.py @@ -42,6 +42,7 @@ ApduFormat, SmartCardConnection, SmartCardProtocol, + ScpKeyParams, ) from cryptography import x509 @@ -61,8 +62,9 @@ from dataclasses import dataclass from enum import Enum, IntEnum, unique -from typing import Optional, Union, Type, cast +from typing import Optional, Union, Type, cast, overload +import warnings import logging import gzip import os @@ -422,7 +424,24 @@ def check_key_support( This method will return None if the key (with PIN and touch policies) is supported, or it will raise a NotSupportedError if it is not. + + THIS FUNCTION IS DEPRECATED! Use PivSession.check_key_support() instead. """ + warnings.warn( + "Deprecated: use PivSession.check_key_support() instead.", + DeprecationWarning, + ) + _do_check_key_support(version, key_type, pin_policy, touch_policy, generate) + + +def _do_check_key_support( + version: Version, + key_type: KEY_TYPE, + pin_policy: PIN_POLICY, + touch_policy: TOUCH_POLICY, + generate: bool = True, + fips_restrictions: bool = False, +) -> None: if version[0] == 0 and version > (0, 1, 3): return # Development build, skip version checks @@ -441,13 +460,11 @@ def check_key_support( raise NotSupportedError("RSA key generation not supported on this YubiKey") # FIPS - if (4, 4, 0) <= version < (4, 5, 0): - if key_type == KEY_TYPE.RSA1024: - raise NotSupportedError("RSA 1024 not supported on YubiKey FIPS (4 Series)") + if fips_restrictions or (4, 4, 0) <= version < (4, 5, 0): + if key_type in (KEY_TYPE.RSA1024, KEY_TYPE.X25519): + raise NotSupportedError("RSA 1024 not supported on YubiKey FIPS") if pin_policy == PIN_POLICY.NEVER: - raise NotSupportedError( - "PIN_POLICY.NEVER not allowed on YubiKey FIPS (4 Series)" - ) + raise NotSupportedError("PIN_POLICY.NEVER not allowed on YubiKey FIPS") # New key types if version < (5, 7, 0) and key_type in ( @@ -458,12 +475,6 @@ def check_key_support( ): raise NotSupportedError(f"{key_type} requires YubiKey 5.7 or later") - # TODO: Detect Bio capabilities - if version < () and pin_policy in (PIN_POLICY.MATCH_ONCE, PIN_POLICY.MATCH_ALWAYS): - raise NotSupportedError( - "Biometric match PIN policy requires YubiKey 5.6 or later" - ) - def _parse_device_public_key(key_type, encoded): data = Tlv.parse_dict(encoded) @@ -487,15 +498,28 @@ def _parse_device_public_key(key_type, encoded): class PivSession: """A session with the PIV application.""" - def __init__(self, connection: SmartCardConnection): + def __init__( + self, + connection: SmartCardConnection, + scp_key_params: Optional[ScpKeyParams] = None, + ): self.protocol = SmartCardProtocol(connection) self.protocol.select(AID.PIV) + + if scp_key_params: + self.protocol.init_scp(scp_key_params) + self._version = Version.from_bytes( self.protocol.send_apdu(0, INS_GET_VERSION, 0, 0) ) self.protocol.enable_touch_workaround(self.version) if self.version >= (4, 0, 0): self.protocol.apdu_format = ApduFormat.EXTENDED + + try: + self._management_key_type = self.get_management_key_metadata().key_type + except NotSupportedError: + self._management_key_type = MANAGEMENT_KEY_TYPE.TDES self._current_pin_retries = 3 self._max_pin_retries = 3 logger.debug(f"PIV session initialized (version={self.version})") @@ -504,6 +528,10 @@ def __init__(self, connection: SmartCardConnection): def version(self) -> Version: return self._version + @property + def management_key_type(self) -> MANAGEMENT_KEY_TYPE: + return self._management_key_type + def reset(self) -> None: logger.debug("Preparing PIV reset") @@ -546,15 +574,41 @@ def reset(self) -> None: logger.info("PIV application data reset performed") + @overload + def authenticate(self, management_key: bytes) -> None: + ... + + @overload def authenticate( self, key_type: MANAGEMENT_KEY_TYPE, management_key: bytes ) -> None: + ... + + def authenticate(self, *args, **kwargs) -> None: """Authenticate to PIV with management key. - :param key_type: The management key type. - :param management_key: The management key in raw bytes. + :param bytes management_key: The management key in raw bytes. """ - key_type = MANAGEMENT_KEY_TYPE(key_type) + key_type = kwargs.get("key_type") + management_key = kwargs.get("management_key") + if len(args) == 2: + key_type, management_key = args + elif len(args) == 1: + management_key = args[0] + else: + key_type = kwargs.get("key_type") + management_key = kwargs.get("management_key") + if key_type: + warnings.warn( + "Deprecated: call authenticate() without passing management_key_type.", + DeprecationWarning, + ) + if self.management_key_type != key_type: + raise ValueError("Incorrect management key type") + if not isinstance(management_key, bytes): + raise TypeError("management_key must be bytes") + + key_type = self.management_key_type logger.debug(f"Authenticating with key type: {key_type}") response = self.protocol.send_apdu( 0, @@ -615,6 +669,7 @@ def set_management_key( 0xFE if require_touch else 0xFF, int2bytes(key_type) + Tlv(SLOT_CARD_MANAGEMENT, management_key), ) + self._management_key_type = key_type logger.info("Management key set") def verify_pin(self, pin: str) -> None: @@ -633,10 +688,24 @@ def verify_pin(self, pin: str) -> None: self._current_pin_retries = retries raise InvalidPinError(retries) - def verify_uv(self) -> bytes: + def verify_uv( + self, temporary_pin: bool = False, check_only: bool = False + ) -> Optional[bytes]: logger.debug("Verifying UV") + if temporary_pin and check_only: + raise ValueError( + "Cannot request temporary PIN when doing check-only verification" + ) + + if check_only: + data = b"" + elif temporary_pin: + data = Tlv(2) + else: + data = Tlv(3) + try: - return self.protocol.send_apdu(0, INS_VERIFY, 0, SLOT_OCC_AUTH) + response = self.protocol.send_apdu(0, INS_VERIFY, 0, SLOT_OCC_AUTH, data) except ApduError as e: if e.sw == SW.REFERENCE_DATA_NOT_FOUND: raise NotSupportedError( @@ -648,6 +717,7 @@ def verify_uv(self) -> bytes: raise InvalidPinError( retries, f"Fingerprint mismatch, {retries} attempts remaining" ) + return response if temporary_pin else None def verify_temporary_pin(self, pin: bytes) -> None: logger.debug("Verifying temporary PIN") @@ -1009,7 +1079,7 @@ def put_key( """ slot = SLOT(slot) key_type = KEY_TYPE.from_public_key(private_key.public_key()) - check_key_support(self.version, key_type, pin_policy, touch_policy, False) + self.check_key_support(key_type, pin_policy, touch_policy, False) ln = key_type.bit_len // 8 if key_type.algorithm == ALGORITHM.RSA: numbers = private_key.private_numbers() @@ -1065,7 +1135,7 @@ def generate_key( """ slot = SLOT(slot) key_type = KEY_TYPE(key_type) - check_key_support(self.version, key_type, pin_policy, touch_policy, True) + self.check_key_support(key_type, pin_policy, touch_policy, True) data: bytes = Tlv(TAG_GEN_ALGORITHM, int2bytes(key_type)) if pin_policy: data += Tlv(TAG_PIN_POLICY, int2bytes(pin_policy)) @@ -1175,3 +1245,34 @@ def _use_private_key(self, slot, key_type, message, exponentiation): if e.sw == SW.INCORRECT_PARAMETERS: raise e # TODO: Different error, No key? raise + + def check_key_support( + self, + key_type: KEY_TYPE, + pin_policy: PIN_POLICY, + touch_policy: TOUCH_POLICY, + generate: bool, + fips_restrictions: bool = False, + ) -> None: + """Check if a key type is supported by this YubiKey. + + This method will return None if the key (with PIN and touch policies) is + supported, or it will raise a NotSupportedError if it is not. + + Set the generate parameter to True to check if generating the key is supported + (in addition to importing). + + Set fips_restrictions to True to apply restrictions based on FIPS status. + """ + + _do_check_key_support( + self.version, + key_type, + pin_policy, + touch_policy, + generate, + fips_restrictions, + ) + + if pin_policy in (PIN_POLICY.MATCH_ONCE, PIN_POLICY.MATCH_ALWAYS): + self.get_bio_metadata() diff --git a/yubikit/securedomain.py b/yubikit/securedomain.py new file mode 100644 index 00000000..9fd0d7ab --- /dev/null +++ b/yubikit/securedomain.py @@ -0,0 +1,371 @@ +from .core import Tlv, int2bytes, BadResponseError +from .core.smartcard import ( + AID, + SmartCardConnection, + SmartCardProtocol, + ApduError, + SW, + ScpProcessor, +) +from .core.smartcard.scp import ( + INS_INITIALIZE_UPDATE, + INS_EXTERNAL_AUTHENTICATE, + INS_INTERNAL_AUTHENTICATE, + INS_PERFORM_SECURITY_OPERATION, + KeyRef, + ScpKid, + ScpKeyParams, + StaticKeys, +) + +from cryptography import x509 +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.primitives.asymmetric import ec +from typing import Mapping, Sequence, Union, cast +from enum import IntEnum, unique + + +import logging + +logger = logging.getLogger(__name__) + + +INS_GET_DATA = 0xCA +INS_PUT_KEY = 0xD8 +INS_STORE_DATA = 0xE2 +INS_DELETE = 0xE4 +INS_GENERATE_KEY = 0xF1 + +TAG_KEY_INFORMATION = 0xE0 +TAG_CARD_RECOGNITION_DATA = 0x66 +TAG_CA_KLOC_IDENTIFIERS = 0xFF33 +TAG_CA_KLCC_IDENTIFIERS = 0xFF34 +TAG_CERTIFICATE_STORE = 0xBF21 + + +@unique +class KeyType(IntEnum): + AES = 0x88 + ECC_PUBLIC_KEY = 0xB0 + ECC_PRIVATE_KEY = 0xB1 + ECC_KEY_PARAMS = 0xF0 + + +_DEFAULT_KCV_IV = b"\1" * 16 + + +@unique +class Curve(IntEnum): + SECP256R1 = 0x00 + SECP384R1 = 0x01 + SECP521R1 = 0x02 + BrainpoolP256R1 = 0x03 + BrainpoolP384R1 = 0x05 + BrainpoolP512R1 = 0x07 + + @classmethod + def _from_key(cls, private_key: ec.EllipticCurvePrivateKey) -> "Curve": + name = private_key.curve.name.lower() + for curve in cls: + if curve.name.lower() == name: + return curve + raise ValueError("Unsupported private key") + + @property + def _curve(self) -> ec.EllipticCurve: + return getattr(ec, self.name)() + + +def _int2asn1(value: int) -> bytes: + bs = int2bytes(value) + if bs[0] & 0x80: + bs = b"\x00" + bs + return Tlv(0x93, bs) + + +def _encrypt_cbc(key: bytes, data: bytes, iv: bytes = b"\0" * 16) -> bytes: + encryptor = Cipher( + algorithms.AES(key), + # TODO: modes.CBC(iv), + modes.ECB(), # nosec + backend=default_backend(), + ).encryptor() + return encryptor.update(data) + encryptor.finalize() + + +class SecureDomainSession: + """A session for managing SCP keys""" + + def __init__(self, connection: SmartCardConnection): + self.protocol = SmartCardProtocol(connection) + self.protocol.select(AID.SECURE_DOMAIN) + logger.debug("SecureDomain session initialized") + + def authenticate(self, key_params: ScpKeyParams) -> None: + """Initialize SCP and authenticate the session. + + SCP11b does not authenticate the OCE, and will not allow the usage of commands + which require authentication of the OCE. + """ + self.protocol.init_scp(key_params) + + def get_data(self, tag: int, data: bytes = b"") -> bytes: + """Read data from the secure domain.""" + return self.protocol.send_apdu(0, INS_GET_DATA, tag >> 8, tag & 0xFF, data) + + def get_key_information(self) -> Mapping[KeyRef, Mapping[int, int]]: + """Get information about the currently loaded keys.""" + # 11.3.3.1.1 Key Information Template ('E0') + keys = {} + for d in Tlv.parse_list(self.get_data(TAG_KEY_INFORMATION)): + data = Tlv.unpack(0xC0, d) + keys[KeyRef(data[:2])] = dict(zip(data[2::2], data[3::2])) + return keys + + def get_card_recognition_data(self) -> bytes: + """Get information about the card.""" + # 7.4.1.3 Card Recognition Data + return Tlv.unpack(0x73, self.get_data(TAG_CARD_RECOGNITION_DATA)) + + def get_supported_ca_identifiers( + self, kloc: bool = False, klcc: bool = False + ) -> Mapping[KeyRef, bytes]: + """Get a list of the CA issuer Subject Key Identifiers for keys. + + Setting one of kloc or klcc to True will cause only those CAs to be returned. + By default, this will get both KLOC and KLCC CAs. + + :param kloc: Get KLOC CAs. + :param klcc: Get KLCC CAs. + """ + if not kloc and not klcc: + kloc = klcc = True + logger.debug(f"Getting CA identifiers KLOC={kloc}, KLCC={klcc}") + data = b"" + # Combine CA list for KLCC and KLOC + for fetch, tag in ( + (kloc, TAG_CA_KLOC_IDENTIFIERS), + (klcc, TAG_CA_KLCC_IDENTIFIERS), + ): + if fetch: + try: + data += self.get_data(tag) + except ApduError as e: + if e.sw != SW.REFERENCE_DATA_NOT_FOUND: + raise + tlvs = Tlv.parse_list(data) + return { + KeyRef(tlvs[i + 1].value): tlvs[i].value for i in range(0, len(tlvs), 2) + } + + def get_certificate_bundle(self, key: KeyRef) -> Sequence[x509.Certificate]: + """Get the certificates associated with the given SCP11 private key. + + Certificates are returned leaf-last. + """ + logger.debug(f"Getting certificate bundle for {key}") + try: + return [ + x509.load_der_x509_certificate(cert) + for cert in Tlv.parse_list( + self.get_data(TAG_CERTIFICATE_STORE, Tlv(0xA6, Tlv(0x83, key))) + ) + ] + except ApduError as e: + if e.sw == SW.REFERENCE_DATA_NOT_FOUND: + return [] + raise + + def reset(self) -> None: + """Perform a factory reset of the Secure Domain. + + This will remove all keys and associated data, as well as restore the default + SCP03 static keys, and generate a new (attestable) SCP11b key. + """ + logger.debug("Resetting all SCP keys") + # Reset is done by blocking all available keys + data = b"\0" * 8 + for key in self.get_key_information().keys(): + if key.kid == 0x01: + # SCP03 uses KID=0, we use KVN=0 to allow deleting the default keys + # which have an invalid KVN (0xff). + key = KeyRef(0, 0) + ins = INS_INITIALIZE_UPDATE + elif key.kid in (0x02, 0x03): + continue # Skip these, will be deleted by 0x01 + elif key.kid in (0x11, 0x15): + ins = INS_EXTERNAL_AUTHENTICATE + elif key.kid == 0x13: + ins = INS_INTERNAL_AUTHENTICATE + else: # 0x10, 0x20-0x2F + ins = INS_PERFORM_SECURITY_OPERATION + + for _ in range(65): + try: + self.protocol.send_apdu(0x80, ins, key.kvn, key.kid, data) + except ApduError as e: + if e.sw in ( + SW.AUTH_METHOD_BLOCKED, + SW.SECURITY_CONDITION_NOT_SATISFIED, + ): + break + elif e.sw == SW.INCORRECT_PARAMETERS: + continue + raise + logger.info("SCP keys reset") + + def store_data(self, data: bytes) -> None: + """Stores data in the secure domain. + + Requires OCE verification. + """ + self.protocol.send_apdu(0, INS_STORE_DATA, 0x90, 0, data) + + def store_certificate_bundle( + self, key: KeyRef, certificates: Sequence[x509.Certificate] + ) -> None: + """Store the certificate chain for the given key. + + Requires OCE verification. + + Certificates should be in order, with the leaf certificate last. + """ + logger.debug(f"Storing certificate bundle for {key}") + self.store_data( + Tlv(0xA6, Tlv(0x83, key)) + + Tlv( + TAG_CERTIFICATE_STORE, + b"".join( + c.public_bytes(serialization.Encoding.DER) for c in certificates + ), + ) + ) + logger.info("Certificate bundle stored") + + def store_allow_list(self, key: KeyRef, serials: Sequence[int]) -> None: + """Store which certificate serial numbers that can be used for a given key. + + Requires OCE verification. + + If no allowlist is stored, any certificate signed by the CA can be used. + """ + logger.debug(f"Storing serial allowlist for {key}") + self.store_data( + Tlv(0xA6, Tlv(0x83, key)) + + Tlv(0x70, b"".join(_int2asn1(s) for s in serials)) + ) + logger.info("Serial allowlist stored") + + def store_ca_issuer(self, key: KeyRef, ski: bytes) -> None: + """Store the SKI (Subject Key Identifier) for the CA of a given key. + + Requires OCE verification. + """ + logger.debug(f"Storing CA issuer SKI for {key}: {ski.hex()}") + klcc = key.kid in (ScpKid.SCP11a, ScpKid.SCP11b, ScpKid.SCP11c) + self.store_data( + Tlv( + 0xA6, + Tlv(0x80, b"\1" if klcc else b"\0") + Tlv(0x42, ski) + Tlv(0x83, key), + ) + ) + logger.info("CA issuer SKI stored") + + def delete_key(self, kid: int, kvn: int, delete_last: bool = False) -> None: + """Delete one (or more) keys. + + Requires OCE verification. + + All keys matching the given KID and/or KVN will be deleted. + To delete the final key you must set delete_last = True. + """ + if not kid and not kvn: + raise ValueError("Must specify at least one of kid, kvn.") + + logger.debug(f"Deleting keys with KID={kid or 'ANY'}, KVN={kvn or 'ANY'}") + data = b"" + if kid: + data += Tlv(0xD0, bytes([kid])) + if kvn: + data += Tlv(0xD2, bytes([kvn])) + self.protocol.send_apdu(0x80, INS_DELETE, 0, int(delete_last), data) + logger.info("Keys deleted") + + def generate_ec_key( + self, key: KeyRef, curve: Curve = Curve.SECP256R1, replace_kvn: int = 0 + ) -> ec.EllipticCurvePublicKey: + """Generate a new SCP11 key. + + Requires OCE verification. + + Use replace_kvn to replace an existing key. + """ + logger.debug( + f"Generating new key for {key}" + + (f", replacing KVN={replace_kvn}" if replace_kvn else "") + ) + data = bytes([key.kvn]) + Tlv(KeyType.ECC_KEY_PARAMS, bytes([curve])) + resp = self.protocol.send_apdu( + 0x80, INS_GENERATE_KEY, replace_kvn, key.kid, data + ) + encoded_point = Tlv.unpack(KeyType.ECC_PUBLIC_KEY, resp) + logger.info("New key generated") + return ec.EllipticCurvePublicKey.from_encoded_point(curve._curve, encoded_point) + + def put_key( + self, + key: KeyRef, + sk: Union[StaticKeys, ec.EllipticCurvePrivateKey, ec.EllipticCurvePublicKey], + replace_kvn: int = 0, + ) -> None: + """Import an SCP key. + + Requires OCE verification. + + The value of the sk argument should match the SCP type as defined by the KID. + Use replace_kvn to replace an existing key. + """ + logger.debug(f"Importing key into {key} of type {type(sk)}") + processor = self.protocol._processor + if not isinstance(processor, ScpProcessor): + raise ValueError("Must be authenticated!") + + data = bytes([key.kvn]) + expected = data + dek = processor._state._keys.key_dek + p2 = key.kid + if isinstance(sk, StaticKeys): + if not dek: + raise ValueError("No session DEK key available") + if not sk.key_dek: + raise ValueError("New DEK must be set in static keys") + p2 |= 0x80 + for k in cast(Sequence[bytes], sk): + kcv = _encrypt_cbc(k, _DEFAULT_KCV_IV)[:3] + data += Tlv(KeyType.AES, _encrypt_cbc(dek, k)) + bytes([len(kcv)]) + kcv + expected += kcv + else: + if isinstance(sk, ec.EllipticCurvePrivateKey): + if not dek: + raise ValueError("No session DEK key available") + n = (sk.key_size + 7) // 8 + s = int2bytes(sk.private_numbers().private_value, n) + data += Tlv(KeyType.ECC_PRIVATE_KEY, _encrypt_cbc(dek, s)) + elif isinstance(sk, ec.EllipticCurvePublicKey): + data += Tlv( + KeyType.ECC_PUBLIC_KEY, + sk.public_bytes( + serialization.Encoding.X962, + serialization.PublicFormat.UncompressedPoint, + ), + ) + else: + raise TypeError("Unsupported key type") + data += Tlv(KeyType.ECC_KEY_PARAMS, bytes([Curve._from_key(sk)])) + b"\0" + + resp = self.protocol.send_apdu(0x80, INS_PUT_KEY, replace_kvn, p2, data) + if resp != expected: + raise BadResponseError("Incorrect key check value") + logger.info("Key imported") diff --git a/yubikit/support.py b/yubikit/support.py index 29da8811..75315ba9 100644 --- a/yubikit/support.py +++ b/yubikit/support.py @@ -444,6 +444,8 @@ def get_name(info: DeviceInfo, key_type: Optional[YUBIKEY]) -> str: name_parts.append("Bio") if _fido_only(usb_supported): name_parts.append("- FIDO Edition") + elif CAPABILITY.PIV in usb_supported: + name_parts.append("- Multi-protocol Edition") if info.is_fips: name_parts.append("FIPS") if info.is_sky and info.serial: diff --git a/yubikit/yubiotp.py b/yubikit/yubiotp.py index c05b3cd6..2924eeb7 100644 --- a/yubikit/yubiotp.py +++ b/yubikit/yubiotp.py @@ -746,6 +746,13 @@ def __init__(self, connection: Union[OtpConnection, SmartCardConnection]): f"state={self.get_config_state()}" ) + def init_scp03(self): + require_version(self.version, (5, 3, 0)) + backend = self.backend + if isinstance(backend, _YubiOtpSmartCardBackend): + return backend.protocol.init_scp03() + raise NotSupportedError("Requires smart card connection") + def close(self) -> None: self.backend.close()