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