From 0cccd62230d5ce10a841bcd4c4747ce46df7f209 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: Thu, 1 Oct 2026 22:03:36 +0300
Subject: [PATCH] release: v3.9.0 - the operation start buttons wait for a
preset and a keyset
Gating: the SCP80 "Send to Card", the RAM Execute and the TAR-probe Execute
buttons carry `data-needs-keyset`; pysimApplyAvailability() disables them
(with the hint "Select a card preset and its keyset" when the card itself is
fine) unless the view's preset is selected and the form's keyset number is one
of its keysets - `spKeysetStartReady`, the same rule the click-time guards
enforce, which stay as the last line of defence. spKeysetSync() refreshes the
gate on every preset/keyset change, and the KIc/KID index selects now call
spKeysetSync() so the keyset selector follows a manual index change too.
Version 3.9.0: server VERSION, pyproject.toml and the PWA header; sw cache
simple-v346; the workspace AGENTS gains its v3.9.0 history entry.
Tests: indicator.test.js drives the gate (no preset -> disabled with the hint,
an undefined keyset -> disabled, a matching keyset -> enabled) and guards the
three buttons' attribute; the cards/keysets/ram sandboxes stub
pysimApplyAvailability. Help EN/RU notes the disabled start buttons.
---
frontend/help-ru.html | 6 ++--
frontend/help.html | 6 ++--
frontend/index.html | 32 ++++++++++++++++---
frontend/sw.js | 2 +-
frontend/tests/cards_counter.test.js | 3 +-
frontend/tests/cards_form.test.js | 3 +-
frontend/tests/indicator.test.js | 48 ++++++++++++++++++++++++++++
frontend/tests/keysets.test.js | 1 +
frontend/tests/ram.test.js | 1 +
pyproject.toml | 2 +-
pysim_simple_server/server.py | 2 +-
11 files changed, 91 insertions(+), 15 deletions(-)
diff --git a/frontend/help-ru.html b/frontend/help-ru.html
index 4b2823a..842a6aa 100644
--- a/frontend/help-ru.html
+++ b/frontend/help-ru.html
@@ -282,7 +282,7 @@
Верхнеуровневая вкладка SCP80 объединяет разделы, связанные с SCP80. Переключение — тремя переключателями: Secured Packet, RAM и TAR-сканер. Собирает защищённые пакеты SCP80 по ETSI TS 102 225. При подключении карты сервер читает её EF.ICCID (2FE2); если в пресете указан тот же номер (цифрами или сырым hex из EF), он автоматически выбирается во всех трёх представлениях SCP80. Вердикт низкого счётчика (cntr_low или предупреждающий ответ ENVELOPE, в котором всё ещё есть такой PoR) не исправляется повторной отправкой: любая операция SCP80/RAM немедленно останавливается, результат показывает счётчик карты и кнопку К пресету, открывающую соответствующий пресет на вкладке Cards, где счётчик нужно поднять выше — автоматически ничего не записывается.
3.1 Secured Packet
-
Собирает защищённые пакеты SCP80 по ETSI TS 102 225. Селектор Keyset рядом с выбором пресета указывает, из какого keyset берутся ключи и счётчик; отправка отклоняется, если в пресете нет такого номера keyset или KIc/KID несут разные номера (TS 102 225 A.2). Селектор недоступен, пока не выбран пресет карты.
+
Собирает защищённые пакеты SCP80 по ETSI TS 102 225. Селектор Keyset рядом с выбором пресета указывает, из какого keyset берутся ключи и счётчик; отправка отклоняется, если в пресете нет такого номера keyset или KIc/KID несут разные номера (TS 102 225 A.2). Селектор недоступен, пока не выбран пресет карты. Кнопка запуска недоступна, пока не выбран пресет карты и один из его keysets.
Структура пакета
Поле
Размер
Описание
@@ -314,7 +314,7 @@
Кнопка Проверить в pySim сверяет собранный пакет с эталонной реализацией OtaDialectSms.encode_cmd. Кнопка Отправить на карту доставляет пакет через ENVELOPE SMS-PP-DOWNLOAD (при подключении к серверу). 2-байтовый префикс CPL передаётся, когда пакет зашифрован или несёт RC/CC/DS (входит в их входные данные), а также при конкатенации SMS; одиночное незащищённое SMS сохраняет форму pySim, начинающуюся с CHL (TS 31.115 Table 1 NOTE). Под полем пакета показывается его размер и число SMS; пакет, не помещающийся в одно SMS, отправляется конкатенированной загрузкой (TS 31.115 §4.3) — пакет делится на части SMS-пользовательских данных (первое SMS 132 октета, последующие по 134; первое дополнительно несёт информационные элементы конкатенации и CPI), и сегменты отправляются по порядку. Более 5 сегментов отклоняется: это практический предел буфера конкатенации карты. Полученный Proof of Receipt декодируется и показывается строкой статуса PoR (статус, TAR, счётчик, сырой PoR); статусное слово и данные ответа последней команды подставляются в подвкладку «Разбор R-APDU» (Remote APDU → «Разбор»), а успешный PoR увеличивает счётчик повторов (когда в SPI1 есть проверка счётчика — b5 b4 ≠ 00; без неё счётчик не увеличивается и не сохраняется) и очищает пакет.
3.2 RAM
-
Выполняет операции удалённого управления приложениями (Remote Application Management) как защищённые пакеты SCP80 через SMS-PP-DOWNLOAD ENVELOPE. Карта должна поддерживать SCP03 (AES или 3DES). Пресет карты со вкладки Карты обеспечивает SPI, ключи, TAR и счётчик (RAM использует ISD TAR пресета, по умолчанию 000000). Селектор Keyset рядом с выбором пресета указывает, из какого keyset берутся ключи и счётчик операции. Селектор недоступен, пока не выбран пресет карты.
+
Выполняет операции удалённого управления приложениями (Remote Application Management) как защищённые пакеты SCP80 через SMS-PP-DOWNLOAD ENVELOPE. Карта должна поддерживать SCP03 (AES или 3DES). Пресет карты со вкладки Карты обеспечивает SPI, ключи, TAR и счётчик (RAM использует ISD TAR пресета, по умолчанию 000000). Селектор Keyset рядом с выбором пресета указывает, из какого keyset берутся ключи и счётчик операции. Селектор недоступен, пока не выбран пресет карты. Кнопка запуска недоступна, пока не выбран пресет карты и один из его keysets.
Операции
@@ -340,7 +340,7 @@
3.3 TAR-сканер
-
Третья пилюля вкладки SCP80 показывает, какие OTA-приложения зарегистрированы на карте: отправляется по одному защищённому пакету на каждый выбранный TAR — стандартные значения из TS 101 220 V18.3.0 Annex D, Table D.1, каждая строка несёт документ-источник — с безобидной C-APDU (по умолчанию SELECT MF), пресетом и keyset этого представления, SPI 16/01 (проверка счётчика + PoR).
+
Третья пилюля вкладки SCP80 показывает, какие OTA-приложения зарегистрированы на карте: отправляется по одному защищённому пакету на каждый выбранный TAR — стандартные значения из TS 101 220 V18.3.0 Annex D, Table D.1, каждая строка несёт документ-источник — с безобидной C-APDU (по умолчанию SELECT MF), пресетом и keyset этого представления, SPI 16/01 (проверка счётчика + PoR). Кнопка Execute недоступна, пока не выбран пресет карты и один из его keysets.
Список предзаполнен стандартными TAR (все отмечены; кнопки Выбрать все / Снять все переключают быстро), свои TAR добавляются кнопкой Добавить TAR (три hex-байта и необязательное описание). Пользовательские записи хранятся в localStorage по возможности — их потеря безвредна.
Каждый TAR классифицируется по ответу карты: зарегистрировано (ENVELOPE 9000 + PoR со SW и данными приложения), принято, нет ответа (PoR без R-APDU), принято, нет PoR (пакет молча отброшен) и отклонено (ENVELOPE не 9000, обычно 6200 для TAR без зарегистрированного приложения). Таблица результата появляется в окне операции; строка под кнопкой Execute показывает зарегистрировано N / M.
Каждый принятый картой пакет расходует счётчик, который сохраняется в keyset пресета — проба отклоняется, если в SPI1 нет проверки счётчика или в пресете нет такого номера keyset. На живой карте: 000000, B00000 и B00001 зарегистрированы (приложения компактного формата), расширенный формат, USAT, мультиплексирование и CASD отклонены с 6200.
The SCP80 top-level tab groups the SCP80-related views. It is switched by three pills: Secured Packet, RAM and TAR probe. Assembles SCP80 secured packets per ETSI TS 102 225. When a card is equipped the server reads its EF.ICCID (2FE2); if a card preset carries the same number (digits, or the raw EF hex), it is selected automatically in all three SCP80 views.
3.1 Secured Packet
-
Builds SCP80 secured packets per ETSI TS 102 225. The Keyset selector next to the card preset selector picks which keyset of the preset supplies the keys and the counter; the send is refused when the preset does not define that keyset number or when its KIc/KID carry different numbers (TS 102 225 A.2). It is disabled until a card preset is selected.
+
Builds SCP80 secured packets per ETSI TS 102 225. The Keyset selector next to the card preset selector picks which keyset of the preset supplies the keys and the counter; the send is refused when the preset does not define that keyset number or when its KIc/KID carry different numbers (TS 102 225 A.2). It is disabled until a card preset is selected. The start button stays disabled until a card preset and one of its keysets are selected.
Packet structure
Field
Size
Description
@@ -313,7 +313,7 @@
A “Verify vs pySim” button cross-checks the assembled packet against pySim’s reference OtaDialectSms.encode_cmd. A “Send to Card” button delivers it via SMS-PP-DOWNLOAD ENVELOPE (when connected to the server). The 2-byte CPL prefix is transmitted whenever the packet is ciphered or carries RC/CC/DS (it is part of their input) and whenever the packet is concatenated; a single unprotected SMS keeps pySim’s CHL-first form (TS 31.115 Table 1 NOTE). Under the packet field the app reports its size and how many SMS it takes; a packet that does not fit one SMS is sent as a concatenated download (TS 31.115 §4.3) — the packet is split into SMS user-data parts (first SM 132 octets, following ones 134; the first one additionally carries the concatenation and CPI information elements) and the segments are sent in order. More than 5 segments is refused, that being the practical limit of the card’s concatenation buffer. The returned Proof of Receipt is decoded and shown as a PoR status line (status, TAR, counter, raw PoR); the last command’s status word and response data are filled into the R-APDU parser panel, and a successful PoR advances the replay counter (when SPI1 carries a counter check — b5 b4 ≠ 00; without one no counter is advanced or saved) and clears the packet. A low-counter verdict (cntr_low, or a warning ENVELOPE answer that still carries such a PoR) is not recoverable by retrying: every SCP80/RAM operation stops immediately and the result states the card's counter with a Go to preset button that opens the matching preset in the Cards tab, where the counter must be raised above it — nothing is written automatically.
3.2 RAM
-
Delivers Remote Application Management operations as SCP80 secured packets via SMS-PP-DOWNLOAD ENVELOPE. The card must support SCP03 (AES or 3DES). A saved card preset from the Cards tab provides the SPI, keys, TAR, and counter (RAM uses the preset's ISD TAR, 000000 by default). The Keyset selector next to the preset selector picks the keyset whose keys and counter the operation uses. It is disabled until a card preset is selected.
+
Delivers Remote Application Management operations as SCP80 secured packets via SMS-PP-DOWNLOAD ENVELOPE. The card must support SCP03 (AES or 3DES). A saved card preset from the Cards tab provides the SPI, keys, TAR, and counter (RAM uses the preset's ISD TAR, 000000 by default). The Keyset selector next to the preset selector picks the keyset whose keys and counter the operation uses. It is disabled until a card preset is selected. The start button stays disabled until a card preset and one of its keysets are selected.
Operations
@@ -339,7 +339,7 @@
3.3 TAR probe
-
The third SCP80 pill probes which OTA applications the card has registered: it sends one secured packet per selected TAR — the standard allocations of TS 101 220 V18.3.0 Annex D, Table D.1, each row carrying the document it comes from — with a harmless C-APDU (default SELECT MF) using the view's preset and keyset, SPI 16/01 (counter check + PoR).
+
The third SCP80 pill probes which OTA applications the card has registered: it sends one secured packet per selected TAR — the standard allocations of TS 101 220 V18.3.0 Annex D, Table D.1, each row carrying the document it comes from — with a harmless C-APDU (default SELECT MF) using the view's preset and keyset, SPI 16/01 (counter check + PoR). The Execute button stays disabled until a card preset and one of its keysets are selected.
The list is pre-filled with the standard TARs (all checked; Select all / Select none switch them quickly) and custom TARs can be added with Add TAR (three hex bytes plus an optional description). Custom entries are kept in localStorage on a best-effort basis — losing them is harmless.
Every TAR is classified from the card's answer: registered (ENVELOPE 9000 + a PoR carrying the application's own status word and data), accepted, no answer (a PoR without an R-APDU), accepted, no PoR (the packet was silently dropped) and refused (a non-9000 ENVELOPE, typically 6200 for a TAR without a registered application). The result table appears in the operation popup; the line under Execute summarises registered N / M.
Each packet the card accepts consumes a counter, which is saved into the preset's keyset — the probe is refused when SPI1 has no counter check or the named preset does not define the keyset number. Observed on a live card: 000000, B00000 and B00001 registered (compact-format applications), the expanded-format TARs, USAT, multiplexing and CASD refused with 6200.
diff --git a/frontend/index.html b/frontend/index.html
index 898debe..c4f7be0 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -676,7 +676,7 @@
-
+
@@ -708,7 +708,7 @@
-
+
@@ -943,7 +943,7 @@
-
+
@@ -1791,7 +1791,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.8.0';
+const SIMPLE_VERSION = '3.9.0';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching =====
@@ -4745,6 +4745,7 @@ function updateSpKic() {
document.getElementById('sp-kic-hex').value = byte.toString(16).padStart(2, '0').toUpperCase();
updateKeyPlaceholder('kic', alg & 0x0F);
spInvalidate();
+ spKeysetSync();
}
function updateSpKid() {
const idx = parseInt(document.getElementById('sp-kid-idx').value, 16);
@@ -4753,6 +4754,7 @@ function updateSpKid() {
document.getElementById('sp-kid-hex').value = byte.toString(16).padStart(2, '0').toUpperCase();
updateKeyPlaceholder('kid', alg & 0x0F);
spInvalidate();
+ spKeysetSync();
}
function updateKeyPlaceholder(prefix, algNib) {
const keyLen = {1: 8, 2: 16, 5: 16, 9: 24, 0xD: 8}[algNib] || 8;
@@ -7715,7 +7717,13 @@ function pysimApplyAvailability() {
// From-card buttons need a readable ICCID, not just a session.
disabled = pysimControlDisabled('card', state) || !_pysimCardIccid;
}
+ const ksView = el.getAttribute('data-needs-keyset');
+ const ksBlocked = ksView && !spKeysetStartReady(ksView);
+ if (ksBlocked) disabled = true;
let hint = disabled ? pysimNeedsHint(needs, state) : null;
+ // The generic hint is "Insert and equip a card": with the card fine,
+ // the missing preset/keyset is the real blocker.
+ if (ksBlocked && state === 'card') hint = t('Select a card preset and its keyset');
if (el.hasAttribute('data-needs-decoder') && !pysimFsHasDecoder()) {
// Client-side decoder required (Read decoded) — the card hint wins
// when the card itself is the blocker.
@@ -11485,6 +11493,9 @@ function spKeysetSync() {
sync('sp-keyset-sel', spPresetIdx);
sync('ram-keyset-sel', ramPresetIdx);
sync('tar-keyset-sel', tarPresetIdx);
+ // The start buttons follow the same state: they wait for a preset and a
+ // keyset defined in it.
+ pysimApplyAvailability();
}
function spKeysetApply(ks) {
@@ -11593,6 +11604,18 @@ function ramKeysetGuard() {
return spKeysetCheck(idx >= 0 ? cards[idx] : null, kic, kid);
}
+// Whether a view's start button may run: its preset must be selected and the
+// form's keyset number one of that preset's keysets (the click-time guards keep
+// the byte-level checks). '00'/'00' (no security) with a preset selected stays
+// allowed - it is a deliberate form choice.
+function spKeysetStartReady(view) {
+ const idx = view === 'ram' ? ramPresetIdx() : (view === 'tar' ? tarPresetIdx() : spPresetIdx());
+ if (idx < 0) return false;
+ const kic = (document.getElementById('sp-kic-hex') || {}).value;
+ const kid = (document.getElementById('sp-kid-hex') || {}).value;
+ return spKeysetCheck(cards[idx], kic, kid) === '';
+}
+
function cardsFormValues() {
const val = id => {
const el = document.getElementById('cards-' + id);
@@ -19856,6 +19879,7 @@ const LANG_RU = {
'not defined in the preset': 'не определён в пресете',
'— no keyset —': '— нет keyset —',
'— select a card preset —': '— выберите пресет карты —',
+ 'Select a card preset and its keyset': 'Выберите пресет карты и его keyset',
'counter': 'счётчик',
'Define at least one keyset (KIc/KID and their keys)': 'Определите хотя бы один keyset (KIc/KID и его ключи)',
'Each keyset needs KIc, KID and both keys': 'Каждому набору нужны KIc, KID и оба ключа',
diff --git a/frontend/sw.js b/frontend/sw.js
index 8c39226..052a540 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'simple-v345';
+const CACHE = 'simple-v346';
const URLS = [
'index.html',
'help.html',
diff --git a/frontend/tests/cards_counter.test.js b/frontend/tests/cards_counter.test.js
index 74b0621..43b8947 100644
--- a/frontend/tests/cards_counter.test.js
+++ b/frontend/tests/cards_counter.test.js
@@ -33,7 +33,8 @@ for (const fn of ['cardsTarValue', 'cardsApplyFields', 'cardsApply',
]) {
code += extractFunc(html, fn) + '\n';
}
-code = 'var _cardsKeysetCount = 0;\nglobalThis.spPresetWarningRender = function() {};\n' + code;
+code = 'var _cardsKeysetCount = 0;\nglobalThis.spPresetWarningRender = function() {};\n'
+ + 'globalThis.pysimApplyAvailability = function() {};\n' + code;
code = 'var _genSpBuildStub = function() {};\n' + code;
eval(code);
globalThis._genSpBuild = () => {};
diff --git a/frontend/tests/cards_form.test.js b/frontend/tests/cards_form.test.js
index 55b46ac..5c0b966 100644
--- a/frontend/tests/cards_form.test.js
+++ b/frontend/tests/cards_form.test.js
@@ -37,7 +37,8 @@ for (const fn of ['cardsTarValue', 'cardsFormValues', 'cardsClearForm', 'cardsEd
'ramKeysetChanged', 'ramPresetIdx', 'tarPresetIdx', 'spKeysetGuard', 'ramKeysetGuard']) {
code += extractFunc(html, fn, ASYNC_FNS.indexOf(fn) >= 0) + '\n';
}
-code = 'var _cardsKeysetCount = 0;\nglobalThis.spPresetWarningRender = function() {};\n' + code;
+code = 'var _cardsKeysetCount = 0;\nglobalThis.spPresetWarningRender = function() {};\n'
+ + 'globalThis.pysimApplyAvailability = function() {};\n' + code;
eval(code);
const CARD_IDS = ['cards-name','cards-iccid','cards-adm',
diff --git a/frontend/tests/indicator.test.js b/frontend/tests/indicator.test.js
index 2d87005..dfb51a2 100644
--- a/frontend/tests/indicator.test.js
+++ b/frontend/tests/indicator.test.js
@@ -34,6 +34,14 @@ code += extractFunc(html, 'pysimUpdatePresetIndicators') + '\n';
code += extractFunc(html, 'cardsScp80Complete') + '\n';
code += extractFunc(html, 'cardsScp81Complete') + '\n';
code += extractFunc(html, 'cardsMatchedPreset') + '\n';
+code += extractFunc(html, 'spKeysetStartReady') + '\n';
+code += extractFunc(html, 'spPresetIdx') + '\n';
+code += extractFunc(html, 'ramPresetIdx') + '\n';
+code += extractFunc(html, 'tarPresetIdx') + '\n';
+code += extractFunc(html, 'spKeysetCheck') + '\n';
+code += extractFunc(html, 'spKeysetFor') + '\n';
+code += extractFunc(html, 'spKeysetList') + '\n';
+code += extractFunc(html, 'spKeysetKvnOf') + '\n';
code += 'globalThis.t = s => s;\n';
code += 'globalThis.testRunActive = () => false;\n';
eval(code);
@@ -287,3 +295,43 @@ test('card action labels carry the equipped ICCID', () => {
assert.strictEqual(plain.textContent, '');
assert.strictEqual(colon.textContent, '');
});
+
+test('the start buttons wait for a preset and a keyset defined in it', () => {
+ const run = (presetIdx, kic, kid) => {
+ const el = fakeEl();
+ el.setAttribute('data-needs', 'card');
+ el.setAttribute('data-needs-keyset', 'sp');
+ const els = {
+ 'sp-card-sel': { value: presetIdx },
+ 'sp-kic-hex': { value: kic },
+ 'sp-kid-hex': { value: kid },
+ };
+ globalThis.document = { querySelectorAll: () => [el], getElementById: id => els[id] || null };
+ globalThis.cards = [{ name: 'EP', keysets: [
+ { kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' }] }];
+ _pysimServerAvailable = true;
+ _pysimCardEquipped = true;
+ pysimApplyAvailability();
+ return el;
+ };
+ // no preset selected -> disabled with the hint
+ let el = run('', '15', '15');
+ assert.strictEqual(el.disabled, true);
+ assert.strictEqual(el.attrs.title, 'Select a card preset and its keyset');
+ // a keyset number the preset does not define -> disabled
+ el = run('0', '35', '35');
+ assert.strictEqual(el.disabled, true);
+ // a keyset of the preset -> enabled, no tooltip
+ el = run('0', '15', '15');
+ assert.strictEqual(el.disabled, false);
+ assert.strictEqual(el.attrs.title, undefined);
+});
+
+test('the SCP80/RAM/TAR start buttons carry the keyset gate', () => {
+ const sp = /