diff --git a/README.md b/README.md index 68fb7d9..4c8c0bc 100644 --- a/README.md +++ b/README.md @@ -449,7 +449,7 @@ Stores saved card configurations (presets) in `localStorage`. A preset holds the The **SCP81** column shows whether the preset supplies a usable PSK pair: **✓** (identity and key), **⚠** (only one of the two — the listener ignores such a preset), **—** (no PSK). Identity and key must be set together. -**Add a card:** fill in the name, ICCID (optional), SPI1/SPI2, KIc/KID keys and indices, TAR, the SCP81 PSK pair (optional) and click **Add**. The card appears in the list and becomes available in the RAM tab's **Card preset** dropdown. +**Add a card:** fill in the name, ICCID (optional — **From card** fills it from the equipped card's EF.ICCID), SPI1/SPI2, KIc/KID keys and indices, TAR, the SCP81 PSK pair (optional) and click **Add**. A duplicate ICCID (compared ignoring spaces and the raw-hex form) is refused, naming the conflicting preset. The card appears in the list and becomes available in the RAM tab's **Card preset** dropdown. **Edit / remove:** **Edit** loads a preset into the form (the Add button becomes **Save**; **Cancel** clears the form); **Remove** deletes the row from `localStorage`. A successful SCP80 send advances and stores the replay counter, and edits are pushed into a running SCP81 listener automatically. diff --git a/README_RUS.md b/README_RUS.md index e1c7545..201dc4c 100644 --- a/README_RUS.md +++ b/README_RUS.md @@ -412,7 +412,7 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на | Поле | Описание | |---|---| | Name | Понятная метка (обязательна) | -| ICCID | Опциональный идентификатор карты | +| ICCID | Опциональный идентификатор карты; кнопка **С карты** подставляет EF.ICCID подключённой карты (активна, только когда ICCID читается) | | SPI1 / SPI2 | Уровень безопасности и настройки PoR | | Индекс KIc / KID | Номер версии ключа (задаётся вместе с ключами) | | Ключ KIc / KID | Hex-ключи шифрования и MAC | @@ -423,7 +423,7 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на Столбец **SCP81** показывает, задана ли в пресете рабочая пара PSK: **✓** (идентификатор и ключ), **⚠** (только одно из двух — слушатель такой пресет игнорирует), **—** (PSK нет). Идентификатор и ключ задаются вместе. -**Добавить карту:** заполните имя, ICCID (опционально), SPI1/SPI2, ключи и индексы KIc/KID, TAR, при необходимости пару PSK и нажмите **Add**. Карта появится в списке и станет доступна в выпадающем списке **Card preset** на вкладке RAM. +**Добавить карту:** заполните имя, ICCID (опционально — кнопка **С карты** подставляет EF.ICCID подключённой карты), SPI1/SPI2, ключи и индексы KIc/KID, TAR, при необходимости пару PSK и нажмите **Add**. Дубликат ICCID (сравнение без пробелов и с учётом сырой hex-формы) отклоняется с указанием конфликтующего пресета. Карта появится в списке и станет доступна в выпадающем списке **Card preset** на вкладке RAM. **Изменить/удалить:** **Edit** загружает пресет в форму (кнопка Add становится **Save**; **Cancel** очищает форму); **Remove** удаляет строку из `localStorage`. Успешная отправка SCP80 увеличивает и сохраняет счётчик повторов, а изменения сразу передаются работающему слушателю SCP81. diff --git a/frontend/help-ru.html b/frontend/help-ru.html index dcc4ac7..ab19d06 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -303,7 +303,7 @@ ПолеОписание NameПонятная метка (обязательна) - ICCIDОпциональный идентификатор карты + ICCIDОпциональный идентификатор карты. Кнопка С карты подставляет EF.ICCID подключённой карты (активна, только когда ICCID читается); при сохранении ICCID, уже присутствующий в списке, отклоняется (сравнение без пробелов и с учётом сырой hex-формы, конфликтующий пресет называется) KIc / KIDИндикаторы ключа и алгоритма (например, 15 = индекс 1, 3DES-CBC2; x2 = AES); задаются вместе с ключами SPI1 / SPI2Security Parameter Indicators TARToolkit Application Reference diff --git a/frontend/help.html b/frontend/help.html index 3f9b010..393ef91 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -303,7 +303,7 @@ FieldDescription NameHuman-readable label (required) - ICCIDOptional card identifier + ICCIDOptional card identifier. From card fills it with the equipped card's EF.ICCID (enabled only while a readable ICCID is known); saving refuses an ICCID that already exists in the list (compared ignoring spaces and the raw-hex form, and the conflicting preset is named) KIc / KIDKey and algorithm indicators (e.g. 15 = index 1, 3DES-CBC2; x2 = AES), required together with the keys SPI1 / SPI2Security Parameter Indicators TARToolkit Application Reference diff --git a/frontend/index.html b/frontend/index.html index f4270c7..a0945d6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -18,7 +18,7 @@
-

