Files
simtrace-analyser/tests/test_decode.py
T
catarrh 5dea6bceb8 Files view: the full DF chain in the file path (v2.7.6)
- the tracker kept only the immediate parent DF, so a phonebook file read as
  "DF_PHONEBOOK/4FA1" — the trace's own selection path (00a4080406
  7F105F3A4FA1) spells out the whole chain; the channel state now carries it
  (selected_df_state() returns (df, path), e.g. ('5f3a', ['7f10', '5f3a'])),
  the tracker stores it per file and _file_path() renders it:
  "DF_TELECOM/DF_PHONEBOOK/4FA1"
- chain rules: a child DF selected by FID appends to the current chain, MF and
  the ADF restart it, an AID is its own root, a path selection uses the DFs
  in the path (an explicit '7fff' collapsing to the ADF), and an EF selects
  keeps it.  A leading MF is implicit in a longer path; MF-only files keep
  "MF/…", ADF files "ADF/…" (the previous labels unchanged)
- the chain is learned from the FCP sighting too and extends a shorter path
  recorded earlier, so entries created from a GET RESPONSE still get the full
  path
- verified on the live traces (sessions 47/193): 5f3a/4fa1 ->
  DF_TELECOM/DF_PHONEBOOK/4FA1, 5f3a/4f30 -> DF_TELECOM/DF_PHONEBOOK/EF_PBR
  (4F30), while 3f00/2fe2 -> MF/EF_ICCID (2FE2) and 7fff/6f07 ->
  ADF/EF_IMSI (6F07) stay as before
- tests: selected_df_state (path/FID/MF/ADF/AID/EF cases) and a tracker flow
  with a path selection asserting the full path; 529 Python + 76 Node tests
- README/README_RUS show the chain; PWA_VERSION/pyproject/version.py 2.7.6,
  SW cache simtrace-analyser-v86
2026-10-18 01:05:55 +03:00

