From d0f8fb8f36a2d4ea6aa54a4772eceff2a1157caf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Sat, 22 Aug 2026 23:39:41 +0300 Subject: [PATCH] Decode command qualifiers for all CAT/SAT/USAT commands; bump to 1.14.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the spec audit: TS 102 223 §8.6 qualifier coverage for every command that defines a coding (verified against V18.3.0 text): - Value tables: SET UP CALL ('00-'05), LAUNCH BROWSER, GET READER STATUS, SET FRAMES, ENCAPSULATED SESSION CONTROL ('00-'04), LSI COMMAND ('00/'01). - Bitmask decoders (deviations from default shown; all-default hidden; pure-RFU falls back to raw hex): SEND SHORT MESSAGE packing bit, PLAY TONE vibrate, DISPLAY TEXT / DISPLAY MULTIMEDIA MESSAGE priority + clear-delay, GET INKEY (alphabet/UCS2/Yes-No/immediate- digit/help), GET INPUT (alphabet/UCS2/hidden/packed/help), SELECT ITEM (presentation type/soft key/help), SET UP MENU, TIMER MANAGEMENT (start/deactivate/get value), LANGUAGE NOTIFICATION, OPEN CHANNEL (link establishment/reconnection/background/DNS with bearer-dependent note), CLOSE CHANNEL (NAA reuse note), SEND DATA, DECLARE SERVICE. - RFU-byte commands (MORE TIME, POLL INTERVAL, SEND SS/USSD, ACTIVATE…) keep raw-hex fallback. Tests: +11 TestQualifiers cases — 317 total. Version 1.14.0, SW cache simtrace-analyser-v17. --- frontend/index.html | 2 +- frontend/sw.js | 2 +- pyproject.toml | 2 +- simtrace2_pysniff/__init__.py | 2 +- simtrace2_pysniff/server/decode.py | 163 +++++++++++++++++++++++++++++ tests/test_decode.py | 61 +++++++++++ tests/test_pcap_import.py | 2 +- 7 files changed, 229 insertions(+), 5 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 280fecd..92d0d2a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1868,7 +1868,7 @@ function onFilterChange() { } // ──────────────────── Versioning ──────────────────── -const PWA_VERSION = '1.13.0'; +const PWA_VERSION = '1.14.0'; const MIN_SERVER_VERSION = '1.0.0'; function parseVersion(v) { diff --git a/frontend/sw.js b/frontend/sw.js index f3c8a33..1bc7832 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simtrace-analyser-v16'; +const CACHE = 'simtrace-analyser-v17'; const PRECACHE = [ 'index.html', diff --git a/pyproject.toml b/pyproject.toml index 6223d36..c1207db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.backends._legacy:_Backend" [project] name = "simtrace2-pysniff" -version = "1.13.0" +version = "1.14.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 6c98ded..83c795f 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.13.0' +__version__ = '1.14.0' __all__ = [ 'SniffSession', 'DeviceDisconnected', diff --git a/simtrace2_pysniff/server/decode.py b/simtrace2_pysniff/server/decode.py index a41c0cd..7158be3 100644 --- a/simtrace2_pysniff/server/decode.py +++ b/simtrace2_pysniff/server/decode.py @@ -1085,6 +1085,169 @@ def _idle_text_qualifier(qualifier): COMMAND_QUALIFIERS[0x28] = _idle_text_qualifier +# ──────────────────── §8.6 bitmask qualifier decoders ──────────────────── +# Each decoder lists the *deviations from default*; an all-default (0x00) +# qualifier stays hidden, a non-zero qualifier with only RFU bits set +# falls back to raw hex. + +def _bitmask(fn): + def decoder(q): + val = fn(q) + parts = [val] if isinstance(val, str) else [p for p in val if p] + if parts: + return ', '.join(parts) + return None if q == 0 else f'0x{q:02X}' + return decoder + + +def _alphabet_bit(q): + return 'UCS2 alphabet requested' if q & 0x02 else None + + +def _help_bit(q): + return 'help information available' if q & 0x80 else None + + +@_bitmask +def _send_sm_qualifier(q): + return ['SMS packing by terminal required'] if q & 0x01 else [] + + +@_bitmask +def _play_tone_qualifier(q): + return ['vibrate alert if available'] if q & 0x01 else [] + + +@_bitmask +def _display_text_qualifier(q): + return [('high priority' if q & 0x01 else None), + ('wait for user to clear message' if q & 0x80 else None)] + + +@_bitmask +def _get_inkey_qualifier(q): + return [ + 'alphabet set requested' if q & 0x01 else None, + _alphabet_bit(q), + 'Yes/No response requested' if q & 0x04 else None, + 'immediate digit response requested' if q & 0x08 else None, + _help_bit(q), + ] + + +@_bitmask +def _get_input_qualifier(q): + return [ + 'alphabet set requested' if q & 0x01 else None, + _alphabet_bit(q), + 'input shall not be revealed' if q & 0x04 else None, + 'SMS packed format requested' if q & 0x08 else None, + _help_bit(q), + ] + + +@_bitmask +def _select_item_qualifier(q): + pres = [] + if q & 0x01: + pres.append('navigation options presentation' + if q & 0x02 else 'data values presentation') + return pres + ['selection using soft key preferred' if q & 0x04 else None, + _help_bit(q)] + + +@_bitmask +def _set_up_menu_qualifier(q): + return ['selection using soft key preferred' if q & 0x01 else None, + _help_bit(q)] + + +@_bitmask +def _timer_mgmt_qualifier(q): + op = (q >> 1) & 0x03 + return {1: 'deactivate timer', 2: 'get current timer value'}.get(op, []) + + +@_bitmask +def _language_notification_qualifier(q): + return ['specific language notification'] if q & 0x01 else [] + + +@_bitmask +def _open_channel_qualifier(q): + # Coding depends on the bearer; bits decoded per the packet-data / + # local bearer definition (TS 102 223 §8.6). + parts = [p for p in [ + 'immediate link establishment' if q & 0x01 else None, + 'automatic reconnection' if q & 0x02 else None, + 'background mode link establishment' if q & 0x04 else None, + 'DNS server address(es) requested' if q & 0x08 else None] if p] + return [p + ' (bearer-dependent)' for p in parts] + + +@_bitmask +def _close_channel_qualifier(q): + # Packet data service: b1 = reuse Network Access Name indication; + # other bearers code b1 differently — hence the note. + if q & 0x01: + return ['next OPEN CHANNEL reuses same NAA/bearer (bearer-dependent)'] + return [] + + +@_bitmask +def _send_data_qualifier(q): + return ['send data immediately'] if q & 0x01 else [] + + +@_bitmask +def _declare_service_qualifier(q): + return ['delete service from terminal database'] if q & 0x01 else [] + + +@_bitmask +def _display_mms_qualifier(q): + return [('high priority' if q & 0x01 else None), + ('wait for user to clear message' if q & 0x80 else None)] + + +COMMAND_QUALIFIERS.update({ + 0x10: {0x00: 'set up call, but only if not currently busy', + 0x01: 'set up call, not busy, with redial', + 0x02: 'set up call, holding all other calls', + 0x03: 'set up call, holding others, with redial', + 0x04: 'set up call, disconnecting all other calls (if any)', + 0x05: 'set up call, disconnecting others, with redial'}, + 0x13: _send_sm_qualifier, # SEND SHORT MESSAGE + 0x15: {0x00: 'launch browser if not already launched', + 0x02: 'use existing browser (no secured session)', + 0x03: 'close browser session and launch new'}, # LAUNCH BROWSER + 0x20: _play_tone_qualifier, # PLAY TONE + 0x21: _display_text_qualifier, # DISPLAY TEXT + 0x22: _get_inkey_qualifier, # GET INKEY + 0x23: _get_input_qualifier, # GET INPUT + 0x24: _select_item_qualifier, # SELECT ITEM + 0x25: _set_up_menu_qualifier, # SET UP MENU + 0x27: _timer_mgmt_qualifier, # TIMER MANAGEMENT + 0x33: {0x00: 'Card reader status', + 0x01: 'Card reader identifier'}, # GET READER STATUS + 0x35: _language_notification_qualifier, # LANGUAGE NOTIFICATION + 0x40: _open_channel_qualifier, # OPEN CHANNEL + 0x41: _close_channel_qualifier, # CLOSE CHANNEL + 0x43: _send_data_qualifier, # SEND DATA + 0x47: _declare_service_qualifier, # DECLARE SERVICE + 0x50: {0x00: 'draw separator between adjoining frames', + 0x01: 'no separator between frames'}, # SET FRAMES + 0x62: _display_mms_qualifier, # DISPLAY MULTIMEDIA MESSAGE + 0x73: {0x00: 'end encapsulated command session', + 0x01: 'request Master SA setup', + 0x02: 'request Connection SA setup', + 0x03: 'request Secure Channel Start', + 0x04: 'close Master/Connection SA, keep session'}, # eCAT + 0x79: {0x00: 'Proactive Session Request', + 0x01: 'UICC Platform Reset'}, # LSI COMMAND +}) + + def _command_qualifier(cmd_type, qualifier): """Decode a Command Qualifier (Command Details byte 3) into a string. diff --git a/tests/test_decode.py b/tests/test_decode.py index 2e107fa..16f5d63 100644 --- a/tests/test_decode.py +++ b/tests/test_decode.py @@ -1779,6 +1779,67 @@ class TestEnvelopeDecoders(unittest.TestCase): {'src': 'Network', 'dst': 'UICC'}) +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): + self.assertEqual(self._fetch_qual(0x27, 0x02), 'deactivate timer') + self.assertEqual(self._fetch_qual(0x27, 0x04), + 'get current timer value') + self.assertIsNone(self._fetch_qual(0x27, 0x00)) # start = default + + 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)) + + 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 4bc2e8e..d42cde1 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.13.0') + self.assertEqual(__version__, '1.14.0') def test_status_includes_version(self): import os