From 18e705a8c36f6097e1777ca61dcf63394a068b80 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: Wed, 30 Sep 2026 22:36:03 +0300 Subject: [PATCH] feat: TAR probe becomes its own SCP80 pill with a TAR checklist (v3.8.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The probe is not a RAM operation, so it moves out of the RAM view into a third SCP80 pill (Secured packet | RAM | TAR probe, RU "TAR-сканер"), with its own preset and key-version selectors, its own equipped-card mismatch banner and its own result line; the help gains a "3.3 TAR probe" section and the anchor map follows the pill. The TAR field is a checklist now: pre-populated with the standard allocations of TS 101 220 V18.3.0 Annex D, Table D.1 (each row carrying the document it comes from, all checked by default, Select all / Select none), plus custom TARs added through a small form (three hex bytes + an optional description, removed with a x). Custom entries live in localStorage['simple_tar_probe'] on a best-effort basis - losing them is harmless, the standard list is always there. Only the checked TARs are sent; nothing checked is refused with a message. A Python test guards the PWA's list against the server's TAR_PROBE_TARS (the endpoint keeps its own default for API callers). RAM leftovers removed (the tar-probe operation entry, the #ram-tar-rows block, the ramExecute dispatch, ramTarProbe); getRamSpParams() takes the view's selector id; tarRender/tarApplyCard/tarKeysetChanged/tarKeysetGuard mirror the RAM view; spKeysetSync and spPresetWarningRender cover the new selectors. sw cache simple-v338. Tests: 740 Node (the checklist rows, the custom round-trip, the checked-list read, the renderer, the pill wiring) and 619 Python (the list drift guard). --- frontend/help-ru.html | 7 +- frontend/help.html | 7 +- frontend/index.html | 294 ++++++++++++++++++++++++++----- frontend/sw.js | 2 +- frontend/tests/tar_probe.test.js | 174 ++++++++++++------ tests/test_fetch_routes.py | 15 ++ 6 files changed, 398 insertions(+), 101 deletions(-) diff --git a/frontend/help-ru.html b/frontend/help-ru.html index ab44e20..f0a1b2c 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -323,7 +323,6 @@ Обзор карты (все данные GP)Запрос GET STATUS для ISD, приложений, ELF и модулей ELF, а также GET DATA FF21 (расширенные ресурсы карты: число установленных приложений и свободная энергонезависимая/энергозависимая память). Компактный листинг содержит только AID, состояние жизненного цикла и привилегии (версии в устаревшем формате нет); если карта отвергает его (P2.b2=0, устаревший по GP), запросы повторяются в расширенном формате E3, а флажок Запрашивать расширенные данные реестра (связи load-файла и SD) дополнительно запрашивает их и объединяет расширенные поля: версию исполняемого load-файла, AID load-файла, AID Security Domain, списки модулей, пункты меню STK апплета (SCP Registry Data, TS 102 226 8.2.1.6) и любые другие объекты реестра, которые вернула карта (показываются как есть, без потерь) — он стоит несколько дополнительных защищённых пакетов на каждый обзор и включён по умолчанию (запоминается в браузере; снимите флажок, чтобы пропустить). Карты различаются в том, что принимают, поэтому расширенный запрос пробуется в трёх формах — эталонный список тегов (с EA по TS 102 226), тот же список без EA и вовсе без списка тегов (форма GlobalPlatformPro) — а обзор сообщает, какая форма сработала и где выполнен откат к компактному списку (версии пакетов тогда недоступны). Результаты отображаются в обзоре (строка успеха минимальна, счётчики — в заголовках разделов) с кнопками Удалить для каждого элемента и определённым форматом команд RAM (подсказка при наведении объясняет compact и expanded). Установка пакета (.cap файл)Отправка .cap файла на карту через сервер: INSTALL[for load] → LOAD ×N → INSTALL[for install (+make selectable)]. Load-файл делится на LOAD APDU размера размер блока LOAD (1–240 байт полезной нагрузки, по умолчанию 240); каждый защищённый пакет доставляется одним SMS или, если он больше, конкатенированной SMS-PP загрузкой до 5 сегментов, так что большой .cap просто занимает несколько SMS. Сразу после выбора файл проверяется и в форме показывается оценка требований к памяти: требование NVRAM (образ кода + постоянные данные, сумма в стиле C6+C8 из GP Card Spec) и оценка RAM. Там же показаны библиотеки, с которыми слинкован CAP (его компонент Import: имя библиотеки, AID и минимальная версия ≥ x.y — имена стандартных библиотек и семейства подставляются, а где известно, даётся подсказка о версии Java Card), идентификаторы пакета и апплетов и разбивка по компонентам (размеры и доли в порядке load-файла) — всё сгруппировано по разделам Package, Requires, Memory и Components. Строка Platform показывает минимальный уровень Java Card, который должна предоставить карта (выводится из импорта javacard.framework; флаг int в заголовке дополнительно требует поддержки int — Java Card 2.2+), и релиз ETSI, из которого пришли импорты UICC API (версия uicc.toolkit → REL-N по корпусу sim-tools в рабочем каталоге; основание показано в скобках): JCRE проверяет каждый импорт по своим пакетам при верификации load-файла (JC VM spec 4.5.2), поэтому карта со старой платформой отклоняет LOAD сразу после завершения компонента Import (в строке шага виден remote SW карты и компонент, который завершил блок, например завершает Import) — меньший размер блока LOAD помогает только если блок превышает буфер карты. Повреждённый файл не проходит анализ, и Execute остаётся недоступной до успешного анализа. Оценка носит справочный характер и никогда не используется при установке: NVRAM — это образ кода плюс постоянные данные (сумма в стиле C6+C8 по GP Table 11-48; управление памятью карты, округление и запись в реестре не учитываются, а образ статических полей входит в обе части); рекомендуемый C6 следует за load-файлом, а рекомендуемые квоты — это десятичные значения в байтах (шестнадцатеричное значение в скобках — это кодирование параметров установки C7/C8: C6 — энергонезависимый код, C7 — энергозависимая RAM, C8 — энергонезависимые данные). Проверка совместимости CAPОтвечает на один вопрос, ничего не устанавливая: может ли эта карта удовлетворить список импортов CAP? Сервер выполняет проверку формата, INSTALL[for load] и блоки LOAD только до блока, завершающего компонент Import — именно там JCRE проверяет список импортов — и останавливается: без флага последнего блока и без INSTALL[for install], поэтому ничего не фиксируется и удалять после этого ничего не нужно. Список «Версии импортов для проверки» предзаполняется из анализа CAP: AID=version переписывает запрошенную версию импорта, +AID=version добавляет синтетический импорт (запрос возможностей карты: LOAD проходит, только если карта экспортирует этот пакет версии ≥ указанной), а «Все 0.0» снимает все требования. Использование в два шага: нажмите «Все 0.0» — если проверка проходит, причиной был список импортов; затем возвращайте по одной строке, чтобы найти пакет и версию, которых не хватает карте. Вердикт явно указывает, что проверяется только этап LOAD/импортов: этап связывания (наличие реально вызываемых API) проверяет настоящая установка на INSTALL[for install]. Запуск и результат показываются в окне (как при установке): строки шагов, вердикт и блок деталей по каждому AID — при отказе он называет раздел CAP, который покрывает неудачный блок (диапазон байтов load-файла и компоненты, и завершает ли этот блок Import), SW команды с его значением, список импортов ровно в том виде, в каком он проверялся (версии CAP, правки пробы, добавленные синтетические записи, строки без совпадений) и что означает состояние пробы; при успехе — список импортов, который прошёл проверку. - Проба TAR (зарегистрированные OTA-приложения)Отправляет по одному защищённому пакету на каждый TAR — стандартные значения из TS 101 220 Annex D, Table D.1, если список TAR не задан — с безобидной C-APDU (по умолчанию SELECT MF), набором ключей предустановки и SPI 16/01 (проверка счётчика + PoR), и классифицирует ответ карты: зарегистрировано (ENVELOPE 9000 + PoR со SW/данными приложения), принято, нет PoR (пакет молча отброшен), отклонено (обычно 6200). Полезно для картирования OTA-приложений карты. Каждый принятый пакет расходует счётчик, который сохраняется в набор ключей — проба отклоняется, если в SPI1 нет проверки счётчика. @@ -340,6 +339,12 @@
+

3.3 TAR-сканер

+

Третья пилюля вкладки SCP80 показывает, какие OTA-приложения зарегистрированы на карте: отправляется по одному защищённому пакету на каждый выбранный TAR — стандартные значения из TS 101 220 V18.3.0 Annex D, Table D.1, каждая строка несёт документ-источник — с безобидной C-APDU (по умолчанию SELECT MF), предустановкой и набором ключей этого представления, SPI 16/01 (проверка счётчика + PoR).

+

Список предзаполнен стандартными TAR (все отмечены; кнопки Выбрать все / Снять все переключают быстро), свои TAR добавляются кнопкой Добавить TAR (три hex-байта и необязательное описание). Пользовательские записи хранятся в localStorage по возможности — их потеря безвредна.

+

Каждый TAR классифицируется по ответу карты: зарегистрировано (ENVELOPE 9000 + PoR со SW и данными приложения), принято, нет ответа (PoR без R-APDU), принято, нет PoR (пакет молча отброшен) и отклонено (ENVELOPE не 9000, обычно 6200 для TAR без зарегистрированного приложения). Таблица результата появляется в окне операции; строка под кнопкой Execute показывает зарегистрировано N / M.

+

Каждый принятый картой пакет расходует счётчик, который сохраняется в набор ключей предустановки — проба отклоняется, если в SPI1 нет проверки счётчика или в предустановке нет указанной версии ключа. На живой карте: 000000, B00000 и B00001 зарегистрированы (приложения компактного формата), расширенный формат, USAT, мультиплексирование и CASD отклонены с 6200.

+

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/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 0eb243e..450059f 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -321,7 +321,6 @@ -
Explore Card (all GP data)Queries GET STATUS for ISD, Applications, ELFs, and ELF Modules, plus GET DATA FF21 (Extended Card Resources: the number of installed applications and the free non-volatile/volatile memory). The compact listing carries the AID, life cycle and privileges (the legacy format has no version); if a card rejects it (P2.b2=0, deprecated by GP) the queries are retried in the expanded E3 form, and the Fetch expanded registry details (load-file / SD associations) checkbox also requests it as an extra pass and merges the expanded-only fields: the Executable Load File version, Load File AID, Security Domain AID, module lists, the applet STK menu entries (SCP Registry Data, TS 102 226 8.2.1.6) and any other registry data objects the card returns (shown raw, never dropped) — it costs a few extra secured packets per Explore and is on by default (remembered per browser; untick to skip). Cards differ in what they accept, so the expanded query is tried in three forms — the reference tag list (with the TS 102 226 EA), the same list without EA, and no tag list at all (the GlobalPlatformPro form) — and the explorer reports which form worked and where the run fell back to the compact listing (package versions are then unavailable). Results appear in an explorer view (the success line stays minimal; the counts are in the section headings) with per-item Delete buttons and the detected RAM command format (hover for what compact vs expanded means).
Install Package (.cap file)Sends a .cap file to the card via the server: INSTALL[for load] → LOAD ×N → INSTALL[for install (+make selectable)]. The load file is split into LOAD APDUs of the LOAD block size (1–240 bytes of payload, 240 by default); each secured packet is delivered as a single SMS or, when larger, as a concatenated SMS-PP download of up to 5 segments, so a large .cap simply takes several SMS. As soon as the file is selected it is validated and its memory requirements are estimated in the form: the NVRAM requirement (the code image + persistent data, the GlobalPlatform Card Spec C6+C8 style total) and the RAM estimate. The same box reports the libraries the CAP is linked against (its Import component: library name, AID and the minimum version ≥ x.y, with standard names and families resolved and a Java Card SDK hint where known), the package/applet identity and the component breakdown (sizes and shares in load-file order) — grouped under the Package, Requires, Memory and Components headings. The Platform line names the minimum Java Card level the card must provide (derived from the javacard.framework import; the header int flag additionally requires int support, Java Card 2.2+) and the ETSI release the UICC API imports come from (uicc.toolkit version → REL-N, per the workspace sim-tools corpus; shown with its basis in brackets): the JCRE checks every import against its own packages when it verifies the load file (JC VM spec 4.5.2), so a card whose platform is older rejects the LOAD as soon as the Import component is complete (the step line carries the card's remote SW and names the component the block completed, e.g. completes Import) — a smaller LOAD block only helps when the block exceeds the card's buffer. A corrupt or wrong-format file fails the analysis, and Execute stays disabled until a successful analysis. The estimate is informational only and is never used for installation: NVRAM is the code image plus the persistent data (the GlobalPlatform C6+C8 style total, Table 11-48 — card memory management, rounding and the registry entry are not included, and the static field image is counted in both parts); the suggested C6 follows the load file, and the suggested quotas are decimal byte counts (the hex in parentheses is how the C7/C8 install parameters encode them: C6 = non-volatile code, C7 = volatile RAM, C8 = non-volatile data).
Probe TARs (registered OTA applications)Sends one secured packet per TAR — the standard allocations of TS 101 220 Annex D, Table D.1, unless a TAR list is given — each carrying a harmless C-APDU (default SELECT MF) with the preset's keyset and SPI 16/01 (counter check + PoR), and classifies the card's answer: registered (ENVELOPE 9000 + a PoR with the application's own status word/data), accepted, no PoR (the packet was silently dropped), refused (typically 6200). Useful to map which OTA applications a card carries. Every accepted packet consumes a counter, which is saved into the keyset — the probe is refused when the SPI1 has no counter check.
CAP compatibility testAnswers one question without installing anything: can this card satisfy the CAP's import list? The server runs the format check, INSTALL[for load] and the LOAD blocks only up to the block that completes the Import component — the point where the JCRE verifies the import list — then stops: no last-block flag and no INSTALL[for install], so nothing is committed and nothing has to be deleted afterwards. The Import versions to test list is pre-filled from the CAP analysis: AID=version rewrites an import's requested version, +AID=version appends a synthetic import (a card-capability query: the LOAD passes only if the card exports that package at ≥ that version), and Set all 0.0 drops every requirement. Two-step use: press Set all 0.0 — if the test passes, the import list was the blocker; then restore one line at a time to find the package and version the card lacks. The verdict states it covers the LOAD/import gate only: the link gate (whether the applet's actual API calls exist) is what a real install checks at INSTALL[for install]. The run and its result appear in a popup (like the install): the step lines, the verdict and a per-AID detail block — on a rejection it names the CAP section the failing block covers (load-file byte range and components, and whether that block completes Import), the remote status word with its meaning, the import list exactly as tested (CAP versions, probe rewrites, appended synthetic entries, unmatched lines) and what the probe state implies; on success it lists the import list that passed.
@@ -339,6 +338,12 @@
+

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 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 key version. Observed on a live card: 000000, B00000 and B00001 registered (compact-format applications), the expanded-format TARs, USAT, multiplexing and CASD refused with 6200.

+

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; 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 b5ea111..0be5f62 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -520,6 +520,7 @@
+
@@ -705,7 +706,6 @@ -
@@ -722,13 +722,6 @@
- + @@ -1801,8 +1829,10 @@ function scp80SwitchSubtab(name) { }); document.getElementById('scp80-sub-sp').classList.toggle('hidden', name !== 'sp'); document.getElementById('scp80-sub-ram').classList.toggle('hidden', name !== 'ram'); + document.getElementById('scp80-sub-tar').classList.toggle('hidden', name !== 'tar'); if (name === 'ram') ramRender(); - scp80HelpAnchor = { sp: 'secured-packet', ram: 'ram' }[name] || 'secured-packet'; + if (name === 'tar') tarProbeRender(); + scp80HelpAnchor = { sp: 'secured-packet', ram: 'ram', tar: 'tar-probe' }[name] || 'secured-packet'; setHelpAnchor(scp80HelpAnchor); } @@ -8412,7 +8442,6 @@ function ramOpChanged() { const isCap = op === 'install-cap'; const isCompat = op === 'compat'; const isApp = op === 'install-app' || op === 'make-selectable'; - const isProbe = op === 'tar-probe'; const setHidden = (id, hidden) => { const el = document.getElementById(id); if (el) el.classList.toggle('hidden', hidden); @@ -8421,7 +8450,6 @@ function ramOpChanged() { setHidden('ram-cap-rows', !(isCap || isCompat)); setHidden('ram-compat-rows', !isCompat); setHidden('ram-app-rows', !isApp); - setHidden('ram-tar-rows', !isProbe); // The Execute button's .cap gate applies to the install-cap and compat ops. if (typeof pysimApplyAvailability === 'function') pysimApplyAvailability(); } @@ -8793,10 +8821,11 @@ function scriptsCapFileChanged() { } // Collect SP params from the SP form (populated by ramApplyCard -> cardsApply) -function getRamSpParams() { +function getRamSpParams(selId) { // `preset_id` names the server-side preset the operation belongs to: the - // server persists the counter it consumes there (v3.8.0). - const sel = document.getElementById('ram-card-sel'); + // server persists the counter it consumes there (v3.8.0). `selId` picks + // the view's preset selector (RAM by default, the TAR probe its own). + const sel = document.getElementById(selId || 'ram-card-sel'); const idx = sel ? parseInt(sel.value, 10) : NaN; const preset = (!isNaN(idx) && cards[idx]) ? cards[idx] : null; return { @@ -10493,14 +10522,112 @@ async function ramInstallApp(sp, op) { // the list field is empty. Each packet the card accepts consumes a counter, // which the server persists into the keyset (one per accepted TAR). -function ramTarVerdictLabel(verdict) { +// TAR values allocated by ETSI (TS 101 220 V18.3.0 Annex D, Table D.1) with +// the document each allocation comes from. Kept in sync with the server's +// TAR_PROBE_TARS by a test; custom TARs live in localStorage (best effort). +const TAR_PROBE_TARS = [ + { tar: '000000', label: 'Issuer Security Domain (compact)', doc: 'TS 102 226' }, + { tar: 'B20100', label: 'Issuer Security Domain (expanded)', doc: 'TS 102 226' }, + { tar: 'B00000', label: 'UICC shared file system RFM (compact)', doc: 'TS 102 226' }, + { tar: 'B00001', label: 'ADF RFM (compact)', doc: 'TS 131 116' }, + { tar: 'B00010', label: 'SIM file system RFM (compact)', doc: 'TS 131 116' }, + { tar: 'B00120', label: 'UICC shared file system RFM (expanded)', doc: 'TS 102 226' }, + { tar: 'B00130', label: 'SIM file system RFM (expanded)', doc: 'TS 131 116' }, + { tar: 'B00140', label: 'ADF RFM (expanded)', doc: 'TS 131 116' }, + { tar: 'B20000', label: 'USAT interpreter', doc: 'TS 131 114' }, + { tar: 'B20200', label: 'Multiplexing application', doc: 'TS 102 225' }, + { tar: 'B20201', label: 'Controlling Authority Security Domain', doc: 'GP Amendment A' }, + { tar: 'B20202', label: 'OMA BCAST audience measurement', doc: 'OMA' }, + { tar: 'B20203', label: 'OMA DM LWM2M application', doc: 'OMA' }, + { tar: 'B00200', label: 'RFU (control)', doc: '' }, +]; +const TAR_PROBE_STORAGE = 'simple_tar_probe'; + +// The custom TARs added by the user (best effort: losing them is harmless). +function tarProbeCustomLoad() { + try { + const d = JSON.parse(localStorage.getItem(TAR_PROBE_STORAGE) || '[]'); + if (!Array.isArray(d)) return []; + return d.filter(e => e && /^[0-9A-F]{6}$/.test(String(e.tar || '').toUpperCase())) + .map(e => ({ tar: String(e.tar).toUpperCase(), label: String(e.label || ''), custom: true })); + } catch (e) { + return []; + } +} + +function tarProbeCustomSave(list) { + try { + localStorage.setItem(TAR_PROBE_STORAGE, JSON.stringify( + (list || []).map(e => ({ tar: e.tar, label: e.label || '' })))); + } catch (e) { /* best effort */ } +} + +// The standard rows plus the custom ones. +function tarProbeAllRows() { + return TAR_PROBE_TARS.concat(tarProbeCustomLoad()); +} + +// The checklist rows (pure): a checkbox per TAR, checked by default, the spec +// document in the tooltip and a remove button on custom rows. +function tarProbeRowsHtml(rows) { + return (rows || []).map(r => { + const title = r.doc ? ' title="' + esc(r.doc) + '"' : ''; + return ''; + }).join(''); +} + +// The selected TARs, in list order (pure read of the DOM). +function tarProbeChecked() { + return Array.from(document.querySelectorAll('#tar-list input[type="checkbox"]')) + .filter(cb => cb.checked) + .map(cb => String(cb.value).toUpperCase()); +} + +function tarProbeRender() { + const box = document.getElementById('tar-list'); + if (box) box.innerHTML = tarProbeRowsHtml(tarProbeAllRows()); +} + +function tarProbeSelectAll(on) { + document.querySelectorAll('#tar-list input[type="checkbox"]').forEach(cb => { cb.checked = !!on; }); +} + +function tarProbeAddCustom() { + const el = document.getElementById('tar-custom'); + const noteEl = document.getElementById('tar-custom-note'); + const tar = (el && el.value || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase(); + if (!/^[0-9A-F]{6}$/.test(tar)) { alert(t('A TAR is three hex bytes, e.g. B00001')); return; } + const custom = tarProbeCustomLoad(); + if (!custom.some(e => e.tar === tar)) { + custom.push({ tar: tar, label: (noteEl && noteEl.value || '').trim(), custom: true }); + tarProbeCustomSave(custom); + } + if (el) el.value = ''; + if (noteEl) noteEl.value = ''; + tarProbeRender(); +} + +function tarProbeRemoveCustom(tar) { + const want = String(tar || '').toUpperCase(); + tarProbeCustomSave(tarProbeCustomLoad().filter(e => e.tar !== want)); + tarProbeRender(); +} + +function tarProbeVerdictLabel(verdict) { return { registered: 'registered', no_answer: 'accepted, no answer', no_por: 'accepted, no PoR', refused: 'refused' }[verdict] || verdict; } // The probe result table (pure): TAR, application, ENVELOPE SW, verdict, the // application's status word and data. -function ramTarProbeHtml(data) { +function tarProbeHtml(data) { if (!data || !Array.isArray(data.results)) return ''; let html = '
' + esc(t('Registered applications:')) + ' ' + (data.registered || 0) + ' / ' + (data.total || 0) + '' + @@ -10518,7 +10645,7 @@ function ramTarProbeHtml(data) { '
' + '' + '' + - '' + + '' + '' + ''; }); @@ -10526,53 +10653,107 @@ function ramTarProbeHtml(data) { return html; } -async function ramTarProbe(sp) { - const apdu = (document.getElementById('ram-tar-apdu').value || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase(); +// The probe result line (shared by the success and failure paths). +function tarProbeResult(text, ok) { + const el = document.getElementById('tar-result'); + el.textContent = text; + el.classList.remove('hidden', 'text-green-600', 'text-red-600'); + el.classList.add(ok ? 'text-green-600' : 'text-red-600'); + return text; +} + +async function tarProbe(sp, tars) { + const apdu = (document.getElementById('tar-apdu').value || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase(); if (!apdu) { alert(t('Specify the probe APDU')); return; } - const tars = (document.getElementById('ram-tar-list').value || '') - .split(/[\s,;]+/).map(s => s.trim().toUpperCase()) - .filter(s => /^[0-9A-F]{6}$/.test(s)); + if (!tars || !tars.length) { alert(t('Select at least one TAR')); return; } const body = { preset_id: sp.preset_id, spi1: sp.spi1, spi2: '01', kic: sp.kic, kid: sp.kid, kicKey: sp.kicKey, kidKey: sp.kidKey, - cntr: sp.cntr, apdu: apdu, + cntr: sp.cntr, apdu: apdu, tars: tars, }; - if (tars.length) body.tars = tars; - ramClearResults(); - ramOpModalBegin(t('Probe TARs (registered OTA applications)')); + ramOpModalBegin(t('TAR probe')); ramOpModalStatus(t('Sending to card...')); let data; try { data = await pysimFetch('/api/tar-probe', body); } catch (e) { - const msg = t('Error') + ': ' + e.message; - const el = document.getElementById('ram-result'); - el.textContent = msg; - el.classList.remove('hidden', 'text-green-600'); - el.classList.add('text-red-600'); - ramOpModalFinish(msg, false, ''); + ramOpModalFinish(tarProbeResult(t('Error') + ': ' + e.message, false), false, ''); return; } - ramHideProgress(); if (data.final_cntr) ramSaveCntr(data.final_cntr); if (!data.success) { - const msg = t('Error') + ': ' + (data.error || '?'); - const el = document.getElementById('ram-result'); - el.textContent = msg; - el.classList.remove('hidden', 'text-green-600'); - el.classList.add('text-red-600'); - ramOpModalFinish(msg, false, ''); + ramOpModalFinish(tarProbeResult(t('Error') + ': ' + (data.error || '?'), false), false, ''); return; } - const html = ramTarProbeHtml(data); - const resultEl = document.getElementById('ram-result'); - resultEl.textContent = t('Registered applications:') + ' ' + data.registered + ' / ' + data.total; - resultEl.classList.remove('hidden', 'text-red-600'); - resultEl.classList.add('text-green-600'); - const stepsEl = document.getElementById('ram-steps'); - stepsEl.classList.add('hidden'); - ramOpModalFinish(resultEl.textContent, true, html, true); + const text = tarProbeResult(t('Registered applications:') + ' ' + data.registered + ' / ' + data.total, true); + ramOpModalFinish(text, true, tarProbeHtml(data), true); +} + +// The TAR-probe view's preset selector (populated from cards[], like the RAM one). +function tarRender() { + const sel = document.getElementById('tar-card-sel'); + if (!sel) return; + const prev = parseInt(sel.value, 10); + const keep = (!isNaN(prev) && cards[prev]) ? prev : -1; + sel.innerHTML = ''; + cards.forEach((c, i) => { + const opt = document.createElement('option'); + opt.value = i; + opt.textContent = c.name || ('Card ' + i); + sel.appendChild(opt); + }); + if (keep >= 0) sel.value = String(keep); + spKeysetSync(); +} + +function tarPresetIdx() { + const sel = document.getElementById('tar-card-sel'); + const idx = sel ? parseInt(sel.value, 10) : NaN; + return (!isNaN(idx) && cards[idx]) ? idx : -1; +} + +function tarApplyCard(idx) { + // copy the selected preset into the shared SP form fields (the probe reads + // kic/kid/keys/counter from there, like the RAM view does) + const i = parseInt(idx, 10); + if (!isNaN(i) && cards[i]) _ramCardIdx = i; + _spTarKey = 'tar'; + cardsApply(idx); + spPresetWarningRender(); +} + +function tarKeysetChanged() { + const sel = document.getElementById('tar-keyset-sel'); + if (!sel) return; + const kvn = parseInt(sel.value, 10); + const idx = tarPresetIdx(); + const ks = (idx >= 0 && !isNaN(kvn)) ? spKeysetFor(cards[idx], kvn) : null; + if (ks) spKeysetApply(ks); + spKeysetSync(); +} + +function tarKeysetGuard() { + const idx = tarPresetIdx(); + const kic = (document.getElementById('sp-kic-hex') || {}).value; + const kid = (document.getElementById('sp-kid-hex') || {}).value; + return spKeysetCheck(idx >= 0 ? cards[idx] : null, kic, kid); +} + +async function tarExecute() { + const tars = tarProbeChecked(); + if (!tars.length) { alert(t('Select at least one TAR')); return; } + const cardIdx = parseInt(document.getElementById('tar-card-sel').value, 10); + if (!isNaN(cardIdx) && cards[cardIdx]) _ramCardIdx = cardIdx; + spRefreshFromPreset('tar-card-sel'); + const ksErr = tarKeysetGuard(); + if (ksErr) { alert(ksErr); return; } + const sp = getRamSpParams('tar-card-sel'); + if (!sp.kicKey || !sp.kidKey) { + alert(t('Select a card preset with keys first (RAM subtab → Card preset)')); + return; + } + await tarProbe(sp, tars); } // Measured NV footprint of an install operation: the server reads GET DATA @@ -10723,7 +10904,6 @@ async function ramExecute() { } try { if (op === 'explore') await ramExplore(sp); - else if (op === 'tar-probe') await ramTarProbe(sp); else if (op === 'install-cap') await ramInstallCap(sp); else if (op === 'compat') await ramCompatTest(sp); else if (op === 'install-app' || op === 'make-selectable') await ramInstallApp(sp, op); @@ -11075,6 +11255,7 @@ async function cardsFetch() { } cardsRebuildSelect(); cardsRender(); + tarRender(); scp81PushPskMap(); spPresetWarningRender(); return true; @@ -11087,6 +11268,7 @@ function cardsSave() { // selects, the table and the SCP81 PSK map. cardsRebuildSelect(); cardsRender(); + tarRender(); scp81PushPskMap(); } @@ -11288,6 +11470,12 @@ function spKeysetSync() { 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); + } } function spKeysetApply(ks) { @@ -11369,6 +11557,7 @@ function spPresetWarningRender() { }; show('sp-preset-warning', spPresetWarning(iccid, cards, spPresetIdx())); show('ram-preset-warning', spPresetWarning(iccid, cards, ramPresetIdx())); + show('tar-preset-warning', spPresetWarning(iccid, cards, tarPresetIdx())); } // The RAM view's preset index. @@ -19594,6 +19783,15 @@ const LANG_RU = { 'Select a card preset - its keysets define the key versions': 'Выберите предустановку карты — её наборы ключей определяют версии ключей', 'SCP80 counters saved to the preset.': 'Счётчики SCP80 сохранены в предустановке.', 'counter not tracked (SPI1 has no counter check)': 'счётчик не отслеживается (в SPI1 нет проверки счётчика)', + 'TAR probe': 'TAR-сканер', + 'Sends one secured packet per selected TAR (TS 101 220 Annex D, Table D.1) carrying a harmless command, and classifies the card\'s answer - which OTA applications it has registered. Every packet the card accepts consumes a counter, which is saved to the preset.': 'Отправляет по одному защищённому пакету на каждый выбранный TAR (TS 101 220 Annex D, Table D.1) с безобидной командой и классифицирует ответ карты — какие OTA-приложения на ней зарегистрированы. Каждый принятый картой пакет расходует счётчик, который сохраняется в предустановке.', + 'TARs to probe — TS 101 220 V18.3.0 Annex D, Table D.1': 'TAR для пробы — TS 101 220 V18.3.0 Annex D, Table D.1', + 'Select all': 'Выбрать все', + 'Select none': 'Снять все', + 'Add TAR': 'Добавить TAR', + 'Remove TAR': 'Удалить TAR', + 'A TAR is three hex bytes, e.g. B00001': 'TAR — это три hex-байта, например B00001', + 'Select at least one TAR': 'Выберите хотя бы один TAR', 'Probe TARs (registered OTA applications)': 'Проба TAR (зарегистрированные OTA-приложения)', 'Probe APDU (hex)': 'APDU пробы (hex)', 'TARs to probe (hex, one per line; empty = all standard TARs of TS 101 220 Annex D)': 'TAR для пробы (hex, по одному в строке; пусто = все стандартные TAR из TS 101 220 Annex D)', diff --git a/frontend/sw.js b/frontend/sw.js index 9d419fb..27321fd 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v337'; +const CACHE = 'simple-v338'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/tar_probe.test.js b/frontend/tests/tar_probe.test.js index cf1e7a8..02faeb7 100644 --- a/frontend/tests/tar_probe.test.js +++ b/frontend/tests/tar_probe.test.js @@ -22,70 +22,144 @@ function extractFunc(src, name) { } let code = ''; -for (const fn of ['esc', 'escHtml', 'ramTarVerdictLabel', 'ramTarProbeHtml']) { +for (const fn of ['esc', 'escHtml', 'tarProbeVerdictLabel', 'tarProbeHtml', + 'tarProbeRowsHtml', 'tarProbeCustomLoad', 'tarProbeCustomSave', + 'tarProbeAllRows', 'tarProbeChecked']) { code += extractFunc(html, fn) + '\n'; } +code += "var TAR_PROBE_TARS = JSON.parse('" + + JSON.stringify(eval('(' + html.match(/const TAR_PROBE_TARS = \[[\s\S]*?\n\];/)[0] + .replace('const TAR_PROBE_TARS = ', '').replace(/;$/, '') + ')')) + "');\n"; +code += "var TAR_PROBE_STORAGE = 'simple_tar_probe';\n"; code += 'function t(s){return s;}\n'; eval(code); -const DATA = { - success: true, - registered: 2, - total: 4, - final_cntr: '0000000187', - results: [ - { tar: '000000', label: 'Issuer Security Domain (compact)', verdict: 'registered', - sw: '9000', por_status: 'por_ok', por_sw: '6D00', por_data: '' }, - { tar: 'B00000', label: 'UICC shared file system RFM (compact)', verdict: 'registered', - sw: '9000', por_status: 'por_ok', por_sw: '6B00', por_data: '3F00' }, - { tar: 'B00001', label: 'ADF RFM (compact)', verdict: 'no_por', - sw: '9000', por_status: 'no_por', por_sw: null, por_data: null }, - { tar: 'B00200', label: 'RFU (control)', verdict: 'refused', - sw: '6200', por_status: 'envelope_error', por_sw: null, por_data: null }, - ], -}; +function fakeStorage() { + const map = {}; + globalThis.localStorage = { + getItem: k => (k in map ? map[k] : null), + setItem: (k, v) => { map[k] = String(v); }, + }; + return map; +} -test('ramTarVerdictLabel names every verdict', () => { - assert.strictEqual(ramTarVerdictLabel('registered'), 'registered'); - assert.strictEqual(ramTarVerdictLabel('no_answer'), 'accepted, no answer'); - assert.strictEqual(ramTarVerdictLabel('no_por'), 'accepted, no PoR'); - assert.strictEqual(ramTarVerdictLabel('refused'), 'refused'); - assert.strictEqual(ramTarVerdictLabel('something'), 'something'); +test('the standard list covers the Annex D allocations', () => { + const tars = TAR_PROBE_TARS.map(e => e.tar); + assert.ok(tars.length >= 10, tars.length); + for (const want of ['000000', 'B20100', 'B00000', 'B00001', 'B00010', + 'B00120', 'B00130', 'B00140', 'B20000', 'B20200', 'B20201', 'B00200']) { + assert.ok(tars.includes(want), want); + } + // every row carries the spec document it comes from + TAR_PROBE_TARS.forEach(e => { + assert.match(e.tar, /^[0-9A-F]{6}$/); + assert.ok(e.label, e.tar); + }); }); -test('ramTarProbeHtml renders the summary and one row per TAR', () => { - const out = ramTarProbeHtml(DATA); +test('tarProbeRowsHtml renders checked rows and marks custom ones', () => { + const out = tarProbeRowsHtml([ + { tar: '000000', label: 'Issuer Security Domain (compact)', doc: 'TS 102 226' }, + { tar: '123456', label: 'my applet', custom: true }, + ]); + assert.match(out, /value="000000" checked/); + assert.match(out, /title="TS 102 226"/); + assert.match(out, /Issuer Security Domain \(compact\)/); + assert.match(out, /value="123456" checked/); + assert.match(out, /tarProbeRemoveCustom\('123456'\)/, 'custom rows get a remove button'); + assert.ok(!/tarProbeRemoveCustom\('000000'\)/.test(out), 'standard rows have none'); + // escaping + const evil = tarProbeRowsHtml([{ tar: 'AAx', custom: true }]); + assert.ok(!evil.includes('AAx')); +}); + +test('custom TARs round-trip through localStorage (best effort)', () => { + fakeStorage(); + assert.deepStrictEqual(tarProbeCustomLoad(), []); + tarProbeCustomSave([{ tar: '123456', label: 'mine' }, { tar: 'aabbcc' }]); + const back = tarProbeCustomLoad(); + assert.deepStrictEqual(back, [{ tar: '123456', label: 'mine', custom: true }, + { tar: 'AABBCC', label: '', custom: true }]); + // garbage is dropped, never fatal + localStorage.setItem('simple_tar_probe', '{not json'); + assert.deepStrictEqual(tarProbeCustomLoad(), []); + localStorage.setItem('simple_tar_probe', JSON.stringify([{ tar: 'zz' }, { tar: 'B0B0B0' }])); + assert.deepStrictEqual(tarProbeCustomLoad().map(e => e.tar), ['B0B0B0']); +}); + +test('tarProbeAllRows appends the custom TARs to the standard ones', () => { + fakeStorage(); + tarProbeCustomSave([{ tar: '123456', label: 'mine' }]); + const rows = tarProbeAllRows(); + assert.strictEqual(rows.length, TAR_PROBE_TARS.length + 1); + assert.strictEqual(rows[rows.length - 1].tar, '123456'); + assert.strictEqual(rows[rows.length - 1].custom, true); +}); + +test('tarProbeChecked reads the checked boxes in list order', () => { + const boxes = [ + { value: '000000', checked: true }, + { value: 'B00000', checked: false }, + { value: 'b00120', checked: true }, + ]; + globalThis.document = { querySelectorAll: () => boxes }; + assert.deepStrictEqual(tarProbeChecked(), ['000000', 'B00120']); + boxes.forEach(b => { b.checked = false; }); + assert.deepStrictEqual(tarProbeChecked(), []); +}); + +test('tarProbeVerdictLabel names every verdict', () => { + assert.strictEqual(tarProbeVerdictLabel('registered'), 'registered'); + assert.strictEqual(tarProbeVerdictLabel('no_answer'), 'accepted, no answer'); + assert.strictEqual(tarProbeVerdictLabel('no_por'), 'accepted, no PoR'); + assert.strictEqual(tarProbeVerdictLabel('refused'), 'refused'); + assert.strictEqual(tarProbeVerdictLabel('something'), 'something'); +}); + +test('tarProbeHtml renders the summary and one row per TAR', () => { + const out = tarProbeHtml({ + success: true, registered: 2, total: 4, final_cntr: '0000000187', + results: [ + { tar: '000000', label: 'ISD', verdict: 'registered', sw: '9000', por_sw: '6D00', por_data: '' }, + { tar: 'B00000', label: 'RFM', verdict: 'registered', sw: '9000', por_sw: '6B00', por_data: '3F00' }, + { tar: 'B00001', label: 'ADF', verdict: 'no_por', sw: '9000' }, + { tar: 'B00200', label: 'RFU', verdict: 'refused', sw: '6200' }, + ], + }); assert.match(out, /Registered applications: 2 \/ 4<\/b>/); assert.match(out, /counter 0000000187/); - for (const r of DATA.results) { - assert.ok(out.includes(r.tar), r.tar); - } - assert.ok(out.includes('6D00') && out.includes('6B00'), 'the application SWs are shown'); - assert.ok(out.includes('6200'), 'the refused ENVELOPE SW is shown'); - // the verdict colours: registered = green, no_por = amber, refused = red assert.match(out, /text-emerald-700 dark:text-emerald-400">registered/); assert.match(out, /text-amber-600 dark:text-amber-400">accepted, no PoR/); assert.match(out, /text-red-600 dark:text-red-400">refused/); - // an empty payload renders nothing - assert.strictEqual(ramTarProbeHtml(null), ''); - assert.strictEqual(ramTarProbeHtml({}), ''); + assert.ok(out.includes('6D00') && out.includes('6200')); + assert.strictEqual(tarProbeHtml(null), ''); }); -test('the probe escapes what the card returned', () => { - const out = ramTarProbeHtml({ results: [ - { tar: 'x', label: 'y', verdict: 'registered', sw: '<', por_sw: '>', por_data: '&' }, - ], registered: 1, total: 1 }); - assert.ok(!out.includes('x'), 'the TAR must be escaped'); - assert.ok(!out.includes('y'), 'the label must be escaped'); -}); - -test('the RAM form wires the TAR probe', () => { - assert.match(html, /
' + esc(r.tar) + '' + esc(r.label || '') + '' + esc(r.sw || '-') + '' + esc(t(ramTarVerdictLabel(r.verdict))) + '' + esc(t(tarProbeVerdictLabel(r.verdict))) + '' + esc(r.por_sw || '-') + '' + esc(r.por_data || '-') + '