fix: accept comprehension-required tag variants everywhere (v3.6.40)

The TERMINAL PROFILE chain parsed the SET UP MENU title with 'tag == 0x05'
only, so the live Alfa card's title (CR-set 0x85, 'Alfa Mobile') was
dropped: /api/menu returned items without a title and the PWA showed
'No menu set by the card' - card-specific only because cards pick the tag
variant (this one sends plain 0x8F items and a CR-set title in the same
command).

- _parse_setup_menu_command (pure, tested with the live FETCH bytes)
  replaces the inline parse; the PWA shows a title-less menu as 'STK: Menu'
  instead of 'No menu set by the card' (stkMenuLabel, pure).
- _cmd_tlv is now the single lookup helper: exact tag, then tag ^ 0x80
  (bit 8 is the CR flag, TS 101 220 7.1.1); the old '| 0x80'-only helper and
  the duplicate _cmd_tlv_any are gone.
- Remaining single-style checks fixed: _parse_setup_menu_items (0x8F/0x0F),
  _parse_select_item title (0x85/0x05), _find_sms_tpdu (0x8B/0x0B - the
  plain variant lost the SEND SHORT MESSAGE PoR TPDU), the response
  scripting parsers (R-APDU 0x23/0xA3, executed count 0x80/0x00) and the
  FF21 resource tags (0x81-0x83/0x01-0x03).
- tests/test_tag_variants.py flips the CR flag on every top-level tag of
  real command fixtures and asserts identical parse results for all
  parsers (verified to fail when a single-style lookup is reintroduced).

