diff --git a/frontend/index.html b/frontend/index.html index 7beeef4..014579a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3386,7 +3386,7 @@ function onSearchChange() { } // ──────────────────── Versioning ──────────────────── -const PWA_VERSION = '2.7.7'; +const PWA_VERSION = '2.7.8'; const MIN_SERVER_VERSION = '2.3.0'; function parseVersion(v) { diff --git a/frontend/sw.js b/frontend/sw.js index 54b48ec..519fc62 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simtrace-analyser-v87'; +const CACHE = 'simtrace-analyser-v88'; const PRECACHE = [ 'index.html', diff --git a/pyproject.toml b/pyproject.toml index 304f3b5..b3b2d36 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.backends._legacy:_Backend" [project] name = "simtrace-analyser" -version = "2.7.7" +version = "2.7.8" 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/database.py b/simtrace_analyser/database.py index bf8e761..7bffdc9 100644 --- a/simtrace_analyser/database.py +++ b/simtrace_analyser/database.py @@ -296,6 +296,7 @@ class Database: "ORDER BY id DESC LIMIT 500", (session_id, before_id)).fetchall() states = {} + learned = {} # df -> {sfi: fid} learned this session clk_hz = None for data, typ in reversed(rows): if typ in ('atr', 'change', 'gap'): @@ -320,13 +321,19 @@ class Database: continue (st['sel'], st['df'], st['sfi_map'], st['df_path']) = self._advance_state(d, st['sel'], st['df'], - st['sfi_map'], st['df_path']) + st['sfi_map'], st['df_path'], + learned) st['prev'] = self._context_from_decoded(d) return states, clk_hz - def _advance_state(self, d, sel, df, sfi_map, df_path=None): - """Update (sel, df, sfi_map, df_path) for one decoded TPDU.""" + def _advance_state(self, d, sel, df, sfi_map, df_path=None, learned=None): + """Update (sel, df, sfi_map, df_path) for one decoded TPDU. + + *learned* caches the session's SFI→FID relations per DF (from the + FCPs' '88' DO and the card's EF_PBR), so leaving the DF and coming + back does not forget them.""" from .decode import selected_df_state, sfi_table, KNOWN_FIDS + from .pbr import parse_pbr_record new_df, new_path = selected_df_state(d, df, df_path) if new_df is not None and new_df != df: # Only a successful SELECT changes the current DF. @@ -334,12 +341,34 @@ class Database: df = new_df df_path = new_path sfi_map = dict(sfi_table(new_df)) + sfi_map.update((learned or {}).get(new_df, {})) # Learn SFI from the FCP of the last SELECT (GET RESPONSE). if d.get('ins_hex') == 'c0' and d.get('response_for') == 'SELECT' and sel: sfi = (d.get('response') or {}).get('sfi') if sfi is not None: sfi_map = dict(sfi_map) sfi_map[sfi] = sel['fid'] + if learned is not None: + learned.setdefault(df, {})[sfi] = sel['fid'] + # Learn the phonebook file references from an EF_PBR record + # (TS 31.102 §4.4.2.1): the card declares each file's FID and SFI, so + # an SFI-addressed SEARCH/READ after the PBR read resolves even + # without the file's own FCP. + f = d.get('file') or {} + hx = (d.get('body') or {}).get('hex') if f.get('fid') == '4f30' else None + if hx: + try: + parsed = parse_pbr_record(bytes.fromhex(hx)) + except ValueError: + parsed = None + if parsed: + for group in parsed['groups']: + for item in group['files']: + if item.get('sfi') and 'fid' in item: + sfi_map = dict(sfi_map) + sfi_map[item['sfi']] = item['fid'] + if learned is not None: + learned.setdefault(df, {})[item['sfi']] = item['fid'] sel = self._selection_after(d, sel) # A record/SEARCH command with a valid SFI sets the file as current EF # (TS 102 221 §11.1.2). @@ -395,6 +424,7 @@ class Database: from .decode import atr_rate_fields, cla_channel msgs = [] states = dict(initial_states or {}) + learned = {} # df -> {sfi: fid} learned this session last_atr_i = None # index of the most recent ATR in *msgs* last_atr_rated = False # whether that ATR already carries a data rate for row in rows: @@ -416,7 +446,8 @@ class Database: if msg['type'] == 'tpdu' and d and d.get('ins_hex'): (st['sel'], st['df'], st['sfi_map'], st['df_path']) = self._advance_state(d, st['sel'], st['df'], - st['sfi_map'], st['df_path']) + st['sfi_map'], st['df_path'], + learned) st['prev'] = self._context_from_decoded(d) elif msg['type'] == 'atr': states = {} diff --git a/simtrace_analyser/version.py b/simtrace_analyser/version.py index 17ab6e5..47467f4 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.7.7' +__version__ = '2.7.8' diff --git a/tests/test_decode.py b/tests/test_decode.py index 65eca52..0e4310e 100644 --- a/tests/test_decode.py +++ b/tests/test_decode.py @@ -1782,6 +1782,53 @@ class TestSelectionTracking(unittest.TestCase): self.assertEqual(msgs[3]['decoded']['cla']['channel'], 8) self.assertEqual(msgs[5]['decoded']['cla']['channel'], 12) + def test_pbr_sfi_resolves_after_the_pbr_read(self): + # The card declares the phonebook SFIs in EF_PBR (TS 31.102 §4.4.2.1: + # FID + SFI per primitive tag). A SEARCH RECORD addressed by SFI 2 + # after the PBR read must resolve to the declared file (EF_ADN 4F3A) + # even though nothing selected it and no FCP carried its '88' DO. + import tempfile + from simtrace_analyser.database import Database + with tempfile.NamedTemporaryFile(suffix='.db') as tmp: + db = Database(tmp.name) + sid = db.create_session('capture') + db.insert_message(sid, 0.0, 'tpdu', + bytes.fromhex('00a40804047f105f3a6102')) + db.insert_message(sid, 0.1, 'tpdu', + bytes.fromhex('00c0000008' + + '62088202412183025f3a' + '9000')) + db.insert_message(sid, 0.2, 'tpdu', + bytes.fromhex('00a40000024f306102')) + db.insert_message(sid, 0.3, 'tpdu', + bytes.fromhex('00c000000b' + + '620b8205422100180183024f30' + + '9000')) + db.insert_message(sid, 0.4, 'tpdu', + bytes.fromhex('00b2010418' + + 'a80ac0034f3a02c5034fa104' + 'aa0ac2034f4a06cb034f4f07' + + '9000')) + # Leave DF_PHONEBOOK and come back (the live trace does this): + # the learned SFI relations must survive the excursion. + db.insert_message(sid, 0.5, 'tpdu', + bytes.fromhex('00a40804047f106f066102')) + db.insert_message(sid, 0.6, 'tpdu', + bytes.fromhex('00c000000b' + + '620b82054221004b0183026f06' + + '9000')) + db.insert_message(sid, 0.7, 'tpdu', + bytes.fromhex('00a40804047f105f3a6120')) + db.insert_message(sid, 0.8, 'tpdu', + bytes.fromhex('00c0000008' + + '620882013883025f3a' + + '9000')) + db.insert_message(sid, 0.9, 'tpdu', + bytes.fromhex('00a2011427' + 'ff' * 39 + + '9000')) + search = db.get_messages(sid)[9]['decoded'] + self.assertEqual(search['p2']['sfi'], 2) + self.assertEqual(search['file'], {'fid': '4f3a', 'ef': '4F3A'}) + def test_manage_channel_opens_use_their_channel(self): # The MANAGE CHANNEL open/close exchanges themselves run on the # basic channel; a later extended-channel command must not disturb it. diff --git a/tests/test_pcap_import.py b/tests/test_pcap_import.py index 1132873..47914a8 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.7.7') + self.assertEqual(__version__, '2.7.8') def test_status_includes_version(self): import os