diff --git a/frontend/index.html b/frontend/index.html index 7a203ab..0cd46e9 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1128,6 +1128,49 @@ const API = { function el(id) { return document.getElementById(id); } function $on(id, ev, fn) { el(id).addEventListener(ev, fn); } +function tpRows(tp) { + if (!tp) return ''; + const rows = []; + if (tp.device_class === 'modem') { + rows.push(['Device class', 'headless/modem — ' + (tp.class_reason || '')]); + } else if (tp.device_class === 'handset') { + rows.push(['Device class', 'handset — ' + (tp.class_reason || '')]); + } else { + rows.push(['Device class', 'not determined']); + } + const gen = []; + if (tp.hsdpa) gen.push('HSDPA (3G)'); + if (tp.eutran) gen.push('E-UTRAN (4G)'); + rows.push(['Radio', gen.length + ? 'declared: ' + gen.join(', ') + : 'no 3G/4G capability declared (under-declaration is common)']); + if (tp.utran_ps) rows.push(['UTRAN PS ext.', 'declared']); + if (tp.bip === true) { + let v = 'capable'; + if (tp.bip_channels != null) { + v += ' — ' + tp.bip_channels + ' channel' + (tp.bip_channels === 1 ? '' : 's'); + } + if (tp.bip_bearers && tp.bip_bearers.length) v += ' · ' + tp.bip_bearers.join(', '); + rows.push(['BIP', v]); + if (tp.bip_transports && tp.bip_transports.length) { + rows.push(['BIP transports', tp.bip_transports.join(', ')]); + } + } else if (tp.bip === false) { + rows.push(['BIP', 'not advertised (no OPEN CHANNEL)']); + } + if (tp.standards) rows.push(['Standards', tp.standards]); + const ui = []; + if (tp.soft_keys != null) ui.push('soft keys ' + tp.soft_keys); + if (tp.screen_h != null || tp.screen_w != null) { + ui.push('screen ' + (tp.screen_h || 0) + '×' + (tp.screen_w || 0) + ' chars'); + } + if (tp.markup && tp.markup.length) ui.push('markup ' + tp.markup.join('/')); + if (ui.length) rows.push(['UI', ui.join(' · ')]); + rows.push(['Profile', tp.length + ' bytes — ' + (tp.era || '')]); + return `Terminal profile` + + rows.map(([l, v]) => `${l}${v}`).join(''); +} + function responseRows(resp) { if (!resp) return ''; const rows = []; @@ -1694,6 +1737,7 @@ function showDetail(msg, entry) { ${body} ${cmdRows(decoded.cmd)} ${responseRows(decoded.response)} + ${tpRows(decoded.tp)} ${fileRows(decoded.file)} ${sw} ${decoded.errors ? `Errors${decoded.errors.join(', ')}` : ''} @@ -2637,7 +2681,7 @@ function onFilterChange() { } // ──────────────────── Versioning ──────────────────── -const PWA_VERSION = '1.22.20'; +const PWA_VERSION = '1.22.21'; const MIN_SERVER_VERSION = '1.0.0'; function parseVersion(v) { diff --git a/frontend/sw.js b/frontend/sw.js index cceb82d..7be20b2 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simtrace-analyser-v52'; +const CACHE = 'simtrace-analyser-v53'; const PRECACHE = [ 'index.html', diff --git a/pyproject.toml b/pyproject.toml index e8082b8..b7b4be4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.backends._legacy:_Backend" [project] name = "simtrace2-pysniff" -version = "1.22.20" +version = "1.22.21" 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 d578d92..317d5e8 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.22.20' +__version__ = '1.22.21' __all__ = [ 'SniffSession', 'DeviceDisconnected', diff --git a/simtrace2_pysniff/server/decode.py b/simtrace2_pysniff/server/decode.py index 928a889..19ddb41 100644 --- a/simtrace2_pysniff/server/decode.py +++ b/simtrace2_pysniff/server/decode.py @@ -885,7 +885,7 @@ APDU_SPEC = { 0x10: { 'name': 'TERMINAL PROFILE', 'p1p2': {'unused': True}, - 'body': {'label': 'TLV data'}, + 'body': {'label': 'Profile'}, }, 0x76: { 'name': 'SUSPEND UICC', @@ -2731,6 +2731,136 @@ def _decode_proactive(body): return result +# ──────────────────── TERMINAL PROFILE device characteristics ──────────────────── + +def _decode_terminal_profile(data): + """Decode a TERMINAL PROFILE (TS 102 223 §5.2) into device facts. + + Bit numbering per the spec tables: b1 = LSB = first row, so a facility + at bit b(n) has mask ``1 << (n - 1)``. Only *declared* capabilities are + reported — a clear bit is never proof of absence (real 3G devices such + as the Huawei E173 and Nokia E52 leave the HSDPA bit clear), so no + "2G" claim is derived from a zero byte 17. + + Inference rules and their caveats: projects/docs/TERMINAL_PROFILE.md. + """ + if not data: + return None + n = len(data) + + def byte(num): + return data[num - 1] if n >= num else None + + b4, b11, b12, b13 = byte(4), byte(11), byte(12), byte(13) + b14, b15, b17, b18 = byte(14), byte(15), byte(17), byte(18) + b21, b22 = byte(21), byte(22) + + result = {'length': n} + + # --- BIP: byte 12 b1 OPEN CHANNEL; byte 13 bearers + channel count --- + if b12 is not None: + result['bip'] = bool(b12 & 0x01) + if b13 is not None: + result['bip_channels'] = (b13 >> 5) & 0x07 + result['bip_bearers'] = [name for mask, name in ( + (0x01, 'CSD'), (0x02, 'GPRS'), (0x04, 'Bluetooth'), + (0x08, 'IrDA'), (0x10, 'RS232')) if b13 & mask] + + # --- Radio generation: byte 17 b8 HSDPA (3G), b7 E-UTRAN (4G) --- + if b17 is not None: + hsdpa = bool(b17 & 0x80) + eutran = bool(b17 & 0x40) + result['hsdpa'] = hsdpa + result['eutran'] = eutran + if hsdpa and eutran: + result['generation'] = '3G/4G' + elif eutran: + result['generation'] = '4G' + elif hsdpa: + result['generation'] = '3G' + result['bip_transports'] = [name for mask, name in ( + (0x01, 'TCP client, remote'), (0x02, 'UDP client, remote'), + (0x04, 'TCP server'), (0x08, 'TCP client, local'), + (0x10, 'UDP client, local'), (0x20, 'direct channel')) + if b17 & mask] + if b22 is not None: + result['utran_ps'] = bool(b22 & 0x01) + + # --- Standards family: byte 18 b7 IMEISV (3GPP), b5 ESN (CDMA) --- + if b18 is not None: + imeisv = bool(b18 & 0x40) + esn = bool(b18 & 0x10) + result['imeisv'] = imeisv + result['esn'] = esn + if imeisv and esn: + result['standards'] = '3GPP + CDMA' + elif imeisv: + result['standards'] = '3GPP' + elif esn: + result['standards'] = 'CDMA (3GPP2)' + + # --- UI facts: byte 11 soft keys; bytes 14/15 screen; byte 21 markup --- + if b11 is not None: + result['soft_keys'] = b11 + if b14 is not None: + result['screen_h'] = b14 & 0x1F + if b15 is not None: + result['screen_w'] = b15 & 0x7F + if b21 is not None: + result['markup'] = [name for mask, name in ( + (0x01, 'WML'), (0x02, 'XHTML'), (0x04, 'HTML'), (0x08, 'CHTML')) + if b21 & mask] + + # --- Device class (strict rules, TERMINAL_PROFILE.md §4) --- + no_display = bool(b14 & 0x20) if b14 is not None else False # byte 14 b6 + no_keypad = bool(b14 & 0x40) if b14 is not None else False # byte 14 b7 + if no_display or no_keypad: + result['device_class'] = 'modem' + reasons = [name for name, set_ in ( + ('no display', no_display), ('no keypad', no_keypad)) if set_] + result['class_reason'] = 'declared: ' + ', '.join(reasons) + else: + screen_present = bool((result.get('screen_h') or 0) + or (result.get('screen_w') or 0)) + soft_keys = result.get('soft_keys') + menus = True + if b4 is not None: + menus = bool(b4 & 0x01) or bool(b4 & 0x20) # SELECT ITEM / SET UP MENU + if soft_keys == 0 and not screen_present and not menus: + result['device_class'] = 'modem' + result['class_reason'] = 'likely: no soft keys, no screen size, no menus' + elif screen_present and soft_keys not in (None, 0): + result['device_class'] = 'handset' + result['class_reason'] = (f"screen {result.get('screen_h') or 0}" + f"\u00d7{result.get('screen_w') or 0}" + f", soft keys {soft_keys}") + + # --- Era from the profile length (soft signal, TERMINAL_PROFILE.md §3) --- + if n <= 9: + result['era'] = 'early/minimal profile' + elif n <= 20: + result['era'] = 'classic 2G/3G-era profile' + elif n <= 28: + result['era'] = 'transitional-era profile' + elif n <= 32: + result['era'] = 'smartphone/BIP-era profile' + else: + result['era'] = 'modern LTE/5G-era profile' + + # --- Concise APDU-list description (replaces the raw hex) --- + parts = [] + if result.get('device_class'): + parts.append(result['device_class']) + if result.get('generation'): + parts.append(result['generation']) + if result.get('bip') is True: + parts.append('BIP') + elif result.get('bip') is False: + parts.append('no BIP') + result['summary'] = ' \u00b7 '.join(parts) if parts else f'{n}-byte profile' + return result + + # ──────────────────── Decode entry point ──────────────────── # ──────────────────── File data decoders (READ/UPDATE) ──────────────────── @@ -3728,6 +3858,10 @@ def _build_summary(result): txt += f' \u2014 {brief}' parts.append(txt) + tp = result.get('tp') + if tp and tp.get('summary'): + parts.append(tp['summary']) + cmd = result.get('cmd') if cmd: if cmd.get('context'): @@ -3914,6 +4048,10 @@ def decode_message(raw_data, prev=None): fcp = _decode_fcp(body) if fcp: result['response'] = fcp + elif ins == 0x10: # TERMINAL PROFILE → device characteristics + tp = _decode_terminal_profile(body) + if tp: + result['tp'] = tp # File data decode for READ/UPDATE using the current selection. p1p2 = result.get('p1p2') or {} diff --git a/tests/test_decode.py b/tests/test_decode.py index f92f968..252931e 100644 --- a/tests/test_decode.py +++ b/tests/test_decode.py @@ -1006,6 +1006,79 @@ class TestPacketSummaries(unittest.TestCase): self.assertIsNone(self._dec('010100', 'vcc').get('summary')) +class TestTerminalProfile(unittest.TestCase): + """TERMINAL PROFILE device characteristics (regression anchors from + projects/docs/TERMINAL_PROFILE.md §10).""" + + def _dec(self, profile_hex): + data = bytes.fromhex(profile_hex) + raw = '80100000%02x' % len(data) + profile_hex + '9000' + return decode_message(bytes.fromhex(raw)) + + def test_xiaomi_mi_a1(self): + r = self._dec('FFFFFFFF7F9F00DFFF03021FE2000000C3FB0007041178' + '00710100000038428003') + tp = r['tp'] + self.assertEqual(r['body']['label'], 'Profile') + self.assertTrue(tp['bip']) + self.assertEqual(tp['bip_channels'], 7) + self.assertEqual(tp['bip_bearers'], ['GPRS']) + self.assertEqual(tp['bip_transports'], + ['TCP client, remote', 'UDP client, remote']) + self.assertTrue(tp['hsdpa']) + self.assertTrue(tp['eutran']) + self.assertEqual(tp['generation'], '3G/4G') + self.assertTrue(tp['utran_ps']) + self.assertEqual(tp['standards'], '3GPP + CDMA') + self.assertEqual(tp['summary'], '3G/4G \u00b7 BIP') + self.assertEqual(r['summary'], '3G/4G \u00b7 BIP') + + def test_quectel_gsm_module(self): + r = self._dec('FFFFFFFF7F1F007FFF00001F230811060700') + tp = r['tp'] + self.assertTrue(tp['bip']) + self.assertEqual(tp['bip_channels'], 1) + self.assertEqual(tp['bip_bearers'], ['CSD', 'GPRS']) + self.assertNotIn('generation', tp) + self.assertEqual(tp['summary'], 'BIP') + + def test_huawei_e173_underdeclared(self): + # The E173 is a 3G modem yet leaves E-UTRAN/HSDPA clear — the TP + # must not be read as "2G". + r = self._dec('2965D7E11F90001FD50000000000000000E00000') + tp = r['tp'] + self.assertIs(tp['bip'], False) + self.assertNotIn('generation', tp) + self.assertEqual(tp['standards'], '3GPP') + self.assertEqual(tp['summary'], 'no BIP') + + def test_nokia_7210(self): + r = self._dec('FFFFFFFF7F3F00DFFF00001FE28A0D0203290000020040') + tp = r['tp'] + self.assertTrue(tp['bip']) + self.assertEqual(tp['bip_channels'], 7) + self.assertEqual(tp['screen_h'], 10) + self.assertEqual(tp['screen_w'], 13) + self.assertNotIn('device_class', tp) + self.assertEqual(tp['summary'], 'BIP') + + def test_declared_headless_modem(self): + # byte 14 b6 (0x20) = no display capability (class ND). + r = self._dec('FFFFFFFF7F9F00DFFF03021F00' + '20' + '00' * 6) + tp = r['tp'] + self.assertEqual(tp['device_class'], 'modem') + self.assertIn('declared', tp['class_reason']) + self.assertIn('no display', tp['class_reason']) + self.assertEqual(tp['summary'], 'modem \u00b7 BIP') + + def test_handset_class(self): + r = self._dec('FFFFFFFF7F9F00DFFF03021FE208110000') + tp = r['tp'] + self.assertEqual(tp['device_class'], 'handset') + self.assertIn('screen 8\u00d717', tp['class_reason']) + self.assertEqual(tp['summary'], 'handset \u00b7 BIP') + + class TestSummary(unittest.TestCase): def test_select_path_from_mf(self): r = decode_message(bytes.fromhex('a0a40804022fe29000')) diff --git a/tests/test_pcap_import.py b/tests/test_pcap_import.py index d782f98..8dc338f 100644 --- a/tests/test_pcap_import.py +++ b/tests/test_pcap_import.py @@ -224,7 +224,7 @@ class TestImportEndpoint(unittest.TestCase): class TestVersion(unittest.TestCase): def test_package_version(self): from simtrace2_pysniff import __version__ - self.assertEqual(__version__, '1.22.20') + self.assertEqual(__version__, '1.22.21') def test_status_includes_version(self): import os