diff --git a/tests/docker/bookworm/Dockerfile b/tests/docker/bookworm/Dockerfile index 24f6076..1d09095 100644 --- a/tests/docker/bookworm/Dockerfile +++ b/tests/docker/bookworm/Dockerfile @@ -31,7 +31,7 @@ RUN apt install -y libccid \ tpm2-tools \ swtpm \ && rm -rf /var/lib/apt/lists/* -RUN pip3 install pytest pycvc cryptography pyscard --break-system-packages +RUN pip3 install pytest pycvc cryptography pyscard 'yubikey-manager>=5.6,<6' --break-system-packages RUN git clone https://github.com/Yubico/yubico-piv-tool WORKDIR /yubico-piv-tool RUN git checkout tags/yubico-piv-tool-2.5.1 diff --git a/tests/docker/bullseye/Dockerfile b/tests/docker/bullseye/Dockerfile index 322318a..fe77479 100644 --- a/tests/docker/bullseye/Dockerfile +++ b/tests/docker/bullseye/Dockerfile @@ -30,7 +30,7 @@ RUN apt install -y libccid \ libtss2-dev \ tpm2-tools \ && rm -rf /var/lib/apt/lists/* -RUN pip3 install pytest pycvc cryptography pyscard +RUN pip3 install pytest pycvc cryptography pyscard 'yubikey-manager>=5.6,<6' RUN git clone https://github.com/Yubico/yubico-piv-tool WORKDIR /yubico-piv-tool RUN git checkout tags/yubico-piv-tool-2.5.1 diff --git a/tests/docker/jammy/Dockerfile b/tests/docker/jammy/Dockerfile index 677968e..4058f87 100644 --- a/tests/docker/jammy/Dockerfile +++ b/tests/docker/jammy/Dockerfile @@ -26,7 +26,7 @@ RUN apt install -y libccid \ check \ gengetopt \ && rm -rf /var/lib/apt/lists/* -RUN pip3 install pytest pycvc cryptography pyscard +RUN pip3 install pytest pycvc cryptography pyscard 'yubikey-manager>=5.6,<6' RUN git clone https://github.com/Yubico/yubico-piv-tool WORKDIR /yubico-piv-tool RUN git checkout tags/yubico-piv-tool-2.5.1 diff --git a/tests/piv/conftest.py b/tests/piv/conftest.py new file mode 100644 index 0000000..ef04db3 --- /dev/null +++ b/tests/piv/conftest.py @@ -0,0 +1,54 @@ +import pytest + +from card_reader import get_ccid_device + +yubikit_core = pytest.importorskip("yubikit.core") +yubikit_piv = pytest.importorskip("yubikit.piv") +from yubikit.core import TRANSPORT +from yubikit.core.smartcard import ApduError, SW, SmartCardConnection +from yubikit.piv import PivSession + +from piv_helpers import DEFAULT_MANAGEMENT_KEY + + +class PyscardConnection(SmartCardConnection): + @property + def transport(self): + return TRANSPORT.USB + + def __init__(self, reader): + self.reader = reader + + def send_and_receive(self, apdu): + response = self.reader.send_cmd(apdu) + return response[:-2], int.from_bytes(response[-2:], "big") + + def close(self): + self.reader.ccid_power_off() + + +@pytest.fixture(scope="session") +def piv_connection(): + try: + reader = get_ccid_device() + except Exception as error: + pytest.skip(f"PC/SC service or card unavailable: {error}") + connection = PyscardConnection(reader) + yield connection + connection.close() + + +@pytest.fixture(scope="function") +def piv(piv_connection): + try: + return PivSession(piv_connection) + except ApduError as error: + if error.sw in (SW.APPLET_SELECT_FAILED, SW.FILE_NOT_FOUND): + pytest.skip("connected card does not expose the PIV application") + raise + + +@pytest.fixture +def managed_piv(piv): + piv.authenticate(DEFAULT_MANAGEMENT_KEY) + return piv diff --git a/tests/piv/piv_helpers.py b/tests/piv/piv_helpers.py new file mode 100644 index 0000000..ec1d8a7 --- /dev/null +++ b/tests/piv/piv_helpers.py @@ -0,0 +1,23 @@ +import pytest + +from yubikit.core.smartcard import ApduError, SW +from yubikit.piv import DEFAULT_MANAGEMENT_KEY + + +DEFAULT_PIN = "123456" +DEFAULT_PUK = "12345678" + + +def assert_apdu_error(call, status): + with pytest.raises(ApduError) as raised: + call() + assert raised.value.sw == status + + +def delete_key(piv, slot): + try: + piv.authenticate(DEFAULT_MANAGEMENT_KEY) + piv.delete_key(slot) + except ApduError as error: + if error.sw not in (SW.FILE_NOT_FOUND, SW.REFERENCE_DATA_NOT_FOUND): + raise diff --git a/tests/piv/test_000_application.py b/tests/piv/test_000_application.py new file mode 100644 index 0000000..e3f2cb0 --- /dev/null +++ b/tests/piv/test_000_application.py @@ -0,0 +1,18 @@ +from yubikit.core.smartcard import SW +from yubikit.core import Tlv + +from piv_helpers import assert_apdu_error + + +def test_select_and_version(piv): + assert tuple(piv.version) == (5, 7, 0) + + +def test_discovery_object(piv): + # Pico wraps the discovery TLV in 0x53 and appends two implementation bytes. + response = piv.protocol.send_apdu(0, 0xCB, 0x3F, 0xFF, Tlv(0x5C, b"\x7e")) + assert Tlv.unpack(0x53, response).startswith(bytes.fromhex("4f0ba0000003080000100001005f2f024010")) + + +def test_unsupported_instruction_has_expected_status(piv): + assert_apdu_error(lambda: piv.protocol.send_apdu(0, 0x00, 0, 0), SW.INVALID_INSTRUCTION) diff --git a/tests/piv/test_010_pin.py b/tests/piv/test_010_pin.py new file mode 100644 index 0000000..1e17e82 --- /dev/null +++ b/tests/piv/test_010_pin.py @@ -0,0 +1,46 @@ +import pytest + +from piv_helpers import DEFAULT_PIN, DEFAULT_PUK +from yubikit.core import InvalidPinError + + +def test_pin_metadata_and_authentication(piv): + metadata = piv.get_pin_metadata() + assert metadata.default_value + assert metadata.total_attempts == 3 + assert metadata.attempts_remaining == 3 + + with pytest.raises(InvalidPinError) as raised: + piv.verify_pin("000000") + assert raised.value.attempts_remaining == 2 + piv.verify_pin(DEFAULT_PIN) + + +def test_pin_change_round_trip(piv): + new_pin = "654321" + changed = False + try: + piv.change_pin(DEFAULT_PIN, new_pin) + changed = True + piv.verify_pin(new_pin) + with pytest.raises(InvalidPinError): + piv.verify_pin(DEFAULT_PIN) + piv.verify_pin(new_pin) + finally: + if changed: + piv.change_pin(new_pin, DEFAULT_PIN) + + +def test_puk_change_and_unblock_round_trip(piv): + new_puk = "87654321" + new_pin = "135790" + changed = False + try: + piv.change_puk(DEFAULT_PUK, new_puk) + changed = True + piv.unblock_pin(new_puk, new_pin) + piv.verify_pin(new_pin) + piv.change_pin(new_pin, DEFAULT_PIN) + finally: + if changed: + piv.change_puk(new_puk, DEFAULT_PUK) diff --git a/tests/piv/test_020_management.py b/tests/piv/test_020_management.py new file mode 100644 index 0000000..3f294f3 --- /dev/null +++ b/tests/piv/test_020_management.py @@ -0,0 +1,34 @@ +import pytest + +from piv_helpers import DEFAULT_MANAGEMENT_KEY, assert_apdu_error +from yubikit.core import Tlv +from yubikit.core.smartcard import ApduError, SW +from yubikit.piv import KEY_TYPE, MANAGEMENT_KEY_TYPE, SLOT + + +def test_default_management_key_authenticates(piv): + piv.authenticate(DEFAULT_MANAGEMENT_KEY) + + +def test_invalid_management_key_has_expected_status(piv): + with pytest.raises(ApduError) as raised: + piv.authenticate(b"\x00" * len(DEFAULT_MANAGEMENT_KEY)) + assert raised.value.sw == SW.DATA_INVALID + + +def test_management_key_change_round_trip(piv): + new_key = b"Pico PIV temporary key".ljust(24, b"!") + changed = False + try: + piv.authenticate(DEFAULT_MANAGEMENT_KEY) + piv.set_management_key(MANAGEMENT_KEY_TYPE.AES192, new_key) + changed = True + piv.authenticate(new_key) + finally: + if changed: + piv.set_management_key(MANAGEMENT_KEY_TYPE.AES192, DEFAULT_MANAGEMENT_KEY) + + +def test_key_generation_requires_management_authentication(piv): + request = Tlv(0xAC, Tlv(0x80, bytes([KEY_TYPE.ECCP256]))) + assert_apdu_error(lambda: piv.protocol.send_apdu(0, 0x47, 0, SLOT.RETIRED1, request), SW.SECURITY_CONDITION_NOT_SATISFIED) diff --git a/tests/piv/test_030_keys.py b/tests/piv/test_030_keys.py new file mode 100644 index 0000000..1426b14 --- /dev/null +++ b/tests/piv/test_030_keys.py @@ -0,0 +1,97 @@ +import pytest +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa +from itertools import product + +from piv_helpers import DEFAULT_PIN, assert_apdu_error, delete_key +from yubikit.core.smartcard import SW +from yubikit.piv import KEY_TYPE, PIN_POLICY, SLOT, TOUCH_POLICY + + +SUPPORTED_KEY_TYPES = (KEY_TYPE.RSA1024, KEY_TYPE.ECCP256, KEY_TYPE.ECCP384) +COMMON_SLOTS = (SLOT.AUTHENTICATION, SLOT.SIGNATURE, SLOT.KEY_MANAGEMENT, SLOT.CARD_AUTH) +TEST_SLOTS = COMMON_SLOTS + tuple(SLOT(value) for value in range(0x82, 0x96)) +KEY_CASES = tuple(product(SUPPORTED_KEY_TYPES, TEST_SLOTS)) + + +@pytest.mark.parametrize(("key_type", "slot"), KEY_CASES) +def test_generate_and_delete_supported_key_types(managed_piv, key_type, slot): + try: + public_key = managed_piv.generate_key(slot, key_type, PIN_POLICY.ONCE, TOUCH_POLICY.NEVER) + assert managed_piv.get_slot_metadata(slot).public_key.public_numbers() == public_key.public_numbers() + finally: + delete_key(managed_piv, slot) + assert_apdu_error(lambda: managed_piv.get_slot_metadata(slot), SW.REFERENCE_DATA_NOT_FOUND) + + +@pytest.mark.parametrize(("key_type", "slot"), KEY_CASES) +def test_sign_and_verify_supported_key_types(managed_piv, key_type, slot): + message = b"Pico PIV functional test" + try: + public_key = managed_piv.generate_key(slot, key_type, PIN_POLICY.ONCE, TOUCH_POLICY.NEVER) + managed_piv.verify_pin(DEFAULT_PIN) + signature = managed_piv.sign(slot, key_type, message, hashes.SHA256(), padding.PKCS1v15() if key_type.algorithm.value == "rsa" else None) + if isinstance(public_key, rsa.RSAPublicKey): + public_key.verify(signature, message, padding.PKCS1v15(), hashes.SHA256()) + else: + public_key.verify(signature, message, ec.ECDSA(hashes.SHA256())) + finally: + delete_key(managed_piv, slot) + + +@pytest.mark.parametrize("slot", COMMON_SLOTS) +def test_common_slots_can_sign(managed_piv, slot): + key_type = KEY_TYPE.ECCP256 + message = b"Pico PIV common slot test" + try: + public_key = managed_piv.generate_key(slot, key_type, PIN_POLICY.ONCE, TOUCH_POLICY.NEVER) + managed_piv.verify_pin(DEFAULT_PIN) + signature = managed_piv.sign(slot, key_type, message, hashes.SHA256(), None) + public_key.verify(signature, message, ec.ECDSA(hashes.SHA256())) + finally: + delete_key(managed_piv, slot) + + +def test_sign_requires_pin(managed_piv): + slot = SLOT.RETIRED1 + try: + managed_piv.generate_key(slot, KEY_TYPE.ECCP256, PIN_POLICY.ONCE, TOUCH_POLICY.NEVER) + assert_apdu_error(lambda: managed_piv.sign(slot, KEY_TYPE.ECCP256, b"no PIN", hashes.SHA256(), None), SW.SECURITY_CONDITION_NOT_SATISFIED) + finally: + delete_key(managed_piv, slot) + + +def test_rsa_decipher(managed_piv): + slot = SLOT.KEY_MANAGEMENT + plaintext = b"Pico PIV RSA decipher" + try: + public_key = managed_piv.generate_key(slot, KEY_TYPE.RSA1024, PIN_POLICY.ONCE, TOUCH_POLICY.NEVER) + oaep = padding.OAEP(mgf=padding.MGF1(hashes.SHA256()), algorithm=hashes.SHA256(), label=None) + ciphertext = public_key.encrypt(plaintext, oaep) + managed_piv.verify_pin(DEFAULT_PIN) + assert managed_piv.decrypt(slot, ciphertext, oaep) == plaintext + finally: + delete_key(managed_piv, slot) + + +@pytest.mark.parametrize( + ("key_type", "private_key"), + [ + (KEY_TYPE.RSA1024, rsa.generate_private_key(public_exponent=65537, key_size=1024)), + (KEY_TYPE.ECCP256, ec.generate_private_key(ec.SECP256R1())), + ], +) +def test_import_key_and_use_it(managed_piv, key_type, private_key): + slot = SLOT.RETIRED2 + try: + assert managed_piv.put_key(slot, private_key, PIN_POLICY.ONCE, TOUCH_POLICY.NEVER) == key_type + managed_piv.verify_pin(DEFAULT_PIN) + message = b"imported PIV key" + signature = managed_piv.sign(slot, key_type, message, hashes.SHA256(), padding.PKCS1v15() if key_type.algorithm.value == "rsa" else None) + public_key = private_key.public_key() + if isinstance(public_key, rsa.RSAPublicKey): + public_key.verify(signature, message, padding.PKCS1v15(), hashes.SHA256()) + else: + public_key.verify(signature, message, ec.ECDSA(hashes.SHA256())) + finally: + delete_key(managed_piv, slot) diff --git a/tests/piv/test_040_objects.py b/tests/piv/test_040_objects.py new file mode 100644 index 0000000..640c67e --- /dev/null +++ b/tests/piv/test_040_objects.py @@ -0,0 +1,88 @@ +from datetime import datetime, timedelta, timezone +from itertools import product + +import pytest +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa +from cryptography.x509.oid import NameOID + +from piv_helpers import DEFAULT_MANAGEMENT_KEY, DEFAULT_PIN, assert_apdu_error, delete_key +from yubikit.core import Tlv +from yubikit.core.smartcard import ApduError, SW +from yubikit.piv import KEY_TYPE, OBJECT_ID, PIN_POLICY, SLOT, TOUCH_POLICY +from ykman.piv import generate_csr, generate_self_signed_certificate + +from test_030_keys import SUPPORTED_KEY_TYPES, TEST_SLOTS + + +def test_certificate_round_trip(managed_piv): + slot = SLOT.RETIRED1 + ca_key = ec.generate_private_key(ec.SECP256R1()) + try: + public_key = managed_piv.generate_key(slot, KEY_TYPE.ECCP256, PIN_POLICY.ONCE, TOUCH_POLICY.NEVER) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Pico PIV test")]) + certificate = x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(public_key).serial_number(x509.random_serial_number()).not_valid_before(datetime.now(timezone.utc) - timedelta(minutes=1)).not_valid_after(datetime.now(timezone.utc) + timedelta(days=1)).sign(ca_key, hashes.SHA256()) + managed_piv.put_certificate(slot, certificate) + assert managed_piv.get_certificate(slot).public_bytes(serialization.Encoding.DER) == certificate.public_bytes(serialization.Encoding.DER) + managed_piv.delete_certificate(slot) + assert_apdu_error(lambda: managed_piv.get_certificate(slot), 0x6A82) + finally: + delete_key(managed_piv, slot) + + +@pytest.mark.parametrize(("key_type", "slot"), tuple(product(SUPPORTED_KEY_TYPES, TEST_SLOTS))) +def test_signature_and_certificate_round_trip_for_all_script_cases(managed_piv, key_type, slot): + message = b"Pico PIV shell compatibility test" + ca_key = ec.generate_private_key(ec.SECP256R1()) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Pico PIV shell compatibility")]) + ca_certificate = x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(ca_key.public_key()).serial_number(x509.random_serial_number()).not_valid_before(datetime.now(timezone.utc) - timedelta(minutes=1)).not_valid_after(datetime.now(timezone.utc) + timedelta(days=1)).sign(ca_key, hashes.SHA256()) + try: + public_key = managed_piv.generate_key(slot, key_type, PIN_POLICY.ONCE, TOUCH_POLICY.NEVER) + managed_piv.verify_pin(DEFAULT_PIN) + signature = managed_piv.sign(slot, key_type, message, hashes.SHA256(), padding.PKCS1v15() if isinstance(public_key, rsa.RSAPublicKey) else None) + if isinstance(public_key, rsa.RSAPublicKey): + public_key.verify(signature, message, padding.PKCS1v15(), hashes.SHA256()) + else: + public_key.verify(signature, message, ec.ECDSA(hashes.SHA256())) + + csr = generate_csr(managed_piv, slot, public_key, "CN=Pico PIV slot") + assert csr.is_signature_valid + assert csr.public_key().public_numbers() == public_key.public_numbers() + certificate = generate_self_signed_certificate(managed_piv, slot, public_key, "CN=Pico PIV slot", datetime.now(timezone.utc) - timedelta(minutes=1), datetime.now(timezone.utc) + timedelta(days=1)) + assert certificate.subject == certificate.issuer + if isinstance(public_key, rsa.RSAPublicKey): + public_key.verify(certificate.signature, certificate.tbs_certificate_bytes, padding.PKCS1v15(), hashes.SHA256()) + else: + public_key.verify(certificate.signature, certificate.tbs_certificate_bytes, ec.ECDSA(hashes.SHA256())) + + certificate = x509.CertificateBuilder().subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Pico PIV slot")])).issuer_name(name).public_key(public_key).serial_number(x509.random_serial_number()).not_valid_before(datetime.now(timezone.utc) - timedelta(minutes=1)).not_valid_after(datetime.now(timezone.utc) + timedelta(days=1)).sign(ca_key, hashes.SHA256()) + managed_piv.put_certificate(slot, certificate) + stored = managed_piv.get_certificate(slot) + assert stored.public_bytes(serialization.Encoding.DER) == certificate.public_bytes(serialization.Encoding.DER) + ca_key.public_key().verify(stored.signature, stored.tbs_certificate_bytes, ec.ECDSA(stored.signature_hash_algorithm)) + ca_key.public_key().verify(ca_certificate.signature, ca_certificate.tbs_certificate_bytes, ec.ECDSA(ca_certificate.signature_hash_algorithm)) + managed_piv.delete_certificate(slot) + finally: + delete_key(managed_piv, slot) + + +def test_chuid_object_round_trip(managed_piv): + original = None + try: + try: + original = managed_piv.get_object(OBJECT_ID.CHUID) + except ApduError as error: + if error.sw != SW.FILE_NOT_FOUND: + raise + value = b"\x30\x03PIV" + managed_piv.put_object(OBJECT_ID.CHUID, value) + assert managed_piv.get_object(OBJECT_ID.CHUID) == value + finally: + managed_piv.authenticate(DEFAULT_MANAGEMENT_KEY) + managed_piv.put_object(OBJECT_ID.CHUID, original) + + +def test_object_write_requires_management_authentication(piv): + request = Tlv(0x5C, b"\x5f\xc1\x02") + Tlv(0x53, b"test") + assert_apdu_error(lambda: piv.protocol.send_apdu(0, 0xDB, 0x3F, 0xFF, request), SW.SECURITY_CONDITION_NOT_SATISFIED) diff --git a/tests/piv/test_050_attestation.py b/tests/piv/test_050_attestation.py new file mode 100644 index 0000000..6d64ed6 --- /dev/null +++ b/tests/piv/test_050_attestation.py @@ -0,0 +1,27 @@ +from itertools import product + +import pytest +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat +from cryptography import x509 + +from piv_helpers import delete_key +from yubikit.core import Tlv +from yubikit.piv import KEY_TYPE, PIN_POLICY, SLOT, TOUCH_POLICY + +from test_030_keys import SUPPORTED_KEY_TYPES, TEST_SLOTS + + +@pytest.mark.parametrize(("key_type", "slot"), tuple(product(SUPPORTED_KEY_TYPES, TEST_SLOTS))) +def test_attestation_chain_for_all_script_cases(managed_piv, key_type, slot): + try: + public_key = managed_piv.generate_key(slot, key_type, PIN_POLICY.ONCE, TOUCH_POLICY.NEVER) + # Pico stores F9 as raw DER, not as a standard certificate object. + response = managed_piv.protocol.send_apdu(0, 0xCB, 0x3F, 0xFF, Tlv(0x5C, b"\x5f\xff\x01")) + issuer = x509.load_der_x509_certificate(Tlv.unpack(0x53, response)) + attested = managed_piv.attest_key(slot) + assert attested.issuer == issuer.subject + assert attested.public_key().public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo) == public_key.public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo) + issuer.public_key().verify(attested.signature, attested.tbs_certificate_bytes, ec.ECDSA(attested.signature_hash_algorithm)) + finally: + delete_key(managed_piv, slot) diff --git a/tests/piv/test_090_reset.py b/tests/piv/test_090_reset.py new file mode 100644 index 0000000..4b0ab34 --- /dev/null +++ b/tests/piv/test_090_reset.py @@ -0,0 +1,17 @@ +import os + +import pytest + +from piv_helpers import DEFAULT_MANAGEMENT_KEY, DEFAULT_PIN, assert_apdu_error +from yubikit.core.smartcard import SW + + +@pytest.mark.skipif(not os.environ.get("PIV_RUN_DESTRUCTIVE"), reason="set PIV_RUN_DESTRUCTIVE=1 to run the destructive PIV reset test") +def test_factory_reset_restores_defaults(piv): + piv.reset() + assert piv.get_pin_metadata().default_value + assert piv.get_puk_metadata().default_value + assert piv.get_management_key_metadata().default_value + piv.verify_pin(DEFAULT_PIN) + piv.authenticate(DEFAULT_MANAGEMENT_KEY) + assert_apdu_error(lambda: piv.get_slot_metadata(0x82), SW.REFERENCE_DATA_NOT_FOUND) diff --git a/tests/requirements-piv.txt b/tests/requirements-piv.txt new file mode 100644 index 0000000..a1d8459 --- /dev/null +++ b/tests/requirements-piv.txt @@ -0,0 +1,4 @@ +pytest>=8,<9 +cryptography>=43,<47 +pyscard>=2,<3 +yubikey-manager>=5.6,<6