diff --git a/frontend/index.html b/frontend/index.html index 5bbbc7d..209d2c7 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1881,7 +1881,7 @@ function onFilterChange() { } // ──────────────────── Versioning ──────────────────── -const PWA_VERSION = '1.15.1'; +const PWA_VERSION = '1.16.0'; const MIN_SERVER_VERSION = '1.0.0'; function parseVersion(v) { diff --git a/frontend/sw.js b/frontend/sw.js index 2c3a8eb..d5f8cd5 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simtrace-analyser-v21'; +const CACHE = 'simtrace-analyser-v22'; const PRECACHE = [ 'index.html', diff --git a/pyproject.toml b/pyproject.toml index 6ae3e51..2b20a1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.backends._legacy:_Backend" [project] name = "simtrace2-pysniff" -version = "1.15.1" +version = "1.16.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 fc0124e..2682846 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.15.1' +__version__ = '1.16.0' __all__ = [ 'SniffSession', 'DeviceDisconnected', diff --git a/simtrace2_pysniff/server/decode.py b/simtrace2_pysniff/server/decode.py index 95a7f26..6e06b7d 100644 --- a/simtrace2_pysniff/server/decode.py +++ b/simtrace2_pysniff/server/decode.py @@ -906,6 +906,65 @@ APDU_SPEC = { 'p1p2': {'unused': True}, 'body': {'label': 'TLV data'}, }, + 0xD0: { + 'name': 'WRITE BINARY', + 'p1p2': {'fmt': 'uint16be', 'label': 'Offset'}, + 'body': {'label': 'Data'}, + }, + 0xD1: { + 'name': 'WRITE BINARY (odd INS)', + 'p1p2': {'fmt': 'uint16be', 'label': 'Offset'}, + 'body': {'label': 'BER-TLV data'}, + }, + 0xA0: { + 'name': 'SEARCH BINARY', + 'p1p2': {'fmt': 'uint16be', 'label': 'Offset'}, + 'body': {'label': 'Search parameters'}, + }, + 0xB3: { + 'name': 'READ RECORD (odd INS)', + 'le': True, + 'p1': {'fmt': 'uint8', 'label': 'Record number'}, + 'p2': { + 'label': 'Mode', + 'bits': { + 0x02: 'next record', + 0x03: 'previous record', + 0x04: 'absolute mode (record number in P1)', + }, + }, + }, + 0xC3: { + 'name': 'ENVELOPE (odd INS)', + 'body': {'label': 'BER-TLV data'}, + }, + 0x7A: { + 'name': 'EXCHANGE CAPABILITIES', + 'p1p2': {'unused': True}, + 'body': {'label': 'Capability list'}, + }, + 0xD4: { + 'name': 'RESIZE FILE', + 'p1p2': {'unused': True}, + 'body': {'label': 'BER-TLV data'}, + }, + 0x0F: { + 'name': 'ERASE BINARY (odd INS)', + 'p1p2': {'fmt': 'uint16be', 'label': 'Offset'}, + 'body': {'label': 'BER-TLV data'}, + }, + 0x2A: { + 'name': 'PERFORM SECURITY OPERATION', + 'p1': {'label': 'Operation'}, + 'p2': {'label': 'Operation'}, + 'body': {'label': 'Command data'}, + }, + 0x46: { + 'name': 'GENERATE ASYMMETRIC KEY PAIR', + 'p1': {0x00: 'new key', 0x80: 'generate/derive', 0x81: 'read public key'}, + 'p2': {0x00: 'No indication'}, + 'body': {'label': 'BER-TLV data'}, + }, } @@ -1530,10 +1589,13 @@ _FILE_TYPES = { 0b111: 'DF or ADF', } _EF_STRUCTURES = { + # TS 102 221 Table 11.5 (overrides ISO 7816-4 Table 14, where 110 = + # cyclic and 100 = linear variable): 100 = cyclic, 110 = BER-TLV. 0b000: 'no information', 0b001: 'transparent', 0b010: 'linear fixed', - 0b110: 'cyclic', + 0b100: 'cyclic', + 0b110: 'BER-TLV', } _LIFE_CYCLE = { 0x00: 'no information given', @@ -1551,7 +1613,7 @@ def _decode_file_descriptor(value): if not value: return {} b = value[0] - ft = (b >> 3) & 0x07 + ft = (b >> 3) & 0x07 # bits b6-b4 = file type (1-based; 0-based 5..3) result = { 'shareable': 'shareable' if (b & 0x40) else 'not shareable', 'file_type': _FILE_TYPES.get(ft, 'RFU'), @@ -1603,8 +1665,16 @@ def _decode_fcp(data): result['sfi'] = value[0] >> 3 if value else None elif tag == 0x8A: result['life_cycle'] = _LIFE_CYCLE.get(value[0] if value else 0, f'0x{(value[0] if value else 0):02X}') + elif tag == 0x86: + result['security_attr_proprietary'] = value.hex().upper() + elif tag == 0x87: + result['extending_ef_id'] = value.hex().upper() + elif tag == 0x8B: + result['security_attr_expanded'] = value.hex().upper() + elif tag == 0x8C: + result['security_attr_compact'] = value.hex().upper() elif tag == 0xAB: - result['short_ef_id'] = value.hex().upper() + result['security_attr_template'] = value.hex().upper() if not sfi_seen and fid: sfi = int(fid, 16) & 0x1F result['sfi'] = sfi if 1 <= sfi <= 30 else None @@ -3044,16 +3114,148 @@ def _decode_cbmi(raw, p1=None): def _decode_cbmir(raw, p1=None): - if len(raw) >= 4: - low = (raw[0] << 8) | raw[1] - high = (raw[2] << 8) | raw[3] - return {'range': [low, high]} - return {'raw': raw.hex().upper()} + """EF_CBMIR (§10.3.28): 4-byte ranges [msg-id low(2) + high(2)], 4n B.""" + ranges = [] + for i in range(0, len(raw) - 3, 4): + rec = raw[i:i + 4] + if all(b == 0xff for b in rec): + continue + low = (rec[0] << 8) | rec[1] + high = (rec[2] << 8) | rec[3] + ranges.append([low, high]) + if not ranges: + return {'raw': raw.hex().upper()} + return {'ranges': ranges if len(ranges) > 1 else ranges[0]} def _decode_ecc(raw, p1=None): - txt = _decode_annex_a(raw.rstrip(b'\xff')) - return {'number': txt} if txt else {'raw': raw.hex().upper()} + """EF_ECC records (UICC_FILES §10.3.27): ECC digits BCD(2) + ESC(1).""" + if all(b == 0xff for b in raw): + return {'empty': True} + recs = [] + for i in range(0, len(raw) - 2, 3): + rec = raw[i:i + 3] + if all(b == 0xff for b in rec): + continue + digits = _swap_nibbles(rec[:2].hex()).rstrip('fF') + recs.append({'number': digits or None, 'esc': f'0x{rec[2]:02X}'}) + if not recs: + return {'raw': raw.hex().upper()} + return recs[0] if len(recs) == 1 else {'numbers': recs} + + +def _decode_kc(raw, p1=None): + """EF_Kc / EF_KcGPRS (UICC_FILES §10.3.3): Kc(8) + Kc/KSI byte + (b8-b5 ciphering key sequence number, b4-b1 RFU).""" + if all(b == 0xff for b in raw): + return {'empty': True} + out = {'kc': raw[:8].hex().upper()} if len(raw) >= 8 else {} + if len(raw) >= 9: + out['ksi'] = (raw[8] >> 4) & 0x0F + return out or {'raw': raw.hex().upper()} + + +def _decode_opl(raw, p1=None): + """EF_OPL (§10.3.42): PLMN(3, 'D' = wild) + LAC range(2+2) + PNN rec(1).""" + if all(b == 0xff for b in raw): + return {'empty': True} + recs = [] + for i in range(0, len(raw) - 7, 8): + rec = raw[i:i + 8] + if all(b == 0xff for b in rec): + continue + plmn = _decode_plmn(rec[:3]) or {} + lac_lo = rec[3:5].hex().upper() + lac_hi = rec[5:7].hex().upper() + recs.append({ + 'mcc': plmn.get('mcc'), 'mnc': plmn.get('mnc'), + 'lac_range': [lac_lo, lac_hi], + 'pnn_record': rec[7], + }) + if not recs: + return {'raw': raw.hex().upper()} + return recs[0] if len(recs) == 1 else {'records': recs} + + +def _decode_ext1(raw, p1=None): + """EXT records (§10.5.10): len(1) + BCD(10) + CCP(1) + EXT(1).""" + if all(b == 0xff for b in raw): + return {'empty': True} + n = raw[0] + digits = _swap_nibbles(raw[1:11].hex())[:n * 2].rstrip('fF') if n else '' + return { + 'number': digits or None, + 'ccp': None if raw[11] == 0xff else raw[11], + 'ext': None if raw[12] == 0xff else raw[12], + } + + +def _decode_ad(raw, p1=None): + """EF_AD (§10.3.18): op mode + additional info + optional MNC length.""" + if all(b == 0xff for b in raw): + return {'empty': True} + op = raw[0] & 0x03 + out = {'op_mode': {0: 'normal operation', 1: 'specific features'}.get(op, f'0x{op:02X}'), + 'test_mode': bool(raw[0] & 0x04)} + if len(raw) >= 3: + out['additional_info'] = raw[1:3].hex().upper() + if len(raw) >= 4 and raw[3] != 0xff: + out['mnc_length'] = raw[3] + return out + + +def _decode_acl(raw, p1=None): + """EF_ACL (§4.2.48): APN count byte + 'DD'-tagged APN TLVs.""" + if all(b == 0xff for b in raw): + return {'empty': True} + apns = [] + for tag, _length, value in parse_tlv(raw[1:]): + if tag == 0xDD: + apns.append(value.decode('ascii', 'replace') if value else + '(network provided)') + return {'apns': apns} if apns else {'raw': raw.hex().upper()} + + +def _decode_smsp(raw, p1=None): + """EF_SMSP (§10.5.6): alpha(Y) + PI + TP-DA(12) + SCA(12) + PID/DCS/VP.""" + if all(b == 0xff for b in raw): + return {'empty': True} + if len(raw) < 28: + return {'raw': raw.hex().upper()} + y = len(raw) - 28 + out = {} + alpha = _decode_annex_a(raw[:y].rstrip(b'\xff')) + if alpha: + out['alpha'] = alpha + pi = raw[y] + out['param_indicators'] = f'0x{pi:02X}' + if not (pi & 0x01): + out['tp_da'] = _decode_bcd_address(raw[y + 1:y + 13][:12]) + if not (pi & 0x10): + out['sca'] = _decode_bcd_address(raw[y + 13:y + 25][:12]) + if not (pi & 0x02): + out['tp_pid'] = f'0x{raw[y + 25]:02X}' + if not (pi & 0x04): + out['tp_dcs'] = f'0x{raw[y + 26]:02X}' + if not (pi & 0x08): + out['tp_vp'] = f'0x{raw[y + 27]:02X}' + return out + + +def _decode_spdi(raw, p1=None): + """EF_SPDI (§4.2.66): 'A3' container with PLMN records (§0.3).""" + if all(b == 0xff for b in raw): + return {'empty': True} + for tag, _length, value in parse_tlv(raw): + if tag == 0xA3: + plmns = [] + for i in range(0, len(value) - 2, 3): + plmn = _decode_plmn(value[i:i + 3]) + if plmn: + plmns.append(f"{plmn['mcc']}-{plmn['mnc']}") + if plmns: + return {'plmns': plmns if len(plmns) > 1 else plmns[0]} + return {'raw': raw.hex().upper()} def _decode_spn(raw, p1=None): @@ -3229,6 +3431,18 @@ FILE_DECODERS = { '6fc4': _decode_hex, '6fca': _decode_hex, '6fc9': _decode_hex, + '6f20': _decode_kc, + '6f52': _decode_kc, + '6fc6': _decode_opl, + '6f4a': _decode_ext1, + '6f4b': _decode_ext1, + '6f4c': _decode_ext1, + '6f4e': _decode_ext1, + '6f55': _decode_ext1, + '6fad': _decode_ad, + '6f57': _decode_acl, + '6f42': _decode_smsp, + '6fcd': _decode_spdi, } diff --git a/tests/test_decode.py b/tests/test_decode.py index 803507c..98a5f0e 100644 --- a/tests/test_decode.py +++ b/tests/test_decode.py @@ -1870,6 +1870,136 @@ class TestQualifiers(unittest.TestCase): '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 + r = _decode_spdi(bytes.fromhex('a30652f01000f110'), p1=None) + self.assertEqual(r['plmns'], ['250-01', '001-01']) + + 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).""" diff --git a/tests/test_pcap_import.py b/tests/test_pcap_import.py index 9219d2f..7fae3de 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.15.1') + self.assertEqual(__version__, '1.16.0') def test_status_includes_version(self): import os