Add pytest tests for PIV

Signed-off-by: Pol Henarejos <pol.henarejos@cttc.es>
This commit is contained in:
Pol Henarejos
2026-08-14 20:28:48 +02:00
parent 3469f61bb9
commit 351aa46e5d
13 changed files with 411 additions and 3 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+54
View File
@@ -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
+23
View File
@@ -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
+18
View File
@@ -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)
+46
View File
@@ -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)
+34
View File
@@ -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)
+97
View File
@@ -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)
+88
View File
@@ -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)
+27
View File
@@ -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)
+17
View File
@@ -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)
+4
View File
@@ -0,0 +1,4 @@
pytest>=8,<9
cryptography>=43,<47
pyscard>=2,<3
yubikey-manager>=5.6,<6