diff --git a/frontend/help-ru.html b/frontend/help-ru.html index bb72de1..caa229c 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -340,7 +340,7 @@

4. Вкладка «Карты»

-

Хранит предустановки карт на сервере (~/.pysim-simple-server/card_presets.json; переопределяется --card-presets), чтобы представление Secured Packet могло автоматически подставлять ключи и параметры, слушатель SCP81 HTTP OTA — находить PSK-ключи, а счётчики SCP80, израсходованные операцией, сохранялись самой операцией — закрытая вкладка или второе окно браузера больше не могут потерять инкремент. Предустановка содержит один набор ключей на версию ключа (ниббл b8..b5 KIc/KID, TS 102 225 §5.1.2/A.2): KIc и KID набора должны содержать одну версию (01–0F; A.2 отвергает несовпадение), и у каждой версии ключа на карте свой счётчик (Annex A.1), поэтому счётчики ведутся по наборам. Список загружается при подключении PWA и экспортируется/импортируется как JSON (импорт принимает и предустановки старых сборок — плоские и с наборами ключей). «Карты» — верхнеуровневая вкладка. При подключении карты читается её EF.ICCID, и предустановка с тем же ICCID автоматически выбирается в обоих представлениях SCP80. Поля формы сгруппированы в два блока с рамкой: SCP80 (GSM 03.48, ETSI TS 102 225) (KIc/KID, SPI1/SPI2, TAR-ы ISD/UICC RFM/ADF RFM, счётчик и ключи) и SCP81 (HTTP OTA) (пара PSK identity/ключ с пояснением о выборе ключа). Необязательное поле ADM находится в верхней строке и сохраняется для последующего использования файловым менеджером. В шапке отображаются серые маркеры SCP80/SCP81 и значок ключа на бейдже ADM, когда в предустановке с ICCID подключённой карты эти поля заполнены.

+

Хранит предустановки карт на сервере (~/.pysim-simple-server/card_presets.json; переопределяется --card-presets), чтобы представление Secured Packet могло автоматически подставлять ключи и параметры, слушатель SCP81 HTTP OTA — находить PSK-ключи, а счётчики SCP80, израсходованные операцией, сохранялись самой операцией — закрытая вкладка или второе окно браузера больше не могут потерять инкремент. Предустановка содержит один набор ключей на версию ключа (ниббл b8..b5 KIc/KID, TS 102 225 §5.1.2/A.2): KIc и KID набора должны содержать одну версию (01–0F; A.2 отвергает несовпадение), и у каждой версии ключа на карте свой счётчик (Annex A.1), поэтому счётчики ведутся по наборам. Список загружается при подключении PWA и экспортируется/импортируется как JSON (импорт принимает и предустановки старых сборок — плоские и с наборами ключей). «Карты» — верхнеуровневая вкладка. При подключении карты читается её EF.ICCID, и предустановка с тем же ICCID автоматически выбирается в обоих представлениях SCP80; если подключённая карта не совпадает ни с одной предустановкой — или выбранная предустановка принадлежит другой карте — оба представления SCP80/RAM показывают жёлтое предупреждение над выбором предустановки. Поля формы сгруппированы в два блока с рамкой: SCP80 (GSM 03.48, ETSI TS 102 225) (KIc/KID, SPI1/SPI2, TAR-ы ISD/UICC RFM/ADF RFM, счётчик и ключи) и SCP81 (HTTP OTA) (пара PSK identity/ключ с пояснением о выборе ключа). Необязательное поле ADM находится в верхней строке и сохраняется для последующего использования файловым менеджером. В шапке отображаются серые маркеры SCP80/SCP81 и значок ключа на бейдже ADM, когда в предустановке с ICCID подключённой карты эти поля заполнены.

diff --git a/frontend/help.html b/frontend/help.html index 7e2f644..0f5e190 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -339,7 +339,7 @@

4. Cards tab

-

