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