Migrates all 43 sim modules and 23 test files (~7.8k LOC, 686 tests) from .worktrees/sim-framework/ into pm3py/sim/. Import fixes: - 4 files: ..protocol/..transport → ..core.protocol/..core.transport - trace_fmt.py: pm3py.hf_15 → pm3py.trace.ndef - test_sim_pm3medium.py: flat imports → core.* - test_sim_trace_fmt.py: flat imports → core.* - Added sim Cmd entries to core/protocol.py (EML_SETMEM, SIM_TABLE_*) 751 tests passing (686 sim + 65 core). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
421 lines
15 KiB
Python
421 lines
15 KiB
Python
"""Tests for AES TAM1/MAM authentication in NxpAesAuth mixin."""
|
|
import asyncio
|
|
import os
|
|
|
|
import pytest
|
|
from Crypto.Cipher import AES
|
|
|
|
from pm3py.sim.frame import RFFrame
|
|
|
|
|
|
def run(coro):
|
|
return asyncio.get_event_loop().run_until_complete(coro)
|
|
|
|
|
|
class TestTam1:
|
|
def test_challenge_readbuffer(self):
|
|
"""Full TAM1 flow: CHALLENGE computes TResponse, READBUFFER returns it."""
|
|
from pm3py.sim.icode_dna import IcodeDnaTag
|
|
from pm3py.sim.auth_aes import KEY_HEADER_ACTIVE_LOCKED
|
|
|
|
key = b"\xAA" * 16
|
|
tag = IcodeDnaTag(aes_keys=[key, None, None, None])
|
|
tag._key_headers[0] = KEY_HEADER_ACTIVE_LOCKED
|
|
run(tag.power_on())
|
|
|
|
ichallenge = bytes(range(10))
|
|
|
|
# CHALLENGE: flags(02) cmd(39) CSI(00) AuthMethod(00) KeyID(00) IChallenge(10)
|
|
cmd = bytes([0x02, 0x39, 0x00, 0x00, 0x00]) + ichallenge
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is None # CHALLENGE has no RF response
|
|
|
|
# READBUFFER: flags(02) cmd(3A)
|
|
cmd = bytes([0x02, 0x3A])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert len(resp.data) >= 17 # flags + 16 bytes TResponse
|
|
|
|
tresponse_wire = resp.data[1:17]
|
|
tresponse = tresponse_wire[::-1] # reverse for decryption
|
|
|
|
cipher = AES.new(key, AES.MODE_ECB)
|
|
decrypted = cipher.decrypt(tresponse)
|
|
|
|
# Verify C_TAM1 constant
|
|
assert decrypted[0:2] == bytes([0x96, 0xC5])
|
|
# Verify echoed IChallenge (bytes 6-15, reversed back)
|
|
echoed = decrypted[6:16]
|
|
assert echoed == ichallenge
|
|
|
|
def test_challenge_key1(self):
|
|
"""TAM1 with key slot 1."""
|
|
from pm3py.sim.icode_dna import IcodeDnaTag
|
|
from pm3py.sim.auth_aes import KEY_HEADER_ACTIVE_LOCKED
|
|
|
|
key = b"\xCC" * 16
|
|
tag = IcodeDnaTag(aes_keys=[None, key, None, None])
|
|
tag._key_headers[1] = KEY_HEADER_ACTIVE_LOCKED
|
|
run(tag.power_on())
|
|
|
|
ichallenge = b"\xFF" * 10
|
|
cmd = bytes([0x02, 0x39, 0x00, 0x00, 0x01]) + ichallenge
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is None
|
|
|
|
cmd = bytes([0x02, 0x3A])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
|
|
tresponse = resp.data[1:17][::-1]
|
|
cipher = AES.new(key, AES.MODE_ECB)
|
|
decrypted = cipher.decrypt(tresponse)
|
|
assert decrypted[0:2] == bytes([0x96, 0xC5])
|
|
assert decrypted[6:16] == ichallenge
|
|
|
|
def test_challenge_inactive_key_silent(self):
|
|
"""CHALLENGE with inactive key: tag stays silent, READBUFFER returns nothing."""
|
|
from pm3py.sim.icode_dna import IcodeDnaTag
|
|
|
|
tag = IcodeDnaTag(aes_keys=[b"\xBB" * 16, None, None, None])
|
|
# Key header NOT active (default 0x81)
|
|
run(tag.power_on())
|
|
|
|
cmd = bytes([0x02, 0x39, 0x00, 0x00, 0x00]) + bytes(10)
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is None
|
|
|
|
# READBUFFER should have nothing
|
|
cmd = bytes([0x02, 0x3A])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is None
|
|
|
|
def test_readbuffer_without_challenge(self):
|
|
"""READBUFFER without prior CHALLENGE returns nothing."""
|
|
from pm3py.sim.icode_dna import IcodeDnaTag
|
|
|
|
tag = IcodeDnaTag()
|
|
run(tag.power_on())
|
|
|
|
cmd = bytes([0x02, 0x3A])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is None
|
|
|
|
def test_readbuffer_one_shot(self):
|
|
"""TResponse is cleared after first READBUFFER (one-shot)."""
|
|
from pm3py.sim.icode_dna import IcodeDnaTag
|
|
from pm3py.sim.auth_aes import KEY_HEADER_ACTIVE_LOCKED
|
|
|
|
key = b"\xDD" * 16
|
|
tag = IcodeDnaTag(aes_keys=[key, None, None, None])
|
|
tag._key_headers[0] = KEY_HEADER_ACTIVE_LOCKED
|
|
run(tag.power_on())
|
|
|
|
# CHALLENGE
|
|
cmd = bytes([0x02, 0x39, 0x00, 0x00, 0x00]) + bytes(10)
|
|
run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
|
|
# First READBUFFER — should succeed
|
|
cmd = bytes([0x02, 0x3A])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
|
|
# Second READBUFFER — should return nothing (consumed)
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is None
|
|
|
|
def test_challenge_no_key_in_slot(self):
|
|
"""CHALLENGE with None key slot stays silent."""
|
|
from pm3py.sim.icode_dna import IcodeDnaTag
|
|
from pm3py.sim.auth_aes import KEY_HEADER_ACTIVE_LOCKED
|
|
|
|
tag = IcodeDnaTag(aes_keys=[None, None, None, None])
|
|
tag._key_headers[0] = KEY_HEADER_ACTIVE_LOCKED
|
|
run(tag.power_on())
|
|
|
|
cmd = bytes([0x02, 0x39, 0x00, 0x00, 0x00]) + bytes(10)
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is None
|
|
|
|
cmd = bytes([0x02, 0x3A])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is None
|
|
|
|
def test_challenge_invalid_key_id(self):
|
|
"""CHALLENGE with key_id > 3 stays silent."""
|
|
from pm3py.sim.icode_dna import IcodeDnaTag
|
|
from pm3py.sim.auth_aes import KEY_HEADER_ACTIVE_LOCKED
|
|
|
|
tag = IcodeDnaTag(aes_keys=[b"\xAA" * 16, None, None, None])
|
|
tag._key_headers[0] = KEY_HEADER_ACTIVE_LOCKED
|
|
run(tag.power_on())
|
|
|
|
cmd = bytes([0x02, 0x39, 0x00, 0x00, 0x04]) + bytes(10)
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is None
|
|
|
|
def test_challenge_wrong_auth_method(self):
|
|
"""CHALLENGE with AuthMethod != 0x00 (not TAM1) stays silent."""
|
|
from pm3py.sim.icode_dna import IcodeDnaTag
|
|
from pm3py.sim.auth_aes import KEY_HEADER_ACTIVE_LOCKED
|
|
|
|
tag = IcodeDnaTag(aes_keys=[b"\xAA" * 16, None, None, None])
|
|
tag._key_headers[0] = KEY_HEADER_ACTIVE_LOCKED
|
|
run(tag.power_on())
|
|
|
|
# AuthMethod = 0x02 (MAM1, not TAM1)
|
|
cmd = bytes([0x02, 0x39, 0x00, 0x02, 0x00]) + bytes(10)
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is None
|
|
|
|
rb_cmd = bytes([0x02, 0x3A])
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(rb_cmd)))
|
|
assert resp is None
|
|
|
|
|
|
class TestMam:
|
|
"""Tests for MAM1/MAM2 mutual authentication via AUTHENTICATE (0x35)."""
|
|
|
|
def _make_tag(self, key=b"\xAA" * 16, key_slot=0):
|
|
from pm3py.sim.icode_dna import IcodeDnaTag
|
|
from pm3py.sim.auth_aes import KEY_HEADER_ACTIVE_LOCKED
|
|
|
|
keys = [None] * 4
|
|
keys[key_slot] = key
|
|
tag = IcodeDnaTag(aes_keys=keys)
|
|
tag._key_headers[key_slot] = KEY_HEADER_ACTIVE_LOCKED
|
|
run(tag.power_on())
|
|
return tag
|
|
|
|
def _do_mam1(self, tag, key, ichallenge, key_id=0):
|
|
"""Send MAM1 and return (resp, tchallenge)."""
|
|
cmd = bytes([0x02, 0x35, 0x00, 0x02, key_id]) + ichallenge[::-1]
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert len(resp.data) == 24
|
|
assert resp.data[0] == 0x04
|
|
assert resp.data[1] == 0xA7
|
|
|
|
tc_high_reversed = resp.data[2:8]
|
|
encrypted_reversed = resp.data[8:24]
|
|
|
|
cipher = AES.new(key, AES.MODE_ECB)
|
|
decrypted = cipher.decrypt(encrypted_reversed[::-1])
|
|
|
|
assert decrypted[0:2] == bytes([0xDA, 0x83])
|
|
tc_low = decrypted[2:6]
|
|
echoed_ichallenge = decrypted[6:16]
|
|
assert echoed_ichallenge == ichallenge
|
|
|
|
tc_high = tc_high_reversed[::-1]
|
|
tchallenge = tc_high + tc_low
|
|
return resp, tchallenge
|
|
|
|
def _compute_iresponse(self, key, ichallenge, tchallenge):
|
|
"""Compute IResponse for MAM2."""
|
|
C_MAM2_PURPOSE = bytes([0xDA, 0x80])
|
|
ich_31_0 = ichallenge[6:10]
|
|
plaintext = C_MAM2_PURPOSE + ich_31_0 + tchallenge
|
|
cipher = AES.new(key, AES.MODE_ECB)
|
|
iresponse = cipher.decrypt(plaintext)
|
|
return iresponse[::-1]
|
|
|
|
def test_mam_complete_flow(self):
|
|
"""Complete MAM1 -> MAM2 flow results in authenticated key."""
|
|
key = b"\xAA" * 16
|
|
tag = self._make_tag(key)
|
|
ichallenge = os.urandom(10)
|
|
|
|
resp, tchallenge = self._do_mam1(tag, key, ichallenge)
|
|
|
|
iresponse_wire = self._compute_iresponse(key, ichallenge, tchallenge)
|
|
|
|
cmd = bytes([0x02, 0x35, 0x00, 0x06]) + iresponse_wire
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] == 0x00
|
|
assert 0 in tag._aes_authenticated
|
|
|
|
def test_mam1_response_structure(self):
|
|
"""MAM1 response has correct flags, header, and 24-byte length."""
|
|
key = b"\xBB" * 16
|
|
tag = self._make_tag(key)
|
|
ichallenge = bytes(range(10))
|
|
|
|
cmd = bytes([0x02, 0x35, 0x00, 0x02, 0x00]) + ichallenge[::-1]
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
|
|
assert resp is not None
|
|
assert len(resp.data) == 24
|
|
assert resp.data[0] == 0x04
|
|
assert resp.data[1] == 0xA7
|
|
|
|
def test_mam2_wrong_iresponse(self):
|
|
"""MAM2 with incorrect IResponse returns error."""
|
|
key = b"\xCC" * 16
|
|
tag = self._make_tag(key)
|
|
ichallenge = os.urandom(10)
|
|
|
|
self._do_mam1(tag, key, ichallenge)
|
|
|
|
bad_iresponse = os.urandom(16)
|
|
cmd = bytes([0x02, 0x35, 0x00, 0x06]) + bad_iresponse
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] == 0x01
|
|
assert 0 not in tag._aes_authenticated
|
|
|
|
def test_mam2_without_prior_mam1(self):
|
|
"""MAM2 without prior MAM1 returns error."""
|
|
key = b"\xDD" * 16
|
|
tag = self._make_tag(key)
|
|
|
|
iresponse = os.urandom(16)
|
|
cmd = bytes([0x02, 0x35, 0x00, 0x06]) + iresponse
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] == 0x01
|
|
assert 0 not in tag._aes_authenticated
|
|
|
|
def test_mam_key1(self):
|
|
"""MAM flow with key slot 1."""
|
|
key = b"\xEE" * 16
|
|
tag = self._make_tag(key, key_slot=1)
|
|
ichallenge = os.urandom(10)
|
|
|
|
resp, tchallenge = self._do_mam1(tag, key, ichallenge, key_id=1)
|
|
|
|
iresponse_wire = self._compute_iresponse(key, ichallenge, tchallenge)
|
|
cmd = bytes([0x02, 0x35, 0x00, 0x06]) + iresponse_wire
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp.data[0] == 0x00
|
|
assert 1 in tag._aes_authenticated
|
|
|
|
def test_mam1_inactive_key_error(self):
|
|
"""MAM1 with inactive key returns error."""
|
|
from pm3py.sim.icode_dna import IcodeDnaTag
|
|
|
|
tag = IcodeDnaTag(aes_keys=[b"\xAA" * 16, None, None, None])
|
|
run(tag.power_on())
|
|
|
|
cmd = bytes([0x02, 0x35, 0x00, 0x02, 0x00]) + bytes(10)
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp is not None
|
|
assert resp.data[0] == 0x01
|
|
|
|
def test_mam1_invalid_key_id(self):
|
|
"""MAM1 with key_id > 3 returns error."""
|
|
tag = self._make_tag()
|
|
|
|
cmd = bytes([0x02, 0x35, 0x00, 0x02, 0x05]) + bytes(10)
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp.data[0] == 0x01
|
|
|
|
def test_unsupported_auth_method(self):
|
|
"""AUTHENTICATE with AuthMethod 0x80 returns error (not supported)."""
|
|
tag = self._make_tag()
|
|
|
|
cmd = bytes([0x02, 0x35, 0x00, 0x80]) + bytes(16)
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp.data[0] == 0x01
|
|
|
|
def test_mam_state_cleared_after_mam2_failure(self):
|
|
"""After MAM2 failure, state is cleared -- second MAM2 also fails."""
|
|
key = b"\xAA" * 16
|
|
tag = self._make_tag(key)
|
|
ichallenge = os.urandom(10)
|
|
|
|
self._do_mam1(tag, key, ichallenge)
|
|
|
|
cmd = bytes([0x02, 0x35, 0x00, 0x06]) + os.urandom(16)
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp.data[0] == 0x01
|
|
|
|
cmd = bytes([0x02, 0x35, 0x00, 0x06]) + os.urandom(16)
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
assert resp.data[0] == 0x01
|
|
|
|
def test_mam1_ichallenge_byte_reversal(self):
|
|
"""Verify IChallenge is de-reversed correctly from wire format."""
|
|
key = b"\xAA" * 16
|
|
tag = self._make_tag(key)
|
|
|
|
ichallenge = bytes([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A])
|
|
|
|
cmd = bytes([0x02, 0x35, 0x00, 0x02, 0x00]) + ichallenge[::-1]
|
|
resp = run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
|
|
encrypted_reversed = resp.data[8:24]
|
|
cipher = AES.new(key, AES.MODE_ECB)
|
|
decrypted = cipher.decrypt(encrypted_reversed[::-1])
|
|
assert decrypted[6:16] == ichallenge
|
|
|
|
def _do_mam_full(self, tag, key, ichallenge, key_id=0, purpose_nibble=0x00):
|
|
"""Complete MAM1+MAM2 flow with a specific purpose nibble. Returns MAM2 response."""
|
|
_, tchallenge = self._do_mam1(tag, key, ichallenge, key_id=key_id)
|
|
|
|
purpose_byte = 0x80 | purpose_nibble
|
|
ich_31_0 = ichallenge[6:10]
|
|
plaintext = bytes([0xDA, purpose_byte]) + ich_31_0 + tchallenge
|
|
cipher = AES.new(key, AES.MODE_ECB)
|
|
iresponse = cipher.decrypt(plaintext)
|
|
iresponse_reversed = iresponse[::-1]
|
|
|
|
cmd = bytes([0x02, 0x35, 0x00, 0x06]) + iresponse_reversed
|
|
return run(tag.handle_frame(RFFrame.from_bytes(cmd)))
|
|
|
|
def test_mam2_purpose_enable_privacy(self):
|
|
"""MAM2 with purpose=0x09 sets privacy mode."""
|
|
from pm3py.sim.auth_aes import PRIV_PRIVACY
|
|
|
|
key = b"\xAA" * 16
|
|
tag = self._make_tag(key)
|
|
tag._key_privileges[0] = PRIV_PRIVACY
|
|
ichallenge = os.urandom(10)
|
|
|
|
resp = self._do_mam_full(tag, key, ichallenge, purpose_nibble=0x09)
|
|
assert resp.data[0] == 0x00
|
|
assert 0 in tag._aes_authenticated
|
|
assert tag._privacy_mode is True
|
|
|
|
def test_mam2_purpose_disable_privacy(self):
|
|
"""MAM2 with purpose=0x0A clears privacy mode."""
|
|
from pm3py.sim.auth_aes import PRIV_PRIVACY
|
|
|
|
key = b"\xAA" * 16
|
|
tag = self._make_tag(key)
|
|
tag._key_privileges[0] = PRIV_PRIVACY
|
|
tag._privacy_mode = True
|
|
ichallenge = os.urandom(10)
|
|
|
|
resp = self._do_mam_full(tag, key, ichallenge, purpose_nibble=0x0A)
|
|
assert resp.data[0] == 0x00
|
|
assert 0 in tag._aes_authenticated
|
|
assert tag._privacy_mode is False
|
|
|
|
def test_mam2_purpose_destroy(self):
|
|
"""MAM2 with purpose=0x0B sets destroyed."""
|
|
from pm3py.sim.auth_aes import PRIV_DESTROY
|
|
|
|
key = b"\xAA" * 16
|
|
tag = self._make_tag(key)
|
|
tag._key_privileges[0] = PRIV_DESTROY
|
|
ichallenge = os.urandom(10)
|
|
|
|
resp = self._do_mam_full(tag, key, ichallenge, purpose_nibble=0x0B)
|
|
assert resp.data[0] == 0x00
|
|
assert 0 in tag._aes_authenticated
|
|
assert tag._destroyed is True
|
|
|
|
def test_mam2_purpose_requires_privilege(self):
|
|
"""MAM2 with privacy purpose but no PRIV_PRIVACY returns error."""
|
|
key = b"\xAA" * 16
|
|
tag = self._make_tag(key)
|
|
# No privileges set (default 0x00)
|
|
ichallenge = os.urandom(10)
|
|
|
|
resp = self._do_mam_full(tag, key, ichallenge, purpose_nibble=0x09)
|
|
assert resp.data[0] == 0x01
|
|
assert 0 not in tag._aes_authenticated
|
|
assert tag._privacy_mode is False
|