sw cache simple-v313; tests: 524 Python / 675 Node.
This commit is contained in:
2026-09-29 01:54:39 +03:00
parent 61a52a3f05
commit 904eca1758
9 changed files with 250 additions and 54 deletions
+1 -1
View File
@@ -488,7 +488,7 @@
<p class="text-sm mb-3">Работа с сессией Card Application Toolkit. Четыре подвкладки: <strong>&laquo;Телефон&raquo;</strong> (меню STK, STATUS и опрос, подписанные события, журнал проактивных команд), <strong>&laquo;Конфигурация TR&raquo;</strong> (данные ответов, подставляемые в TERMINAL RESPONSE для проактивных команд), <strong>&laquo;eSIM LPA&raquo;</strong> (локальные операции с eUICC) и <strong>&laquo;Тест-скрипт&raquo;</strong> (сценарные тест-скрипты карты с ожиданием проактивных команд, см. <a href="#test-script" class="text-blue-600 dark:text-blue-400 hover:underline">&sect;8.9</a>).</p> <p class="text-sm mb-3">Работа с сессией Card Application Toolkit. Четыре подвкладки: <strong>&laquo;Телефон&raquo;</strong> (меню STK, STATUS и опрос, подписанные события, журнал проактивных команд), <strong>&laquo;Конфигурация TR&raquo;</strong> (данные ответов, подставляемые в TERMINAL RESPONSE для проактивных команд), <strong>&laquo;eSIM LPA&raquo;</strong> (локальные операции с eUICC) и <strong>&laquo;Тест-скрипт&raquo;</strong> (сценарные тест-скрипты карты с ожиданием проактивных команд, см. <a href="#test-script" class="text-blue-600 dark:text-blue-400 hover:underline">&sect;8.9</a>).</p>
<h3 id="stk-menu" class="text-lg font-medium mb-2">8.1 Меню STK</h3> <h3 id="stk-menu" class="text-lg font-medium mb-2">8.1 Меню STK</h3>
<p class="text-sm mb-3">Если карта выдала команду SET UP MENU, вверху этого представления появляется блок &laquo;Меню STK&raquo; с изумрудной кнопкой <strong>STK: &lt;название&gt;</strong>, открывающей оверлей меню (браузер STK-меню карты). Если карта не задала меню, вместо кнопки показывается &laquo;Меню не задано картой&raquo;. Состояние меню обновляется при каждом открытии представления. Пункты меню, для которых карта передала <strong>Next Action Indicator</strong> (TS 102 223 &sect;8.24), показывают его серым суффиксом (например, <code class="font-mono text-sm">&rarr; SET UP MENU</code>) &mdash; что карта выполнит при выборе пункта. Интерактивные проактивные команды всегда получают TERMINAL RESPONSE: оверлей ждёт вашего выбора, и если вы не ответили и не нажали <strong>Timeout</strong>, сервер сам отвечает результатом timeout через <code class="font-mono text-sm">--menu-timeout</code> секунд (по умолчанию 60, <code class="font-mono text-sm">0</code> отключает). <strong>Назад</strong> и <strong>Timeout</strong> продолжают диалог с картой: если карта в ответ выдаёт следующую проактивную команду (SELECT ITEM или DISPLAY TEXT), панель показывает её; кэшированное верхнее меню появляется только когда карте больше нечего выполнять. Оверлей также отвечает на запросы карты <strong>GET INKEY</strong> и <strong>GET INPUT</strong> (TS 102 223 &sect;6.4.2/&sect;6.4.3): панель показывает текст карты и поле ввода, ограничения которого заданы командой (длина ответа min&ndash;max, цифровая клавиатура, скрытый ввод, подставленный текст по умолчанию, кнопки Да/Нет для GET INKEY, справка, если карта её объявила), а ответ возвращается в Text string внутри TERMINAL RESPONSE в запрошенной кодировке (SMS-алфавит unpacked, UCS2 или packed).</p> <p class="text-sm mb-3">Если карта выдала команду SET UP MENU, вверху этого представления появляется блок &laquo;Меню STK&raquo; с изумрудной кнопкой <strong>STK: &lt;название&gt;</strong>, открывающей оверлей меню (браузер STK-меню карты). Если карта не задала меню, вместо кнопки показывается &laquo;Меню не задано картой&raquo;; SET UP MENU без заголовка (alpha identifier необязателен) показывается как <strong>STK: Меню</strong>. Состояние меню обновляется при каждом открытии представления. Пункты меню, для которых карта передала <strong>Next Action Indicator</strong> (TS 102 223 &sect;8.24), показывают его серым суффиксом (например, <code class="font-mono text-sm">&rarr; SET UP MENU</code>) &mdash; что карта выполнит при выборе пункта. Интерактивные проактивные команды всегда получают TERMINAL RESPONSE: оверлей ждёт вашего выбора, и если вы не ответили и не нажали <strong>Timeout</strong>, сервер сам отвечает результатом timeout через <code class="font-mono text-sm">--menu-timeout</code> секунд (по умолчанию 60, <code class="font-mono text-sm">0</code> отключает). <strong>Назад</strong> и <strong>Timeout</strong> продолжают диалог с картой: если карта в ответ выдаёт следующую проактивную команду (SELECT ITEM или DISPLAY TEXT), панель показывает её; кэшированное верхнее меню появляется только когда карте больше нечего выполнять. Оверлей также отвечает на запросы карты <strong>GET INKEY</strong> и <strong>GET INPUT</strong> (TS 102 223 &sect;6.4.2/&sect;6.4.3): панель показывает текст карты и поле ввода, ограничения которого заданы командой (длина ответа min&ndash;max, цифровая клавиатура, скрытый ввод, подставленный текст по умолчанию, кнопки Да/Нет для GET INKEY, справка, если карта её объявила), а ответ возвращается в Text string внутри TERMINAL RESPONSE в запрошенной кодировке (SMS-алфавит unpacked, UCS2 или packed).</p>
<h3 id="subscribed-events" class="text-lg font-medium mb-2">8.2 Подписанные события (SET UP EVENT LIST)</h3> <h3 id="subscribed-events" class="text-lg font-medium mb-2">8.2 Подписанные события (SET UP EVENT LIST)</h3>
<p class="text-sm mb-2">События, которые отслеживает карта. У каждого события есть кнопка <strong>Отправить</strong>, открывающая форму, специфичную для типа события:</p> <p class="text-sm mb-2">События, которые отслеживает карта. У каждого события есть кнопка <strong>Отправить</strong>, открывающая форму, специфичную для типа события:</p>
+1 -1
View File
@@ -487,7 +487,7 @@
<p class="text-sm mb-3">Interacts with the Card Application Toolkit session. The view has four pills: <strong>Phone</strong> (STK menu, STATUS and polling, subscribed events, proactive command log), <strong>TR Config</strong> (response data injected into TERMINAL RESPONSEs for proactive commands), <strong>eSIM LPA</strong> (local eUICC operations) and <strong>Test script</strong> (scripted card test scripts with proactive-command expectations, see <a href="#test-script" class="text-blue-600 dark:text-blue-400 hover:underline">&sect;8.9</a>).</p> <p class="text-sm mb-3">Interacts with the Card Application Toolkit session. The view has four pills: <strong>Phone</strong> (STK menu, STATUS and polling, subscribed events, proactive command log), <strong>TR Config</strong> (response data injected into TERMINAL RESPONSEs for proactive commands), <strong>eSIM LPA</strong> (local eUICC operations) and <strong>Test script</strong> (scripted card test scripts with proactive-command expectations, see <a href="#test-script" class="text-blue-600 dark:text-blue-400 hover:underline">&sect;8.9</a>).</p>
<h3 id="stk-menu" class="text-lg font-medium mb-2">8.1 STK menu</h3> <h3 id="stk-menu" class="text-lg font-medium mb-2">8.1 STK menu</h3>
<p class="text-sm mb-3">When the card has issued a SET UP MENU command, a &ldquo;STK menu&rdquo; block appears at the top of this view with an emerald <strong>STK: &lt;title&gt;</strong> button that opens the menu overlay (same as the card&rsquo;s STK menu browser). If the card has not set up a menu, the block shows &ldquo;No menu set by the card&rdquo; instead. The menu state is refreshed each time the view is opened. Menu items whose card-provided <strong>Next Action Indicator</strong> (TS 102 223 &sect;8.24) is present show it as a small gray suffix (e.g. <code class="font-mono text-sm">&rarr; SET UP MENU</code>) &mdash; what the card would do when the item is selected. User-interactive proactive commands always get a TERMINAL RESPONSE: the overlay pauses for your choice, and if you neither answer nor press <strong>Timeout</strong>, the server answers with a timeout result after the <code class="font-mono text-sm">--menu-timeout</code> seconds (default 60, <code class="font-mono text-sm">0</code> disables). <strong>Back</strong> and <strong>Timeout</strong> keep the dialogue with the card going: when the card replies with a further proactive command (SELECT ITEM or DISPLAY TEXT) the panel shows it; the cached top menu appears only when the card has nothing more to execute. The overlay also answers the card&rsquo;s <strong>GET INKEY</strong> and <strong>GET INPUT</strong> prompts (TS 102 223 &sect;6.4.2/&sect;6.4.3): the panel shows the card&rsquo;s text with an input field whose constraints come from the command (Response length min&ndash;max, digits-only keyboard, hidden entry, default text prefilled, Yes/No buttons for GET INKEY, help when the card advertises it), and the answer is returned in the TERMINAL RESPONSE&rsquo;s Text string in the requested coding (SMS default alphabet unpacked, UCS2 or packed).</p> <p class="text-sm mb-3">When the card has issued a SET UP MENU command, a &ldquo;STK menu&rdquo; block appears at the top of this view with an emerald <strong>STK: &lt;title&gt;</strong> button that opens the menu overlay (same as the card&rsquo;s STK menu browser). If the card has not set up a menu, the block shows &ldquo;No menu set by the card&rdquo; instead; a SET UP MENU without a title (the alpha identifier is optional) shows as <strong>STK: Menu</strong>. The menu state is refreshed each time the view is opened. Menu items whose card-provided <strong>Next Action Indicator</strong> (TS 102 223 &sect;8.24) is present show it as a small gray suffix (e.g. <code class="font-mono text-sm">&rarr; SET UP MENU</code>) &mdash; what the card would do when the item is selected. User-interactive proactive commands always get a TERMINAL RESPONSE: the overlay pauses for your choice, and if you neither answer nor press <strong>Timeout</strong>, the server answers with a timeout result after the <code class="font-mono text-sm">--menu-timeout</code> seconds (default 60, <code class="font-mono text-sm">0</code> disables). <strong>Back</strong> and <strong>Timeout</strong> keep the dialogue with the card going: when the card replies with a further proactive command (SELECT ITEM or DISPLAY TEXT) the panel shows it; the cached top menu appears only when the card has nothing more to execute. The overlay also answers the card&rsquo;s <strong>GET INKEY</strong> and <strong>GET INPUT</strong> prompts (TS 102 223 &sect;6.4.2/&sect;6.4.3): the panel shows the card&rsquo;s text with an input field whose constraints come from the command (Response length min&ndash;max, digits-only keyboard, hidden entry, default text prefilled, Yes/No buttons for GET INKEY, help when the card advertises it), and the answer is returned in the TERMINAL RESPONSE&rsquo;s Text string in the requested coding (SMS default alphabet unpacked, UCS2 or packed).</p>
<h3 id="subscribed-events" class="text-lg font-medium mb-2">8.2 Subscribed events (SET UP EVENT LIST)</h3> <h3 id="subscribed-events" class="text-lg font-medium mb-2">8.2 Subscribed events (SET UP EVENT LIST)</h3>
<p class="text-sm mb-2">The events the card monitors. Each event has a <strong>Send</strong> button that opens a form specific to the event type:</p> <p class="text-sm mb-2">The events the card monitors. Each event has a <strong>Send</strong> button that opens a form specific to the event type:</p>
+14 -3
View File
@@ -1732,7 +1732,7 @@
// ===== Version ===== // ===== Version =====
// Single source of truth for the PWA version: shown in the header and used // Single source of truth for the PWA version: shown in the header and used
// by the server version check in pysimConnect(). // by the server version check in pysimConnect().
const SIMPLE_VERSION = '3.6.39'; const SIMPLE_VERSION = '3.6.40';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching ===== // ===== Tab switching =====
@@ -9599,13 +9599,23 @@ async function ramExecute() {
// ===== STK Menu Browser ===== // ===== STK Menu Browser =====
let stkMenuStack = []; let stkMenuStack = [];
// The STK button label from /api/menu (pure): the card's title, or a generic
// label when the SET UP MENU carried none (the alpha identifier is optional,
// TS 102 223 6.6.7 - a title-less menu must not read as "No menu set by the
// card"). Empty string = no menu.
function stkMenuLabel(data) {
if (!data || !data.items || !data.items.length) return '';
return data.title || t('Menu');
}
async function stkCheckMenu() { async function stkCheckMenu() {
try { try {
const data = await pysimFetch('/api/menu'); const data = await pysimFetch('/api/menu');
const btn = document.getElementById('stk-menu-btn'); const btn = document.getElementById('stk-menu-btn');
const none = document.getElementById('stk-menu-none'); const none = document.getElementById('stk-menu-none');
if (data && data.title && data.items && data.items.length) { const label = stkMenuLabel(data);
document.getElementById('stk-menu-title').textContent = data.title; if (label) {
document.getElementById('stk-menu-title').textContent = label;
btn.style.display = ''; btn.style.display = '';
none.style.display = 'none'; none.style.display = 'none';
} else { } else {
@@ -17641,6 +17651,7 @@ const LANG_RU = {
'Yes': 'Да', 'Yes': 'Да',
'No': 'Нет', 'No': 'Нет',
'Help': 'Справка', 'Help': 'Справка',
'Menu': 'Меню',
'one character': 'один символ', 'one character': 'один символ',
'characters': 'символов', 'characters': 'символов',
'up to': 'до', 'up to': 'до',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v312'; const CACHE = 'simple-v313';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+13
View File
@@ -26,6 +26,7 @@ code += extractFunc(html, 'stkNoPendingError') + '\n';
code += extractFunc(html, 'stkMenuPanelReset') + '\n'; code += extractFunc(html, 'stkMenuPanelReset') + '\n';
code += extractFunc(html, 'stkMenuNaiSuffix') + '\n'; code += extractFunc(html, 'stkMenuNaiSuffix') + '\n';
code += extractFunc(html, 'stkMenuItemsHtml') + '\n'; code += extractFunc(html, 'stkMenuItemsHtml') + '\n';
code += extractFunc(html, 'stkMenuLabel') + '\n';
code += extractFunc(html, 'stkInputHtml') + '\n'; code += extractFunc(html, 'stkInputHtml') + '\n';
code += extractFunc(html, 'stkInputHint') + '\n'; code += extractFunc(html, 'stkInputHint') + '\n';
code += extractFunc(html, 'stkInputValidate') + '\n'; code += extractFunc(html, 'stkInputValidate') + '\n';
@@ -181,3 +182,15 @@ test('stkInputValidate mirrors the server input rules', () => {
assert.strictEqual(stkInputValidate({ type: 'get_inkey', digits_only: true }, '*').ok, true); assert.strictEqual(stkInputValidate({ type: 'get_inkey', digits_only: true }, '*').ok, true);
assert.strictEqual(stkInputValidate({ type: 'get_inkey', digits_only: true }, 'a').ok, false); assert.strictEqual(stkInputValidate({ type: 'get_inkey', digits_only: true }, 'a').ok, false);
}); });
test('stkMenuLabel shows the card title or a generic label', () => {
globalThis.t = s => s;
// the card's title when it sent one
assert.strictEqual(stkMenuLabel({ title: 'Alfa Mobile', items: [{ id: 1 }] }), 'Alfa Mobile');
// a title-less SET UP MENU still shows the menu (TS 102 223 6.6.7)
assert.strictEqual(stkMenuLabel({ items: [{ id: 1 }] }), 'Menu');
// no items = no menu, whatever the title
assert.strictEqual(stkMenuLabel({ title: 'x', items: [] }), '');
assert.strictEqual(stkMenuLabel({ items: [] }), '');
assert.strictEqual(stkMenuLabel(null), '');
});
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "pysim-simple-server" name = "pysim-simple-server"
version = "3.6.39" version = "3.6.40"
description = "HTTP REST server wrapping pysim for the SIMple PWA" description = "HTTP REST server wrapping pysim for the SIMple PWA"
requires-python = ">=3.8" requires-python = ">=3.8"
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
+64 -45
View File
@@ -31,7 +31,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad from osmocom.utils import rpad
VERSION = '3.6.39' VERSION = '3.6.40'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -1397,7 +1397,7 @@ def _parse_response_scripting(data):
val = body[voff:voff + ln] val = body[voff:voff + ln]
if tag == 0x80 and val: if tag == 0x80 and val:
count = int.from_bytes(val, 'big') count = int.from_bytes(val, 'big')
elif tag == 0x23 and len(val) >= 2: elif tag in (0x23, 0xA3) and len(val) >= 2:
last = (val[-2:].hex().upper(), val[:-2].hex().upper()) last = (val[-2:].hex().upper(), val[:-2].hex().upper())
off = voff + ln off = voff + ln
if last is None: if last is None:
@@ -1936,9 +1936,12 @@ def _dec_imei(hex8):
def _cmd_tlv(tlvs, tag): def _cmd_tlv(tlvs, tag):
"""Fetch a command TLV, tolerating both the plain tag and its """Fetch a command TLV, tolerating both tag styles: bit 8 of the tag is
comprehension-required variant (e.g. 0x24 and 0xA4, TS 101 220 7.1.1).""" the comprehension-required flag (TS 101 220 7.1.1), so the other variant
return tlvs.get(tag) or tlvs.get(tag | 0x80) or b'' is `tag ^ 0x80` - covering both directions (0x24/0xA4, 0x8D/0x0D,
0x05/0x85). The exact tag wins. Every proactive-TLV lookup goes through
this helper; tests/test_tag_variants.py enforces it for all parsers."""
return tlvs.get(tag) or tlvs.get(tag ^ 0x80) or b''
def _tlv_map(data): def _tlv_map(data):
@@ -2594,7 +2597,7 @@ def _scp81_parse_response(body):
off = voff + tlen off = voff + tlen
if tag == 0x80: if tag == 0x80:
count = int.from_bytes(val, 'big') if val else 0 count = int.from_bytes(val, 'big') if val else 0
elif tag == 0x23 and len(val) >= 2: elif tag in (0x23, 0xA3) and len(val) >= 2:
out.append((val[:-2], val[-2:].hex().upper())) out.append((val[:-2], val[-2:].hex().upper()))
return count, out return count, out
@@ -2610,11 +2613,11 @@ def _scp81_decode_memory(rapdu):
tag, tlen = content[off], content[off + 1] tag, tlen = content[off], content[off + 1]
val = content[off + 2:off + 2 + tlen] val = content[off + 2:off + 2 + tlen]
off += 2 + tlen off += 2 + tlen
if tag == 0x81: if tag in (0x81, 0x01):
out['applets'] = int.from_bytes(val, 'big') out['applets'] = int.from_bytes(val, 'big')
elif tag == 0x82: elif tag in (0x82, 0x02):
out['free_nv'] = int.from_bytes(val, 'big') out['free_nv'] = int.from_bytes(val, 'big')
elif tag == 0x83: elif tag in (0x83, 0x03):
out['free_volatile'] = int.from_bytes(val, 'big') out['free_volatile'] = int.from_bytes(val, 'big')
return out or None return out or None
@@ -4130,7 +4133,7 @@ def _find_sms_tpdu(raw):
while off < len(raw) - 1: while off < len(raw) - 1:
tag = raw[off] tag = raw[off]
tlen, val_off = _ber_len_at(raw, off + 1) tlen, val_off = _ber_len_at(raw, off + 1)
if tag == 0x8B and tlen >= 1: if tag in (0x8B, 0x0B) and tlen >= 1:
return raw[val_off:val_off + tlen].hex() return raw[val_off:val_off + tlen].hex()
nxt = val_off + tlen nxt = val_off + tlen
if nxt <= off: # no forward progress: stop if nxt <= off: # no forward progress: stop
@@ -4279,15 +4282,9 @@ def _item_line(item):
return line return line
def _cmd_tlv_any(tlvs, tag):
"""Command TLV lookup tolerating the comprehension-required variant in
both directions (0x81/0x01, 0x8D/0x0D, 0x91/0x11, TS 101 220 7.1.1)."""
return tlvs.get(tag) or tlvs.get(tag | 0x80) or tlvs.get(tag & 0x7F) or b''
def _cmd_qualifier(tlvs): def _cmd_qualifier(tlvs):
"""Command Qualifier byte of the Command details TLV (TS 102 223 8.6).""" """Command Qualifier byte of the Command details TLV (TS 102 223 8.6)."""
cd = _cmd_tlv_any(tlvs, 0x81) cd = _cmd_tlv(tlvs, 0x81)
return cd[2] if len(cd) >= 3 else None return cd[2] if len(cd) >= 3 else None
@@ -4296,7 +4293,7 @@ def _parse_get_inkey(raw):
(TS 102 223 6.6.2, 8.6, 8.15). Returns None when the template is not a (TS 102 223 6.6.2, 8.6, 8.15). Returns None when the template is not a
parseable GET INKEY.""" parseable GET INKEY."""
tlvs = httpota.proactive_tlvs(raw) tlvs = httpota.proactive_tlvs(raw)
text = _cmd_tlv_any(tlvs, 0x8D) if tlvs else b'' text = _cmd_tlv(tlvs, 0x8D) if tlvs else b''
if not text: if not text:
return None return None
qual = _cmd_qualifier(tlvs) qual = _cmd_qualifier(tlvs)
@@ -4319,12 +4316,12 @@ def _parse_get_input(raw):
Text (8.23) and the qualifier's request flags (TS 102 223 6.6.3, 8.6). Text (8.23) and the qualifier's request flags (TS 102 223 6.6.3, 8.6).
Returns None when the template is not a parseable GET INPUT.""" Returns None when the template is not a parseable GET INPUT."""
tlvs = httpota.proactive_tlvs(raw) tlvs = httpota.proactive_tlvs(raw)
text = _cmd_tlv_any(tlvs, 0x8D) if tlvs else b'' text = _cmd_tlv(tlvs, 0x8D) if tlvs else b''
if not text: if not text:
return None return None
qual = _cmd_qualifier(tlvs) qual = _cmd_qualifier(tlvs)
rl = _cmd_tlv_any(tlvs, 0x91) rl = _cmd_tlv(tlvs, 0x91)
default = _cmd_tlv_any(tlvs, 0x9D) default = _cmd_tlv(tlvs, 0x9D)
try: try:
prompt = _decode_dcs_text(text) prompt = _decode_dcs_text(text)
default_text = _decode_dcs_text(default) if default else None default_text = _decode_dcs_text(default) if default else None
@@ -4351,7 +4348,7 @@ def _parse_select_item(raw):
while off < len(raw) - 1: while off < len(raw) - 1:
tag, tlen = raw[off], raw[off + 1] tag, tlen = raw[off], raw[off + 1]
val = raw[off + 2: off + 2 + tlen]; off += 2 + tlen val = raw[off + 2: off + 2 + tlen]; off += 2 + tlen
if tag == 0x05 and tlen >= 1: if tag in (0x85, 0x05) and tlen >= 1:
try: try:
_title = _decode_stk_text(val) _title = _decode_stk_text(val)
except Exception: except Exception:
@@ -4372,7 +4369,7 @@ def _parse_setup_menu_items(raw):
while off < len(raw) - 1: while off < len(raw) - 1:
tag, tlen = raw[off], raw[off + 1] tag, tlen = raw[off], raw[off + 1]
val = raw[off + 2: off + 2 + tlen]; off += 2 + tlen val = raw[off + 2: off + 2 + tlen]; off += 2 + tlen
if tag == 0x8F and tlen >= 2: if tag in (0x8F, 0x0F) and tlen >= 2:
items.append({'id': val[0], 'text': _decode_stk_text(val[1:])}) 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 tlen >= 1:
nai = val nai = val
@@ -4441,10 +4438,49 @@ def _run_proactive_chain(scc, sw91, on_fetch=None, status_poll=True):
_bip_flush_channel_events(scc) _bip_flush_channel_events(scc)
def _parse_setup_menu_command(raw):
"""Parse a SET UP MENU proactive command: title (Alpha identifier
0x85/0x05) and items (0x8F/0x0F) with their Items Next Action Indicator.
Both TLV tag styles must be accepted (TS 101 220 7.1.1): the live Alfa
card sends the title as '85' while this parser used to look for '05'
only, so the menu arrived without a title and the PWA showed "No menu set
by the card" (2026-09-29, card-specific because of the tag variant).
Returns the menu dict or None when the template is not a SET UP MENU."""
if not raw or raw[0] != 0xD0:
return None
tlvs = httpota.proactive_tlvs(raw)
cd = _cmd_tlv(tlvs, 0x81)
if len(cd) < 3 or cd[1] != 0x25:
return None
menu = {'command_number': cd[0], 'items': []}
# an Item walk, not a TLV map: every 0x8F occurrence must be collected
off = _skip_ber_len(raw, 1)
nai = None
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:
menu['title'] = _STK_DECODE._decode(val, {}, 'stk_title')
except Exception:
menu['title'] = val.hex()
elif tag in (0x8F, 0x0F) and tlen >= 2:
try:
txt = _STK_DECODE._decode(val[1:], {}, 'stk_item')
except Exception:
txt = val[1:].hex()
menu['items'].append({'id': val[0], 'text': txt})
elif tag in (0x18, 0x98) and tlen >= 1:
nai = val
_attach_nai(menu['items'], nai)
return menu
def _send_terminal_profile(scc, tp_hex): def _send_terminal_profile(scc, tp_hex):
tp_data, tp_sw = scc._tp.send_apdu('%s100000%02x%s' % (scc.cat_cla, len(tp_hex) // 2, tp_hex)) tp_data, tp_sw = scc._tp.send_apdu('%s100000%02x%s' % (scc.cat_cla, len(tp_hex) // 2, tp_hex))
sim_menu = None sim_menu = None
sim_menu = None
event_list = None event_list = None
if tp_sw.startswith('91'): if tp_sw.startswith('91'):
sw = tp_sw sw = tp_sw
@@ -4458,36 +4494,19 @@ def _send_terminal_profile(scc, tp_hex):
raw = bytes.fromhex(fdata) raw = bytes.fromhex(fdata)
if raw[0] == 0xD0: if raw[0] == 0xD0:
off = _skip_ber_len(raw, 1) off = _skip_ber_len(raw, 1)
menu = None
items = []
nai = None
while off < len(raw) - 1: while off < len(raw) - 1:
tag, tlen = raw[off], raw[off + 1] tag, tlen = raw[off], raw[off + 1]
val = raw[off + 2: off + 2 + tlen] val = raw[off + 2: off + 2 + tlen]
off += 2 + tlen off += 2 + tlen
if tag == 0x81 and tlen >= 3: if tag in (0x81, 0x01) and tlen >= 3:
cmd_num, cmd_type, cmd_qual = val[0], val[1], val[2] cmd_num, cmd_type, cmd_qual = val[0], val[1], val[2]
if cmd_type == 0x25: elif tag in (0x82, 0x02) and tlen >= 2:
menu = {'command_number': cmd_num, 'items': items}
elif tag == 0x82 and tlen >= 2:
dev_src, dev_dst = val[0], val[1] dev_src, dev_dst = val[0], val[1]
elif tag == 0x05 and tlen >= 1 and menu is not None:
try:
menu['title'] = _STK_DECODE._decode(val, {}, 'stk_title')
except Exception:
menu['title'] = val.hex()
elif tag == 0x8F and tlen >= 2:
try:
txt = _STK_DECODE._decode(val[1:], {}, 'stk_item')
except Exception:
txt = val[1:].hex()
items.append({'id': val[0], 'text': txt})
elif tag in (0x18, 0x98) and tlen >= 1:
nai = val
elif tag in (0x99, 0x19) and tlen >= 1: elif tag in (0x99, 0x19) and tlen >= 1:
event_list = [b for b in val] event_list = [b for b in val]
if cmd_type == 0x25:
menu = _parse_setup_menu_command(raw)
if menu: if menu:
_attach_nai(items, nai)
sim_menu = menu sim_menu = menu
if fdata and cmd_type in (0x03, 0x04): if fdata and cmd_type in (0x03, 0x04):
_handle_card_poll_command(cmd_type, raw) _handle_card_poll_command(cmd_type, raw)
+30
View File
@@ -49,6 +49,7 @@ from pysim_simple_server.server import (
_menu_send_response, _menu_send_response,
_parse_get_input, _parse_get_input,
_parse_get_inkey, _parse_get_inkey,
_parse_setup_menu_command,
_input_text_tlv, _input_text_tlv,
_validate_input_response, _validate_input_response,
_pack_gsm7, _pack_gsm7,
@@ -1805,6 +1806,35 @@ class StkInputTests(unittest.TestCase):
self.assertIn('04020103', server.scc._tp.sent[0].upper()) self.assertIn('04020103', server.scc._tp.sent[0].upper())
class SetupMenuParseTests(unittest.TestCase):
"""SET UP MENU parsing must accept both TLV tag styles (v3.6.40)."""
# The live Alfa card's SET UP MENU as fetched during the TERMINAL PROFILE
# chain (2026-09-29): the title is the CR-set tag 0x85, the items 0x8F.
LIVE_FETCH = bytes.fromhex(
'd03c810301250082028182850b416c6661204d6f62696c65'
'8f16808112089db0c1c2c0beb9bab82f53657474696e6773'
'8f0881810400d4e5f3f418020021')
def test_live_card_title_and_items_are_parsed(self):
menu = _parse_setup_menu_command(self.LIVE_FETCH)
self.assertEqual(menu['command_number'], 1)
self.assertEqual(menu['title'], 'Alfa Mobile')
self.assertEqual(menu['items'][0], {'id': 0x80, 'text': 'Настройки/Settings'})
self.assertEqual(menu['items'][1],
{'id': 0x81, 'text': 'Test', 'nai': 0x21, 'nai_name': 'DISPLAY TEXT'})
def test_plain_tag_variants_are_accepted(self):
# same command with the plain title tag 0x05 and a plain 0x0F item
raw = bytes.fromhex('d012810301250082028182850b416c6661204d6f62696c650f00')
menu = _parse_setup_menu_command(raw)
self.assertEqual(menu['title'], 'Alfa Mobile')
self.assertEqual(menu['items'], [])
# a command that is not SET UP MENU has no menu
self.assertIsNone(_parse_setup_menu_command(bytes.fromhex('d009810301260182028182')))
self.assertIsNone(_parse_setup_menu_command(b''))
class RamProgressTests(unittest.TestCase): class RamProgressTests(unittest.TestCase):
"""Live RAM-operation progress exposed in /api/status (v3.6.34).""" """Live RAM-operation progress exposed in /api/status (v3.6.34)."""
+123
View File
@@ -0,0 +1,123 @@
"""Every proactive-command TLV parser must accept both tag styles.
Bit 8 of a COMPREHENSION-TLV tag is the comprehension-required flag
(TS 101 220 7.1.1) and real cards switch between the plain and the CR-set
variant per command - and sometimes per TLV within one command (the reference
traces show OPEN CHANNEL with `01 03` and SEND DATA with `81 03` from the
same card). A single-style lookup silently drops data: the live Alfa card's
SET UP MENU title arrives as tag `85`, and looking for `05` only made the PWA
show "No menu set by the card" (2026-09-29).
This is the drift guard for the rule implemented by `server._cmd_tlv`: it
flips the flag on every top-level tag of real command fixtures and asserts
that each parser returns exactly the same result. Add a fixture here for
every new proactive-command parser.
"""
import unittest
from pysim_simple_server.server import (
_ber_len_at,
_decode_cmd,
_find_duration,
_find_sms_tpdu,
_parse_display_text,
_parse_get_inkey,
_parse_get_input,
_parse_proactive_header,
_parse_response_scripting,
_parse_select_item,
_parse_setup_menu_command,
_parse_setup_menu_items,
_scp81_decode_memory,
_scp81_parse_response,
_skip_ber_len,
)
# The live Alfa card's SET UP MENU (2026-09-29): CR-set title 0x85, items 0x8F.
LIVE_MENU = bytes.fromhex(
'd03c810301250082028182850b416c6661204d6f62696c65'
'8f16808112089db0c1c2c0beb9bab82f53657474696e6773'
'8f0881810400d4e5f3f418020021')
# The live RemMobileID GET INPUT (2026-09-29): qualifier 04 = digits + hidden.
LIVE_GET_INPUT = bytes.fromhex(
'd03c8103012304820281828d2d0804120432043504340438044204350020004d006f00'
'620069006c0065002d00490044002000500049004e003191020410')
SEND_SM = bytes.fromhex('d0158103011300820283818b0b916106152670f900a35f020101')
OPEN_CHANNEL = bytes.fromhex(
'd02b8103014001820281828500b50103b9020200c70b076d656761666f6e2e7275'
'bc03021f90be05217f000001')
SEND_DATA = bytes.fromhex('d00e810301430182028182213701013603aabbcc')
TIMER_CR = bytes.fromhex('d011810301270082028182a40103a503417023')
TIMER_PLAIN = bytes.fromhex('d00c010301270102028182240103')
def flip_tags(raw):
"""Return the command with every top-level TLV tag's CR flag flipped."""
raw = bytes(raw)
out = bytearray(raw)
off = _skip_ber_len(raw, 1) if raw and raw[0] == 0xD0 else 0
while off < len(out) - 1:
out[off] ^= 0x80
tlen, voff = _ber_len_at(bytes(out), off + 1)
if tlen < 0:
break
off = voff + tlen
return bytes(out)
# (label, raw, parser) - the parser takes the raw command and returns a value
# that must be identical for the plain and the flipped variant.
FIXTURES = [
('proactive header', LIVE_MENU, _parse_proactive_header),
('SET UP MENU command', LIVE_MENU, _parse_setup_menu_command),
('SET UP MENU items', LIVE_MENU, _parse_setup_menu_items),
('SELECT ITEM', bytes.fromhex('d00d8103012400820281828f020141'), _parse_select_item),
('DISPLAY TEXT', bytes.fromhex('d00d8103012100820281828d020441'), _parse_display_text),
('GET INPUT (live)', LIVE_GET_INPUT, _parse_get_input),
('GET INKEY', bytes.fromhex('d00e8103012204820281828d03080041'), _parse_get_inkey),
('POLL INTERVAL duration', bytes.fromhex('d00d8103010300820283818402011e'), _find_duration),
('SEND SHORT MESSAGE TPDU', SEND_SM, _find_sms_tpdu),
('decode SET UP MENU', LIVE_MENU, lambda raw: _decode_cmd(0x25, raw, 0x00)),
('decode GET INPUT', LIVE_GET_INPUT, lambda raw: _decode_cmd(0x23, raw, 0x04)),
('decode SEND SHORT MESSAGE', SEND_SM, lambda raw: _decode_cmd(0x13, raw, 0x00)),
('decode OPEN CHANNEL', OPEN_CHANNEL, lambda raw: _decode_cmd(0x40, raw, 0x01)),
('decode SEND DATA', SEND_DATA, lambda raw: _decode_cmd(0x43, raw, 0x01)),
('decode TIMER (CR-set fixture)', TIMER_CR, lambda raw: _decode_cmd(0x27, raw, 0x00)),
('decode TIMER (plain fixture)', TIMER_PLAIN, lambda raw: _decode_cmd(0x27, raw, 0x00)),
]
class TagVariantMatrixTests(unittest.TestCase):
def test_flipping_every_tag_keeps_the_parse_result(self):
for label, raw, parser in FIXTURES:
with self.subTest(label=label):
plain = parser(raw)
flipped = parser(flip_tags(raw))
self.assertEqual(plain, flipped,
'%s differs between the tag styles' % label)
self.assertNotEqual(raw, flip_tags(raw), 'fixture has no TLVs?')
def test_response_scripting_accepts_both_rapdu_tags(self):
definite = bytes.fromhex('ab0780010123029000')
self.assertEqual(_parse_response_scripting(definite), (1, '9000', ''))
self.assertEqual(_parse_response_scripting(bytes.fromhex('ab07800101a3029000')),
(1, '9000', ''))
indefinite = bytes.fromhex('af80800101230290000000')
self.assertEqual(_parse_response_scripting(indefinite), (1, '9000', ''))
self.assertEqual(_parse_response_scripting(bytes.fromhex('af80800101a30290000000')),
(1, '9000', ''))
# the SCP81 R-APDU list parser reads the same templates
self.assertEqual(_scp81_parse_response(definite), (1, [(b'', '9000')]))
self.assertEqual(_scp81_parse_response(bytes.fromhex('ab07800101a3029000')),
(1, [(b'', '9000')]))
def test_ff21_resource_tags_accept_both_styles(self):
cr = _scp81_decode_memory(bytes.fromhex('ff210c810105820300ff000301 10'.replace(' ', '')))
plain = _scp81_decode_memory(bytes.fromhex('ff210c010105020300ff000301 10'.replace(' ', '')))
self.assertEqual(cr, {'applets': 5, 'free_nv': 0xFF00, 'free_volatile': 0x10})
self.assertEqual(plain, cr)
if __name__ == '__main__':
unittest.main()