OTAMan SIM OTA with a Human Face v2.2.17

+

OTAMan SIM OTA with a Human Face v2.2.18

@@ -1137,6 +1137,7 @@
+
@@ -4711,6 +4712,7 @@ let _pysimServerAvailable = null; // null until probed let _pysimCardEquipped = false; let _pysimEquipping = false; let _pysimAdmVerified = null; // null = no card session +let _pysimCardIccid = null; // EF.ICCID digits of the equipped card (null = unknown) let _pysimAvailabilityTimer = null; function pysimAvailabilityState() { @@ -4725,7 +4727,9 @@ function pysimControlDisabled(needs, state) { } function pysimNeedsHint(needs, state) { - return state === 'server-down' ? t('Connect to server') : t('Insert and equip a card'); + if (state === 'server-down') return t('Connect to server'); + if (needs === 'card-iccid' && state === 'card') return t('Card equipped but its ICCID is not readable'); + return t('Insert and equip a card'); } function pysimUpdateStateIndicator() { @@ -4756,7 +4760,11 @@ function pysimApplyAvailability() { const state = pysimAvailabilityState(); document.querySelectorAll('[data-needs]').forEach(el => { const needs = el.getAttribute('data-needs'); - const disabled = pysimControlDisabled(needs, state); + let disabled = pysimControlDisabled(needs, state); + if (needs === 'card-iccid') { + // From-card buttons need a readable ICCID, not just a session. + disabled = pysimControlDisabled('card', state) || !_pysimCardIccid; + } el.disabled = disabled; if (disabled) el.setAttribute('title', pysimNeedsHint(needs, state)); else el.removeAttribute('title'); @@ -6015,8 +6023,10 @@ function cardsAdd() { alert(t('PSK key must be 32 hex characters')); return; } - if (v.iccid && cards.some((c, i) => c.iccid === v.iccid && i !== _cardsEditIdx)) { - alert('Card with this ICCID already exists'); return; + const dupIdx = cardsFindDuplicateIccid(v.iccid, _cardsEditIdx); + if (dupIdx >= 0) { + alert(t('Card with this ICCID already exists') + ': ' + (cards[dupIdx].name || ('Card ' + dupIdx))); + return; } if (_cardsEditIdx !== null && cards[_cardsEditIdx]) { cards[_cardsEditIdx] = Object.assign({}, cards[_cardsEditIdx], v); @@ -6133,6 +6143,23 @@ function cardsFindByIccid(iccid) { return cards.findIndex(c => cardsNormIccid(c.iccid) === norm); } +function cardsFindDuplicateIccid(iccid, skipIdx) { + // Duplicate detection ignores the edited row and compares normalized + // values (digits, spaced digits or raw EF hex all mean the same card). + const norm = cardsNormIccid(iccid); + if (!norm) return -1; + return cards.findIndex((c, i) => i !== skipIdx && cardsNormIccid(c.iccid) === norm); +} + +function cardsIccidFromCard() { + // Fill the form's ICCID from the equipped card (status.iccid). The button + // is only enabled while a readable ICCID is known; alert just in case the + // state changed between the last poll and the click. + const iccid = _pysimCardIccid; + if (!iccid) { alert(t('Card equipped but its ICCID is not readable')); return; } + document.getElementById('cards-iccid').value = iccid; +} + let _cardsAutoIccid = null; function cardsAutoSelectByIccid(iccid) { @@ -7646,6 +7673,7 @@ function pysimCardStateUpdate(status) { _pysimServerAvailable = true; _pysimCardEquipped = !!status.connected; _pysimEquipping = !!status.equipping; + _pysimCardIccid = status.connected ? (status.iccid || null) : null; pysimUpdateAdmIndicator(status); pysimApplyAvailability(); if (pysimProactiveSeqChanged(status.proactive_seq) @@ -11637,6 +11665,9 @@ const LANG_RU = { 'executed': 'выполнено', 'pending': 'ожидает', 'Name is required': 'Укажите имя', + 'From card': 'С карты', + 'Card with this ICCID already exists': 'Карта с таким ICCID уже есть в списке', + 'Card equipped but its ICCID is not readable': 'Карта подключена, но её ICCID не читается', 'Saved.': 'Сохранено.', 'Invalid APDU': 'Некорректный APDU', 'Add at least one APDU': 'Добавьте хотя бы один APDU', diff --git a/frontend/sw.js b/frontend/sw.js index dc974c8..5b4f48a 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'otaman-v184'; +const CACHE = 'otaman-v185'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/card_state.test.js b/frontend/tests/card_state.test.js index c820824..85c0b58 100644 --- a/frontend/tests/card_state.test.js +++ b/frontend/tests/card_state.test.js @@ -24,7 +24,7 @@ function extractFunc(src, name) { let code = 'var _pysimCardStateKey = null;\nvar _pysimCardSession = null;\n' + 'var _pysimServerAvailable = null;\nvar _pysimCardEquipped = false;\n' + 'var _pysimProactiveSeq = null;\nvar _pysimStkSig = null;\nvar _pysimAdmVerified = null;\n' - + 'var _cardsAutoIccid = null;\n'; + + 'var _cardsAutoIccid = null;\nvar _pysimCardIccid = null;\n'; code += extractFunc(html, 'pysimCardStateUpdate') + '\n'; code += extractFunc(html, 'pysimAvailabilityState') + '\n'; code += extractFunc(html, 'pysimControlDisabled') + '\n'; @@ -61,6 +61,7 @@ function setup() { _pysimAdmVerified = null; _pysimServerAvailable = null; _cardsAutoIccid = null; + _pysimCardIccid = null; globalThis.document = { getElementById: id => id === 'state-indicator-adm' ? adm : el, querySelectorAll: () => [], @@ -122,6 +123,19 @@ test('a disconnect clears the ICCID auto-selection guard', () => { assert.strictEqual(_cardsAutoIccid, null); }); +test('the last status ICCID is kept for the From card button', () => { + setup(); + pysimCardStateUpdate(status({ connected: true, card_present: true, card_session: 2, iccid: '8970119000004600098' })); + assert.strictEqual(_pysimCardIccid, '8970119000004600098'); + // equipped but unreadable -> null (button disabled) + pysimCardStateUpdate(status({ connected: true, card_present: true, card_session: 2, iccid: null })); + assert.strictEqual(_pysimCardIccid, null); + // disconnect clears it as well + pysimCardStateUpdate(status({ connected: true, card_present: true, card_session: 2, iccid: '8970119000004600098' })); + pysimCardStateUpdate(status({ connected: false, card_present: false, card_session: 6 })); + assert.strictEqual(_pysimCardIccid, null); +}); + test('card session change triggers a data reset', () => { const { calls } = setup(); pysimCardStateUpdate(status({ card_session: 3 })); diff --git a/frontend/tests/cards_iccid.test.js b/frontend/tests/cards_iccid.test.js index 3de5449..41b35d2 100644 --- a/frontend/tests/cards_iccid.test.js +++ b/frontend/tests/cards_iccid.test.js @@ -23,7 +23,8 @@ function extractFunc(src, name) { let code = ''; for (const fn of ['swapNibbles', 'encIccid', 'decIccid', - 'cardsNormIccid', 'cardsFindByIccid', 'cardsAutoSelectByIccid']) { + 'cardsNormIccid', 'cardsFindByIccid', 'cardsFindDuplicateIccid', + 'cardsIccidFromCard', 'cardsAutoSelectByIccid']) { code += extractFunc(html, fn) + '\n'; } eval(code); @@ -33,11 +34,18 @@ const RAW_HEX = '980711090000640090F8'; const DIGITS = '8970119000004600098'; function fakeDoc() { - const els = { 'sp-card-sel': { value: '' }, 'ram-card-sel': { value: '' } }; + const els = { 'sp-card-sel': { value: '' }, 'ram-card-sel': { value: '' }, 'cards-iccid': { value: '' } }; globalThis.document = { getElementById: id => els[id] || null }; return els; } +function fakeAlert() { + const seen = []; + globalThis.alert = msg => seen.push(msg); + globalThis.t = s => s; + return seen; +} + test('cardsNormIccid accepts digits, separators and raw EF hex', () => { assert.strictEqual(cardsNormIccid(DIGITS), DIGITS); assert.strictEqual(cardsNormIccid(' 89 70 1190-0000 4600 098 '), DIGITS); @@ -104,6 +112,53 @@ test('cardsAutoSelectByIccid reacts to a card swap and to unreadable ICCIDs', () assert.strictEqual(cardsAutoSelectByIccid('1234567890123456789'), -1); }); +test('cardsFindDuplicateIccid catches duplicates across stored formats', () => { + globalThis.cards = [ + { name: 'A', iccid: '1111111111111111111' }, + { name: 'B', iccid: RAW_HEX }, + { name: 'C', iccid: '' }, + ]; + assert.strictEqual(cardsFindDuplicateIccid(DIGITS), 1); + assert.strictEqual(cardsFindDuplicateIccid('89 70 1190-0000 4600 098'), 1); + assert.strictEqual(cardsFindDuplicateIccid('1111111111111111111'), 0); + assert.strictEqual(cardsFindDuplicateIccid('2222222222222222222'), -1); + // empty ICCID is never a duplicate (the field is optional) + assert.strictEqual(cardsFindDuplicateIccid(''), -1); + assert.strictEqual(cardsFindDuplicateIccid(' '), -1); +}); + +test('cardsFindDuplicateIccid skips the row being edited', () => { + globalThis.cards = [{ name: 'A', iccid: DIGITS }, { name: 'B', iccid: '' }]; + assert.strictEqual(cardsFindDuplicateIccid(DIGITS, 0), -1); + assert.strictEqual(cardsFindDuplicateIccid(RAW_HEX, 0), -1); + assert.strictEqual(cardsFindDuplicateIccid(RAW_HEX, 1), 0); +}); + +test('cardsIccidFromCard fills the field from the equipped card', () => { + const els = fakeDoc(); + const seen = fakeAlert(); + globalThis._pysimCardIccid = DIGITS; + cardsIccidFromCard(); + assert.strictEqual(els['cards-iccid'].value, DIGITS); + assert.deepStrictEqual(seen, []); + // no readable ICCID (state raced the click): alert, leave the field alone + els['cards-iccid'].value = 'keep-me'; + globalThis._pysimCardIccid = null; + cardsIccidFromCard(); + assert.strictEqual(els['cards-iccid'].value, 'keep-me'); + assert.deepStrictEqual(seen, ['Card equipped but its ICCID is not readable']); +}); + +test('the From card button and the duplicate refusal are wired into the form', () => { + assert.match(html, /