Files
simtrace-analyser/tests/test_decode.py
T
catarrh 04443878a7 Show destination address in MO SHORT MESSAGE CONTROL summaries
- _build_summary: append 'DA <tp_da>' after the SMSC part for the
  MO SHORT MESSAGE CONTROL envelope (the envelope decoder already
  stores the second address TLV as cmd.tp_da; the detail view already
  showed it)
- test: constructed D5 envelope with SMSC + destination address
- Backend-only, no version bump
2026-09-19 00:29:33 +03:00

2602 lines
119 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 simtrace2_pysniff.server.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')
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_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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.decode import _decode_datetime
self.assertIsNone(_decode_datetime(bytes.fromhex('A505326C333939')))
def test_datetime_invalid_semantics(self):
from simtrace2_pysniff.server.decode import _decode_datetime
self.assertIsNone(_decode_datetime(bytes.fromhex('62310100000000')))
def test_pli_language(self):
from simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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')
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')
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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.decode import _decode_secured_packet
self.assertEqual(_decode_secured_packet(bytes.fromhex('0001')), {'raw': '0001'})
def test_send_sm_response_packet(self):
from simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.decode import _decode_bcd_address
self.assertEqual(_decode_bcd_address(bytes.fromhex('912143658709')), '+1234567890')
def test_national(self):
from simtrace2_pysniff.server.decode import _decode_bcd_address
self.assertEqual(_decode_bcd_address(bytes.fromhex('A12143658709')), '1234567890')
def test_odd_digits(self):
from simtrace2_pysniff.server.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 simtrace2_pysniff.server.decode import _decode_annex_a
self.assertEqual(_decode_annex_a(b'Alfa Mobile'), 'Alfa Mobile')
def test_ucs2_variant1(self):
from simtrace2_pysniff.server.decode import _decode_annex_a
self.assertEqual(_decode_annex_a(b'\x80' + 'Привет'.encode('utf_16_be')), 'Привет')
def test_ucs2_variant2_base_ptr(self):
from simtrace2_pysniff.server.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 simtrace2_pysniff.server.decode import _decode_annex_a
self.assertEqual(_decode_annex_a(b'\x90' + 'Привет'.encode('utf_16_be')), 'Привет')
def test_gsm7_packed(self):
from simtrace2_pysniff.server.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 simtrace2_pysniff.server.decode import _decode_pnn
r = _decode_pnn(bytes.fromhex('43058441b6390c' + 'ff' * 13))
self.assertEqual(r['full'], 'Alfa')
def test_pnn_ucs2(self):
from simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.decode import _decode_dcs_text
self.assertEqual(_decode_dcs_text(b'\x00Alfa'), 'Alfa')
def test_ucs2(self):
from simtrace2_pysniff.server.decode import _decode_dcs_text
self.assertEqual(_decode_dcs_text(b'\x08' + 'OK'.encode('utf_16_be')), 'OK')
def test_latin1(self):
from simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.decode import _decode_atr
# T0 = 0x03 (3 historical bytes); historical = 80 00 05 (life cycle: operational)
r = _decode_atr(bytes.fromhex('3B03800005'))
self.assertEqual(r['historical_len'], 3)
self.assertEqual(r['historical']['category'], 'status information (life cycle)')
self.assertEqual(r['historical']['life_cycle'], 'operational state (activated)')
def test_atr_t15_is_pps_capability(self):
from simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.decode import _decode_file_data
f = _decode_file_data('6f07', bytes.fromhex('082905917700917259'))
self.assertEqual(f['imsi'], '250197700192795')
def test_iccid(self):
from simtrace2_pysniff.server.decode import _decode_file_data
f = _decode_file_data('2fe2', bytes.fromhex('98891020000000460012'))
self.assertEqual(f['iccid'], '89980102000000640021')
def test_li(self):
from simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.decode import _decode_file_data
f = _decode_file_data('6f04', b'\x80')
self.assertIn('empty', f)
def test_adn(self):
from simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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')
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_fid(self):
from simtrace2_pysniff.server.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'}))
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('6f7e'), {})
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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.decode import decode_sw
self.assertIsNone(decode_sw(bytes.fromhex('6205'))['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'])
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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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_envelope_types_extended(self):
from simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.decode import _decode_acl
r = _decode_acl(bytes.fromhex('01dd08696e7465726e6574'), p1=None)
self.assertEqual(r['apns'], ['internet'])
def test_spdi(self):
from simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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):
# 0x0C = 0000 1100: b4-b3 = 01 → proprietary secure messaging
r = self._cla(0x0c)
self.assertEqual(r['secure_messaging'], 'proprietary')
def test_chain_bit_b5(self):
# 0x20 = 0010 0000: b5=1 → first or continuing command of chain
r = self._cla(0x20)
self.assertEqual(r['chain'], 'first or continuing')
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_etsi_uicc(self):
r = self._cla(0x80)
self.assertEqual(r['interclass'], 'ETSI-defined (UICC/USIM)')
def test_etsi_sim(self):
r = self._cla(0xa0)
self.assertEqual(r['interclass'], 'ETSI-defined (SIM/GSM)')
class TestLineEvents(unittest.TestCase):
"""sigrok-iso7816-stream RST/VCC line events (GSMTAP 0x10/0x11)."""
def _dec(self, kind, payload):
from simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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 simtrace2_pysniff.server.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)
if __name__ == '__main__':
unittest.main()