diff --git a/frontend/index.html b/frontend/index.html index 3d5a379..99cfd37 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1749,7 +1749,7 @@ // ===== Version ===== // Single source of truth for the PWA version: shown in the header and used // by the server version check in pysimConnect(). -const SIMPLE_VERSION = '3.6.56'; +const SIMPLE_VERSION = '3.6.57'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== Tab switching ===== @@ -9170,6 +9170,7 @@ function ramExpandedGroups(report) { groups.push(g); } g.compact = g.compact || !!r.compact; + g.transport = g.transport || !!r.transport; if (r.form) { const score = ((r.entries || 0) > 0 ? 100 : 0) + (RAM_EXPANDED_FORMS.length - RAM_EXPANDED_FORMS.indexOf(r.form)); @@ -9204,7 +9205,10 @@ function ramExpandedReport(report) { if (g.form) return; const why = g.sw ? ' (' + g.sw + ')' : ''; if (!g.compact) { - parts.push(g.label + ': ' + (g.sw || t('no data'))); + parts.push(g.label + ': ' + (g.transport && g.sw + ? t('request error') + ' (' + g.sw + ')' : (g.sw || t('no data')))); + } else if (g.transport) { + parts.push(g.label + ': ' + t('request error') + why + ' \u2014 ' + t('compact listing used')); } else if (g.p1 === '80') { // cards commonly report nothing for the ISD in the expanded form // (GPPro treats its 6A88/6A86 as 'no data to report') @@ -9409,10 +9413,12 @@ async function ramExplore(sp) { : RAM_EXPANDED_FORMS.slice(); if (p1 === '80') forms = [expandedForm || 'tags']; let lastSw = ''; + let lastTransport = false; for (const form of forms) { let data = ''; let sw = ''; let accepted = true; + let transportError = false; const sendOne = async (apdu, what) => { ramShowProgress(label + ' P1=' + p1 + ' ' + what + '...'); const res = await ramSendOta(apdu, Object.assign({}, sp, { cntr, spi2 })); @@ -9421,6 +9427,8 @@ async function ramExplore(sp) { else if (res.success && spPorAccepted(res.por)) cntr = ramIncrementCntr(cntr); if (!res.success || !res.por || !spPorAccepted(res.por)) { accepted = false; + // a request/transport failure is not a remote status word + transportError = !res.por; sw = res.por ? res.por.response_status : (res.error || t('no data')); return ''; } @@ -9451,16 +9459,19 @@ async function ramExplore(sp) { const parsed = data ? parser(data) : []; if (parsed.length) { expandedForm = form; - return { form: form, parsed: parsed, sw: sw, entries: parsed.length }; + return { form: form, parsed: parsed, sw: sw, entries: parsed.length, + transport: transportError }; } lastSw = sw; + lastTransport = transportError; if (!accepted) break; if (ramRemoteSwOk(sw)) { // accepted but empty: nothing to gain from another form - return { form: form, parsed: [], sw: sw, entries: 0 }; + return { form: form, parsed: [], sw: sw, entries: 0, + transport: transportError }; } } - return { form: null, parsed: [], sw: lastSw, entries: 0 }; + return { form: null, parsed: [], sw: lastSw, entries: 0, transport: lastTransport }; } async function paginate(p1, collector, parser, label, quiet) { @@ -9533,7 +9544,8 @@ async function ramExplore(sp) { if (collector.length === before || wantDetails) { const out = await expandedPass(p1, label, parser, spi2); expandedReport.push({ p1: p1, label: label, compact: collector.length > before, - form: out.form, sw: out.sw, entries: out.entries }); + form: out.form, sw: out.sw, entries: out.entries, + transport: out.transport }); if (collector.length === before) { // Nothing from the compact pass: the expanded result is the listing collector.push(...out.parsed); @@ -18766,6 +18778,7 @@ const LANG_RU = { 'Package versions come from the expanded registry query \u2014 this card did not return them.': 'Версии пакетов приходят из расширенного запроса реестра \u2014 эта карта их не вернула.', 'Package versions are only in the tag-list forms of the expanded registry query \u2014 this card accepted only the no-tag-list form.': 'Версии пакетов есть только в формах расширенного запроса со списком тегов \u2014 эта карта приняла только форму без списка тегов.', 'from the app instance': 'от экземпляра апплета', + 'request error': 'ошибка запроса', 'STK menu entry:': 'Пункт меню STK:', 'position': 'позиция', 'Other registry data:': 'Прочие данные реестра:', diff --git a/frontend/sw.js b/frontend/sw.js index 21e9ed9..4e5ee01 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v329'; +const CACHE = 'simple-v330'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/ram.test.js b/frontend/tests/ram.test.js index 3c036c6..4058fd0 100644 --- a/frontend/tests/ram.test.js +++ b/frontend/tests/ram.test.js @@ -666,6 +666,14 @@ test('ramExpandedReport summarizes the query forms and the fallbacks', () => { { p1: '10', label: 'ELFs', compact: true, form: 'lean', sw: '9000', entries: 3 }, { p1: '20', label: 'ELFs', compact: true, form: 'notags', sw: '9000', entries: 5 }, ]), 'Expanded registry: lean tag list (ELFs)'); + // a request/transport failure is not a remote status word + assert.strictEqual(ramExpandedReport([ + { p1: '40', label: 'Apps', compact: true, form: 'tags', sw: '9000', entries: 9 }, + { p1: '10', label: 'ELFs', compact: true, form: null, sw: 'index out of range', transport: true }, + ]), 'Expanded registry: tag list + EA (Apps) \u00b7 ELFs: request error (index out of range) \u2014 compact listing used'); + assert.strictEqual(ramExpandedReport([ + { p1: '10', label: 'ELFs', compact: false, form: null, sw: 'index out of range', transport: true }, + ]), 'Expanded registry refused by the card \u2014 compact listing only (package versions unavailable) ELFs: request error (index out of range)'); assert.strictEqual(ramExpandedReport([]), ''); delete globalThis.t; }); @@ -707,6 +715,8 @@ test('the Explore detects the expanded query form and reports it', () => { assert.ok(src.includes('ramChainGetResponse(apdu)'), '61xx must be answered with a chained GET RESPONSE'); assert.ok(!src.includes('00C00000'), 'no standalone GET RESPONSE (it answers 6700)'); assert.ok(src.includes('ramExpandedQueryApdu(p1, form, page > 1)'), '6310 must page'); + // a transport failure must not be reported as a remote status word + assert.ok(src.includes('transportError'), 'request errors are marked as such'); }); test('ramParseAppStatus parses the expanded E3 listing (vendor traces)', () => { diff --git a/pyproject.toml b/pyproject.toml index 43a22e7..3076de6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "3.6.56" +version = "3.6.57" description = "HTTP REST server wrapping pysim for the SIMple PWA" requires-python = ">=3.8" # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py index 2a3b464..3004073 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -31,7 +31,7 @@ from osmocom.tlv import BER_TLV_IE from osmocom.utils import rpad -VERSION = '3.6.56' +VERSION = '3.6.57' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE @@ -2070,11 +2070,7 @@ def _decode_poll_negotiation(data_hex): return None result = None unit = interval = None - off = 0 - while off + 1 < len(data): - tag, tlen = data[off], data[off + 1] - val = data[off + 2: off + 2 + tlen] - off += 2 + tlen + for tag, val in _ber_tlv_list(data): if tag in (0x11, 0x91) and len(val) >= 1: result = val[0] elif tag in (0x04, 0x84) and len(val) >= 2: @@ -2203,11 +2199,8 @@ def _tlv_map(data): """Top-level COMPREHENSION-TLV map {tag: value} of a payload without a D0 wrapper (e.g. the command-specific TLVs of a TERMINAL RESPONSE).""" out = {} - off = 0 - while off + 1 < len(data): - tag, tlen = data[off], data[off + 1] - out.setdefault(tag, data[off + 2: off + 2 + tlen]) - off += 2 + tlen + for tag, val in _ber_tlv_list(data or b''): + out.setdefault(tag, val) return out @@ -4221,6 +4214,38 @@ def _skip_ber_len(raw, off): return off + 3 +def _ber_tlv_list(data): + """[(tag, value)] of a BER-TLV sequence, stopping at a truncated or + malformed TLV instead of walking into the value. + + The length MUST be read as BER: the big listings carry long-form lengths + (`8B 81 96` = the 150-byte SMS TPDU) and a single-byte read walks into the + value, eventually indexing past the end (live 2026-09-30: `IndexError: + index out of range` in the FETCH path - a SEND SHORT MESSAGE whose TLV + list was mis-walked).""" + out = [] + off = 0 + while off + 1 < len(data): + tag = data[off] + tlen, voff = _ber_len_at(data, off + 1) + if voff + tlen > len(data): + break + out.append((tag, data[voff:voff + tlen])) + if voff + tlen <= off: # no forward progress: stop + break + off = voff + tlen + return out + + +def _proactive_body(raw): + """The TLV region of a D0 proactive command (after its BER length).""" + if not raw or raw[0] != 0xD0: + return b'' + ln, off = _ber_len_at(raw, 1) + end = off + ln if 0 < ln <= len(raw) - off else len(raw) + return raw[off:end] + + # ---- TIMER MANAGEMENT (TS 102 223 6.6.21, 6.8.13/14, 7.4) ----------------- # The terminal keeps up to 8 timers per card session. On expiry it must send # ENVELOPE (TIMER EXPIRATION, tag D7) so the card can act (a common OTA retry @@ -4362,18 +4387,14 @@ def _parse_proactive_header(raw): cmd_num, cmd_type = 1, 0 cmd_qual = None dev_src, dev_dst = 0x83, 0x81 - if raw[0] == 0xD0: - off = _skip_ber_len(raw, 1) - while off < len(raw) - 1: - tag, tlen = raw[off], raw[off + 1] - val = raw[off + 2: off + 2 + tlen]; off += 2 + tlen - # Cards use both the plain (01/02) and comprehension-required - # (81/82) tag variants - TS 101 220 7.1.1 leaves the CR flag to - # the application, and the reference cards switch between them. - if tag in (0x01, 0x81) and tlen >= 3: - cmd_num, cmd_type, cmd_qual = val[0], val[1], val[2] - elif tag in (0x02, 0x82) and tlen >= 2: - dev_src, dev_dst = val[0], val[1] + for tag, val in _ber_tlv_list(_proactive_body(raw)): + # Cards use both the plain (01/02) and comprehension-required + # (81/82) tag variants - TS 101 220 7.1.1 leaves the CR flag to the + # application, and the reference cards switch between them. + if tag in (0x01, 0x81) and len(val) >= 3: + cmd_num, cmd_type, cmd_qual = val[0], val[1], val[2] + elif tag in (0x02, 0x82) and len(val) >= 2: + dev_src, dev_dst = val[0], val[1] return cmd_num, cmd_type, dev_src, dev_dst, cmd_qual @@ -4479,13 +4500,9 @@ def _parse_sms_concat(tpdu_bytes): def _parse_display_text(raw): - if raw[0] == 0xD0: - off = _skip_ber_len(raw, 1) - while off < len(raw) - 1: - tag, tlen = raw[off], raw[off + 1] - val = raw[off + 2: off + 2 + tlen]; off += 2 + tlen - if tag in (0x8D, 0x0D) and tlen >= 1: - return _decode_dcs_text(val) + for tag, val in _ber_tlv_list(_proactive_body(raw)): + if tag in (0x8D, 0x0D) and val: + return _decode_dcs_text(val) return None @@ -4598,35 +4615,26 @@ def _parse_get_input(raw): def _parse_select_item(raw): items = [] nai = None - if raw[0] == 0xD0: - off = _skip_ber_len(raw, 1) - while off < len(raw) - 1: - tag, tlen = raw[off], raw[off + 1] - val = raw[off + 2: off + 2 + tlen]; off += 2 + tlen - if tag in (0x85, 0x05) and tlen >= 1: - try: - _title = _decode_stk_text(val) - except Exception: - pass - elif tag in (0x8F, 0x0F) and tlen >= 2: - items.append({'id': val[0], 'text': _decode_stk_text(val[1:])}) - elif tag in (0x18, 0x98) and tlen >= 1: - nai = val + for tag, val in _ber_tlv_list(_proactive_body(raw)): + if tag in (0x85, 0x05) and val: + try: + _title = _decode_stk_text(val) + except Exception: + pass + elif tag in (0x8F, 0x0F) and len(val) >= 2: + items.append({'id': val[0], 'text': _decode_stk_text(val[1:])}) + elif tag in (0x18, 0x98) and val: + nai = val return _attach_nai(items, nai) def _parse_setup_menu_items(raw): items = [] nai = None - if not raw or raw[0] != 0xD0: - return items - off = _skip_ber_len(raw, 1) - while off < len(raw) - 1: - tag, tlen = raw[off], raw[off + 1] - val = raw[off + 2: off + 2 + tlen]; off += 2 + tlen - if tag in (0x8F, 0x0F) and tlen >= 2: + for tag, val in _ber_tlv_list(_proactive_body(raw)): + if tag in (0x8F, 0x0F) and len(val) >= 2: items.append({'id': val[0], 'text': _decode_stk_text(val[1:])}) - elif tag in (0x18, 0x98) and tlen >= 1: + elif tag in (0x18, 0x98) and val: nai = val return _attach_nai(items, nai) @@ -4655,23 +4663,42 @@ def _run_proactive_chain(scc, sw91, on_fetch=None, status_poll=True): fdata, sw = rv[0], rv[1] raw = bytes.fromhex(fdata) if fdata else None action = None + cmd_num, cmd_type, dev_src, dev_dst, cmd_qual = 1, 0, 0x83, 0x81, None + entry = None if raw: - cmd_num, cmd_type, dev_src, dev_dst, cmd_qual = _parse_proactive_header(raw) - entry = _log_proactive(cmd_type, raw, cmd_qual, cmd_num) - else: - cmd_num, cmd_type, dev_src, dev_dst, cmd_qual = 1, 0, 0x83, 0x81, None - entry = None - if raw and cmd_type in (0x03, 0x04): - _handle_card_poll_command(cmd_type, raw) - if on_fetch: - action = on_fetch(raw, cmd_num, cmd_type, dev_src, dev_dst) + # Every step is guarded: a decoder bug must never break the + # FETCH/TERMINAL RESPONSE conversation (live 2026-09-30: an + # IndexError in the header parser turned into a 500 with the + # card's command left unanswered). The exception and the + # offending bytes go to the log; the TR is still sent. + try: + cmd_num, cmd_type, dev_src, dev_dst, cmd_qual = _parse_proactive_header(raw) + except Exception as e: + sys.stderr.write('FETCH parse error: %s (raw=%s)\n' % (e, raw.hex())) + try: + entry = _log_proactive(cmd_type, raw, cmd_qual, cmd_num) + except Exception as e: + sys.stderr.write('FETCH log error: %s (raw=%s)\n' % (e, raw.hex())) + try: + if raw and cmd_type in (0x03, 0x04): + _handle_card_poll_command(cmd_type, raw) + if on_fetch: + action = on_fetch(raw, cmd_num, cmd_type, dev_src, dev_dst) + except Exception as e: + sys.stderr.write('FETCH handler error: %s (raw=%s)\n' % ( + e, raw.hex() if raw else '(none)')) paused = action == 'pause' if not paused: tr_tlv = None - if raw and cmd_type in (0x40, 0x41, 0x42, 0x43, 0x44): - tr_tlv = _handle_bip_command(scc, cmd_num, cmd_type, cmd_qual, raw, dev_src, dev_dst) - if cmd_type == 0x27: - tr_tlv = _handle_timer_command(cmd_num, cmd_type, cmd_qual, raw, dev_src, dev_dst) + try: + if raw and cmd_type in (0x40, 0x41, 0x42, 0x43, 0x44): + tr_tlv = _handle_bip_command(scc, cmd_num, cmd_type, cmd_qual, raw, dev_src, dev_dst) + if cmd_type == 0x27: + tr_tlv = _handle_timer_command(cmd_num, cmd_type, cmd_qual, raw, dev_src, dev_dst) + except Exception as e: + sys.stderr.write('FETCH handler error: %s (raw=%s)\n' % ( + e, raw.hex() if raw else '(none)')) + tr_tlv = None if tr_tlv is None: tr_tlv = _build_tr(scc, cmd_num, cmd_type, dev_src, dev_dst, cmd_qual) tr_rv = scc._tp.send_apdu('%s140000%02x%s' % (scc.cat_cla, len(tr_tlv), tr_tlv.hex())) diff --git a/tests/test_ota_helpers.py b/tests/test_ota_helpers.py index c89e787..9b65d14 100644 --- a/tests/test_ota_helpers.py +++ b/tests/test_ota_helpers.py @@ -32,6 +32,9 @@ from pysim_simple_server.server import ( _parse_setup_menu_items, _calc_ud_offset, _find_sms_tpdu, + _parse_proactive_header, + _parse_display_text, + _tlv_map, _parse_response_scripting, _parse_sms_concat, _por_remote_sw, @@ -1588,6 +1591,81 @@ class RamSendGpApduLoggingTest(unittest.TestCase): self.assertIn('RAM SECURED-PACKET (LOAD (1/2)): AABB', out) +class ProactiveLongTlvTests(unittest.TestCase): + """BER long-form lengths in proactive command TLVs (v3.6.57). + + The live card's SEND SHORT MESSAGE carries its 150-byte SMS TPDU as + `8B 81 96`: reading the length as one byte walked into the TPDU and + eventually indexed past the end (`IndexError: index out of range`, live + 2026-09-30, 500 in the FETCH path with the command left unanswered).""" + + # the live FETCH shape: command details + device identities + address + + # the long-form SMS TPDU TLV + TPDU = bytes.fromhex( + '410005812143f500f68c0700030302017100008d0a00000000000000bb0000029000e3284f10' + 'a1130001180001ffffffff89a10039009f7001018410a1130001180001ffffffff89a1003908' + 'e33a4f10a1130001180002fff7100e89040002009f7001018410a1130001180002fff7100e' + '89040002088410a1130001180002fff7100e89494d4508e3174f07f0414c464160019f7001') + RAW = (bytes.fromhex('d081ab8103011300820281838607919733824009f08b8196') + + TPDU + bytes(range(len(TPDU), 150))) + + def test_header_parses_with_a_long_form_tlv(self): + self.assertEqual(_parse_proactive_header(self.RAW), (1, 0x13, 0x81, 0x83, 0)) + # the long TLV is found as one TLV, not mis-walked into its value + self.assertEqual(sorted(_tlv_map(self.RAW[3:]).keys()), [0x81, 0x82, 0x86, 0x8B]) + self.assertEqual(len(_find_sms_tpdu(self.RAW)), 150 * 2) + + def test_a_truncated_tlv_does_not_raise(self): + # a declared length running past the data ends the walk + raw = bytes.fromhex('d004810381') + self.assertEqual(_parse_proactive_header(raw), (1, 0, 0x83, 0x81, None)) + self.assertEqual(_tlv_map(bytes.fromhex('810381')), {}) + # display text with a long-form text TLV (144 bytes -> 8D 81 90) + text = bytes([0x8D, 0x81, 0x90]) + b'\x00' * 0x90 + head = bytes([0x81, 0x03, 0x01, 0x21, 0x00]) + raw2 = bytes([0xD0, 0x81, len(head) + len(text)]) + head + text + self.assertIsNotNone(_parse_display_text(raw2)) + + +class ProactiveChainHardeningTests(unittest.TestCase): + """A decoder bug must never leave a FETCH unanswered (v3.6.57): every + step of the FETCH/TR loop is guarded and the TR is still sent.""" + + def test_a_raising_parser_still_gets_a_terminal_response(self): + from pysim_simple_server import server as srv + + class FakeTp: + def __init__(self): + self.sent = [] + self.proactive_handler = None + + def send_apdu(self, apdu): + self.sent.append(apdu) + if apdu.startswith('80120000'): # FETCH + return ('d0048103011300', '9000') + return ('', '9000') + + class FakeScc: + cat_cla = '80' + + def __init__(self): + self._tp = FakeTp() + + scc = FakeScc() + orig = srv._parse_proactive_header + + def boom(raw): + raise IndexError('index out of range') + + try: + srv._parse_proactive_header = boom + srv._run_proactive_chain(scc, '91A8', None, status_poll=False) + finally: + srv._parse_proactive_header = orig + self.assertTrue(any(a.startswith('80140000') for a in scc._tp.sent), + 'the FETCH must be answered: %r' % (scc._tp.sent,)) + + class RamNvFootprintTests(unittest.TestCase): """GET DATA FF21 reads around an install (v3.6.51): the free-NV values and their delta. The reads are best-effort - a card without FF21 (or a