ui: the keyset selector is disabled and cleared while no preset is selected

A keyset only exists inside a card preset, but the SCP80/RAM/TAR keyset
selectors stayed enabled with no preset selected - showing either
"— no keyset —" or a stale "Keyset N — not defined in the preset" entry left
over from an earlier preset.  spKeysetSync() now disables the selector and
reduces it to a "— select a card preset —" placeholder whenever its view has
no preset selected, and re-enables/populates it as soon as one is.

Clearing the preset dropdown ("— Select card —") also refreshed nothing:
cardsApply() returned before the sync, so the stale keyset list stayed; it now
syncs the selectors (and the preset warning) on the empty selection too.

The three selects get disabled:opacity-40 disabled:cursor-not-allowed (the
rules are already in the compiled style.css).  Tests: keysets.test.js covers
the disabled/cleared state and the re-enable plus a cardsApply source guard;
cards_form.test.js and cards_counter.test.js gain tarPresetIdx in their
sandboxes.  Help EN/RU and both AGENTS files; sw cache simple-v343.
This commit is contained in:
2026-10-01 21:41:07 +03:00
parent cafec549fa
commit 12da7ed593
7 changed files with 79 additions and 27 deletions
+2 -2
View File
@@ -282,7 +282,7 @@
<p class="mb-3">Верхнеуровневая вкладка <strong>SCP80</strong> объединяет разделы, связанные с SCP80. Переключение — тремя переключателями: <strong>Secured Packet</strong>, <strong>RAM</strong> и <strong>TAR-сканер</strong>. Собирает защищённые пакеты SCP80 по ETSI TS 102 225. При подключении карты сервер читает её <strong>EF.ICCID</strong> (2FE2); если в пресете указан тот же номер (цифрами или сырым hex из EF), он автоматически выбирается во всех трёх представлениях SCP80. Вердикт <strong>низкого счётчика</strong> (<code class="font-mono text-sm">cntr_low</code> или предупреждающий ответ ENVELOPE, в котором всё ещё есть такой PoR) <em>не</em> исправляется повторной отправкой: любая операция SCP80/RAM немедленно останавливается, результат показывает счётчик карты и кнопку <strong>К пресету</strong>, открывающую соответствующий пресет на вкладке Cards, где счётчик нужно поднять выше &mdash; автоматически ничего не записывается.</p>
<h3 id="secured-packet" class="text-lg font-medium mb-2">3.1 Secured Packet</h3>
<p class="mb-2">Собирает защищённые пакеты SCP80 по ETSI TS 102 225. Селектор <strong>Keyset</strong> рядом с выбором пресета указывает, из какого keyset берутся ключи и счётчик; отправка отклоняется, если в пресете нет такого номера keyset или KIc/KID несут разные номера (TS 102 225 A.2).</p>
<p class="mb-2">Собирает защищённые пакеты SCP80 по ETSI TS 102 225. Селектор <strong>Keyset</strong> рядом с выбором пресета указывает, из какого keyset берутся ключи и счётчик; отправка отклоняется, если в пресете нет такого номера keyset или KIc/KID несут разные номера (TS 102 225 A.2). Селектор недоступен, пока не выбран пресет карты.</p>
<h4 id="packet-structure" class="font-medium mb-1">Структура пакета</h4>
<table class="w-full text-sm mb-3 border-collapse">
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Поле</th><th class="text-left py-1 px-2">Размер</th><th class="text-left py-1 px-2">Описание</th></tr></thead>
@@ -314,7 +314,7 @@
<p class="text-sm mb-3">Кнопка <strong>Проверить в pySim</strong> сверяет собранный пакет с эталонной реализацией <code class="font-mono text-sm">OtaDialectSms.encode_cmd</code>. Кнопка <strong>Отправить на карту</strong> доставляет пакет через ENVELOPE SMS-PP-DOWNLOAD (при подключении к серверу). 2-байтовый префикс CPL передаётся, когда пакет зашифрован или несёт RC/CC/DS (входит в их входные данные), а также при конкатенации SMS; одиночное незащищённое SMS сохраняет форму pySim, начинающуюся с CHL (TS 31.115 Table 1 NOTE). Под полем пакета показывается его размер и число SMS; пакет, не помещающийся в одно SMS, отправляется <strong>конкатенированной</strong> загрузкой (TS 31.115 &sect;4.3) &mdash; пакет делится на части SMS-пользовательских данных (первое SMS 132 октета, последующие по 134; первое дополнительно несёт информационные элементы конкатенации и CPI), и сегменты отправляются по порядку. Более 5 сегментов отклоняется: это практический предел буфера конкатенации карты. Полученный Proof of Receipt декодируется и показывается строкой статуса PoR (статус, TAR, счётчик, сырой PoR); статусное слово и данные ответа последней команды подставляются в подвкладку <strong>&laquo;Разбор R-APDU&raquo;</strong> (Remote APDU &rarr; &laquo;Разбор&raquo;), а успешный PoR увеличивает счётчик повторов (когда в SPI1 есть проверка счётчика &mdash; b5 b4 &ne; 00; без неё счётчик не увеличивается и не сохраняется) и очищает пакет.</p>
<h3 id="ram" class="text-lg font-medium mb-2">3.2 RAM</h3>
<p class="mb-2">Выполняет операции удалённого управления приложениями (Remote Application Management) как защищённые пакеты SCP80 через SMS-PP-DOWNLOAD ENVELOPE. Карта должна поддерживать SCP03 (AES или 3DES). Пресет карты со вкладки <strong>Карты</strong> обеспечивает SPI, ключи, TAR и счётчик (RAM использует <strong>ISD TAR</strong> пресета, по умолчанию <code class="font-mono text-sm">000000</code>). Селектор <strong>Keyset</strong> рядом с выбором пресета указывает, из какого keyset берутся ключи и счётчик операции.</p>
<p class="mb-2">Выполняет операции удалённого управления приложениями (Remote Application Management) как защищённые пакеты SCP80 через SMS-PP-DOWNLOAD ENVELOPE. Карта должна поддерживать SCP03 (AES или 3DES). Пресет карты со вкладки <strong>Карты</strong> обеспечивает SPI, ключи, TAR и счётчик (RAM использует <strong>ISD TAR</strong> пресета, по умолчанию <code class="font-mono text-sm">000000</code>). Селектор <strong>Keyset</strong> рядом с выбором пресета указывает, из какого keyset берутся ключи и счётчик операции. Селектор недоступен, пока не выбран пресет карты.</p>
<h4 id="ram-operations" class="font-medium mb-1">Операции</h4>
<table class="w-full text-sm mb-3 border-collapse">
+2 -2
View File
@@ -281,7 +281,7 @@
<p class="mb-3">The <strong>SCP80</strong> top-level tab groups the SCP80-related views. It is switched by three pills: <strong>Secured Packet</strong>, <strong>RAM</strong> and <strong>TAR probe</strong>. Assembles SCP80 secured packets per ETSI TS 102 225. When a card is equipped the server reads its <strong>EF.ICCID</strong> (2FE2); if a card preset carries the same number (digits, or the raw EF hex), it is selected automatically in all three SCP80 views.</p>
<h3 id="secured-packet" class="text-lg font-medium mb-2">3.1 Secured Packet</h3>
<p class="mb-2">Builds SCP80 secured packets per ETSI TS 102 225. The <strong>Keyset</strong> 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).</p>
<p class="mb-2">Builds SCP80 secured packets per ETSI TS 102 225. The <strong>Keyset</strong> 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.</p>
<h4 id="packet-structure" class="font-medium mb-1">Packet structure</h4>
<table class="w-full text-sm mb-3 border-collapse">
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Field</th><th class="text-left py-1 px-2">Size</th><th class="text-left py-1 px-2">Description</th></tr></thead>
@@ -313,7 +313,7 @@
<p class="text-sm mb-3">A &ldquo;Verify vs pySim&rdquo; button cross-checks the assembled packet against pySim&rsquo;s reference <code class="font-mono text-sm">OtaDialectSms.encode_cmd</code>. A &ldquo;Send to Card&rdquo; 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&rsquo;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 <strong>concatenated</strong> download (TS 31.115 &sect;4.3) &mdash; 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&rsquo;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&rsquo;s status word and response data are filled into the <strong>R-APDU parser</strong> panel, and a successful PoR advances the replay counter (when SPI1 carries a counter check &mdash; b5 b4 &ne; 00; without one no counter is advanced or saved) and clears the packet. A <strong>low-counter</strong> verdict (<code class="font-mono text-sm">cntr_low</code>, or a warning ENVELOPE answer that still carries such a PoR) is <em>not</em> recoverable by retrying: every SCP80/RAM operation stops immediately and the result states the card's counter with a <strong>Go to preset</strong> button that opens the matching preset in the Cards tab, where the counter must be raised above it &mdash; nothing is written automatically.</p>
<h3 id="ram" class="text-lg font-medium mb-2">3.2 RAM</h3>
<p class="mb-2">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 <strong>Cards</strong> tab provides the SPI, keys, TAR, and counter (RAM uses the preset's <strong>ISD TAR</strong>, <code class="font-mono text-sm">000000</code> by default). The <strong>Keyset</strong> selector next to the preset selector picks the keyset whose keys and counter the operation uses.</p>
<p class="mb-2">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 <strong>Cards</strong> tab provides the SPI, keys, TAR, and counter (RAM uses the preset's <strong>ISD TAR</strong>, <code class="font-mono text-sm">000000</code> by default). The <strong>Keyset</strong> 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.</p>
<h4 id="ram-operations" class="font-medium mb-1">Operations</h4>
<table class="w-full text-sm mb-3 border-collapse">
+30 -20
View File
@@ -529,7 +529,7 @@
<option value="" data-l10n="— Select card —">— Select card —</option>
</select>
<label class="block mt-2 mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Keyset">Keyset</label>
<select id="sp-keyset-sel" onchange="spKeysetChanged()" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800"></select>
<select id="sp-keyset-sel" onchange="spKeysetChanged()" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800 disabled:opacity-40 disabled:cursor-not-allowed"></select>
</div>
<div id="sp-preset-warning" class="hidden mb-3 px-3 py-2 rounded border border-amber-400 dark:border-amber-600 bg-amber-50 dark:bg-amber-900/20 text-sm text-amber-700 dark:text-amber-300"></div>
@@ -696,7 +696,7 @@
</div>
<div class="flex-1">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Keyset">Keyset</label>
<select id="ram-keyset-sel" onchange="ramKeysetChanged()" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800"></select>
<select id="ram-keyset-sel" onchange="ramKeysetChanged()" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800 disabled:opacity-40 disabled:cursor-not-allowed"></select>
</div>
<div class="flex-1">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Operation">Operation</label>
@@ -923,7 +923,7 @@
</div>
<div class="flex-1">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Keyset">Keyset</label>
<select id="tar-keyset-sel" onchange="tarKeysetChanged()" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800"></select>
<select id="tar-keyset-sel" onchange="tarKeysetChanged()" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800 disabled:opacity-40 disabled:cursor-not-allowed"></select>
</div>
</div>
<div id="tar-preset-warning" class="hidden mb-3 px-3 py-2 rounded border border-amber-400 dark:border-amber-600 bg-amber-50 dark:bg-amber-900/20 text-sm text-amber-700 dark:text-amber-300"></div>
@@ -11464,24 +11464,27 @@ function cardsKeysetsFromForm() {
// matches their keyset number is selected; a number the preset does not define
// shows as "not defined" (and the packet actions refuse it).
function spKeysetSync() {
const spIdx = spPresetIdx();
const spPreset = spIdx >= 0 ? cards[spIdx] : null;
const kic = (document.getElementById('sp-kic-hex') || {}).value;
const kid = (document.getElementById('sp-kid-hex') || {}).value;
const spSel = document.getElementById('sp-keyset-sel');
if (spSel) spSel.innerHTML = spKeysetOptionsHtml(spPreset, kic, kid);
const ramSel = document.getElementById('ram-keyset-sel');
if (ramSel) {
const ramIdx = ramPresetIdx();
const ramPreset = ramIdx >= 0 ? cards[ramIdx] : null;
ramSel.innerHTML = spKeysetOptionsHtml(ramPreset, kic, kid);
}
const tarSel = document.getElementById('tar-keyset-sel');
if (tarSel) {
const tarIdx = tarPresetIdx();
const tarPreset = tarIdx >= 0 ? cards[tarIdx] : null;
tarSel.innerHTML = spKeysetOptionsHtml(tarPreset, kic, kid);
}
// A keyset only exists inside a card preset: with no preset selected the
// selector is disabled and cleared, so a stale "not defined in the preset"
// entry can never linger.
const sync = (selId, idxFn) => {
const sel = document.getElementById(selId);
if (!sel) return;
const idx = idxFn();
const preset = idx >= 0 ? cards[idx] : null;
if (!preset) {
sel.disabled = true;
sel.innerHTML = '<option value="">' + esc(t('\u2014 select a card preset \u2014')) + '</option>';
return;
}
sel.disabled = false;
sel.innerHTML = spKeysetOptionsHtml(preset, kic, kid);
};
sync('sp-keyset-sel', spPresetIdx);
sync('ram-keyset-sel', ramPresetIdx);
sync('tar-keyset-sel', tarPresetIdx);
}
function spKeysetApply(ks) {
@@ -11915,7 +11918,13 @@ function cardsApplyFields(idx, keepTar) {
}
function cardsApply(idx, keepTar) {
if (!cardsApplyFields(idx, keepTar)) return;
if (!cardsApplyFields(idx, keepTar)) {
// "— Select card —": there is no keyset to choose, and the preset
// warning must not outlive the selection it described.
spKeysetSync();
spPresetWarningRender();
return;
}
_genSpBuild();
spShowSizeInfo();
}
@@ -19834,6 +19843,7 @@ const LANG_RU = {
'is not defined in the preset': 'не определён в пресете',
'not defined in the preset': 'не определён в пресете',
'— no keyset —': '— нет keyset —',
'— select a card preset —': '— выберите пресет карты —',
'counter': 'счётчик',
'Define at least one keyset (KIc/KID and their keys)': 'Определите хотя бы один keyset (KIc/KID и его ключи)',
'Each keyset needs KIc, KID and both keys': 'Каждому набору нужны KIc, KID и оба ключа',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v342';
const CACHE = 'simple-v343';
const URLS = [
'index.html',
'help.html',
+1 -1
View File
@@ -28,7 +28,7 @@ for (const fn of ['cardsTarValue', 'cardsApplyFields', 'cardsApply',
'spKeysetOptionsHtml', 'cardsKeysetRowHtml', 'cardsKeysetRowsRender',
'cardsKeysetAdd', 'cardsKeysetRemove', 'cardsKeysetKvnUpdate',
'cardsKeysetsFromForm', 'spKeysetSync', 'spKeysetApply',
'spKeysetChanged', 'ramKeysetChanged', 'ramPresetIdx', 'spKeysetGuard',
'spKeysetChanged', 'ramKeysetChanged', 'ramPresetIdx', 'tarPresetIdx', 'spKeysetGuard',
'ramKeysetGuard',
]) {
code += extractFunc(html, fn) + '\n';
+1 -1
View File
@@ -34,7 +34,7 @@ for (const fn of ['cardsTarValue', 'cardsFormValues', 'cardsClearForm', 'cardsEd
'spKeysetOptionsHtml', 'cardsKeysetRowHtml', 'cardsKeysetRowsRender',
'cardsKeysetAdd', 'cardsKeysetRemove', 'cardsKeysetKvnUpdate',
'cardsKeysetsFromForm', 'spKeysetSync', 'spKeysetApply', 'spKeysetChanged',
'ramKeysetChanged', 'ramPresetIdx', 'spKeysetGuard', 'ramKeysetGuard']) {
'ramKeysetChanged', 'ramPresetIdx', 'tarPresetIdx', 'spKeysetGuard', 'ramKeysetGuard']) {
code += extractFunc(html, fn, ASYNC_FNS.indexOf(fn) >= 0) + '\n';
}
code = 'var _cardsKeysetCount = 0;\nglobalThis.spPresetWarningRender = function() {};\n' + code;
+42
View File
@@ -175,3 +175,45 @@ test('the RAM and TAR views rebuild their keyset lists when their preset is know
// opening the TAR pill populates both its selectors
assert.ok(html.includes("if (name === 'tar') { tarRender(); tarProbeRender(); }"));
});
test('the keyset selector is disabled and cleared while no preset is selected', () => {
const els = {
'sp-keyset-sel': { value: '', innerHTML: '' },
'ram-keyset-sel': { value: '', innerHTML: '' },
'tar-keyset-sel': { value: '', innerHTML: '' },
'sp-card-sel': { value: '' },
'ram-card-sel': { value: '' },
'tar-card-sel': { value: '' },
'sp-kic-hex': { value: '' },
'sp-kid-hex': { value: '' },
};
globalThis.document = { getElementById: id => els[id] || null };
globalThis.cards = [];
spKeysetSync();
for (const id of ['sp-keyset-sel', 'ram-keyset-sel', 'tar-keyset-sel']) {
assert.strictEqual(els[id].disabled, true, id + ' must be disabled');
assert.match(els[id].innerHTML, /select a card preset/);
assert.ok(!els[id].innerHTML.includes('not defined'), id);
}
// a selected preset enables the selector and lists its keysets
globalThis.cards = [{ name: 'EP', keysets: [
{ kic: '15', kid: '15', kicKey: 'AA', kidKey: 'BB', cntr: '0000000001' }] }];
els['sp-card-sel'].value = '0';
els['ram-card-sel'].value = '0';
els['tar-card-sel'].value = '0';
els['sp-kic-hex'].value = '15';
els['sp-kid-hex'].value = '15';
spKeysetSync();
for (const id of ['sp-keyset-sel', 'ram-keyset-sel', 'tar-keyset-sel']) {
assert.strictEqual(els[id].disabled, false, id + ' must be enabled');
assert.match(els[id].innerHTML, /value="1" selected/);
}
});
test('clearing the preset selection clears the keyset selectors', () => {
const at = html.indexOf('function cardsApply(');
assert.ok(at > 0);
const body = html.slice(at, at + 600);
assert.ok(body.includes('spKeysetSync();'),
'cardsApply must sync the keyset selectors when no preset is selected');
});