This commit is contained in:
jonesmarvin8
2026-04-26 20:27:22 -04:00
parent a99fccd704
commit 41f34f4ff4
138 changed files with 7538 additions and 125 deletions
@@ -0,0 +1,95 @@
import sys
import pytest
from unittest.mock import Mock, patch
from keycard import constants
from keycard.card_interface import CardInterface
from keycard.exceptions import APDUError
from keycard.commands.change_secret import change_secret
@pytest.fixture
def mock_card():
card = Mock(spec=CardInterface)
card.send_secure_apdu = Mock()
return card
def test_change_secret_pairing_str_success(mock_card):
change_secret_module = sys.modules['keycard.commands.change_secret']
with patch.object(
change_secret_module, 'generate_pairing_token'
) as mock_generate:
mock_generate.return_value = bytes(32)
change_secret(mock_card, 'pairingtoken', constants.PinType.PAIRING)
mock_generate.assert_called_once_with('pairingtoken')
mock_card.send_secure_apdu.assert_called_once_with(
ins=constants.INS_CHANGE_SECRET,
p1=constants.PinType.PAIRING.value,
data=mock_generate.return_value
)
def test_change_secret_user_pin_str_success(mock_card):
pin = '123456'
change_secret(mock_card, pin, constants.PinType.USER)
mock_card.send_secure_apdu.assert_called_once_with(
ins=constants.INS_CHANGE_SECRET,
p1=constants.PinType.USER.value,
data=pin.encode('utf-8')
)
def test_change_secret_user_pin_invalid_length(mock_card):
with pytest.raises(ValueError, match="User PIN must be exactly 6 digits."):
change_secret(mock_card, b'12345', constants.PinType.USER)
with pytest.raises(ValueError, match="User PIN must be exactly 6 digits."):
change_secret(mock_card, '12345', constants.PinType.USER)
def test_change_secret_puk_success(mock_card):
puk = b'123456789012'
change_secret(mock_card, puk, constants.PinType.PUK)
mock_card.send_secure_apdu.assert_called_once_with(
ins=constants.INS_CHANGE_SECRET,
p1=constants.PinType.PUK.value,
data=puk
)
def test_change_secret_puk_str_success(mock_card):
puk = '123456789012'
change_secret(mock_card, puk, constants.PinType.PUK)
mock_card.send_secure_apdu.assert_called_once_with(
ins=constants.INS_CHANGE_SECRET,
p1=constants.PinType.PUK.value,
data=puk.encode('utf-8')
)
def test_change_secret_puk_invalid_length(mock_card):
with pytest.raises(ValueError, match="PUK must be exactly 12 digits."):
change_secret(mock_card, b'1234567890', constants.PinType.PUK)
with pytest.raises(ValueError, match="PUK must be exactly 12 digits."):
change_secret(mock_card, '1234567890', constants.PinType.PUK)
def test_change_secret_pairing_bytes_success(mock_card):
secret = b'a' * 32
change_secret(mock_card, secret, constants.PinType.PAIRING)
mock_card.send_secure_apdu.assert_called_once_with(
ins=constants.INS_CHANGE_SECRET,
p1=constants.PinType.PAIRING.value,
data=secret
)
def test_change_secret_pairing_bytes_invalid_length(mock_card):
with pytest.raises(ValueError, match="Pairing secret must be 32 bytes."):
change_secret(mock_card, b'a' * 31, constants.PinType.PAIRING)
def test_change_secret_raises_apdu_error(mock_card):
mock_card.send_secure_apdu.side_effect = APDUError(0x6A80)
with pytest.raises(APDUError):
change_secret(mock_card, b'123456', constants.PinType.USER)
@@ -0,0 +1,14 @@
from keycard.commands import derive_key
from keycard.constants import INS_DERIVE_KEY, DerivationSource
from keycard.parsing.keypath import KeyPath
def test_derive_key_valid_master(card):
key_path = KeyPath("m/44'/60'/0'/0/0")
derive_key(card, key_path.to_string())
card.send_secure_apdu.assert_called_once_with(
ins=INS_DERIVE_KEY,
p1=DerivationSource.MASTER,
data=key_path.data
)
@@ -0,0 +1,100 @@
import pytest
from keycard.commands.export_key import export_key
from keycard.constants import DerivationOption, DerivationSource
from keycard.parsing.exported_key import ExportedKey
def test_export_key_success_public_only(card):
public_key = b'\x04' + b'\x01' * 64
inner_tlv = b'\x80' + bytes([len(public_key)]) + public_key
outer_tlv = b'\xA1' + bytes([len(inner_tlv)]) + inner_tlv
card.send_secure_apdu.return_value = outer_tlv
result = export_key(
card,
derivation_option=DerivationOption.CURRENT,
public_only=True,
keypath=None,
make_current=False,
source=DerivationSource.MASTER
)
assert isinstance(result, ExportedKey)
assert result.public_key == public_key
assert result.private_key is None
assert result.chain_code is None
def test_export_key_with_path_string(card):
public_key = b'\x04' + b'\x02' * 64
inner_tlv = b'\x80' + bytes([len(public_key)]) + public_key
outer_tlv = b'\xA1' + bytes([len(inner_tlv)]) + inner_tlv
card.send_secure_apdu.return_value = outer_tlv
result = export_key(
card,
derivation_option=DerivationOption.DERIVE,
public_only=True,
keypath="m/44'/60'/0'/0/0",
make_current=True,
source=DerivationSource.MASTER
)
assert isinstance(result, ExportedKey)
assert result.public_key == public_key
def test_export_key_invalid_keypath_length_bytes(card):
with pytest.raises(
ValueError,
match="Byte keypath must be a multiple of 4"
):
export_key(
card,
derivation_option=DerivationOption.DERIVE,
public_only=True,
keypath=b'\x01\x02\x03',
make_current=False,
source=DerivationSource.PARENT
)
def test_export_key_requires_keypath_if_not_current(card):
with pytest.raises(
ValueError,
match="Keypath required unless using CURRENT derivation"
):
export_key(
card,
derivation_option=DerivationOption.DERIVE,
public_only=True,
keypath=None,
make_current=False,
source=DerivationSource.CURRENT
)
def test_export_key_invalid_keypath_type(card):
with pytest.raises(TypeError, match="Keypath must be a string or bytes"):
export_key(
card,
derivation_option=DerivationOption.DERIVE,
public_only=True,
keypath=123,
make_current=False,
source=DerivationSource.CURRENT
)
def test_export_key_missing_keypair_template(card):
card.send_secure_apdu.return_value = b'\xA0\x00'
with pytest.raises(ValueError, match="Missing keypair template"):
export_key(
card,
derivation_option=DerivationOption.CURRENT,
public_only=True,
keypath=None,
make_current=False,
source=DerivationSource.MASTER
)
@@ -0,0 +1,25 @@
import pytest
from unittest.mock import Mock
from keycard import constants
from keycard.commands.factory_reset import factory_reset
from keycard.exceptions import APDUError
def test_factory_reset_success(card):
mock_response = Mock()
mock_response.status_word = 0x9000
card.send_apdu.return_value = mock_response
factory_reset(card)
card.send_apdu.assert_called_once_with(
ins=constants.INS_FACTORY_RESET,
p1=0xAA,
p2=0x55
)
def test_factory_reset_failure(card):
card.send_apdu.side_effect = APDUError(0x6A80)
with pytest.raises(APDUError):
factory_reset(card)
@@ -0,0 +1,20 @@
import pytest
from keycard import constants
from keycard.commands.generate_key import generate_key
from keycard.exceptions import APDUError
def test_generate_key_success(card):
mock_id = b'\x01' * 32
card.send_secure_apdu.return_value = mock_id
result = generate_key(card)
assert result == mock_id
card.send_secure_apdu.assert_called_once_with(
ins=constants.INS_GENERATE_KEY)
def test_generate_key_apdu_error(card):
card.send_secure_apdu.side_effect = APDUError(0x6A80)
with pytest.raises(APDUError):
generate_key(card)
@@ -0,0 +1,40 @@
import pytest
from keycard.constants import INS_GENERATE_MNEMONIC
from keycard.commands.generate_mnemonic import generate_mnemonic
def test_generate_mnemonic_valid(card):
card.send_secure_apdu.return_value = bytes([
0x00, 0x00,
0x07, 0xFF,
0x05, 0x39,
0x00, 0x2A
])
result = generate_mnemonic(card, checksum_size=6)
card.send_secure_apdu.assert_called_once_with(
ins=INS_GENERATE_MNEMONIC,
p1=6
)
assert result == [0, 2047, 1337, 42]
def test_generate_mnemonic_invalid_checksum(card):
with pytest.raises(
ValueError,
match="Checksum size must be between 4 and 8"
):
generate_mnemonic(card, checksum_size=2)
def test_generate_mnemonic_odd_length_response(card):
# Simulate invalid odd-length byte response
card.send_secure_apdu.return_value = b'\x00\x01\x02'
with pytest.raises(
ValueError,
match="Response must contain an even number of bytes"
):
generate_mnemonic(card, checksum_size=6)
@@ -0,0 +1,31 @@
import pytest
from keycard.commands.get_data import get_data
from keycard import constants
def test_get_data_secure_channel(card):
card.is_secure_channel_open = True
card.send_secure_apdu.return_value = b"secure_data"
result = get_data(card, slot=constants.StorageSlot.PUBLIC)
card.send_secure_apdu.assert_called_once_with(
ins=constants.INS_GET_DATA,
p1=constants.StorageSlot.PUBLIC,
)
assert result == card.send_secure_apdu.return_value
def test_get_data_proprietary_channel(card):
card.is_secure_channel_open = False
card.send_apdu.return_value = b"proprietary_data"
result = get_data(card, slot=constants.StorageSlot.NDEF)
card.send_apdu.assert_called_once_with(
ins=constants.INS_GET_DATA,
p1=constants.StorageSlot.NDEF.value,
cla=constants.CLA_PROPRIETARY
)
assert result == card.send_apdu.return_value
def test_get_data_invalid_slot(card):
with pytest.raises(AttributeError):
get_data(card, slot="INVALID_SLOT")
@@ -0,0 +1,24 @@
from keycard.commands.get_status import get_status
def test_get_application_status(card):
card.send_secure_apdu.return_value = bytes.fromhex(
'A309020103020102010101')
result = get_status(card)
assert result['pin_retry_count'] == 3
assert result['puk_retry_count'] == 2
assert result['initialized'] is True
def test_get_key_path_status(card):
key_path = [0x8000002C, 0x8000003C]
card.send_secure_apdu.return_value = b''.join(
i.to_bytes(4, 'big') for i in key_path
)
result = get_status(card, key_path=True)
assert result == key_path
@@ -0,0 +1,85 @@
import sys
import pytest
from unittest.mock import MagicMock, patch
from keycard.commands.init import init
from keycard.exceptions import APDUError
from keycard import constants
PIN = b'1234'
PUK = b'5678'
PAIRING_SECRET = b'abcdefgh'
CARD_PUBLIC_KEY = b'\x04' + b'\x00' * 64 # Valid uncompressed pubkey format
@pytest.fixture
def ecc_patches():
init_module = sys.modules['keycard.commands.init']
with (
patch.object(init_module, 'urandom', return_value=b'\x00' * 16),
patch.object(
init_module,
'aes_cbc_encrypt',
side_effect=lambda k, iv,
pt: b'\xAA' * len(pt)
),
patch.object(init_module, 'SigningKey') as mock_signing_key_cls,
patch.object(init_module, 'VerifyingKey') as mock_verifying_key_cls,
patch.object(init_module, 'ECDH') as mock_ecdh_cls,
):
mock_gen = mock_signing_key_cls.generate
fake_privkey = MagicMock()
fake_privkey.verifying_key.to_string.return_value = b'\x01' * 65
mock_gen.return_value = fake_privkey
mock_parse = mock_verifying_key_cls.from_string
mock_parse.return_value = 'parsed-pubkey'
ecdh_instance = MagicMock()
ecdh_instance.generate_sharedsecret_bytes.return_value = b'\xBB' * 32
mock_ecdh_cls.return_value = ecdh_instance
yield
def test_init_success(card, ecc_patches):
card.send_apdu.return_value = b''
card.card_public_key = CARD_PUBLIC_KEY
init(card, PIN, PUK, PAIRING_SECRET)
card.send_apdu.assert_called_once_with(
ins=constants.INS_INIT,
data=bytes.fromhex(
'4101010101010101010101010101010101010101010101010101010'
'1010101010101010101010101010101010101010101010101010101'
'010101010101010101010100000000000000000000000000000000'
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')
)
@pytest.mark.parametrize('secret_length', [10, 240])
def test_init_data_length(card, ecc_patches, secret_length):
card.send_apdu.return_value = b''
card.card_public_key = CARD_PUBLIC_KEY
pairing_secret = b'x' * secret_length
plaintext = PIN + PUK + pairing_secret
total_data_len = 1 + 65 + 16 + len(plaintext)
if total_data_len > 255:
with pytest.raises(ValueError, match='Data too long'):
init(card, PIN, PUK, pairing_secret)
else:
init(card, PIN, PUK, pairing_secret)
assert card.send_apdu.call_count == 1
def test_init_apdu_error(card, ecc_patches):
card.send_apdu.side_effect = APDUError(0x6A84)
card.card_public_key = CARD_PUBLIC_KEY
with pytest.raises(APDUError) as excinfo:
init(card, PIN, PUK, PAIRING_SECRET)
assert excinfo.value.sw == 0x6A84
@@ -0,0 +1,57 @@
import pytest
from keycard.parsing.keypath import KeyPath
from keycard.constants import DerivationSource
def test_keypath_from_string_master():
path = KeyPath("m/44'/60'/0'/0/0")
assert path.source == DerivationSource.MASTER
assert path.data == bytes.fromhex(
'8000002c8000003c800000000000000000000000')
assert path.to_string() == "m/44'/60'/0'/0/0"
def test_keypath_from_string_parent():
path = KeyPath('../1/2/3')
assert path.source == DerivationSource.PARENT
assert path.to_string() == '../1/2/3'
def test_keypath_from_string_current_default():
path = KeyPath('1/2/3')
assert path.source == DerivationSource.CURRENT
assert path.to_string() == './1/2/3'
def test_keypath_from_bytes():
data = bytes.fromhex('8000002c00000001')
path = KeyPath(data, source=DerivationSource.PARENT)
assert path.source == DerivationSource.PARENT
assert path.data == data
assert path.to_string() == "../44'/1"
def test_keypath_empty_string_raises():
with pytest.raises(ValueError, match="Empty path"):
KeyPath('')
def test_keypath_invalid_component():
with pytest.raises(ValueError, match="Invalid component: abc"):
KeyPath('m/abc')
def test_keypath_too_many_components():
long_path = 'm/' + '/'.join('0' for _ in range(11))
with pytest.raises(ValueError, match="Too many components"):
KeyPath(long_path)
def test_keypath_invalid_byte_length():
with pytest.raises(ValueError, match="Byte path must be a multiple of 4"):
KeyPath(b'\x00\x01')
def test_keypath_invalid_type():
with pytest.raises(TypeError, match="Path must be a string or bytes"):
KeyPath(123)
@@ -0,0 +1,89 @@
import pytest
from keycard.commands.load_key import load_key
from keycard import constants
from hashlib import sha256
from keycard.parsing import tlv
def test_load_key_bip39(card):
seed = b"\xAA" * 64
fake_uid = b"\xBB" * 32
card.send_secure_apdu.return_value = fake_uid
result = load_key(
card,
key_type=constants.LoadKeyType.BIP39_SEED,
bip39_seed=seed
)
card.send_secure_apdu.assert_called_once_with(
ins=constants.INS_LOAD_KEY,
p1=constants.LoadKeyType.BIP39_SEED,
p2=0,
data=seed
)
assert result == fake_uid
def test_load_key_pair(card):
public_key = b'\x04' + b'\x01' * 64
private_key = b'\x02' * 32
uid = sha256(public_key).digest()
card.send_secure_apdu.return_value = uid
encoded = tlv.encode_tlv(
0xA1,
tlv.encode_tlv(0x80, public_key) +
tlv.encode_tlv(0x81, private_key)
)
result = load_key(
card,
key_type=constants.LoadKeyType.ECC,
public_key=public_key,
private_key=private_key
)
card.send_secure_apdu.assert_called_once_with(
ins=constants.INS_LOAD_KEY,
p1=constants.LoadKeyType.ECC,
p2=0,
data=encoded
)
assert result == uid
def test_bip39_seed_too_short(card):
with pytest.raises(ValueError, match="BIP39/LEE seed must be 16-64 bytes"):
load_key(
card,
key_type=constants.LoadKeyType.BIP39_SEED,
bip39_seed=b"\xAA" * 8
)
def test_bip39_seed_missing(card):
with pytest.raises(ValueError, match="Either bip39_seed or lee_seed must be provided for key_type = BIP39_SEED"):
load_key(
card,
key_type=constants.LoadKeyType.BIP39_SEED
)
def test_ecc_missing_private_key(card):
with pytest.raises(ValueError, match="Private key.*required"):
load_key(
card,
key_type=constants.LoadKeyType.ECC,
public_key=b"\x04" + b"\x01" * 64
)
def test_extended_ecc_missing_private_key(card):
with pytest.raises(ValueError, match="Private key.*required"):
load_key(
card,
key_type=constants.LoadKeyType.EXTENDED_ECC,
public_key=b"\x04" + b"\x02" * 64,
chain_code=b"\x00" * 32
)
@@ -0,0 +1,48 @@
import pytest
from keycard import constants
from keycard.commands.mutually_authenticate import mutually_authenticate
from keycard.exceptions import APDUError
def test_mutually_authenticate_success(card):
client_challenge = bytes(32)
card.send_secure_apdu.return_value = bytes(32)
mutually_authenticate(card, client_challenge)
card.send_secure_apdu.assert_called_once_with(
ins=constants.INS_MUTUALLY_AUTHENTICATE,
data=client_challenge
)
def test_mutually_authenticate_invalid_status_word(card):
card.send_secure_apdu.side_effect = APDUError(0x6F00)
with pytest.raises(APDUError, match='APDU failed with SW=6F00'):
mutually_authenticate(card, bytes(32))
def test_mutually_authenticate_invalid_response_length(card):
client_challenge = b'\xAA' * 32
response = b'\xBB' * 16 # Invalid length
card.send_secure_apdu.return_value = response
with pytest.raises(
ValueError,
match='Response to MUTUALLY AUTHENTICATE is not 32 bytes'
):
mutually_authenticate(card, client_challenge)
def test_mutually_authenticate_auto_challenge(card, monkeypatch):
fake_challenge = b'\xCC' * 32
monkeypatch.setattr('os.urandom', lambda n: fake_challenge)
card.send_secure_apdu.return_value = fake_challenge
mutually_authenticate(card)
card.send_secure_apdu.assert_called_once()
@@ -0,0 +1,109 @@
import sys
import pytest
from unittest.mock import MagicMock, patch
from ecdsa import SECP256k1
from keycard.card_interface import CardInterface
from keycard.commands.open_secure_channel import open_secure_channel
from keycard.exceptions import APDUError
@pytest.fixture
def mock_ecdsa():
open_secure_channel_module = sys.modules[
'keycard.commands.open_secure_channel'
]
with (
patch.object(
open_secure_channel_module,
'SecureChannel'
) as mock_secure_channel,
patch.object(
open_secure_channel_module,
'VerifyingKey'
) as mock_verifying_key,
patch.object(
open_secure_channel_module,
'ECDH'
) as mock_ecdh,
patch.object(
open_secure_channel_module,
'SigningKey'
) as mock_signing_key,
):
yield {
'secure_channel': mock_secure_channel,
'verifying_key': mock_verifying_key,
'ecdh': mock_ecdh,
'signing_key': mock_signing_key,
}
def test_open_secure_channel_success(mock_ecdsa):
mock_verifying_key = mock_ecdsa['verifying_key']
mock_ecdh = mock_ecdsa['ecdh']
mock_signing_key = mock_ecdsa['signing_key']
mock_secure_channel = mock_ecdsa['secure_channel']
pairing_index = 1
pairing_key = b'pairing_key'
card = MagicMock(spec=CardInterface)
card.card_public_key = b'\x04' + b'\x01' * 64
salt = b'A' * 32
seed_iv = b'B' * 16
response_data = salt + seed_iv
card.send_apdu.return_value = response_data
# Mock SigningKey.generate
mock_signing_key_instance = MagicMock()
mock_signing_key_instance.verifying_key.to_string.return_value = \
b'\x04' + b'\x02' * 64
mock_signing_key.generate.return_value = mock_signing_key_instance
mock_verifying_key.from_string.return_value = MagicMock()
mock_ecdh_instance = MagicMock()
mock_ecdh.return_value = mock_ecdh_instance
mock_ecdh_instance.generate_sharedsecret_bytes.return_value = (
b'shared_secret'
)
mock_secure_channel.open.return_value = 'secure_session'
result = open_secure_channel(
card,
pairing_index,
pairing_key
)
card.send_apdu.assert_called_once()
mock_verifying_key.from_string.assert_called_once_with(
card.card_public_key, curve=SECP256k1
)
mock_ecdh.assert_called_once()
mock_ecdh_instance.generate_sharedsecret_bytes.assert_called_once()
mock_secure_channel.open.assert_called_once_with(
b'shared_secret', pairing_key, salt, seed_iv
)
assert result == 'secure_session'
def test_open_secure_channel_raises_apdu_error(card, mock_ecdsa):
mock_signing_key = mock_ecdsa['signing_key']
# Mock SigningKey.generate
mock_signing_key_instance = MagicMock()
mock_signing_key_instance.verifying_key.to_string.return_value = \
b'\x04' + b'\x02' * 64
mock_signing_key.generate.return_value = mock_signing_key_instance
pairing_index = 1
pairing_key = b'pairing_key'
card.card_public_key = b'\x04' + b'\x01' * 64
card.send_apdu.side_effect = APDUError(0x6A80)
with pytest.raises(APDUError):
open_secure_channel(
card,
pairing_index,
pairing_key
)
@@ -0,0 +1,106 @@
import sys
import pytest
import hashlib
from unittest.mock import patch
from keycard.constants import INS_PAIR, PairingMode
from keycard.commands.pair import pair
from keycard.exceptions import APDUError, InvalidResponseError
@pytest.fixture
def mock_urandom():
pair_module = sys.modules['keycard.commands.pair']
with patch.object(pair_module, 'urandom', return_value=b'\x01' * 32):
yield
def test_pair_success(card, mock_urandom):
shared_secret = b'\xAA' * 32
client_challenge = b'\x01' * 32
card_challenge = b'\x02' * 32
expected_card_cryptogram = hashlib.sha256(
shared_secret + client_challenge).digest()
expected_client_cryptogram = hashlib.sha256(
shared_secret + card_challenge).digest()
first_response = expected_card_cryptogram + card_challenge
second_response = b'\x05' + card_challenge
card.send_apdu.side_effect = [first_response, second_response]
result = pair(card, shared_secret)
assert result == (5, expected_client_cryptogram)
assert card.send_apdu.call_count == 2
def test_pairing_mode(card, mock_urandom):
shared_secret = b'\xAA' * 32
client_challenge = b'\x01' * 32
card_challenge = b'\x02' * 32
expected_card_cryptogram = hashlib.sha256(
shared_secret + client_challenge).digest()
first_response = expected_card_cryptogram + card_challenge
second_response = b'\x05' + card_challenge
card.send_apdu.side_effect = [first_response, second_response]
pair(card, shared_secret, PairingMode.EPHEMERAL)
card.send_apdu.assert_any_call(
ins=INS_PAIR,
p2=PairingMode.EPHEMERAL,
data=client_challenge
)
def test_pair_invalid_shared_secret(card, mock_urandom):
with pytest.raises(ValueError, match='Shared secret must be 32 bytes'):
pair(card, b'short')
def test_pair_apdu_error_on_first(card, mock_urandom):
card.send_apdu.side_effect = APDUError(0x6A82)
with pytest.raises(APDUError):
pair(card, b'\x00' * 32)
def test_pair_invalid_response_length_first(card, mock_urandom):
card.send_apdu.return_value = bytes(10)
with pytest.raises(
InvalidResponseError,
match='Unexpected response length'
):
pair(card, b'\x00' * 32)
def test_pair_cryptogram_mismatch(card, mock_urandom):
wrong_card_cryptogram = b'\xAB' * 32
card_challenge = b'\x02' * 32
response = wrong_card_cryptogram + card_challenge
card.send_apdu.side_effect = [response]
with pytest.raises(InvalidResponseError, match='Card cryptogram mismatch'):
pair(card, b'\xAA' * 32)
def test_pair_invalid_response_second_apdu(card, mock_urandom):
shared_secret = b'\xAA' * 32
client_challenge = b'\x01' * 32
card_challenge = b'\x02' * 32
card_cryptogram = hashlib.sha256(shared_secret + client_challenge).digest()
first_response = card_cryptogram + card_challenge
second_response = b'\x00' * 10
card.send_apdu.side_effect = [first_response, second_response]
with pytest.raises(
InvalidResponseError,
match='Unexpected response length'
):
pair(card, shared_secret)
@@ -0,0 +1,11 @@
from keycard.commands.remove_key import remove_key
def test_remove_key_calls_send_secure_apdu_with_correct_ins(card):
remove_key(card)
card.send_secure_apdu.assert_called_once_with(ins=0xD3)
def test_remove_key_returns_none(card):
result = remove_key(card)
assert result is None
@@ -0,0 +1,41 @@
import sys
import pytest
from unittest.mock import MagicMock, patch
from keycard.commands.select import select
from keycard.exceptions import APDUError
from keycard import constants
def test_select_success():
select_module = sys.modules['keycard.commands.select']
dummy_info = MagicMock()
response_data = b'\x01\x02\x03\x04'
card = MagicMock()
card.send_apdu.return_value = response_data
with patch.object(select_module, 'ApplicationInfo') as mock_app_info:
mock_app_info.parse.return_value = dummy_info
result = select(card)
card.send_apdu.assert_called_once_with(
cla=constants.CLAISO7816,
ins=constants.INS_SELECT,
p1=0x04,
p2=0x00,
data=constants.KEYCARD_AID
)
mock_app_info.parse.assert_called_once_with(response_data)
assert result == dummy_info
def test_select_apdu_error():
card = MagicMock()
card.send_apdu.side_effect = APDUError(0x6A82)
with pytest.raises(APDUError) as excinfo:
select(card)
assert excinfo.value.sw == 0x6A82
@@ -0,0 +1,24 @@
from keycard.commands.set_pinless_path import set_pinless_path
from keycard.constants import INS_SET_PINLESS_PATH
from keycard.parsing.keypath import KeyPath
def test_set_pinless_path(card):
path = "m/44'/60'/0'/0/0"
expected_data = KeyPath(path).data
set_pinless_path(card, path)
card.send_secure_apdu.assert_called_once_with(
ins=INS_SET_PINLESS_PATH,
data=expected_data
)
def test_set_pinless_path_empty(card):
set_pinless_path(card, "")
card.send_secure_apdu.assert_called_once_with(
ins=INS_SET_PINLESS_PATH,
data=b""
)
@@ -0,0 +1,101 @@
import sys
import pytest
from unittest import mock
from keycard.commands.sign import sign
from keycard import constants
from keycard.exceptions import InvalidStateError
from keycard.parsing.keypath import KeyPath
def test_sign_current_key(card):
sign_module = sys.modules['keycard.commands.sign']
digest = b'\xAA' * 32
raw = b'\x01' * 64 + b'\x1f'
encoded = b'\x80' + bytes([len(raw)]) + raw
card.send_secure_apdu.return_value = encoded
with mock.patch.object(sign_module, "SignatureResult"):
sign(card, digest)
card.send_secure_apdu.assert_called_once_with(
ins=constants.INS_SIGN,
p1=constants.DerivationOption.CURRENT,
p2=constants.SigningAlgorithm.ECDSA_SECP256K1,
data=digest,
)
def test_sign_with_derivation_path(card):
sign_module = sys.modules['keycard.commands.sign']
digest = bytes(32)
raw = bytes(65)
encoded = b'\x80' + bytes([len(raw)]) + raw
card.send_secure_apdu.return_value = encoded
key_path = KeyPath("m/44'/60'/0'/0/0")
expected_data = digest + key_path.data
with mock.patch.object(sign_module, "SignatureResult"):
sign(
card,
digest,
p1=constants.DerivationOption.DERIVE,
derivation_path=key_path.to_string()
)
card.send_secure_apdu.assert_called_once_with(
ins=constants.INS_SIGN,
p1=constants.DerivationOption.DERIVE,
p2=constants.SigningAlgorithm.ECDSA_SECP256K1,
data=expected_data,
)
def test_sign_requires_pin(card):
card.is_pin_verified = False
digest = b'\xCC' * 32
with pytest.raises(
InvalidStateError,
match="PIN must be verified to sign with this derivation option"
):
sign(card, digest)
def test_sign_short_digest(card):
short_digest = b'\xDD' * 10
with pytest.raises(ValueError, match="Digest must be exactly 32 bytes"):
sign(card, short_digest)
def test_sign_missing_path(card):
digest = b'\xEE' * 32
with pytest.raises(ValueError, match="Derivation path cannot be empty"):
sign(
card,
digest,
p1=constants.DerivationOption.DERIVE,
derivation_path=None
)
def test_sign_not_implemented_algo(card):
digest = b'\xAB' * 32
with pytest.raises(
NotImplementedError,
match="Signature algorithm 255 not supported"
):
sign(card, digest, p2=0xFF)
def test_sign_raw_signature_wrong_length(card):
digest = b'\xCC' * 32
raw = b'\x01' * 64 # Should be 65 bytes
encoded = b'\x80' + bytes([len(raw)]) + raw
card.send_secure_apdu.return_value = encoded
card.is_pin_verified = True
with pytest.raises(ValueError, match="Expected 65-byte raw signature"):
sign(card, digest)
@@ -0,0 +1,29 @@
import pytest
from keycard.commands import store_data
from keycard import constants
def test_store_data_calls_send_secure_apdu_with_correct_args(card):
store_data(card, b"hello", constants.StorageSlot.PUBLIC)
card.send_secure_apdu.assert_called_once_with(
ins=constants.INS_STORE_DATA,
p1=constants.StorageSlot.PUBLIC.value,
data=b'hello'
)
def test_store_data_uses_default_slot(card):
store_data(card, b'world')
card.send_secure_apdu.assert_called_once_with(
ins=constants.INS_STORE_DATA,
p1=constants.StorageSlot.PUBLIC,
data=b'world'
)
def test_store_data_raises_value_error_on_too_long_data(card):
with pytest.raises(ValueError, match="Data too long"):
store_data(card, b'a' * 128, constants.StorageSlot.PUBLIC)
@@ -0,0 +1,43 @@
import pytest
from keycard.commands.unblock_pin import unblock_pin
from keycard import constants
def test_unblock_pin_with_valid_str(card):
puk = '123456789012'
pin = '123456'
unblock_pin(card, puk + pin)
card.send_secure_apdu.assert_called_once_with(
ins=constants.INS_UNBLOCK_PIN,
data=(puk + pin).encode('utf-8')
)
def test_unblock_pin_with_valid_bytes(card):
data = b'123456789012123456'
unblock_pin(card, data)
card.send_secure_apdu.assert_called_once_with(
ins=constants.INS_UNBLOCK_PIN,
data=data
)
@pytest.mark.parametrize('bad_input', [
'12345678901212345', # Too short
'1234567890121234567', # Too long
b'12345678901212345', # Too short (bytes)
b'1234567890121234567', # Too long (bytes)
])
def test_unblock_pin_invalid_length(card, bad_input):
with pytest.raises(ValueError, match='exactly 18 digits'):
unblock_pin(card, bad_input)
@pytest.mark.parametrize('bad_input', [
'12345678901A123456', # Non-digit in PUK
'12345678901212345A', # Non-digit in PIN
'ABCDEFGHIJKL123456', # All non-digits in PUK
])
def test_unblock_pin_invalid_digits(card, bad_input):
with pytest.raises(ValueError, match='must be numeric digits'):
unblock_pin(card, bad_input)
@@ -0,0 +1,25 @@
import pytest
from keycard.commands.unpair import unpair
from keycard.apdu import APDUResponse
from keycard.exceptions import APDUError
from keycard import constants
def test_unpair_success(card):
card.send_secure_apdu.return_value = APDUResponse(b'', 0x9000)
unpair(card, 1)
card.send_secure_apdu.assert_called_once_with(
ins=constants.INS_UNPAIR,
p1=0x01,
)
def test_unpair_apdu_error(card):
card.send_secure_apdu.side_effect = APDUError(0x6A84)
with pytest.raises(APDUError) as excinfo:
unpair(card, 1)
assert excinfo.value.sw == 0x6A84
@@ -0,0 +1,29 @@
import pytest
from keycard.commands.verify_pin import verify_pin
from keycard.exceptions import APDUError
from keycard import constants
def test_verify_pin_success(card):
assert verify_pin(card, '1234') is True
card.send_secure_apdu.assert_called_once_with(
ins=constants.INS_VERIFY_PIN,
data=b'1234'
)
def test_verify_pin_incorrect_but_allowed(card):
card.send_secure_apdu.side_effect = APDUError(0x63C2)
assert verify_pin(card, '0000') is False
def test_verify_pin_blocked(card):
card.send_secure_apdu.side_effect = APDUError(0x63C0)
with pytest.raises(RuntimeError, match='PIN is blocked'):
verify_pin(card, '0000')
def test_verify_pin_other_apdu_error(card):
card.send_secure_apdu.side_effect = APDUError(0x6A80)
with pytest.raises(APDUError):
verify_pin(card, '0000')