feat: migrate sim framework from worktree to master
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>
This commit is contained in:
420
tests/test_sim_aes_auth.py
Normal file
420
tests/test_sim_aes_auth.py
Normal file
@@ -0,0 +1,420 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user