3174 lines
148 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for server APDU/CAT decoding."""
import unittest
from simtrace_analyser.decode import (
decode_message,
decode_sniff_msg,
CAT_COMMAND_TYPES,
ENVELOPE_TYPES,
parse_tlv,
)
class TestParseTlv(unittest.TestCase):
def test_simple(self):
data = bytes.fromhex('8103010500')
self.assertEqual(parse_tlv(data), [(0x81, 3, bytes.fromhex('010500'))])
def test_multiple(self):
data = bytes.fromhex('810301050082028182')
self.assertEqual(parse_tlv(data), [
(0x81, 3, bytes.fromhex('010500')),
(0x82, 2, bytes.fromhex('8182')),
])
def test_empty(self):
self.assertEqual(parse_tlv(b''), [])
def test_long_form_length(self):
# D0 81 BC … — a proactive command whose length is 0xBC (188),
# encoded in BER-TLV long form.
inner = bytes.fromhex('8103012500') + b'\x82\x02\x81\x82'
data = b'\xd0' + b'\x81' + bytes([len(inner)]) + inner
self.assertEqual(parse_tlv(data), [(0xD0, len(inner), inner)])
def test_long_form_two_byte(self):
# 0x82 followed by two length bytes.
value = b'\x00' * 300
data = b'\x70' + b'\x82\x01\x2c' + value
self.assertEqual(parse_tlv(data), [(0x70, 300, value)])
class TestAuthenticateNaming(unittest.TestCase):
def test_ins_88(self):
r = decode_message(bytes.fromhex('0088008122' + '00' * 34 + '6135'))
self.assertEqual(r['ins_name'], 'AUTHENTICATE')
def test_ins_89(self):
r = decode_message(bytes.fromhex('0089008122' + '00' * 34 + '6135'))
self.assertEqual(r['ins_name'], 'AUTHENTICATE')
class TestCatDecoding(unittest.TestCase):
def test_fetch_setup_menu(self):
r = decode_message(bytes.fromhex(
'8012000030D02E810301250082028182050B416C6661204D6F62696C65'
'8F16808112089DB0C1C2C0BEB9BAB82F53657474696E67739000'))
self.assertEqual(r['ins_name'], 'FETCH')
self.assertEqual(r['cat_command'], 'SET UP MENU')
def test_fetch_setup_event_list(self):
r = decode_message(bytes.fromhex(
'801200000FD00D810301050082028182990203129000'))
self.assertEqual(r['cat_command'], 'SET UP EVENT LIST')
def test_fetch_poll_interval(self):
r = decode_message(bytes.fromhex(
'801200000FD00D8103010300820281820402011E9000'))
self.assertEqual(r['cat_command'], 'POLL INTERVAL')
def test_fetch_provide_local_info(self):
r = decode_message(bytes.fromhex(
'801200000BD0098103012600820281829000'))
self.assertEqual(r['cat_command'], 'PROVIDE LOCAL INFORMATION')
self.assertEqual(r['cmd']['qualifier'],
'0x00 Location Info (MCC, MNC, LAC/TAC, Cell ID)')
def test_envelope_event_download(self):
r = decode_message(bytes.fromhex(
'80C200000CD60A190103020282811B01029000'))
self.assertEqual(r['ins_name'], 'ENVELOPE')
self.assertEqual(r['cat_command'], 'EVENT DOWNLOAD')
def test_tr_has_response_to(self):
r = decode_message(bytes.fromhex(
'801400000C8103010500020282810301009130'))
self.assertEqual(r['ins_name'], 'TERMINAL RESPONSE')
self.assertNotIn('cat_command', r)
self.assertEqual(r['response_to'], 'SET UP EVENT LIST')
def test_select_no_cat(self):
r = decode_message(bytes.fromhex('a0a40000023f009000'))
self.assertEqual(r['ins_name'], 'SELECT')
self.assertNotIn('cat_command', r)
def test_envelope_sms_pp_download(self):
r = decode_message(bytes.fromhex(
'80C2000022D120020283810607919740430900F40B1104038154F50004'
'628021410300210248699000'))
self.assertEqual(r['ins_name'], 'ENVELOPE')
self.assertEqual(r['cat_command'], 'SMS-PP DOWNLOAD')
cmd = r['cmd']
self.assertEqual(cmd['smsc'], '+79043490004')
self.assertEqual(cmd['device_ids'], {'src': 'Network', 'dst': 'UICC'})
t = cmd['tpdu']
self.assertEqual(t['mti'], 'SMS-DELIVER')
self.assertEqual(t['oa'], '455')
self.assertEqual(t['text'], 'Hi')
self.assertEqual(t['scts'], '2026-08-12 14:30:00 (UTC+03:00)')
def test_envelope_mo_short_message_control(self):
# D5 envelope: device ids + SMSC address + destination address
# (first address TLV → smsc, second → tp_da).
r = decode_message(bytes.fromhex(
'80C2000018'
'D516820283818607919733824009F08607919721436587F9'
'9000'))
self.assertEqual(r['ins_name'], 'ENVELOPE')
self.assertEqual(r['cat_command'], 'MO SHORT MESSAGE CONTROL')
cmd = r['cmd']
self.assertEqual(cmd['smsc'], '+79332804900')
self.assertEqual(cmd['tp_da'], '+79123456789')
self.assertEqual(r['summary'], 'SMSC +79332804900, DA +79123456789')
class TestCommandTypeTables(unittest.TestCase):
def test_known_values(self):
self.assertEqual(CAT_COMMAND_TYPES[0x03], 'POLL INTERVAL')
self.assertEqual(CAT_COMMAND_TYPES[0x05], 'SET UP EVENT LIST')
self.assertEqual(CAT_COMMAND_TYPES[0x25], 'SET UP MENU')
self.assertEqual(CAT_COMMAND_TYPES[0x21], 'DISPLAY TEXT')
self.assertEqual(CAT_COMMAND_TYPES[0x24], 'SELECT ITEM')
self.assertEqual(CAT_COMMAND_TYPES[0x26], 'PROVIDE LOCAL INFORMATION')
def test_envelope_known_values(self):
self.assertEqual(ENVELOPE_TYPES[0xD3], 'MENU SELECTION')
self.assertEqual(ENVELOPE_TYPES[0xD6], 'EVENT DOWNLOAD')
def test_unassigned_envelope_tags_unnamed(self):
# TS 31.111 §9.1: 'E1'-'E3' reserved for 3GPP (for future usage);
# no name exists, so no placeholder label may be shown.
for tag in (0xE1, 0xE2, 0xE3):
self.assertNotIn(tag, ENVELOPE_TYPES)
class TestChangeAndFidiDecoding(unittest.TestCase):
def test_change_reset_assert(self):
r = decode_sniff_msg(b'', 'change', flags=1 << 2)
self.assertEqual(r['type'], 'change')
self.assertEqual(r['flags'], ['Reset asserted'])
def test_change_card_insert(self):
r = decode_sniff_msg(b'', 'change', flags=1 << 0)
self.assertEqual(r['flags'], ['Card inserted'])
def test_change_multiple_flags(self):
r = decode_sniff_msg(b'', 'change', flags=(1 << 2) | (1 << 3))
self.assertEqual(r['flags'], ['Reset asserted', 'Reset de-asserted'])
def test_change_no_flags(self):
r = decode_sniff_msg(b'', 'change', flags=0)
self.assertEqual(r['flags'], ['no changes'])
def test_fidi(self):
r = decode_sniff_msg(b'\x97', 'fidi')
self.assertEqual(r['type'], 'fidi')
self.assertEqual(r['fi'], 9)
self.assertEqual(r['di'], 7)
self.assertEqual(r['fi_val'], 512)
self.assertEqual(r['di_val'], 64)
def test_tpdu_error_flags_surfaced(self):
r = decode_sniff_msg(bytes.fromhex('00a40000023f009000'), 'tpdu',
flags=(1 << 7) | (1 << 6))
self.assertEqual(r['errors'], ['malformed', 'checksum error'])
def test_tpdu_no_error_flags(self):
r = decode_sniff_msg(bytes.fromhex('00a40000023f009000'), 'tpdu', flags=0)
self.assertNotIn('errors', r)
def test_atr_error_flags_surfaced(self):
r = decode_sniff_msg(bytes.fromhex('3b00'), 'atr', flags=(1 << 5))
self.assertEqual(r['errors'], ['incomplete'])
class TestTerminalResponse(unittest.TestCase):
def test_tr_setup_event_list(self):
r = decode_message(bytes.fromhex(
'801400000C8103010500020282810301009130'))
self.assertEqual(r['ins_name'], 'TERMINAL RESPONSE')
self.assertEqual(r['response_to'], 'SET UP EVENT LIST')
def test_tr_setup_menu(self):
r = decode_message(bytes.fromhex(
'801400000C810301250002028281830100910F'))
self.assertEqual(r['response_to'], 'SET UP MENU')
def test_tr_poll_interval(self):
r = decode_message(bytes.fromhex(
'80140000108103010300020282810301000402011E9000'))
self.assertEqual(r['response_to'], 'POLL INTERVAL')
class TestPliQualifier(unittest.TestCase):
def test_tr_pli_imei(self):
r = decode_message(bytes.fromhex(
'8014000016810301260102028281030106130952F0991EC57A68009F'))
self.assertEqual(r['response_to'], 'PROVIDE LOCAL INFORMATION')
self.assertEqual(r['response']['qualifier'], '0x01 IMEI')
def test_fetch_pli_location(self):
r = decode_message(bytes.fromhex(
'801200000BD0098103012600820281829000'))
self.assertEqual(r['cat_command'], 'PROVIDE LOCAL INFORMATION')
self.assertEqual(r['cmd']['qualifier'],
'0x00 Location Info (MCC, MNC, LAC/TAC, Cell ID)')
def test_fetch_pli_date_time(self):
r = decode_message(bytes.fromhex(
'801200000BD0098103012603820281829000'))
self.assertEqual(r['cat_command'], 'PROVIDE LOCAL INFORMATION')
self.assertEqual(r['cmd']['qualifier'], '0x03 Date, time and time zone')
def test_fetch_pli_meid(self):
# '0B' = MEID of the terminal (TS 102 223 §8.6).
r = decode_message(bytes.fromhex(
'801200000BD009810301260B820281829000'))
self.assertEqual(r['cat_command'], 'PROVIDE LOCAL INFORMATION')
self.assertEqual(r['cmd']['qualifier'], '0x0B MEID of the terminal')
def test_non_pli_no_qualifier(self):
r = decode_message(bytes.fromhex(
'801400000C810301250002028281830100910F'))
self.assertEqual(r['response_to'], 'SET UP MENU')
class TestGetResponseContext(unittest.TestCase):
GET_RESPONSE = bytes.fromhex('00C000002962278202782183023F00A50A800171')
def test_with_prev_select(self):
r = decode_message(self.GET_RESPONSE, prev={'ins_name': 'SELECT', 'sw1': '61'})
self.assertEqual(r['response_for'], 'SELECT')
def test_with_prev_non_61(self):
r = decode_message(self.GET_RESPONSE, prev={'ins_name': 'SELECT', 'sw1': '90'})
self.assertIsNone(r['response_for'])
def test_orphaned(self):
r = decode_message(self.GET_RESPONSE, prev=None)
self.assertIsNone(r['response_for'])
def test_get_response_por(self):
# GET RESPONSE after ENVELOPE SMS-PP DOWNLOAD → Response Packet (PoR).
r = decode_message(
bytes.fromhex('00C0000024') +
bytes.fromhex('027100001F0AB0000100000000C100000290000080041C043004400438044FD83CDF37FF') +
bytes.fromhex('9000'),
prev={'ins': 0xC2, 'ins_name': 'ENVELOPE', 'sw1': '61'})
self.assertEqual(r['response_for'], 'ENVELOPE')
self.assertEqual(r['response']['rpl'], 31)
self.assertEqual(r['response']['rhl'], 10)
self.assertEqual(r['response']['tar'], 'B00001')
self.assertEqual(r['response']['cntr'], '00000000C1')
self.assertEqual(r['response']['status'], {'code': '00', 'name': 'PoR OK'})
self.assertEqual(r['response']['data'], '0290000080041C043004400438044FD83CDF37FF')
self.assertIn('PoR OK · TAR B00001', r['summary'])
def test_get_response_non_por_not_parsed(self):
# A GET RESPONSE after ENVELOPE that is not a PoR must stay undecoded.
r = decode_message(bytes.fromhex('00C0000004') + bytes.fromhex('AABBCCDD') + bytes.fromhex('9000'),
prev={'ins': 0xC2, 'ins_name': 'ENVELOPE', 'sw1': '61'})
self.assertEqual(r['response_for'], 'ENVELOPE')
self.assertNotIn('response', r)
class TestFcpResponse(unittest.TestCase):
GET_RESPONSE = bytes.fromhex(
'00C000002962278202782183023F00A50A8001718302F9628701018A01058B032F0601C60990014083010183010A9000')
def test_select_response_fcp(self):
r = decode_message(self.GET_RESPONSE, prev={'ins': 0xA4, 'ins_name': 'SELECT', 'sw1': '61'})
self.assertEqual(r['response_for'], 'SELECT')
resp = r['response']
self.assertEqual(resp['template'], 'FCP')
self.assertEqual(resp['file_id'], '3F00')
self.assertEqual(resp['file_id_name'], 'MF')
self.assertEqual(resp['file_descriptor']['file_type'], 'DF or ADF')
self.assertEqual(resp['file_descriptor']['shareable'], 'shareable')
self.assertEqual(resp['life_cycle'], 'operational state (activated)')
def test_ef_descriptor_transparent(self):
from simtrace_analyser.decode import _decode_file_descriptor
fd = _decode_file_descriptor(bytes.fromhex('0121'))
self.assertEqual(fd['file_type'], 'Working EF')
self.assertEqual(fd['structure'], 'transparent')
def test_ef_descriptor_linear_fixed(self):
from simtrace_analyser.decode import _decode_file_descriptor
fd = _decode_file_descriptor(bytes.fromhex('0221'))
self.assertEqual(fd['file_type'], 'Working EF')
self.assertEqual(fd['structure'], 'linear fixed')
def test_fcp_file_size(self):
from simtrace_analyser.decode import _decode_fcp
# FCP: file descriptor (transparent EF) + FID 6F07 + file size 9 + total 9
fcp = _decode_fcp(bytes.fromhex('8202012183026f07800109810109'))
self.assertEqual(fcp['file_descriptor']['file_type'], 'Working EF')
self.assertEqual(fcp['file_descriptor']['structure'], 'transparent')
self.assertEqual(fcp['file_id_name'], 'EF_IMSI')
self.assertEqual(fcp['file_size'], 9)
self.assertEqual(fcp['total_file_size'], 9)
def test_fcp_sfi(self):
# FCP SFI tag 0x88 carries the SFI in bits b8..b4 (value >> 3).
from simtrace_analyser.decode import _decode_fcp
fcp = _decode_fcp(bytes.fromhex('8202412183026f7e880158'))
self.assertEqual(fcp['sfi'], 11)
def test_fcp_sfi_empty(self):
# FCP SFI tag 0x88 with length 0 → file does not support SFI.
from simtrace_analyser.decode import _decode_fcp
fcp = _decode_fcp(bytes.fromhex('82054221005a0d83026f068800'))
self.assertIsNone(fcp['sfi'])
def test_fcp_sfi_absent_uses_fid_lsbs(self):
# No SFI tag → SFI = 5 LSBs of the FID (TS 102 221 §11.1.2).
from simtrace_analyser.decode import _decode_fcp
fcp = _decode_fcp(bytes.fromhex('8202412183022f06'))
self.assertEqual(fcp['sfi'], 0x06)
def test_fcp_summary_transparent(self):
# GET RESPONSE (FCP) for a transparent EF of 9 bytes
gr = bytes.fromhex('00c000000c') + bytes.fromhex('620c8202012183026f07800109810109') + bytes.fromhex('9000')
r = decode_message(gr, prev={'ins': 0xA4, 'ins_name': 'SELECT', 'sw1': '61'})
self.assertEqual(r['summary'], 'response for SELECT, Working EF, transparent, 9 B')
def test_fcp_summary_linear_fixed(self):
# linear fixed EF, 3 records x 64 bytes
gr = bytes.fromhex('00c000000e') + bytes.fromhex('620e8205022100400383026f07800109') + bytes.fromhex('9000')
r = decode_message(gr, prev={'ins': 0xA4, 'ins_name': 'SELECT', 'sw1': '61'})
self.assertEqual(r['summary'], 'response for SELECT, Working EF, linear fixed, 3 rec × 64 B')
def test_fcp_summary_df(self):
r = decode_message(self.GET_RESPONSE, prev={'ins': 0xA4, 'ins_name': 'SELECT', 'sw1': '61'})
self.assertEqual(r['summary'], 'response for SELECT, DF or ADF (MF)')
class TestAuthResponse(unittest.TestCase):
def test_3g_success(self):
from simtrace_analyser.decode import _decode_auth
data = bytes.fromhex('DB084CFA9017FD0DD85A101A2622F60E8ABD2C2497B9A8EFAF55E510CAA393329FF97868B9537369D5266A4F084D417B05ABFAFAEE')
r = _decode_auth(data)
self.assertEqual(r['type'], '3G/EPS/5G')
self.assertEqual(r['status'], 'success')
self.assertEqual(r['res'], '4CFA9017FD0DD85A')
self.assertEqual(r['ck'], '1A2622F60E8ABD2C2497B9A8EFAF55E5')
self.assertEqual(r['ik'], 'CAA393329FF97868B9537369D5266A4F')
def test_3g_sync_fail(self):
from simtrace_analyser.decode import _decode_auth
data = bytes.fromhex('DC0E' + '00' * 14)
r = _decode_auth(data)
self.assertEqual(r['type'], '3G/EPS/5G')
self.assertEqual(r['status'], 'sync fail')
self.assertEqual(r['auts'], '00' * 14)
def test_gsm(self):
from simtrace_analyser.decode import _decode_auth
sres = bytes.fromhex('AABBCCDD')
kc = bytes.fromhex('0011223344556677')
r = _decode_auth(sres + kc)
self.assertEqual(r['type'], 'GSM')
self.assertEqual(r['sres'], 'AABBCCDD')
self.assertEqual(r['kc'], '0011223344556677')
class TestTrResult(unittest.TestCase):
def test_success(self):
r = decode_message(bytes.fromhex('801400000C8103010500020282810301009130'))
self.assertEqual(r['response']['code'], '0x00')
self.assertEqual(r['response']['name'], 'Command performed successfully')
def test_session_terminated(self):
from simtrace_analyser.decode import _decode_tr_result
r = _decode_tr_result(bytes.fromhex('810301050002028281030110'))
self.assertEqual(r['code'], '0x10')
self.assertEqual(r['name'], 'Proactive UICC session terminated by the user')
class TestTrAdditionalInfo(unittest.TestCase):
def test_poll_interval_duration(self):
r = decode_message(bytes.fromhex(
'80140000108103010300020282810301000402011E9000'))
self.assertEqual(r['response_to'], 'POLL INTERVAL')
self.assertEqual(r['response']['duration'], {'value': 30, 'unit': 'seconds'})
def test_pli_location_info(self):
r = decode_message(bytes.fromhex(
'8014000017810301260002028281030106130952F0991EC57A43C09F9000'))
self.assertEqual(
r['response']['local_info']['Location'],
'MCC 250 MNC 99 · LAC 0x1EC5 · Cell 0x7A43C09F')
def test_pli_imei(self):
r = decode_message(bytes.fromhex(
'801400001681030126010202828103010014088A763201652428089000'))
self.assertEqual(r['response']['local_info']['IMEI'],
'867231056428280')
def test_pli_datetime(self):
from simtrace_analyser.decode import _decode_datetime
self.assertEqual(
_decode_datetime(bytes.fromhex('62802141030021')),
'2026-08-12 14:30:00 (UTC+03:00)')
self.assertEqual(
_decode_datetime(bytes.fromhex('628021410300FF')),
'2026-08-12 14:30:00 (UTCunknown)')
def test_datetime_invalid_bcd(self):
from simtrace_analyser.decode import _decode_datetime
self.assertIsNone(_decode_datetime(bytes.fromhex('A505326C333939')))
def test_datetime_invalid_semantics(self):
from simtrace_analyser.decode import _decode_datetime
self.assertIsNone(_decode_datetime(bytes.fromhex('62310100000000')))
def test_pli_language(self):
from simtrace_analyser.decode import _decode_local_info, PLI_LANGUAGE
self.assertEqual(_decode_local_info(PLI_LANGUAGE, b'en'),
{'label': 'Language', 'value': 'en'})
def test_pli_battery(self):
from simtrace_analyser.decode import _decode_local_info, PLI_BATTERY
self.assertEqual(_decode_local_info(PLI_BATTERY, b'\x05'),
{'label': 'Battery', 'value': 'full'})
def test_pli_access_tech(self):
from simtrace_analyser.decode import _decode_local_info, PLI_ACCESS_TECH
self.assertEqual(_decode_local_info(PLI_ACCESS_TECH, bytes([0x03, 0x07])),
{'label': 'Access technology', 'value': 'UTRAN, E-UTRAN'})
class TestAuthCommand(unittest.TestCase):
def test_3g_rand_autn(self):
r = decode_message(bytes.fromhex(
'0088008122104A75BA425D438C549EF35BA5E3DD53051065E04D80E4DC8000A5C7FD842F41395E6135'))
cmd = r['cmd']
self.assertNotIn('context', cmd)
self.assertEqual(cmd['specific_key'], True)
self.assertEqual(cmd['rand'], '4A75BA425D438C549EF35BA5E3DD5305')
self.assertEqual(cmd['autn'], '65E04D80E4DC8000A5C7FD842F41395E')
self.assertEqual(cmd['sqn_ak'], '65E04D80E4DC')
self.assertEqual(cmd['amf'], '8000')
self.assertEqual(cmd['mac'], 'A5C7FD842F41395E')
def test_auth_p2_context(self):
r = decode_message(bytes.fromhex(
'0088008122104A75BA425D438C549EF35BA5E3DD53051065E04D80E4DC8000A5C7FD842F41395E6135'))
p2 = r['p2']
self.assertEqual(p2['raw'], '81')
self.assertIn('3G/EPS/5G context', p2['bits'])
self.assertIn('Specific reference data', p2['bits'])
def test_gsm_rand_only(self):
from simtrace_analyser.decode import _decode_auth_cmd
r = _decode_auth_cmd(bytes.fromhex('10' + '00' * 16), 0x00)
self.assertNotIn('context', r)
self.assertNotIn('specific_key', r)
self.assertEqual(r['rand'], '00' * 16)
self.assertNotIn('autn', r)
def test_sync_fail_auts(self):
from simtrace_analyser.decode import _decode_auth
auts = bytes.fromhex('010203040506' + '0a0b0c0d0e0f1011')
r = _decode_auth(b'\xdc' + bytes([len(auts)]) + auts)
self.assertEqual(r['status'], 'sync fail')
self.assertEqual(r['auts'], auts.hex().upper())
self.assertEqual(r['sqn_ak'], '010203040506')
self.assertEqual(r['mac_s'], '0A0B0C0D0E0F1011')
class TestEventDownload(unittest.TestCase):
def test_location_status(self):
r = decode_message(bytes.fromhex(
'80C2000017D615190103020282811B0100130952F0991EC57A43C09F9000'))
cmd = r['cmd']
self.assertEqual(cmd['type'], 'EVENT DOWNLOAD')
self.assertEqual(cmd['events'], ['Location status'])
self.assertEqual(cmd['location_status'], 'Normal service')
self.assertEqual(r['summary'], 'Location status: Normal service')
def test_event_no_service(self):
r = decode_message(bytes.fromhex(
'80C200000CD60A190103020282811B01029000'))
self.assertEqual(r['cmd']['events'], ['Location status'])
self.assertEqual(r['cmd']['location_status'], 'No service')
self.assertEqual(r['summary'], 'Location status: No service')
def test_location_status_with_info(self):
r = decode_message(bytes.fromhex(
'80C2000017D615190103020282811B0100130952F02026ADBC51A16F9000'))
cmd = r['cmd']
self.assertEqual(cmd['location_status'], 'Normal service')
self.assertEqual(cmd['location_info'],
'MCC 250 MNC 02 · LAC 0x26AD · Cell 0xBC51A16F')
def test_mt_call(self):
r = decode_message(bytes.fromhex(
'80C2000014D612190100020283811C010586069121436587099000'))
cmd = r['cmd']
self.assertEqual(cmd['events'], ['MT call'])
self.assertEqual(cmd['transaction_id'], 5)
self.assertEqual(cmd['caller'], '+1234567890')
def test_cell_broadcast(self):
cbp = ('4C511002F211' +
'040000313A0103035E047FFB0F31303238322A333036343835363234' +
'0D' * 54)
r = decode_message(bytes.fromhex(
'80C2000060D25E020283810C58' + cbp + '9000'))
cmd = r['cmd']
self.assertEqual(cmd['type'], 'CELL BROADCAST DOWNLOAD')
self.assertEqual(cmd['cb_page']['serial'], '4C51')
self.assertEqual(cmd['cb_page']['message_id'], '0x1002')
self.assertEqual(cmd['cb_page']['page'], '1/1')
self.assertEqual(cmd['cb_page']['content'],
'040000313A0103035E047FFB0F31303238322A333036343835363234' + '0D' * 54)
class TestSmTpdu(unittest.TestCase):
def _bcd(self, number):
d = [int(c) for c in number]
out = bytearray()
for i in range(0, len(d), 2):
lo = d[i]
hi = d[i + 1] if i + 1 < len(d) else 0xF
out.append((hi << 4) | lo)
return bytes(out)
def test_deliver_gsm7(self):
from simtrace_analyser.decode import _decode_sm_tpdu
oa = b'\x91' + self._bcd('79031234567')
tpdu = (b'\x00' + bytes([11]) + oa + b'\x00\x00' + b'\x00' * 7 +
b'\x05' + bytes.fromhex('E8329BFD06'))
r = _decode_sm_tpdu(tpdu)
self.assertEqual(r['mti'], 'SMS-DELIVER')
self.assertEqual(r['oa'], '+79031234567')
self.assertEqual(r['encoding'], 'GSM 7-bit')
self.assertEqual(r['text'], 'hello')
def test_deliver_ucs2(self):
from simtrace_analyser.decode import _decode_sm_tpdu
oa = b'\x91' + self._bcd('79031234567')
ucs2 = '\u041f\u0440\u0438\u0432\u0435\u0442'.encode('utf-16-be')
tpdu = (b'\x00' + bytes([11]) + oa + b'\x00\x08' + b'\x00' * 7 +
bytes([len(ucs2)]) + ucs2)
r = _decode_sm_tpdu(tpdu)
self.assertEqual(r['encoding'], 'UCS2')
self.assertEqual(r['text'], '\u041f\u0440\u0438\u0432\u0435\u0442')
def test_8bit_no_text(self):
from simtrace_analyser.decode import _decode_sm_tpdu
oa = b'\x91' + self._bcd('79031234567')
# Mixed low/high bytes are treated as binary → no text decode.
payload = bytes.fromhex('00018081A0FF07')
tpdu = (b'\x00' + bytes([11]) + oa + b'\x00\x04' + b'\x00' * 7 +
bytes([len(payload)]) + payload)
r = _decode_sm_tpdu(tpdu)
self.assertEqual(r['encoding'], '8-bit data')
self.assertNotIn('text', r)
self.assertEqual(r['payload'], '00018081A0FF07')
def test_sim_data_download(self):
from simtrace_analyser.decode import _decode_sm_tpdu
oa = b'\x91' + self._bcd('79031234567')
ud = bytes.fromhex('02700000200d000000000000020000000000000010100102a2090804420435044104420600')
tpdu = (b'\x44' + bytes([11]) + oa + b'\x7f\x04' + b'\x00' * 7 +
bytes([len(ud)]) + ud)
r = _decode_sm_tpdu(tpdu)
self.assertEqual(r['pid_name'], 'SIM data download (secured packet)')
self.assertEqual(r['udh'][0]['name'], 'Command Packet Identifier (CPI)')
self.assertEqual(r['secured']['cpl'], 32)
self.assertEqual(r['secured']['chl'], 13)
self.assertEqual(r['secured']['spi']['counter'], 'no counter')
self.assertEqual(r['secured']['spi']['rc_cc_ds'], 'none')
self.assertEqual(r['secured']['tar'], '000002')
self.assertEqual(r['secured']['cntr'], '0000000000')
self.assertEqual(r['secured']['data'], '0010100102A2090804420435044104420600')
def test_response_packet(self):
from simtrace_analyser.decode import _decode_response_packet
body = bytes.fromhex('001f0a00000000000000500000ab12800101230d08a0000001510000000f9a9000')
r = _decode_response_packet(body)
self.assertEqual(r['rpl'], 31)
self.assertEqual(r['rhl'], 10)
self.assertEqual(r['tar'], '000000')
self.assertEqual(r['cntr'], '0000000050')
self.assertEqual(r['pcntr'], 0)
self.assertEqual(r['status'], {'code': '00', 'name': 'PoR OK'})
self.assertEqual(r['data'], 'AB12800101230D08A0000001510000000F9A9000')
def test_secured_packet_fallback(self):
from simtrace_analyser.decode import _decode_secured_packet
self.assertEqual(_decode_secured_packet(bytes.fromhex('0001')), {'raw': '0001'})
def test_send_sm_response_packet(self):
from simtrace_analyser.decode import _decode_sm_tpdu
tpdu = bytes.fromhex('41000481112200f624027100001f0a00000000000000500000ab12800101230d08a0000001510000000f9a9000')
r = _decode_sm_tpdu(tpdu)
self.assertEqual(r['mti'], 'SMS-SUBMIT')
self.assertEqual(r['da'], '1122')
self.assertEqual(r['udh'][0]['name'], 'Response Packet Identifier (RPI)')
self.assertEqual(r['response_packet']['rpl'], 31)
self.assertEqual(r['response_packet']['cntr'], '0000000050')
self.assertEqual(r['response_packet']['status'], {'code': '00', 'name': 'PoR OK'})
self.assertEqual(r['response_packet']['data'], 'AB12800101230D08A0000001510000000F9A9000')
def test_deliver_scts(self):
from simtrace_analyser.decode import _decode_sm_tpdu
oa = b'\x91' + self._bcd('79031234567')
scts = bytes.fromhex('62802141030021')
tpdu = (b'\x00' + bytes([11]) + oa + b'\x00\x00' + scts + b'\x00')
r = _decode_sm_tpdu(tpdu)
self.assertEqual(r['scts'], '2026-08-12 14:30:00 (UTC+03:00)')
def test_deliver_pid_name(self):
from simtrace_analyser.decode import _decode_sm_tpdu
oa = b'\x91' + self._bcd('79031234567')
tpdu = (b'\x00' + bytes([11]) + oa + b'\x48\x00' + b'\x00' * 7 + b'\x00')
r = _decode_sm_tpdu(tpdu)
self.assertEqual(r['pid_name'], 'Device Triggering Short Message')
def test_deliver_dcs_info(self):
from simtrace_analyser.decode import _decode_sm_tpdu
oa = b'\x91' + self._bcd('79031234567')
tpdu = (b'\x00' + bytes([11]) + oa + b'\x00\x11' + b'\x00' * 7 + b'\x00')
r = _decode_sm_tpdu(tpdu)
self.assertEqual(r['dcs_info']['group'], 'Message Marked for Automatic Deletion')
self.assertEqual(r['dcs_info']['has_class'], True)
self.assertEqual(r['dcs_info']['msg_class'], 1)
def test_dcs_mwi(self):
from simtrace_analyser.decode import _decode_dcs_full
d = _decode_dcs_full(0xD0)
self.assertEqual(d['group'], 'Message Waiting Indication')
self.assertEqual(d['action'], 'store')
self.assertEqual(d['sense'], 'inactive')
self.assertEqual(d['indication'], 'voicemail')
def test_udh_concat_8bit(self):
from simtrace_analyser.decode import _decode_udh
el = _decode_udh(bytes.fromhex('0003A10201'))
self.assertEqual(el[0]['name'], 'Concatenated short messages, 8-bit reference number')
self.assertEqual(el[0]['data'], {'reference': 0xA1, 'max': 2, 'seq': 1})
def test_udh_concat_16bit(self):
from simtrace_analyser.decode import _decode_udh
el = _decode_udh(bytes.fromhex('080403000201'))
self.assertEqual(el[0]['name'], 'Concatenated short message, 16-bit reference number')
self.assertEqual(el[0]['data'], {'reference': 0x0300, 'max': 2, 'seq': 1})
def test_udh_app_port_8bit(self):
from simtrace_analyser.decode import _decode_udh
el = _decode_udh(bytes.fromhex('0402B5A5'))
self.assertEqual(el[0]['data'], {'dest_port': 0xB5, 'orig_port': 0xA5})
def test_udh_app_port_16bit(self):
from simtrace_analyser.decode import _decode_udh
el = _decode_udh(bytes.fromhex('05040B8423F0'))
self.assertEqual(el[0]['data'], {'dest_port': 0x0B84, 'orig_port': 0x23F0})
def test_udh_special_sms(self):
from simtrace_analyser.decode import _decode_udh
el = _decode_udh(bytes.fromhex('01028102'))
self.assertEqual(el[0]['data'], {'store': True, 'indication': 'fax', 'count': 2})
def test_deliver_udh_ucs2(self):
from simtrace_analyser.decode import _decode_sm_tpdu
oa = b'\x91' + self._bcd('79031234567')
ud = bytes.fromhex('050003A10201') + 'Hi'.encode('utf-16-be')
tpdu = (b'\x40' + bytes([11]) + oa + b'\x00\x08' + b'\x00' * 7 +
bytes([len(ud)]) + ud)
r = _decode_sm_tpdu(tpdu)
self.assertEqual(r['udh'][0]['data'], {'reference': 0xA1, 'max': 2, 'seq': 1})
self.assertEqual(r['text'], 'Hi')
def test_submit_vp_relative(self):
from simtrace_analyser.decode import _decode_sm_tpdu
da = b'\x81' + self._bcd('999')
tpdu = b'\x09' + b'\x2A' + bytes([len(da)]) + da + b'\x00\x00' + b'\x8F' + b'\x00'
r = _decode_sm_tpdu(tpdu)
self.assertEqual(r['mti'], 'SMS-SUBMIT')
self.assertEqual(r['vp'], '720 min')
def test_submit_vp_absolute(self):
from simtrace_analyser.decode import _decode_sm_tpdu
da = b'\x81' + self._bcd('999')
scts = bytes.fromhex('62802141030021')
tpdu = b'\x11' + b'\x2A' + bytes([len(da)]) + da + b'\x00\x00' + scts + b'\x00'
r = _decode_sm_tpdu(tpdu)
self.assertEqual(r['vp'], '2026-08-12 14:30:00 (UTC+03:00)')
def test_submit_no_ud_encoding(self):
# Real capture: absolute VP with non-BCD bytes, no TP-UD.
from simtrace_analyser.decode import _decode_sm_tpdu
tpdu = bytes.fromhex('11FF038154F50004A505326C333939')
r = _decode_sm_tpdu(tpdu)
self.assertEqual(r['da'], '455')
self.assertEqual(r['encoding'], '8-bit data')
self.assertEqual(r['msg_class'], 0)
self.assertEqual(r['vp'], 'A505326C333939')
def test_8bit_ascii_text(self):
from simtrace_analyser.decode import _decode_sm_tpdu
oa = b'\x91' + self._bcd('79031234567')
payload = b'Hello World'
tpdu = (b'\x00' + bytes([11]) + oa + b'\x00\x04' + b'\x00' * 7 +
bytes([len(payload)]) + payload)
r = _decode_sm_tpdu(tpdu)
self.assertEqual(r['encoding'], '8-bit data')
self.assertEqual(r['text'], 'Hello World')
def test_8bit_ascii_ctrl_replaced(self):
from simtrace_analyser.decode import _decode_sm_tpdu
oa = b'\x91' + self._bcd('79031234567')
payload = b'Hi\x00There'
tpdu = (b'\x00' + bytes([11]) + oa + b'\x00\x04' + b'\x00' * 7 +
bytes([len(payload)]) + payload)
r = _decode_sm_tpdu(tpdu)
self.assertEqual(r['text'], 'Hi\u00b7There')
def test_8bit_high_text(self):
from simtrace_analyser.decode import _decode_sm_tpdu
oa = b'\x91' + self._bcd('79031234567')
payload = '\u00e9\u00fc\u00f1'.encode('latin-1')
tpdu = (b'\x00' + bytes([11]) + oa + b'\x00\x04' + b'\x00' * 7 +
bytes([len(payload)]) + payload)
r = _decode_sm_tpdu(tpdu)
self.assertEqual(r['text'], '\u00e9\u00fc\u00f1')
def test_8bit_mixed_no_text(self):
from simtrace_analyser.decode import _decode_sm_tpdu
oa = b'\x91' + self._bcd('79031234567')
payload = b'\x00\x01\xff\xfe'
tpdu = (b'\x00' + bytes([11]) + oa + b'\x00\x04' + b'\x00' * 7 +
bytes([len(payload)]) + payload)
r = _decode_sm_tpdu(tpdu)
self.assertNotIn('text', r)
self.assertEqual(r['payload'], '0001FFFE')
class TestBcdAddress(unittest.TestCase):
def test_international(self):
from simtrace_analyser.decode import _decode_bcd_address
self.assertEqual(_decode_bcd_address(bytes.fromhex('912143658709')), '+1234567890')
def test_national(self):
from simtrace_analyser.decode import _decode_bcd_address
self.assertEqual(_decode_bcd_address(bytes.fromhex('A12143658709')), '1234567890')
def test_odd_digits(self):
from simtrace_analyser.decode import _decode_bcd_address
self.assertEqual(_decode_bcd_address(bytes.fromhex('9121436587F9')), '+123456789')
class TestProactiveDecode(unittest.TestCase):
def test_setup_menu(self):
r = decode_message(bytes.fromhex(
'8012000030D02E810301250082028182050B416C6661204D6F62696C'
'658F16808112089DB0C1C2C0BEB9BAB82F53657474696E67739000'))
cmd = r['cmd']
self.assertEqual(cmd['type'], 'SET UP MENU')
self.assertEqual(cmd['title'], 'Alfa Mobile')
self.assertEqual(cmd['items'], [{'id': 0x80, 'text': 'Настройки/Settings'}])
def test_setup_event_list(self):
r = decode_message(bytes.fromhex(
'801200000FD00D810301050082028182990203129000'))
self.assertEqual(r['cmd']['events'], ['Location status', 'Network Rejection'])
def test_poll_interval_duration(self):
r = decode_message(bytes.fromhex(
'801200000FD00D8103010300820281820402011E9000'))
self.assertEqual(r['cmd']['duration'], {'value': 30, 'unit': 'seconds'})
def test_send_short_message_tpdu(self):
r = decode_message(bytes.fromhex(
'801200004AD04881030113008202818305000B3B11FF038199F90004A531'
'494D45492038363732333130353634323832383020494D53492032353031'
'3937373030313932373935204556454E5420319000'))
self.assertEqual(r['cmd']['type'], 'SEND SHORT MESSAGE')
self.assertEqual(r['cmd']['tpdu']['mti'], 'SMS-SUBMIT')
self.assertEqual(r['cmd']['tpdu']['da'], '999')
def test_send_short_message_rich_tpdu(self):
r = decode_message(bytes.fromhex(
'801200001AD01881030113008202818305000B0B0101038199F9000402'
'48699000'))
t = r['cmd']['tpdu']
self.assertEqual(r['cmd']['type'], 'SEND SHORT MESSAGE')
self.assertEqual(t['mti'], 'SMS-SUBMIT')
self.assertEqual(t['da'], '999')
self.assertEqual(t['pid_name'], 'SME-to-SME (implicit)')
self.assertEqual(t['encoding'], '8-bit data')
self.assertEqual(t['text'], 'Hi')
def test_setup_menu_long_form_length(self):
# Real Mi A1 startup trace: D0 length encoded as 0x81 0xBC (188 bytes).
r = decode_message(bytes.fromhex(
'80120000BFD081BC810301250082028182051580004D0065006700610046'
'006F006E00500052004F8F100180041C0435043304300424043E043D8F18'
'02800420043004370432043B043504470435043D0438044F8F0C03800421'
'043F043E044004428F100480041D043E0432043E0441044204388F100580'
'04240438043D0430043D0441044B8F1006800421043F044004300432043A'
'04308F100780041E043104490435043D043804358F188080041A0430043B'
'043504390434043E0441043A043E043F9000'))
self.assertEqual(r['cat_command'], 'SET UP MENU')
self.assertEqual(r['cmd']['title'], 'MegaFonPRO')
self.assertEqual(r['cmd']['items'][0], {'id': 1, 'text': 'МегаФон'})
class TestAnnexA(unittest.TestCase):
def test_gsm_default(self):
from simtrace_analyser.decode import _decode_annex_a
self.assertEqual(_decode_annex_a(b'Alfa Mobile'), 'Alfa Mobile')
def test_ucs2_variant1(self):
from simtrace_analyser.decode import _decode_annex_a
self.assertEqual(_decode_annex_a(b'\x80' + 'Привет'.encode('utf_16_be')), 'Привет')
def test_ucs2_variant2_base_ptr(self):
from simtrace_analyser.decode import _decode_annex_a
# 'Н' = U+041D, base 0x08<<7 = 0x400, offset 0x1D
self.assertEqual(_decode_annex_a(bytes.fromhex('8101089D')), 'Н')
def test_ucs2_variant_90(self):
from simtrace_analyser.decode import _decode_annex_a
self.assertEqual(_decode_annex_a(b'\x90' + 'Привет'.encode('utf_16_be')), 'Привет')
def test_gsm7_packed(self):
from simtrace_analyser.decode import _decode_annex_a
self.assertEqual(_decode_annex_a(bytes.fromhex('41b6390c')), 'Alfa')
class TestPnnSpn(unittest.TestCase):
def test_pnn_full_name(self):
from simtrace_analyser.decode import _decode_pnn
r = _decode_pnn(bytes.fromhex('43058441b6390c' + 'ff' * 13))
self.assertEqual(r['full'], 'Alfa')
def test_pnn_ucs2(self):
from simtrace_analyser.decode import _decode_pnn
v = b'\x90' + 'Привет'.encode('utf-16-be')
r = _decode_pnn(b'\x43' + bytes([len(v)]) + v)
self.assertEqual(r['full'], 'Привет')
def test_spn_ucs2_90(self):
from simtrace_analyser.decode import _decode_spn
r = _decode_spn(b'\x00' + b'\x90' + 'Привет'.encode('utf-16-be'))
self.assertEqual(r['name'], 'Привет')
def test_spn_gsm7(self):
from simtrace_analyser.decode import _decode_spn
r = _decode_spn(b'\x00' + bytes.fromhex('41b6390c'))
self.assertEqual(r['name'], 'Alfa')
class TestDcsText(unittest.TestCase):
def test_gsm7(self):
from simtrace_analyser.decode import _decode_dcs_text
self.assertEqual(_decode_dcs_text(b'\x00Alfa'), 'Alfa')
def test_ucs2(self):
from simtrace_analyser.decode import _decode_dcs_text
self.assertEqual(_decode_dcs_text(b'\x08' + 'OK'.encode('utf_16_be')), 'OK')
def test_latin1(self):
from simtrace_analyser.decode import _decode_dcs_text
self.assertEqual(_decode_dcs_text(b'\x04caf\xe9'), 'café')
class TestAtrPps(unittest.TestCase):
def test_atr_simple_t0_only(self):
from simtrace_analyser.decode import _decode_atr
# 3B 00: direct, T0=00, no interface bytes, 0 historical bytes, only T=0 → no TCK
r = _decode_atr(bytes.fromhex('3B00'))
self.assertEqual(r['convention'], 'direct')
self.assertEqual(r['t0'], '00')
self.assertEqual(r['historical_len'], 0)
self.assertNotIn('tck', r)
def test_atr_ta1(self):
from simtrace_analyser.decode import _decode_atr
# 3B 12 11 00 00: TA1=11 (Fi=1,Di=1), 2 historical bytes, T=0 → no TCK
r = _decode_atr(bytes.fromhex('3B12110000'))
self.assertEqual(r['convention'], 'direct')
self.assertEqual(r['interface'][0]['name'], 'TA1')
self.assertEqual(r['interface'][0]['f'], 372)
self.assertEqual(r['interface'][0]['d'], 1)
self.assertEqual(r['historical_len'], 2)
self.assertNotIn('tck', r)
def test_atr_data_rate_from_clk(self):
from simtrace_analyser.decode import _decode_atr
# TA1=11 → F=372, D=1; CLK 3.579545 MHz → 9622 bit/s, ETU 103.9 µs
r = _decode_atr(bytes.fromhex('3B12110000'), clk_hz=3579545)
self.assertEqual(r['clk_hz'], 3579545)
self.assertEqual(r['data_rate'], 9622)
self.assertEqual(r['etu_us'], 103.9)
def test_atr_data_rate_uses_ta1_fidi(self):
from simtrace_analyser.decode import _decode_atr
# TA1=95 → F=512, D=16; CLK 4 MHz → 125000 bit/s, ETU 8.0 µs
r = _decode_atr(bytes.fromhex('3B1095'), clk_hz=4000000)
self.assertEqual(r['data_rate'], 125000)
self.assertEqual(r['etu_us'], 8.0)
def test_atr_data_rate_defaults_without_ta1(self):
from simtrace_analyser.decode import _decode_atr
# No TA1 → default F=372, D=1
r = _decode_atr(bytes.fromhex('3B00'), clk_hz=4000000)
self.assertEqual(r['data_rate'], 10753)
self.assertEqual(r['etu_us'], 93.0)
def test_atr_no_clk_no_rate(self):
from simtrace_analyser.decode import _decode_atr
r = _decode_atr(bytes.fromhex('3B12110000'))
self.assertNotIn('clk_hz', r)
self.assertNotIn('data_rate', r)
self.assertNotIn('etu_us', r)
def test_atr_td1_t1_has_tck(self):
from simtrace_analyser.decode import _decode_atr
# 3B 80 01 80 31 80 66 ... : construct T=1 with TCK
# T0=80 → TD1 present, 0 historical; TD1=01 (T=1, no more interface); then TCK
# bytes: 3B 80 01 xx where xx = XOR(80,01) = 81
r = _decode_atr(bytes.fromhex('3B800181'))
self.assertEqual(r['protocols'], ['T=1'])
self.assertIn('tck', r)
self.assertTrue(r['tck_valid'])
self.assertEqual(r['tck'], '81')
def test_atr_inverse_convention(self):
from simtrace_analyser.decode import _decode_atr
# Inverse: TS=3F, then inverted bytes. Take direct ATR "3B 00" and invert body.
# Inverted 0x00 is 0x00, so ATR = 3F 00 → convention inverse, T0=00.
r = _decode_atr(bytes.fromhex('3F00'))
self.assertEqual(r['convention'], 'inverse')
self.assertEqual(r['t0'], '00')
def test_atr_historical_life_cycle(self):
from simtrace_analyser.decode import _decode_atr
# T0 = 0x03 (3 historical bytes). Category '80' (the value a UICC
# always uses, TS 102 221 §6.3.1) carries COMPACT-TLV objects; the
# trailing '81' object is a status indicator holding the LCS
# (ISO 7816-4 §8.1.1.3).
r = _decode_atr(bytes.fromhex('3B03808105'))
self.assertEqual(r['historical_len'], 3)
self.assertEqual(r['historical']['category'], 'COMPACT-TLV objects')
self.assertEqual(r['historical']['life_cycle'],
'operational state (activated)')
self.assertEqual(r['historical']['objects'], [])
def test_atr_historical_status_bytes(self):
from simtrace_analyser.decode import _decode_atr
# '82' = SW1-SW2, '83' = LCS + SW1-SW2 (ISO 7816-4 §8.1.1.3).
r = _decode_atr(bytes.fromhex('3B0480829000'))
self.assertEqual(r['historical']['sw'], '9000')
r = _decode_atr(bytes.fromhex('3B058083059000'))
self.assertEqual(r['historical']['life_cycle'],
'operational state (activated)')
self.assertEqual(r['historical']['sw'], '9000')
def test_atr_historical_status_category_00(self):
from simtrace_analyser.decode import _decode_atr
# Category '00': the last three bytes are the status indicator, the
# bytes before it optional COMPACT-TLVs.
r = _decode_atr(bytes.fromhex('3B0400059000'))
self.assertEqual(r['historical']['category'],
'COMPACT-TLV objects + status indicator')
self.assertEqual(r['historical']['life_cycle'],
'operational state (activated)')
self.assertEqual(r['historical']['sw'], '9000')
def test_atr_card_capabilities(self):
from simtrace_analyser.decode import _decode_atr
# A real workspace-card ATR's historical bytes: '31 E0' card service
# data, '73 FE 21 1B' card capabilities (3 tables: selection methods
# FE, data coding 21, 1B = card-assigned channels, 4 maximum) and
# '66 ...' pre-issuing data. TS 102 221 §6.3.1 pins the first two
# objects.
r = _decode_atr(bytes.fromhex('3B0E8031E073FE211B66D0022A761300'))
hist = r['historical']
self.assertEqual(hist['category'], 'COMPACT-TLV objects')
self.assertEqual([o['tag'] for o in hist['objects']], ['3', '7', '6'])
cs = hist['objects'][0]['card_service']
self.assertEqual(cs['selection'], ['full DF name', 'partial DF name'])
self.assertTrue(cs['ber_tlv_in_dir'])
self.assertFalse(cs['ber_tlv_in_atr'])
self.assertTrue(cs['card_with_mf'])
caps = hist['card_capabilities']
self.assertEqual(caps['tables'], 3)
self.assertEqual(caps['max_channels'], 4)
# b5/b4 declare the assignment methods the card supports (here both).
self.assertEqual(caps['channel_assignment'],
['by the card', 'by the interface device'])
self.assertFalse(caps['command_chaining'])
self.assertFalse(caps['extended_lc_le'])
self.assertIn('full DF name', caps['selection_methods'])
self.assertEqual(caps['data_coding']['write'], 'proprietary')
def test_atr_card_capabilities_extended(self):
from simtrace_analyser.decode import _decode_atr
# '73 FE 21 57': y=z=t=1 → "eight or more" logical channels
# (ISO 7816-4 Table 88) — the extended-channel capable cards.
r = _decode_atr(bytes.fromhex('3B0E8031E073FE215766D0022A761300'))
caps = r['historical']['card_capabilities']
self.assertEqual(caps['max_channels'], '8 or more')
self.assertEqual(caps['channel_assignment'], ['by the card'])
self.assertTrue(caps['extended_lc_le'])
def test_atr_historical_reserved_and_proprietary(self):
from simtrace_analyser.decode import _decode_atr
r = _decode_atr(bytes.fromhex('3B018F'))
self.assertEqual(r['historical']['category'],
'reserved (ISO 7816-4)')
r = _decode_atr(bytes.fromhex('3B01A0'))
self.assertEqual(r['historical']['category'], 'proprietary')
r = _decode_atr(bytes.fromhex('3B021001'))
self.assertEqual(r['historical']['category'], 'DIR data reference')
self.assertEqual(r['historical']['dir_reference'], '01')
def test_manage_channel_response(self):
# MANAGE CHANNEL open: the card returns the assigned channel as the
# response data byte (ISO 7816-4 §7.1.5); a close carries none.
from simtrace_analyser.decode import decode_message
r = decode_message(bytes.fromhex('0070000001019110'))
self.assertEqual(r['p1']['name'], 'Open channel')
self.assertEqual(r['response'], {'channel': 1})
r = decode_message(bytes.fromhex('0070000001029000'))
self.assertEqual(r['response'], {'channel': 2})
r = decode_message(bytes.fromhex('00708002009000'))
self.assertEqual(r['p1']['name'], 'Close channel')
self.assertNotIn('response', r)
# The extended channels are assigned the same way (1-19).
r = decode_message(bytes.fromhex('0070000001139000'))
self.assertEqual(r['response'], {'channel': 0x13})
def test_atr_t15_is_pps_capability(self):
from simtrace_analyser.decode import _decode_atr
# Real UICC ATR: T0=9F (TA1+TD1, 15 hist bytes), TA1=95, TD1=80 (T=0),
# TD2=1F (T=15 → PPS capability), TA3=C7 (clock stop + classes A/B/C).
r = _decode_atr(bytes.fromhex('3B9F95801FC78031E073FE21136364035B83079000F7'))
self.assertEqual(r['protocols'], ['T=0'])
self.assertTrue(r['pps'])
ta3 = r['interface'][1]
self.assertEqual(ta3['name'], 'TA3')
self.assertEqual(ta3['clock_stop'], 'no preference')
self.assertEqual(ta3['classes'], ['A', 'B', 'C'])
self.assertTrue(r['tck_valid'])
def test_pps(self):
from simtrace_analyser.decode import _decode_pps
# FF 10 11 xx: PPS0=10 (T=0, PPS1 present), PPS1=11 (Fi=1,Di=1), PCK=XOR(FF,10,11)=FE
r = _decode_pps(bytes.fromhex('FF1011FE'))
self.assertEqual(r['protocol'], 'T=0')
self.assertEqual(r['fi_di']['f'], 372)
self.assertEqual(r['fi_di']['d'], 1)
self.assertTrue(r['pck_valid'])
self.assertEqual(r['pck'], 'FE')
class TestPacketSummaries(unittest.TestCase):
"""APDU-list descriptions for ATR/PPS/RST/VCC packets."""
def _dec(self, hexstr, kind, clk_hz=None):
from simtrace_analyser.decode import decode_sniff_msg
return decode_sniff_msg(bytes.fromhex(hexstr), kind, clk_hz=clk_hz)
def test_atr_simple(self):
self.assertEqual(self._dec('3B00', 'atr')['summary'], 'T=0')
def test_atr_ta1(self):
self.assertEqual(self._dec('3B12110000', 'atr')['summary'],
'T=0 \u00b7 F=372, D=1, F/D=372')
def test_atr_classes_and_data_rate(self):
r = self._dec('3B9F95801FC78031E073FE21136364035B83079000F7', 'atr',
clk_hz=3582090)
self.assertEqual(
r['summary'],
'T=0 \u00b7 F=512, D=16, F/D=32 \u00b7 classes A, B, C \u00b7 111940 bit/s')
def test_atr_unparsed_has_no_summary(self):
self.assertIsNone(self._dec('3B', 'atr')['summary'])
def test_pps_summary(self):
self.assertEqual(self._dec('FF10957A', 'pps')['summary'],
'T=0 \u00b7 F=512, D=16, F/D=32')
def test_rst_clk_summary(self):
self.assertEqual(self._dec('0001010036A88A', 'rst')['summary'],
'CLK 3.582 MHz')
def test_rst_without_clk_has_no_summary(self):
self.assertIsNone(self._dec('010000', 'rst').get('summary'))
def test_vcc_has_no_summary(self):
self.assertIsNone(self._dec('010100', 'vcc').get('summary'))
class TestTerminalProfile(unittest.TestCase):
"""TERMINAL PROFILE device characteristics (regression anchors from
projects/docs/TERMINAL_PROFILE.md §10)."""
def _dec(self, profile_hex):
data = bytes.fromhex(profile_hex)
raw = '80100000%02x' % len(data) + profile_hex + '9000'
return decode_message(bytes.fromhex(raw))
def test_xiaomi_mi_a1(self):
r = self._dec('FFFFFFFF7F9F00DFFF03021FE2000000C3FB0007041178'
'00710100000038428003')
tp = r['tp']
self.assertEqual(r['body']['label'], 'Profile')
self.assertTrue(tp['bip'])
self.assertEqual(tp['bip_channels'], 7)
self.assertEqual(tp['bip_bearers'], ['GPRS'])
self.assertEqual(tp['bip_transports'],
['TCP client, remote', 'UDP client, remote'])
self.assertTrue(tp['hsdpa'])
self.assertTrue(tp['eutran'])
self.assertEqual(tp['generation'], '3G/4G')
self.assertTrue(tp['utran_ps'])
self.assertEqual(tp['standards'], '3GPP + CDMA')
self.assertEqual(tp['summary'], '3G/4G \u00b7 BIP')
self.assertEqual(r['summary'], '3G/4G \u00b7 BIP')
def test_quectel_gsm_module(self):
r = self._dec('FFFFFFFF7F1F007FFF00001F230811060700')
tp = r['tp']
self.assertTrue(tp['bip'])
self.assertEqual(tp['bip_channels'], 1)
self.assertEqual(tp['bip_bearers'], ['CSD', 'GPRS'])
self.assertNotIn('generation', tp)
self.assertEqual(tp['summary'], 'BIP')
def test_huawei_e173_underdeclared(self):
# The E173 is a 3G modem yet leaves E-UTRAN/HSDPA clear — the TP
# must not be read as "2G".
r = self._dec('2965D7E11F90001FD50000000000000000E00000')
tp = r['tp']
self.assertIs(tp['bip'], False)
self.assertNotIn('generation', tp)
self.assertEqual(tp['standards'], '3GPP')
self.assertEqual(tp['summary'], 'no BIP')
def test_nokia_7210(self):
r = self._dec('FFFFFFFF7F3F00DFFF00001FE28A0D0203290000020040')
tp = r['tp']
self.assertTrue(tp['bip'])
self.assertEqual(tp['bip_channels'], 7)
self.assertEqual(tp['screen_h'], 10)
self.assertEqual(tp['screen_w'], 13)
self.assertNotIn('device_class', tp)
self.assertEqual(tp['summary'], 'BIP')
def test_declared_headless_modem(self):
# byte 14 b6 (0x20) = no display capability (class ND).
r = self._dec('FFFFFFFF7F9F00DFFF03021F00' + '20' + '00' * 6)
tp = r['tp']
self.assertEqual(tp['device_class'], 'modem')
self.assertIn('declared', tp['class_reason'])
self.assertIn('no display', tp['class_reason'])
self.assertEqual(tp['summary'], 'modem \u00b7 BIP')
def test_handset_class(self):
r = self._dec('FFFFFFFF7F9F00DFFF03021FE208110000')
tp = r['tp']
self.assertEqual(tp['device_class'], 'handset')
self.assertIn('screen 8\u00d717', tp['class_reason'])
self.assertEqual(tp['summary'], 'handset \u00b7 BIP')
class TestTerminalCapability(unittest.TestCase):
"""TERMINAL CAPABILITY (TS 102 221 §11.1.19) — real samples captured
from the local trace sessions (49/55/58/61/206/208)."""
def _dec(self, body_hex):
raw = '80AA0000%02x%s9000' % (len(body_hex) // 2, body_hex)
return decode_message(bytes.fromhex(raw))
def test_power_supply(self):
# A9 0A 80 03 04 3C FF 81 00 82 01 00 — class C, 60 mA, clock n/a.
r = self._dec('A90A8003043CFF8100820100')
self.assertEqual(r['body']['label'], 'Terminal capability template (A9)')
ps = r['cmd']['power_supply']
self.assertEqual(ps['classes'], ['C'])
self.assertEqual(ps['class_raw'], 0x04)
self.assertEqual(ps['max_current_ma'], 60)
self.assertIsNone(ps['clock_mhz'])
self.assertTrue(r['cmd']['extended_channels'])
self.assertEqual(r['cmd']['interfaces'], [])
self.assertEqual(r['summary'],
'Class C \u00b7 60 mA \u00b7 CLK n/a \u00b7 extended channels')
def test_interfaces_and_euicc(self):
# A9 08 81 00 82 01 01 83 01 87 — UICC-CLF + SGP.22 LPA functions
# (b8 is an RFU bit and must not affect the function list).
r = self._dec('A9088100820101830187')
cmd = r['cmd']
self.assertNotIn('power_supply', cmd)
self.assertEqual(cmd['interfaces'], ['UICC-CLF'])
self.assertEqual(cmd['euicc_sgp22']['raw'], '87')
self.assertEqual(cmd['euicc_sgp22']['functions'], ['LUId', 'LPDd', 'LDSd'])
self.assertEqual(r['summary'],
'extended channels \u00b7 UICC-CLF \u00b7 '
'eUICC: LUId, LPDd, LDSd')
def test_interfaces_only(self):
# A9 05 81 00 82 01 01 — phones that omit the power supply TLV.
r = self._dec('A9058100820101')
self.assertNotIn('power_supply', r['cmd'])
self.assertEqual(r['summary'], 'extended channels \u00b7 UICC-CLF')
def test_extended_channels_nonzero_length(self):
# A UICC shall interpret any length as zero (TS 102 221 §11.1.19.2.2).
r = self._dec('A906810100820101')
self.assertTrue(r['cmd']['extended_channels'])
self.assertEqual(r['cmd']['extended_channels_raw'], '00')
def test_private_tlv_preserved(self):
# Unknown/private TLV ('DD') inside the A9 template must survive.
r = self._dec('A9088100820101DD017F')
self.assertEqual(r['cmd']['raw_tlv'], [{'tag': 'DD', 'value': '7F'}])
self.assertEqual(r['summary'], 'extended channels \u00b7 UICC-CLF')
def test_no_a9_template(self):
# Command data without the A9 template stays undecoded (no crash).
r = self._dec('8003043CFF')
self.assertNotIn('cmd', r)
class TestSummary(unittest.TestCase):
def test_select_path_from_mf(self):
r = decode_message(bytes.fromhex('a0a40804022fe29000'))
self.assertEqual(r['summary'], 'Path from MF: 2FE2 (EF_ICCID), Return FCI template')
def test_select_by_file_id(self):
r = decode_message(bytes.fromhex('a0a40000023f009000'))
self.assertEqual(r['summary'], 'DF/EF/MF by file ID: 3F00 (MF)')
def test_read_record(self):
r = decode_message(bytes.fromhex('a0b2010400'))
self.assertEqual(r['summary'], 'Record number: 1, absolute mode (record number in P1)')
def test_verify_pin_no_leak(self):
# VERIFY PIN with P2 = 0x01 (PIN Appl 1): the PIN value must not leak.
r = decode_message(bytes.fromhex('0020000108' + '12' * 8))
self.assertEqual(r['summary'], 'PIN Appl 1')
self.assertNotIn('12', r['summary'])
def test_fetch_setup_menu(self):
r = decode_message(bytes.fromhex(
'8012000030D02E810301250082028182050B416C6661204D6F62696C'
'658F16808112089DB0C1C2C0BEB9BAB82F53657474696E67739000'))
self.assertEqual(r['summary'], 'Alfa Mobile, 1 item')
def test_envelope_sms_pp(self):
r = decode_message(bytes.fromhex(
'80C2000022D120020283810607919740430900F40B1104038154F50004'
'628021410300210248699000'))
self.assertEqual(r['summary'], 'SMSC +79043490004, SMS-DELIVER from 455 «Hi»')
def test_terminal_response(self):
r = decode_message(bytes.fromhex(
'80140000108103010300020282810301000402011E9000'))
self.assertEqual(r['summary'], '→ POLL INTERVAL, Command performed successfully')
def test_get_response_for(self):
r = decode_message(bytes.fromhex('a0c000000f'),
prev={'ins_name': 'SELECT', 'sw1': '61'})
self.assertEqual(r['summary'], 'response for SELECT')
class TestSelectPath(unittest.TestCase):
def test_path_adf_alias(self):
# 7FFF = current ADF; 6F05 after it → EF_LI (USIM).
r = decode_message(bytes.fromhex('a0a40804047fff6f059000'))
self.assertEqual(r['body']['note'], 'current ADF/EF_LI')
self.assertEqual(r['summary'],
'Path from MF: 7FFF6F05 (current ADF/EF_LI), Return FCI template')
def test_path_full(self):
r = decode_message(bytes.fromhex('a0a40804067f107f206f079000'))
self.assertEqual(r['body']['note'], 'DF_TELECOM/DF_GSM/EF_IMSI')
def test_path_current_df(self):
# P1 = 0x09 → 'Path from current DF' (no 7FFF/3F00 prefix).
r = decode_message(bytes.fromhex('a0a40904046f076f049000'))
self.assertEqual(r['p1']['name'], 'Path from current DF')
self.assertEqual(r['body']['note'], 'EF_IMSI/EF_IMPU')
def test_path_isim_adf_alias(self):
# 7FFF 6F04 → EF_IMPU (ISIM).
r = decode_message(bytes.fromhex('a0a40804047fff6f049000'))
self.assertEqual(r['body']['note'], 'current ADF/EF_IMPU')
def test_path_df_child(self):
# 7FFF 5F3B 4F20 → current ADF / DF_GSM_ACCESS / EF_Kc.
r = decode_message(bytes.fromhex('a0a40804067fff5f3b4f209000'))
self.assertEqual(r['body']['note'], 'current ADF/DF_GSM_ACCESS/EF_Kc')
def test_path_df_child_disambiguates_fid(self):
# 7F10 5F3B 4F47 → DF_TELECOM / DF_MULTIMEDIA / EF_MML (5F3B is
# DF_MULTIMEDIA here, not DF_GSM_ACCESS).
r = decode_message(bytes.fromhex('a0a40804067f105f3b4f479000'))
self.assertEqual(r['body']['note'], 'DF_TELECOM/DF_MULTIMEDIA/EF_MML')
def test_path_phonebook(self):
r = decode_message(bytes.fromhex('a0a40804067f105f3a4f229000'))
self.assertEqual(r['body']['note'], 'DF_TELECOM/DF_PHONEBOOK/EF_PSC')
def test_path_5gs(self):
r = decode_message(bytes.fromhex('a0a40804067fff5fc04f019000'))
self.assertEqual(r['body']['note'], 'current ADF/DF_5GS/EF_5GS3GPPLOCI')
class TestFileDecoders(unittest.TestCase):
def test_imsi(self):
from simtrace_analyser.decode import _decode_file_data
f = _decode_file_data('6f07', bytes.fromhex('082905102143658709'))
self.assertEqual(f['imsi'], '250011234567890')
def test_imsi_real_card(self):
# Real capture (Mi A1): 08 length byte + 8 bytes, first nibble is the
# TS 24.008 type+parity indicator (9 = IMSI, odd).
from simtrace_analyser.decode import _decode_file_data
f = _decode_file_data('6f07', bytes.fromhex('082905917700917259'))
self.assertEqual(f['imsi'], '250197700192795')
def test_iccid(self):
from simtrace_analyser.decode import _decode_file_data
f = _decode_file_data('2fe2', bytes.fromhex('98891020000000460012'))
self.assertEqual(f['iccid'], '89980102000000640021')
def test_li(self):
from simtrace_analyser.decode import _decode_file_data
f = _decode_file_data('6f05', b'enru')
self.assertEqual(f['languages'], ['en', 'ru'])
def test_ef_impu_empty_record(self):
# EF_IMPU record that is an unused identity: tag 0x80, zero-length
# value, FF erase filler. Must be reported empty, NOT decoded as text.
from simtrace_analyser.decode import _decode_file_data
f = _decode_file_data('6f04', bytes.fromhex('8000' + 'ff' * 73))
self.assertIn('empty', f)
self.assertNotIn('text', f)
def test_ef_impu_all_ff(self):
from simtrace_analyser.decode import _decode_file_data
f = _decode_file_data('6f04', b'\xff' * 75)
self.assertIn('empty', f)
def test_ef_impu_sip_uri(self):
from simtrace_analyser.decode import _decode_file_data
payload = b'\x80' + bytes([len(b'sip:alice@example.com')]) + b'sip:alice@example.com'
f = _decode_file_data('6f04', payload)
self.assertEqual(f.get('text'), 'sip:alice@example.com')
def test_ef_impu_lone_tag(self):
# A record containing only the identity tag (no length/value) is empty.
from simtrace_analyser.decode import _decode_file_data
f = _decode_file_data('6f04', b'\x80')
self.assertIn('empty', f)
def test_adn(self):
from simtrace_analyser.decode import _decode_file_data
f = _decode_file_data('6f3a', bytes.fromhex(
'42204841203120536963FFFFFFFFFFFF06810628560810FFFFFFFFFFFFFF'))
self.assertEqual(f['name'], 'B HA 1 Sic')
self.assertEqual(f['number'], '6082658001')
def test_adn_ber_tlv_fallback(self):
# Non-ADN data (BER-TLV) must not be decoded as a name/number.
from simtrace_analyser.decode import _decode_file_data
raw = bytes.fromhex(
'A0348001078120E823FF53C3E271754A644ED63DEFCF24A916387E3C585F'
'0820CF3E27841852F7820400000000830400000000840102'
'FFFFFFFFFFFFFFFFFFFFFFFFFFFF')
f = _decode_file_data('6fc7', raw)
self.assertNotIn('name', f)
self.assertIn('raw', f)
def test_ust(self):
from simtrace_analyser.decode import _decode_file_data
f = _decode_file_data('6f38', bytes.fromhex('07'))
self.assertEqual([s['n'] for s in f['services']], [1, 2, 3])
self.assertEqual(f['services'][0]['name'], 'Local Phone Book')
def test_plmn_list(self):
from simtrace_analyser.decode import _decode_file_data
f = _decode_file_data('6f30', bytes.fromhex('42f095'))
self.assertEqual(len(f['plmns']), 1)
self.assertIn('mcc', f['plmns'][0])
self.assertIn('mnc', f['plmns'][0])
def test_dir(self):
from simtrace_analyser.decode import _decode_file_data
f = _decode_file_data('2f00', bytes.fromhex(
'612b4f10a0000000871002fffff00189000001ff50074d656761466f6e'
'730ea00c80011781025f408203454150ffffffffffffffffffffffffffffffffffffff'))
self.assertEqual(f['applications'][0]['label'], 'MegaFon')
self.assertEqual(f['applications'][0]['aid'], 'A0000000871002FFFFF00189000001FF')
eap = f['applications'][0]['eap']
self.assertEqual(eap['eap_types'], ['EAP-AKA'])
self.assertEqual(eap['dfs'], ['5f40'])
self.assertEqual(eap['label'], 'EAP')
def test_nai_empty_record(self):
from simtrace_analyser.decode import _decode_file_data
# Unused ISIM record (all FF) must be 'empty', not garbage text.
f = _decode_file_data('6f04', b'\xff' * 75)
self.assertTrue(f['empty'])
self.assertNotIn('text', f)
def test_nai(self):
from simtrace_analyser.decode import _decode_file_data
f = _decode_file_data('6f04', bytes.fromhex(
'80357369703a32353030323639333537373333363840696d732e6d6e63'
'3030322e6d63633235302e336770706e6574776f726b2e6f7267'))
self.assertEqual(f['text'], 'sip:250026935773368@ims.mnc002.mcc250.3gppnetwork.org')
def test_plmn_wact(self):
from simtrace_analyser.decode import _decode_file_data
# TS 51.011 §10.3.35: 5-byte entries (3 PLMN + 2 access tech).
f = _decode_file_data('6f60', bytes.fromhex('52f020400052f0208000'))
self.assertEqual(f['plmns'][0]['mcc'], '250')
self.assertEqual(f['plmns'][0]['mnc'], '02')
self.assertEqual(f['plmns'][0]['access_tech'], 'E-UTRAN NB-S1, E-UTRAN WB-S1')
self.assertEqual(f['plmns'][1]['access_tech'], 'UTRAN')
def test_sms_record(self):
from simtrace_analyser.decode import _decode_file_data
# status 0x07 (MO, to be sent) + empty SMSC (len 0) + SMS-SUBMIT TPDU
rec = bytes.fromhex('0700') + bytes.fromhex('01ff038199f90004024869')
f = _decode_file_data('6f3c', rec)
self.assertEqual(f['direction'], 'MO')
self.assertEqual(f['status'], 'message to be sent')
self.assertEqual(f['tpdu']['mti'], 'SMS-SUBMIT')
self.assertEqual(f['tpdu']['da'], '999')
self.assertEqual(f['tpdu']['text'], 'Hi')
def test_loci(self):
from simtrace_analyser.decode import _decode_file_data
# EF_LOCI (11 bytes): TMSI 4 + LAI 5 + TMSI_TIME 1 + status 1.
f = _decode_file_data('6f7e', bytes.fromhex('1e731e5752f09969c50000'))
self.assertEqual(f['tmsi'], '1E731E57')
self.assertEqual(f['mcc'], '250')
self.assertEqual(f['mnc'], '99')
self.assertEqual(f['lac'], '0x69C5')
self.assertEqual(f['location_update_status'], '0x00')
def test_epsloci(self):
from simtrace_analyser.decode import _decode_file_data
# EF_EPSLOCI (18 bytes): GUTI 12 + TAI 5 + status 1.
f = _decode_file_data('6fe3', bytes.fromhex('0bf652f0998001b0fb91192652f0991d9900'))
self.assertEqual(f['guti'], '0BF652F0998001B0FB911926')
self.assertEqual(f['eps_update_status'], '0x00')
def test_psloci(self):
from simtrace_analyser.decode import _decode_file_data
# EF_PSLOCI (14 bytes): P-TMSI 4 + sig 3 + RAI 6 + status 1.
f = _decode_file_data('6f73', bytes.fromhex('1111111122222252f0991d990001'))
self.assertEqual(f['p_tmsi'], '11111111')
self.assertEqual(f['p_tmsi_signature'], '222222')
self.assertEqual(f['mcc'], '250')
self.assertEqual(f['mnc'], '99')
self.assertEqual(f['lac'], '0x1D99')
self.assertEqual(f['rac'], '0x00')
self.assertEqual(f['update_status'], '0x01')
def test_epsnsc(self):
from simtrace_analyser.decode import _decode_file_data
# EF_EPSNSC record: A0 { 80 KSI_ASME, 81 K_ASME, 82/83 NAS counts, 84 algos }.
f = _decode_file_data('6fe4', bytes.fromhex(
'a0188001018104aabbccdd820400000003830400000004840102'))
self.assertEqual(f['ksi_asme'], '01')
self.assertEqual(f['k_asme'], 'AABBCCDD')
self.assertEqual(f['uplink_nas_count'], 3)
self.assertEqual(f['downlink_nas_count'], 4)
self.assertEqual(f['nas_algorithms'], '02')
def test_read_binary_direct(self):
# SELECT EF_IMSI then READ BINARY → file decoded in body.
r = decode_message(bytes.fromhex('00b0000009') +
bytes.fromhex('082905102143658709') + bytes.fromhex('9000'),
prev={'sel': {'fid': '6f07', 'name': 'EF_IMSI'}})
self.assertEqual(r['file']['imsi'], '250011234567890')
self.assertIn('IMSI 250011234567890', r['summary'])
def test_read_binary_offset_skip(self):
# Non-zero offset: content decode is skipped (data starts mid-file)
# but the operation is still attributed to the selected file.
r = decode_message(bytes.fromhex('00b00100') +
bytes.fromhex('082905102143658709') + bytes.fromhex('9000'),
prev={'sel': {'fid': '6f07'}})
self.assertEqual(r['file'], {'fid': '6f07', 'ef': 'EF_IMSI'})
self.assertNotIn('imsi', r['file'])
def test_read_binary_offset_form(self):
# b8 of P1 = 0 → offset = b7..b1 << 8 | P2.
r = decode_message(bytes.fromhex('00b0010000'), prev=None)
self.assertEqual(r['p1p2'], {'value': 0x0100, 'offset': 256})
self.assertIn('Offset: 0x0100', r['summary'])
def test_read_binary_sfi_form(self):
# b8 of P1 = 1 → SFI referencing: SFI = b5..b1, P2 = offset.
r = decode_message(bytes.fromhex('00d68b000b') +
bytes.fromhex('0000000000000000000000') + bytes.fromhex('9000'),
prev=None)
self.assertEqual(r['p1p2'], {'value': 0x8B00, 'sfi': 11, 'offset': 0})
self.assertIn('SFI 11', r['summary'])
self.assertNotIn('offset 0', r['summary'])
def test_read_binary_sfi_offset(self):
# SFI referencing with a non-zero offset (P2 = 5).
r = decode_message(bytes.fromhex('00b08b0500'), prev=None)
self.assertEqual(r['p1p2'], {'value': 0x8B05, 'sfi': 11, 'offset': 5})
self.assertIn('SFI 11, offset 5', r['summary'])
def test_read_via_get_response(self):
iccid = bytes.fromhex('98891020000000460012')
gr = bytes.fromhex('00c00000') + bytes([len(iccid)]) + iccid + bytes.fromhex('9000')
r = decode_message(gr, prev={'ins': 0xB0, 'ins_name': 'READ BINARY', 'sw1': '61',
'sel': {'fid': '2fe2'}, 'file_ok': True})
self.assertEqual(r['file']['iccid'], '89980102000000640021')
def test_get_response_file_ok_false(self):
iccid = bytes.fromhex('98891020000000460012')
gr = bytes.fromhex('00c00000') + bytes([len(iccid)]) + iccid + bytes.fromhex('9000')
r = decode_message(gr, prev={'ins': 0xB0, 'ins_name': 'READ BINARY', 'sw1': '61',
'sel': {'fid': '2fe2'}, 'file_ok': False})
self.assertNotIn('file', r)
def test_search_record_numbers(self):
# SEARCH RECORD returns 61xx; GET RESPONSE carries the record numbers.
numbers = bytes(range(3, 21)) # 3..20
gr = bytes.fromhex('00c00000') + bytes([len(numbers)]) + numbers + bytes.fromhex('9000')
r = decode_message(gr, prev={'ins': 0xA2, 'ins_name': 'SEARCH RECORD', 'sw1': '61',
'sel': {'fid': '6f3a'}, 'file_ok': True})
self.assertEqual(r['file']['record_numbers'], list(range(3, 21)))
self.assertIn('18 record(s)', r['summary'])
def test_select_target_fid(self):
from simtrace_analyser.decode import select_target_fid
self.assertEqual(select_target_fid({'p1': {'raw': '00'}, 'body': {'hex': '6f07'}}), '6f07')
self.assertEqual(select_target_fid({'p1': {'raw': '08'}, 'body': {'hex': '7fff6f05'}}), '6f05')
self.assertIsNone(select_target_fid({'p1': {'raw': '04'}, 'body': {'hex': 'a0000000871002'}}))
self.assertIsNone(select_target_fid({'p1': {'raw': '08'}, 'body': {'hex': '7fff'}}))
class TestSelectionTracking(unittest.TestCase):
def test_bad_fcs_roundtrip(self):
# GSMTAP BAD_FCS flag stored via insert_message must surface as a
# decode error in the DB → frontend path (not just the direct call).
import tempfile
from simtrace_analyser.database import Database
from simtrace2_pysniff.gsmtap import GSMTAP_FLAG_BAD_FCS
with tempfile.NamedTemporaryFile(suffix='.db') as tmp:
db = Database(tmp.name)
sid = db.create_session('capture')
db.insert_message(sid, 0.1, 'tpdu', bytes.fromhex('80f2000000'),
GSMTAP_FLAG_BAD_FCS)
msgs = db.get_messages(sid)
self.assertIn('bad FCS (desynced)', msgs[0]['decoded']['errors'])
def test_select_then_read(self):
import tempfile
from simtrace_analyser.database import Database
with tempfile.NamedTemporaryFile(suffix='.db') as tmp:
db = Database(tmp.name)
sid = db.create_session('capture')
db.insert_message(sid, 0.0, 'atr', b'\x3b\x00')
db.insert_message(sid, 0.1, 'tpdu', bytes.fromhex('a0a40804047fff6f079000'))
db.insert_message(sid, 0.2, 'tpdu',
bytes.fromhex('00b0000009') + bytes.fromhex('082905102143658709') + bytes.fromhex('9000'))
msgs = db.get_messages(sid)
read = msgs[2]
self.assertEqual(read['decoded']['file']['imsi'], '250011234567890')
def test_atr_resets_selection(self):
import tempfile
from simtrace_analyser.database import Database
with tempfile.NamedTemporaryFile(suffix='.db') as tmp:
db = Database(tmp.name)
sid = db.create_session('capture')
db.insert_message(sid, 0.0, 'tpdu', bytes.fromhex('a0a40804047fff6f079000'))
db.insert_message(sid, 0.1, 'atr', b'\x3b\x00')
db.insert_message(sid, 0.2, 'tpdu',
bytes.fromhex('00b0000009') + bytes.fromhex('082905102143658709') + bytes.fromhex('9000'))
msgs = db.get_messages(sid)
self.assertNotIn('file', msgs[2]['decoded'])
def test_timeout_wt_keeps_selection(self):
# A waiting-time timeout interrupts one TPDU but the card stays
# selected, so the selection must not be reset.
import tempfile
from simtrace_analyser.database import Database
with tempfile.NamedTemporaryFile(suffix='.db') as tmp:
db = Database(tmp.name)
sid = db.create_session('capture')
db.insert_message(sid, 0.0, 'tpdu', bytes.fromhex('a0a40804047fff6f079000'))
db.insert_message(sid, 0.1, 'change', b'', 1 << 4) # TIMEOUT_WT
db.insert_message(sid, 0.2, 'tpdu',
bytes.fromhex('00b0000009') + bytes.fromhex('082905102143658709') + bytes.fromhex('9000'))
msgs = db.get_messages(sid)
self.assertEqual(msgs[2]['decoded']['file']['imsi'], '250011234567890')
def test_reset_assert_resets_selection(self):
import tempfile
from simtrace_analyser.database import Database
with tempfile.NamedTemporaryFile(suffix='.db') as tmp:
db = Database(tmp.name)
sid = db.create_session('capture')
db.insert_message(sid, 0.0, 'tpdu', bytes.fromhex('a0a40804047fff6f079000'))
db.insert_message(sid, 0.1, 'change', b'', 1 << 2) # RESET_ASSERT
db.insert_message(sid, 0.2, 'tpdu',
bytes.fromhex('00b0000009') + bytes.fromhex('082905102143658709') + bytes.fromhex('9000'))
msgs = db.get_messages(sid)
self.assertNotIn('file', msgs[2]['decoded'])
def test_gap_resets_selection(self):
# A capture gap (device disconnected/reconnected) means messages may
# have been missed, so the selection must be reset.
import tempfile
from simtrace_analyser.database import Database
with tempfile.NamedTemporaryFile(suffix='.db') as tmp:
db = Database(tmp.name)
sid = db.create_session('capture')
db.insert_message(sid, 0.0, 'tpdu', bytes.fromhex('a0a40804047fff6f079000'))
db.insert_message(sid, 0.1, 'gap', b'', 0)
db.insert_message(sid, 0.2, 'tpdu',
bytes.fromhex('00b0000009') + bytes.fromhex('082905102143658709') + bytes.fromhex('9000'))
msgs = db.get_messages(sid)
self.assertNotIn('file', msgs[2]['decoded'])
def test_failed_select_keeps_selection(self):
# A SELECT that fails (6A82) must not change the current file.
import tempfile
from simtrace_analyser.database import Database
with tempfile.NamedTemporaryFile(suffix='.db') as tmp:
db = Database(tmp.name)
sid = db.create_session('capture')
db.insert_message(sid, 0.0, 'tpdu', bytes.fromhex('a0a40804047fff6f079000'))
db.insert_message(sid, 0.1, 'tpdu', bytes.fromhex('a0a40804047fff6f136a82'))
db.insert_message(sid, 0.2, 'tpdu',
bytes.fromhex('00b0000009') + bytes.fromhex('082905102143658709') + bytes.fromhex('9000'))
msgs = db.get_messages(sid)
read = msgs[2]
self.assertEqual(read['decoded']['file']['imsi'], '250011234567890')
def test_record_op_on_transparent_is_stale(self):
# SELECT a transparent EF, then READ RECORD → selection is stale, so
# we must not decode garbage.
import tempfile
from simtrace_analyser.database import Database
with tempfile.NamedTemporaryFile(suffix='.db') as tmp:
db = Database(tmp.name)
sid = db.create_session('capture')
db.insert_message(sid, 0.0, 'tpdu', bytes.fromhex('a0a40804047fff6f056123'))
db.insert_message(sid, 0.1, 'tpdu',
bytes.fromhex('00c000000a') + bytes.fromhex('62088202012183026f05') + bytes.fromhex('9000'))
db.insert_message(sid, 0.2, 'tpdu',
bytes.fromhex('00b2010408') + bytes.fromhex('800101a010a4068301019501') + bytes.fromhex('9000'))
msgs = db.get_messages(sid)
read = msgs[2]
self.assertTrue(read['decoded']['file']['stale'])
def test_sfi_update_resolves_eps_loci(self):
# SFI referencing must resolve the target EF (EF_EPSLOCI), not the
# last-selected file.
import tempfile
from simtrace_analyser.database import Database
with tempfile.NamedTemporaryFile(suffix='.db') as tmp:
db = Database(tmp.name)
sid = db.create_session('capture')
db.insert_message(sid, 0.0, 'atr', b'\x3b\x00')
db.insert_message(sid, 0.1, 'tpdu', bytes.fromhex('00a40804027fff612a'))
db.insert_message(sid, 0.2, 'tpdu',
bytes.fromhex('00d69e0012') +
bytes.fromhex('0bf652f0998001b0fb91192652f0991d9900') +
bytes.fromhex('9000'))
msgs = db.get_messages(sid)
up = msgs[2]
self.assertEqual(up['decoded']['file']['ef'], 'EF_EPSLOCI')
self.assertEqual(up['decoded']['file']['guti'], '0BF652F0998001B0FB911926')
def test_sfi_unknown_not_misattributed(self):
# An SFI not in the map must not fall back to the selected file.
import tempfile
from simtrace_analyser.database import Database
with tempfile.NamedTemporaryFile(suffix='.db') as tmp:
db = Database(tmp.name)
sid = db.create_session('capture')
db.insert_message(sid, 0.0, 'tpdu', bytes.fromhex('00a40804022fe2611e'))
db.insert_message(sid, 0.1, 'tpdu', bytes.fromhex('00d6890001079000'))
msgs = db.get_messages(sid)
up = msgs[1]
self.assertTrue(up['decoded']['file']['unknown'])
self.assertEqual(up['decoded']['file']['sfi'], 9)
def test_record_sfi_sets_current_ef(self):
# A record command with a valid SFI sets that file as the current EF,
# so a subsequent non-SFI record op targets it (TS 102 221 §11.1.2).
import tempfile
from simtrace_analyser.database import Database
with tempfile.NamedTemporaryFile(suffix='.db') as tmp:
db = Database(tmp.name)
sid = db.create_session('capture')
db.insert_message(sid, 0.0, 'atr', b'\x3b\x00')
db.insert_message(sid, 0.1, 'tpdu', bytes.fromhex('00a40804047fff6f7e611e'))
db.insert_message(sid, 0.2, 'tpdu', bytes.fromhex('00dc01c436') +
bytes.fromhex('a0348001018104aabbccdd820400000003830400000004840102') +
bytes.fromhex('9000'))
db.insert_message(sid, 0.3, 'tpdu', bytes.fromhex('00dc010436') +
bytes.fromhex('a0348001078104aabbccdd820400000004830400000003840102') +
bytes.fromhex('9000'))
msgs = db.get_messages(sid)
self.assertEqual(msgs[2]['decoded']['file']['ef'], 'EF_EPSNSC')
# 0.3 has P2=04 (no SFI) → must still target EF_EPSNSC.
self.assertEqual(msgs[3]['decoded']['file']['ef'], 'EF_EPSNSC')
def test_channel_selection_not_clobbered(self):
# A SELECT on channel 1 (CLA 01) must not change channel 0's selection.
import tempfile
from simtrace_analyser.database import Database
with tempfile.NamedTemporaryFile(suffix='.db') as tmp:
db = Database(tmp.name)
sid = db.create_session('capture')
db.insert_message(sid, 0.0, 'tpdu', bytes.fromhex('00a40804047fff6f409000'))
db.insert_message(sid, 0.1, 'tpdu', bytes.fromhex('01a40804047fff6f02611e'))
db.insert_message(sid, 0.2, 'tpdu',
bytes.fromhex('00b2010427') +
bytes.fromhex('8109089cbeb920bdbebcb5c0ffffffffffffffffffffffffff07919752738981f7ffffffffffff') +
bytes.fromhex('9000'))
msgs = db.get_messages(sid)
up = msgs[2]
# channel 0 still selects EF_MSISDN, not channel 1's EF_IMPI.
self.assertEqual(up['decoded']['file']['ef'], 'EF_MSISDN')
def test_extended_channel_selection_isolated(self):
# CLA 0x44 = further-interindustry coding, extended channel 8
# (TS 102 221 §10.3: b4-b1 + 4); 0x48 = channel 12. Each channel
# keeps its own selection — before the channel fix the state was
# keyed by CLA & 0x03, so these all clobbered channel 0.
import tempfile
from simtrace_analyser.database import Database
with tempfile.NamedTemporaryFile(suffix='.db') as tmp:
db = Database(tmp.name)
sid = db.create_session('capture')
db.insert_message(sid, 0.0, 'tpdu',
bytes.fromhex('00a40804047fff6f409000')) # ch0: MSISDN
db.insert_message(sid, 0.1, 'tpdu',
bytes.fromhex('44a40804047fff6f029000')) # ch8: OCST
db.insert_message(sid, 0.2, 'tpdu',
bytes.fromhex('48a40804047fff6fb79000')) # ch12: ECC
read = (bytes.fromhex('b2010427') +
bytes.fromhex('8109089cbeb920bdbebcb5c0ffffffffffffffffffffffffff07919752738981f7ffffffffffff') +
bytes.fromhex('9000'))
db.insert_message(sid, 0.3, 'tpdu', bytes([0x44]) + read)
db.insert_message(sid, 0.4, 'tpdu', bytes([0x00]) + read)
db.insert_message(sid, 0.5, 'tpdu', bytes([0x48]) + read)
msgs = db.get_messages(sid)
self.assertEqual(msgs[3]['decoded']['file']['ef'], 'EF_OCST')
self.assertEqual(msgs[4]['decoded']['file']['ef'], 'EF_MSISDN')
self.assertEqual(msgs[5]['decoded']['file']['ef'], 'EF_ECC')
self.assertEqual(msgs[3]['decoded']['cla']['channel'], 8)
self.assertEqual(msgs[5]['decoded']['cla']['channel'], 12)
def test_manage_channel_opens_use_their_channel(self):
# The MANAGE CHANNEL open/close exchanges themselves run on the
# basic channel; a later extended-channel command must not disturb it.
import tempfile
from simtrace_analyser.database import Database
with tempfile.NamedTemporaryFile(suffix='.db') as tmp:
db = Database(tmp.name)
sid = db.create_session('capture')
db.insert_message(sid, 0.0, 'tpdu', bytes.fromhex('0070000001059000'))
db.insert_message(sid, 0.1, 'tpdu', bytes.fromhex('44a40804047fff6f079000'))
msgs = db.get_messages(sid)
self.assertEqual(msgs[0]['decoded']['response'], {'channel': 5})
self.assertEqual(msgs[1]['decoded']['cla']['channel'], 8)
class TestSfiResolution(unittest.TestCase):
def test_sfi_resolved(self):
r = decode_message(
bytes.fromhex('00d69e00120bf652f0998001b0fb91192652f0991d99009000'),
prev={'sfi_map': {30: '6fe3'}})
self.assertEqual(r['file']['ef'], 'EF_EPSLOCI')
self.assertEqual(r['file']['guti'], '0BF652F0998001B0FB911926')
def test_sfi_unknown(self):
r = decode_message(bytes.fromhex('00d6880001079000'), prev={'sfi_map': {}})
self.assertTrue(r['file']['unknown'])
self.assertEqual(r['file']['sfi'], 8)
def test_sfi_read_resolves(self):
# READ BINARY via SFI needs no prev['sel']; only the SFI map.
r = decode_message(bytes.fromhex('00b08b000b') +
bytes.fromhex('1e731e5752f09969c50000') + bytes.fromhex('9000'),
prev={'sfi_map': {11: '6f7e'}})
self.assertEqual(r['file']['ef'], 'EF_LOCI')
self.assertEqual(r['file']['tmsi'], '1E731E57')
def test_selected_df_state(self):
from simtrace_analyser.decode import selected_df_state
def sel(p1, body):
return {'ins_hex': 'a4', 'p1': {'raw': p1}, 'body': {'hex': body}}
# A path selection spells out the DF chain: 7F10/5F3A/4FA1.
self.assertEqual(selected_df_state(sel('08', '7f105f3a4fa1')),
('5f3a', ['7f10', '5f3a']))
# A child DF selected by FID appends to the current chain.
self.assertEqual(selected_df_state(sel('00', '5f3a'), '7f10', ['7f10']),
('5f3a', ['7f10', '5f3a']))
# MF and the ADF start a fresh chain; an AID is the root.
self.assertEqual(selected_df_state(sel('00', '3f00'), '7f10',
['7f10', '5f3a']),
('3f00', ['3f00']))
self.assertEqual(selected_df_state(sel('08', '7fff6f07'), '3f00',
['3f00']), ('7fff', ['7fff']))
aid = 'aid:a00000015141434c00'
self.assertEqual(selected_df_state(sel('04', 'A00000015141434C00'),
'3f00', ['3f00']), (aid, [aid]))
# An EF selection leaves the chain in place; non-SELECTs never move it.
self.assertEqual(selected_df_state(sel('09', '4fa1'), '5f3a',
['7f10', '5f3a']),
('5f3a', ['7f10', '5f3a']))
self.assertEqual(selected_df_state({'ins_hex': 'b0'}), (None, None))
def test_selected_df_fid(self):
from simtrace_analyser.decode import selected_df_fid, sfi_table
self.assertEqual(selected_df_fid({'ins_hex': 'a4', 'p1': {'raw': '08'},
'body': {'hex': '3f007fff'}}), '7fff')
self.assertEqual(selected_df_fid({'ins_hex': 'a4', 'p1': {'raw': '00'},
'body': {'hex': '7f20'}}), '7f20')
self.assertIsNone(selected_df_fid({'ins_hex': 'a4', 'p1': {'raw': '00'},
'body': {'hex': '6f7e'}}))
self.assertIsNone(selected_df_fid({'ins_hex': 'b0'}))
# SELECT by AID: the USIM maps to its virtual FID, any other
# application keeps its AID as the DF identity.
self.assertEqual(selected_df_fid({'ins_hex': 'a4', 'p1': {'raw': '04'},
'body': {'hex': 'a0000000871002ff'}}), '7fff')
self.assertEqual(selected_df_fid({'ins_hex': 'a4', 'p1': {'raw': '04'},
'body': {'hex': 'A00000015141434C00'}}),
'aid:a00000015141434c00')
self.assertIsNone(selected_df_fid({'ins_hex': 'a4', 'p1': {'raw': '04'},
'body': {'hex': ''}}))
# An unknown AID has no SFI table; the ISIM AID is keyed by its AID
# (TS 31.103 has no SFI annex, so its table comes from the file rows).
self.assertEqual(sfi_table('aid:a00000015141434c00'), {})
self.assertEqual(sfi_table('aid:a0000000871004')[0x07], '6f07') # EF_IST
self.assertEqual(sfi_table('7fff')[0x1e], '6fe3')
self.assertEqual(sfi_table('3f00')[0x1e], '2f00')
self.assertEqual(sfi_table('7fff')[0x17], '6f06')
self.assertEqual(sfi_table('7fff')[0x1a], '6fc6')
self.assertEqual(sfi_table('5f40')[0x07], '4f47') # EF_HWSIDL (H.3)
self.assertEqual(sfi_table('5fc0')[0x10], '4f10') # EF_5GSEDRX (H.9)
self.assertEqual(sfi_table('6f7e'), {})
# TS 102 221 §8.4.1: selecting an EF leaves its parent DF current.
# 'path from MF', single component → directly under MF (Table 8.2).
self.assertEqual(selected_df_fid({'ins_hex': 'a4', 'p1': {'raw': '08'},
'body': {'hex': '2fe2'}}, '7fff'), '3f00')
# 'path from MF' via the ADF: the EF's parent is 7FFF.
self.assertEqual(selected_df_fid({'ins_hex': 'a4', 'p1': {'raw': '08'},
'body': {'hex': '7fff6f07'}}, '3f00'), '7fff')
# 'path from current DF': a single EF stays in the current DF.
self.assertEqual(selected_df_fid({'ins_hex': 'a4', 'p1': {'raw': '09'},
'body': {'hex': '6f7e'}}, '7fff'), '7fff')
# …and an unknown application keeps its AID identity for '7fff'.
self.assertEqual(selected_df_fid({'ins_hex': 'a4', 'p1': {'raw': '08'},
'body': {'hex': '7fff4f09'}},
'aid:a00000015141434c00'),
'aid:a00000015141434c00')
def test_record_p2_sfi(self):
# READ RECORD P2 = 0xBC → SFI = 0xBC>>3 = 23, mode = 0x04.
r = decode_message(bytes.fromhex('00b204bc00'), prev={})
self.assertEqual(r['p2']['sfi'], 23)
self.assertIn('absolute mode', r['p2']['bits'][0])
def test_update_record_sfi_resolves(self):
# UPDATE RECORD P2 = 0xC4 → SFI 24 = EF_EPSNSC (record 54 B).
raw = bytes.fromhex('00dc01c436') + bytes.fromhex(
'a0348001078120ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'
'8204ffffffff8304ffffffff840100') + bytes.fromhex('9000')
r = decode_message(raw, prev={'sfi_map': {24: '6fe4'}})
self.assertEqual(r['file']['ef'], 'EF_EPSNSC')
self.assertEqual(r['file']['ksi_asme'], '07')
class TestP1P2(unittest.TestCase):
def test_search_record_p2_modes(self):
r = decode_message(bytes.fromhex('00a2010508ffffffffffffffff9000'))
self.assertEqual(r['p2']['bits'], ['Simple search (backward)'])
r = decode_message(bytes.fromhex('00a2010708ffffffffffffffff9000'))
self.assertEqual(r['p2']['bits'], ['Proprietary search'])
def test_pin_p2_reference(self):
r = decode_message(bytes.fromhex('0020000108' + '12' * 8))
self.assertEqual(r['p1']['name'], 'No indication')
self.assertEqual(r['p2']['name'], 'PIN Appl 1')
r = decode_message(bytes.fromhex('0020008108' + '12' * 8))
self.assertEqual(r['p2']['name'], 'Second PIN Appl 1')
def test_manage_channel_p2(self):
r = decode_message(bytes.fromhex('0070000300'))
self.assertEqual(r['p1']['name'], 'Open channel')
self.assertEqual(r['p2']['name'], 'Channel 3')
def test_retrieve_data_p2(self):
r = decode_message(bytes.fromhex('80cb008004' + '4f' * 4))
self.assertEqual(r['p2']['bits'], ['First block'])
def test_get_data_tag(self):
r = decode_message(bytes.fromhex('80ca005000'))
self.assertEqual(r['p1p2'], {'label': 'Tag', 'value': 0x50})
def test_commands_always_have_p1p2(self):
# FETCH and GET RESPONSE have unused P1/P2 (always '00') — RFU.
r = decode_message(bytes.fromhex('801200000fd00d8103010300820281820402011e9000'))
self.assertEqual(r['p1p2'], {'unused': True, 'value': 0})
r = decode_message(bytes.fromhex('00c0000000'), prev={'ins': 0xA4, 'ins_name': 'SELECT', 'sw1': '61'})
self.assertEqual(r['p1p2'], {'unused': True, 'value': 0})
def test_increase_p1p2_unused(self):
r = decode_message(bytes.fromhex('80320000030100000000'))
self.assertEqual(r['p1p2'], {'unused': True, 'value': 0})
def test_deactivate_activate_file_p1(self):
r = decode_message(bytes.fromhex('00040000126f0a'))
self.assertEqual(r['p1']['name'], 'EF by file ID')
self.assertEqual(r['p2']['name'], 'No indication')
r = decode_message(bytes.fromhex('00040800123f002f00'))
self.assertEqual(r['p1']['name'], 'Path from MF')
r = decode_message(bytes.fromhex('00040900122f00'))
self.assertEqual(r['p1']['name'], 'Path from current DF')
class TestSwWrongLength(unittest.TestCase):
def test_6c(self):
from simtrace_analyser.decode import decode_sw
self.assertEqual(decode_sw(bytes.fromhex('6c38'))['name'],
'Wrong length (Le): correct length is 0x38 (56 bytes)')
def test_62_unknown_is_unnamed(self):
from simtrace_analyser.decode import decode_sw
self.assertIsNone(decode_sw(bytes.fromhex('6205'))['name'])
def test_62_86_sensor_warning(self):
from simtrace_analyser.decode import decode_sw
self.assertEqual(decode_sw(bytes.fromhex('6286'))['name'],
'No input data available from a sensor on the card')
def test_63_81_file_filled_warning(self):
from simtrace_analyser.decode import decode_sw
self.assertEqual(decode_sw(bytes.fromhex('6381'))['name'],
'File filled up by the last write')
def test_66_class_not_fabricated(self):
# No '66xx' row exists in ISO 7816-4:2005 Table 6 or TS 102 221
# tables 10.9/10.10 — the decoder must not invent a name.
from simtrace_analyser.decode import decode_sw
self.assertIsNone(decode_sw(bytes.fromhex('6600'))['name'])
self.assertIsNone(decode_sw(bytes.fromhex('6681'))['name'])
class TestStatusFcp(unittest.TestCase):
def test_status_body_is_fcp(self):
raw = bytes.fromhex(
'80f2000038'
'62368202782183027ff08410a0000000871002ff45ff0189020111008a01058b032f0608c60c90016083010183018183010a81040000039f'
'9000')
r = decode_message(raw)
self.assertEqual(r['ins_name'], 'STATUS')
self.assertEqual(r['sw']['sw1'], '90')
resp = r['response']
self.assertEqual(resp['template'], 'FCP')
self.assertEqual(resp['file_id'], '7FF0')
self.assertEqual(resp['df_name'], 'A0000000871002FF45FF018902011100')
self.assertEqual(resp['file_descriptor']['file_type'], 'DF or ADF')
self.assertEqual(resp['life_cycle'], 'operational state (activated)')
self.assertEqual(r['summary'], 'Data: 56 bytes \u2014 FCP of 7FF0 (DF or ADF)')
def test_status_no_data_has_no_summary(self):
# 80 F2 00 00 00 6C 25 — P3=0, card answers "wrong length"; no data.
r = decode_message(bytes.fromhex('80f20000006c25'))
self.assertEqual(r['ins_name'], 'STATUS')
self.assertNotIn('body', r)
self.assertNotIn('response', r)
self.assertIsNone(r['summary'])
def test_status_mf_summary(self):
raw = bytes.fromhex(
'80f200000d'
'620b8202782183023f008a0105'
'9000')
r = decode_message(raw)
self.assertEqual(r['response']['file_id'], '3F00')
self.assertEqual(r['response']['file_id_name'], 'MF')
self.assertEqual(r['summary'], 'Data: 13 bytes \u2014 FCP of MF (DF or ADF)')
def test_status_ef_summary(self):
raw = bytes.fromhex(
'80f2000024'
'62228202412183026f73a509c0018091047f206f538a01058b036f06098002000e880160'
'9000')
r = decode_message(raw)
self.assertEqual(r['response']['file_id_name'], 'EF_PSLOCI')
self.assertEqual(
r['summary'],
'Data: 36 bytes \u2014 FCP of EF_PSLOCI (Working EF, transparent, 14 B)')
def test_status_single_byte_pluralization(self):
r = decode_message(bytes.fromhex('80f2000001009000'))
self.assertEqual(r['summary'], 'Data: 1 byte')
class TestLengthMismatch(unittest.TestCase):
def test_lc_truncated(self):
# ENVELOPE with Lc=21, body missing (only SW captured).
r = decode_message(bytes.fromhex('80c20000159000'))
self.assertEqual(r['sw']['sw1'], '90')
self.assertEqual(r['sw']['sw2'], '00')
self.assertNotIn('body', r)
self.assertEqual(r['length_mismatch'],
{'kind': 'truncated', 'expected': 21, 'actual': 0})
def test_lc_excessive(self):
# ENVELOPE with Lc=3 but 4 bytes of body + SW.
r = decode_message(bytes.fromhex('80c2000003d10102039000'))
self.assertEqual(r['length_mismatch'],
{'kind': 'excessive', 'expected': 3, 'actual': 4})
def test_select_well_formed(self):
r = decode_message(bytes.fromhex('a0a40000023f009000'))
self.assertNotIn('length_mismatch', r)
def test_le_short_response_not_flagged(self):
# READ BINARY (Le) returning an error SW with no data is not "truncated".
r = decode_message(bytes.fromhex('00b08f00066a82'))
self.assertEqual(r['sw']['sw1'], '6a')
self.assertNotIn('length_mismatch', r)
class TestRefresh(unittest.TestCase):
def test_uicc_reset(self):
r = decode_message(bytes.fromhex('801200000bd0098103010104820281829000'))
self.assertEqual(r['cat_command'], 'REFRESH')
self.assertEqual(r['cmd']['qualifier'], '0x04 UICC Reset')
self.assertNotIn('file_list', r['cmd'])
self.assertNotIn('aid', r['cmd'])
def test_file_change_notification(self):
r = decode_message(bytes.fromhex('8012000011d00f81030101018202818292046f076f209000'))
self.assertEqual(r['cmd']['qualifier'], '0x01 File Change Notification')
self.assertEqual(r['cmd']['file_list'], ['6F07', '6F20'])
def test_steering_of_roaming(self):
# ETSI "Reserved for GSM/3G" — TS 31.111 §8.6 defines the mode.
r = decode_message(bytes.fromhex('801200000bd0098103010107820281829000'))
self.assertEqual(r['cmd']['qualifier'], '0x07 Steering of Roaming')
def test_steering_of_roaming_iwlan(self):
r = decode_message(bytes.fromhex('801200000bd0098103010108820281829000'))
self.assertEqual(r['cmd']['qualifier'],
'0x08 Steering of Roaming for I-WLAN')
class TestItemsNextActionIndicator(unittest.TestCase):
"""Items Next Action Indicator (TS 102 223 §8.24, tag '18') — real
SET UP MENU samples from the local trace sessions (47/53/57)."""
def test_second_item_display_text(self):
# session 53: 2 items, item 2 → DISPLAY TEXT ('21'); '00' = no action.
r = decode_message(bytes.fromhex(
'8012000040D03E810301250082028182850B416C6661204D6F62696C'
'658F16808112089DB0C1C2C0BEB9BAB82F53657474696E67738F0A81'
'416C66612D494D4549180200219000'))
items = r['cmd']['items']
self.assertNotIn('next_action', items[0])
self.assertEqual(items[1]['next_action'], 'DISPLAY TEXT')
# Detail-only: the APDU-list summary stays unchanged.
self.assertEqual(r['summary'], 'Alfa Mobile, 2 items')
def test_second_item_select_item(self):
# session 57: 2 items, item 2 → SELECT ITEM ('24').
r = decode_message(bytes.fromhex(
'8012000044D042810301250082028182050B416C6661204D6F62696C'
'658F16808112089DB0C1C2C0BEB9BAB82F53657474696E67738F0E81'
'416C66612053494D2074657374180200249000'))
self.assertEqual(r['cmd']['items'][1]['next_action'], 'SELECT ITEM')
def test_last_item_of_nine(self):
# session 47: 9 items, only the last has SELECT ITEM ('24').
r = decode_message(bytes.fromhex(
'80120000E6D081E3810301250082028182051580004D0065006700610046006F006E00500052004F8F100180041C0435043304300424043E043D8F1802800420043004370432043B043504470435043D0438044F8F0C03800421043F043E044004428F100480041D043E0432043E0441044204388F10058004240438043D0430043D0441044B8F1006800421043F044004300432043A04308F100780041E043104490435043D043804358F188080041A0430043B043504390434043E0441043A043E043F8F1A8180041C043E04310438043B044C043D044B043900200049004418090000000000000000249000'))
items = r['cmd']['items']
self.assertEqual(len(items), 9)
self.assertNotIn('next_action', items[0])
self.assertEqual(items[-1]['next_action'], 'SELECT ITEM')
def test_length_mismatch_kept_raw(self):
# §8.24 requires X = number of items; a mismatched list stays raw.
r = decode_message(bytes.fromhex(
'8012000014D0128103012500820281828F03014142180224249000'))
items = r['cmd']['items']
self.assertEqual(len(items), 1)
self.assertNotIn('next_action', items[0])
self.assertEqual(r['cmd']['raw_tlv'], [{'tag': '18', 'value': '2424'}])
class TestNoReservedLabels(unittest.TestCase):
# ETSI 'Reserved for GSM/3GPP' values must be resolved via the sibling
# spec or left unnamed — never surfaced as a 'Reserved ...' label.
def test_no_reserved_labels(self):
from simtrace_analyser import decode as d
for name in ('SW_NAMES', 'REFRESH_MODES', 'ENVELOPE_TYPES',
'TR_RESULTS', 'PLI_QUALIFIERS', 'CAT_COMMAND_TYPES',
'EVENT_TYPES'):
table = getattr(d, name)
for value in table.values():
if isinstance(value, str):
self.assertFalse(value.startswith('Reserved'),
f'{name}: {value}')
class TestIdleModeText(unittest.TestCase):
"""SET UP IDLE MODE TEXT (TS 102 223 §6.6.13) proactive command."""
def _fetch(self, inner_hex):
# FETCH response body: D0 BER-TLV wrapping the proactive command
inner = bytes.fromhex(inner_hex)
body = b'\xd0' + bytes([len(inner)]) + inner
return decode_message(
bytes.fromhex('80120000%02x' % len(body)) + body + b'\x90\x00')
def test_8bit_text_icon_qualifier(self):
# "Hello" 8-bit + icon record 5 + qualifier 0x01 (RFU byte — no
# icon bit exists per TS 102 223 V18.3.0 §8.6)
r = self._fetch(
'810301280182028182' # cmd details (qual 0x01) + device ids
'8d060448656c6c6f' # Text String: DCS 0x04 (8-bit), "Hello"
'1e0105') # Icon Identifier: record 5
self.assertEqual(r['cat_command'], 'SET UP IDLE MODE TEXT')
self.assertEqual(r['cmd']['text'], 'Hello')
# Qualifier byte is RFU per V18.3.0 §8.6 — non-zero shows raw
self.assertEqual(r['cmd']['qualifier'], '0x01')
self.assertEqual(r['cmd']['icon_id'], 5)
def test_ucs2_text(self):
# "Привет" UCS-2 (DCS 0x08)
r = self._fetch(
'810301280082028182'
'8d0d08041f04400438043204350442')
self.assertEqual(r['cat_command'], 'SET UP IDLE MODE TEXT')
self.assertEqual(r['cmd']['text'], 'Привет')
def test_qualifier_zero_hidden(self):
r = self._fetch('8103012800820281828d060448656c6c6f')
self.assertNotIn('qualifier', r['cmd'])
def test_qualifier_rfu_bits_raw(self):
r = self._fetch('8103012802820281828d060448656c6c6f')
self.assertEqual(r['cmd']['qualifier'], '0x02')
def test_empty_text_removes_idle_text(self):
# Zero-length Text String instructs the ME to remove the idle text
r = self._fetch('8103012800820281828d00')
self.assertEqual(r['cmd']['text'], '')
def test_no_icon_tlv(self):
r = self._fetch('8103012800820281828d060448656c6c6f')
self.assertNotIn('icon_id', r['cmd'])
class TestSatLegacyQuirks(unittest.TestCase):
"""Real-card SAT quirks and unknown-TLV transparency.
Tag assignments verified against GSM 11.14 v5.1.0/v5.9.0 §12.3 and
TS 102 223 V18.3.0: Text String = '0D'/'8D' in all generations;
'10'/'90' = Item identifier.
"""
def _fetch(self, inner_hex):
inner = bytes.fromhex(inner_hex)
body = b'\xd0' + bytes([len(inner)]) + inner
return decode_message(
bytes.fromhex('80120000%02x' % len(body)) + body + b'\x90\x00')
def test_idle_mode_text_nonstandard_tag(self):
# Real-card capture: SET UP IDLE MODE TEXT whose Text String ("037",
# 8-bit data) is tagged '10' instead of '8D'.
r = decode_message(bytes.fromhex(
'8012000011d00f8103012800820281821004043033379000'))
self.assertEqual(r['cat_command'], 'SET UP IDLE MODE TEXT')
self.assertEqual(r['cmd']['text'], '037')
self.assertIn('non-standard', r['cmd']['text_note'])
self.assertNotIn('raw_tlv', r['cmd'])
def test_item_identifier_single_byte(self):
# Genuine 1-byte Item identifier (SELECT ITEM flow) must not be
# mistaken for the quirk text fallback.
r = self._fetch('810301240082028182900102')
self.assertEqual(r['cat_command'], 'SELECT ITEM')
self.assertEqual(r['cmd']['item_id'], 2)
self.assertNotIn('text', r['cmd'])
def test_unknown_tlv_preserved(self):
r = self._fetch('8103012800820281827102aabb')
self.assertEqual(r['cmd']['raw_tlv'],
[{'tag': '71', 'value': 'AABB'}])
def test_device_ids_not_in_raw_tlv(self):
# Structural Device Identities TLV must not surface as unknown.
r = self._fetch('810301280082028182')
self.assertNotIn('raw_tlv', r['cmd'])
def test_compliant_text_still_wins_over_quirk(self):
# If both a compliant 8D text and a '10'-tagged object exist,
# the compliant one is used and the other lands in raw_tlv.
r = self._fetch('8103012100820281828d0204411003010102')
self.assertEqual(r['cmd']['text'], 'A')
self.assertIn({'tag': '10', 'value': '010102'}, r['cmd']['raw_tlv'])
def test_compliant_text_clears_quirk_note(self):
# Quirk '10' text first, then a compliant 8D Text String:
# the compliant value wins and the stale note must disappear.
r = self._fetch('810301280082028182'
'1003043337' # non-standard '10': DCS+"37"
'8d060448656c6c6f') # compliant "Hello"
self.assertEqual(r['cmd']['text'], 'Hello')
self.assertNotIn('text_note', r['cmd'])
class TestSpecRegistry(unittest.TestCase):
"""Registries verified against TS 102 223 V18.3.0 / TS 31.111 V18.12.0."""
def _fetch(self, inner_hex):
inner = bytes.fromhex(inner_hex)
body = b'\xd0' + bytes([len(inner)]) + inner
return decode_message(
bytes.fromhex('80120000%02x' % len(body)) + body + b'\x90\x00')
def test_event_names_multi_rat_and_profile_container(self):
# SET UP EVENT LIST with events '14', '19', '1E'
from simtrace_analyser.decode import EVENT_TYPES
self.assertEqual(EVENT_TYPES[0x14],
'Access Technology Change (multiple)')
self.assertEqual(EVENT_TYPES[0x19], 'Profile Container')
self.assertEqual(EVENT_TYPES[0x1E], 'CAG cell selection')
self.assertEqual(EVENT_TYPES[0x1F], 'Slices Status Change')
def test_event_list_fetch_decodes_new_events(self):
# SET UP EVENT LIST with events '14', '19', '1E'
r = self._fetch('810301050002028281190314191e')
self.assertEqual(r['cat_command'], 'SET UP EVENT LIST')
self.assertEqual(r['cmd']['events'],
['Access Technology Change (multiple)',
'Profile Container', 'CAG cell selection'])
def test_tr_result_me_unable_vs_network(self):
from simtrace_analyser.decode import TR_RESULTS
self.assertIn('ME currently unable', TR_RESULTS[0x20])
self.assertIn('Network currently unable', TR_RESULTS[0x21])
def test_tr_result_user_session_codes(self):
from simtrace_analyser.decode import TR_RESULTS
self.assertEqual(TR_RESULTS[0x14],
'USSD or SS transaction terminated by the user')
self.assertNotIn(0x0A, TR_RESULTS)
self.assertNotIn(0x0E, TR_RESULTS)
def test_tr_result_permanent_problems(self):
from simtrace_analyser.decode import TR_RESULTS
self.assertEqual(TR_RESULTS[0x3A],
'Bearer Independent Protocol error')
self.assertEqual(TR_RESULTS[0x3B],
'Access Technology unable to process command')
self.assertEqual(TR_RESULTS[0x3C], 'Frames error')
self.assertEqual(TR_RESULTS[0x3D], 'MMS error')
def test_tr_result_bip_temporary(self):
# '28' per TS 102 223 §8.12 (timer in Additional information).
from simtrace_analyser.decode import TR_RESULTS
self.assertEqual(TR_RESULTS[0x28],
'Bearer Independent Protocol temporary error')
self.assertNotIn(0x29, TR_RESULTS)
def test_envelope_types_extended(self):
from simtrace_analyser.decode import ENVELOPE_TYPES
self.assertEqual(ENVELOPE_TYPES[0xD9], 'USSD DOWNLOAD')
self.assertEqual(ENVELOPE_TYPES[0xDD],
'GEOGRAPHICAL LOCATION REPORTING')
self.assertEqual(ENVELOPE_TYPES[0xDE], 'ENVELOPE CONTAINER')
self.assertEqual(ENVELOPE_TYPES[0xE0], '5G PROSE REPORT')
def test_lsi_command_type(self):
from simtrace_analyser.decode import CAT_COMMAND_TYPES
self.assertEqual(CAT_COMMAND_TYPES[0x79], 'LSI COMMAND')
def test_proprietary_type_fallback(self):
r = self._fetch('810301f20082028182')
self.assertEqual(r['cat_command'], 'Proprietary (0xF2)')
self.assertEqual(r['cmd']['type'], 'Proprietary (0xF2)')
def test_pli_qualifiers_esn_and_supported_rat(self):
from simtrace_analyser.decode import PLI_QUALIFIERS
self.assertEqual(PLI_QUALIFIERS[0x07], 'ESN of the terminal')
self.assertEqual(PLI_QUALIFIERS[0x1A],
'Supported Radio Access Technologies')
class TestEnvelopeDecoders(unittest.TestCase):
"""Phase 2: inner TLV decoding for the remaining ENVELOPE types."""
def _envelope(self, hexstr):
apdu = '80C20000%02x' % (len(hexstr) // 2) + hexstr + '9000'
return decode_message(bytes.fromhex(apdu))
def test_menu_selection_item_and_help(self):
# D3 | device ids (UICC→Terminal) | item identifier 02 | help request
r = self._envelope('D309820281821001029500')
cmd = r['cmd']
self.assertEqual(cmd['type'], 'MENU SELECTION')
self.assertEqual(cmd['item_id'], 2)
self.assertTrue(cmd['help'])
self.assertEqual(cmd['device_ids'], {'src': 'UICC', 'dst': 'Terminal'})
def test_timer_expiration(self):
# D7 | device ids | timer id 01 | timer value semi-octet h/m/s
r = self._envelope('D70C820282812401012503102030')
cmd = r['cmd']
self.assertEqual(cmd['type'], 'TIMER EXPIRATION')
self.assertEqual(cmd['timer_id'], 1)
self.assertEqual(cmd['timer_value'], '01:02:03')
def test_ussd_download(self):
# D9 | device ids | USSD string, DCS 0x04 (8-bit) "*100#"
r = self._envelope('D90C820283810A06042A31303023')
cmd = r['cmd']
self.assertEqual(cmd['type'], 'USSD DOWNLOAD')
self.assertEqual(cmd['ussd'], '*100#')
self.assertEqual(cmd['device_ids'], {'src': 'Network', 'dst': 'UICC'})
def test_call_control_address_and_ccp(self):
# D4 | device ids | address (intl '+79') | CCP 0xAA
r = self._envelope('D40C8202838106039179F10701AA')
cmd = r['cmd']
self.assertEqual(cmd['type'], 'CALL CONTROL')
self.assertIn('+', cmd['address'])
self.assertEqual(cmd['ccp'], 'AA')
def test_envelope_container_recurses(self):
# DE wrapping a complete MENU SELECTION envelope
inner = bytes.fromhex('D309820281821001029500')
body = b'\xDE' + bytes([len(inner)]) + inner
lc = len(body)
r = decode_message(
bytes.fromhex('80C20000%02x' % lc) + body + b'\x90\x00')
cmd = r['cmd']
self.assertEqual(cmd['type'], 'ENVELOPE CONTAINER')
self.assertEqual(cmd['encapsulated']['type'], 'MENU SELECTION')
self.assertEqual(cmd['encapsulated']['item_id'], 2)
def test_prose_report_keeps_raw_objects(self):
# E0 | device ids | unknown data object → preserved in raw_tlv
r = self._envelope('E008820282819902AABB')
cmd = r['cmd']
self.assertEqual(cmd['type'], '5G PROSE REPORT')
self.assertEqual(cmd['raw_tlv'], [{'tag': '19', 'value': 'AABB'}])
def test_proactive_device_ids_decoded(self):
r = decode_message(bytes.fromhex(
'801200000dd00b8103012800820283818d00'))
self.assertEqual(r['cmd']['device_ids'],
{'src': 'Network', 'dst': 'UICC'})
def test_timer_value_semi_octet_order(self):
# TS 23.040 semi-octets: first digit in the LOW nibble.
# 90 52 30 → "09:25:03" (pre-fix code read high-nibble first).
from simtrace_analyser.decode import _decode_timer_value
self.assertEqual(_decode_timer_value(bytes.fromhex('905230')),
'09:25:03')
# 01 02 03 means "10:20:30", not "01:02:03".
self.assertEqual(_decode_timer_value(bytes.fromhex('010203')),
'10:20:30')
class TestQualifiers(unittest.TestCase):
"""Phase 3: §8.6 qualifier decoding for the remaining commands."""
def _fetch_qual(self, cmd_type, qual):
inner_hex = '810301%02x%02x820281828d00' % (cmd_type, qual)
inner = bytes.fromhex(inner_hex)
body = b'\xd0' + bytes([len(inner)]) + inner
r = decode_message(
bytes.fromhex('80120000%02x' % len(body)) + body + b'\x90\x00')
return r['cmd'].get('qualifier')
def test_set_up_call_value_table(self):
self.assertEqual(self._fetch_qual(0x10, 0x04),
'0x04 set up call, disconnecting all other calls (if any)')
def test_display_text_bits(self):
self.assertEqual(self._fetch_qual(0x21, 0x81),
'high priority, wait for user to clear message')
self.assertIsNone(self._fetch_qual(0x21, 0x00))
def test_get_inkey_bits(self):
self.assertEqual(self._fetch_qual(0x22, 0x0B),
'alphabet set requested, UCS2 alphabet requested, '
'immediate digit response requested')
def test_get_input_bits(self):
self.assertEqual(self._fetch_qual(0x23, 0x04),
'input shall not be revealed')
def test_select_item_presentation_and_help(self):
self.assertEqual(self._fetch_qual(0x24, 0x05),
'data values presentation, '
'selection using soft key preferred')
def test_timer_management_ops(self):
# TS 102 223 §8.6: bits 1-2 — '00' start, '01' deactivate,
# '10' get current value, '11' RFU.
self.assertIsNone(self._fetch_qual(0x27, 0x00)) # start = default
self.assertEqual(self._fetch_qual(0x27, 0x01), 'deactivate timer')
self.assertEqual(self._fetch_qual(0x27, 0x02),
'get current timer value')
self.assertEqual(self._fetch_qual(0x27, 0x04), '0x04') # b1-b2=00
def test_send_sm_packing_bit(self):
self.assertEqual(self._fetch_qual(0x13, 0x01),
'SMS packing by terminal required')
def test_open_channel_bearer_dependent(self):
self.assertIn('immediate link establishment',
self._fetch_qual(0x40, 0x01))
self.assertIn('bearer-dependent', self._fetch_qual(0x40, 0x01))
def test_launch_browser_value_table(self):
self.assertEqual(self._fetch_qual(0x15, 0x03),
"0x03 close browser session and launch new")
def test_lsi_command_values(self):
self.assertEqual(self._fetch_qual(0x79, 0x01), '0x01 UICC Platform Reset')
def test_rfu_byte_commands_stay_raw(self):
self.assertEqual(self._fetch_qual(0x02, 0xFF), '0xFF') # MORE TIME
self.assertIsNone(self._fetch_qual(0x02, 0x00))
def test_get_inkey_yes_no_suppresses_alphabet_bits(self):
# b3 (Yes/No) disables the b1/b2 character-set coding per §8.6
self.assertEqual(self._fetch_qual(0x22, 0x04),
'Yes/No response requested')
class TestFileDescriptor(unittest.TestCase):
"""Audit: TS 102 221 Table 11.5 file descriptor byte coding."""
def _fdb(self, hexstr):
from simtrace_analyser.decode import _decode_file_descriptor
return _decode_file_descriptor(bytes.fromhex(hexstr))
def test_df_shareable(self):
r = self._fdb('78')
self.assertEqual(r['file_type'], 'DF or ADF')
self.assertEqual(r['shareable'], 'shareable')
def test_transparent_working_ef(self):
r = self._fdb('01')
self.assertEqual(r['file_type'], 'Working EF')
self.assertEqual(r['structure'], 'transparent')
def test_cyclic_is_100_not_110(self):
# TS 221 T11.5 overrides ISO: 100 = cyclic, 110 = BER-TLV
self.assertEqual(self._fdb('04')['structure'], 'cyclic')
self.assertEqual(self._fdb('06')['structure'], 'BER-TLV')
def test_type_bits_b6_b4(self):
# 0x09 = 0b00001001: type bits b6b5b4=001 (Internal), structure 001
r = self._fdb('09')
self.assertEqual(r['file_type'], 'Internal EF')
self.assertEqual(r['structure'], 'transparent')
def test_record_params(self):
r = self._fdb('42020030') # shareable linear, data coding 0x02, rec len 48
self.assertEqual(r['file_type'], 'Working EF')
self.assertEqual(r['structure'], 'linear fixed')
self.assertEqual(r['data_coding'], '0x02')
self.assertEqual(r['record_length'], 48)
class TestFcpTags(unittest.TestCase):
"""Audit: FCP tag registry per ISO T12 / TS 221 §11.1.1.4."""
def test_ab_is_security_attributes(self):
from simtrace_analyser.decode import _decode_fcp
r = _decode_fcp(bytes.fromhex('6204ab02aabb'))
# outer 6F wrapper → recurse; AB = security attributes template
self.assertIn('security_attr_template', r)
self.assertNotIn('short_ef_id', r)
def test_sfi_tag_88_high_bits(self):
from simtrace_analyser.decode import _decode_fcp
# SFI 7 in bits b8-b4 → value byte 0x38
r = _decode_fcp(bytes.fromhex('880138'))
self.assertEqual(r['sfi'], 7)
def test_sfi_fallback_fid_low_bits(self):
from simtrace_analyser.decode import _decode_fcp
r = _decode_fcp(bytes.fromhex('83026f7e'))
self.assertEqual(r['sfi'], 0x1e) # 6F7E & 0x1F = 30
class TestNewFileDecoders(unittest.TestCase):
"""Audit: file decoders added per UICC_FILES.md."""
def test_ecc_bcd_and_esc(self):
from simtrace_analyser.decode import _decode_ecc
r = _decode_ecc(bytes.fromhex('11f200'), p1=None)
self.assertEqual(r['number'], '112')
self.assertEqual(r['esc'], '0x00')
def test_kc_ksi(self):
from simtrace_analyser.decode import _decode_kc
r = _decode_kc(bytes.fromhex('0011223344556677' + '07'), p1=None)
self.assertEqual(r['kc'], '0011223344556677')
self.assertEqual(r['ksi'], 0)
def test_opl_record(self):
from simtrace_analyser.decode import _decode_opl
# PLMN 250-01 (52 f0 f1), LAC 0000..FFFE, PNN rec 1
r = _decode_opl(bytes.fromhex('52f0100000fffe01'), p1=None)
self.assertEqual(r['mcc'], '250')
self.assertEqual(r['mnc'], '01')
self.assertEqual(r['lac_range'], ['0000', 'FFFE'])
self.assertEqual(r['pnn_record'], 1)
def test_ext1(self):
from simtrace_analyser.decode import _decode_ext1
raw = bytes.fromhex('03' + '21f39210000000000000' + 'ff' + 'ff')
r = _decode_ext1(raw, p1=None)
self.assertTrue(r['number'].startswith('123'))
self.assertIsNone(r['ccp'])
self.assertIsNone(r['ext'])
def test_ad(self):
from simtrace_analyser.decode import _decode_ad
r = _decode_ad(bytes.fromhex('00000003'), p1=None)
self.assertEqual(r['op_mode'], 'normal operation')
self.assertEqual(r['mnc_length'], 3)
def test_acl(self):
from simtrace_analyser.decode import _decode_acl
r = _decode_acl(bytes.fromhex('01dd08696e7465726e6574'), p1=None)
self.assertEqual(r['apns'], ['internet'])
def test_spdi(self):
from simtrace_analyser.decode import _decode_spdi
# Standard form: A3 wrapping inner '80' TLVs, one PLMN each
r = _decode_spdi(bytes.fromhex('a30a8003' '52f010' '8003' '72f010'), p1=None)
self.assertEqual(r['plmns'], [{'mcc': '250', 'mnc': '01'},
{'mcc': '270', 'mnc': '01'}])
def test_spdi_bare_records_fallback(self):
from simtrace_analyser.decode import _decode_spdi
r = _decode_spdi(bytes.fromhex('a306' '52f010' '00f110'), p1=None)
self.assertEqual(r['plmns'], [{'mcc': '250', 'mnc': '01'},
{'mcc': '001', 'mnc': '01'}])
def test_spdi_read_binary_regression(self):
# stk_test1 session msg 14036: READ BINARY of EF_SPDI crashed
# _build_summary (plmns were strings, not dicts) → whole message
# decoded as None ("No decoded data available").
prev = {'sel': {'fid': '6fcd', 'name': 'EF_SPDI',
'structure': 'transparent'},
'ins': 0xA4, 'ins_name': 'SELECT', 'sw1': '61'}
r = decode_message(bytes.fromhex(
'00b000000da305800352f020ffffffffffff9000'), prev=prev)
self.assertEqual(r['file']['plmns'],
[{'mcc': '250', 'mnc': '02'}])
self.assertIn('250/02', r['summary'])
def test_smsp(self):
from simtrace_analyser.decode import _decode_smsp
raw = bytes.fromhex('00' + '00' + 'ff' * 24 + '000000')
r = _decode_smsp(raw, p1=None)
self.assertEqual(r['param_indicators'], '0x00')
def test_cbmir_multiple_ranges(self):
from simtrace_analyser.decode import _decode_cbmir
r = _decode_cbmir(bytes.fromhex('00 01 00 02' .replace(' ', '') + 'fffe0000'), p1=None)
self.assertEqual(r['ranges'], [[1, 2], [65534, 0]])
def test_missing_apdu_spec_names(self):
from simtrace_analyser.decode import decode_message
for ins, name in [('d0', 'WRITE BINARY'), ('a0', 'SEARCH BINARY'),
('b3', 'READ RECORD (odd INS)'),
('c3', 'ENVELOPE (odd INS)'),
('7a', 'EXCHANGE CAPABILITIES'),
('d4', 'RESIZE FILE'),
('2a', 'PERFORM SECURITY OPERATION'),
('46', 'GENERATE ASYMMETRIC KEY PAIR')]:
r = decode_message(bytes.fromhex('00' + ins + '000000' + '9000'))
self.assertEqual(r['ins_name'], name, ins)
class TestSwUiccSpecific(unittest.TestCase):
"""TS 102 221 UICC-specific SWs (tables 10.7-10.15)."""
def _sw(self, hexstr):
from simtrace_analyser.decode import decode_sw
return decode_sw(bytes.fromhex(hexstr))
def test_91xx_proactive_pending(self):
self.assertEqual(self._sw('9107')['name'],
'Proactive command pending (7 bytes)')
self.assertEqual(self._sw('910b')['name'],
'Proactive command pending (11 bytes)')
def test_62f_more_data(self):
self.assertEqual(self._sw('62f1')['name'], 'More data available')
self.assertEqual(self._sw('62f2')['name'],
'More data available and proactive command pending')
self.assertEqual(self._sw('62f3')['name'], 'Response data available')
def test_63f_not_pin_failure(self):
# 63F1/F2 are "more data expected", NOT PIN retries
self.assertEqual(self._sw('63f1')['name'], 'More data expected')
self.assertEqual(self._sw('63f2')['name'],
'More data expected and proactive command pending')
def test_63cx_still_pin_retries(self):
self.assertIn('retries remaining', self._sw('63c3')['name'])
self.assertIn('Verification failed', self._sw('63c3')['name'])
def test_sat_busy_and_auth_errors(self):
self.assertEqual(self._sw('9300')['name'], 'SIM Application Toolkit busy')
self.assertEqual(self._sw('9850')['name'],
'INCREASE cannot be performed, max value reached')
self.assertEqual(self._sw('9862')['name'],
'Authentication error, application specific')
self.assertEqual(self._sw('9863')['name'],
'Security session or association expired')
self.assertEqual(self._sw('9864')['name'],
'Minimum UICC suspension time too long')
def test_6401_immediate_response(self):
self.assertEqual(self._sw('6401')['name'],
'Execution error — immediate response required')
def test_6500_nv_memory_changed(self):
# TS 102 221 table 10.10: 6500 = no info given, NV memory CHANGED
name = self._sw('6500')['name']
self.assertIn('NV memory changed', name)
self.assertNotEqual(name, 'Execution error — memory failure')
def test_existing_patterns_intact(self):
self.assertIn('GET RESPONSE', self._sw('6110')['name'])
self.assertIn('correct length is 0x38', self._sw('6c38')['name'])
self.assertIsNone(self._sw('6205')['name'])
class TestSecuredCiphering(unittest.TestCase):
"""Ciphered SCP80 packets: on-wire CNTR/PCNTR/RC are ciphertext
(TS 102 225 Table 2 note 1) — flagged, not shown as real values."""
def test_ciphered_packet_flagged(self):
# stk_test1-style SMS-PP DOWNLOAD secured packet (ciphering on):
# CNTR on the wire is ciphertext; the card's real counter (~200)
# is only recoverable after 3DES decryption.
from simtrace_analyser.decode import _decode_secured_packet
sec = bytes.fromhex('003815160115 15b000011830a44e4191ad45d0ec'
'2a291315986468a539d248fcdce7952c31cd635baec'
'7e93a5d567b59f6283316c0dad8603bf3'.replace(' ', ''))
r = _decode_secured_packet(sec)
self.assertTrue(r['ciphered'])
self.assertTrue(r['spi']['ciphering'])
self.assertEqual(r['cntr'], '1830A44E41') # wire bytes kept, flagged
def test_unciphered_packet_plain_cntr(self):
# SPI1 = 0x10: counter present, NO ciphering, RC/CC/DS none →
# CNTR is genuine plaintext (e.g. 200 = 0x00000000C8).
from simtrace_analyser.decode import _decode_secured_packet
sec = bytes.fromhex('00100d10011515b0000100000000c800aabb')
r = _decode_secured_packet(sec)
self.assertFalse(r['ciphered'])
self.assertEqual(r['cntr'], '00000000C8')
self.assertNotIn('rc_cc_ds', r)
def test_response_packet_ciphered_flag(self):
from simtrace_analyser.decode import _decode_response_packet
body = bytes.fromhex('001f0a00000000000000500000ab12800101230d08a0000001510000000f9a9000')
r = _decode_response_packet(body, ciphered=True)
self.assertTrue(r['ciphered'])
r2 = _decode_response_packet(body)
self.assertFalse(r2['ciphered'])
def test_por_ciphered_via_context(self):
# GET RESPONSE after a ciphered ENVELOPE: the response packet's
# ciphered flag flows from the command's SPI2.b5 via prev context.
prev = {'ins': 0xC2, 'ins_name': 'ENVELOPE', 'sw1': '61',
'por_ciphered': True}
raw = bytes.fromhex('80c0000019' +
'0271' +
'001f0a00000000000000500000ab12800101230d08a0000001510000000f9a9000'[:0x19 * 2] +
'9000')
r = decode_message(raw, prev=prev)
self.assertTrue(r['response']['ciphered'])
def test_cipher_block_contiguous(self):
# cipher_block = the on-wire ciphered octets (CNTR..end) and equals
# the reassembled cntr+pcntr+rc+data — what a client-side decryptor
# feeds to 3DES/AES-CBC.
from simtrace_analyser.decode import _decode_secured_packet
sec = bytes.fromhex('00381516011515b000011830a44e4191ad45d0ec'
'2a291315986468a539d248fcdce7952c31cd635baec'
'7e93a5d567b59f6283316c0dad8603bf3'.replace(' ', ''))
r = _decode_secured_packet(sec)
self.assertEqual(len(r['cipher_block']) // 2, 48)
self.assertEqual(r['cipher_block'],
(r['cntr'] + f'{r["pcntr"]:02X}' + r['rc_cc_ds'] + r['data']))
self.assertEqual(r['kic_raw'], 0x15)
self.assertEqual(r['kid_raw'], 0x15)
def test_aes_algorithm_recognized(self):
# AES must stay recognized for ciphering (KIc nibble 2) and for the
# CC (KID nibble 2 = AES-CMAC) — the client-side decryptor relies on it.
from simtrace_analyser.decode import _KIC_ALGO, _KID_CC_ALGO
self.assertEqual(_KIC_ALGO[0x02], 'AES-CBC')
self.assertEqual(_KID_CC_ALGO[0x02], 'AES-CMAC')
class TestSpiBits(unittest.TestCase):
"""TS 102 225 §5.1.1: SPI1 b3 = ciphering, SPI2 b5 = PoR ciphering."""
def _spi(self, spi_hex):
from simtrace_analyser.decode import _decode_secured_packet
# CPL(2) CHL(1)=0x0D SPI(2) KIc KID TAR(3) CNTR(5) PCNTR + 1 data byte
body = bytes.fromhex('000d0d' + spi_hex + '1505000001000000000000aa')
return _decode_secured_packet(body)['spi']
def test_spi1_ciphering_b3(self):
# 0x16 = 0001 0110: b3=1 → ciphering ON; old code checked b4 (0x08) → False
self.assertTrue(self._spi('1605')['ciphering'])
# 0x12 = 0001 0010: b3=0 → ciphering OFF
self.assertFalse(self._spi('1205')['ciphering'])
# 0x04 = 0000 0100: only b3 set → ciphering ON
self.assertTrue(self._spi('0405')['ciphering'])
def test_spi2_por_ciphered_b5(self):
# 0x10 = 0001 0000: b5=1 → PoR ciphered; old code checked b6 (0x20) → False
self.assertTrue(self._spi('0510')['por_ciphered'])
# 0x20 = 0010 0000: b6=0... wait, b5=0 → NOT ciphered
self.assertFalse(self._spi('0520')['por_ciphered'])
def test_por_transport_field(self):
# TS 31.115: SPI2 b6 (0x20) = PoR transport (0 = SMS-DELIVER-REPORT, 1 = SMS-SUBMIT)
self.assertEqual(self._spi('0500')['por_transport'], 'SMS-DELIVER-REPORT')
self.assertEqual(self._spi('0520')['por_transport'], 'SMS-SUBMIT')
class TestApduSpecCoverage(unittest.TestCase):
"""Newly added APDU_SPEC entries decode with names."""
def _ins(self, cla, ins, p1p2='0000', body='', le=''):
raw = bytes.fromhex(cla + ins + p1p2 + '%02x' % (len(body) // 2) + body + le)
return decode_message(raw)['ins_name']
def test_update_binary_odd_ins(self):
r = self._ins('00', 'd7', '0010', 'aabb')
self.assertEqual(r, 'UPDATE BINARY (odd INS)')
def test_update_record_odd_ins(self):
r = self._ins('00', 'dd', '0104', 'aabb')
self.assertEqual(r, 'UPDATE RECORD (odd INS)')
def test_write_record(self):
r = self._ins('00', 'd2', '0104', 'aabb')
self.assertEqual(r, 'WRITE RECORD')
def test_external_authenticate(self):
r = self._ins('00', '82', '0100', 'aabbccdd')
self.assertEqual(r, 'EXTERNAL AUTHENTICATE')
def test_general_authenticate(self):
r = self._ins('00', '86', '0000', '7c0aa50401020304')
self.assertEqual(r, 'GENERAL AUTHENTICATE')
def test_manage_security_environment(self):
r = self._ins('00', '22', '41a4', '')
self.assertEqual(r, 'MANAGE SECURITY ENVIRONMENT')
def test_erase_binary(self):
r = self._ins('00', '0e', '0010', '0020')
self.assertEqual(r, 'ERASE BINARY')
def test_erase_records(self):
r = self._ins('00', '0c', '0304', '')
self.assertEqual(r, 'ERASE RECORDS')
def test_manage_lsi(self):
r = self._ins('80', '7c', '0000', '0201')
self.assertEqual(r, 'MANAGE LSI')
class TestFileAttribution(unittest.TestCase):
"""Non-SFI READ/UPDATE attribute to the last file selected in the
channel — even when the file has no content decoder (mobileid
session 63 regression: UPDATE BINARY after a proactive group)."""
SEL_SMSS = {'sel': {'fid': '6f43', 'name': 'EF_SMSS', 'structure': 'transparent'}}
def _upd(self, prev):
raw = bytes.fromhex('00d6000004aabbccdd' + '9000')
return decode_message(raw, prev=prev)
def test_update_atributed_without_content_decoder(self):
# EF_SMSS (6F43) has no FILE_DECODERS entry — attribution must
# still carry the file name.
r = self._upd(dict(self.SEL_SMSS, ins=0xB0, ins_name='SELECT', sw1='61'))
self.assertEqual(r['file'], {'fid': '6f43', 'ef': 'EF_SMSS'})
def test_update_with_content_decoder_wins(self):
prev = dict(self.SEL_SMSS, ins=0xB0, ins_name='SELECT', sw1='61')
prev['sel'] = {'fid': '6f7e', 'name': 'EF_LOCI', 'structure': 'transparent'}
raw = bytes.fromhex('00d600000b' + '00' * 11 + '9000')
r = decode_message(raw, prev=prev)
self.assertEqual(r['file']['ef'], 'EF_LOCI')
self.assertIn('mcc', r['file'])
def test_update_nonzero_offset_attributed(self):
# Content decode skipped for non-zero offset, attribution kept.
raw = bytes.fromhex('00d6001004aabbccdd' + '9000')
r = decode_message(raw, prev=dict(self.SEL_SMSS, ins=0xB0,
ins_name='SELECT', sw1='61'))
self.assertEqual(r['file']['ef'], 'EF_SMSS')
def test_get_response_attribution(self):
# READ BINARY (61 XX) → GET RESPONSE on a decoder-less file
prev = dict(self.SEL_SMSS, ins=0xB0, ins_name='READ BINARY', sw1='61',
file_ok=True)
raw = bytes.fromhex('00c0000002' + 'cafe' + '9000')
r = decode_message(raw, prev=prev)
self.assertEqual(r['file'], {'fid': '6f43', 'ef': 'EF_SMSS'})
class TestClaDecode(unittest.TestCase):
"""ISO 7816-4 §5.1.1 first/further interindustry CLA coding."""
def _cla(self, value):
from simtrace_analyser.decode import decode_cla
return decode_cla(value)
def test_basic_channel_iso(self):
r = self._cla(0x00)
self.assertEqual(r['channel'], 0)
self.assertEqual(r['secure_messaging'], 'none')
self.assertEqual(r['chain'], 'last or only')
def test_sm_header_authenticated(self):
# 0x1C = 0001 1100: SM bits b4-b3 = 11 → header authenticated
r = self._cla(0x1c)
self.assertEqual(r['secure_messaging'], 'SM header authenticated')
def test_sm_proprietary(self):
# 0x04 = 0000 0100: b4-b3 = 01 → proprietary secure messaging;
# 0x0C = b4-b3 = 11 → header authenticated (ISO 7816-4 Table 2).
r = self._cla(0x04)
self.assertEqual(r['secure_messaging'], 'proprietary')
r = self._cla(0x0c)
self.assertEqual(r['channel'], 0)
self.assertEqual(r['secure_messaging'], 'SM header authenticated')
def test_chain_bit_b5(self):
# 0x10 = 0001 0000: b5=1 → first or continuing command of chain
# (ISO 7816-4 §5.1.1 Table 2).
r = self._cla(0x10)
self.assertEqual(r['chain'], 'first or continuing')
def test_reserved_class_001x(self):
# 0x20 = 0010 0000: the '001x xxxx' range is reserved by ISO
# 7816-4; its bits carry no defined SM/chaining meaning.
r = self._cla(0x20)
self.assertEqual(r['interclass'], 'reserved (ISO 7816-4)')
self.assertEqual(r['chain'], 'n/a')
self.assertEqual(r['secure_messaging'], 'n/a')
def test_further_interindustry_channel_offset(self):
# 0x40 = 0100 0000: further format, channel = b4-b1 + 4 = 4
r = self._cla(0x40)
self.assertEqual(r['interclass'], 'inter-industry (further format)')
self.assertEqual(r['channel'], 4)
def test_extended_channels_iso(self):
# TS 102 221 §10.3: the further-interindustry coding carries the
# extended channels 4-19 in b4-b1 (ISO 7816-4 §5.1.1 Table 3).
for cla, channel in ((0x44, 8), (0x49, 13), (0x4a, 14), (0x4f, 19),
(0x60, 4), (0x6f, 19)):
r = self._cla(cla)
self.assertEqual(r['channel'], channel, f'CLA {cla:#04x}')
# b6 of the further format is the SM indication, b5 the chaining bit.
self.assertEqual(self._cla(0x60)['secure_messaging'],
'SM header not authenticated')
self.assertEqual(self._cla(0x70)['chain'], 'first or continuing')
def test_extended_channels_etsi(self):
# TS 102 221 Table 10.4a: the ETSI '11x0 xxxx' coding addresses the
# extended channels too (b4-b1 + 4).
for cla, channel in ((0xc0, 4), (0xc5, 9), (0xcf, 19), (0xe0, 4),
(0xe5, 9), (0xef, 19)):
r = self._cla(cla)
self.assertEqual(r['interclass'],
'ETSI-defined (extended logical channels)',
f'CLA {cla:#04x}')
self.assertEqual(r['channel'], channel, f'CLA {cla:#04x}')
# b6 is the SM indication (Table 10.4b), b5 must be 0.
self.assertEqual(self._cla(0xe5)['secure_messaging'],
'SM header not authenticated')
self.assertEqual(self._cla(0xc5)['secure_messaging'], 'none')
def test_etsi_uicc(self):
r = self._cla(0x80)
self.assertEqual(r['interclass'], 'ETSI-defined (UICC/USIM)')
self.assertEqual(r['channel'], 0)
# The low nibble of the ETSI classes carries SM (b4-b3) and the
# standard channel (b2-b1), TS 102 221 Table 10.3/10.4.
r = self._cla(0x8c)
self.assertEqual(r['interclass'], 'ETSI-defined (UICC/USIM)')
self.assertEqual(r['channel'], 0)
self.assertEqual(r['secure_messaging'], 'SM header authenticated')
r = self._cla(0x83)
self.assertEqual(r['channel'], 3)
def test_etsi_sim(self):
r = self._cla(0xa0)
self.assertEqual(r['interclass'], 'ETSI-defined (SIM/GSM)')
r = self._cla(0xa3)
self.assertEqual(r['interclass'], 'ETSI-defined (SIM/GSM)')
self.assertEqual(r['channel'], 3)
def test_proprietary_and_invalid(self):
# b5 = 1 contradicts the ETSI class codings (TS 102 221 §10.1.1) and
# 0xFF is invalid; both keep the best-effort channel reading.
self.assertEqual(self._cla(0x90)['interclass'], 'proprietary')
self.assertEqual(self._cla(0xd5)['channel'], 9)
self.assertEqual(self._cla(0xff)['interclass'], 'invalid (ISO 7816-4)')
def test_cla_channel_helper(self):
# The tracker/state paths share this helper with decode_cla.
from simtrace_analyser.decode import cla_channel
self.assertEqual(cla_channel(0x00), 0)
self.assertEqual(cla_channel(0x03), 3)
self.assertEqual(cla_channel(0x80), 0)
self.assertEqual(cla_channel(0x44), 8)
self.assertEqual(cla_channel(0x4f), 19)
self.assertEqual(cla_channel(0xef), 19)
def test_looks_like_apdu(self):
from simtrace_analyser.decode import looks_like_apdu
self.assertTrue(looks_like_apdu(bytes.fromhex('00a40004023f00')))
self.assertTrue(looks_like_apdu(bytes.fromhex('80f2000000')))
self.assertFalse(looks_like_apdu(b''))
self.assertFalse(looks_like_apdu(bytes.fromhex('00a4')))
self.assertFalse(looks_like_apdu(bytes.fromhex('ff10957aff10957a')))
self.assertFalse(looks_like_apdu(bytes.fromhex('2d6ccfb0ff')))
self.assertFalse(looks_like_apdu(bytes.fromhex('31e073fe211b66')))
class TestLineEvents(unittest.TestCase):
"""sigrok-iso7816-stream RST/VCC line events (GSMTAP 0x10/0x11)."""
def _dec(self, kind, payload):
from simtrace_analyser.decode import decode_line_event
return decode_line_event(bytes.fromhex(payload), kind)
def test_rst_asserted(self):
r = self._dec('rst', '010000')
self.assertEqual(r['type'], 'rst')
self.assertEqual(r['event'], 'reset asserted')
self.assertEqual(r['label'], 'RESET ASSERTED')
self.assertEqual(r['level'], 'low')
def test_rst_deasserted(self):
r = self._dec('rst', '000100')
self.assertEqual(r['event'], 'reset de-asserted')
self.assertEqual(r['label'], 'RST de-asserted — ATR follows')
self.assertEqual(r['level'], 'high')
def test_vcc_on(self):
r = self._dec('vcc', '010100')
self.assertEqual(r['event'], 'power applied')
self.assertEqual(r['label'], 'VCC ON (power-up)')
self.assertEqual(r['level'], 'high')
def test_vcc_off(self):
r = self._dec('vcc', '000000')
self.assertEqual(r['event'], 'power removed')
self.assertEqual(r['label'], 'VCC OFF (power-down)')
self.assertEqual(r['level'], 'low')
def test_db_roundtrip_label(self):
# rst/vcc must be decoded server-side (database.py dispatch) so the
# PWA receives decoded.label/event, not just the raw bytes.
import tempfile
from simtrace_analyser.database import Database
with tempfile.NamedTemporaryFile(suffix='.db') as tmp:
db = Database(tmp.name)
sid = db.create_session('capture')
db.insert_message(sid, 0.1, 'rst', bytes.fromhex('000100'), 0)
db.insert_message(sid, 0.2, 'vcc', bytes.fromhex('010100'), 0)
msgs = db.get_messages(sid)
self.assertEqual(msgs[0]['decoded']['label'], 'RST de-asserted — ATR follows')
self.assertEqual(msgs[1]['decoded']['label'], 'VCC ON (power-up)')
def test_sniff_routing(self):
from simtrace_analyser.decode import decode_sniff_msg
self.assertEqual(decode_sniff_msg(b'\x01\x00\x00', 'rst')['type'], 'rst')
self.assertEqual(decode_sniff_msg(b'\x00\x01\x00', 'vcc')['type'], 'vcc')
def test_bad_fcs_flag_marks_tpdu(self):
from simtrace_analyser.decode import decode_sniff_msg
r = decode_sniff_msg(b'\x80\xf2\x00\x00\x00', 'tpdu', flags=0x01)
self.assertIn('bad FCS (desynced)', r['errors'])
def test_bad_fcs_flag_marks_rst(self):
from simtrace_analyser.decode import decode_sniff_msg
r = decode_sniff_msg(b'\x01\x00\x00', 'rst', flags=0x01)
self.assertIn('bad FCS (desynced)', r['errors'])
self.assertEqual(r['event'], 'reset asserted')
def test_no_fcs_flag_no_error(self):
from simtrace_analyser.decode import decode_sniff_msg
r = decode_sniff_msg(b'\x01\x00\x00', 'rst', flags=0)
self.assertNotIn('errors', r)
def test_short_payload(self):
r = self._dec('rst', '01')
self.assertEqual(r['type'], 'rst')
self.assertNotIn('event', r)
def test_rst_clk_extension(self):
# v1.9.0 payload: direction, level, flags(bit0=CLK), clk_hz_be32.
r = self._dec('rst', '000101003d0900') # 4 MHz
self.assertEqual(r['event'], 'reset de-asserted')
self.assertEqual(r['clk_hz'], 4000000)
def test_rst_clk_extension_asserted(self):
r = self._dec('rst', '010001003d0900') # 4 MHz
self.assertEqual(r['event'], 'reset asserted')
self.assertEqual(r['clk_hz'], 4000000)
def test_rst_clk_flag_absent(self):
r = self._dec('rst', '000100003d0900')
self.assertNotIn('clk_hz', r)
def test_rst_clk_flag_truncated(self):
# Flag says CLK follows but the payload is too short — ignore.
r = self._dec('rst', '000101003d09')
self.assertNotIn('clk_hz', r)
def test_db_atr_data_rate(self):
# CLK from RST → data rate on the following ATR; rate consumed once,
# so a second ATR without a preceding RST has no rate.
import tempfile
from simtrace_analyser.database import Database
with tempfile.NamedTemporaryFile(suffix='.db') as tmp:
db = Database(tmp.name)
sid = db.create_session('capture')
db.insert_message(sid, 0.1, 'rst', bytes.fromhex('000101003d0900'), 0)
db.insert_message(sid, 0.2, 'atr', bytes.fromhex('3B12110000'), 0)
db.insert_message(sid, 0.3, 'atr', bytes.fromhex('3B12110000'), 0)
msgs = db.get_messages(sid)
self.assertEqual(msgs[1]['decoded']['data_rate'], 10753) # 4 MHz / 372
self.assertEqual(msgs[1]['decoded']['etu_us'], 93.0)
self.assertNotIn('data_rate', msgs[2]['decoded'])
def test_db_atr_rate_backfill(self):
# Ordering caveat: a short ATR can be emitted before the RST event
# that measured its CLK rate — the rate is backfilled onto it.
import tempfile
from simtrace_analyser.database import Database
with tempfile.NamedTemporaryFile(suffix='.db') as tmp:
db = Database(tmp.name)
sid = db.create_session('capture')
db.insert_message(sid, 0.1, 'atr', bytes.fromhex('3B12110000'), 0)
db.insert_message(sid, 0.2, 'rst', bytes.fromhex('000101003d0900'), 0)
msgs = db.get_messages(sid)
self.assertEqual(msgs[0]['decoded']['data_rate'], 10753)
class TestSpecTables(unittest.TestCase):
"""The file/SFI dictionaries are spec-sourced: this pins them against
tests/data/spec_files.json (extracted from the PDFs in docs/uicc/)."""
@classmethod
def setUpClass(cls):
import json
import pathlib
cls.fixture = json.loads(
(pathlib.Path(__file__).parent / 'data' / 'spec_files.json')
.read_text())
def test_file_name_is_df_aware(self):
from simtrace_analyser.decode import _file_name
# 4F09: SUPI-as-NAI under DF_5GS, group communication under DF_ProSe.
self.assertEqual(_file_name('4f09', '5fc0'), 'EF_SUPI_NAI')
self.assertEqual(_file_name('4f09', '5f90'), 'EF_PROSE_GC')
# 6F07: IMSI in the USIM/SIM, the ISIM service table in the ISIM.
self.assertEqual(_file_name('6f07', '7fff'), 'EF_IMSI')
self.assertEqual(_file_name('6f07', '7f20'), 'EF_IMSI')
self.assertEqual(_file_name('6f07', 'aid:a0000000871004'), 'EF_IST')
# 6F4E: EF_EXT4 under DF_TELECOM, EF_EXT5 in the USIM ADF.
self.assertEqual(_file_name('6f4e', '7f10'), 'EF_EXT4')
self.assertEqual(_file_name('6f4e', '7fff'), 'EF_EXT5')
# Without a DF the unambiguous flat table is the fallback; a FID
# that is ambiguous (or unknown) stays numeric.
self.assertEqual(_file_name('2fe2'), 'EF_ICCID')
self.assertEqual(_file_name('2fe2', '7fff'), 'EF_ICCID')
self.assertEqual(_file_name('4f09'), '4F09')
self.assertEqual(_file_name('1234', '7fff'), '1234')
def test_file_tables_cover_the_pinned_specs(self):
from simtrace_analyser.decode import _file_name
for df, files in self.fixture['files'].items():
for fid, name in files.items():
self.assertEqual(_file_name(fid, df), name, f'{df}/{fid}')
def test_sfi_tables_match_the_pinned_specs(self):
from simtrace_analyser.decode import sfi_table
for df, table in self.fixture['sfi'].items():
self.assertEqual(sfi_table(df),
{int(s, 16): fid for s, fid in table.items()},
df)
def test_flat_names_are_spec_verified(self):
import collections
from simtrace_analyser.decode import KNOWN_FIDS
# Every flat name must be the spec name of that FID somewhere (the DF
# tables, the DF identifiers, or the two intentional specials): a typo
# or an invented name fails, and a DF-specific name can no longer
# sneak into the flat table under a conflicting FID.
names = collections.defaultdict(set)
for files in self.fixture['files'].values():
for fid, name in files.items():
names[fid].add(name)
for fid, name in self.fixture['df_fids'].items():
names[fid].add(name)
names['3f00'].add('MF')
names['6f65'].add('EF_RPLMNAcT') # legacy value, not re-assigned
for fid, name in KNOWN_FIDS.items():
self.assertIn(name, names.get(fid, set()), f'{fid}={name}')
if __name__ == '__main__':
unittest.main()