From 0027c405c166c66476fc13253ea3835f5d06af4e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?=
=?UTF-8?q?=D0=B8=D0=BD?=
Date: Tue, 29 Sep 2026 01:30:40 +0300
Subject: [PATCH] feat: answer GET INKEY / GET INPUT interactively (v3.6.38)
The menu chain paused only for DISPLAY TEXT / SELECT ITEM / SET UP MENU,
so a GET INPUT was auto-answered with a bare success TR and no Text
string - the live RemMobileID PIN prompt got an empty answer.
Server: _parse_get_inkey/_parse_get_input read the prompt, qualifier
flags (digits-only, UCS2, Yes/No, immediate, hidden, packed, help),
Response length 91 and Default Text 9D; the fetch handler pauses with
them; _menu_send_response(..., text=) validates the answer (digits set,
min/max, one char for GET INKEY) and builds the Text string TLV 8D in the
requested coding (SMS unpacked 04 / UCS2 08 / packed 00 via _pack_gsm7),
Yes/No as 01/00, empty input as the null text string, help as result 13
and the variable-timeout Duration echoed (6.8.4). /api/menu-respond
takes text; /api/stk-status carries pending_data so a card-initiated
prompt opens the panel; _decode_cmd decodes both commands.
PWA: stkInputHtml/stkInputHint/stkInputValidate render the constrained
panel (password field for hidden entry, numeric keyboard, default text,
Yes/No buttons, help, immediate-digit auto-submit); OK submits through
stkMenuOk; the poll opens the panel for card-initiated prompts.
sw cache simple-v311; tests: 517 Python / 674 Node.
---
frontend/help-ru.html | 2 +-
frontend/help.html | 2 +-
frontend/index.html | 157 ++++++++++++++++++++++-
frontend/sw.js | 2 +-
frontend/tests/stk_menu.test.js | 49 ++++++++
pyproject.toml | 2 +-
pysim_simple_server/server.py | 217 ++++++++++++++++++++++++++++++--
tests/test_ota_helpers.py | 154 +++++++++++++++++++++++
8 files changed, 571 insertions(+), 14 deletions(-)
diff --git a/frontend/help-ru.html b/frontend/help-ru.html
index b7553c9..e5551bc 100644
--- a/frontend/help-ru.html
+++ b/frontend/help-ru.html
@@ -488,7 +488,7 @@
Работа с сессией Card Application Toolkit. Четыре подвкладки: «Телефон» (меню STK, STATUS и опрос, подписанные события, журнал проактивных команд), «Конфигурация TR» (данные ответов, подставляемые в TERMINAL RESPONSE для проактивных команд), «eSIM LPA» (локальные операции с eUICC) и «Тест-скрипт» (сценарные тест-скрипты карты с ожиданием проактивных команд, см. §8.9 ).
- Если карта выдала команду SET UP MENU, вверху этого представления появляется блок «Меню STK» с изумрудной кнопкой STK: <название> , открывающей оверлей меню (браузер STK-меню карты). Если карта не задала меню, вместо кнопки показывается «Меню не задано картой». Состояние меню обновляется при каждом открытии представления. Пункты меню, для которых карта передала Next Action Indicator (TS 102 223 §8.24), показывают его серым суффиксом (например, → SET UP MENU) — что карта выполнит при выборе пункта. Интерактивные проактивные команды всегда получают TERMINAL RESPONSE: оверлей ждёт вашего выбора, и если вы не ответили и не нажали Timeout , сервер сам отвечает результатом timeout через --menu-timeout секунд (по умолчанию 60, 0 отключает). Назад и Timeout продолжают диалог с картой: если карта в ответ выдаёт следующую проактивную команду (SELECT ITEM или DISPLAY TEXT), панель показывает её; кэшированное верхнее меню появляется только когда карте больше нечего выполнять.
+ Если карта выдала команду SET UP MENU, вверху этого представления появляется блок «Меню STK» с изумрудной кнопкой STK: <название> , открывающей оверлей меню (браузер STK-меню карты). Если карта не задала меню, вместо кнопки показывается «Меню не задано картой». Состояние меню обновляется при каждом открытии представления. Пункты меню, для которых карта передала Next Action Indicator (TS 102 223 §8.24), показывают его серым суффиксом (например, → SET UP MENU) — что карта выполнит при выборе пункта. Интерактивные проактивные команды всегда получают TERMINAL RESPONSE: оверлей ждёт вашего выбора, и если вы не ответили и не нажали Timeout , сервер сам отвечает результатом timeout через --menu-timeout секунд (по умолчанию 60, 0 отключает). Назад и Timeout продолжают диалог с картой: если карта в ответ выдаёт следующую проактивную команду (SELECT ITEM или DISPLAY TEXT), панель показывает её; кэшированное верхнее меню появляется только когда карте больше нечего выполнять. Оверлей также отвечает на запросы карты GET INKEY и GET INPUT (TS 102 223 §6.4.2/§6.4.3): панель показывает текст карты и поле ввода, ограничения которого заданы командой (длина ответа min–max, цифровая клавиатура, скрытый ввод, подставленный текст по умолчанию, кнопки Да/Нет для GET INKEY, справка, если карта её объявила), а ответ возвращается в Text string внутри TERMINAL RESPONSE в запрошенной кодировке (SMS-алфавит unpacked, UCS2 или packed).
8.2 Подписанные события (SET UP EVENT LIST)
События, которые отслеживает карта. У каждого события есть кнопка Отправить , открывающая форму, специфичную для типа события:
diff --git a/frontend/help.html b/frontend/help.html
index ad07ba8..82c7238 100644
--- a/frontend/help.html
+++ b/frontend/help.html
@@ -487,7 +487,7 @@
Interacts with the Card Application Toolkit session. The view has four pills: Phone (STK menu, STATUS and polling, subscribed events, proactive command log), TR Config (response data injected into TERMINAL RESPONSEs for proactive commands), eSIM LPA (local eUICC operations) and Test script (scripted card test scripts with proactive-command expectations, see §8.9 ).
- When the card has issued a SET UP MENU command, a “STK menu” block appears at the top of this view with an emerald STK: <title> button that opens the menu overlay (same as the card’s STK menu browser). If the card has not set up a menu, the block shows “No menu set by the card” instead. The menu state is refreshed each time the view is opened. Menu items whose card-provided Next Action Indicator (TS 102 223 §8.24) is present show it as a small gray suffix (e.g. → SET UP MENU) — 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 Timeout , the server answers with a timeout result after the --menu-timeout seconds (default 60, 0 disables). Back and Timeout 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.
+ When the card has issued a SET UP MENU command, a “STK menu” block appears at the top of this view with an emerald STK: <title> button that opens the menu overlay (same as the card’s STK menu browser). If the card has not set up a menu, the block shows “No menu set by the card” instead. The menu state is refreshed each time the view is opened. Menu items whose card-provided Next Action Indicator (TS 102 223 §8.24) is present show it as a small gray suffix (e.g. → SET UP MENU) — 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 Timeout , the server answers with a timeout result after the --menu-timeout seconds (default 60, 0 disables). Back and Timeout 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’s GET INKEY and GET INPUT prompts (TS 102 223 §6.4.2/§6.4.3): the panel shows the card’s text with an input field whose constraints come from the command (Response length min–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’s Text string in the requested coding (SMS default alphabet unpacked, UCS2 or packed).
8.2 Subscribed events (SET UP EVENT LIST)
The events the card monitors. Each event has a Send button that opens a form specific to the event type:
diff --git a/frontend/index.html b/frontend/index.html
index 529ce1b..721a206 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1705,7 +1705,7 @@
@@ -1732,7 +1732,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.37';
+const SIMPLE_VERSION = '3.6.38';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching =====
@@ -9688,10 +9688,126 @@ async function stkMenuItemClick(itemId) {
}
}
+// User-input prompt for a pending GET INKEY / GET INPUT (pure). The card's
+// constraints drive the field: hidden entry masks the value (TS 102 223
+// 6.4.3), digits-only switches the keyboard, Response length bounds the text.
+function stkInputHtml(pd) {
+ pd = pd || {};
+ let html = '' + esc(pd.text || '') + '
';
+ if (pd.yes_no) {
+ html += '' +
+ '' + esc(t('Yes')) + ' ' +
+ '' + esc(t('No')) + ' ' +
+ '
';
+ return html;
+ }
+ const max = Number(pd.max) || 0;
+ const attrs = [
+ 'id="stk-input-field"',
+ 'class="w-full px-2 py-1 text-sm rounded border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-900 font-mono"',
+ 'type="' + (pd.hidden ? 'password' : 'text') + '"',
+ pd.digits_only ? 'inputmode="numeric"' : '',
+ (max && max !== 0xFF) ? 'maxlength="' + max + '"' : '',
+ pd.immediate ? 'oninput="stkInputImmediate()"' : '',
+ 'value="' + esc(pd.default || '') + '"',
+ ].filter(Boolean).join(' ');
+ html += ' ';
+ const hint = stkInputHint(pd);
+ if (hint) html += '' + esc(hint) + '
';
+ if (pd.help) {
+ html += '' + esc(t('Help')) + '
';
+ }
+ return html;
+}
+
+// '4–16 characters · digits 0-9 * # + · hidden entry' (pure).
+function stkInputHint(pd) {
+ pd = pd || {};
+ const parts = [];
+ const lo = Number(pd.min) || 0;
+ const hi = Number(pd.max) || 0;
+ const hasHi = hi && hi !== 0xFF;
+ if (pd.type === 'get_inkey') {
+ parts.push(t('one character'));
+ } else if (lo && hasHi) {
+ parts.push(lo + '\u2013' + hi + ' ' + t('characters'));
+ } else if (hasHi) {
+ parts.push(t('up to') + ' ' + hi + ' ' + t('characters'));
+ } else if (lo) {
+ parts.push(t('At least') + ' ' + lo + ' ' + t('characters'));
+ }
+ if (pd.digits_only || pd.hidden) parts.push(t('digits 0-9 * # +'));
+ if (pd.hidden) parts.push(t('hidden entry'));
+ return parts.join(' \u00b7 ');
+}
+
+// Validate the typed answer against the card's request (pure; mirrors the
+// server rules so the panel can explain before sending).
+function stkInputValidate(pd, value) {
+ pd = pd || {};
+ const v = value == null ? '' : String(value);
+ const digits = /^[0-9*#+]*$/;
+ if (pd.type === 'get_inkey') {
+ if (v.length !== 1) return { ok: false, error: t('Enter exactly one character') };
+ if (pd.digits_only && !digits.test(v)) return { ok: false, error: t('Digits only (0-9, *, #, +)') };
+ return { ok: true };
+ }
+ const lo = Number(pd.min) || 0;
+ const hi = Number(pd.max) || 0;
+ if (v.length < lo) return { ok: false, error: t('At least') + ' ' + lo + ' ' + t('characters') };
+ if (hi && hi !== 0xFF && v.length > hi) return { ok: false, error: t('At most') + ' ' + hi + ' ' + t('characters') };
+ if ((pd.digits_only || pd.hidden) && !digits.test(v)) return { ok: false, error: t('Digits only (0-9, *, #, +)') };
+ return { ok: true };
+}
+
+let _stkInputPending = null;
+
+// Send the typed (or Yes/No) answer; the server validates again and builds
+// the Text string TERMINAL RESPONSE.
+async function stkInputSubmit(text) {
+ const pd = _stkInputPending || {};
+ if (text !== '01' && text !== '00') {
+ const field = document.getElementById('stk-input-field');
+ const value = field ? field.value : '';
+ const v = stkInputValidate(pd, value);
+ if (!v.ok) {
+ document.getElementById('stk-menu-panel-content').insertAdjacentHTML('beforeend',
+ '' + esc(v.error) + '
');
+ return;
+ }
+ text = value;
+ }
+ const content = document.getElementById('stk-menu-panel-content');
+ content.innerHTML = '' + esc(t('Sending...')) + ' ';
+ try {
+ const data = await pysimFetch('/api/menu-respond', { result: 'ok', text: text });
+ stkMenuHandleResponse(data);
+ } catch (e) {
+ content.innerHTML = 'Error: ' + esc(e.message) + ' ';
+ }
+}
+
+// The OK button: submit the pending input, otherwise the plain 'ok' answer.
+function stkMenuOk() {
+ if (_stkInputPending && (_stkInputPending.type === 'get_input' || _stkInputPending.type === 'get_inkey')) {
+ stkInputSubmit(null);
+ return;
+ }
+ stkMenuRespond('ok');
+}
+
+// Immediate digit response (GET INKEY qualifier bit 4): pass the first
+// character on without asking for confirmation.
+function stkInputImmediate() {
+ const field = document.getElementById('stk-input-field');
+ if (field && field.value.length >= 1) stkInputSubmit(null);
+}
+
function stkMenuHandleResponse(data) {
const content = document.getElementById('stk-menu-panel-content');
const backBtn = document.getElementById('stk-back-btn');
const btns = document.getElementById('stk-menu-buttons');
+ _stkInputPending = null;
btns.classList.add('hidden');
if (data.type === 'display_text') {
content.innerHTML = '' + esc(data.text) + '
';
@@ -9704,6 +9820,12 @@ function stkMenuHandleResponse(data) {
backBtn.style.display = '';
document.getElementById('stk-ok-btn').style.display = 'none';
btns.classList.remove('hidden');
+ } else if (data.type === 'get_input' || data.type === 'get_inkey') {
+ _stkInputPending = data;
+ content.innerHTML = stkInputHtml(data);
+ backBtn.style.display = '';
+ document.getElementById('stk-ok-btn').style.display = data.yes_no ? 'none' : '';
+ btns.classList.remove('hidden');
} else {
content.innerHTML = 'SW: ' + esc(data.sw || '?') + ' ';
stkSetPillsDisabled(false);
@@ -9726,6 +9848,7 @@ async function stkSubItemClick(itemId) {
// itself (the menu watchdog's timeout TERMINAL RESPONSE, or a new selection
// that shadowed it): the dialog can no longer be answered (v3.6.35).
function stkMenuPanelReset() {
+ _stkInputPending = null;
const panel = document.getElementById('stk-menu-panel');
if (panel) panel.classList.add('hidden');
const btns = document.getElementById('stk-menu-buttons');
@@ -9734,6 +9857,18 @@ function stkMenuPanelReset() {
if (back) back.style.display = 'none';
}
+// Open the panel for a pending command the server paused on by itself (a
+// card-initiated GET INPUT / SELECT ITEM): the reply to our own click renders
+// through stkMenuHandleResponse instead.
+function stkMenuShowPending(pd) {
+ document.getElementById('stk-menu-panel-title').textContent = document.getElementById('stk-menu-title').textContent;
+ stkSetPillsDisabled(true);
+ stkMenuStack = [];
+ document.getElementById('stk-back-btn').style.display = 'none';
+ document.getElementById('stk-menu-panel').classList.remove('hidden');
+ stkMenuHandleResponse(pd);
+}
+
// The /api/menu-respond reply for a command the server already answered.
function stkNoPendingError(data) {
return !!(data && typeof data.error === 'string'
@@ -11921,6 +12056,12 @@ function pysimStartBackendPoll() {
// A command the server answered by itself must not leave a stale
// dialog behind (the panel is not refreshed when the tab is hidden)
if (stkChanged && !stk.pending) stkMenuPanelReset();
+ // A command the card sent on its own (not the reply to our click)
+ // opens the panel with its prompt (v3.6.38)
+ if (stkChanged && stk.pending && stk.pending_data && isViewVisible('tab-phone')
+ && document.getElementById('stk-menu-panel').classList.contains('hidden')) {
+ stkMenuShowPending(stk.pending_data);
+ }
if (ts) {
const wasRunning = testRunActive();
_testRunState = ts;
@@ -17497,6 +17638,18 @@ const LANG_RU = {
'Event sent.': 'Событие отправлено.',
'Event data sent.': 'Данные события отправлены.',
'Sending...': 'Отправка...',
+ 'Yes': 'Да',
+ 'No': 'Нет',
+ 'Help': 'Справка',
+ 'one character': 'один символ',
+ 'characters': 'символов',
+ 'up to': 'до',
+ 'At least': 'Не менее',
+ 'At most': 'Не более',
+ 'digits 0-9 * # +': 'цифры 0-9 * # +',
+ 'hidden entry': 'скрытый ввод',
+ 'Enter exactly one character': 'Введите ровно один символ',
+ 'Digits only (0-9, *, #, +)': 'Только цифры (0-9, *, #, +)',
'Invalid': 'Некорректное значение',
'Service Status': 'Статус сервиса',
'Normal service': 'Обычный сервис',
diff --git a/frontend/sw.js b/frontend/sw.js
index 390018e..4da6f35 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'simple-v310';
+const CACHE = 'simple-v311';
const URLS = [
'index.html',
'help.html',
diff --git a/frontend/tests/stk_menu.test.js b/frontend/tests/stk_menu.test.js
index ae59ec8..292e4ae 100644
--- a/frontend/tests/stk_menu.test.js
+++ b/frontend/tests/stk_menu.test.js
@@ -26,6 +26,9 @@ code += extractFunc(html, 'stkNoPendingError') + '\n';
code += extractFunc(html, 'stkMenuPanelReset') + '\n';
code += extractFunc(html, 'stkMenuNaiSuffix') + '\n';
code += extractFunc(html, 'stkMenuItemsHtml') + '\n';
+code += extractFunc(html, 'stkInputHtml') + '\n';
+code += extractFunc(html, 'stkInputHint') + '\n';
+code += extractFunc(html, 'stkInputValidate') + '\n';
code += 'globalThis.esc = s => s;\n';
eval(code);
@@ -132,3 +135,49 @@ test('an already-answered respond does not reach the dialog handler', async () =
assert.strictEqual(calls.handled, null);
assert.strictEqual(calls.rendered, 0);
});
+
+test('stkInputHtml builds the card-driven input field', () => {
+ globalThis.t = s => s;
+ const out = stkInputHtml({ type: 'get_input', text: 'PIN', min: 4, max: 16,
+ digits_only: true, hidden: true });
+ assert.ok(out.includes('PIN'), out);
+ assert.ok(out.includes('type="password"'), out); // hidden entry
+ assert.ok(out.includes('inputmode="numeric"'), out); // digits only
+ assert.ok(out.includes('maxlength="16"'), out);
+ assert.ok(out.includes('4\u201316 characters'), out);
+ assert.ok(out.includes('hidden entry'), out);
+ // UCS2 alphabet entry: a plain text field without the numeric keyboard
+ const ucs2 = stkInputHtml({ type: 'get_input', text: 'Name', min: 0, max: 10, ucs2: true });
+ assert.ok(ucs2.includes('type="text"'), ucs2);
+ assert.ok(!ucs2.includes('inputmode'), ucs2);
+ assert.ok(ucs2.includes('up to 10 characters'), ucs2);
+ // 0xFF = no maximum (TS 102 223 8.11): no maxlength and no range hint
+ const noMax = stkInputHtml({ type: 'get_input', text: 'x', min: 0, max: 255 });
+ assert.ok(!noMax.includes('maxlength'), noMax);
+ assert.ok(!noMax.includes('characters'), noMax);
+ // Yes/No: buttons instead of a field
+ const yesNo = stkInputHtml({ type: 'get_inkey', text: 'Continue?', yes_no: true });
+ assert.ok(yesNo.includes("stkInputSubmit('01')"), yesNo);
+ assert.ok(yesNo.includes("stkInputSubmit('00')"), yesNo);
+ assert.ok(!yesNo.includes('stk-input-field'), yesNo);
+ // the card's default text is prefilled; help adds the help action
+ const def = stkInputHtml({ type: 'get_input', text: 'x', default: '1234', help: true });
+ assert.ok(def.includes('value="1234"'), def);
+ assert.ok(def.includes("stkMenuRespond('help')"), def);
+});
+
+test('stkInputValidate mirrors the server input rules', () => {
+ globalThis.t = s => s;
+ const pd = { type: 'get_input', min: 4, max: 16, digits_only: true, hidden: true };
+ assert.strictEqual(stkInputValidate(pd, '1234').ok, true);
+ assert.strictEqual(stkInputValidate(pd, '12').ok, false);
+ assert.strictEqual(stkInputValidate(pd, '12345678901234567').ok, false);
+ assert.strictEqual(stkInputValidate(pd, 'abcd').ok, false);
+ // hidden entry allows only the digits set even without digits_only
+ assert.strictEqual(stkInputValidate({ type: 'get_input', hidden: true }, 'ab').ok, false);
+ // GET INKEY: exactly one character, digits-only honours * # +
+ assert.strictEqual(stkInputValidate({ type: 'get_inkey' }, 'a').ok, true);
+ assert.strictEqual(stkInputValidate({ type: 'get_inkey' }, 'ab').ok, false);
+ assert.strictEqual(stkInputValidate({ type: 'get_inkey', digits_only: true }, '*').ok, true);
+ assert.strictEqual(stkInputValidate({ type: 'get_inkey', digits_only: true }, 'a').ok, false);
+});
diff --git a/pyproject.toml b/pyproject.toml
index 8177aeb..7614737 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-simple-server"
-version = "3.6.37"
+version = "3.6.38"
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 7257b5e..b81442b 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.37'
+VERSION = '3.6.38'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -2129,6 +2129,33 @@ def _decode_cmd(cmd_type, raw, qualifier):
if cmd_type == 0x21:
text = _parse_display_text(raw)
return [{'label': 'Text', 'value': text}] if text else []
+ if cmd_type in (0x22, 0x23):
+ info = (_parse_get_inkey(raw) if cmd_type == 0x22
+ else _parse_get_input(raw))
+ if not info:
+ return []
+ out = [{'label': 'Text', 'value': info['text']}]
+ if cmd_type == 0x22:
+ kind = ('Yes/No' if info['yes_no'] else
+ 'digits only' if info['digits_only'] else
+ 'UCS2' if info['ucs2'] else 'SMS default alphabet')
+ if info['immediate']:
+ kind += ', immediate'
+ else:
+ kind = ('digits only' if info['digits_only'] else
+ 'UCS2' if info['ucs2'] else 'SMS default alphabet')
+ if info['hidden']:
+ kind += ', hidden'
+ if info['packed']:
+ kind += ', packed'
+ max_text = 'no max' if info['max'] in (0, 0xFF) else str(info['max'])
+ out.append({'label': 'Length', 'value': '%d..%s' % (info['min'], max_text)})
+ if info.get('default'):
+ out.append({'label': 'Default', 'value': info['default']})
+ if info['help']:
+ kind += ', help'
+ out.append({'label': 'Response', 'value': kind})
+ return out
if cmd_type == 0x24:
items = _parse_select_item(raw)
if items:
@@ -4252,6 +4279,70 @@ def _item_line(item):
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):
+ """Command Qualifier byte of the Command details TLV (TS 102 223 8.6)."""
+ cd = _cmd_tlv_any(tlvs, 0x81)
+ return cd[2] if len(cd) >= 3 else None
+
+
+def _parse_get_inkey(raw):
+ """GET INKEY (0x22) fields: prompt text and the qualifier's request flags
+ (TS 102 223 6.6.2, 8.6, 8.15). Returns None when the template is not a
+ parseable GET INKEY."""
+ tlvs = httpota.proactive_tlvs(raw)
+ text = _cmd_tlv_any(tlvs, 0x8D) if tlvs else b''
+ if not text:
+ return None
+ qual = _cmd_qualifier(tlvs)
+ try:
+ prompt = _decode_dcs_text(text)
+ except Exception:
+ return None
+ return {
+ 'text': prompt,
+ 'digits_only': bool(qual is not None and not (qual & 0x01)),
+ 'ucs2': bool(qual is not None and (qual & 0x02)),
+ 'yes_no': bool(qual is not None and (qual & 0x04)),
+ 'immediate': bool(qual is not None and (qual & 0x08)),
+ 'help': bool(qual is not None and (qual & 0x80)),
+ }
+
+
+def _parse_get_input(raw):
+ """GET INPUT (0x23) fields: prompt text, Response length (8.11), Default
+ 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."""
+ tlvs = httpota.proactive_tlvs(raw)
+ text = _cmd_tlv_any(tlvs, 0x8D) if tlvs else b''
+ if not text:
+ return None
+ qual = _cmd_qualifier(tlvs)
+ rl = _cmd_tlv_any(tlvs, 0x91)
+ default = _cmd_tlv_any(tlvs, 0x9D)
+ try:
+ prompt = _decode_dcs_text(text)
+ default_text = _decode_dcs_text(default) if default else None
+ except Exception:
+ return None
+ return {
+ 'text': prompt,
+ 'min': rl[0] if len(rl) >= 1 else 0,
+ 'max': rl[1] if len(rl) >= 2 else 0xFF,
+ 'default': default_text,
+ 'digits_only': bool(qual is not None and not (qual & 0x01)),
+ 'ucs2': bool(qual is not None and (qual & 0x02)),
+ 'hidden': bool(qual is not None and (qual & 0x04)),
+ 'packed': bool(qual is not None and (qual & 0x08)),
+ 'help': bool(qual is not None and (qual & 0x80)),
+ }
+
+
def _parse_select_item(raw):
items = []
nai = None
@@ -4474,25 +4565,128 @@ def _make_menu_fetch_handler(server, resp):
'dev_src': dev_src, 'dev_dst': dev_dst, 'items': items}
resp.update(type='select_item', items=items)
return 'pause'
+ elif cmd_type in (0x22, 0x23):
+ # GET INKEY / GET INPUT: the user's answer is the TR's Text
+ # string, so the panel must pause for it (v3.6.38)
+ info = (_parse_get_inkey(raw) if cmd_type == 0x22
+ else _parse_get_input(raw)) if raw else None
+ if info:
+ kind = 'get_inkey' if cmd_type == 0x22 else 'get_input'
+ pending = dict(info, type=kind, cmd_num=cmd_num,
+ cmd_type=cmd_type, dev_src=dev_src, dev_dst=dev_dst)
+ dur = _find_duration(raw)
+ pending['duration_unit'] = dur[0] if dur else None
+ pending['shown_at'] = time.monotonic()
+ server.stk_pending = pending
+ resp.update(dict(info, type=kind))
+ return 'pause'
return _on_menu_fetch
-def _menu_send_response(server, result, item_id=None):
+# Hidden entry allows only the digits set (TS 102 223 6.4.3); digits-only
+# requests use the same set for both input commands (6.4.2/6.4.3).
+_DIGITS_CHARSET = re.compile(r'^[0-9*#+]*$')
+
+
+def _validate_input_response(pd, text):
+ """Validate the user's input against the pending GET INKEY / GET INPUT
+ request (TS 102 223 6.4.2/6.4.3). Returns (text, error)."""
+ text = '' if text is None else str(text)
+ if pd['type'] == 'get_inkey':
+ if pd.get('yes_no'):
+ if text not in ('01', '00'):
+ return None, 'yes/no response must be 01 (positive) or 00 (negative)'
+ return text, None
+ if len(text) != 1:
+ return None, 'GET INKEY needs exactly one character'
+ if pd.get('digits_only') and not _DIGITS_CHARSET.match(text):
+ return None, 'digits only (0-9, *, #, +)'
+ return text, None
+ lo = int(pd.get('min', 0) or 0)
+ hi = int(pd.get('max', 0xFF) or 0)
+ if len(text) < lo:
+ return None, 'at least %d character(s) required' % lo
+ if hi not in (0, 0xFF) and len(text) > hi:
+ return None, 'at most %d character(s) allowed' % hi
+ if (pd.get('digits_only') or pd.get('hidden')) and not _DIGITS_CHARSET.match(text):
+ return None, 'digits only (0-9, *, #, +)'
+ return text, None
+
+
+def _pack_gsm7(septets):
+ """Pack GSM 03.38 septets into 7-bit octets (TS 23.038 4, SMS packing)."""
+ out = bytearray()
+ acc = 0
+ bits = 0
+ for s in septets:
+ acc |= (s & 0x7F) << bits
+ bits += 7
+ while bits >= 8:
+ out.append(acc & 0xFF)
+ acc >>= 8
+ bits -= 8
+ if bits:
+ out.append(acc & 0xFF)
+ return bytes(out)
+
+
+def _input_text_tlv(pd, text):
+ """Text string TLV (TS 102 223 8.15) carrying the user's answer for a GET
+ INKEY / GET INPUT TERMINAL RESPONSE (6.8.5): the response is coded in the
+ SMS default alphabet unpacked (DCS '04'), as UCS2 ('08') when the request
+ asked for it, or packed ('00') for a packed GET INPUT."""
+ if text == '':
+ return bytes([0x8D, 0x00]) # null text string (empty input)
+ if pd.get('yes_no'):
+ body = bytes([0x04, 0x01 if text == '01' else 0x00])
+ elif pd.get('ucs2'):
+ body = bytes([0x08]) + text.encode('utf-16-be')
+ elif pd.get('packed'):
+ body = bytes([0x00]) + _pack_gsm7(text.encode('gsm03.38'))
+ else:
+ body = bytes([0x04]) + text.encode('gsm03.38')
+ return bytes([0x8D, len(body)]) + body
+
+
+def _menu_send_response(server, result, item_id=None, text=None):
"""Send the pending command's TERMINAL RESPONSE and continue the chain.
- Shared by /api/menu-respond and the user-input timeout watchdog. Returns
- (payload, http_status)."""
+ Shared by /api/menu-respond and the user-input timeout watchdog. `text`
+ is the user's answer for a pending GET INKEY / GET INPUT (v3.6.38).
+ Returns (payload, http_status)."""
if not server.stk_pending:
return {'error': 'no pending command'}, 400
scc = server.scc
- RESULT_MAP = {'ok': 0x00, 'cancel': 0x10, 'back': 0x11, 'timeout': 0x12}
+ RESULT_MAP = {'ok': 0x00, 'cancel': 0x10, 'back': 0x11, 'timeout': 0x12,
+ 'help': 0x13}
gr = RESULT_MAP.get(result, 0x00)
pd = server.stk_pending
+ input_types = ('get_inkey', 'get_input')
+ text_tlv = b''
+ duration_tlv = b''
+ if result == 'ok' and pd['type'] in input_types:
+ text, err = _validate_input_response(pd, text)
+ if err:
+ return {'error': err}, 400
+ try:
+ text_tlv = _input_text_tlv(pd, text)
+ except UnicodeEncodeError as e:
+ return {'error': 'cannot encode the response text: %s' % e}, 400
+ if pd.get('duration_unit') is not None and pd.get('shown_at') is not None:
+ # TS 102 223 6.4.2/6.4.3: a variable-timeout request gets the
+ # actual display/entry duration back, in the requested unit (8.8)
+ elapsed = max(0.0, time.monotonic() - pd['shown_at'])
+ value = int(round(elapsed * {0x00: 1 / 60.0, 0x01: 1.0,
+ 0x02: 10.0}.get(pd['duration_unit'], 1.0)))
+ value = max(1, min(255, value))
+ duration_tlv = bytes([0x04, 0x02, pd['duration_unit'], value])
cd = bytes([0x81, 0x03, pd['cmd_num'], pd['cmd_type'], 0x00])
di = bytes([0x82, 0x02, pd['dev_dst'], pd['dev_src']])
tr_data = cd + di
if isinstance(item_id, int) and result == 'ok' and pd['type'] == 'select_item':
tr_data += bytes([0x90, 0x01, item_id])
tr_data += bytes([0x83, 0x02, gr, 0x00])
+ # Table 6.8.0 order after Result: Duration (D) then Text string (E)
+ tr_data += duration_tlv + text_tlv
tr_hex = '%s140000%02x%s' % (scc.cat_cla, len(tr_data), tr_data.hex())
tr_rv = scc._tp.send_apdu(tr_hex)
sys.stderr.write('TR(menu): cmd=%02x type=%02x result=%02x -> %s\n' % (pd['cmd_num'], pd['cmd_type'], gr, tr_rv[1]))
@@ -5437,9 +5631,15 @@ class PysimHandler(BaseHTTPRequestHandler):
self._send_json(log)
self._log_resp(log)
elif self.path == '/api/stk-status':
+ pd = self.server.stk_pending
resp = {'active': self.server.menu_active,
- 'pending': self.server.stk_pending is not None,
- 'pending_type': self.server.stk_pending['type'] if self.server.stk_pending else None}
+ 'pending': pd is not None,
+ 'pending_type': pd['type'] if pd else None}
+ if pd:
+ # the panel renders a card-initiated pending command from this
+ # (GET INKEY / GET INPUT prompt, SELECT ITEM items, ...)
+ resp['pending_data'] = {k: v for k, v in pd.items()
+ if k not in ('shown_at',)}
self._send_json(resp)
self._log_resp(resp)
elif self.path in ('/api/scp81/status', '/api/bip/status'):
@@ -6084,7 +6284,8 @@ class PysimHandler(BaseHTTPRequestHandler):
elif self.path == '/api/menu-respond':
body = self._read_body()
self._log_req(body)
- resp, code = _menu_send_response(self.server, body.get('result', 'ok'), body.get('item_id'))
+ resp, code = _menu_send_response(self.server, body.get('result', 'ok'),
+ body.get('item_id'), body.get('text'))
self._send_json(resp, code)
self._log_resp(resp)
elif self.path == '/api/event-send':
diff --git a/tests/test_ota_helpers.py b/tests/test_ota_helpers.py
index 20952c0..d5e6a97 100644
--- a/tests/test_ota_helpers.py
+++ b/tests/test_ota_helpers.py
@@ -46,6 +46,12 @@ from pysim_simple_server.server import (
_spi_from_bytes,
_split_secured_packet,
_tr_data_only,
+ _menu_send_response,
+ _parse_get_input,
+ _parse_get_inkey,
+ _input_text_tlv,
+ _validate_input_response,
+ _pack_gsm7,
SCP80_FIRST_BYTES,
SCP80_MAX_SEGMENTS,
SCP80_NEXT_BYTES,
@@ -1651,6 +1657,154 @@ class RamCommandFormatTests(unittest.TestCase):
self.assertEqual(len(state['steps']), 2)
+class StkInputTests(unittest.TestCase):
+ """GET INKEY / GET INPUT handling (v3.6.38, TS 102 223 6.4.2/6.4.3)."""
+
+ # The live GET INPUT from the RemMobileID menu (2026-09-29): qualifier
+ # 0x04 = digits only + hidden entry, Response length 4..16.
+ GET_INPUT = bytes.fromhex(
+ 'd03c8103012304820281828d2d0804120432043504340438044204350020004d006f00'
+ '620069006c0065002d00490044002000500049004e003191020410')
+
+ def test_parse_get_input_reads_text_and_response_length(self):
+ info = _parse_get_input(self.GET_INPUT)
+ self.assertEqual(info['text'], 'Введите Mobile-ID PIN1')
+ self.assertEqual((info['min'], info['max']), (4, 16))
+ self.assertTrue(info['digits_only'] and info['hidden'])
+ self.assertFalse(info['ucs2'] or info['packed'] or info['help'])
+ self.assertIsNone(info['default'])
+
+ def test_parse_get_inkey_reads_the_qualifier_flags(self):
+ # Command details: qualifier 0x84 = digits only + Yes/No, help bit set
+ raw = bytes.fromhex('d0188103012284820281828d0d0804120435044004350434043a0430')
+ info = _parse_get_inkey(raw)
+ self.assertTrue(info['digits_only'])
+ self.assertTrue(info['yes_no'])
+ self.assertTrue(info['help'])
+ self.assertFalse(info['ucs2'] or info['immediate'])
+ # a template without a Text string is not a parseable input command
+ self.assertIsNone(_parse_get_inkey(bytes.fromhex('d0038103012200')))
+
+ def test_decode_cmd_reports_the_input_request(self):
+ decoded = _decode_cmd(0x23, self.GET_INPUT, 0x04)
+ self.assertEqual(decoded[0], {'label': 'Text', 'value': 'Введите Mobile-ID PIN1'})
+ self.assertEqual(decoded[1], {'label': 'Length', 'value': '4..16'})
+ self.assertEqual(decoded[2], {'label': 'Response', 'value': 'digits only, hidden'})
+ # max 0xFF = no maximum (8.11); a help flag is reported too
+ raw = bytes.fromhex('d0118103012381820281828d020041910200ff')
+ decoded = _decode_cmd(0x23, raw, 0x81)
+ self.assertIn({'label': 'Length', 'value': '0..no max'}, decoded)
+ self.assertEqual(decoded[-1], {'label': 'Response', 'value': 'SMS default alphabet, help'})
+
+ def test_input_text_tlv_codings(self):
+ pd = {'type': 'get_input', 'digits_only': True, 'hidden': True}
+ # digits: SMS default alphabet unpacked (DCS 04), one byte per char
+ self.assertEqual(_input_text_tlv(pd, '1234').hex().upper(), '8D050431323334')
+ # empty input: the null text string (6.8.5)
+ self.assertEqual(_input_text_tlv(pd, '').hex().upper(), '8D00')
+ # UCS2 request: DCS 08
+ self.assertEqual(_input_text_tlv(dict(pd, ucs2=True, hidden=False), '12').hex().upper(),
+ '8D050800310032')
+ # packed request: DCS 00 + TS 23.038 septet packing
+ self.assertEqual(_input_text_tlv(dict(pd, packed=True, hidden=False), '1234').hex().upper(),
+ '8D050031D98C06')
+ # GET INKEY Yes/No: value 01 positive / 00 negative (6.8.5)
+ yesno = {'type': 'get_inkey', 'yes_no': True}
+ self.assertEqual(_input_text_tlv(yesno, '01').hex().upper(), '8D020401')
+ self.assertEqual(_input_text_tlv(yesno, '00').hex().upper(), '8D020400')
+
+ def test_pack_gsm7_matches_the_sms_packing(self):
+ self.assertEqual(_pack_gsm7(b'1234').hex().upper(), '31D98C06')
+ # 7 septets need 7 octets; the trailing bit is the last septet's MSB
+ # (0 for '7' = 0x37), so the padding octet stays 00
+ self.assertEqual(_pack_gsm7(b'1234567').hex().upper(), '31D98C56B3DD00')
+
+ def test_validate_input_response(self):
+ pd = {'type': 'get_input', 'min': 4, 'max': 16, 'digits_only': True, 'hidden': True}
+ self.assertEqual(_validate_input_response(pd, '1234'), ('1234', None))
+ self.assertIsNotNone(_validate_input_response(pd, '12')[1])
+ self.assertIsNotNone(_validate_input_response(pd, '1' * 17)[1])
+ self.assertIsNotNone(_validate_input_response(pd, 'abcd')[1])
+ # hidden entry allows only the digits set even without digits_only
+ self.assertIsNotNone(_validate_input_response({'type': 'get_input', 'hidden': True}, 'ab')[1])
+ # GET INKEY: exactly one character, digits-only honours * # +
+ self.assertEqual(_validate_input_response({'type': 'get_inkey'}, 'a'), ('a', None))
+ self.assertIsNotNone(_validate_input_response({'type': 'get_inkey'}, 'ab')[1])
+ self.assertEqual(_validate_input_response({'type': 'get_inkey', 'digits_only': True}, '*'),
+ ('*', None))
+ self.assertIsNotNone(_validate_input_response({'type': 'get_inkey', 'digits_only': True}, 'a')[1])
+ # Yes/No: only 01 / 00
+ yesno = {'type': 'get_inkey', 'yes_no': True}
+ self.assertEqual(_validate_input_response(yesno, '01'), ('01', None))
+ self.assertIsNotNone(_validate_input_response(yesno, 'yes')[1])
+
+ def test_menu_send_response_carries_the_input_text(self):
+ class FakeTp:
+ def __init__(self):
+ self.sent = []
+
+ def send_apdu(self, apdu):
+ self.sent.append(apdu)
+ return '', '9000'
+
+ class FakeServer:
+ def __init__(self, pending):
+ self.stk_pending = pending
+ self.menu_active = True
+ self.scc = types.SimpleNamespace(cat_cla='80', _tp=FakeTp())
+
+ def pending(**over):
+ pd = {'type': 'get_input', 'cmd_num': 1, 'cmd_type': 0x23,
+ 'dev_src': 0x81, 'dev_dst': 0x82, 'text': 'PIN',
+ 'min': 4, 'max': 16, 'default': None, 'digits_only': True,
+ 'ucs2': False, 'hidden': True, 'packed': False, 'help': False,
+ 'duration_unit': None, 'shown_at': None}
+ pd.update(over)
+ return pd
+
+ server = FakeServer(pending())
+ resp, code = _menu_send_response(server, 'ok', None, '1234')
+ self.assertEqual(code, 200, resp)
+ apdu = server.scc._tp.sent[0].upper()
+ self.assertIn('8D050431323334', apdu) # the entered digits
+ self.assertIn('83020000', apdu) # successful result
+ self.assertIsNone(server.stk_pending)
+ # a rejected value sends nothing and keeps the command pending
+ server2 = FakeServer(pending())
+ resp2, code2 = _menu_send_response(server2, 'ok', None, 'abcd')
+ self.assertEqual(code2, 400)
+ self.assertIn('digits only', resp2['error'])
+ self.assertEqual(server2.scc._tp.sent, [])
+ # help is result 0x13 without a Text string
+ server3 = FakeServer(pending(help=True))
+ resp3, code3 = _menu_send_response(server3, 'help')
+ self.assertEqual(code3, 200)
+ self.assertIn('83021300', server3.scc._tp.sent[0].upper())
+
+ def test_menu_send_response_echoes_the_variable_timeout(self):
+ class FakeTp:
+ def __init__(self):
+ self.sent = []
+
+ def send_apdu(self, apdu):
+ self.sent.append(apdu)
+ return '', '9000'
+
+ server = types.SimpleNamespace(
+ stk_pending={'type': 'get_input', 'cmd_num': 2, 'cmd_type': 0x23,
+ 'dev_src': 0x81, 'dev_dst': 0x82, 'text': 'PIN',
+ 'min': 4, 'max': 16, 'digits_only': True, 'hidden': True,
+ 'ucs2': False, 'packed': False, 'help': False,
+ 'duration_unit': 0x01, 'shown_at': 997.0},
+ menu_active=True,
+ scc=types.SimpleNamespace(cat_cla='80', _tp=FakeTp()))
+ with mock.patch('time.monotonic', return_value=1000.0):
+ resp, code = _menu_send_response(server, 'ok', None, '1234')
+ self.assertEqual(code, 200, resp)
+ # TS 102 223 6.8.4: 3 seconds in the requested unit (seconds)
+ self.assertIn('04020103', server.scc._tp.sent[0].upper())
+
+
class RamProgressTests(unittest.TestCase):
"""Live RAM-operation progress exposed in /api/status (v3.6.34)."""