Stores card presets on the server (~/.pysim-simple-server/card_presets.json; --card-presets overrides it) so the Secured Packet view can auto-fill keys and parameters, the SCP81 HTTP OTA listener can look up PSK keys, and the SCP80 counters an operation consumed are persisted with the operation itself — a closed tab or a second browser window can no longer lose an increment. A preset carries one keyset per key version (the b8..b5 nibble of KIc/KID, TS 102 225 §5.1.2/A.2): KIc and KID of a keyset must carry the same version (01–0F; A.2 rejects a mismatch), and each key version has its own counter on the card (Annex A.1), so counters are tracked per keyset. The list is loaded when the PWA connects and can be exported/imported as JSON (the import also takes presets exported by older builds, flat or keyset-shaped). Cards is a top-level tab. When the card is equipped its EF.ICCID is read and the preset with the same ICCID is selected automatically in both SCP80 views. The form groups the fields into two bordered blocks: SCP80 (GSM 03.48, ETSI TS 102 225) (KIc/KID, SPI1/SPI2, the ISD/UICC RFM/ADF RFM TARs, counter and keys) and SCP81 (HTTP OTA) (the PSK identity/key pair with the key-selection note). The optional ADM field sits in the top row and is stored for later use by the file manager. The header shows gray SCP80/SCP81 markers and a key glyph on the ADM badge when the preset matching the equipped card's ICCID has those settings filled in.

+

Stores card presets on the server (~/.pysim-simple-server/card_presets.json; --card-presets overrides it) so the Secured Packet view can auto-fill keys and parameters, the SCP81 HTTP OTA listener can look up PSK keys, and the SCP80 counters an operation consumed are persisted with the operation itself — a closed tab or a second browser window can no longer lose an increment. A preset carries one keyset per key version (the b8..b5 nibble of KIc/KID, TS 102 225 §5.1.2/A.2): KIc and KID of a keyset must carry the same version (01–0F; A.2 rejects a mismatch), and each key version has its own counter on the card (Annex A.1), so counters are tracked per keyset. The list is loaded when the PWA connects and can be exported/imported as JSON (the import also takes presets exported by older builds, flat or keyset-shaped). Cards is a top-level tab. When the card is equipped its EF.ICCID is read and the preset with the same ICCID is selected automatically in both SCP80 views; when the equipped card matches no preset — or the selected preset belongs to another card — both SCP80/RAM views show an amber warning above the preset selector. The form groups the fields into two bordered blocks: SCP80 (GSM 03.48, ETSI TS 102 225) (KIc/KID, SPI1/SPI2, the ISD/UICC RFM/ADF RFM TARs, counter and keys) and SCP81 (HTTP OTA) (the PSK identity/key pair with the key-selection note). The optional ADM field sits in the top row and is stored for later use by the file manager. The header shows gray SCP80/SCP81 markers and a key glyph on the ADM badge when the preset matching the equipped card's ICCID has those settings filled in.

