diff --git a/README.md b/README.md index d5b9282..33cfd6f 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,10 @@ GSMTAP into this analyser. 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 +- TERMINAL CAPABILITY (TS 102 221 §11.1.19) — terminal power supply + (voltage class, maximum current, clock), extended logical channels, + additional interfaces (UICC-CLF) and eUICC device capabilities + (SGP.22/SGP.32) - 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/README_RUS.md b/README_RUS.md index d1b33f5..64646a8 100644 --- a/README_RUS.md +++ b/README_RUS.md @@ -40,6 +40,10 @@ SIM/UICC из UDP-потока GSMTAP (или импортирует файлы proactive-команды с декодированными квалификаторами, TERMINAL RESPONSE, ENVELOPE (Menu Selection, Call Control, SMS-PP download…); учитываются известные несоответствия старых карт, нераспознанные TLV сохраняются +- TERMINAL CAPABILITY (TS 102 221 §11.1.19) — питание терминала (класс + напряжения, максимальный ток, тактовая частота), расширенные логические + каналы, дополнительные интерфейсы (UICC-CLF) и возможности eUICC + (SGP.22/SGP.32) - SMS TPDU — SMS-DELIVER/SUBMIT, метки времени SCTS, информационные элементы UDH - SCP80 OTA secured packets — биты SPI (шифрование, требование/режим PoR, diff --git a/frontend/index.html b/frontend/index.html index cc93f8b..6fbc192 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1300,6 +1300,31 @@ function cmdRows(cmd) { rows.push(['Encapsulated', cmd.encapsulated.type]); } if (cmd.text_note) rows.push(['Text note', cmd.text_note]); + + // TERMINAL CAPABILITY (TS 102 221 §11.1.19) + if (cmd.power_supply) { + const ps = cmd.power_supply; + const bits = []; + bits.push(ps.classes && ps.classes.length + ? 'class ' + ps.classes.join('+') + : 'class 0x' + hx(ps.class_raw || 0, 2)); + if (ps.max_current_ma !== undefined) bits.push(ps.max_current_ma + ' mA'); + if (ps.clock_mhz !== undefined && ps.clock_mhz !== null) bits.push(ps.clock_mhz + ' MHz clock'); + else if (ps.clock_raw !== undefined) bits.push('clock not indicated'); + rows.push(['Power supply', bits.join(', ')]); + } + if (cmd.extended_channels) rows.push(['Logical channels', 'extended support']); + if (cmd.extended_channels_raw) rows.push(['Logical channels raw', cmd.extended_channels_raw]); + if (cmd.interfaces) rows.push(['Additional interfaces', + cmd.interfaces.length ? cmd.interfaces.join(', ') : 'none']); + if (cmd.interfaces_raw) rows.push(['Interfaces raw', cmd.interfaces_raw]); + if (cmd.euicc_sgp22) { + const e = cmd.euicc_sgp22; + const names = (e.functions || []).join(', '); + rows.push(['eUICC (SGP.22)', names ? names + ' \u00b7 0x' + e.raw : '0x' + e.raw]); + } + if (cmd.euicc_sgp32) rows.push(['eUICC (SGP.32)', '0x' + (cmd.euicc_sgp32.raw || '')]); + if (cmd.raw_tlv) { for (const t of cmd.raw_tlv) rows.push(['TLV 0x' + t.tag, t.value || '(empty)']); } @@ -2681,7 +2706,7 @@ function onFilterChange() { } // ──────────────────── Versioning ──────────────────── -const PWA_VERSION = '2.0.0'; +const PWA_VERSION = '2.1.0'; const MIN_SERVER_VERSION = '1.0.0'; function parseVersion(v) { diff --git a/frontend/sw.js b/frontend/sw.js index 74ec633..547250b 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simtrace-analyser-v54'; +const CACHE = 'simtrace-analyser-v55'; const PRECACHE = [ 'index.html', diff --git a/pyproject.toml b/pyproject.toml index 34bfb9b..967b6ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.backends._legacy:_Backend" [project] name = "simtrace-analyser" -version = "2.0.0" +version = "2.1.0" description = "SIMtrace Analyser — APDU trace analyser for GSMTAP/PCAP captures (bundles the simtrace2-pysniff utility)" readme = {file = "README.md", content-type = "text/markdown"} license = {text = "GPL-2.0-or-later"} diff --git a/simtrace_analyser/decode.py b/simtrace_analyser/decode.py index 8fcdd84..bedc37c 100644 --- a/simtrace_analyser/decode.py +++ b/simtrace_analyser/decode.py @@ -834,7 +834,7 @@ APDU_SPEC = { 0xAA: { 'name': 'TERMINAL CAPABILITY', 'p1p2': {'unused': True}, - 'body': {'label': 'TLV data'}, + 'body': {'label': 'Terminal capability template (A9)'}, }, 0x73: { 'name': 'MANAGE SECURE CHANNEL', @@ -2861,6 +2861,86 @@ def _decode_terminal_profile(data): return result +# ──────────────────── TERMINAL CAPABILITY (TS 102 221 §11.1.19) ──────────────────── + +# The command data is the constructed terminal capability template (tag 'A9'). +# Supply voltage class coding per Table 6.1 (same as the ATR class indication); +# the eUICC tag '83' bit meanings per GSMA SGP.22 v2.6 §3.4.2 Table 7. +_TC_SUPPLY_CLASSES = ( + (0x01, 'A'), + (0x02, 'B'), + (0x04, 'C'), + (0x08, 'D'), + (0x10, 'E'), +) + +_TC_EUICC_SGP22_BITS = ( + (0x01, 'LUId'), + (0x02, 'LPDd'), + (0x04, 'LDSd'), + (0x08, 'LUIe (SCWS)'), +) + + +def _decode_terminal_capability(data): + """Decode a TERMINAL CAPABILITY command body (TS 102 221 §11.1.19). + + TLV objects inside the template (tag 'A9'): + '80' terminal power supply, '81' extended logical channels terminal + support, '82' additional interfaces support, '83'/'84' eUICC-related + device capabilities (GSMA SGP.22 / SGP.32). Unknown and private TLV + objects are preserved as ``raw_tlv``. + """ + inner = None + for tag, _length, value in parse_tlv(data): + if tag == 0xA9: + inner = value + break + if inner is None: + return None + + result = {} + raw_tlv = [] + for tag, _length, value in parse_tlv(inner): + if tag == 0x80: # terminal power supply (§11.1.19.2.1) + supply = { + 'class_raw': value[0] if value else None, + 'classes': [name for bit, name in _TC_SUPPLY_CLASSES + if value and value[0] & bit], + } + if len(value) >= 2: + supply['max_current_ma'] = value[1] + if len(value) >= 3: + supply['clock_raw'] = value[2] + supply['clock_mhz'] = (None if value[2] == 0xFF + else round(value[2] / 10, 1)) + result['power_supply'] = supply + elif tag == 0x81: # extended logical channels (§11.1.19.2.2) + # Only zero length is defined; a UICC treats any length as zero. + result['extended_channels'] = True + if value: + result['extended_channels_raw'] = value.hex().upper() + elif tag == 0x82: # additional interfaces support (§11.1.19.2.3) + if value: + result['interfaces'] = ['UICC-CLF'] if value[0] & 0x01 else [] + if len(value) > 1: # extra bytes are ignored by the UICC + result['interfaces_raw'] = value.hex().upper() + elif tag == 0x83: # eUICC indications — GSMA SGP.22 (§11.1.19.2.4) + caps = {'raw': value.hex().upper() if value else ''} + if value: + caps['functions'] = [name for bit, name in _TC_EUICC_SGP22_BITS + if value[0] & bit] + result['euicc_sgp22'] = caps + elif tag == 0x84: # eUICC indications — GSMA SGP.32 (raw, no pinned spec) + result['euicc_sgp32'] = {'raw': value.hex().upper() if value else ''} + else: + raw_tlv.append({'tag': f'{tag:02X}', 'value': value.hex().upper()}) + + if raw_tlv: + result['raw_tlv'] = raw_tlv + return result or None + + # ──────────────────── Decode entry point ──────────────────── # ──────────────────── File data decoders (READ/UPDATE) ──────────────────── @@ -3906,6 +3986,34 @@ def _build_summary(result): s += f" \u00ab{tpdu['text']}\u00bb" parts.append(s) + supply = cmd.get('power_supply') + interfaces = cmd.get('interfaces') + sgp22 = cmd.get('euicc_sgp22') + sgp32 = cmd.get('euicc_sgp32') + if supply or cmd.get('extended_channels') or interfaces or sgp22 or sgp32: + tc = [] + if supply: + if supply.get('classes'): + tc.append('Class ' + '+'.join(supply['classes'])) + if supply.get('max_current_ma') is not None: + tc.append(f"{supply['max_current_ma']} mA") + if supply.get('clock_mhz') is not None: + tc.append(f"CLK {supply['clock_mhz']:g} MHz") + elif supply.get('clock_raw') is not None: + tc.append('CLK n/a') + if cmd.get('extended_channels'): + tc.append('extended channels') + if interfaces: + tc.append(', '.join(interfaces)) + if sgp22: + funcs = sgp22.get('functions') or [] + tc.append('eUICC: ' + (', '.join(funcs) if funcs + else '0x' + sgp22.get('raw', ''))) + if sgp32: + tc.append('eUICC SGP.32: 0x' + sgp32.get('raw', '')) + if tc: + parts.append(' \u00b7 '.join(tc)) + if result.get('response_to'): parts.append('\u2192 ' + result['response_to']) response = result.get('response') @@ -4052,6 +4160,10 @@ def decode_message(raw_data, prev=None): tp = _decode_terminal_profile(body) if tp: result['tp'] = tp + elif ins == 0xAA: # TERMINAL CAPABILITY → terminal capability template + caps = _decode_terminal_capability(body) + if caps: + result['cmd'] = caps # File data decode for READ/UPDATE using the current selection. p1p2 = result.get('p1p2') or {} diff --git a/simtrace_analyser/version.py b/simtrace_analyser/version.py index c35e0e1..9a35ae8 100644 --- a/simtrace_analyser/version.py +++ b/simtrace_analyser/version.py @@ -1,3 +1,3 @@ """Single source of truth for the SIMtrace Analyser version.""" -__version__ = '2.0.0' +__version__ = '2.1.0' diff --git a/tests/test_decode.py b/tests/test_decode.py index ddffee6..85d4a07 100644 --- a/tests/test_decode.py +++ b/tests/test_decode.py @@ -1079,6 +1079,65 @@ class TestTerminalProfile(unittest.TestCase): self.assertEqual(tp['summary'], 'handset \u00b7 BIP') +class TestTerminalCapability(unittest.TestCase): + """TERMINAL CAPABILITY (TS 102 221 §11.1.19) — real samples captured + from the local trace sessions (49/55/58/61/206/208).""" + + def _dec(self, body_hex): + raw = '80AA0000%02x%s9000' % (len(body_hex) // 2, body_hex) + return decode_message(bytes.fromhex(raw)) + + def test_power_supply(self): + # A9 0A 80 03 04 3C FF 81 00 82 01 00 — class C, 60 mA, clock n/a. + r = self._dec('A90A8003043CFF8100820100') + self.assertEqual(r['body']['label'], 'Terminal capability template (A9)') + ps = r['cmd']['power_supply'] + self.assertEqual(ps['classes'], ['C']) + self.assertEqual(ps['class_raw'], 0x04) + self.assertEqual(ps['max_current_ma'], 60) + self.assertIsNone(ps['clock_mhz']) + self.assertTrue(r['cmd']['extended_channels']) + self.assertEqual(r['cmd']['interfaces'], []) + self.assertEqual(r['summary'], + 'Class C \u00b7 60 mA \u00b7 CLK n/a \u00b7 extended channels') + + def test_interfaces_and_euicc(self): + # A9 08 81 00 82 01 01 83 01 87 — UICC-CLF + SGP.22 LPA functions + # (b8 is an RFU bit and must not affect the function list). + r = self._dec('A9088100820101830187') + cmd = r['cmd'] + self.assertNotIn('power_supply', cmd) + self.assertEqual(cmd['interfaces'], ['UICC-CLF']) + self.assertEqual(cmd['euicc_sgp22']['raw'], '87') + self.assertEqual(cmd['euicc_sgp22']['functions'], ['LUId', 'LPDd', 'LDSd']) + self.assertEqual(r['summary'], + 'extended channels \u00b7 UICC-CLF \u00b7 ' + 'eUICC: LUId, LPDd, LDSd') + + def test_interfaces_only(self): + # A9 05 81 00 82 01 01 — phones that omit the power supply TLV. + r = self._dec('A9058100820101') + self.assertNotIn('power_supply', r['cmd']) + self.assertEqual(r['summary'], 'extended channels \u00b7 UICC-CLF') + + def test_extended_channels_nonzero_length(self): + # A UICC shall interpret any length as zero (TS 102 221 §11.1.19.2.2). + r = self._dec('A906810100820101') + self.assertTrue(r['cmd']['extended_channels']) + self.assertEqual(r['cmd']['extended_channels_raw'], '00') + + def test_private_tlv_preserved(self): + # Unknown/private TLV ('DD') inside the A9 template must survive. + r = self._dec('A9088100820101DD017F') + self.assertEqual(r['cmd']['raw_tlv'], [{'tag': 'DD', 'value': '7F'}]) + self.assertEqual(r['summary'], 'extended channels \u00b7 UICC-CLF') + + def test_no_a9_template(self): + # Command data without the A9 template stays undecoded (no crash). + r = self._dec('8003043CFF') + self.assertNotIn('cmd', r) + + class TestSummary(unittest.TestCase): def test_select_path_from_mf(self): r = decode_message(bytes.fromhex('a0a40804022fe29000')) diff --git a/tests/test_pcap_import.py b/tests/test_pcap_import.py index cc09f94..8c4dedb 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__, '2.0.0') + self.assertEqual(__version__, '2.1.0') def test_status_includes_version(self): import os