diff --git a/README.md b/README.md index 249a4ad..1d8d431 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,10 @@ that both captures APDU traffic and serves that PWA. - Status words — full ISO 7816-4 set plus UICC-specific values: `91XX` (proactive command pending), `62F1/F2/F3`, `63F1/F2` (more data), `9300` (SAT busy), `9850`, `9862–64` -- SIM Toolkit (CAT) — proactive commands with decoded qualifiers, - TERMINAL RESPONSE, ENVELOPE (Menu Selection, Call Control, SMS-PP download…) +- SIM Toolkit — SAT (GSM 11.14), CAT (TS 102 223) and USAT (TS 31.111) + proactive commands with decoded qualifiers, TERMINAL RESPONSE, ENVELOPE + (Menu Selection, Call Control, SMS-PP download…); tolerates known + non-compliant legacy-card quirks and preserves unrecognized TLVs - SMS TPDU — SMS-DELIVER/SUBMIT, SCTS timestamps, UDH information elements - SCP80 OTA secured packets — SPI bits (ciphering, PoR requirement/mode, RC/CC/DS, counter), KIc/KID algorithm + key set, TAR, CNTR/PCNTR; diff --git a/frontend/index.html b/frontend/index.html index ee77229..280fecd 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -651,7 +651,11 @@ function cmdRows(cmd) { if (cmd.subaddress) rows.push(['Subaddress', cmd.subaddress]); if (cmd.smsc) rows.push(['SMSC', cmd.smsc]); if (cmd.tp_da) rows.push(['TP-DA', cmd.tp_da]); - if (cmd.device_ids) rows.push(['Device IDs', cmd.device_ids]); + if (cmd.device_ids) { + const di = cmd.device_ids; + rows.push(['Device IDs', typeof di === 'string' ? di + : (di.src + ' → ' + di.dst)]); + } if (cmd.cb_page) { const cb = cmd.cb_page; if (cb.serial) rows.push(['CB serial', cb.serial]); @@ -676,6 +680,18 @@ function cmdRows(cmd) { if (cmd.duration) rows.push(['Duration', cmd.duration.value + ' ' + cmd.duration.unit]); if (cmd.address) rows.push(['Address', cmd.address]); if (cmd.response_length) rows.push(['Response length', cmd.response_length.min + '…' + cmd.response_length.max]); + if (cmd.item_id !== undefined) rows.push(['Item id', '0x' + hx(cmd.item_id, 2)]); + if (cmd.help) rows.push(['Help', 'requested']); + if (cmd.timer_id !== undefined) rows.push(['Timer id', '0x' + hx(cmd.timer_id, 2)]); + if (cmd.timer_value) rows.push(['Timer value', cmd.timer_value]); + if (cmd.ussd) rows.push(['USSD string', cmd.ussd]); + if (cmd.encapsulated && cmd.encapsulated.type) { + rows.push(['Encapsulated', cmd.encapsulated.type]); + } + if (cmd.text_note) rows.push(['Text note', cmd.text_note]); + if (cmd.raw_tlv) { + for (const t of cmd.raw_tlv) rows.push(['TLV 0x' + t.tag, t.value || '(empty)']); + } // SM TPDU (nested in SMS-PP download / SEND SHORT MESSAGE) const t = cmd.tpdu; @@ -1852,7 +1868,7 @@ function onFilterChange() { } // ──────────────────── Versioning ──────────────────── -const PWA_VERSION = '1.11.0'; +const PWA_VERSION = '1.13.0'; const MIN_SERVER_VERSION = '1.0.0'; function parseVersion(v) { diff --git a/frontend/sw.js b/frontend/sw.js index f6116bd..f3c8a33 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simtrace-analyser-v14'; +const CACHE = 'simtrace-analyser-v16'; const PRECACHE = [ 'index.html', diff --git a/pyproject.toml b/pyproject.toml index cb62ba8..6223d36 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.backends._legacy:_Backend" [project] name = "simtrace2-pysniff" -version = "1.11.0" +version = "1.13.0" description = "Python-based sniffing tool for SIMtrace2 hardware (SIM card communication sniffer)" readme = {file = "README.md", content-type = "text/markdown"} license = {text = "GPL-2.0-or-later"} diff --git a/simtrace2_pysniff/__init__.py b/simtrace2_pysniff/__init__.py index 9222449..6c98ded 100644 --- a/simtrace2_pysniff/__init__.py +++ b/simtrace2_pysniff/__init__.py @@ -6,7 +6,7 @@ from .gsmtap import GsmtapSender, GsmtapReceiver from .pcap import PcapWriter from .dump import FileDumper, format_message -__version__ = '1.11.0' +__version__ = '1.13.0' __all__ = [ 'SniffSession', 'DeviceDisconnected', diff --git a/simtrace2_pysniff/server/decode.py b/simtrace2_pysniff/server/decode.py index ecf42f9..a41c0cd 100644 --- a/simtrace2_pysniff/server/decode.py +++ b/simtrace2_pysniff/server/decode.py @@ -956,6 +956,7 @@ CAT_COMMAND_TYPES = { 0x71: 'CONTACTLESS STATE CHANGED', 0x72: 'COMMAND CONTAINER', 0x73: 'ENCAPSULATED SESSION CONTROL', + 0x79: 'LSI COMMAND', 0x81: 'END OF PROACTIVE SESSION', } @@ -982,6 +983,19 @@ ENVELOPE_TYPES = { 0xD5: 'MO SHORT MESSAGE CONTROL', 0xD6: 'EVENT DOWNLOAD', 0xD7: 'TIMER EXPIRATION', + # TS 101 220 table 7.17 + TS 31.111 §9.1 + 0xD9: 'USSD DOWNLOAD', + 0xDA: 'MMS TRANSFER STATUS', + 0xDB: 'MMS NOTIFICATION', + 0xDC: 'TERMINAL APPLICATIONS', + 0xDD: 'GEOGRAPHICAL LOCATION REPORTING', + 0xDE: 'ENVELOPE CONTAINER', + 0xDF: 'PROSE REPORT', + 0xE0: '5G PROSE REPORT', + 0xE1: 'Reserved for 3GPP', + 0xE2: 'Reserved for 3GPP', + 0xE3: 'Reserved for 3GPP', + 0xE4: 'GSMA', } @@ -1027,6 +1041,7 @@ PLI_QUALIFIERS = { 0x04: 'Language setting', 0x05: 'Timing Advance', 0x06: 'Access Technology (single)', + 0x07: 'ESN of the terminal', 0x08: 'IMEISV', 0x09: 'Search Mode', 0x0A: 'Battery charge state', @@ -1042,6 +1057,7 @@ PLI_QUALIFIERS = { 0x15: 'Slices information', 0x16: 'CAG information list', 0x17: 'Rejected slices information', + 0x1A: 'Supported Radio Access Technologies', } @@ -1095,6 +1111,8 @@ def _command_details_name(value): """Decode a Command Details TLV value into the command type name.""" if len(value) < 2: return None + if 0xF0 <= value[1] <= 0xFE: # TS 102 223 §9.4 — reserved for proprietary use + return f'Proprietary ({value[1]:#04x})' return CAT_COMMAND_TYPES.get(value[1]) @@ -1157,31 +1175,38 @@ TR_RESULTS = { 0x07: 'Command performed with modification', 0x08: 'REFRESH performed but indicated NAA was not active', 0x09: 'Command performed successfully, tone not played', - 0x0A: 'Proactive UICC session terminated by the user', - 0x0B: 'Backward move in the proactive UICC session requested by the user', - 0x0C: 'No response from user', - 0x0D: 'Help information required by the user', - 0x0E: 'USSD/SS transaction terminated by the user', - 0x10: 'Terminal currently unable to process command (screen busy)', - 0x11: 'Terminal currently unable to process command (busy on call)', - 0x12: 'Terminal currently unable to process command (USSD/SS ongoing)', - 0x13: 'Terminal currently unable to process command (no service)', - 0x14: 'Terminal currently unable to process command (access control class bar)', - 0x15: 'Terminal currently unable to process command (radio resource unavailable)', - 0x20: 'Network currently unable to process command', - 0x30: 'Beyond terminal capability', - 0x31: 'Command type not understood by terminal', - 0x32: 'Command data not understood by terminal', - 0x33: 'Command number not known by terminal', + 0x10: 'Proactive UICC session terminated by the user', + 0x11: 'Backward move in the proactive UICC session requested by the user', + 0x12: 'No response from user', + 0x13: 'Help information required by the user', + 0x14: 'USSD or SS transaction terminated by the user', + # TS 102 223 §8.12.1 / TS 31.111: '15'-'16' reserved for 3GPP; + # per-cause "terminal unable" details are Additional information + # bytes of result '20', not separate general results. + 0x20: 'ME currently unable to process command', + 0x21: 'Network currently unable to process command', + 0x22: 'User did not accept the proactive command', + 0x23: 'User cleared down call before connection or network release', + 0x24: 'Action in contradiction with the current timer state', + 0x25: 'Interaction with call control by NAA, temporary problem', + 0x26: 'Launch browser generic error', + 0x27: 'MMS temporary problem', + # '28'-'29' reserved for 3GPP + 0x30: 'Command beyond ME capabilities', + 0x31: 'Command type not understood by ME', + 0x32: 'Command data not understood by ME', + 0x33: 'Command number not known by ME', 0x34: 'SS Return Error', 0x35: 'SMS RP-ERROR', 0x36: 'Error, required values are missing', - 0x37: 'USSD Return Error', - 0x38: 'MultipleCard commands error', - 0x39: 'Interaction with CC by NAA, wrong procedure', - 0x3C: 'Access Technology unable to process command', - 0x3D: 'Frames error', - 0x3E: 'MMS Error', + 0x37: 'USSD return error', + 0x38: 'Multiple Card command error', + 0x39: 'Interaction with call/SM control by USIM, permanent problem', + 0x3A: 'Bearer Independent Protocol error', + 0x3B: 'Access Technology unable to process command', + 0x3C: 'Frames error', + 0x3D: 'MMS error', + # '3E'-'3F' reserved for 3GPP } @@ -1515,18 +1540,18 @@ EVENT_TYPES = { 0x03: 'Location status', 0x04: 'User activity', 0x05: 'Idle screen available', 0x06: 'Card reader status', 0x07: 'Language selection', 0x08: 'Browser termination', 0x09: 'Data available', - 0x0A: 'Channel status', 0x0B: 'Access Technology Change', + 0x0A: 'Channel status', 0x0B: 'Access Technology Change (single)', 0x0C: 'Display parameters changed', 0x0D: 'Local connection', 0x0E: 'Network Search Mode Change', 0x0F: 'Browsing status', - 0x10: 'Frames Information Change', 0x11: 'I-WLAN Access Status', - 0x12: 'Network Rejection', 0x13: 'HCI Connectivity', - 0x14: 'Change of UICC Access', 0x15: 'CSG Cell Change', - 0x16: 'Contactless state request', 0x17: 'Profile Container', - 0x18: 'LTE D2D Discovery Monitoring', 0x19: 'LTE D2D Communication Monitoring', - 0x1A: 'LTE D2D Announcement Response', 0x1B: 'LTE D2D Revocation', - 0x1C: 'LTE D2D Application Port', 0x1D: 'LTE D2D Security Recovery', - 0x1E: 'Off-net Emergency Call', 0x1F: 'ECall Over IMS', - 0x20: 'EARFCN Update', 0x21: 'SCEF Channel Status', + 0x10: 'Frames Information Change', 0x11: '(I-)WLAN Access Status', + 0x12: 'Network Rejection', 0x13: 'HCI connectivity event', + 0x14: 'Access Technology Change (multiple)', 0x15: 'CSG cell selection', + 0x16: 'Contactless state request', 0x17: 'IMS Registration', + 0x18: 'IMS Incoming data', 0x19: 'Profile Container', + 0x1A: 'Void', 0x1B: 'Secured Profile Container', + 0x1C: 'Poll Interval Negotiation', 0x1D: 'Data Connection Status Change', + 0x1E: 'CAG cell selection', 0x1F: 'Slices Status Change', + # TS 102 223 §8.25: '20'-'22' reserved for 3GPP (future usage) } LOCATION_STATUS = { @@ -1536,6 +1561,44 @@ LOCATION_STATUS = { } +# TS 102 223 §8.7 device identity coding (summary per UICC_SPECS.md §6.2) +_DEVICE_ID_RANGES = [ + (0x10, 0x17, lambda v: f'Card reader {v - 0x10}'), + (0x21, 0x27, lambda v: f'Channel {v - 0x21}'), + (0x31, 0x3F, lambda v: f'eCAT client {v - 0x30}'), +] +_DEVICE_IDS = { + 0x01: 'Keypad', 0x02: 'Display', 0x03: 'Earpiece', + 0x81: 'UICC', 0x82: 'Terminal', 0x83: 'Network', +} + + +def _device_id_name(value): + """Decode one Device Identity byte (TS 102 223 §8.7).""" + if value in _DEVICE_IDS: + return _DEVICE_IDS[value] + for lo, hi, fmt in _DEVICE_ID_RANGES: + if lo <= value <= hi: + return fmt(value) + return f'0x{value:02X}' + + +def _decode_device_ids(value): + """Decode a Device Identities TLV value into {src, dst} names.""" + if len(value) < 2: + return None + return {'src': _device_id_name(value[0]), 'dst': _device_id_name(value[1])} + + +def _decode_timer_value(b): + """Decode a 3-byte Timer Value (TS 102 223 §8.38): semi-octet h/m/s.""" + if len(b) != 3: + return None + def so(x): + return '%d%d' % ((x >> 4) & 0x0F, x & 0x0F) + return f'{so(b[0])}:{so(b[1])}:{so(b[2])}' + + def _decode_bcd_address(data): """Decode a [TON/NPI + BCD digits] address into a string.""" if not data: @@ -2184,57 +2247,109 @@ def _decode_envelope(body): return {} tag, _length, value = tlvs[0] result = {'type': ENVELOPE_TYPES.get(tag, f'0x{tag:02X}')} - inner = parse_tlv(value) - if tag == 0xD6: # EVENT DOWNLOAD - for t, _l, v in inner: - if t == 0x19: # Event list + if tag == 0xDE: # ENVELOPE CONTAINER — wraps another complete envelope + for t, _l, v in parse_tlv(value): + if t in ENVELOPE_TYPES: + sub = _decode_envelope(bytes([t, len(v)]) + v) + sub['encapsulated'] = True + result['encapsulated'] = sub + elif (t & 0x7F) == _P_DEVICE_IDS: + d = _decode_device_ids(v) + if d: + result['device_ids'] = d + return result + + inner = parse_tlv(value) + raw_tlv = [] + for t, _l, v in inner: + base = t & 0x7F + if base == _P_DEVICE_IDS: # common to every envelope type + d = _decode_device_ids(v) + if d: + result['device_ids'] = d + continue + if tag == 0xD6: # EVENT DOWNLOAD + if base == _P_EVENT_LIST and v: result['events'] = [EVENT_TYPES.get(e, f'0x{e:02X}') for e in v] - elif t == 0x1B: # Location status + elif base == _P_LOCATION_STATUS: if v: result['location_status'] = LOCATION_STATUS.get(v[0], f'0x{v[0]:02X}') - elif t == 0x13: # Location information + elif base == _P_LOCAL_INFO: li = _decode_local_info(PLI_LOCATION_INFO, v) result['location_info'] = li['value'] if li else v.hex().upper() - elif t in (0x06, 0x86): # Address (caller's number, MT call) + elif base == _P_ADDRESS and v: # caller's number (MT call) result['caller'] = _decode_bcd_address(v) - elif t == 0x1C: # Transaction identifier (MT call) + elif base == _P_TRANSACTION_ID: if v: result['transaction_id'] = v[0] - elif t in (0x08, 0x88): # Subaddress + elif base == _P_SUBADDRESS: result['subaddress'] = v.hex().upper() - elif t == 0x02: # Device identities - result['device_ids'] = v.hex().upper() - elif tag == 0xD5: # MO SHORT MESSAGE CONTROL - for t, _l, v in inner: - if t == 0x06: # Address objects + else: + raw_tlv.append({'tag': f'{base:02X}', 'value': v.hex().upper()}) + elif tag == 0xD5: # MO SHORT MESSAGE CONTROL + if base == _P_ADDRESS and v: if 'smsc' not in result: result['smsc'] = _decode_bcd_address(v) else: result['tp_da'] = _decode_bcd_address(v) - elif t == 0x13: + elif base == _P_LOCAL_INFO: result['location_info'] = v.hex().upper() - elif t == 0x02: - result['device_ids'] = v.hex().upper() - elif tag == 0xD1: # SMS-PP DOWNLOAD - for t, _l, v in inner: - if t == 0x06: + else: + raw_tlv.append({'tag': f'{base:02X}', 'value': v.hex().upper()}) + elif tag == 0xD1: # SMS-PP DOWNLOAD + if base == _P_ADDRESS and v: result['smsc'] = _decode_bcd_address(v) - elif t in (0x0B, 0x8B): # SMS TPDU (SMS-DELIVER) + elif base == _P_SMS_TPDU and v: result['tpdu'] = _decode_sm_tpdu(v) - elif t == 0x02: - result['device_ids'] = v.hex().upper() - elif tag == 0xD2: # CELL BROADCAST DOWNLOAD - for t, _l, v in inner: - if t == 0x02: - result['device_ids'] = v.hex().upper() - elif t in (0x0C, 0x8C): # CB page + else: + raw_tlv.append({'tag': f'{base:02X}', 'value': v.hex().upper()}) + elif tag == 0xD2: # CELL BROADCAST DOWNLOAD + if base == _P_CB_PAGE and v: result['cb_page'] = _decode_cb_page(v) - else: # D3 / D4 / D7 — device identities only - for t, _l, v in inner: - if t == 0x02: - result['device_ids'] = v.hex().upper() + else: + raw_tlv.append({'tag': f'{base:02X}', 'value': v.hex().upper()}) + elif tag == 0xD3: # MENU SELECTION + if base == _P_ITEM_ID and v: + result['item_id'] = v[0] + elif base == _P_HELP_REQUEST: # TS 101 220: '15'/'95', length 00 + result['help'] = True + else: + raw_tlv.append({'tag': f'{base:02X}', 'value': v.hex().upper()}) + elif tag == 0xD4: # CALL CONTROL + if base == _P_ADDRESS and v: + result['address'] = _decode_bcd_address(v) + elif base == _P_CCP and v: + result['ccp'] = v.hex().upper() + elif base == _P_SUBADDRESS and v: + result['subaddress'] = v.hex().upper() + else: + raw_tlv.append({'tag': f'{base:02X}', 'value': v.hex().upper()}) + elif tag == 0xD7: # TIMER EXPIRATION + if base == _P_TIMER_ID and v: + result['timer_id'] = v[0] if len(v) == 1 else v.hex().upper() + elif base == _P_TIMER_VALUE: + tv = _decode_timer_value(v) + if tv: + result['timer_value'] = tv + else: + raw_tlv.append({'tag': f'{base:02X}', 'value': v.hex().upper()}) + elif tag == 0xD9: # USSD DOWNLOAD + if base == _P_USSD_STRING and v: + result['ussd'] = _decode_dcs_text(v) + else: + raw_tlv.append({'tag': f'{base:02X}', 'value': v.hex().upper()}) + elif tag == 0xDD: # GEOGRAPHICAL LOCATION REPORTING + if base == _P_LOCAL_INFO and v: + li = _decode_local_info(PLI_LOCATION_INFO, v) + result['location_info'] = li['value'] if li else v.hex().upper() + else: + raw_tlv.append({'tag': f'{base:02X}', 'value': v.hex().upper()}) + else: # DA/DB/DC/DF/E0/… — keep objects visible as hex + raw_tlv.append({'tag': f'{base:02X}', 'value': v.hex().upper()}) + if raw_tlv: + result['raw_tlv'] = raw_tlv return result @@ -2254,6 +2369,24 @@ _P_RESPONSE_LEN = 0x11 _P_EVENT_LIST = 0x19 _P_ICON_ID = 0x1E _P_AID = 0x2F +# TS 101 220 table (COMPREHENSION-TLV tags, base values; CR form = +0x80) +_P_CCP = 0x07 +_P_SUBADDRESS = 0x08 +_P_USSD_STRING = 0x0A +_P_CB_PAGE = 0x0C +_P_LOCAL_INFO = 0x13 +_P_HELP_REQUEST = 0x15 +_P_ITEM_ID = 0x10 +_P_LOCATION_STATUS = 0x1B +_P_TRANSACTION_ID = 0x1C +_P_TIMER_ID = 0x24 +_P_TIMER_VALUE = 0x25 + +# Commands whose payload is defined as a Text String (GSM 11.14 / TS 102 223): +# DISPLAY TEXT, GET INKEY, GET INPUT, SET UP IDLE MODE TEXT. Used for a +# guarded fallback against non-compliant cards that send the Text String +# with the next object's tag ('10', Item identifier). +_TEXT_STRING_COMMANDS = {0x21, 0x22, 0x23, 0x28} def _decode_proactive(body): @@ -2278,6 +2411,8 @@ def _decode_proactive(body): items = [] file_list = [] aid = None + raw_tlv = [] + text_seen = False for tag, _length, value in inner: base = tag & 0x7F if base == _P_CMD_DETAILS and len(value) >= 2: @@ -2288,6 +2423,7 @@ def _decode_proactive(body): result['title'] = _decode_annex_a(value) elif base == _P_TEXT_STRING: result['text'] = _decode_dcs_text(value) + text_seen = True elif base == _P_ITEM and len(value) >= 2: items.append({'id': value[0], 'text': _decode_annex_a(value[1:])}) elif base == _P_SMS_TPDU and value: @@ -2302,14 +2438,32 @@ def _decode_proactive(body): result['address'] = _decode_bcd_address(value) elif base == _P_ICON_ID and value: result['icon_id'] = value[0] + elif base == _P_ITEM_ID and len(value) == 1: + result['item_id'] = value[0] + elif base == _P_ITEM_ID and len(value) >= 2 and cmd_type in _TEXT_STRING_COMMANDS \ + and not text_seen: + # Non-compliant card quirk (observed on a real SAT SIM): the + # Text String carries the Item identifier tag '10' instead of + # '8D'. A genuine Item identifier is one byte, so length plus + # command context disambiguate the two. + result['text'] = _decode_dcs_text(value) + result['text_note'] = "text string with non-standard tag '10'" + text_seen = True elif base == _P_FILE_LIST and value: file_list = [value[i:i + 2].hex().upper() for i in range(0, len(value), 2)] elif base == _P_AID and value: aid = value.hex().upper() + elif base == _P_DEVICE_IDS and len(value) >= 2: + result['device_ids'] = _decode_device_ids(value) + else: + raw_tlv.append({'tag': f'{base:02X}', 'value': value.hex().upper()}) if cmd_type is None: return None - result['type'] = CAT_COMMAND_TYPES.get(cmd_type, f'0x{cmd_type:02X}') + if 0xF0 <= cmd_type <= 0xFE: # TS 102 223 §9.4 — reserved for proprietary use + result['type'] = f'Proprietary ({cmd_type:#04x})' + else: + result['type'] = CAT_COMMAND_TYPES.get(cmd_type, f'0x{cmd_type:02X}') qualifier_desc = _command_qualifier(cmd_type, qualifier) if qualifier_desc: result['qualifier'] = qualifier_desc @@ -2319,6 +2473,8 @@ def _decode_proactive(body): result['aid'] = aid if items: result['items'] = items + if raw_tlv: + result['raw_tlv'] = raw_tlv return result diff --git a/tests/test_decode.py b/tests/test_decode.py index 3f48a62..2e107fa 100644 --- a/tests/test_decode.py +++ b/tests/test_decode.py @@ -101,7 +101,7 @@ class TestCatDecoding(unittest.TestCase): self.assertEqual(r['cat_command'], 'SMS-PP DOWNLOAD') cmd = r['cmd'] self.assertEqual(cmd['smsc'], '+79043490004') - self.assertEqual(cmd['device_ids'], '8381') + self.assertEqual(cmd['device_ids'], {'src': 'Network', 'dst': 'UICC'}) t = cmd['tpdu'] self.assertEqual(t['mti'], 'SMS-DELIVER') self.assertEqual(t['oa'], '455') @@ -355,8 +355,8 @@ class TestTrResult(unittest.TestCase): def test_session_terminated(self): from simtrace2_pysniff.server.decode import _decode_tr_result - r = _decode_tr_result(bytes.fromhex('81030105000202828103010A')) - self.assertEqual(r['code'], '0x0A') + r = _decode_tr_result(bytes.fromhex('810301050002028281030110')) + self.assertEqual(r['code'], '0x10') self.assertEqual(r['name'], 'Proactive UICC session terminated by the user') @@ -1592,6 +1592,193 @@ class TestIdleModeText(unittest.TestCase): 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']) + + +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('D70C820282812401012503010203') + 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'}) + + class TestSwUiccSpecific(unittest.TestCase): """TS 102 221 UICC-specific SWs (tables 10.7-10.15).""" diff --git a/tests/test_pcap_import.py b/tests/test_pcap_import.py index cb5c6fc..4bc2e8e 100644 --- a/tests/test_pcap_import.py +++ b/tests/test_pcap_import.py @@ -197,7 +197,7 @@ class TestImportEndpoint(unittest.TestCase): class TestVersion(unittest.TestCase): def test_package_version(self): from simtrace2_pysniff import __version__ - self.assertEqual(__version__, '1.11.0') + self.assertEqual(__version__, '1.13.0') def test_status_includes_version(self): import os