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
View File
@@ -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')
+9
View File
@@ -0,0 +1,9 @@
import pytest
from unittest.mock import Mock
from keycard.card_interface import CardInterface
@pytest.fixture
def card():
mock = Mock(spec=CardInterface)
return mock
@@ -0,0 +1,55 @@
import pytest
from keycard.crypto import aes
def test_aes_cbc_encrypt_decrypt_roundtrip():
key = b'0123456789abcdef'
iv = b'abcdef9876543210'
data = b'hello world 1234'
ciphertext = aes.aes_cbc_encrypt(key, iv, data)
decrypted = aes.aes_cbc_decrypt(key, iv, ciphertext)
assert decrypted == data
def test_aes_cbc_encrypt_padding():
key = b'0123456789abcdef'
iv = b'abcdef9876543210'
data = b'abc'
ciphertext = aes.aes_cbc_encrypt(key, iv, data)
assert len(ciphertext) % 16 == 0
decrypted = aes.aes_cbc_decrypt(key, iv, ciphertext)
assert decrypted == data
def test_aes_cbc_encrypt_no_padding():
key = b'0123456789abcdef'
iv = b'abcdef9876543210'
data = b'1234567890abcdef'
ciphertext = aes.aes_cbc_encrypt(key, iv, data, padding=False)
assert len(ciphertext) == 16
with pytest.raises(ValueError):
aes.aes_cbc_decrypt(key, iv, ciphertext)
def test_aes_cbc_decrypt_invalid_padding():
key = b'0123456789abcdef'
iv = b'abcdef9876543210'
data = b'1234567890abcdef'
ciphertext = aes.aes_cbc_encrypt(key, iv, data, padding=False)
with pytest.raises(ValueError):
aes.aes_cbc_decrypt(key, iv, ciphertext)
@pytest.mark.parametrize('data', [
b'',
b'a',
b'short',
b'exactly16bytes!!',
b'longer data that is not a multiple of block size',
])
def test_various_lengths(data):
key = b'0123456789abcdef'
iv = b'abcdef9876543210'
ciphertext = aes.aes_cbc_encrypt(key, iv, data)
decrypted = aes.aes_cbc_decrypt(key, iv, ciphertext)
assert decrypted == data
@@ -0,0 +1,28 @@
import hashlib
import unicodedata
from keycard.crypto.generate_pairing_token import generate_pairing_token
def test_generate_pairing_token_deterministic():
passphrase = "correct horse battery staple"
expected = hashlib.pbkdf2_hmac(
'sha256',
unicodedata.normalize('NFKD', passphrase).encode('utf-8'),
unicodedata.normalize(
'NFKD', 'Keycard Pairing Password Salt').encode('utf-8'),
50000,
dklen=32
)
assert generate_pairing_token(passphrase) == expected
def test_generate_pairing_token_unicode_normalization():
token_plain = generate_pairing_token("")
token_composed = generate_pairing_token("é")
assert token_plain == token_composed
def test_generate_pairing_token_output_length():
token = generate_pairing_token("whatever")
assert isinstance(token, bytes)
assert len(token) == 32
@@ -0,0 +1,162 @@
import pytest
from keycard.exceptions import InvalidResponseError
from keycard.parsing.application_info import ApplicationInfo
class DummyCapabilities:
CREDENTIALS_MANAGEMENT = 1
SECURE_CHANNEL = 2
@staticmethod
def parse(val):
return val
def test_parse_simple_pubkey(monkeypatch):
monkeypatch.setattr(
'keycard.parsing.application_info.Capabilities',
DummyCapabilities
)
data = bytes([0x80, 0x04, 0x01, 0x02, 0x03, 0x04])
info = ApplicationInfo.parse(data)
assert info.ecc_public_key == b'\x01\x02\x03\x04'
assert info.capabilities == 3
assert info.instance_uid is None
assert info.key_uid is None
assert info.version_major == 0
assert info.version_minor == 0
def test_str_method():
info = ApplicationInfo(
capabilities=7,
ecc_public_key=b'\x01\x02',
instance_uid=b'\xAA\xBB',
key_uid=b'\xCC\xDD',
version_major=2,
version_minor=5,
)
s = str(info)
assert '2.5' in s
assert 'aabb' in s
assert 'ccdd' in s
assert '0102' in s
assert '7' in s
def test_parse_tlv_success(monkeypatch):
def dummy_parse_tlv(data):
if data == b'\xA4\x0C' + b'\x01'*12:
return {
0xA4: [
b'\x8F\x02\xAA\xBB'
b'\x80\x02\x01\x02'
b'\x8E\x02\xCC\xDD'
b'\x8D\x01\x07'
b'\x02\x02\x02\x05'
]
}
return {
0x8F: [b'\xAA\xBB'],
0x80: [b'\x01\x02'],
0x8E: [b'\xCC\xDD'],
0x8D: [b'\x07'],
0x02: [b'\x02\x05']
}
class DummyCapabilities:
@staticmethod
def parse(val):
return val
monkeypatch.setattr(
'keycard.parsing.application_info.parse_tlv',
dummy_parse_tlv
)
monkeypatch.setattr(
'keycard.parsing.application_info.Capabilities',
DummyCapabilities
)
# Simulate TLV-encoded data
data = b'\xA4\x0C' + b'\x01'*12
info = ApplicationInfo.parse(data)
assert info.instance_uid == b'\xAA\xBB'
assert info.ecc_public_key == b'\x01\x02'
assert info.key_uid == b'\xCC\xDD'
assert info.capabilities == 7
assert info.version_major == 2
assert info.version_minor == 5
def test_parse_tlv_missing_a4(monkeypatch):
def dummy_parse_tlv(data):
# No 0xA4 tag present
return {}
monkeypatch.setattr(
'keycard.parsing.application_info.parse_tlv',
dummy_parse_tlv
)
with pytest.raises(InvalidResponseError):
ApplicationInfo.parse(b'\x00\x01\x02')
def test_parse_tlv_missing_fields(monkeypatch):
def dummy_parse_tlv(data):
# Missing some tags
return {
0xA4: [b'']
}
class DummyCapabilities:
@staticmethod
def parse(val):
return val
monkeypatch.setattr(
'keycard.parsing.application_info.parse_tlv',
dummy_parse_tlv
)
monkeypatch.setattr(
'keycard.parsing.application_info.Capabilities',
DummyCapabilities
)
# Should raise KeyError due to missing tags in inner_tlv
with pytest.raises(KeyError):
ApplicationInfo.parse(b'\xA4\x01\x00')
def test_parse_pubkey_empty(monkeypatch):
monkeypatch.setattr(
'keycard.parsing.application_info.Capabilities',
DummyCapabilities
)
# No pubkey bytes
data = bytes([0x80, 0x00])
info = ApplicationInfo.parse(data)
assert info.ecc_public_key == b''
assert info.capabilities == 1 # Only CREDENTIALS_MANAGEMENT
assert info.instance_uid is None
assert info.key_uid is None
assert info.version_major == 0
assert info.version_minor == 0
def test_is_initialized_property():
info = ApplicationInfo(
capabilities=1,
ecc_public_key=None,
instance_uid=None,
key_uid=None,
version_major=0,
version_minor=0,
)
assert not info.is_initialized
info.key_uid = b'\x01'
assert info.is_initialized
@@ -0,0 +1,87 @@
import pytest
from keycard.parsing.identity import parse, InvalidResponseError
from keycard.parsing import identity
def make_tlv(tag, value):
return bytes([tag, len(value)]) + value
def fake_parse_tlv(data):
if data == b'outer':
return {0xA0: [b'inner']}
elif data == b'inner':
return {0x8A: [b'cert'], 0x30: [b'sig']}
return {}
def test_parse_success(monkeypatch):
monkeypatch.setattr(
identity,
"parse_tlv",
lambda data:
{0xA0: [b'inner']} if data == b'data'
else {0x8A: [b'c'*95], 0x30: [b's'*64]})
monkeypatch.setattr(
identity,
"_verify",
lambda certificate, signature, challenge: None
)
monkeypatch.setattr(
identity,
"_recover_public_key",
lambda certificate: b'pubkey'
)
challenge = b'challenge'
data = b'data'
result = parse(challenge, data)
assert result == b'pubkey'
def test_parse_malformed_index(monkeypatch):
monkeypatch.setattr(
identity,
"parse_tlv",
lambda data: {0xA0: [b'inner']} if data == b'data' else {}
)
challenge = b'challenge'
data = b'data'
with pytest.raises(
InvalidResponseError,
match="Malformed identity response"
):
parse(challenge, data)
def test_parse_certificate_too_short(monkeypatch):
monkeypatch.setattr(
identity,
"parse_tlv",
lambda data:
{0xA0: [b'inner']} if data == b'data'
else {0x8A: [b'c'*10], 0x30: [b's'*64]}
)
challenge = b'challenge'
data = b'data'
with pytest.raises(
InvalidResponseError,
match="Malformed identity response"
):
parse(challenge, data)
def test_parse_signature_too_short(monkeypatch):
monkeypatch.setattr(
identity,
"parse_tlv",
lambda data:
{0xA0: [b'inner']} if data == b'data'
else {0x8A: [b'c'*95], 0x30: [b's'*10]})
challenge = b'challenge'
data = b'data'
with pytest.raises(
InvalidResponseError,
match="Malformed identity response"
):
parse(challenge, data)
@@ -0,0 +1,117 @@
from keycard.parsing.signature_result import SignatureResult
from keycard.constants import SigningAlgorithm
from unittest import mock
def test_signature_result_with_minimal_r_s():
r = 1
s = 1
digest = b'\x01' * 32
public_key = b'\x02' + b'\x01' * 32
sig = SignatureResult(
digest=digest,
algo=SigningAlgorithm.ECDSA_SECP256K1,
r=r,
s=s,
public_key=public_key,
recovery_id=0
)
assert sig.r == b'\x01'
assert sig.s == b'\x01'
assert sig.signature == b'\x01\x01'
def test_signature_result_with_large_r_s():
r = 2**255
s = 2**255 - 1
digest = b'\x01' * 32
public_key = b'\x02' + b'\x01' * 32
sig = SignatureResult(
digest=digest,
algo=SigningAlgorithm.ECDSA_SECP256K1,
r=r,
s=s,
public_key=public_key,
recovery_id=2
)
assert sig.r == r.to_bytes((r.bit_length() + 7) // 8, 'big')
assert sig.s == s.to_bytes((s.bit_length() + 7) // 8, 'big')
assert sig.recovery_id == 2
def test_signature_result_signature_der_property():
r = 123
s = 456
digest = b'\x01' * 32
public_key = b'\x02' + b'\x01' * 32
with mock.patch(
'keycard.parsing.signature_result.util.sigencode_der'
) as mock_sigencode_der:
mock_sigencode_der.return_value = b'der'
sig = SignatureResult(
digest=digest,
algo=SigningAlgorithm.ECDSA_SECP256K1,
r=r,
s=s,
public_key=public_key,
recovery_id=3
)
der = sig.signature_der
assert der == b'der'
mock_sigencode_der.assert_called_once_with(r, s, 3)
def test_signature_result_repr_exists():
r = int.from_bytes(b'\x01' * 32, 'big')
s = int.from_bytes(b'\x01' * 32, 'big')
digest = b'\x01' * 32
public_key = b'\x02' + b'\x01' * 32
sig = SignatureResult(
digest=digest,
algo=SigningAlgorithm.ECDSA_SECP256K1,
r=r,
s=s,
public_key=public_key,
recovery_id=0
)
assert isinstance(repr(sig), str)
def test_signature_result_public_key_and_recovery_id_priority():
r = 5
s = 6
digest = b'\x01' * 32
public_key = b'\x02' + b'\x01' * 32
sig = SignatureResult(
digest=digest,
algo=SigningAlgorithm.ECDSA_SECP256K1,
r=r,
s=s,
public_key=public_key,
recovery_id=7
)
assert sig.public_key == public_key
assert sig.recovery_id == 7
def test_signature_result_missing_public_key_calls_recover():
r = 10
s = 20
digest = b'\x01' * 32
with mock.patch.object(
SignatureResult,
"_recover_public_key",
return_value=b'\x02' + b'\x02' * 32
) as mock_pubkey:
sig = SignatureResult(
digest=digest,
algo=SigningAlgorithm.ECDSA_SECP256K1,
r=r,
s=s,
public_key=None,
recovery_id=9
)
assert sig.public_key == b'\x02' + b'\x02' * 32
assert sig.recovery_id == 9
mock_pubkey.assert_called_once_with(digest)
+122
View File
@@ -0,0 +1,122 @@
import pytest
from keycard.exceptions import InvalidResponseError
from keycard.parsing import tlv
def test_parse_ber_length_short_form():
data = bytes([0x05])
length, consumed = tlv._parse_ber_length(data, 0)
assert length == 5
assert consumed == 1
def test_parse_ber_length_long_form_1byte():
data = bytes([0x81, 0x10])
length, consumed = tlv._parse_ber_length(data, 0)
assert length == 0x10
assert consumed == 2
def test_parse_ber_length_long_form_2bytes():
data = bytes([0x82, 0x01, 0xF4])
length, consumed = tlv._parse_ber_length(data, 0)
assert length == 500
assert consumed == 3
def test_parse_ber_length_unsupported_length():
data = bytes([0x85, 0, 0, 0, 0, 0])
with pytest.raises(InvalidResponseError):
tlv._parse_ber_length(data, 0)
def test_parse_ber_length_exceeds_buffer():
data = bytes([0x82, 0x01])
with pytest.raises(InvalidResponseError):
tlv._parse_ber_length(data, 0)
def test_parse_tlv_single():
data = bytes([0x01, 0x03, ord('a'), ord('b'), ord('c')])
result = tlv.parse_tlv(data)
assert 0x01 in result
assert result[0x01][0] == b'abc'
def test_parse_tlv_multiple_tags():
data = bytes([
0x01, 0x02, ord('h'), ord('i'),
0x02, 0x01, ord('x')])
result = tlv.parse_tlv(data)
assert result[0x01][0] == b'hi'
assert result[0x02][0] == b'x'
def test_parse_tlv_repeated_tag():
data = bytes([
0x01, 0x01, ord('a'),
0x01, 0x02, ord('b'), ord('c')
])
result = tlv.parse_tlv(data)
assert result[0x01][0] == b'a'
assert result[0x01][1] == b'bc'
def test_parse_tlv_long_length():
data = bytes([0x10, 0x82, 0x01, 0x01]) + b'a' * 257
result = tlv.parse_tlv(data)
assert result[0x10][0] == b'a' * 257
def test_parse_tlv_incomplete_value():
data = bytes([0x01, 0x05, ord('a'), ord('b'), ord('c')])
with pytest.raises(InvalidResponseError):
tlv.parse_tlv(data)
def test_encode_tlv_short():
tag = 0x01
value = b'\xAB\xCD'
encoded = tlv.encode_tlv(tag, value)
assert encoded == b'\x01\x02\xAB\xCD'
def test_encode_tlv_1byte_long_form():
tag = 0x02
value = b'\x00' * 130 # >127 triggers long form
encoded = tlv.encode_tlv(tag, value)
assert encoded[:2] == b'\x02\x81'
assert encoded[2] == 130
assert encoded[3:] == value
def test_encode_tlv_2byte_long_form():
tag = 0x03
value = b'\xFF' * 300 # >255 triggers 2-byte length
encoded = tlv.encode_tlv(tag, value)
assert encoded[:3] == b'\x03\x82\x01'
assert encoded[3] == 0x2C # 300 = 0x012C
assert encoded[4:] == value
def test_encode_tlv_empty_value():
tag = 0x04
value = b""
encoded = tlv.encode_tlv(tag, value)
assert encoded == b'\x04\x00'
def test_encode_tlv_max_short_length():
tag = 0x10
value = b"A" * 127
encoded = tlv.encode_tlv(tag, value)
assert encoded[1] == 127
assert len(encoded) == 2 + 127
def test_encode_tlv_max_1byte_long_form():
tag = 0x20
value = b"A" * 255
encoded = tlv.encode_tlv(tag, value)
assert encoded[1:3] == b'\x81\xFF'
+51
View File
@@ -0,0 +1,51 @@
import pytest
from keycard.apdu import encode_lv, APDUResponse
def test_encode_lv_valid():
value = bytes(10)
result = encode_lv(value)
assert result == b"\x0A" + value
def test_encode_lv_too_long():
value = bytes(256)
with pytest.raises(ValueError):
encode_lv(value)
def test_encode_lv_empty():
value = bytes()
result = encode_lv(value)
assert result == b"\x00"
def test_encode_lv_single_byte():
value = bytes([0xFF])
result = encode_lv(value)
assert result == b"\x01\xFF"
def test_encode_lv_max_length():
value = bytes(255)
result = encode_lv(value)
assert result == b"\xFF" + value
def test_apdu_response_success():
r = APDUResponse([0x01, 0x02], 0x9000)
assert r.data == [0x01, 0x02]
assert r.status_word == 0x9000
def test_apdu_response_error_status():
r = APDUResponse([], 0x6A82)
assert r.status_word == 0x6A82
assert isinstance(r.status_word, int)
def test_apdu_response_all_status_range():
for sw in [0x9000, 0x6A80, 0x6A84, 0x6982]:
r = APDUResponse([0x00], sw)
assert r.status_word == sw
assert r.data == [0x00]
+531
View File
@@ -0,0 +1,531 @@
import pytest
from unittest.mock import MagicMock, patch
from keycard import constants
from keycard.apdu import APDUResponse
from keycard.exceptions import APDUError
from keycard.parsing.exported_key import ExportedKey
from keycard.keycard import KeyCard
from keycard.transport import Transport
def test_keycard_init_with_transport():
transport = MagicMock(spec=Transport)
kc = KeyCard(transport)
assert kc.transport == transport
assert kc.card_public_key is None
assert kc.session is None
def test_select_sets_card_pubkey():
mock_info = MagicMock()
mock_info.ecc_public_key = b'pubkey'
with patch('keycard.keycard.commands.select', return_value=mock_info):
kc = KeyCard(MagicMock())
result = kc.select()
assert kc.card_public_key == b'pubkey'
assert result == mock_info
def test_init_calls_command():
transport = MagicMock()
with patch('keycard.keycard.commands.init') as mock_init:
kc = KeyCard(transport)
kc.card_public_key = b'pub'
kc.init(b'pin', b'puk', b'secret')
mock_init.assert_called_once_with(kc, b'pin', b'puk', b'secret')
def test_ident_calls_command():
with patch('keycard.keycard.commands.ident', return_value='identity') as m:
kc = KeyCard(MagicMock())
result = kc.ident(b'challenge')
m.assert_called_once()
assert result == 'identity'
def test_open_secure_channel_with_mutual_authentication():
with patch(
'keycard.keycard.commands.open_secure_channel'
) as mock_osc:
with patch(
'keycard.keycard.commands.mutually_authenticate'
) as mock_ma:
mock_osc.return_value = 'session'
kc = KeyCard(MagicMock())
kc._card_public_key = b'pub'
kc.open_secure_channel(1, b'pairing_key')
mock_osc.assert_called_once_with(kc, 1, b'pairing_key')
mock_ma.assert_called_once_with(kc)
assert kc.session == 'session'
def test_open_secure_channel_without_mutual_authentication():
with patch(
'keycard.keycard.commands.open_secure_channel'
) as mock_osc:
with patch(
'keycard.keycard.commands.mutually_authenticate'
) as mock_ma:
mock_osc.return_value = 'session'
kc = KeyCard(MagicMock())
kc._card_public_key = b'pub'
kc.open_secure_channel(1, b'pairing_key', False)
mock_osc.assert_called_once_with(kc, 1, b'pairing_key')
mock_ma.assert_not_called()
assert kc.session == 'session'
def test_mutually_authenticate_calls_command():
with patch('keycard.keycard.commands.mutually_authenticate') as mock_auth:
kc = KeyCard(MagicMock())
kc.secure_session = 'sess'
kc.mutually_authenticate()
mock_auth.assert_called_once()
def test_pair_returns_expected_tuple():
with patch('keycard.keycard.commands.pair', return_value=(1, b'crypt')):
kc = KeyCard(MagicMock())
result = kc.pair(b'shared')
assert result == (1, b'crypt')
def test_verify_pin_delegates_call_and_returns_result():
with patch(
'keycard.keycard.commands.verify_pin',
return_value=True
) as mock_cmd:
kc = KeyCard(MagicMock())
kc.secure_session = 'sess'
result = kc.verify_pin('1234')
mock_cmd.assert_called_once_with(kc, b'1234')
assert result is True
def test_unpair_delegates_call():
transport = MagicMock()
with patch('keycard.keycard.commands.unpair') as mock_unpair:
kc = KeyCard(transport)
kc.secure_session = 'sess'
kc.unpair(2)
mock_unpair.assert_called_once_with(kc, 2)
def test_send_secure_apdu_success():
mock_session = MagicMock()
mock_session.wrap_apdu.return_value = b'encrypted'
mock_session.unwrap_response.return_value = (b'plaintext', 0x9000)
mock_transport = MagicMock()
mock_response = MagicMock()
mock_response.status_word = 0x9000
mock_response.data = b'ciphertext'
mock_transport.send_apdu.return_value = mock_response
kc = KeyCard(mock_transport)
kc.session = mock_session
result = kc.send_secure_apdu(0xA4, 0x01, 0x02, b'data')
mock_session.wrap_apdu.assert_called_once_with(
cla=kc.transport.send_apdu.call_args[0][0][0],
ins=0xA4,
p1=0x01,
p2=0x02,
data=b'data'
)
mock_transport.send_apdu.assert_called_once()
mock_session.unwrap_response.assert_called_once_with(mock_response)
assert result == b'plaintext'
def test_send_secure_apdu_raises_on_transport_status_word():
mock_session = MagicMock()
mock_session.wrap_apdu.return_value = b'encrypted'
mock_transport = MagicMock()
mock_transport.send_apdu.return_value = APDUResponse(
b'', status_word=0x6A82)
kc = KeyCard(mock_transport)
kc.session = mock_session
with pytest.raises(APDUError) as exc:
kc.send_secure_apdu(0xA4, 0x00, 0x00, b'data')
assert exc.value.args[0] == 'APDU failed with SW=6A82'
def test_send_secure_apdu_raises_on_unwrap_status_word():
mock_session = MagicMock()
mock_session.wrap_apdu.return_value = b'encrypted'
mock_session.unwrap_response.return_value = (b'plaintext', 0x6A84)
mock_transport = MagicMock()
mock_transport.send_apdu.return_value = APDUResponse(
b'', status_word=0x9000)
kc = KeyCard(mock_transport)
kc.session = mock_session
with pytest.raises(APDUError) as exc:
kc.send_secure_apdu(0xA4, 0x00, 0x00, b'data')
assert exc.value.args[0] == 'APDU failed with SW=6A84'
def test_send_apdu_success(monkeypatch):
mock_transport = MagicMock()
mock_response = MagicMock()
mock_response.status_word = 0x9000
mock_response.data = b'response'
mock_transport.send_apdu.return_value = mock_response
kc = KeyCard(mock_transport)
result = kc.send_apdu(ins=0xA4, p1=0x01, p2=0x02, data=b'data')
expected_apdu = bytes([0x80, 0xA4, 0x01, 0x02, 4]) + b'data'
mock_transport.send_apdu.assert_called_once_with(expected_apdu)
assert result == b'response'
def test_send_apdu_raises_on_non_success_status(monkeypatch):
mock_transport = MagicMock()
mock_transport.send_apdu.return_value = APDUResponse(b'', 0x6A82)
kc = KeyCard(mock_transport)
with pytest.raises(APDUError) as exc:
kc.send_apdu(ins=0xA4, p1=0x00, p2=0x00, data=b'')
assert exc.value.args[0] == 'APDU failed with SW=6A82'
def test_send_apdu_with_custom_cla(monkeypatch):
mock_transport = MagicMock()
mock_response = MagicMock()
mock_response.status_word = 0x9000
mock_response.data = b'abc'
mock_transport.send_apdu.return_value = mock_response
kc = KeyCard(mock_transport)
result = kc.send_apdu(ins=0xA4, p1=0x01, p2=0x02, data=b'data', cla=0x90)
expected_apdu = bytes([0x90, 0xA4, 0x01, 0x02, 4]) + b'data'
mock_transport.send_apdu.assert_called_once_with(expected_apdu)
assert result == b'abc'
def test_unblock_pin_calls_command_with_bytes():
with patch('keycard.keycard.commands.unblock_pin') as mock_unblock:
kc = KeyCard(MagicMock())
puk = b'123456789012'
new_pin = b'654321'
kc.unblock_pin(puk, new_pin)
mock_unblock.assert_called_once_with(kc, puk + new_pin)
def test_unblock_pin_calls_command_with_str():
with patch('keycard.keycard.commands.unblock_pin') as mock_unblock:
kc = KeyCard(MagicMock())
puk = '123456789012'
new_pin = '654321'
kc.unblock_pin(puk, new_pin)
mock_unblock.assert_called_once_with(
kc,
(puk + new_pin).encode('utf-8')
)
def test_unblock_pin_calls_command_with_mixed_types():
with patch('keycard.keycard.commands.unblock_pin') as mock_unblock:
kc = KeyCard(MagicMock())
puk = '123456789012'
new_pin = b'654321'
kc.unblock_pin(puk, new_pin)
mock_unblock.assert_called_once_with(kc, puk.encode('utf-8') + new_pin)
def test_remove_key_calls_command():
with patch('keycard.keycard.commands.remove_key') as mock_remove_key:
kc = KeyCard(MagicMock())
kc.remove_key()
mock_remove_key.assert_called_once_with(kc)
def test_store_data_calls_command_with_default_slot():
with patch('keycard.keycard.commands.store_data') as mock_store_data:
kc = KeyCard(MagicMock())
data = b'testdata'
kc.store_data(data)
mock_store_data.assert_called_once_with(
kc, data, constants.StorageSlot.PUBLIC
)
def test_store_data_calls_command_with_custom_slot():
with patch('keycard.keycard.commands.store_data') as mock_store_data:
kc = KeyCard(MagicMock())
data = b'testdata'
slot = MagicMock()
kc.store_data(data, slot)
mock_store_data.assert_called_once_with(kc, data, slot)
def test_store_data_raises_value_error_on_invalid_slot():
with patch(
'keycard.keycard.commands.store_data',
side_effect=ValueError("Invalid slot")
):
kc = KeyCard(MagicMock())
with pytest.raises(ValueError, match="Invalid slot"):
kc.store_data(b'testdata', slot="INVALID")
def test_store_data_raises_value_error_on_data_too_long():
with patch(
'keycard.keycard.commands.store_data',
side_effect=ValueError("data is too long")
):
kc = KeyCard(MagicMock())
long_data = b'a' * 128
with pytest.raises(ValueError, match="data is too long"):
kc.store_data(long_data)
def test_get_data_calls_command_with_default_slot():
with patch(
'keycard.keycard.commands.get_data',
return_value=b'data'
) as mock_get_data:
kc = KeyCard(MagicMock())
result = kc.get_data()
mock_get_data.assert_called_once_with(kc, constants.StorageSlot.PUBLIC)
assert result == b'data'
def test_get_data_calls_command_with_custom_slot():
with patch(
'keycard.keycard.commands.get_data',
return_value=b'data'
) as mock_get_data:
kc = KeyCard(MagicMock())
slot = MagicMock()
result = kc.get_data(slot)
mock_get_data.assert_called_once_with(kc, slot)
assert result == b'data'
def test_export_key_delegates_and_returns_result():
mock_exported = MagicMock(spec=ExportedKey)
with patch(
'keycard.keycard.commands.export_key',
return_value=mock_exported
) as mock_cmd:
kc = KeyCard(MagicMock())
result = kc.export_key(
derivation_option=constants.DerivationOption.DERIVE,
public_only=True,
keypath="m/44'/60'/0'/0/0",
make_current=True,
source=constants.DerivationSource.PARENT
)
mock_cmd.assert_called_once_with(
kc,
derivation_option=constants.DerivationOption.DERIVE,
public_only=True,
keypath="m/44'/60'/0'/0/0",
make_current=True,
source=constants.DerivationSource.PARENT
)
assert result is mock_exported
def test_export_current_key_delegates_and_returns_result():
mock_exported = MagicMock(spec=ExportedKey)
with patch(
'keycard.keycard.commands.export_key',
return_value=mock_exported
) as mock_cmd:
kc = KeyCard(MagicMock())
result = kc.export_current_key(public_only=False)
mock_cmd.assert_called_once_with(
kc,
derivation_option=constants.DerivationOption.CURRENT,
public_only=False,
keypath=None,
make_current=False,
source=constants.DerivationSource.MASTER
)
assert result is mock_exported
def test_sign_current_key():
with patch("keycard.keycard.commands.sign") as mock_sign:
card = KeyCard(MagicMock())
digest = b"\xAA" * 32
mock_sign.return_value = "signed"
result = card.sign(digest)
mock_sign.assert_called_once_with(
card,
digest,
constants.DerivationOption.CURRENT,
constants.SigningAlgorithm.ECDSA_SECP256K1
)
assert result == "signed"
def test_sign_with_path():
with patch("keycard.keycard.commands.sign") as mock_sign:
card = KeyCard(MagicMock())
digest = b"\xBB" * 32
path = [0x8000002C, 0x8000003C, 0, 0, 0] # m/44'/60'/0'/0/0
mock_sign.return_value = "sig"
result = card.sign_with_path(digest, path)
mock_sign.assert_called_once_with(
card,
digest,
constants.DerivationOption.DERIVE,
constants.SigningAlgorithm.ECDSA_SECP256K1,
derivation_path=path
)
assert result == "sig"
def test_sign_with_path_make_current():
with patch("keycard.keycard.commands.sign") as mock_sign:
card = KeyCard(MagicMock())
digest = b"\xCC" * 32
path = [0x8000002C, 0x8000003C, 0, 0, 0]
mock_sign.return_value = "sig"
result = card.sign_with_path(digest, path, make_current=True)
mock_sign.assert_called_once_with(
card,
digest,
constants.DerivationOption.DERIVE_AND_MAKE_CURRENT,
constants.SigningAlgorithm.ECDSA_SECP256K1,
derivation_path=path
)
assert result == "sig"
def test_sign_pinless():
with patch("keycard.keycard.commands.sign") as mock_sign:
card = KeyCard(MagicMock())
digest = b"\xDD" * 32
mock_sign.return_value = "sig"
result = card.sign_pinless(digest)
mock_sign.assert_called_once_with(
card,
digest,
constants.DerivationOption.PINLESS,
constants.SigningAlgorithm.ECDSA_SECP256K1
)
assert result == "sig"
def test_load_key_bip39_seed():
with patch("keycard.keycard.commands.load_key") as mock_load_key:
card = KeyCard(MagicMock())
seed = b"\xAB" * 64
mock_load_key.return_value = b"uid"
result = card.load_key(
key_type=constants.LoadKeyType.BIP39_SEED,
bip39_seed=seed
)
mock_load_key.assert_called_once_with(
card,
key_type=constants.LoadKeyType.BIP39_SEED,
public_key=None,
private_key=None,
chain_code=None,
bip39_seed=seed,
lee_seed=None
)
assert result == b"uid"
def test_load_key_ecc_pair():
with patch("keycard.keycard.commands.load_key") as mock_load_key:
card = KeyCard(MagicMock())
pub = b"\x04" + b"\x01" * 64
priv = b"\x02" * 32
mock_load_key.return_value = b"uid"
result = card.load_key(
key_type=constants.LoadKeyType.ECC,
public_key=pub,
private_key=priv
)
mock_load_key.assert_called_once_with(
card,
key_type=constants.LoadKeyType.ECC,
public_key=pub,
private_key=priv,
chain_code=None,
bip39_seed=None,
lee_seed=None
)
assert result == b"uid"
def test_load_key_extended():
with patch("keycard.keycard.commands.load_key") as mock_load_key:
card = KeyCard(MagicMock())
pub = b"\x04" + b"\x01" * 64
priv = b"\x02" * 32
chain = b"\x00" * 32
mock_load_key.return_value = b"uid"
result = card.load_key(
key_type=constants.LoadKeyType.EXTENDED_ECC,
public_key=pub,
private_key=priv,
chain_code=chain
)
mock_load_key.assert_called_once_with(
card,
key_type=constants.LoadKeyType.EXTENDED_ECC,
public_key=pub,
private_key=priv,
chain_code=chain,
bip39_seed=None,
lee_seed=None
)
assert result == b"uid"
def test_keycard_set_pinless_path():
with patch("keycard.keycard.commands.set_pinless_path") as mock_cmd:
card = KeyCard(MagicMock())
card.set_pinless_path("m/44'/60'/0'/0/0")
mock_cmd.assert_called_once_with(card, "m/44'/60'/0'/0/0")
def test_keycard_generate_mnemonic():
with patch("keycard.keycard.commands.generate_mnemonic") as mock_cmd:
card = KeyCard(None)
mock_cmd.return_value = [0, 2047, 1337, 42]
result = card.generate_mnemonic(checksum_size=6)
mock_cmd.assert_called_once_with(card, 6)
assert result == [0, 2047, 1337, 42]
def test_keycard_derive_key():
with patch("keycard.keycard.commands.derive_key") as mock_cmd:
card = KeyCard(MagicMock())
card.derive_key("m/44'/60'/0'/0/0")
mock_cmd.assert_called_once_with(card, "m/44'/60'/0'/0/0")
@@ -0,0 +1,56 @@
import pytest
from keycard.card_interface import CardInterface
from keycard.preconditions import make_precondition
from keycard.exceptions import InvalidStateError
class DummyCard(CardInterface):
def __init__(self, **attrs):
for k, v in attrs.items():
setattr(self, k, v)
def test_precondition_passes_when_attribute_true():
@make_precondition('is_ready')
def do_something(card):
return "success"
card = DummyCard(is_ready=True)
assert do_something(card) == "success"
def test_precondition_raises_when_attribute_false():
@make_precondition('is_ready')
def do_something(card):
return "should not reach"
card = DummyCard(is_ready=False)
with pytest.raises(InvalidStateError) as exc:
do_something(card)
assert "Is Ready must be satisfied." in str(exc.value)
def test_precondition_raises_when_attribute_missing():
@make_precondition('is_ready')
def do_something(card):
return "should not reach"
card = DummyCard()
with pytest.raises(InvalidStateError) as exc:
do_something(card)
assert "Is Ready must be satisfied." in str(exc.value)
def test_precondition_custom_display_name():
@make_precondition('is_ready', display_name="Custom Name")
def do_something(card):
return "success"
card = DummyCard(is_ready=False)
with pytest.raises(InvalidStateError) as exc:
do_something(card)
assert "Custom Name must be satisfied." in str(exc.value)
def test_precondition_passes_args_kwargs():
@make_precondition('is_ready')
def do_something(card, x, y=2):
return x + y
card = DummyCard(is_ready=True)
assert do_something(card, 3, y=4) == 7
@@ -0,0 +1,132 @@
import pytest
from keycard.apdu import APDUResponse
from keycard.secure_channel import SecureChannel
@pytest.fixture
def session_params():
return {
"shared_secret": bytes(32),
"pairing_key": bytes(32),
"salt": bytes(16),
"seed_iv": bytes(16),
}
def test_open_sets_authenticated_and_keys(session_params):
session = SecureChannel.open(**session_params)
assert session.authenticated is True
assert isinstance(session.enc_key, bytes) and len(session.enc_key) == 32
assert isinstance(session.mac_key, bytes) and len(session.mac_key) == 32
assert session.iv == session_params['seed_iv']
def test_wrap_apdu_authenticated(session_params):
session = SecureChannel.open(**session_params)
wrapped = session.wrap_apdu(
0x80,
0xCA,
0x00,
0x00,
b'testdata'
)
assert isinstance(wrapped, bytes)
assert len(wrapped) > 16 # IV + encrypted data
@pytest.mark.parametrize("ins,should_raise", [
(0x11, False),
(0xCA, True),
])
def test_wrap_apdu_auth_check(ins, should_raise):
session = SecureChannel(
b'\x01' * 32,
b'\x02' * 32,
bytes(16),
authenticated=False
)
if should_raise:
with pytest.raises(ValueError, match="not authenticated"):
session.wrap_apdu(0x80, ins, 0x00, 0x00, b'test')
else:
session.wrap_apdu(0x80, ins, 0x00, 0x00, b'test')
def test_unwrap_response_authenticated_and_mac(monkeypatch, session_params):
# Patch aes_cbc_encrypt and aes_cbc_decrypt to simulate expected behavior
session = SecureChannel.open(**session_params)
plaintext = b"hello world" + b'\x90\x00' # status word 0x9000
# Simulate encryption and MAC
def fake_decrypt(key, iv, data):
return plaintext
def fake_encrypt(key, iv, data, padding=True):
# Return 16 bytes MAC for mac_key, else just return dummy
if key == session.mac_key:
return b'Y' * 16
return b'Z' * (len(data) // 16 * 16)
monkeypatch.setattr('keycard.secure_channel.aes_cbc_decrypt', fake_decrypt)
monkeypatch.setattr('keycard.secure_channel.aes_cbc_encrypt', fake_encrypt)
# Compose response: 16 bytes MAC + encrypted data
response = APDUResponse(b'Y' * 16 + b'Z' * 16, 0x900)
out, sw = session.unwrap_response(response)
assert out == plaintext[:-2]
assert sw == 0x9000
def test_unwrap_response_not_authenticated_raises(session_params):
session = SecureChannel.open(**session_params)
session.authenticated = False
response = APDUResponse(bytes(32), 0x900)
with pytest.raises(ValueError, match="not authenticated"):
session.unwrap_response(response)
def test_unwrap_response_invalid_length_raises(session_params):
session = SecureChannel.open(**session_params)
session.authenticated = True
response = APDUResponse(bytes(10), 0x900)
with pytest.raises(ValueError, match="Invalid secure response length"):
session.unwrap_response(response)
def test_unwrap_response_invalid_mac_raises(monkeypatch, session_params):
session = SecureChannel.open(**session_params)
# Patch aes_cbc_encrypt to return a different MAC
def fake_encrypt(key, iv, data, padding=True):
return b'X' * 16
monkeypatch.setattr(
'keycard.secure_channel.aes_cbc_encrypt',
fake_encrypt
)
response = APDUResponse(b'Y' * 16 + b'Z' * 16, 0x900)
with pytest.raises(ValueError, match="Invalid MAC"):
session.unwrap_response(response)
def test_unwrap_response_missing_status_word(monkeypatch, session_params):
session = SecureChannel.open(**session_params)
def fake_decrypt(key, iv, data):
return b'\x01'
monkeypatch.setattr(
'keycard.secure_channel.aes_cbc_decrypt',
fake_decrypt)
monkeypatch.setattr(
'keycard.secure_channel.aes_cbc_encrypt',
lambda *a, **k: b'Y' * 16)
response = APDUResponse(b'Y' * 16 + b'Z' * 16, 0x9000)
with pytest.raises(ValueError, match="Missing status word"):
session.unwrap_response(response)
+87
View File
@@ -0,0 +1,87 @@
import pytest
from unittest.mock import MagicMock, patch
from keycard.transport import Transport
from keycard.apdu import APDUResponse
from keycard.exceptions import TransportError
@patch("keycard.transport.readers")
def test_transport_connect_success(mock_readers):
mock_connection = MagicMock()
mock_reader = MagicMock()
mock_reader.createConnection.return_value = mock_connection
mock_readers.return_value = [mock_reader]
transport = Transport()
transport.connect()
mock_readers.assert_called_once()
mock_connection.connect.assert_called_once()
assert transport.connection == mock_connection
@patch("keycard.transport.readers", return_value=[])
def test_transport_connect_no_reader(mock_readers):
transport = Transport()
with pytest.raises(TransportError, match="No smart card readers found"):
transport.connect()
@patch("keycard.transport.readers")
def test_send_apdu_success(mock_readers):
mock_connection = MagicMock()
mock_connection.transmit.return_value = ([1, 2, 3], 0x90, 0x00)
mock_reader = MagicMock()
mock_reader.createConnection.return_value = mock_connection
mock_readers.return_value = [mock_reader]
transport = Transport()
transport.connection = mock_connection
apdu = b"\x00\xA4\x04\x00"
response = transport.send_apdu(apdu)
mock_connection.transmit.assert_called_once_with(list(apdu))
assert isinstance(response, APDUResponse)
assert response.data == [1, 2, 3]
assert response.status_word == 0x9000
@patch("keycard.transport.readers")
def test_send_apdu_auto_connect(mock_readers):
mock_connection = MagicMock()
mock_connection.transmit.return_value = ([0x90], 0x90, 0x00)
mock_reader = MagicMock()
mock_reader.createConnection.return_value = mock_connection
mock_readers.return_value = [mock_reader]
transport = Transport()
response = transport.send_apdu(b"\x00")
assert isinstance(response, APDUResponse)
assert response.status_word == 0x9000
assert mock_connection.connect.called
@patch("keycard.transport.readers")
def test_transport_context_manager(mock_readers):
mock_connection = MagicMock()
mock_reader = MagicMock()
mock_reader.createConnection.return_value = mock_connection
mock_readers.return_value = [mock_reader]
with Transport() as transport:
assert transport.connection == mock_connection
mock_connection.disconnect.assert_called_once()
assert transport.connection is None
def test_exit_without_connection():
transport = Transport()
transport.connection = None
transport.__exit__(None, None, None)
assert transport.connection is None
+50
View File
@@ -0,0 +1,50 @@
from ecdsa import ECDH, SigningKey, SECP256k1, VerifyingKey
from keycard.crypto.aes import aes_cbc_encrypt
def test_full_crypto_vector():
card_pubkey_bytes = bytes.fromhex(
'04525481c70263f79c29092e95cfc972e0eb427ea31fe6cc6c96787eb12205737'
'd431929f0837c66a4ee514578a7d5eb78087927851b15b691a79cdea431bd63d9'
)
ephemeral_private_bytes = bytes.fromhex(
'e3b9a83efa7b113bac4562a77c496de21a9f91a17fa8dcb2384ed7154bb43c5c'
)
iv = bytes.fromhex('d2c5feedf4bdb935057f8c78cf92395e')
expected_ciphertext = bytes.fromhex(
'4707ca7edf4218c416f252967da55f1b6e2e65f0ffa0305f71501f53aa283fd5'
'aaa8b049e75288c01034f25893db43d4db4bd6dfc4a6546658dd22227082aa58'
)
ephemeral_key = SigningKey.from_string(
ephemeral_private_bytes,
curve=SECP256k1
)
card_pubkey = VerifyingKey.from_string(
card_pubkey_bytes,
curve=SECP256k1
)
ecdh = ECDH(
curve=SECP256k1,
private_key=ephemeral_key,
public_key=card_pubkey
)
shared_secret = ecdh.generate_sharedsecret_bytes()
pin = b'123456'
puk = b'123456789012'
pairing_secret = b'A' * 32
plaintext = pin + puk + pairing_secret
ciphertext: bytes = aes_cbc_encrypt(shared_secret, iv, plaintext)
assert ciphertext == expected_ciphertext, (
"Ciphertext does not match expected test vector"
)
def test_crypto_vector_fails_on_mismatch():
bogus = b"\x00" * 48
actual = b"\x01" * 48
assert bogus != actual, "Test vector should intentionally fail mismatch"