diff --git a/frontend/index.html b/frontend/index.html index 37c5b01..e483d12 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1851,7 +1851,7 @@ function onFilterChange() { } // ──────────────────── Versioning ──────────────────── -const PWA_VERSION = '1.9.0'; +const PWA_VERSION = '1.10.0'; const MIN_SERVER_VERSION = '1.0.0'; function parseVersion(v) { diff --git a/frontend/sw.js b/frontend/sw.js index 554445f..13255da 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simtrace-analyser-v11'; +const CACHE = 'simtrace-analyser-v12'; const PRECACHE = [ 'index.html', diff --git a/pyproject.toml b/pyproject.toml index c69094e..5b9899e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.backends._legacy:_Backend" [project] name = "simtrace2-pysniff" -version = "1.9.0" +version = "1.10.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 26858f2..1494d69 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.9.0' +__version__ = '1.10.0' __all__ = [ 'SniffSession', 'DeviceDisconnected', diff --git a/simtrace2_pysniff/server/decode.py b/simtrace2_pysniff/server/decode.py index 37992bd..cf5f14a 100644 --- a/simtrace2_pysniff/server/decode.py +++ b/simtrace2_pysniff/server/decode.py @@ -8,20 +8,22 @@ the SIMtrace2 sniffer into structured dicts for the PWA to display. SW_NAMES = { (0x90, 0x00): 'Normal ending', - (0x91, 0x00): 'Normal ending, proactive command pending', - (0x92, 0x00): 'Normal ending after x bytes', (0x61, 0x00): 'Response data available', (0x62, 0x00): 'Warning — no information', (0x62, 0x81): 'Warning — part of data may be corrupted', (0x62, 0x82): 'Warning — EOF reached before reading Le bytes', (0x62, 0x83): 'Warning — selected file deactivated', (0x62, 0x84): 'Warning — FCI not formatted per ISO', + (0x62, 0x85): 'Warning — selected file in termination state', + (0x62, 0xf1): 'More data available', + (0x62, 0xf2): 'More data available and proactive command pending', + (0x62, 0xf3): 'Response data available', (0x63, 0x00): 'Warning — no information', - (0x63, 0xc1): 'Warning — 1 retry remaining', - (0x63, 0xc2): 'Warning — 2 retries remaining', - (0x63, 0xc3): 'Warning — 3 retries remaining', + (0x63, 0xf1): 'More data expected', + (0x63, 0xf2): 'More data expected and proactive command pending', (0x64, 0x00): 'Execution error — no information', - (0x65, 0x00): 'Execution error — memory failure', + (0x64, 0x01): 'Execution error — immediate response required', + (0x65, 0x00): 'Execution error — no information, NV memory changed', (0x65, 0x81): 'Execution error — memory failure', (0x66, 0x00): 'Security error — no information', (0x66, 0x81): 'Reserved for security-related issues', @@ -40,6 +42,7 @@ SW_NAMES = { (0x69, 0x86): 'Command not allowed (no current EF)', (0x69, 0x87): 'Expected SM data objects missing', (0x69, 0x88): 'SM data objects incorrect', + (0x69, 0x89): 'Command not allowed — secure channel security not satisfied', (0x6a, 0x00): 'Checking error — no information', (0x6a, 0x80): 'Incorrect data in command data', (0x6a, 0x81): 'Function not supported', @@ -56,6 +59,11 @@ SW_NAMES = { (0x6d, 0x00): 'Instruction not supported or invalid', (0x6e, 0x00): 'Class not supported', (0x6f, 0x00): 'No precise diagnosis', + (0x93, 0x00): 'SIM Application Toolkit busy', + (0x98, 0x50): 'INCREASE cannot be performed, max value reached', + (0x98, 0x62): 'Authentication error, application specific', + (0x98, 0x63): 'Security session or association expired', + (0x98, 0x64): 'Minimum UICC suspension time too long', } @@ -63,15 +71,17 @@ def _sw_name(sw1, sw2): key = (sw1, sw2) return SW_NAMES.get(key) -# pattern match: 63cx, 62xx, etc. -_SW_PATTERNS = { - (0x63,): lambda s1, s2: ('PIN verification failed, %d retries remaining' % (s2 & 0x0f) - if s2 & 0xc0 == 0xc0 else None), - (0x61,): lambda s1, s2: 'Response: %d bytes available (use GET RESPONSE)' % s2, - (0x6c,): lambda s1, s2: 'Wrong length (Le): correct length is 0x%02X (%d bytes)' % (s2, s2), - (0x9e,): lambda s1, s2: 'Normal processing, %d bytes of response' % s2, - (0x9f,): lambda s1, s2: 'Normal processing, %d bytes of response' % s2, -} +# pattern match: 91xx, 63cx, etc. +_SW_PATTERNS = [ + ((0x91,), lambda s1, s2: 'Proactive command pending (%d bytes)' % s2), + ((0x92,), lambda s1, s2: 'Normal ending, TRANSACT DATA info (0x%02X)' % s2), + ((0x61,), lambda s1, s2: 'Response: %d bytes available (use GET RESPONSE)' % s2), + ((0x63,), lambda s1, s2: ('Verification failed, %d retries remaining' % (s2 & 0x0f) + if s2 & 0xc0 == 0xc0 else None)), + ((0x6c,), lambda s1, s2: 'Wrong length (Le): correct length is 0x%02X (%d bytes)' % (s2, s2)), + ((0x9e,), lambda s1, s2: 'Normal processing, %d bytes of response' % s2), + ((0x9f,), lambda s1, s2: 'Normal processing, %d bytes of response' % s2), +] def decode_sw(raw_sw): @@ -80,17 +90,11 @@ def decode_sw(raw_sw): sw1, sw2 = raw_sw[-2], raw_sw[-1] name = _sw_name(sw1, sw2) if name is None: - for keys, fn in _SW_PATTERNS.items(): - if len(keys) == 1: - if sw1 == keys[0]: - name = fn(sw1, sw2) - if name: - break - elif len(keys) == 2: - if sw1 == keys[0]: - name = fn(sw1, sw2) - if name: - break + for keys, fn in _SW_PATTERNS: + if sw1 == keys[0]: + name = fn(sw1, sw2) + if name: + break return { 'sw1': f'{sw1:02x}', 'sw2': f'{sw2:02x}', @@ -101,23 +105,44 @@ def decode_sw(raw_sw): # ──────────────────── CLA byte ──────────────────── def decode_cla(cla): - chain_bits = (cla >> 4) & 0x03 - chain_names = {0: 'last or only', 1: 'first in chain', 2: 'not last in chain', 3: 'not last'} if cla == 0x80: interclass = 'ETSI-defined (UICC/USIM)' elif cla == 0xa0: interclass = 'ETSI-defined (SIM/GSM)' - elif cla == 0x00: + elif cla & 0x80: + interclass = 'proprietary' + elif (cla >> 6) & 0x03 == 0x00: interclass = 'inter-industry (ISO 7816-4)' + elif (cla >> 6) & 0x03 == 0x01: + interclass = 'inter-industry (further format)' else: - interclass = 'inter-industry' if (cla & 0x80) == 0 else 'proprietary' - result = { - 'hex': f'{cla:02x}', - 'interclass': interclass, - 'channel': cla & 0x03, - 'secure_messaging': 'none' if (cla & 0x0c) == 0 else 'present', - 'chain': chain_names.get(chain_bits, f'unknown ({chain_bits})'), - } + interclass = 'reserved' + if cla & 0x80: + # proprietary / ETSI-defined: no standard SM/chaining/channel coding + result = { + 'hex': f'{cla:02x}', + 'interclass': interclass, + 'channel': cla & 0x03, + 'secure_messaging': 'none', + 'chain': 'last or only', + } + else: + # ISO 7816-4 §5.1.1 first/further interindustry + chain_bit = (cla >> 5) & 0x01 + chain_names = {0: 'last or only', 1: 'first or continuing'} + sm_names = {0: 'none', 1: 'proprietary', 2: 'SM header not authenticated', + 3: 'SM header authenticated'} + sm_ind = (cla >> 3) & 0x03 + channel = cla & 0x03 + if (cla >> 6) & 0x03 == 0x01: + channel += 4 # further format: b4-b1 = channel + 4 + result = { + 'hex': f'{cla:02x}', + 'interclass': interclass, + 'channel': channel, + 'secure_messaging': sm_names.get(sm_ind, 'none'), + 'chain': chain_names.get(chain_bit, 'last or only'), + } return result @@ -594,6 +619,37 @@ APDU_SPEC = { 'p1p2': {'fmt': 'uint16be', 'label': 'Offset'}, 'body': {'label': 'Data'}, }, + 0xD7: { + 'name': 'UPDATE BINARY (odd INS)', + 'p1p2': {'fmt': 'uint16be', 'label': 'Offset'}, + 'body': {'label': 'BER-TLV data'}, + }, + 0xDD: { + 'name': 'UPDATE RECORD (odd INS)', + 'p1': {'fmt': 'uint8', 'label': 'Record number'}, + 'p2': { + 'label': 'Mode', + 'bits': { + 0x02: 'next record', + 0x03: 'previous record', + 0x04: 'absolute mode (record number in P1)', + }, + }, + 'body': {'label': 'BER-TLV data'}, + }, + 0xD2: { + 'name': 'WRITE RECORD', + 'p1': {'fmt': 'uint8', 'label': 'Record number'}, + 'p2': { + 'label': 'Mode', + 'bits': { + 0x02: 'next record', + 0x03: 'previous record', + 0x04: 'absolute mode (record number in P1)', + }, + }, + 'body': {'label': 'Data'}, + }, 0xDC: { 'name': 'UPDATE RECORD', 'p1': {'fmt': 'uint8', 'label': 'Record number'}, @@ -658,6 +714,28 @@ APDU_SPEC = { 'name': 'AUTHENTICATE', 'body': {'label': 'Response/resynchronisation data'}, }, + 0x82: { + 'name': 'EXTERNAL AUTHENTICATE', + 'p1': {'label': 'Security level'}, + 'p2': {0x00: 'No indication'}, + 'body': {'label': 'Cryptogram'}, + }, + 0x86: { + 'name': 'GENERAL AUTHENTICATE', + 'p1p2': {'unused': True}, + 'body': {'label': 'Auth data'}, + }, + 0x87: { + 'name': 'GENERAL AUTHENTICATE (odd INS)', + 'p1p2': {'unused': True}, + 'body': {'label': 'BER-TLV auth data'}, + }, + 0x22: { + 'name': 'MANAGE SECURITY ENVIRONMENT', + 'p1': {0x00: 'No indication', 0x01: 'Set', 0x02: 'Verify', 0x03: 'Restore', 0xF3: 'Erase'}, + 'p2': {'label': 'SE reference'}, + 'body': {'label': 'SE parameters'}, + }, 0x84: { 'name': 'GET CHALLENGE', 'le': True, @@ -709,6 +787,29 @@ APDU_SPEC = { 'p2': {0x00: 'No indication'}, 'body': {'label': 'File ID/path'}, }, + 0x0E: { + 'name': 'ERASE BINARY', + 'p1p2': {'fmt': 'uint16be', 'label': 'Offset'}, + 'body': {'label': 'Erase data'}, + }, + 0x0C: { + 'name': 'ERASE RECORDS', + 'p1': {'fmt': 'uint8', 'label': 'Record number'}, + 'p2': { + 'label': 'Mode', + 'bits': { + 0x02: 'next record', + 0x03: 'previous record', + 0x04: 'absolute mode (record number in P1)', + }, + }, + 'body': None, + }, + 0x7C: { + 'name': 'MANAGE LSI', + 'p1p2': {'unused': True}, + 'body': {'label': 'LSI parameters'}, + }, 0xF2: { 'name': 'STATUS', 'le': True, @@ -1905,11 +2006,12 @@ def _decode_secured_packet(body): 'spi': { 'hex': spi.hex().upper(), 'counter': _OTA_COUNTER.get((spi1 >> 4) & 0x03, 'reserved'), - 'ciphering': bool(spi1 & 0x08), + 'ciphering': bool(spi1 & 0x04), 'rc_cc_ds': _OTA_RC_CC_DS.get(rc_cc_ds_kind, 'reserved'), 'por': _OTA_POR.get(spi2 & 0x03, 'reserved'), 'por_rc_cc_ds': _OTA_RC_CC_DS.get((spi2 >> 2) & 0x03, 'reserved'), - 'por_ciphered': bool(spi2 & 0x20), + 'por_ciphered': bool(spi2 & 0x10), + 'por_transport': 'SMS-DELIVER-REPORT' if not (spi2 & 0x20) else 'SMS-SUBMIT', }, 'kic': {'key': kic >> 4, 'algo': _KIC_ALGO.get(kic & 0x0F, 'reserved')}, 'tar': tar.hex().upper(), @@ -1918,7 +2020,7 @@ def _decode_secured_packet(body): 'data': data.hex().upper(), } kic_algo = _KIC_ALGO.get(kic & 0x0F, 'reserved') - if not (spi1 & 0x08): # SPI says no ciphering → KIc unused + if not (spi1 & 0x04): # SPI says no ciphering → KIc unused kic_algo += ' (unused)' result['kic'] = {'key': kic >> 4, 'algo': kic_algo} if rc_cc_ds_kind == 2: # CC diff --git a/tests/test_decode.py b/tests/test_decode.py index 811c0e4..20d4cc9 100644 --- a/tests/test_decode.py +++ b/tests/test_decode.py @@ -1545,5 +1545,177 @@ class TestRefresh(unittest.TestCase): self.assertEqual(r['cmd']['file_list'], ['6F07', '6F20']) +class TestSwUiccSpecific(unittest.TestCase): + """TS 102 221 UICC-specific SWs (tables 10.7-10.15).""" + + def _sw(self, hexstr): + from simtrace2_pysniff.server.decode import decode_sw + return decode_sw(bytes.fromhex(hexstr)) + + def test_91xx_proactive_pending(self): + self.assertEqual(self._sw('9107')['name'], + 'Proactive command pending (7 bytes)') + self.assertEqual(self._sw('910b')['name'], + 'Proactive command pending (11 bytes)') + + def test_62f_more_data(self): + self.assertEqual(self._sw('62f1')['name'], 'More data available') + self.assertEqual(self._sw('62f2')['name'], + 'More data available and proactive command pending') + self.assertEqual(self._sw('62f3')['name'], 'Response data available') + + def test_63f_not_pin_failure(self): + # 63F1/F2 are "more data expected", NOT PIN retries + self.assertEqual(self._sw('63f1')['name'], 'More data expected') + self.assertEqual(self._sw('63f2')['name'], + 'More data expected and proactive command pending') + + def test_63cx_still_pin_retries(self): + self.assertIn('retries remaining', self._sw('63c3')['name']) + self.assertIn('Verification failed', self._sw('63c3')['name']) + + def test_sat_busy_and_auth_errors(self): + self.assertEqual(self._sw('9300')['name'], 'SIM Application Toolkit busy') + self.assertEqual(self._sw('9850')['name'], + 'INCREASE cannot be performed, max value reached') + self.assertEqual(self._sw('9862')['name'], + 'Authentication error, application specific') + self.assertEqual(self._sw('9863')['name'], + 'Security session or association expired') + self.assertEqual(self._sw('9864')['name'], + 'Minimum UICC suspension time too long') + + def test_6401_immediate_response(self): + self.assertEqual(self._sw('6401')['name'], + 'Execution error — immediate response required') + + def test_6500_nv_memory_changed(self): + # TS 102 221 table 10.10: 6500 = no info given, NV memory CHANGED + name = self._sw('6500')['name'] + self.assertIn('NV memory changed', name) + self.assertNotEqual(name, 'Execution error — memory failure') + + def test_existing_patterns_intact(self): + self.assertIn('GET RESPONSE', self._sw('6110')['name']) + self.assertIn('correct length is 0x38', self._sw('6c38')['name']) + self.assertIsNone(self._sw('6205')['name']) + + +class TestSpiBits(unittest.TestCase): + """TS 102 225 §5.1.1: SPI1 b3 = ciphering, SPI2 b5 = PoR ciphering.""" + + def _spi(self, spi_hex): + from simtrace2_pysniff.server.decode import _decode_secured_packet + # CPL(2) CHL(1)=0x0D SPI(2) KIc KID TAR(3) CNTR(5) PCNTR + 1 data byte + body = bytes.fromhex('000d0d' + spi_hex + '1505000001000000000000aa') + return _decode_secured_packet(body)['spi'] + + def test_spi1_ciphering_b3(self): + # 0x16 = 0001 0110: b3=1 → ciphering ON; old code checked b4 (0x08) → False + self.assertTrue(self._spi('1605')['ciphering']) + # 0x12 = 0001 0010: b3=0 → ciphering OFF + self.assertFalse(self._spi('1205')['ciphering']) + # 0x04 = 0000 0100: only b3 set → ciphering ON + self.assertTrue(self._spi('0405')['ciphering']) + + def test_spi2_por_ciphered_b5(self): + # 0x10 = 0001 0000: b5=1 → PoR ciphered; old code checked b6 (0x20) → False + self.assertTrue(self._spi('0510')['por_ciphered']) + # 0x20 = 0010 0000: b6=0... wait, b5=0 → NOT ciphered + self.assertFalse(self._spi('0520')['por_ciphered']) + + def test_por_transport_field(self): + # TS 31.115: SPI2 b6 (0x20) = PoR transport (0 = SMS-DELIVER-REPORT, 1 = SMS-SUBMIT) + self.assertEqual(self._spi('0500')['por_transport'], 'SMS-DELIVER-REPORT') + self.assertEqual(self._spi('0520')['por_transport'], 'SMS-SUBMIT') + + +class TestApduSpecCoverage(unittest.TestCase): + """Newly added APDU_SPEC entries decode with names.""" + + def _ins(self, cla, ins, p1p2='0000', body='', le=''): + raw = bytes.fromhex(cla + ins + p1p2 + '%02x' % (len(body) // 2) + body + le) + return decode_message(raw)['ins_name'] + + def test_update_binary_odd_ins(self): + r = self._ins('00', 'd7', '0010', 'aabb') + self.assertEqual(r, 'UPDATE BINARY (odd INS)') + + def test_update_record_odd_ins(self): + r = self._ins('00', 'dd', '0104', 'aabb') + self.assertEqual(r, 'UPDATE RECORD (odd INS)') + + def test_write_record(self): + r = self._ins('00', 'd2', '0104', 'aabb') + self.assertEqual(r, 'WRITE RECORD') + + def test_external_authenticate(self): + r = self._ins('00', '82', '0100', 'aabbccdd') + self.assertEqual(r, 'EXTERNAL AUTHENTICATE') + + def test_general_authenticate(self): + r = self._ins('00', '86', '0000', '7c0aa50401020304') + self.assertEqual(r, 'GENERAL AUTHENTICATE') + + def test_manage_security_environment(self): + r = self._ins('00', '22', '41a4', '') + self.assertEqual(r, 'MANAGE SECURITY ENVIRONMENT') + + def test_erase_binary(self): + r = self._ins('00', '0e', '0010', '0020') + self.assertEqual(r, 'ERASE BINARY') + + def test_erase_records(self): + r = self._ins('00', '0c', '0304', '') + self.assertEqual(r, 'ERASE RECORDS') + + def test_manage_lsi(self): + r = self._ins('80', '7c', '0000', '0201') + self.assertEqual(r, 'MANAGE LSI') + + +class TestClaDecode(unittest.TestCase): + """ISO 7816-4 §5.1.1 first/further interindustry CLA coding.""" + + def _cla(self, value): + from simtrace2_pysniff.server.decode import decode_cla + return decode_cla(value) + + def test_basic_channel_iso(self): + r = self._cla(0x00) + self.assertEqual(r['channel'], 0) + self.assertEqual(r['secure_messaging'], 'none') + self.assertEqual(r['chain'], 'last or only') + + def test_sm_header_authenticated(self): + # 0x1C = 0001 1100: SM bits b4-b3 = 11 → header authenticated + r = self._cla(0x1c) + self.assertEqual(r['secure_messaging'], 'SM header authenticated') + + def test_sm_proprietary(self): + # 0x0C = 0000 1100: b4-b3 = 01 → proprietary secure messaging + r = self._cla(0x0c) + self.assertEqual(r['secure_messaging'], 'proprietary') + + def test_chain_bit_b5(self): + # 0x20 = 0010 0000: b5=1 → first or continuing command of chain + r = self._cla(0x20) + self.assertEqual(r['chain'], 'first or continuing') + + def test_further_interindustry_channel_offset(self): + # 0x40 = 0100 0000: further format, channel = b4-b1 + 4 = 4 + r = self._cla(0x40) + self.assertEqual(r['interclass'], 'inter-industry (further format)') + self.assertEqual(r['channel'], 4) + + def test_etsi_uicc(self): + r = self._cla(0x80) + self.assertEqual(r['interclass'], 'ETSI-defined (UICC/USIM)') + + def test_etsi_sim(self): + r = self._cla(0xa0) + self.assertEqual(r['interclass'], 'ETSI-defined (SIM/GSM)') + + if __name__ == '__main__': unittest.main() diff --git a/tests/test_pcap_import.py b/tests/test_pcap_import.py index 7847cce..fc89dc5 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.9.0') + self.assertEqual(__version__, '1.10.0') def test_status_includes_version(self): import os