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.
This commit is contained in:
2026-09-29 01:30:40 +03:00
parent ddf5cbb8e7
commit 0027c405c1
8 changed files with 571 additions and 14 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>
<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), панель показывает её; кэшированное верхнее меню появляется только когда карте больше нечего выполнять.</p>
<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>
<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>
+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>
<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.</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. 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>
<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>
+155 -2
View File
@@ -1705,7 +1705,7 @@
<div id="stk-menu-buttons" class="flex justify-between mt-3 pt-3 border-t border-gray-200 dark:border-slate-700 hidden">
<button id="stk-timeout-btn" onclick="stkMenuRespond('timeout')" class="px-3 py-1 text-xs rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" data-l10n="Timeout">Timeout</button>
<button id="stk-cancel-btn" onclick="stkMenuRespond('cancel')" class="px-3 py-1 text-xs rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" data-l10n="Cancel">Cancel</button>
<button id="stk-ok-btn" onclick="stkMenuRespond('ok')" class="px-4 py-1 text-xs rounded-full bg-blue-600 text-white hover:bg-blue-700" data-l10n="OK">OK</button>
<button id="stk-ok-btn" onclick="stkMenuOk()" class="px-4 py-1 text-xs rounded-full bg-blue-600 text-white hover:bg-blue-700" data-l10n="OK">OK</button>
</div>
</div>
</div>
@@ -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 = '<div class="text-sm whitespace-pre-wrap p-2 bg-gray-50 dark:bg-slate-700 rounded mb-2">' + esc(pd.text || '') + '</div>';
if (pd.yes_no) {
html += '<div class="flex gap-2 justify-center">' +
'<button onclick="stkInputSubmit(\'01\')" class="px-4 py-1 text-sm rounded bg-blue-600 text-white hover:bg-blue-700">' + esc(t('Yes')) + '</button>' +
'<button onclick="stkInputSubmit(\'00\')" class="px-4 py-1 text-sm rounded bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600">' + esc(t('No')) + '</button>' +
'</div>';
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 += '<input ' + attrs + '>';
const hint = stkInputHint(pd);
if (hint) html += '<div class="text-xs text-gray-400 dark:text-slate-500 mt-1">' + esc(hint) + '</div>';
if (pd.help) {
html += '<div class="mt-2 text-center"><button onclick="stkMenuRespond(\'help\')" class="text-xs underline text-gray-500 dark:text-slate-400">' + esc(t('Help')) + '</button></div>';
}
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',
'<div class="text-xs text-red-500 mt-1">' + esc(v.error) + '</div>');
return;
}
text = value;
}
const content = document.getElementById('stk-menu-panel-content');
content.innerHTML = '<span class="text-gray-500 text-sm">' + esc(t('Sending...')) + '</span>';
try {
const data = await pysimFetch('/api/menu-respond', { result: 'ok', text: text });
stkMenuHandleResponse(data);
} catch (e) {
content.innerHTML = '<span class="text-red-500 text-sm">Error: ' + esc(e.message) + '</span>';
}
}
// 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 = '<div class="text-sm whitespace-pre-wrap p-2 bg-gray-50 dark:bg-slate-700 rounded">' + esc(data.text) + '</div>';
@@ -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 = '<span class="text-gray-500 text-sm">SW: ' + esc(data.sw || '?') + '</span>';
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': 'Обычный сервис',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v310';
const CACHE = 'simple-v311';
const URLS = [
'index.html',
'help.html',
+49
View File
@@ -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);
});
+1 -1
View File
@@ -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.
+209 -8
View File
@@ -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':
+154
View File
@@ -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)."""