ПолеОписание
diff --git a/frontend/index.html b/frontend/index.html index 2140c4d..81dba91 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -530,6 +530,7 @@ +
@@ -708,6 +709,7 @@
+ @@ -8380,6 +8382,7 @@ function ramApplyCard(idx) { if (!isNaN(i) && cards[i]) _ramCardIdx = i; _spTarKey = 'tar'; // RAM/GP operations target the ISD cardsApply(idx); + spPresetWarningRender(); } function ramOpChanged() { @@ -10961,6 +10964,7 @@ async function cardsFetch() { cardsRebuildSelect(); cardsRender(); scp81PushPskMap(); + spPresetWarningRender(); return true; } @@ -11204,6 +11208,47 @@ function ramKeysetChanged() { spKeysetSync(); } +// A warning for the SCP80/RAM views when the equipped card and the selected +// preset disagree (pure). Autoselection keeps them in step, so this covers +// what it cannot fix: a card with no preset at all, or a manually selected +// preset that belongs to another card. Returns '' when all is well (or when +// there is no card session / no readable ICCID to compare against). +function spPresetWarning(iccid, presets, selectedIdx) { + const list = presets || []; + const norm = cardsNormIccid(iccid); + if (!norm) return ''; + const matched = list.findIndex(c => cardsNormIccid(c.iccid) === norm); + if (matched < 0) { + if (!list.length) { + return t('No card presets defined — the SCP80/RAM views have no keys ' + + 'for the equipped card. Add one in the Cards tab.'); + } + return t('The equipped card') + ' ' + iccid + ' ' + + t('does not match any card preset — the selected preset may belong to ' + + 'another card. Add it in the Cards tab (From card).'); + } + const sel = Number(selectedIdx); + if (Number.isInteger(sel) && sel >= 0 && sel < list.length && sel !== matched) { + return t('The selected preset is not the equipped card\'s') + ' (' + + (list[matched].name || iccid) + ') — ' + + t('check that its keys and counters belong to this card.'); + } + return ''; +} + +// Render the banner in both SCP80 views (each has its own preset selection). +function spPresetWarningRender() { + const iccid = _pysimCardIccid; + const show = (id, text) => { + const el = document.getElementById(id); + if (!el) return; + el.textContent = text || ''; + el.classList.toggle('hidden', !text); + }; + show('sp-preset-warning', spPresetWarning(iccid, cards, spPresetIdx())); + show('ram-preset-warning', spPresetWarning(iccid, cards, ramPresetIdx())); +} + // The RAM view's preset index. function ramPresetIdx() { const sel = document.getElementById('ram-card-sel'); @@ -11546,6 +11591,7 @@ function cardsApplyFields(idx, keepTar) { spInvalidate(); updateSp(); spKeysetSync(); + spPresetWarningRender(); return true; } @@ -13366,6 +13412,7 @@ function pysimCardStateUpdate(status) { pysimUpdateAdmIndicator(status); pysimUpdateIccidIndicator(_pysimCardIccid); pysimUpdatePresetIndicators(_pysimCardIccid); + spPresetWarningRender(); pysimApplyAvailability(); if (pysimProactiveSeqChanged(status.proactive_seq) && isViewVisible('tab-phone') && isViewVisible('phone-sub-phone')) { @@ -19424,6 +19471,11 @@ const LANG_RU = { 'Duplicate key version in the keysets': 'Дублирующаяся версия ключа в наборах', 'Select a card preset - its keysets define the key versions': 'Выберите предустановку карты — её наборы ключей определяют версии ключей', 'SCP80 counters saved to the preset.': 'Счётчики SCP80 сохранены в предустановке.', + 'No card presets defined — the SCP80/RAM views have no keys for the equipped card. Add one in the Cards tab.': 'Предустановки карт не заданы — в представлениях SCP80/RAM нет ключей для подключённой карты. Добавьте её на вкладке «Карты».', + 'The equipped card': 'Подключённая карта', + 'does not match any card preset — the selected preset may belong to another card. Add it in the Cards tab (From card).': 'не совпадает ни с одной предустановкой — выбранная предустановка может принадлежать другой карте. Добавьте её на вкладке «Карты» (кнопка «С карты»).', + "The selected preset is not the equipped card's": 'Выбранная предустановка не принадлежит подключённой карте', + 'check that its keys and counters belong to this card.': 'проверьте, что её ключи и счётчики относятся к этой карте.', 'Key version (kvn, optional)': 'Версия ключа (kvn, опционально)', "empty = the preset's first keyset": 'пусто = первый набор ключей предустановки', 'Keysets (key version: KIc/KID — counter)': 'Наборы ключей (версия: KIc/KID — счётчик)', diff --git a/frontend/sw.js b/frontend/sw.js index 0676043..5ed6729 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v335'; +const CACHE = 'simple-v336'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/card_state.test.js b/frontend/tests/card_state.test.js index 647673b..da60e64 100644 --- a/frontend/tests/card_state.test.js +++ b/frontend/tests/card_state.test.js @@ -108,6 +108,9 @@ function setup() { globalThis.isViewVisible = () => true; globalThis.pysimProactiveLogRender = () => { calls.proactive++; }; globalThis.cardsAutoSelectByIccid = iccid => { calls.autoIccid.push(iccid); return -1; }; + // the SCP80/RAM preset warning banner is DOM-bound (covered in + // preset_warning.test.js) - stub it for these state tests + globalThis.spPresetWarningRender = () => {}; return { el, adm, iccidEl, scp80El, scp81El, calls }; } diff --git a/frontend/tests/cards_counter.test.js b/frontend/tests/cards_counter.test.js index c1874cd..e259b7e 100644 --- a/frontend/tests/cards_counter.test.js +++ b/frontend/tests/cards_counter.test.js @@ -33,7 +33,7 @@ for (const fn of ['cardsTarValue', 'cardsApplyFields', 'cardsApply', ]) { code += extractFunc(html, fn) + '\n'; } -code = 'var _cardsKeysetCount = 0;\n' + code; +code = 'var _cardsKeysetCount = 0;\nglobalThis.spPresetWarningRender = 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 7096e4d..f9bdf68 100644 --- a/frontend/tests/cards_form.test.js +++ b/frontend/tests/cards_form.test.js @@ -37,7 +37,7 @@ for (const fn of ['cardsTarValue', 'cardsFormValues', 'cardsClearForm', 'cardsEd 'ramKeysetChanged', 'ramPresetIdx', 'spKeysetGuard', 'ramKeysetGuard']) { code += extractFunc(html, fn, ASYNC_FNS.indexOf(fn) >= 0) + '\n'; } -code = 'var _cardsKeysetCount = 0;\n' + code; +code = 'var _cardsKeysetCount = 0;\nglobalThis.spPresetWarningRender = function() {};\n' + code; eval(code); const CARD_IDS = ['cards-name','cards-iccid','cards-adm', diff --git a/frontend/tests/preset_warning.test.js b/frontend/tests/preset_warning.test.js new file mode 100644 index 0000000..2a50a82 --- /dev/null +++ b/frontend/tests/preset_warning.test.js @@ -0,0 +1,83 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); + +const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8'); + +function extractFunc(src, name) { + const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{'); + const m = re.exec(src); + if (!m) throw new Error('function ' + name + ' not found'); + let i = m.index + m[0].length - 1; + let depth = 0; + for (; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}') { + depth--; + if (depth === 0) break; + } + } + return src.slice(m.index, i + 1); +} + +let code = ''; +for (const fn of ['swapNibbles', 'encIccid', 'decIccid', 'cardsNormIccid', + 'spPresetWarning']) { + code += extractFunc(html, fn) + '\n'; +} +code += 'function t(s){return s;}\n'; +eval(code); + +const DIGITS = '8970119000004600098'; +const RAW_HEX = '980711090000640090F8'; +const OTHER = '89390100000129506903'; +const CARDS = [ + { name: 'Card A', iccid: DIGITS }, + { name: 'Card B', iccid: OTHER }, +]; + +test('no warning without a card session or a readable ICCID', () => { + assert.strictEqual(spPresetWarning(null, CARDS, 0), ''); + assert.strictEqual(spPresetWarning('', CARDS, 0), ''); + assert.strictEqual(spPresetWarning(undefined, CARDS, 0), ''); +}); + +test('no warning when the equipped card matches a preset', () => { + assert.strictEqual(spPresetWarning(DIGITS, CARDS, 0), ''); + // the preset's ICCID may be stored in any form + assert.strictEqual(spPresetWarning(DIGITS, [{ name: 'A', iccid: RAW_HEX }], 0), ''); + // the match does not depend on the selection + assert.strictEqual(spPresetWarning(DIGITS, CARDS, 1) === '', false); +}); + +test('a card with no matching preset gets the strong warning', () => { + const text = spPresetWarning('8945001234567890123', CARDS, 0); + assert.match(text, /8945001234567890123/); + assert.match(text, /does not match any card preset/); + assert.match(text, /From card/); + // no presets at all: the other wording + const none = spPresetWarning(DIGITS, [], 0); + assert.match(none, /No card presets defined/); + assert.match(spPresetWarning(DIGITS, null, -1), /No card presets defined/); +}); + +test('a manually selected preset of another card gets the softer hint', () => { + const text = spPresetWarning(DIGITS, CARDS, 1); + assert.match(text, /selected preset is not the equipped card/); + assert.match(text, /Card A/); + // an empty / out-of-range selection is not the softer case (the strong + // warning above covers a card with no preset) + assert.strictEqual(spPresetWarning(DIGITS, CARDS, -1), ''); + assert.strictEqual(spPresetWarning(DIGITS, CARDS, 5), ''); +}); + +test('both SCP80 views carry the warning banner', () => { + assert.match(html, /id="sp-preset-warning" class="hidden mb-3 px-3 py-2 rounded border border-amber-400/); + assert.match(html, /id="ram-preset-warning" class="hidden mb-3 px-3 py-2 rounded border border-amber-400/); + // ... and the renderer is wired into the card state, the preset load and + // the preset selection + assert.ok(html.includes('spPresetWarningRender();')); + assert.ok(html.includes("show('sp-preset-warning', spPresetWarning(iccid, cards, spPresetIdx()));")); + assert.ok(html.includes("show('ram-preset-warning', spPresetWarning(iccid, cards, ramPresetIdx()));")); +}); diff --git a/frontend/tests/ram.test.js b/frontend/tests/ram.test.js index 8085a81..6ba8e37 100644 --- a/frontend/tests/ram.test.js +++ b/frontend/tests/ram.test.js @@ -48,6 +48,7 @@ const an = html.match(/const JC_AID_NAMES = \{[\s\S]*?\n\};/); if (an) code += an[0].replace(/^const /, 'var ') + '\n'; const ar = html.match(/const JC_AID_RIDS = \{[\s\S]*?\n\};/); if (ar) code += ar[0].replace(/^const /, 'var ') + '\n'; +globalThis.spPresetWarningRender = () => {}; eval(code); code += 'var _ramCardIdx = null;\nvar _ramOpLast = null;\nvar _ramExplorerData = null;\n'; eval(code);
FieldDescription