mirror of
https://github.com/logos-blockchain/logos-execution-zone.git
synced 2026-08-25 03:11:21 +00:00
fixes
This commit is contained in:
@@ -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')
|
||||
@@ -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)
|
||||
@@ -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'
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user